diff --git a/src/main.ts b/src/main.ts index 0efeedb..927eebe 100644 --- a/src/main.ts +++ b/src/main.ts @@ -21,6 +21,7 @@ import { agentsRouter } from "./routes/agents.ts"; import { logsRouter } from "./routes/logs.ts"; import { userSettingsRouter } from "./routes/usersettings.ts"; import gitlabRouter from "./routes/gitlab.ts"; +import { giteaRouter } from "./routes/gitea.ts"; import { dockerRouter } from "./routes/docker.ts"; import { exportRouter } from "./routes/export.ts"; import { appUpdateRouter } from "./routes/appUpdate.ts"; @@ -75,6 +76,8 @@ app.use(logsRouter.allowedMethods()); app.use(userSettingsRouter.routes()); app.use(userSettingsRouter.allowedMethods()); app.use(gitlabRouter.routes()); +app.use(giteaRouter.routes()); +app.use(giteaRouter.allowedMethods()); app.use(gitlabRouter.allowedMethods()); app.use(dockerRouter.routes()); app.use(dockerRouter.allowedMethods()); diff --git a/src/routes/gitea.ts b/src/routes/gitea.ts new file mode 100644 index 0000000..a263cae --- /dev/null +++ b/src/routes/gitea.ts @@ -0,0 +1,320 @@ +import { Router } from "@oak/oak"; +import { ObjectId } from "mongodb"; +import { authMiddleware } from "../middleware/auth.ts"; +import { getDB } from "../db/mongo.ts"; + +const router = new Router(); + +// Gitea API configuration +const GITEA_URL = Deno.env.get("GITEA_URL") || "https://git.kronos-soulution.de"; +const GITEA_TOKEN = Deno.env.get("GITEA_TOKEN") || ""; + +// Get user's Gitea token or fall back to global +async function getUserGiteaToken(userId: string): Promise { + const db = await getDB(); + const user = await db.collection("users").findOne({ _id: new ObjectId(userId) }); + return user?.giteaToken || GITEA_TOKEN; +} + +interface GiteaProject { + id: number; + name: string; + full_name: string; + description: string | null; + default_branch: string; + html_url: string; + clone_url: string; + ssh_url: string; + updated_at: string; + owner: { + login: string; + avatar_url: string; + }; +} + +interface GiteaBranch { + name: string; + commit: { + id: string; + message: string; + author: { + name: string; + email: string; + date: string; + }; + }; + protected: boolean; +} + +interface GiteaCommit { + sha: string; + commit: { + message: string; + author: { + name: string; + email: string; + date: string; + }; + }; + html_url: string; + author?: { + login: string; + avatar_url: string; + }; +} + +interface GiteaTreeItem { + name: string; + path: string; + type: "file" | "dir"; + size?: number; + sha: string; +} + +// Helper function to call Gitea API +async function giteaFetch(endpoint: string, options: RequestInit = {}, token?: string): Promise { + const url = `${GITEA_URL}/api/v1${endpoint}`; + const headers: Record = { + "Content-Type": "application/json", + ...options.headers as Record, + }; + + if (token || GITEA_TOKEN) { + headers["Authorization"] = `token ${token || GITEA_TOKEN}`; + } + + const response = await fetch(url, { ...options, headers }); + + if (!response.ok) { + const error = await response.text(); + throw new Error(`Gitea API error (${response.status}): ${error}`); + } + + return response.json(); +} + +// ============ ROUTES ============ + +// Get all accessible Gitea projects +router.get("/api/gitea/projects", authMiddleware, async (ctx) => { + try { + const token = await getUserGiteaToken(ctx.state.user.id); + const projects = await giteaFetch("/user/repos?limit=100", {}, token); + + // Transform to common format + const result = projects.map(p => ({ + id: p.id, + name: p.name, + fullName: p.full_name, + path: p.full_name, + description: p.description, + webUrl: p.html_url, + cloneUrl: p.clone_url, + defaultBranch: p.default_branch, + updatedAt: p.updated_at, + owner: p.owner.login + })); + + ctx.response.body = result; + } catch (err) { + ctx.response.status = 500; + ctx.response.body = { error: err instanceof Error ? err.message : "Failed to fetch Gitea projects" }; + } +}); + +// Get single project details +router.get("/api/gitea/projects/:id", authMiddleware, async (ctx) => { + try { + const token = await getUserGiteaToken(ctx.state.user.id); + const project = await giteaFetch(`/repositories/${ctx.params.id}`, {}, token); + + ctx.response.body = { + id: project.id, + name: project.name, + fullName: project.full_name, + description: project.description, + webUrl: project.html_url, + defaultBranch: project.default_branch + }; + } catch (err) { + ctx.response.status = 500; + ctx.response.body = { error: err instanceof Error ? err.message : "Failed to fetch project" }; + } +}); + +// Get branches for a project +router.get("/api/gitea/projects/:owner/:repo/branches", authMiddleware, async (ctx) => { + try { + const { owner, repo } = ctx.params; + const token = await getUserGiteaToken(ctx.state.user.id); + const branches = await giteaFetch(`/repos/${owner}/${repo}/branches`, {}, token); + + ctx.response.body = branches.map(b => ({ + name: b.name, + protected: b.protected, + commit: { + id: b.commit.id, + message: b.commit.message, + authorName: b.commit.author.name, + date: b.commit.author.date + } + })); + } catch (err) { + ctx.response.status = 500; + ctx.response.body = { error: err instanceof Error ? err.message : "Failed to fetch branches" }; + } +}); + +// Get commits for a project +router.get("/api/gitea/projects/:owner/:repo/commits", authMiddleware, async (ctx) => { + try { + const { owner, repo } = ctx.params; + const branch = ctx.request.url.searchParams.get("branch") || "main"; + const limit = ctx.request.url.searchParams.get("limit") || "20"; + const token = await getUserGiteaToken(ctx.state.user.id); + + const commits = await giteaFetch( + `/repos/${owner}/${repo}/commits?sha=${branch}&limit=${limit}`, + {}, + token + ); + + ctx.response.body = commits.map(c => ({ + id: c.sha, + shortId: c.sha.substring(0, 8), + title: c.commit.message.split('\n')[0], + message: c.commit.message, + authorName: c.commit.author.name, + authorEmail: c.commit.author.email, + date: c.commit.author.date, + webUrl: c.html_url, + authorAvatar: c.author?.avatar_url + })); + } catch (err) { + ctx.response.status = 500; + ctx.response.body = { error: err instanceof Error ? err.message : "Failed to fetch commits" }; + } +}); + +// Get file tree for a project +router.get("/api/gitea/projects/:owner/:repo/tree", authMiddleware, async (ctx) => { + try { + const { owner, repo } = ctx.params; + const ref = ctx.request.url.searchParams.get("ref") || "main"; + const path = ctx.request.url.searchParams.get("path") || ""; + const token = await getUserGiteaToken(ctx.state.user.id); + + const endpoint = path + ? `/repos/${owner}/${repo}/contents/${path}?ref=${ref}` + : `/repos/${owner}/${repo}/contents?ref=${ref}`; + + const items = await giteaFetch(endpoint, {}, token); + + ctx.response.body = items.map(item => ({ + name: item.name, + path: item.path, + type: item.type === "dir" ? "tree" : "blob", + size: item.size + })); + } catch (err) { + ctx.response.status = 500; + ctx.response.body = { error: err instanceof Error ? err.message : "Failed to fetch tree" }; + } +}); + +// Get file content +router.get("/api/gitea/projects/:owner/:repo/file", authMiddleware, async (ctx) => { + try { + const { owner, repo } = ctx.params; + const path = ctx.request.url.searchParams.get("path"); + const ref = ctx.request.url.searchParams.get("ref") || "main"; + + if (!path) { + ctx.response.status = 400; + ctx.response.body = { error: "Path is required" }; + return; + } + + const token = await getUserGiteaToken(ctx.state.user.id); + + interface FileContent { + content: string; + encoding: string; + name: string; + path: string; + size: number; + } + + const file = await giteaFetch( + `/repos/${owner}/${repo}/contents/${path}?ref=${ref}`, + {}, + token + ); + + let content = file.content; + if (file.encoding === "base64") { + content = atob(file.content); + } + + ctx.response.body = { + name: file.name, + path: file.path, + content, + size: file.size + }; + } catch (err) { + ctx.response.status = 500; + ctx.response.body = { error: err instanceof Error ? err.message : "Failed to fetch file" }; + } +}); + +// Search Gitea projects +router.get("/api/gitea/search", authMiddleware, async (ctx) => { + try { + const query = ctx.request.url.searchParams.get("q") || ""; + const token = await getUserGiteaToken(ctx.state.user.id); + + interface SearchResult { + data: GiteaProject[]; + } + + const result = await giteaFetch( + `/repos/search?q=${encodeURIComponent(query)}&limit=20`, + {}, + token + ); + + ctx.response.body = result.data.map(p => ({ + id: p.id, + name: p.name, + fullName: p.full_name, + description: p.description, + webUrl: p.html_url, + owner: p.owner.login + })); + } catch (err) { + ctx.response.status = 500; + ctx.response.body = { error: err instanceof Error ? err.message : "Search failed" }; + } +}); + +// Save user's Gitea token +router.post("/api/gitea/token", authMiddleware, async (ctx) => { + try { + const body = await ctx.request.body.json(); + const { token } = body; + + const db = await getDB(); + await db.collection("users").updateOne( + { _id: new ObjectId(ctx.state.user.id) }, + { $set: { giteaToken: token } } + ); + + ctx.response.body = { message: "Gitea token saved" }; + } catch (err) { + ctx.response.status = 500; + ctx.response.body = { error: err instanceof Error ? err.message : "Failed to save token" }; + } +}); + +export const giteaRouter = router; diff --git a/src/routes/tasks.ts b/src/routes/tasks.ts index c579ff3..460a97a 100644 --- a/src/routes/tasks.ts +++ b/src/routes/tasks.ts @@ -43,6 +43,13 @@ interface Task { updatedAt: Date; } +interface GiteaProjectRef { + projectId: number; + path: string; + url: string; + name: string; +} + interface GitLabProjectRef { projectId: number; path: string; @@ -62,6 +69,8 @@ interface Project { gitlabProjectId?: number; gitlabUrl?: string; gitlabPath?: string; + // Gitea Integration + giteaProjects?: GiteaProjectRef[]; createdAt: Date; updatedAt: Date; } @@ -397,7 +406,7 @@ tasksRouter.post("/projects", authMiddleware, async (ctx) => { } const body = await ctx.request.body.json(); - const { name, description, color, gitlabProjectId, gitlabUrl, gitlabPath } = body; + const { name, description, color, gitlabProjectId, gitlabUrl, gitlabPath, gitlabProjects, giteaProjects, rules } = body; if (!name) { ctx.response.status = 400; @@ -414,7 +423,10 @@ tasksRouter.post("/projects", authMiddleware, async (ctx) => { color: color || "#6366f1", gitlabProjectId: gitlabProjectId || undefined, gitlabUrl: gitlabUrl || undefined, - gitlabPath: gitlabPath || undefined, + gitlabPath: gitlabPath || undefined, + gitlabProjects: gitlabProjects || [], + giteaProjects: giteaProjects || [], + rules: rules || "", createdAt: now, updatedAt: now } as Project); @@ -436,7 +448,7 @@ tasksRouter.put("/projects/:id", authMiddleware, async (ctx) => { const id = ctx.params.id; const body = await ctx.request.body.json(); - const { name, description, color, rules, gitlabProjectId, gitlabUrl, gitlabPath, gitlabProjects } = body; + const { name, description, color, rules, gitlabProjectId, gitlabUrl, gitlabPath, gitlabProjects, giteaProjects } = body; const db = await getDB(); @@ -447,6 +459,7 @@ tasksRouter.put("/projects/:id", authMiddleware, async (ctx) => { if (rules !== undefined) (updateFields as any).rules = rules; // GitLab fields - Multiple projects (new) if (gitlabProjects !== undefined) updateFields.gitlabProjects = gitlabProjects || []; + if (giteaProjects !== undefined) (updateFields as any).giteaProjects = giteaProjects || []; // Legacy single project fields (for backwards compatibility) if (gitlabProjectId !== undefined) updateFields.gitlabProjectId = gitlabProjectId || undefined; if (gitlabUrl !== undefined) updateFields.gitlabUrl = gitlabUrl || undefined;