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
diff --git a/apps/canvas/front/src/App.tsx b/apps/canvas/front/src/App.tsx
index 62652f6..2970cc0 100644
--- a/apps/canvas/front/src/App.tsx
+++ b/apps/canvas/front/src/App.tsx
@@ -3,8 +3,7 @@
import { CanvasBuilder } from './Canvas';
import { Tabs, TabsTrigger, TabsContent, TabsList } from './components/ui/tabs';
import { Config } from './Config';
-import { useStateStore } from './lib/state';
-import { useEffect } from 'react';
+import { Integrations } from './Integrations';
import { Toaster } from './components/ui/toaster';
import { Header } from './Header';
@@ -19,22 +18,22 @@
}
function AppImpl() {
- const store = useStateStore();
- useEffect(() => {
- setTimeout(async () => await store.refreshEnv(), 1);
- }, [store])
return (
- <Tabs defaultValue="canvas">
- <TabsList>
- <TabsTrigger value="canvas">Canvas</TabsTrigger>
- <TabsTrigger value="config">Config</TabsTrigger>
- </TabsList>
- <TabsContent value="canvas">
- <CanvasBuilder />
- </TabsContent>
- <TabsContent value="config">
- <Config />
- </TabsContent>
- </Tabs>
+ <Tabs defaultValue="canvas">
+ <TabsList>
+ <TabsTrigger value="canvas">Canvas</TabsTrigger>
+ <TabsTrigger value="config">Config</TabsTrigger>
+ <TabsTrigger value="integrations">Integrations</TabsTrigger>
+ </TabsList>
+ <TabsContent value="canvas">
+ <CanvasBuilder />
+ </TabsContent>
+ <TabsContent value="config">
+ <Config />
+ </TabsContent>
+ <TabsContent value="integrations">
+ <Integrations />
+ </TabsContent>
+ </Tabs>
);
}
diff --git a/apps/canvas/front/src/Integrations.tsx b/apps/canvas/front/src/Integrations.tsx
new file mode 100644
index 0000000..a566c22
--- /dev/null
+++ b/apps/canvas/front/src/Integrations.tsx
@@ -0,0 +1,132 @@
+import { useProjectId, useGithubService, useStateStore } from '@/lib/state';
+import { Form, FormControl, FormField, FormItem, FormMessage } from './components/ui/form';
+import { Input } from './components/ui/input';
+import { useForm } from 'react-hook-form';
+import { z } from 'zod';
+import { zodResolver } from '@hookform/resolvers/zod';
+import { Button } from './components/ui/button';
+import { useToast } from '@/hooks/use-toast';
+import { Checkbox } from './components/ui/checkbox';
+import { useState, useCallback } from 'react';
+
+const schema = z.object({
+ githubToken: z.string().min(1, "GitHub token is required"),
+});
+
+export function Integrations() {
+ const { toast } = useToast();
+ const store = useStateStore();
+ const projectId = useProjectId();
+ const [isEditing, setIsEditing] = useState(false);
+ const githubService = useGithubService();
+ const [isSaving, setIsSaving] = useState(false);
+
+ const form = useForm<z.infer<typeof schema>>({
+ resolver: zodResolver(schema),
+ mode: "onChange",
+ });
+
+ const onSubmit = useCallback(async (data: z.infer<typeof schema>) => {
+ if (!projectId) return;
+
+ setIsSaving(true);
+
+ try {
+ const response = await fetch(`/api/project/${projectId}/github-token`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({ githubToken: data.githubToken }),
+ });
+
+ if (!response.ok) {
+ throw new Error("Failed to save GitHub token");
+ }
+
+ await store.refreshEnv();
+ setIsEditing(false);
+ form.reset();
+ toast({
+ title: "GitHub token saved successfully",
+ });
+ } catch (error) {
+ toast({
+ variant: "destructive",
+ title: "Failed to save GitHub token",
+ description: error instanceof Error ? error.message : "Unknown error",
+ });
+ } finally {
+ setIsSaving(false);
+ }
+ }, [projectId, store, form, toast, setIsEditing, setIsSaving]);
+
+ const handleCancel = () => {
+ setIsEditing(false);
+ form.reset();
+ };
+
+ return (
+ <div className="space-y-6">
+ <div>
+ <h3 className="text-md font-medium mb-2">GitHub</h3>
+ <div className="space-y-4">
+ <div className="flex items-center space-x-2">
+ <Checkbox checked={!!githubService} disabled />
+ <label className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70">
+ GitHub Access Token
+ </label>
+ </div>
+
+ {!!githubService && !isEditing && (
+ <Button
+ variant="outline"
+ onClick={() => setIsEditing(true)}
+ >
+ Update Access Token
+ </Button>
+ )}
+
+ {(!!!githubService || isEditing) && (
+ <Form {...form}>
+ <form className="space-y-4" onSubmit={form.handleSubmit(onSubmit)}>
+ <FormField
+ control={form.control}
+ name="githubToken"
+ render={({ field }) => (
+ <FormItem>
+ <FormControl>
+ <Input
+ type="password"
+ placeholder="GitHub Personal Access Token"
+ className="border border-black"
+ {...field}
+ />
+ </FormControl>
+ <FormMessage />
+ </FormItem>
+ )}
+ />
+ <div className="flex space-x-2">
+ <Button type="submit" disabled={isSaving}>
+ {isSaving ? "Saving..." : "Save"}
+ </Button>
+ {!!githubService && (
+ <Button
+ type="button"
+ variant="outline"
+ onClick={handleCancel}
+ disabled={isSaving}
+ >
+ Cancel
+ </Button>
+ )}
+ </div>
+ </form>
+ </Form>
+ )}
+ </div>
+ </div>
+ </div>
+ );
+}
\ No newline at end of file
diff --git a/apps/canvas/front/src/components/canvas.tsx b/apps/canvas/front/src/components/canvas.tsx
index 2f261f0..8898b58 100644
--- a/apps/canvas/front/src/components/canvas.tsx
+++ b/apps/canvas/front/src/components/canvas.tsx
@@ -47,9 +47,9 @@
return c.targetHandle === "repository";
}
if (sn.type === "app") {
- if (c.sourceHandle === "ports" && (!sn.data.ports || sn.data.ports.length === 0)) {
+ if (c.sourceHandle === "ports" && (!sn.data.ports || sn.data.ports.length === 0)) {
return false;
- }
+ }
}
if (tn.type === "gateway-https") {
if (c.targetHandle === "https" && tn.data.https !== undefined) {
@@ -81,7 +81,7 @@
x: 0,
y: 0,
},
- isConnectable: true,
+ isConnectable: true,
data: {
domain: n.domain,
label: n.domain,
@@ -109,7 +109,7 @@
isValidConnection={isValidConnection}
fitView
proOptions={{ hideAttribution: true }}
- >
+ >
<Controls />
<Background variant={BackgroundVariant.Dots} gap={12} size={1} />
<Panel position="bottom-right">
diff --git a/apps/canvas/front/src/components/node-app.tsx b/apps/canvas/front/src/components/node-app.tsx
index dcccd82..5b4b3fa 100644
--- a/apps/canvas/front/src/components/node-app.tsx
+++ b/apps/canvas/front/src/components/node-app.tsx
@@ -394,8 +394,8 @@
</SelectTrigger>
</FormControl>
<SelectContent>
- {nodes.filter((n) => n.type === "github" && n.data.address).map((n) => (
- <SelectItem key={n.id} value={n.id}>{`${n.data.address!}`}</SelectItem>
+ {nodes.filter((n) => n.type === "github" && n.data.repository?.id !== undefined).map((n) => (
+ <SelectItem key={n.id} value={n.id}>{`${n.data.repository?.sshURL}`}</SelectItem>
))}
</SelectContent>
</Select>
diff --git a/apps/canvas/front/src/components/node-github.tsx b/apps/canvas/front/src/components/node-github.tsx
index aa48cd1..3ae779e 100644
--- a/apps/canvas/front/src/components/node-github.tsx
+++ b/apps/canvas/front/src/components/node-github.tsx
@@ -1,12 +1,16 @@
import { NodeRect } from './node-rect';
-import { GithubNode, nodeIsConnectable, nodeLabel, useStateStore } from '@/lib/state';
-import { useEffect, useMemo } from 'react';
+import { GithubNode, nodeIsConnectable, nodeLabel, useStateStore, useGithubService } from '@/lib/state';
+import { useEffect, useMemo, useState } from 'react';
import { z } from "zod";
import { DeepPartial, EventType, useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Form, FormControl, FormField, FormItem, FormMessage } from './ui/form';
-import { Input } from './ui/input';
import { Handle, Position } from "@xyflow/react";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from './ui/select';
+import { GitHubRepository } from '../lib/github';
+import { useProjectId } from '@/lib/state';
+import { Alert, AlertDescription } from './ui/alert';
+import { AlertCircle } from 'lucide-react';
export function NodeGithub(node: GithubNode) {
const { id, selected } = node;
@@ -22,54 +26,136 @@
isConnectableStart={isConnectable}
isConnectableEnd={isConnectable}
isConnectable={isConnectable}
- />
+ />
</div>
</NodeRect>
);
}
const schema = z.object({
- address: z.string().min(1),
+ repositoryId: z.number().optional(),
});
export function NodeGithubDetails(node: GithubNode) {
const { id, data } = node;
const store = useStateStore();
+ const projectId = useProjectId();
+ const [repos, setRepos] = useState<GitHubRepository[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+ const githubService = useGithubService();
+
+ useEffect(() => {
+ if (data.repository) {
+ const { id, sshURL } = data.repository;
+ setRepos(prevRepos => {
+ if (!prevRepos.some(r => r.id === id)) {
+ return [...prevRepos, {
+ id,
+ name: sshURL.split('/').pop() || '',
+ full_name: sshURL.split('/').slice(-2).join('/'),
+ html_url: '',
+ ssh_url: sshURL,
+ description: null,
+ private: false,
+ default_branch: 'main'
+ }];
+ }
+ return prevRepos;
+ });
+ }
+ }, [data.repository]);
+
const form = useForm<z.infer<typeof schema>>({
resolver: zodResolver(schema),
mode: "onChange",
defaultValues: {
- address: data.address,
+ repositoryId: data.repository?.id,
}
});
+
useEffect(() => {
const sub = form.watch((value: DeepPartial<z.infer<typeof schema>>, { name, type }: { name?: keyof z.infer<typeof schema> | undefined, type?: EventType | undefined }) => {
if (type !== "change") {
return;
}
switch (name) {
- case "address":
- store.updateNodeData<"github">(id, {
- address: value.address,
- });
+ case "repositoryId":
+ if (value.repositoryId) {
+ const repo = repos.find(r => r.id === value.repositoryId);
+ if (repo) {
+ store.updateNodeData<"github">(id, {
+ repository: {
+ id: repo.id,
+ sshURL: repo.ssh_url,
+ },
+ });
+ }
+ }
break;
}
});
return () => sub.unsubscribe();
- }, [form, store]);
+ }, [form, store, id, repos]);
+
+ useEffect(() => {
+ const fetchRepositories = async () => {
+ if (!!!githubService) return;
+
+ setLoading(true);
+ setError(null);
+ try {
+ const repositories = await githubService.getRepositories();
+ setRepos(repositories);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to fetch repositories");
+ } finally {
+ setLoading(false);
+ }
+ };
+
+ fetchRepositories();
+ }, [githubService]);
+
return (
<>
<Form {...form}>
<form className="space-y-2">
- <FormField
+ <FormField
control={form.control}
- name="address"
+ name="repositoryId"
render={({ field }) => (
<FormItem>
- <FormControl>
- <Input placeholder="address" className="border border-black" {...field} />
- </FormControl>
+ <Select
+ onValueChange={(value) => field.onChange(Number(value))}
+ value={field.value?.toString()}
+ disabled={loading || !projectId || !!!githubService}
+ >
+ <FormControl>
+ <SelectTrigger>
+ <SelectValue placeholder={!!githubService ? "Select a repository" : "GitHub not configured"} />
+ </SelectTrigger>
+ </FormControl>
+ <SelectContent>
+ {repos.map((repo) => (
+ <SelectItem key={repo.id} value={repo.id.toString()}>
+ {repo.full_name}
+ {repo.description && ` - ${repo.description}`}
+ </SelectItem>
+ ))}
+ </SelectContent>
+ </Select>
<FormMessage />
+ {error && <p className="text-sm text-red-500">{error}</p>}
+ {loading && <p className="text-sm text-gray-500">Loading repositories...</p>}
+ {!!!githubService && (
+ <Alert variant="destructive" className="mt-2">
+ <AlertCircle className="h-4 w-4" />
+ <AlertDescription>
+ GitHub access token is not configured. Please configure it in the Integrations tab.
+ </AlertDescription>
+ </Alert>
+ )}
</FormItem>
)}
/>
diff --git a/apps/canvas/front/src/components/ui/alert.tsx b/apps/canvas/front/src/components/ui/alert.tsx
new file mode 100644
index 0000000..350d1cd
--- /dev/null
+++ b/apps/canvas/front/src/components/ui/alert.tsx
@@ -0,0 +1,58 @@
+import * as React from "react"
+import { cva, type VariantProps } from "class-variance-authority"
+import { cn } from "@/lib/utils"
+
+const alertVariants = cva(
+ "relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
+ {
+ variants: {
+ variant: {
+ default: "bg-background text-foreground",
+ destructive:
+ "border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
+ },
+ },
+ defaultVariants: {
+ variant: "default",
+ },
+ }
+)
+
+const Alert = React.forwardRef<
+ HTMLDivElement,
+ React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
+>(({ className, variant, ...props }, ref) => (
+ <div
+ ref={ref}
+ role="alert"
+ className={cn(alertVariants({ variant }), className)}
+ {...props}
+ />
+))
+Alert.displayName = "Alert"
+
+const AlertTitle = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLHeadingElement>
+>(({ className, ...props }, ref) => (
+ <h5
+ ref={ref}
+ className={cn("mb-1 font-medium leading-none tracking-tight", className)}
+ {...props}
+ />
+))
+AlertTitle.displayName = "AlertTitle"
+
+const AlertDescription = React.forwardRef<
+ HTMLParagraphElement,
+ React.HTMLAttributes<HTMLParagraphElement>
+>(({ className, ...props }, ref) => (
+ <div
+ ref={ref}
+ className={cn("text-sm [&_p]:leading-relaxed", className)}
+ {...props}
+ />
+))
+AlertDescription.displayName = "AlertDescription"
+
+export { Alert, AlertTitle, AlertDescription }
\ No newline at end of file
diff --git a/apps/canvas/front/src/lib/config.ts b/apps/canvas/front/src/lib/config.ts
index d6646c9..3ea29b6 100644
--- a/apps/canvas/front/src/lib/config.ts
+++ b/apps/canvas/front/src/lib/config.ts
@@ -102,7 +102,7 @@
type: n.data.type,
name: n.data.label,
source: {
- repository: nodes.filter((i) => i.type === "github").find((i) => i.id === n.data.repository.id)!.data.address,
+ repository: nodes.filter((i) => i.type === "github").find((i) => i.id === n.data.repository.id)!.data.repository!.sshURL,
branch: n.data.repository.branch,
rootDir: n.data.repository.rootDir,
},
@@ -245,7 +245,7 @@
function GitRepositoryValidator(nodes: AppNode[]): Message[] {
const git = nodes.filter((n) => n.type === "github");
- const noAddress: Message[] = git.filter((n) => n.data == null || n.data.address == null || n.data.address === "").map((n) => ({
+ const noAddress: Message[] = git.filter((n) => n.data == null || n.data.repository == null).map((n) => ({
id: `${n.id}-no-address`,
type: "FATAL",
nodeId: n.id,
diff --git a/apps/canvas/front/src/lib/github.ts b/apps/canvas/front/src/lib/github.ts
new file mode 100644
index 0000000..8537671
--- /dev/null
+++ b/apps/canvas/front/src/lib/github.ts
@@ -0,0 +1,36 @@
+export interface GitHubRepository {
+ id: number;
+ name: string;
+ full_name: string;
+ html_url: string;
+ ssh_url: string;
+ description: string | null;
+ private: boolean;
+ default_branch: string;
+}
+
+export interface GitHubService {
+ /**
+ * Fetches a list of repositories for the authenticated user
+ * @returns Promise resolving to an array of GitHub repositories
+ */
+ getRepositories(): Promise<GitHubRepository[]>;
+}
+
+export class GitHubServiceImpl implements GitHubService {
+ private projectId: string;
+
+ constructor(projectId: string) {
+ this.projectId = projectId;
+ }
+
+ async getRepositories(): Promise<GitHubRepository[]> {
+ const response = await fetch(`/api/project/${this.projectId}/repos/github`);
+
+ if (!response.ok) {
+ throw new Error(`Failed to fetch repositories: ${response.statusText}`);
+ }
+
+ return response.json();
+ }
+}
\ No newline at end of file
diff --git a/apps/canvas/front/src/lib/state.ts b/apps/canvas/front/src/lib/state.ts
index 02fd983..a3cd755 100644
--- a/apps/canvas/front/src/lib/state.ts
+++ b/apps/canvas/front/src/lib/state.ts
@@ -1,17 +1,18 @@
import { v4 as uuidv4 } from "uuid";
import { create } from 'zustand';
import { addEdge, applyNodeChanges, applyEdgeChanges, Connection, EdgeChange, useNodes } from '@xyflow/react';
-import {
- type Edge,
- type Node,
- type OnNodesChange,
- type OnEdgesChange,
- type OnConnect,
+import type {
+ Edge,
+ Node,
+ OnNodesChange,
+ OnEdgesChange,
+ OnConnect,
} from '@xyflow/react';
-import { DeepPartial } from "react-hook-form";
+import type { DeepPartial } from "react-hook-form";
import { Category, defaultCategories } from "./categories";
import { CreateValidators, Validator } from "./config";
import { z } from "zod";
+import { GitHubService, GitHubServiceImpl } from './github';
export type InitData = {
label: string;
@@ -135,7 +136,10 @@
};
export type GithubData = NodeData & {
- address: string;
+ repository?: {
+ id: number;
+ sshURL: string;
+ };
};
export type GithubNode = Node<GithubData> & {
@@ -152,7 +156,7 @@
switch (n.type) {
case "network": return n.data.domain;
case "app": return n.data.label || "Service";
- case "github": return n.data.address || "Github";
+ case "github": return n.data.repository?.sshURL || "Github";
case "gateway-https": {
if (n.data && n.data.network && n.data.subdomain) {
return `https://${n.data.subdomain}.${n.data.network}`;
@@ -189,7 +193,7 @@
}
return false;
case "github":
- if (n.data !== undefined && n.data.address) {
+ if (n.data.repository?.id !== undefined) {
return true;
}
return false;
@@ -264,6 +268,7 @@
case "postgresql": return [`DODO_POSTGRESQL_${n.data.label.toUpperCase()}_URL`];
case "volume": return [`DODO_VOLUME_${n.data.label.toUpperCase()}_PATH`];
case undefined: throw new Error("MUST NOT REACH");
+ default: throw new Error("MUST NOT REACH");
}
}
@@ -282,20 +287,38 @@
};
export const envSchema = z.object({
- deployKey: z.string(),
+ deployKey: z.optional(z.string().min(1)),
networks: z.array(z.object({
- name: z.string(),
- domain: z.string(),
- })),
+ name: z.string().min(1),
+ domain: z.string().min(1),
+ })).default([]),
+ integrations: z.object({
+ github: z.boolean(),
+ }),
});
export type Env = z.infer<typeof envSchema>;
+const defaultEnv: Env = {
+ deployKey: undefined,
+ networks: [],
+ integrations: {
+ github: false,
+ }
+};
+
export type Project = {
id: string;
name: string;
}
+export type IntegrationsConfig = {
+ github: boolean;
+};
+
+type NodeUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>>;
+type NodeDataUpdate<T extends NodeType> = DeepPartial<Extract<AppNode, { type: T }>["data"]>;
+
export type AppState = {
projectId: string | undefined;
projects: Project[];
@@ -303,7 +326,8 @@
edges: Edge[];
categories: Category[];
messages: Message[];
- env?: Env;
+ env: Env;
+ githubService: GitHubService | null;
setHighlightCategory: (name: string, active: boolean) => void;
onNodesChange: OnNodesChange<AppNode>;
onEdgesChange: OnEdgesChange;
@@ -312,15 +336,16 @@
setEdges: (edges: Edge[]) => void;
setProject: (projectId: string | undefined) => void;
setProjects: (projects: Project[]) => void;
- updateNode: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>) => void;
- updateNodeData: <T extends NodeType>(id: string, data: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>) => void;
+ updateNode: <T extends NodeType>(id: string, node: NodeUpdate<T>) => void;
+ updateNodeData: <T extends NodeType>(id: string, data: NodeDataUpdate<T>) => void;
replaceEdge: (c: Connection, id?: string) => void;
- refreshEnv: () => Promise<Env | undefined>;
+ refreshEnv: () => Promise<void>;
};
const projectIdSelector = (state: AppState) => state.projectId;
const categoriesSelector = (state: AppState) => state.categories;
const messagesSelector = (state: AppState) => state.messages;
+const githubServiceSelector = (state: AppState) => state.githubService;
const envSelector = (state: AppState) => state.env;
export function useProjectId(): string | undefined {
@@ -347,88 +372,55 @@
return (useNodes<AppNode>().find((n) => n.id === id)!.data.ports || []).find((p) => p.id === portId)!.name;
}
-let envRefresh: Promise<Env | undefined> | null = null;
-
-const fixedEnv: Env = {
- "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
- "networks": [{
- "name": "Public",
- "domain": "v1.dodo.cloud",
- }, {
- "name": "Private",
- "domain": "p.v1.dodo.cloud",
- }],
-};
-
export function useEnv(): Env {
- return fixedEnv;
- const store = useStateStore();
- const env = envSelector(store);
- console.log(env);
- if (env != null) {
- return env;
- }
- if (envRefresh == null) {
- envRefresh = store.refreshEnv();
- envRefresh.finally(() => envRefresh = null);
- }
- return {
- deployKey: "",
- networks: [],
- };
+ return useStateStore(envSelector);
+}
+
+export function useGithubService(): GitHubService | null {
+ return useStateStore(githubServiceSelector);
}
const v: Validator = CreateValidators();
export const useStateStore = create<AppState>((set, get): AppState => {
- set({
- env: {
- "deployKey": "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPK58vMu0MwIzdZT+mqpIBkhl48p9+/YwDCZv7MgTesF",
- "networks": [{
- "name": "Public",
- "domain": "v1.dodo.cloud",
- }, {
- "name": "Private",
- "domain": "p.v1.dodo.cloud",
- }],
- }
- });
- console.log(get().env);
const setN = (nodes: AppNode[]) => {
- set({
- nodes: nodes,
- messages: v(nodes),
- })
+ set((state) => ({
+ ...state,
+ nodes,
+ }));
};
- function updateNodeData<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))["data"]>): void {
- setN(get().nodes.map((n) => {
- if (n.id !== id) {
- return n;
- }
- const nd = {
- ...n,
- data: {
- ...n.data,
- ...d,
- },
- };
- return nd;
- })
- );
- };
- function updateNode<T extends NodeType>(id: string, d: DeepPartial<(AppNode & (Pick<AppNode, "type"> | { type: T }))>): void {
+
+ function updateNodeData<T extends NodeType>(id: string, data: NodeDataUpdate<T>): void {
setN(
get().nodes.map((n) => {
- if (n.id !== id) {
- return n;
+ if (n.id === id) {
+ return {
+ ...n,
+ data: {
+ ...n.data,
+ ...data,
+ },
+ } as Extract<AppNode, { type: T }>;
}
- return {
- ...n,
- ...d,
- };
+ return n;
})
);
- };
+ }
+
+ function updateNode<T extends NodeType>(id: string, node: NodeUpdate<T>): void {
+ setN(
+ get().nodes.map((n) => {
+ if (n.id === id) {
+ return {
+ ...n,
+ ...node,
+ } as Extract<AppNode, { type: T }>;
+ }
+ return n;
+ })
+ );
+ }
+
function onConnect(c: Connection) {
const { nodes, edges } = get();
set({
@@ -447,64 +439,66 @@
});
}
}
- if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
- const sourceEnvVars = nodeEnvVarNames(sn);
- if (sourceEnvVars.length === 0) {
- throw new Error("MUST NOT REACH!");
+ if (tn.type === "app") {
+ if (c.sourceHandle === "env_var" && c.targetHandle === "env_var") {
+ const sourceEnvVars = nodeEnvVarNames(sn);
+ if (sourceEnvVars.length === 0) {
+ throw new Error("MUST NOT REACH!");
+ }
+ const id = uuidv4();
+ if (sourceEnvVars.length === 1) {
+ updateNode<"app">(c.target, {
+ ...tn,
+ data: {
+ ...tn.data,
+ envVars: [
+ ...(tn.data.envVars || []),
+ {
+ id: id,
+ source: c.source,
+ name: sourceEnvVars[0],
+ isEditting: false,
+ },
+ ],
+ },
+ });
+ } else {
+ updateNode<"app">(c.target, {
+ ...tn,
+ data: {
+ ...tn.data,
+ envVars: [
+ ...(tn.data.envVars || []),
+ {
+ id: id,
+ source: c.source,
+ },
+ ],
+ },
+ });
+ }
}
- const id = uuidv4();
- if (sourceEnvVars.length === 1) {
- updateNode(c.target, {
- ...tn,
- data: {
- ...tn.data,
- envVars: [
- ...(tn.data.envVars || []),
- {
- id: id,
- source: c.source,
- name: sourceEnvVars[0],
- isEditting: false,
- },
- ],
- },
- });
- } else {
- updateNode(c.target, {
- ...tn,
- data: {
- ...tn.data,
- envVars: [
- ...(tn.data.envVars || []),
- {
- id: id,
- source: c.source,
- },
- ],
- },
- });
- }
- }
- if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
- const sourcePorts = sn.data.ports || [];
- const id = uuidv4();
- if (sourcePorts.length === 1) {
- updateNode(c.target, {
- ...tn,
- data: {
- ...tn.data,
- envVars: [
- ...(tn.data.envVars || []),
- {
- id: id,
- source: c.source,
- name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
- portId: sourcePorts[0].id,
- isEditting: false,
- },
- ],
- },
- });
+ if (c.sourceHandle === "ports" && c.targetHandle === "env_var") {
+ const sourcePorts = sn.data.ports || [];
+ const id = uuidv4();
+ if (sourcePorts.length === 1) {
+ updateNode<"app">(c.target, {
+ ...tn,
+ data: {
+ ...tn.data,
+ envVars: [
+ ...(tn.data.envVars || []),
+ {
+ id: id,
+ source: c.source,
+ name: nodeEnvVarNamePort(sn, sourcePorts[0].name),
+ portId: sourcePorts[0].id,
+ isEditting: false,
+ },
+ ],
+ },
+ });
+ }
}
}
if (c.sourceHandle === "volume") {
@@ -580,6 +574,8 @@
edges: [],
categories: defaultCategories,
messages: v([]),
+ env: defaultEnv,
+ githubService: null,
setHighlightCategory: (name, active) => {
set({
categories: get().categories.map(
@@ -639,15 +635,41 @@
updateNodeData,
onConnect,
refreshEnv: async () => {
- return get().env;
- const resp = await fetch("/env");
- if (!resp.ok) {
- throw new Error("failed to fetch env config");
+ const projectId = get().projectId;
+ let env: Env = defaultEnv;
+
+ try {
+ if (projectId) {
+ const response = await fetch(`/api/project/${projectId}/env`);
+ if (response.ok) {
+ const data = await response.json();
+ const result = envSchema.safeParse(data);
+ if (result.success) {
+ env = result.data;
+ } else {
+ console.error("Invalid env data:", result.error);
+ }
+ }
+ }
+ } catch (error) {
+ console.error("Failed to fetch integrations:", error);
+ } finally {
+ set({ env: env });
+ if (env.integrations.github) {
+ set({ githubService: new GitHubServiceImpl(projectId!) });
+ } else {
+ set({ githubService: null });
+ }
}
- set({ env: envSchema.parse(await resp.json()) });
- return get().env;
},
- setProject: (projectId) => set({ projectId }),
+ setProject: (projectId) => {
+ set({
+ projectId,
+ });
+ if (projectId) {
+ get().refreshEnv();
+ }
+ },
setProjects: (projects) => set({ projects }),
};
});