mirror of
https://github.com/RayLabsHQ/gitea-mirror.git
synced 2026-03-14 06:23:01 +03:00
* feat: add E2E testing infrastructure with fake GitHub, Playwright, and CI workflow - Add fake GitHub API server (tests/e2e/fake-github-server.ts) with management API for seeding test data - Add Playwright E2E test suite covering full mirror workflow: service health checks, user registration, config, sync, verify - Add Docker Compose for E2E Gitea instance - Add orchestrator script (run-e2e.sh) with cleanup - Add GitHub Actions workflow (e2e-tests.yml) with Gitea service container - Make GITHUB_API_URL configurable via env var for testing - Add npm scripts: test:e2e, test:e2e:ci, test:e2e:keep, test:e2e:cleanup * feat: add real git repos + backup config testing to E2E suite - Create programmatic test git repos (create-test-repos.ts) with real commits, branches (main, develop, feature/*), and tags (v1.0.0, v1.1.0) - Add git-server container to docker-compose serving bare repos via dumb HTTP protocol so Gitea can actually clone them - Update fake GitHub server to emit reachable clone_url fields pointing to the git-server container (configurable via GIT_SERVER_URL env var) - Add management endpoint POST /___mgmt/set-clone-url for runtime config - Update E2E spec with real mirroring verification: * Verify repos appear in Gitea with actual content * Check branches, tags, commits, file content * Verify 4/4 repos mirrored successfully - Add backup configuration test suite: * Enable/disable backupBeforeSync config * Toggle blockSyncOnBackupFailure * Trigger re-sync with backup enabled and verify activities * Verify config persistence across changes - Update CI workflow to use docker compose (not service containers) matching the local run-e2e.sh approach - Update cleanup.sh for git-repos directory and git-server port - All 22 tests passing with real git content verification * refactor: split E2E tests into focused files + add force-push tests Split the monolithic e2e.spec.ts (1335 lines) into 5 focused spec files and a shared helpers module: helpers.ts — constants, GiteaAPI, auth, saveConfig, utilities 01-health.spec.ts — service health checks (4 tests) 02-mirror-workflow.spec.ts — full first-mirror journey (8 tests) 03-backup.spec.ts — backup config toggling (6 tests) 04-force-push.spec.ts — force-push simulation & backup verification (9 tests) 05-sync-verification.spec.ts — dynamic repos, content integrity, reset (5 tests) The force-push tests are the critical addition: F0: Record original state (commit SHAs, file content) F1: Rewrite source repo history (simulate force-push) F2: Sync to Gitea WITHOUT backup F3: Verify data loss — LICENSE file gone, README overwritten F4: Restore source, re-mirror to clean state F5: Enable backup, force-push again, sync through app F6: Verify Gitea reflects the force-push F7: Verify backup system was invoked (snapshot activities logged) F8: Restore source repo for subsequent tests Also added to helpers.ts: - GiteaAPI.getBranch(), .getCommit(), .triggerMirrorSync() - getRepositoryIds(), triggerMirrorJobs(), triggerSyncRepo() All 32 tests passing. * Try to fix actions * Try to fix the other action * Add debug info to check why e2e action is failing * More debug info * Even more debug info * E2E fix attempt #1 * E2E fix attempt #2 * more debug again * E2E fix attempt #3 * E2E fix attempt #4 * Remove a bunch of debug info * Hopefully fix backup bug * Force backups to succeed
1028 lines
32 KiB
TypeScript
1028 lines
32 KiB
TypeScript
/**
|
|
* Fake GitHub API server for E2E testing.
|
|
*
|
|
* Implements the subset of the GitHub REST API that gitea-mirror actually uses:
|
|
* - GET /user (authenticated user)
|
|
* - GET /user/repos (user repositories)
|
|
* - GET /user/starred (starred repositories)
|
|
* - GET /user/orgs (user organizations)
|
|
* - GET /repos/:owner/:repo (single repo)
|
|
* - GET /repos/:owner/:repo/branches (branches)
|
|
* - GET /repos/:owner/:repo/git/refs (git refs)
|
|
* - GET /repos/:owner/:repo/issues (issues)
|
|
* - GET /repos/:owner/:repo/pulls (pull requests)
|
|
* - GET /repos/:owner/:repo/releases (releases)
|
|
* - GET /repos/:owner/:repo/labels (labels)
|
|
* - GET /repos/:owner/:repo/milestones (milestones)
|
|
* - GET /orgs/:org (org details)
|
|
* - GET /orgs/:org/repos (org repos)
|
|
* - GET /user/memberships/orgs/:org (org membership)
|
|
* - GET /rate_limit (rate limit status)
|
|
*
|
|
* All data is served from an in-memory store that can be seeded via the
|
|
* management API:
|
|
* - POST /___mgmt/seed (replace entire store)
|
|
* - POST /___mgmt/add-repo (add a single repo)
|
|
* - POST /___mgmt/add-org (add an organization)
|
|
* - POST /___mgmt/reset (reset to defaults)
|
|
* - GET /___mgmt/health (liveness check)
|
|
*
|
|
* Start:
|
|
* npx tsx tests/e2e/fake-github-server.ts # default port 4580
|
|
* PORT=4580 npx tsx tests/e2e/fake-github-server.ts
|
|
*/
|
|
|
|
import http from "node:http";
|
|
import { URL } from "node:url";
|
|
|
|
// ─── Clone URL Configuration ─────────────────────────────────────────────────
|
|
// When GIT_SERVER_URL is set, clone_url fields will point to a real git HTTP
|
|
// server (e.g. http://git-server) so Gitea can actually clone the repos.
|
|
// When unset, clone_url uses the unreachable https://fake-github.test/ default.
|
|
let GIT_CLONE_BASE_URL =
|
|
process.env.GIT_SERVER_URL || "https://fake-github.test";
|
|
// For html_url we always use the fake domain (it's cosmetic / not cloned)
|
|
const HTML_BASE_URL = "https://fake-github.test";
|
|
|
|
// ─── Types ───────────────────────────────────────────────────────────────────
|
|
|
|
interface FakeUser {
|
|
login: string;
|
|
id: number;
|
|
avatar_url: string;
|
|
type: "User";
|
|
name: string;
|
|
email: string;
|
|
}
|
|
|
|
interface FakeRepo {
|
|
id: number;
|
|
name: string;
|
|
full_name: string;
|
|
owner: {
|
|
login: string;
|
|
id: number;
|
|
type: "User" | "Organization";
|
|
avatar_url: string;
|
|
};
|
|
private: boolean;
|
|
html_url: string;
|
|
clone_url: string;
|
|
description: string | null;
|
|
fork: boolean;
|
|
archived: boolean;
|
|
disabled: boolean;
|
|
visibility: "public" | "private";
|
|
default_branch: string;
|
|
language: string | null;
|
|
size: number;
|
|
has_issues: boolean;
|
|
has_wiki: boolean;
|
|
created_at: string;
|
|
updated_at: string;
|
|
pushed_at: string;
|
|
}
|
|
|
|
interface FakeOrg {
|
|
login: string;
|
|
id: number;
|
|
avatar_url: string;
|
|
description: string;
|
|
public_repos: number;
|
|
total_private_repos: number;
|
|
}
|
|
|
|
interface FakeLabel {
|
|
id: number;
|
|
name: string;
|
|
color: string;
|
|
description: string;
|
|
}
|
|
|
|
interface FakeIssue {
|
|
id: number;
|
|
number: number;
|
|
title: string;
|
|
body: string;
|
|
state: "open" | "closed";
|
|
labels: FakeLabel[];
|
|
user: { login: string; id: number };
|
|
assignees: { login: string; id: number }[];
|
|
comments: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface FakePullRequest {
|
|
id: number;
|
|
number: number;
|
|
title: string;
|
|
body: string;
|
|
state: "open" | "closed" | "merged";
|
|
merged: boolean;
|
|
merge_commit_sha: string | null;
|
|
head: { ref: string; sha: string };
|
|
base: { ref: string; sha: string };
|
|
user: { login: string; id: number };
|
|
labels: FakeLabel[];
|
|
commits: number;
|
|
additions: number;
|
|
deletions: number;
|
|
changed_files: number;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface FakeRelease {
|
|
id: number;
|
|
tag_name: string;
|
|
name: string;
|
|
body: string;
|
|
draft: boolean;
|
|
prerelease: boolean;
|
|
created_at: string;
|
|
published_at: string;
|
|
assets: {
|
|
id: number;
|
|
name: string;
|
|
size: number;
|
|
browser_download_url: string;
|
|
}[];
|
|
}
|
|
|
|
interface FakeBranch {
|
|
name: string;
|
|
commit: { sha: string; url: string };
|
|
protected: boolean;
|
|
}
|
|
|
|
interface FakeMilestone {
|
|
id: number;
|
|
number: number;
|
|
title: string;
|
|
description: string;
|
|
state: "open" | "closed";
|
|
due_on: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
interface FakeRepoData {
|
|
repo: FakeRepo;
|
|
branches: FakeBranch[];
|
|
issues: FakeIssue[];
|
|
pullRequests: FakePullRequest[];
|
|
releases: FakeRelease[];
|
|
labels: FakeLabel[];
|
|
milestones: FakeMilestone[];
|
|
}
|
|
|
|
interface Store {
|
|
user: FakeUser;
|
|
repos: Map<string, FakeRepoData>; // keyed by "owner/name"
|
|
starredRepoKeys: Set<string>;
|
|
orgs: Map<string, FakeOrg>;
|
|
orgRepoKeys: Map<string, string[]>; // org login -> repo keys
|
|
}
|
|
|
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
|
|
let nextId = 1000;
|
|
function genId(): number {
|
|
return nextId++;
|
|
}
|
|
|
|
function now(): string {
|
|
return new Date().toISOString();
|
|
}
|
|
|
|
function makeRepo(
|
|
overrides: Partial<FakeRepo> & { name: string; owner_login: string },
|
|
): FakeRepo {
|
|
const ownerLogin = overrides.owner_login;
|
|
const ownerType = overrides.owner?.type ?? "User";
|
|
const ts = now();
|
|
return {
|
|
id: overrides.id ?? genId(),
|
|
name: overrides.name,
|
|
full_name: `${ownerLogin}/${overrides.name}`,
|
|
owner: {
|
|
login: ownerLogin,
|
|
id: overrides.owner?.id ?? genId(),
|
|
type: ownerType,
|
|
avatar_url:
|
|
overrides.owner?.avatar_url ??
|
|
`https://fake-github.test/avatars/${ownerLogin}`,
|
|
},
|
|
private: overrides.private ?? false,
|
|
html_url: `${HTML_BASE_URL}/${ownerLogin}/${overrides.name}`,
|
|
clone_url:
|
|
overrides.clone_url ??
|
|
`${GIT_CLONE_BASE_URL}/${ownerLogin}/${overrides.name}.git`,
|
|
description: overrides.description ?? `Fake repo ${overrides.name}`,
|
|
fork: overrides.fork ?? false,
|
|
archived: overrides.archived ?? false,
|
|
disabled: overrides.disabled ?? false,
|
|
visibility: overrides.visibility ?? "public",
|
|
default_branch: overrides.default_branch ?? "main",
|
|
language: overrides.language ?? "TypeScript",
|
|
size: overrides.size ?? 128,
|
|
has_issues: overrides.has_issues ?? true,
|
|
has_wiki: overrides.has_wiki ?? true,
|
|
created_at: overrides.created_at ?? ts,
|
|
updated_at: overrides.updated_at ?? ts,
|
|
pushed_at: overrides.pushed_at ?? ts,
|
|
};
|
|
}
|
|
|
|
function makeRepoData(repo: FakeRepo): FakeRepoData {
|
|
const sha = "a".repeat(40);
|
|
return {
|
|
repo,
|
|
branches: [
|
|
{ name: repo.default_branch, commit: { sha, url: "" }, protected: false },
|
|
],
|
|
issues: [],
|
|
pullRequests: [],
|
|
releases: [],
|
|
labels: [
|
|
{
|
|
id: genId(),
|
|
name: "bug",
|
|
color: "d73a4a",
|
|
description: "Something isn't working",
|
|
},
|
|
{
|
|
id: genId(),
|
|
name: "enhancement",
|
|
color: "a2eeef",
|
|
description: "New feature",
|
|
},
|
|
],
|
|
milestones: [],
|
|
};
|
|
}
|
|
|
|
function defaultStore(): Store {
|
|
const user: FakeUser = {
|
|
login: "e2e-test-user",
|
|
id: 1,
|
|
avatar_url: "https://fake-github.test/avatars/e2e-test-user",
|
|
type: "User",
|
|
name: "E2E Test User",
|
|
email: "e2e@test.local",
|
|
};
|
|
|
|
const repos = new Map<string, FakeRepoData>();
|
|
const starredRepoKeys = new Set<string>();
|
|
const orgs = new Map<string, FakeOrg>();
|
|
const orgRepoKeys = new Map<string, string[]>();
|
|
|
|
// Create a few personal repos
|
|
for (const repoName of ["my-project", "dotfiles", "notes"]) {
|
|
const repo = makeRepo({ name: repoName, owner_login: user.login });
|
|
repos.set(`${user.login}/${repoName}`, makeRepoData(repo));
|
|
}
|
|
|
|
// Add an issue and PR to my-project
|
|
const myProject = repos.get(`${user.login}/my-project`)!;
|
|
myProject.issues.push({
|
|
id: genId(),
|
|
number: 1,
|
|
title: "Initial issue for testing",
|
|
body: "This is a test issue created by the fake GitHub server.",
|
|
state: "open",
|
|
labels: [myProject.labels[0]],
|
|
user: { login: user.login, id: user.id },
|
|
assignees: [{ login: user.login, id: user.id }],
|
|
comments: 0,
|
|
created_at: now(),
|
|
updated_at: now(),
|
|
});
|
|
myProject.pullRequests.push({
|
|
id: genId(),
|
|
number: 2,
|
|
title: "Add README",
|
|
body: "Adding a README file.\n\nCloses #1",
|
|
state: "open",
|
|
merged: false,
|
|
merge_commit_sha: null,
|
|
head: { ref: "add-readme", sha: "b".repeat(40) },
|
|
base: { ref: "main", sha: "a".repeat(40) },
|
|
user: { login: user.login, id: user.id },
|
|
labels: [],
|
|
commits: 1,
|
|
additions: 10,
|
|
deletions: 0,
|
|
changed_files: 1,
|
|
created_at: now(),
|
|
updated_at: now(),
|
|
});
|
|
myProject.releases.push({
|
|
id: genId(),
|
|
tag_name: "v1.0.0",
|
|
name: "v1.0.0",
|
|
body: "Initial release",
|
|
draft: false,
|
|
prerelease: false,
|
|
created_at: now(),
|
|
published_at: now(),
|
|
assets: [],
|
|
});
|
|
myProject.milestones.push({
|
|
id: genId(),
|
|
number: 1,
|
|
title: "v1.0",
|
|
description: "First milestone",
|
|
state: "open",
|
|
due_on: null,
|
|
created_at: now(),
|
|
updated_at: now(),
|
|
});
|
|
|
|
// Create a starred repo from another user
|
|
const starredRepo = makeRepo({
|
|
name: "popular-lib",
|
|
owner_login: "other-user",
|
|
description: "A popular library that we starred",
|
|
});
|
|
const starredKey = "other-user/popular-lib";
|
|
repos.set(starredKey, makeRepoData(starredRepo));
|
|
starredRepoKeys.add(starredKey);
|
|
|
|
// Create an organization with a repo
|
|
const orgLogin = "test-org";
|
|
orgs.set(orgLogin, {
|
|
login: orgLogin,
|
|
id: genId(),
|
|
avatar_url: `https://fake-github.test/avatars/${orgLogin}`,
|
|
description: "A test organization",
|
|
public_repos: 1,
|
|
total_private_repos: 0,
|
|
});
|
|
const orgRepo = makeRepo({
|
|
name: "org-tool",
|
|
owner_login: orgLogin,
|
|
owner: {
|
|
login: orgLogin,
|
|
id: genId(),
|
|
type: "Organization",
|
|
avatar_url: "",
|
|
},
|
|
});
|
|
const orgRepoKey = `${orgLogin}/org-tool`;
|
|
repos.set(orgRepoKey, makeRepoData(orgRepo));
|
|
orgRepoKeys.set(orgLogin, [orgRepoKey]);
|
|
|
|
return { user, repos, starredRepoKeys, orgs, orgRepoKeys };
|
|
}
|
|
|
|
// ─── Routing ─────────────────────────────────────────────────────────────────
|
|
|
|
let store: Store = defaultStore();
|
|
|
|
type RouteHandler = (
|
|
params: Record<string, string>,
|
|
query: URLSearchParams,
|
|
body: any,
|
|
) => { status: number; body: any; headers?: Record<string, string> };
|
|
|
|
interface Route {
|
|
method: string;
|
|
pattern: RegExp;
|
|
paramNames: string[];
|
|
handler: RouteHandler;
|
|
}
|
|
|
|
function route(method: string, path: string, handler: RouteHandler): Route {
|
|
// Convert :param segments to named capture groups
|
|
const paramNames: string[] = [];
|
|
const patternStr = path.replace(/:([a-zA-Z_]+)/g, (_m, name) => {
|
|
paramNames.push(name);
|
|
return "([^/]+)";
|
|
});
|
|
return {
|
|
method,
|
|
pattern: new RegExp(`^${patternStr}$`),
|
|
paramNames,
|
|
handler,
|
|
};
|
|
}
|
|
|
|
function paginate<T>(items: T[], query: URLSearchParams): T[] {
|
|
const page = parseInt(query.get("page") || "1", 10);
|
|
const perPage = Math.min(parseInt(query.get("per_page") || "30", 10), 100);
|
|
const start = (page - 1) * perPage;
|
|
return items.slice(start, start + perPage);
|
|
}
|
|
|
|
function rateLimitHeaders(): Record<string, string> {
|
|
const resetTime = Math.floor(Date.now() / 1000) + 3600;
|
|
return {
|
|
"x-ratelimit-limit": "5000",
|
|
"x-ratelimit-remaining": "4999",
|
|
"x-ratelimit-used": "1",
|
|
"x-ratelimit-reset": resetTime.toString(),
|
|
};
|
|
}
|
|
|
|
const routes: Route[] = [
|
|
// ── Authenticated user ──────────────────────────────────────────
|
|
route("GET", "/user", () => ({
|
|
status: 200,
|
|
body: store.user,
|
|
headers: rateLimitHeaders(),
|
|
})),
|
|
|
|
// ── User repos ──────────────────────────────────────────────────
|
|
route("GET", "/user/repos", (_p, query) => {
|
|
const userRepos = Array.from(store.repos.values())
|
|
.filter((rd) => rd.repo.owner.login === store.user.login)
|
|
.map((rd) => rd.repo);
|
|
return {
|
|
status: 200,
|
|
body: paginate(userRepos, query),
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Starred repos ───────────────────────────────────────────────
|
|
route("GET", "/user/starred", (_p, query) => {
|
|
const starred = Array.from(store.starredRepoKeys)
|
|
.map((key) => store.repos.get(key)?.repo)
|
|
.filter(Boolean);
|
|
return {
|
|
status: 200,
|
|
body: paginate(starred as FakeRepo[], query),
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── User organizations ──────────────────────────────────────────
|
|
route("GET", "/user/orgs", (_p, query) => {
|
|
const orgList = Array.from(store.orgs.values()).map((o) => ({
|
|
login: o.login,
|
|
id: o.id,
|
|
avatar_url: o.avatar_url,
|
|
description: o.description,
|
|
}));
|
|
return {
|
|
status: 200,
|
|
body: paginate(orgList, query),
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Org membership ──────────────────────────────────────────────
|
|
route("GET", "/user/memberships/orgs/:org", (params) => {
|
|
const org = store.orgs.get(params.org);
|
|
if (!org) return { status: 404, body: { message: "Not Found" } };
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
url: `https://fake-github.test/user/memberships/orgs/${params.org}`,
|
|
state: "active",
|
|
role: "admin",
|
|
organization: org,
|
|
user: store.user,
|
|
},
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Single repo ─────────────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo", (params) => {
|
|
const key = `${params.owner}/${params.repo}`;
|
|
const rd = store.repos.get(key);
|
|
if (!rd) return { status: 404, body: { message: "Not Found" } };
|
|
return { status: 200, body: rd.repo, headers: rateLimitHeaders() };
|
|
}),
|
|
|
|
// ── Repo branches ───────────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/branches", (params, query) => {
|
|
const key = `${params.owner}/${params.repo}`;
|
|
const rd = store.repos.get(key);
|
|
if (!rd) return { status: 404, body: { message: "Not Found" } };
|
|
return {
|
|
status: 200,
|
|
body: paginate(rd.branches, query),
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Git refs ────────────────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/git/refs", (params) => {
|
|
const key = `${params.owner}/${params.repo}`;
|
|
const rd = store.repos.get(key);
|
|
if (!rd) return { status: 404, body: { message: "Not Found" } };
|
|
const refs = rd.branches.map((b) => ({
|
|
ref: `refs/heads/${b.name}`,
|
|
node_id: "",
|
|
url: "",
|
|
object: { sha: b.commit.sha, type: "commit", url: "" },
|
|
}));
|
|
return { status: 200, body: refs, headers: rateLimitHeaders() };
|
|
}),
|
|
|
|
// ── Issues ──────────────────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/issues", (params, query) => {
|
|
const key = `${params.owner}/${params.repo}`;
|
|
const rd = store.repos.get(key);
|
|
if (!rd) return { status: 404, body: { message: "Not Found" } };
|
|
// GitHub's issues endpoint also returns PRs; we filter them out
|
|
// unless explicitly requested (the app uses separate endpoints)
|
|
return {
|
|
status: 200,
|
|
body: paginate(rd.issues, query),
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Issue comments ──────────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/issues/:issue_number/comments", () => {
|
|
// Return empty comments for simplicity
|
|
return { status: 200, body: [], headers: rateLimitHeaders() };
|
|
}),
|
|
|
|
// ── Pull requests ───────────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/pulls", (params, query) => {
|
|
const key = `${params.owner}/${params.repo}`;
|
|
const rd = store.repos.get(key);
|
|
if (!rd) return { status: 404, body: { message: "Not Found" } };
|
|
return {
|
|
status: 200,
|
|
body: paginate(rd.pullRequests, query),
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Single pull request detail ──────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/pulls/:pull_number", (params) => {
|
|
const key = `${params.owner}/${params.repo}`;
|
|
const rd = store.repos.get(key);
|
|
if (!rd) return { status: 404, body: { message: "Not Found" } };
|
|
const pr = rd.pullRequests.find(
|
|
(p) => p.number === parseInt(params.pull_number, 10),
|
|
);
|
|
if (!pr) return { status: 404, body: { message: "Not Found" } };
|
|
return { status: 200, body: pr, headers: rateLimitHeaders() };
|
|
}),
|
|
|
|
// ── Pull request commits ────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/pulls/:pull_number/commits", () => {
|
|
return {
|
|
status: 200,
|
|
body: [
|
|
{
|
|
sha: "c".repeat(40),
|
|
commit: {
|
|
message: "test commit",
|
|
author: { name: "Test", date: now() },
|
|
},
|
|
author: { login: "e2e-test-user" },
|
|
},
|
|
],
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Pull request files ──────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/pulls/:pull_number/files", () => {
|
|
return {
|
|
status: 200,
|
|
body: [
|
|
{
|
|
sha: "d".repeat(40),
|
|
filename: "README.md",
|
|
status: "added",
|
|
additions: 10,
|
|
deletions: 0,
|
|
changes: 10,
|
|
},
|
|
],
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Releases ────────────────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/releases", (params, query) => {
|
|
const key = `${params.owner}/${params.repo}`;
|
|
const rd = store.repos.get(key);
|
|
if (!rd) return { status: 404, body: { message: "Not Found" } };
|
|
return {
|
|
status: 200,
|
|
body: paginate(rd.releases, query),
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Release assets ──────────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/releases/:release_id/assets", () => {
|
|
return { status: 200, body: [], headers: rateLimitHeaders() };
|
|
}),
|
|
|
|
// ── Labels ──────────────────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/labels", (params, query) => {
|
|
const key = `${params.owner}/${params.repo}`;
|
|
const rd = store.repos.get(key);
|
|
if (!rd) return { status: 404, body: { message: "Not Found" } };
|
|
return {
|
|
status: 200,
|
|
body: paginate(rd.labels, query),
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Milestones ──────────────────────────────────────────────────
|
|
route("GET", "/repos/:owner/:repo/milestones", (params, query) => {
|
|
const key = `${params.owner}/${params.repo}`;
|
|
const rd = store.repos.get(key);
|
|
if (!rd) return { status: 404, body: { message: "Not Found" } };
|
|
return {
|
|
status: 200,
|
|
body: paginate(rd.milestones, query),
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Organization details ────────────────────────────────────────
|
|
route("GET", "/orgs/:org", (params) => {
|
|
const org = store.orgs.get(params.org);
|
|
if (!org) return { status: 404, body: { message: "Not Found" } };
|
|
return { status: 200, body: org, headers: rateLimitHeaders() };
|
|
}),
|
|
|
|
// ── Organization repos ──────────────────────────────────────────
|
|
route("GET", "/orgs/:org/repos", (params, query) => {
|
|
const keys = store.orgRepoKeys.get(params.org) ?? [];
|
|
const orgRepos = keys.map((k) => store.repos.get(k)?.repo).filter(Boolean);
|
|
return {
|
|
status: 200,
|
|
body: paginate(orgRepos as FakeRepo[], query),
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ── Rate limit ──────────────────────────────────────────────────
|
|
route("GET", "/rate_limit", () => {
|
|
const resetTime = Math.floor(Date.now() / 1000) + 3600;
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
resources: {
|
|
core: { limit: 5000, remaining: 4999, reset: resetTime, used: 1 },
|
|
search: { limit: 30, remaining: 30, reset: resetTime, used: 0 },
|
|
},
|
|
rate: { limit: 5000, remaining: 4999, reset: resetTime, used: 1 },
|
|
},
|
|
headers: rateLimitHeaders(),
|
|
};
|
|
}),
|
|
|
|
// ─── Management API ─────────────────────────────────────────────
|
|
route("GET", "/___mgmt/health", () => ({
|
|
status: 200,
|
|
body: {
|
|
status: "ok",
|
|
repos: store.repos.size,
|
|
orgs: store.orgs.size,
|
|
starredCount: store.starredRepoKeys.size,
|
|
gitCloneBaseUrl: GIT_CLONE_BASE_URL,
|
|
},
|
|
})),
|
|
|
|
// Set the git clone base URL at runtime (for when the git-server starts later)
|
|
route("POST", "/___mgmt/set-clone-url", (_p, _q, body) => {
|
|
if (!body || !body.url) {
|
|
return { status: 400, body: { message: "url is required" } };
|
|
}
|
|
const oldUrl = GIT_CLONE_BASE_URL;
|
|
GIT_CLONE_BASE_URL = body.url.replace(/\/$/, "");
|
|
|
|
// Update clone_url on all existing repos
|
|
for (const [key, rd] of store.repos.entries()) {
|
|
const owner = rd.repo.owner.login;
|
|
const name = rd.repo.name;
|
|
rd.repo.clone_url = `${GIT_CLONE_BASE_URL}/${owner}/${name}.git`;
|
|
}
|
|
|
|
console.log(
|
|
`[FakeGitHub] Clone base URL changed: ${oldUrl} → ${GIT_CLONE_BASE_URL}`,
|
|
);
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
message: "Clone URL base updated",
|
|
oldUrl,
|
|
newUrl: GIT_CLONE_BASE_URL,
|
|
reposUpdated: store.repos.size,
|
|
},
|
|
};
|
|
}),
|
|
|
|
route("POST", "/___mgmt/reset", () => {
|
|
nextId = 1000;
|
|
store = defaultStore();
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
message: "Store reset to defaults",
|
|
gitCloneBaseUrl: GIT_CLONE_BASE_URL,
|
|
},
|
|
};
|
|
}),
|
|
|
|
route("POST", "/___mgmt/add-repo", (_p, _q, body) => {
|
|
if (!body || !body.name || !body.owner_login) {
|
|
return {
|
|
status: 400,
|
|
body: { message: "name and owner_login required" },
|
|
};
|
|
}
|
|
const repo = makeRepo(body);
|
|
const key = repo.full_name;
|
|
const repoData = makeRepoData(repo);
|
|
|
|
// Merge in optional pre-populated data
|
|
if (body.issues && Array.isArray(body.issues)) {
|
|
repoData.issues = body.issues;
|
|
}
|
|
if (body.pullRequests && Array.isArray(body.pullRequests)) {
|
|
repoData.pullRequests = body.pullRequests;
|
|
}
|
|
if (body.releases && Array.isArray(body.releases)) {
|
|
repoData.releases = body.releases;
|
|
}
|
|
if (body.labels && Array.isArray(body.labels)) {
|
|
repoData.labels = body.labels;
|
|
}
|
|
if (body.milestones && Array.isArray(body.milestones)) {
|
|
repoData.milestones = body.milestones;
|
|
}
|
|
if (body.branches && Array.isArray(body.branches)) {
|
|
repoData.branches = body.branches;
|
|
}
|
|
|
|
store.repos.set(key, repoData);
|
|
|
|
if (body.starred) {
|
|
store.starredRepoKeys.add(key);
|
|
}
|
|
|
|
// If the owner is an org, track the repo under that org
|
|
if (repo.owner.type === "Organization") {
|
|
const orgKeys = store.orgRepoKeys.get(repo.owner.login) ?? [];
|
|
orgKeys.push(key);
|
|
store.orgRepoKeys.set(repo.owner.login, orgKeys);
|
|
}
|
|
|
|
return { status: 201, body: { message: "Repo added", key } };
|
|
}),
|
|
|
|
route("POST", "/___mgmt/add-org", (_p, _q, body) => {
|
|
if (!body || !body.login) {
|
|
return { status: 400, body: { message: "login required" } };
|
|
}
|
|
const org: FakeOrg = {
|
|
login: body.login,
|
|
id: body.id ?? genId(),
|
|
avatar_url:
|
|
body.avatar_url ?? `https://fake-github.test/avatars/${body.login}`,
|
|
description: body.description ?? "",
|
|
public_repos: body.public_repos ?? 0,
|
|
total_private_repos: body.total_private_repos ?? 0,
|
|
};
|
|
store.orgs.set(org.login, org);
|
|
if (!store.orgRepoKeys.has(org.login)) {
|
|
store.orgRepoKeys.set(org.login, []);
|
|
}
|
|
return { status: 201, body: { message: "Org added", login: org.login } };
|
|
}),
|
|
|
|
route("POST", "/___mgmt/seed", (_p, _q, body) => {
|
|
if (!body) {
|
|
return { status: 400, body: { message: "Body required" } };
|
|
}
|
|
nextId = 1000;
|
|
store = defaultStore();
|
|
|
|
// Override user if provided
|
|
if (body.user) {
|
|
store.user = { ...store.user, ...body.user };
|
|
}
|
|
|
|
// Clear default repos if custom repos are provided
|
|
if (body.repos && Array.isArray(body.repos)) {
|
|
store.repos.clear();
|
|
store.starredRepoKeys.clear();
|
|
for (const r of body.repos) {
|
|
const repo = makeRepo(r);
|
|
const rd = makeRepoData(repo);
|
|
if (r.issues) rd.issues = r.issues;
|
|
if (r.pullRequests) rd.pullRequests = r.pullRequests;
|
|
if (r.releases) rd.releases = r.releases;
|
|
if (r.labels) rd.labels = r.labels;
|
|
if (r.milestones) rd.milestones = r.milestones;
|
|
if (r.branches) rd.branches = r.branches;
|
|
store.repos.set(repo.full_name, rd);
|
|
if (r.starred) store.starredRepoKeys.add(repo.full_name);
|
|
}
|
|
}
|
|
|
|
// Clear/set orgs if provided
|
|
if (body.orgs && Array.isArray(body.orgs)) {
|
|
store.orgs.clear();
|
|
store.orgRepoKeys.clear();
|
|
for (const o of body.orgs) {
|
|
const org: FakeOrg = {
|
|
login: o.login,
|
|
id: o.id ?? genId(),
|
|
avatar_url: o.avatar_url ?? "",
|
|
description: o.description ?? "",
|
|
public_repos: o.public_repos ?? 0,
|
|
total_private_repos: o.total_private_repos ?? 0,
|
|
};
|
|
store.orgs.set(org.login, org);
|
|
store.orgRepoKeys.set(org.login, []);
|
|
}
|
|
}
|
|
|
|
return {
|
|
status: 200,
|
|
body: {
|
|
message: "Store seeded",
|
|
repos: store.repos.size,
|
|
orgs: store.orgs.size,
|
|
},
|
|
};
|
|
}),
|
|
];
|
|
|
|
// ─── Server ──────────────────────────────────────────────────────────────────
|
|
|
|
function matchRoute(
|
|
method: string,
|
|
pathname: string,
|
|
): { route: Route; params: Record<string, string> } | null {
|
|
// Try matching the pathname directly first
|
|
for (const r of routes) {
|
|
if (r.method !== method) continue;
|
|
const match = pathname.match(r.pattern);
|
|
if (match) {
|
|
const params: Record<string, string> = {};
|
|
r.paramNames.forEach((name, i) => {
|
|
params[name] = decodeURIComponent(match[i + 1]);
|
|
});
|
|
return { route: r, params };
|
|
}
|
|
}
|
|
|
|
// If no match, try stripping /api/v3 prefix (Octokit adds this for custom baseUrl)
|
|
const apiV3Prefix = "/api/v3";
|
|
if (pathname.startsWith(apiV3Prefix)) {
|
|
const strippedPath = pathname.slice(apiV3Prefix.length) || "/";
|
|
for (const r of routes) {
|
|
if (r.method !== method) continue;
|
|
const match = strippedPath.match(r.pattern);
|
|
if (match) {
|
|
const params: Record<string, string> = {};
|
|
r.paramNames.forEach((name, i) => {
|
|
params[name] = decodeURIComponent(match[i + 1]);
|
|
});
|
|
return { route: r, params };
|
|
}
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
async function readBody(req: http.IncomingMessage): Promise<any> {
|
|
return new Promise((resolve) => {
|
|
const chunks: Buffer[] = [];
|
|
req.on("data", (chunk: Buffer) => chunks.push(chunk));
|
|
req.on("end", () => {
|
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
if (!raw) return resolve(null);
|
|
try {
|
|
resolve(JSON.parse(raw));
|
|
} catch {
|
|
resolve(raw);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
const PORT = parseInt(
|
|
process.env.PORT || process.env.FAKE_GITHUB_PORT || "4580",
|
|
10,
|
|
);
|
|
|
|
const server = http.createServer(async (req, res) => {
|
|
const url = new URL(req.url || "/", `http://localhost:${PORT}`);
|
|
const method = (req.method || "GET").toUpperCase();
|
|
const pathname = url.pathname;
|
|
|
|
// CORS for local development
|
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
res.setHeader(
|
|
"Access-Control-Allow-Methods",
|
|
"GET, POST, PUT, DELETE, PATCH, OPTIONS",
|
|
);
|
|
res.setHeader(
|
|
"Access-Control-Allow-Headers",
|
|
"Content-Type, Authorization, Accept",
|
|
);
|
|
|
|
if (method === "OPTIONS") {
|
|
res.writeHead(204);
|
|
res.end();
|
|
return;
|
|
}
|
|
|
|
const matched = matchRoute(method, pathname);
|
|
|
|
if (!matched) {
|
|
// Log unmatched requests for debugging
|
|
console.warn(`[FakeGitHub] 404 ${method} ${pathname}`);
|
|
res.writeHead(404, { "Content-Type": "application/json" });
|
|
res.end(
|
|
JSON.stringify({
|
|
message: "Not Found",
|
|
documentation_url: "https://docs.github.com/rest",
|
|
}),
|
|
);
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const body =
|
|
method === "POST" || method === "PUT" || method === "PATCH"
|
|
? await readBody(req)
|
|
: null;
|
|
|
|
const result = matched.route.handler(
|
|
matched.params,
|
|
url.searchParams,
|
|
body,
|
|
);
|
|
|
|
const headers: Record<string, string> = {
|
|
"Content-Type": "application/json",
|
|
...(result.headers || {}),
|
|
};
|
|
|
|
// Add link header for pagination (simplified)
|
|
if (Array.isArray(result.body)) {
|
|
const page = parseInt(url.searchParams.get("page") || "1", 10);
|
|
const perPage = parseInt(url.searchParams.get("per_page") || "30", 10);
|
|
// If we returned a full page, indicate there might be more
|
|
if (result.body.length === perPage) {
|
|
headers["link"] =
|
|
`<${url.origin}${pathname}?page=${page + 1}&per_page=${perPage}>; rel="next"`;
|
|
}
|
|
}
|
|
|
|
res.writeHead(result.status, headers);
|
|
res.end(JSON.stringify(result.body));
|
|
} catch (err) {
|
|
console.error(`[FakeGitHub] Error handling ${method} ${pathname}:`, err);
|
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
res.end(JSON.stringify({ message: "Internal Server Error" }));
|
|
}
|
|
});
|
|
|
|
server.listen(PORT, "0.0.0.0", () => {
|
|
console.log(
|
|
`[FakeGitHub] Fake GitHub API server running on http://0.0.0.0:${PORT}`,
|
|
);
|
|
console.log(
|
|
`[FakeGitHub] Management API: POST http://localhost:${PORT}/___mgmt/{seed,add-repo,add-org,reset}`,
|
|
);
|
|
console.log(
|
|
`[FakeGitHub] Health check: GET http://localhost:${PORT}/___mgmt/health`,
|
|
);
|
|
console.log(`[FakeGitHub] Default user: ${store.user.login}`);
|
|
console.log(
|
|
`[FakeGitHub] Default repos: ${Array.from(store.repos.keys()).join(", ")}`,
|
|
);
|
|
console.log(
|
|
`[FakeGitHub] Default starred: ${Array.from(store.starredRepoKeys).join(", ") || "(none)"}`,
|
|
);
|
|
console.log(
|
|
`[FakeGitHub] Default orgs: ${Array.from(store.orgs.keys()).join(", ") || "(none)"}`,
|
|
);
|
|
});
|
|
|
|
// Graceful shutdown
|
|
process.on("SIGTERM", () => {
|
|
console.log("[FakeGitHub] Received SIGTERM, shutting down...");
|
|
server.close(() => process.exit(0));
|
|
});
|
|
process.on("SIGINT", () => {
|
|
console.log("[FakeGitHub] Received SIGINT, shutting down...");
|
|
server.close(() => process.exit(0));
|
|
});
|
|
|
|
export { server, store, defaultStore };
|