Fixes #348 Fixes #274 Fixes #149 - Connect to existing repos - Push to other branches on GitHub besides main - Allows force push (with confirmation) dialog --------- Co-authored-by: graphite-app[bot] <96075541+graphite-app[bot]@users.noreply.github.com>
418 lines
12 KiB
TypeScript
418 lines
12 KiB
TypeScript
import { Request, Response } from "express";
|
|
import * as path from "path";
|
|
import * as fs from "fs";
|
|
import * as os from "os";
|
|
|
|
const gitHttpMiddlewareFactory = require("git-http-mock-server/middleware");
|
|
|
|
// Push event tracking for tests
|
|
interface PushEvent {
|
|
timestamp: Date;
|
|
repo: string;
|
|
branch: string;
|
|
operation: "push" | "create" | "delete";
|
|
commitSha?: string;
|
|
}
|
|
|
|
const pushEvents: PushEvent[] = [];
|
|
|
|
// Mock data for testing
|
|
const mockAccessToken = "fake_access_token_12345";
|
|
const mockDeviceCode = "fake_device_code_12345";
|
|
const mockUserCode = "FAKE-CODE";
|
|
const mockUser = {
|
|
login: "testuser",
|
|
id: 12345,
|
|
email: "testuser@example.com",
|
|
};
|
|
|
|
const mockRepos = [
|
|
{
|
|
id: 1,
|
|
name: "test-repo-1",
|
|
full_name: "testuser/test-repo-1",
|
|
private: false,
|
|
owner: { login: "testuser" },
|
|
default_branch: "main",
|
|
},
|
|
{
|
|
id: 2,
|
|
name: "test-repo-2",
|
|
full_name: "testuser/test-repo-2",
|
|
private: true,
|
|
owner: { login: "testuser" },
|
|
default_branch: "main",
|
|
},
|
|
{
|
|
id: 3,
|
|
name: "existing-app",
|
|
full_name: "testuser/existing-app",
|
|
private: false,
|
|
owner: { login: "testuser" },
|
|
default_branch: "main",
|
|
},
|
|
];
|
|
|
|
const mockBranches = [
|
|
{ name: "main", commit: { sha: "abc123" } },
|
|
{ name: "develop", commit: { sha: "def456" } },
|
|
{ name: "feature/test", commit: { sha: "ghi789" } },
|
|
];
|
|
|
|
// Store device flow state
|
|
let deviceFlowState = {
|
|
deviceCode: mockDeviceCode,
|
|
userCode: mockUserCode,
|
|
authorized: false,
|
|
pollCount: 0,
|
|
};
|
|
|
|
// GitHub Device Flow - Step 1: Get device code
|
|
export function handleDeviceCode(req: Request, res: Response) {
|
|
console.log("* GitHub Device Code requested");
|
|
|
|
// Reset state for new flow
|
|
deviceFlowState = {
|
|
deviceCode: mockDeviceCode,
|
|
userCode: mockUserCode,
|
|
authorized: false,
|
|
pollCount: 0,
|
|
};
|
|
|
|
res.json({
|
|
device_code: mockDeviceCode,
|
|
user_code: mockUserCode,
|
|
verification_uri: "https://github.com/login/device",
|
|
verification_uri_complete: `https://github.com/login/device?user_code=${mockUserCode}`,
|
|
expires_in: 900,
|
|
interval: 1, // Short interval for testing
|
|
});
|
|
}
|
|
|
|
// GitHub Device Flow - Step 2: Poll for access token
|
|
export function handleAccessToken(req: Request, res: Response) {
|
|
console.log("* GitHub Access Token polling", {
|
|
pollCount: deviceFlowState.pollCount,
|
|
});
|
|
|
|
const { device_code } = req.body;
|
|
|
|
if (device_code !== mockDeviceCode) {
|
|
return res.status(400).json({
|
|
error: "invalid_request",
|
|
error_description: "Invalid device code",
|
|
});
|
|
}
|
|
|
|
deviceFlowState.pollCount++;
|
|
|
|
// Simulate authorization after 3 polls (for testing)
|
|
if (deviceFlowState.pollCount >= 3) {
|
|
deviceFlowState.authorized = true;
|
|
return res.json({
|
|
access_token: mockAccessToken,
|
|
token_type: "bearer",
|
|
scope: "repo,user,workflow",
|
|
});
|
|
}
|
|
|
|
// Return pending status
|
|
res.status(400).json({
|
|
error: "authorization_pending",
|
|
error_description: "The authorization request is still pending",
|
|
});
|
|
}
|
|
|
|
// Get authenticated user info
|
|
export function handleUser(req: Request, res: Response) {
|
|
console.log("* GitHub User info requested");
|
|
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.includes(mockAccessToken)) {
|
|
return res.status(401).json({
|
|
message: "Bad credentials",
|
|
});
|
|
}
|
|
|
|
res.json(mockUser);
|
|
}
|
|
|
|
// Get user emails
|
|
export function handleUserEmails(req: Request, res: Response) {
|
|
console.log("* GitHub User emails requested");
|
|
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.includes(mockAccessToken)) {
|
|
return res.status(401).json({
|
|
message: "Bad credentials",
|
|
});
|
|
}
|
|
|
|
res.json([
|
|
{
|
|
email: "testuser@example.com",
|
|
primary: true,
|
|
verified: true,
|
|
visibility: "public",
|
|
},
|
|
]);
|
|
}
|
|
|
|
// List user repositories
|
|
export function handleUserRepos(req: Request, res: Response) {
|
|
console.log("* GitHub User repos requested");
|
|
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.includes(mockAccessToken)) {
|
|
return res.status(401).json({
|
|
message: "Bad credentials",
|
|
});
|
|
}
|
|
|
|
if (req.method === "GET") {
|
|
// List repos
|
|
res.json(mockRepos);
|
|
} else if (req.method === "POST") {
|
|
// Create repo
|
|
const { name, private: isPrivate } = req.body;
|
|
console.log("* Creating repository:", name);
|
|
|
|
// Check if repo already exists
|
|
const existingRepo = mockRepos.find((repo) => repo.name === name);
|
|
if (existingRepo) {
|
|
return res.status(422).json({
|
|
message: "Repository creation failed.",
|
|
errors: [
|
|
{
|
|
resource: "Repository",
|
|
code: "already_exists",
|
|
field: "name",
|
|
},
|
|
],
|
|
});
|
|
}
|
|
|
|
// Create new repo
|
|
const newRepo = {
|
|
id: mockRepos.length + 1,
|
|
name,
|
|
full_name: `${mockUser.login}/${name}`,
|
|
private: !!isPrivate,
|
|
owner: { login: mockUser.login },
|
|
default_branch: "main",
|
|
};
|
|
|
|
res.status(201).json(newRepo);
|
|
}
|
|
}
|
|
|
|
// Get repository info
|
|
export function handleRepo(req: Request, res: Response) {
|
|
console.log("* GitHub Repo info requested");
|
|
|
|
const { owner, repo } = req.params;
|
|
const authHeader = req.headers.authorization;
|
|
|
|
if (!authHeader || !authHeader.includes(mockAccessToken)) {
|
|
return res.status(401).json({
|
|
message: "Bad credentials",
|
|
});
|
|
}
|
|
|
|
const foundRepo = mockRepos.find((r) => r.full_name === `${owner}/${repo}`);
|
|
|
|
if (!foundRepo) {
|
|
return res.status(404).json({
|
|
message: "Not Found",
|
|
});
|
|
}
|
|
|
|
res.json(foundRepo);
|
|
}
|
|
|
|
// Get repository branches
|
|
export function handleRepoBranches(req: Request, res: Response) {
|
|
console.log("* GitHub Repo branches requested");
|
|
|
|
const { owner, repo } = req.params;
|
|
const authHeader = req.headers.authorization;
|
|
|
|
if (!authHeader || !authHeader.includes(mockAccessToken)) {
|
|
return res.status(401).json({
|
|
message: "Bad credentials",
|
|
});
|
|
}
|
|
|
|
const foundRepo = mockRepos.find((r) => r.full_name === `${owner}/${repo}`);
|
|
|
|
if (!foundRepo) {
|
|
return res.status(404).json({
|
|
message: "Not Found",
|
|
});
|
|
}
|
|
|
|
res.json(mockBranches);
|
|
}
|
|
|
|
// Create repository for organization (not implemented in mock)
|
|
export function handleOrgRepos(req: Request, res: Response) {
|
|
console.log("* GitHub Org repos requested");
|
|
|
|
const authHeader = req.headers.authorization;
|
|
if (!authHeader || !authHeader.includes(mockAccessToken)) {
|
|
return res.status(401).json({
|
|
message: "Bad credentials",
|
|
});
|
|
}
|
|
|
|
// For simplicity, just redirect to user repos for mock
|
|
handleUserRepos(req, res);
|
|
}
|
|
|
|
// Push event management functions for testing
|
|
export function handleGetPushEvents(req: Request, res: Response) {
|
|
console.log("* Getting push events");
|
|
const { repo } = req.query;
|
|
|
|
const events = repo ? pushEvents.filter((e) => e.repo === repo) : pushEvents;
|
|
|
|
res.json(events);
|
|
}
|
|
|
|
export function handleClearPushEvents(req: Request, res: Response) {
|
|
console.log("* Clearing push events");
|
|
pushEvents.length = 0;
|
|
res.json({ cleared: true, timestamp: new Date() });
|
|
}
|
|
|
|
// Handle Git operations (push, pull, clone, etc.) using git-http-mock-server
|
|
export function handleGitPush(req: Request, res: Response, next?: Function) {
|
|
console.log("* GitHub Git operation requested:", req.method, req.url);
|
|
|
|
// Log request headers to see git operation details
|
|
console.log("* Git Headers:", {
|
|
"git-protocol": req.headers["git-protocol"],
|
|
"content-type": req.headers["content-type"],
|
|
"user-agent": req.headers["user-agent"],
|
|
});
|
|
|
|
// Create a unique temporary directory for this request
|
|
const mockReposRoot = fs.mkdtempSync(
|
|
path.join(
|
|
os.tmpdir(),
|
|
"dyad-git-mock-" + Math.random().toString(36).substring(2, 15),
|
|
),
|
|
);
|
|
console.error(`* Created temporary git repos directory: ${mockReposRoot}`);
|
|
|
|
// Create git middleware instance for this request
|
|
const gitHttpMiddleware = gitHttpMiddlewareFactory({
|
|
root: mockReposRoot,
|
|
route: "/github/git",
|
|
glob: "*.git",
|
|
});
|
|
|
|
// Extract repo name from URL path like /github/git/testuser/test-repo.git
|
|
// The middleware expects the repo name as the basename after the route
|
|
const urlPath = req.url;
|
|
const match = urlPath.match(/\/github\/git\/[^/]+\/([^/.]+)\.git/);
|
|
const repoName = match?.[1];
|
|
|
|
if (repoName) {
|
|
console.log(`* Git operation for repo: ${repoName}`);
|
|
|
|
// Track push events if this is a git-receive-pack (push) operation
|
|
if (req.url.includes("/git-receive-pack") && req.method === "POST") {
|
|
console.log("* Git PUSH operation detected for repo:", repoName);
|
|
|
|
// Collect request body to parse git protocol
|
|
let body = "";
|
|
req.on("data", (chunk) => {
|
|
body += chunk.toString();
|
|
});
|
|
req.on("end", () => {
|
|
try {
|
|
// Parse git pack protocol for branch refs
|
|
// Git protocol sends refs in format: "old-sha new-sha refs/heads/branch-name"
|
|
const lines = body.split("\n");
|
|
lines.forEach((line) => {
|
|
// Look for lines containing refs/heads/
|
|
const refMatch = line.match(
|
|
/([0-9a-f]{40})\s+([0-9a-f]{40})\s+refs\/heads\/([^\s\u0000]+)/,
|
|
);
|
|
if (refMatch) {
|
|
const [, oldSha, newSha, branchName] = refMatch;
|
|
const isDelete = newSha === "0".repeat(40);
|
|
const isCreate = oldSha === "0".repeat(40);
|
|
|
|
let operation: "push" | "create" | "delete" = "push";
|
|
if (isDelete) operation = "delete";
|
|
else if (isCreate) operation = "create";
|
|
|
|
pushEvents.push({
|
|
timestamp: new Date(),
|
|
repo: repoName,
|
|
branch: branchName,
|
|
operation,
|
|
commitSha: isDelete ? oldSha : newSha,
|
|
});
|
|
|
|
console.log(
|
|
`* Recorded ${operation} to ${repoName}/${branchName}, commit: ${isDelete ? oldSha : newSha}`,
|
|
);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error("* Error parsing git protocol:", error);
|
|
}
|
|
});
|
|
}
|
|
|
|
// Ensure the bare git repository exists for this repo
|
|
const bareRepoPath = path.join(mockReposRoot, `${repoName}.git`);
|
|
console.log(`* Creating bare git repository at: ${bareRepoPath}`);
|
|
try {
|
|
fs.mkdirSync(bareRepoPath, { recursive: true });
|
|
// Initialize as bare repository
|
|
const { execSync } = require("child_process");
|
|
execSync(`git init --bare`, { cwd: bareRepoPath });
|
|
console.log(
|
|
`* Successfully created bare git repository: ${repoName}.git`,
|
|
);
|
|
} catch (error) {
|
|
console.error(`* Failed to create bare git repository:`, error);
|
|
return res.status(500).json({
|
|
message: "Failed to initialize git repository",
|
|
error: error instanceof Error ? error.message : String(error),
|
|
});
|
|
}
|
|
|
|
// Rewrite the URL to match what the middleware expects
|
|
// Change /github/git/testuser/test-repo.git/... to /github/git/test-repo.git/...
|
|
const rewrittenUrl = req.url.replace(
|
|
/\/github\/git\/[^/]+\//,
|
|
"/github/git/",
|
|
);
|
|
req.url = rewrittenUrl;
|
|
console.log(`* Rewritten URL from ${urlPath} to ${rewrittenUrl}`);
|
|
}
|
|
|
|
// Use git-http-mock-server middleware to handle the actual git operations
|
|
gitHttpMiddleware(
|
|
req,
|
|
res,
|
|
next ||
|
|
(() => {
|
|
// Fallback if middleware doesn't handle the request
|
|
console.log(
|
|
`* Git middleware did not handle request: ${req.method} ${req.url}`,
|
|
);
|
|
res.status(404).json({
|
|
message: "Git operation not supported",
|
|
url: req.url,
|
|
method: req.method,
|
|
});
|
|
}),
|
|
);
|
|
}
|