refactor: replace GitHub API with Gitea-compatible git provider service

This commit is contained in:
plenarius
2026-04-20 23:05:38 -04:00
parent 43d3aa979c
commit b85f70dc95
7 changed files with 140 additions and 283 deletions
-190
View File
@@ -898,161 +898,6 @@
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" "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": { "node_modules/@protobufjs/aspromise": {
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
@@ -2041,12 +1886,6 @@
"tweetnacl": "^0.14.3" "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": { "node_modules/bl": {
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz", "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
@@ -2733,22 +2572,6 @@
"url": "https://opencollective.com/express" "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": { "node_modules/fdir": {
"version": "6.5.0", "version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
@@ -3057,12 +2880,6 @@
"node": ">=6" "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": { "node_modules/json5": {
"version": "2.2.3", "version": "2.2.3",
"resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz",
@@ -4181,12 +3998,6 @@
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==", "integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"license": "MIT" "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": { "node_modules/unpipe": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz",
@@ -5065,7 +4876,6 @@
"name": "@layonara-forge/backend", "name": "@layonara-forge/backend",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@octokit/rest": "^22.0.1",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dockerode": "^4.0.0", "dockerode": "^4.0.0",
-1
View File
@@ -10,7 +10,6 @@
"test": "vitest" "test": "vitest"
}, },
"dependencies": { "dependencies": {
"@octokit/rest": "^22.0.1",
"chokidar": "^4.0.0", "chokidar": "^4.0.0",
"cors": "^2.8.5", "cors": "^2.8.5",
"dockerode": "^4.0.0", "dockerode": "^4.0.0",
+5 -3
View File
@@ -1,7 +1,9 @@
export const GIT_PROVIDER_URL = process.env.GIT_PROVIDER_URL || "https://gitea.layonara.com";
export const REPOS = [ export const REPOS = [
{ name: "nwn-module", upstream: "layonara/nwn-module", branch: "ee" }, { name: "nwn-module", upstream: "layonara/nwn-module", branch: "ee", provider: "gitea" },
{ name: "nwn-haks", upstream: "layonara/nwn-haks", branch: "64bit" }, { name: "nwn-haks", upstream: "layonara/nwn-haks", branch: "64bit", provider: "gitea" },
{ name: "unified", upstream: "plenarius/unified", branch: "master" }, { name: "unified", upstream: "plenarius/unified", branch: "master", provider: "github" },
] as const; ] as const;
export type RepoName = (typeof REPOS)[number]["name"]; export type RepoName = (typeof REPOS)[number]["name"];
+6 -6
View File
@@ -2,19 +2,19 @@ import { Router } from "express";
import { readConfig, writeConfig } from "../services/workspace.service.js"; import { readConfig, writeConfig } from "../services/workspace.service.js";
import { REPOS } from "../config/repos.js"; import { REPOS } from "../config/repos.js";
import { import {
validatePat, validateToken,
forkRepo, forkRepo,
listUserForks, listUserForks,
createPullRequest, createPullRequest,
listPullRequests, listPullRequests,
} from "../services/github.service.js"; } from "../services/git-provider.service.js";
const router = Router(); const router = Router();
async function getPat(): Promise<string> { async function getPat(): Promise<string> {
const config = await readConfig(); const config = await readConfig();
const pat = config.githubPat; const pat = config.githubPat;
if (!pat) throw new Error("GitHub PAT not configured"); if (!pat) throw new Error("Git provider token not configured");
return pat; return pat;
} }
@@ -25,7 +25,7 @@ router.post("/validate-pat", async (req, res) => {
res.status(400).json({ error: "PAT is required" }); res.status(400).json({ error: "PAT is required" });
return; return;
} }
const result = await validatePat(pat); const result = await validateToken(pat);
const config = await readConfig(); const config = await readConfig();
await writeConfig({ ...config, githubPat: pat }); await writeConfig({ ...config, githubPat: pat });
@@ -76,8 +76,8 @@ router.post("/pr", async (req, res) => {
return; return;
} }
const { login } = await import("../services/github.service.js").then((m) => const { login } = await import("../services/git-provider.service.js").then((m) =>
m.validatePat(pat), m.validateToken(pat),
); );
const result = await createPullRequest(pat, { const result = await createPullRequest(pat, {
@@ -0,0 +1,98 @@
import { REPOS, GIT_PROVIDER_URL } from "../config/repos.js";
async function giteaFetch(path: string, token: string, options: RequestInit = {}): Promise<Response> {
return fetch(`${GIT_PROVIDER_URL}/api/v1${path}`, {
...options,
headers: {
"Authorization": `token ${token}`,
"Content-Type": "application/json",
...options.headers,
},
});
}
export async function validateToken(token: string) {
const res = await giteaFetch("/user", token);
if (!res.ok) {
throw new Error(`Token validation failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
return { login: data.login, email: data.email };
}
export async function forkRepo(token: string, owner: string, repo: string) {
const res = await giteaFetch(`/repos/${owner}/${repo}/forks`, token, {
method: "POST",
body: JSON.stringify({}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || `Fork failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
return { fullName: data.full_name, cloneUrl: data.clone_url };
}
export async function listUserForks(token: string) {
const { login } = await validateToken(token);
const results: Array<{ repo: string; forked: boolean; fullName?: string }> = [];
for (const repo of REPOS) {
if (repo.provider !== "gitea") {
results.push({ repo: repo.name, forked: false });
continue;
}
try {
const res = await giteaFetch(`/repos/${login}/${repo.name}`, token);
if (res.ok) {
const data = await res.json();
results.push({ repo: repo.name, forked: true, fullName: data.full_name });
} else {
results.push({ repo: repo.name, forked: false });
}
} catch {
results.push({ repo: repo.name, forked: false });
}
}
return results;
}
export async function createPullRequest(
token: string,
opts: { upstream: string; repo: string; title: string; body: string; head: string; base: string },
) {
const [owner, repo] = opts.upstream.split("/");
const res = await giteaFetch(`/repos/${owner}/${repo}/pulls`, token, {
method: "POST",
body: JSON.stringify({
title: opts.title,
body: opts.body,
head: opts.head,
base: opts.base,
}),
});
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new Error(body.message || `PR creation failed: ${res.status} ${res.statusText}`);
}
const data = await res.json();
return { number: data.number, url: data.html_url };
}
export async function listPullRequests(token: string, owner: string, repo: string) {
const res = await giteaFetch(`/repos/${owner}/${repo}/pulls?state=open`, token);
if (!res.ok) {
throw new Error(`Failed to list PRs: ${res.status} ${res.statusText}`);
}
const data = await res.json();
return data.map((pr: Record<string, unknown>) => ({
number: pr.number,
title: pr.title,
state: pr.state,
url: pr.html_url,
user: (pr.user as Record<string, unknown>)?.login || "",
createdAt: pr.created_at,
}));
}
+31 -13
View File
@@ -1,6 +1,6 @@
import simpleGit, { SimpleGit } from "simple-git"; import simpleGit, { SimpleGit } from "simple-git";
import fs from "fs/promises"; import fs from "fs/promises";
import { REPOS, type RepoName } from "../config/repos.js"; import { REPOS, GIT_PROVIDER_URL, type RepoName } from "../config/repos.js";
import { getRepoPath, readConfig } from "./workspace.service.js"; import { getRepoPath, readConfig } from "./workspace.service.js";
import { broadcast } from "./ws.service.js"; import { broadcast } from "./ws.service.js";
@@ -111,19 +111,29 @@ export async function getDiff(repoPath: string, file?: string) {
return g.diff(["HEAD"]); return g.diff(["HEAD"]);
} }
function getCloneUrl(token: string, user: string, repoName: string, provider: string): string {
const host = provider === "github" ? "github.com" : new URL(GIT_PROVIDER_URL).host;
return `https://${token}@${host}/${user}/${repoName}.git`;
}
function getUpstreamUrl(owner: string, repo: string, provider: string, token?: string): string {
if (provider === "github") {
return `https://github.com/${owner}/${repo}.git`;
}
const host = new URL(GIT_PROVIDER_URL).host;
if (token) {
return `https://${token}@${host}/${owner}/${repo}.git`;
}
return `https://${host}/${owner}/${repo}.git`;
}
export async function setupClone(repoName: RepoName) { export async function setupClone(repoName: RepoName) {
const config = await readConfig(); const config = await readConfig();
const pat = config.githubPat; const pat = config.githubPat;
if (!pat) throw new Error("GitHub PAT not configured"); if (!pat) throw new Error("Git provider token not configured");
const repoDef = getRepoDef(repoName); const repoDef = getRepoDef(repoName);
const [upOwner, upRepo] = repoDef.upstream.split("/"); const [upOwner, upRepo] = repoDef.upstream.split("/");
const { validatePat } = await import("./github.service.js");
const { login } = await validatePat(pat);
const forkUrl = `https://${pat}@github.com/${login}/${upRepo}.git`;
const upstreamUrl = `https://github.com/${upOwner}/${upRepo}.git`;
const targetDir = getRepoPath(repoName); const targetDir = getRepoPath(repoName);
try { try {
@@ -133,11 +143,19 @@ export async function setupClone(repoName: RepoName) {
if (err instanceof Error && err.message.startsWith("Repo directory")) throw err; if (err instanceof Error && err.message.startsWith("Repo directory")) throw err;
} }
await cloneRepo(forkUrl, targetDir, { branch: repoDef.branch, depth: 1 }); if (repoDef.provider === "github") {
const upstreamUrl = getUpstreamUrl(upOwner, upRepo, "github");
const g = git(targetDir); await cloneRepo(upstreamUrl, targetDir, { branch: repoDef.branch, depth: 1 });
await g.addRemote("upstream", upstreamUrl); } else {
await g.fetch("upstream"); const { validateToken } = await import("./git-provider.service.js");
const { login } = await validateToken(pat as string);
const forkUrl = getCloneUrl(pat as string, login, upRepo, repoDef.provider);
const upstreamUrl = getUpstreamUrl(upOwner, upRepo, repoDef.provider, pat as string);
await cloneRepo(forkUrl, targetDir, { branch: repoDef.branch, depth: 1 });
const g = git(targetDir);
await g.addRemote("upstream", upstreamUrl);
await g.fetch("upstream");
}
return { path: targetDir, branch: repoDef.branch }; return { path: targetDir, branch: repoDef.branch };
} }
@@ -1,70 +0,0 @@
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,
}));
}