From f54816a6224a154f90d63e6f8c5c27fa7f2bcf0b Mon Sep 17 00:00:00 2001 From: plenarius Date: Mon, 20 Apr 2026 21:54:09 -0400 Subject: [PATCH] feat: add GitHub service for PAT validation, forking, and PR management --- package-lock.json | 238 ++++++++++++++++++ packages/backend/package.json | 16 +- packages/backend/src/config/repos.ts | 7 + packages/backend/src/index.ts | 2 + packages/backend/src/routes/github.ts | 116 +++++++++ .../backend/src/services/github.service.ts | 70 ++++++ 6 files changed, 442 insertions(+), 7 deletions(-) create mode 100644 packages/backend/src/config/repos.ts create mode 100644 packages/backend/src/routes/github.ts create mode 100644 packages/backend/src/services/github.service.ts diff --git a/package-lock.json b/package-lock.json index 0558600..3a45c48 100644 --- a/package-lock.json +++ b/package-lock.json @@ -852,6 +852,21 @@ "url": "https://opencollective.com/js-sdsl" } }, + "node_modules/@kwsites/file-exists": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz", + "integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==", + "license": "MIT", + "dependencies": { + "debug": "^4.1.1" + } + }, + "node_modules/@kwsites/promise-deferred": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz", + "integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==", + "license": "MIT" + }, "node_modules/@layonara-forge/backend": { "resolved": "packages/backend", "link": true @@ -883,6 +898,161 @@ "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@octokit/auth-token": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-6.0.0.tgz", + "integrity": "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w==", + "license": "MIT", + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/core": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz", + "integrity": "sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q==", + "license": "MIT", + "dependencies": { + "@octokit/auth-token": "^6.0.0", + "@octokit/graphql": "^9.0.3", + "@octokit/request": "^10.0.6", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "before-after-hook": "^4.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/endpoint": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-11.0.3.tgz", + "integrity": "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/graphql": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-9.0.3.tgz", + "integrity": "sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA==", + "license": "MIT", + "dependencies": { + "@octokit/request": "^10.0.6", + "@octokit/types": "^16.0.0", + "universal-user-agent": "^7.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/openapi-types": { + "version": "27.0.0", + "resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-27.0.0.tgz", + "integrity": "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA==", + "license": "MIT" + }, + "node_modules/@octokit/plugin-paginate-rest": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz", + "integrity": "sha512-fNVRE7ufJiAA3XUrha2omTA39M6IXIc6GIZLvlbsm8QOQCYvpq/LkMNGyFlB1d8hTDzsAXa3OKtybdMAYsV/fw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-request-log": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-6.0.0.tgz", + "integrity": "sha512-UkOzeEN3W91/eBq9sPZNQ7sUBvYCqYbrrD8gTbBuGtHEuycE4/awMXcYvx6sVYo7LypPhmQwwpUe4Yyu4QZN5Q==", + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/plugin-rest-endpoint-methods": { + "version": "17.0.0", + "resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-17.0.0.tgz", + "integrity": "sha512-B5yCyIlOJFPqUUeiD0cnBJwWJO8lkJs5d8+ze9QDP6SvfiXSz1BF+91+0MeI1d2yxgOhU/O+CvtiZ9jSkHhFAw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + }, + "peerDependencies": { + "@octokit/core": ">=6" + } + }, + "node_modules/@octokit/request": { + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/@octokit/request/-/request-10.0.8.tgz", + "integrity": "sha512-SJZNwY9pur9Agf7l87ywFi14W+Hd9Jg6Ifivsd33+/bGUQIjNujdFiXII2/qSlN2ybqUHfp5xpekMEjIBTjlSw==", + "license": "MIT", + "dependencies": { + "@octokit/endpoint": "^11.0.3", + "@octokit/request-error": "^7.0.2", + "@octokit/types": "^16.0.0", + "fast-content-type-parse": "^3.0.0", + "json-with-bigint": "^3.5.3", + "universal-user-agent": "^7.0.2" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/request-error": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-7.1.0.tgz", + "integrity": "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw==", + "license": "MIT", + "dependencies": { + "@octokit/types": "^16.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/rest": { + "version": "22.0.1", + "resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-22.0.1.tgz", + "integrity": "sha512-Jzbhzl3CEexhnivb1iQ0KJ7s5vvjMWcmRtq5aUsKmKDrRW6z3r84ngmiFKFvpZjpiU/9/S6ITPFRpn5s/3uQJw==", + "license": "MIT", + "dependencies": { + "@octokit/core": "^7.0.6", + "@octokit/plugin-paginate-rest": "^14.0.0", + "@octokit/plugin-request-log": "^6.0.0", + "@octokit/plugin-rest-endpoint-methods": "^17.0.0" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/@octokit/types": { + "version": "16.0.0", + "resolved": "https://registry.npmjs.org/@octokit/types/-/types-16.0.0.tgz", + "integrity": "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg==", + "license": "MIT", + "dependencies": { + "@octokit/openapi-types": "^27.0.0" + } + }, "node_modules/@protobufjs/aspromise": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", @@ -1343,6 +1513,21 @@ "win32" ] }, + "node_modules/@simple-git/args-pathspec": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz", + "integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==", + "license": "MIT" + }, + "node_modules/@simple-git/argv-parser": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz", + "integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==", + "license": "MIT", + "dependencies": { + "@simple-git/args-pathspec": "^1.0.3" + } + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -1856,6 +2041,12 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/before-after-hook": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/before-after-hook/-/before-after-hook-4.0.0.tgz", + "integrity": "sha512-q6tR3RPqIB1pMiTRMFcZwuG5T8vwp+vUvEG0vuI6B+Rikh5BfPp2fQ82c925FOs+b0lcFQ8CFrL+KbilfZFhOQ==", + "license": "Apache-2.0" + }, "node_modules/bl": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", @@ -2542,6 +2733,22 @@ "url": "https://opencollective.com/express" } }, + "node_modules/fast-content-type-parse": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/fast-content-type-parse/-/fast-content-type-parse-3.0.0.tgz", + "integrity": "sha512-ZvLdcY8P+N8mGQJahJV5G4U88CSvT1rP8ApL6uETe88MBXrBHAkZlSEySdUlyztF7ccb+Znos3TFqaepHxdhBg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "MIT" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -2850,6 +3057,12 @@ "node": ">=6" } }, + "node_modules/json-with-bigint": { + "version": "3.5.8", + "resolved": "https://registry.npmjs.org/json-with-bigint/-/json-with-bigint-3.5.8.tgz", + "integrity": "sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw==", + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -3636,6 +3849,23 @@ "dev": true, "license": "ISC" }, + "node_modules/simple-git": { + "version": "3.36.0", + "resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz", + "integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==", + "license": "MIT", + "dependencies": { + "@kwsites/file-exists": "^1.1.1", + "@kwsites/promise-deferred": "^1.1.1", + "@simple-git/args-pathspec": "^1.0.3", + "@simple-git/argv-parser": "^1.1.0", + "debug": "^4.4.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/steveukx/git-js?sponsor=1" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3951,6 +4181,12 @@ "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "license": "MIT" }, + "node_modules/universal-user-agent": { + "version": "7.0.3", + "resolved": "https://registry.npmjs.org/universal-user-agent/-/universal-user-agent-7.0.3.tgz", + "integrity": "sha512-TmnEAEAsBJVZM/AADELsK76llnwcf9vMKuPz8JflO1frO8Lchitr0fNaN9d+Ap0BjKtqWqd/J17qeDnXh8CL2A==", + "license": "ISC" + }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -4829,10 +5065,12 @@ "name": "@layonara-forge/backend", "version": "0.0.1", "dependencies": { + "@octokit/rest": "^22.0.1", "chokidar": "^4.0.0", "cors": "^2.8.5", "dockerode": "^4.0.0", "express": "^5.0.0", + "simple-git": "^3.36.0", "ws": "^8.18.0" }, "devDependencies": { diff --git a/packages/backend/package.json b/packages/backend/package.json index dc3e1ba..39ed040 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -10,17 +10,19 @@ "test": "vitest" }, "dependencies": { - "express": "^5.0.0", - "dockerode": "^4.0.0", - "ws": "^8.18.0", + "@octokit/rest": "^22.0.1", "chokidar": "^4.0.0", - "cors": "^2.8.5" + "cors": "^2.8.5", + "dockerode": "^4.0.0", + "express": "^5.0.0", + "simple-git": "^3.36.0", + "ws": "^8.18.0" }, "devDependencies": { - "@types/express": "^5.0.0", - "@types/dockerode": "^3.3.0", - "@types/ws": "^8.5.0", "@types/cors": "^2.8.0", + "@types/dockerode": "^3.3.0", + "@types/express": "^5.0.0", + "@types/ws": "^8.5.0", "tsx": "^4.19.0", "vitest": "^3.0.0" } diff --git a/packages/backend/src/config/repos.ts b/packages/backend/src/config/repos.ts new file mode 100644 index 0000000..5aa4e93 --- /dev/null +++ b/packages/backend/src/config/repos.ts @@ -0,0 +1,7 @@ +export const REPOS = [ + { name: "nwn-module", upstream: "layonara/nwn-module", branch: "ee" }, + { name: "nwn-haks", upstream: "layonara/nwn-haks", branch: "64bit" }, + { name: "unified", upstream: "plenarius/unified", branch: "master" }, +] as const; + +export type RepoName = (typeof REPOS)[number]["name"]; diff --git a/packages/backend/src/index.ts b/packages/backend/src/index.ts index aa7a015..733af4d 100644 --- a/packages/backend/src/index.ts +++ b/packages/backend/src/index.ts @@ -12,6 +12,7 @@ import terminalRouter from "./routes/terminal.js"; import buildRouter from "./routes/build.js"; import serverRouter from "./routes/server.js"; import toolsetRouter from "./routes/toolset.js"; +import githubRouter from "./routes/github.js"; import { attachWebSocket, createTerminalSession } from "./services/terminal.service.js"; import { attachLspWebSocket } from "./services/lsp.service.js"; @@ -39,6 +40,7 @@ app.use("/api/terminal", terminalRouter); app.use("/api/build", buildRouter); app.use("/api/server", serverRouter); app.use("/api/toolset", toolsetRouter); +app.use("/api/github", githubRouter); const frontendDist = path.resolve(__dirname, "../../frontend/dist"); app.use(express.static(frontendDist)); diff --git a/packages/backend/src/routes/github.ts b/packages/backend/src/routes/github.ts new file mode 100644 index 0000000..afedba8 --- /dev/null +++ b/packages/backend/src/routes/github.ts @@ -0,0 +1,116 @@ +import { Router } from "express"; +import { readConfig, writeConfig } from "../services/workspace.service.js"; +import { REPOS } from "../config/repos.js"; +import { + validatePat, + forkRepo, + listUserForks, + createPullRequest, + listPullRequests, +} from "../services/github.service.js"; + +const router = Router(); + +async function getPat(): Promise { + const config = await readConfig(); + const pat = config.githubPat; + if (!pat) throw new Error("GitHub PAT not configured"); + return pat; +} + +router.post("/validate-pat", async (req, res) => { + try { + const { pat } = req.body; + if (!pat) { + res.status(400).json({ error: "PAT is required" }); + return; + } + const result = await validatePat(pat); + + const config = await readConfig(); + await writeConfig({ ...config, githubPat: pat }); + + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Invalid PAT"; + res.status(401).json({ error: message }); + } +}); + +router.post("/fork", async (req, res) => { + try { + const pat = await getPat(); + const { repo: repoName } = req.body; + const repoDef = REPOS.find((r) => r.name === repoName); + if (!repoDef) { + res.status(400).json({ error: `Unknown repo: ${repoName}` }); + return; + } + const [owner, repo] = repoDef.upstream.split("/"); + const result = await forkRepo(pat, owner, repo); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Fork failed"; + res.status(500).json({ error: message }); + } +}); + +router.get("/forks", async (_req, res) => { + try { + const pat = await getPat(); + const forks = await listUserForks(pat); + res.json(forks); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to list forks"; + res.status(500).json({ error: message }); + } +}); + +router.post("/pr", async (req, res) => { + try { + const pat = await getPat(); + const { repo: repoName, title, body, headBranch } = req.body; + const repoDef = REPOS.find((r) => r.name === repoName); + if (!repoDef) { + res.status(400).json({ error: `Unknown repo: ${repoName}` }); + return; + } + + const { login } = await import("../services/github.service.js").then((m) => + m.validatePat(pat), + ); + + const result = await createPullRequest(pat, { + upstream: repoDef.upstream, + repo: repoName, + title, + body, + head: `${login}:${headBranch}`, + base: repoDef.branch, + }); + res.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "PR creation failed"; + res.status(500).json({ error: message }); + } +}); + +router.get("/prs/:repo", async (req, res) => { + try { + const pat = await getPat(); + const repoName = req.params.repo; + const repoDef = REPOS.find((r) => r.name === repoName); + if (!repoDef) { + res.status(400).json({ error: `Unknown repo: ${repoName}` }); + return; + } + const [owner, repo] = repoDef.upstream.split("/"); + const prs = await listPullRequests(pat, owner, repo); + res.json(prs); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Failed to list PRs"; + res.status(500).json({ error: message }); + } +}); + +export default router; diff --git a/packages/backend/src/services/github.service.ts b/packages/backend/src/services/github.service.ts new file mode 100644 index 0000000..f2eadc2 --- /dev/null +++ b/packages/backend/src/services/github.service.ts @@ -0,0 +1,70 @@ +import { Octokit } from "@octokit/rest"; +import { REPOS } from "../config/repos.js"; + +export async function validatePat(pat: string) { + const octokit = new Octokit({ auth: pat }); + const { data, headers } = await octokit.rest.users.getAuthenticated(); + const scopes = (headers["x-oauth-scopes"] || "") + .split(",") + .map((s: string) => s.trim()) + .filter(Boolean); + return { login: data.login, scopes }; +} + +export async function forkRepo(pat: string, owner: string, repo: string) { + const octokit = new Octokit({ auth: pat }); + const { data } = await octokit.rest.repos.createFork({ owner, repo }); + return { fullName: data.full_name, cloneUrl: data.clone_url }; +} + +export async function listUserForks(pat: string) { + const octokit = new Octokit({ auth: pat }); + const { data: user } = await octokit.rest.users.getAuthenticated(); + const login = user.login; + + const results: Array<{ repo: string; forked: boolean; fullName?: string }> = []; + + for (const repo of REPOS) { + try { + const { data } = await octokit.rest.repos.get({ + owner: login, + repo: repo.name, + }); + results.push({ repo: repo.name, forked: true, fullName: data.full_name }); + } catch { + results.push({ repo: repo.name, forked: false }); + } + } + + return results; +} + +export async function createPullRequest( + pat: string, + opts: { upstream: string; repo: string; title: string; body: string; head: string; base: string }, +) { + const octokit = new Octokit({ auth: pat }); + const [owner, repo] = opts.upstream.split("/"); + const { data } = await octokit.rest.pulls.create({ + owner, + repo, + title: opts.title, + body: opts.body, + head: opts.head, + base: opts.base, + }); + return { number: data.number, url: data.html_url }; +} + +export async function listPullRequests(pat: string, owner: string, repo: string) { + const octokit = new Octokit({ auth: pat }); + const { data } = await octokit.rest.pulls.list({ owner, repo, state: "open" }); + return data.map((pr) => ({ + number: pr.number, + title: pr.title, + state: pr.state, + url: pr.html_url, + user: pr.user?.login || "", + createdAt: pr.created_at, + })); +}