Add GitHub Sync - Automated repository synchronization tool

Complete implementation of automated GitHub repository synchronization:
- Webhook-based auto-sync from GitHub
- Multi-repository support with branch selection
- Web dashboard for management
- Manual sync and rollback functionality
- Comprehensive logging and monitoring

Located in /gitpusher/ subdirectory as standalone application.
This commit is contained in:
Claude
2025-12-06 09:53:32 +00:00
parent 1456995462
commit 45b15c7fd5
18 changed files with 3818 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
# Deny all access to source directory
Require all denied
# Alternative for older Apache versions
<IfModule !mod_authz_core.c>
Order deny,allow
Deny from all
</IfModule>
+199
View File
@@ -0,0 +1,199 @@
<?php
/**
* ConfigManager - Handles reading and writing JSON configuration files
*/
class ConfigManager {
private $dataDir;
public function __construct($dataDir = '/gitpusher/data') {
$this->dataDir = $dataDir;
$this->ensureDataDirExists();
}
/**
* Ensure data directory exists with proper permissions
*/
private function ensureDataDirExists() {
if (!file_exists($this->dataDir)) {
mkdir($this->dataDir, 0755, true);
}
}
/**
* Read JSON file
*/
public function read($filename) {
$filepath = $this->dataDir . '/' . $filename;
if (!file_exists($filepath)) {
return [];
}
$content = file_get_contents($filepath);
$data = json_decode($content, true);
if (json_last_error() !== JSON_ERROR_NONE) {
error_log("JSON decode error in $filename: " . json_last_error_msg());
return [];
}
return $data;
}
/**
* Write JSON file
*/
public function write($filename, $data) {
$filepath = $this->dataDir . '/' . $filename;
$json = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES);
if ($json === false) {
error_log("JSON encode error: " . json_last_error_msg());
return false;
}
$result = file_put_contents($filepath, $json);
if ($result === false) {
error_log("Failed to write file: $filepath");
return false;
}
// Set appropriate permissions (readable only by owner)
chmod($filepath, 0600);
return true;
}
/**
* Get all repositories from config
*/
public function getRepositories() {
$config = $this->read('config.json');
return $config['repositories'] ?? [];
}
/**
* Get repository by ID
*/
public function getRepository($repoId) {
$repos = $this->getRepositories();
foreach ($repos as $repo) {
if ($repo['id'] === $repoId) {
return $repo;
}
}
return null;
}
/**
* Add new repository
*/
public function addRepository($repoData) {
$config = $this->read('config.json');
if (!isset($config['repositories'])) {
$config['repositories'] = [];
}
// Generate unique ID
$repoData['id'] = uniqid('repo_', true);
$repoData['created_at'] = date('Y-m-d H:i:s');
$repoData['status'] = 'pending';
$config['repositories'][] = $repoData;
return $this->write('config.json', $config) ? $repoData['id'] : false;
}
/**
* Update repository
*/
public function updateRepository($repoId, $updates) {
$config = $this->read('config.json');
if (!isset($config['repositories'])) {
return false;
}
foreach ($config['repositories'] as &$repo) {
if ($repo['id'] === $repoId) {
$repo = array_merge($repo, $updates);
$repo['updated_at'] = date('Y-m-d H:i:s');
return $this->write('config.json', $config);
}
}
return false;
}
/**
* Delete repository
*/
public function deleteRepository($repoId) {
$config = $this->read('config.json');
if (!isset($config['repositories'])) {
return false;
}
$config['repositories'] = array_filter($config['repositories'], function($repo) use ($repoId) {
return $repo['id'] !== $repoId;
});
// Re-index array
$config['repositories'] = array_values($config['repositories']);
return $this->write('config.json', $config);
}
/**
* Get GitHub Personal Access Token
*/
public function getGitHubToken() {
$secrets = $this->read('secrets.json');
return $secrets['github_pat'] ?? null;
}
/**
* Set GitHub Personal Access Token
*/
public function setGitHubToken($token) {
$secrets = $this->read('secrets.json');
$secrets['github_pat'] = $token;
return $this->write('secrets.json', $secrets);
}
/**
* Get webhook secret for a repository
*/
public function getWebhookSecret($repoId) {
$secrets = $this->read('secrets.json');
return $secrets['webhook_secrets'][$repoId] ?? null;
}
/**
* Set webhook secret for a repository
*/
public function setWebhookSecret($repoId, $secret) {
$secrets = $this->read('secrets.json');
if (!isset($secrets['webhook_secrets'])) {
$secrets['webhook_secrets'] = [];
}
$secrets['webhook_secrets'][$repoId] = $secret;
return $this->write('secrets.json', $secrets);
}
/**
* Generate secure webhook secret
*/
public function generateWebhookSecret() {
return bin2hex(random_bytes(32));
}
}
+399
View File
@@ -0,0 +1,399 @@
<?php
/**
* GitHandler - Handles all Git operations
*/
class GitHandler {
private $logger;
private $configManager;
public function __construct(Logger $logger, ConfigManager $configManager) {
$this->logger = $logger;
$this->configManager = $configManager;
}
/**
* Execute shell command and return result
*/
private function exec($command, $cwd = null) {
$descriptorspec = [
0 => ['pipe', 'r'], // stdin
1 => ['pipe', 'w'], // stdout
2 => ['pipe', 'w'] // stderr
];
$process = proc_open($command, $descriptorspec, $pipes, $cwd);
if (!is_resource($process)) {
return [
'success' => false,
'output' => '',
'error' => 'Failed to execute command',
'exit_code' => -1
];
}
$stdout = stream_get_contents($pipes[1]);
$stderr = stream_get_contents($pipes[2]);
fclose($pipes[0]);
fclose($pipes[1]);
fclose($pipes[2]);
$exitCode = proc_close($process);
return [
'success' => $exitCode === 0,
'output' => trim($stdout),
'error' => trim($stderr),
'exit_code' => $exitCode
];
}
/**
* Build Git URL with authentication token
*/
private function buildGitUrl($repoUrl, $token) {
// Convert https://github.com/user/repo.git to https://TOKEN@github.com/user/repo.git
$parsed = parse_url($repoUrl);
if (!$parsed || !isset($parsed['host'])) {
return $repoUrl;
}
$scheme = $parsed['scheme'] ?? 'https';
$host = $parsed['host'];
$path = $parsed['path'] ?? '';
return "$scheme://$token@$host$path";
}
/**
* Clone repository
*/
public function cloneRepository($repoId, $repoUrl, $targetPath, $branch = 'main') {
$this->logger->info($repoId, "Starting clone of repository to $targetPath");
// Check if target path already exists
if (file_exists($targetPath)) {
$this->logger->error($repoId, "Target path already exists: $targetPath");
return [
'success' => false,
'message' => 'Target path already exists'
];
}
// Create parent directory if it doesn't exist
$parentDir = dirname($targetPath);
if (!file_exists($parentDir)) {
mkdir($parentDir, 0755, true);
}
// Get GitHub token
$token = $this->configManager->getGitHubToken();
$gitUrl = $token ? $this->buildGitUrl($repoUrl, $token) : $repoUrl;
// Clone repository
$command = sprintf(
'git clone --branch %s %s %s 2>&1',
escapeshellarg($branch),
escapeshellarg($gitUrl),
escapeshellarg($targetPath)
);
$result = $this->exec($command);
if ($result['success']) {
$this->logger->success($repoId, "Repository cloned successfully", [
'path' => $targetPath,
'branch' => $branch
]);
return [
'success' => true,
'message' => 'Repository cloned successfully',
'output' => $result['output']
];
} else {
$this->logger->error($repoId, "Failed to clone repository", [
'error' => $result['error'],
'output' => $result['output']
]);
return [
'success' => false,
'message' => 'Failed to clone repository',
'error' => $result['error']
];
}
}
/**
* Pull latest changes
*/
public function pull($repoId, $targetPath, $branch = 'main') {
$this->logger->info($repoId, "Starting pull for $targetPath");
// Check if path exists
if (!file_exists($targetPath)) {
$this->logger->error($repoId, "Repository path does not exist: $targetPath");
return [
'success' => false,
'message' => 'Repository path does not exist'
];
}
// Check if it's a git repository
if (!file_exists("$targetPath/.git")) {
$this->logger->error($repoId, "Not a git repository: $targetPath");
return [
'success' => false,
'message' => 'Not a git repository'
];
}
// Get current commit before pull
$currentCommit = $this->getCurrentCommit($targetPath);
// Pull changes
$command = sprintf(
'cd %s && git pull origin %s 2>&1',
escapeshellarg($targetPath),
escapeshellarg($branch)
);
$result = $this->exec($command);
// Check for merge conflicts
if (!$result['success'] || strpos($result['output'], 'CONFLICT') !== false) {
$this->logger->warning($repoId, "Merge conflict detected", [
'output' => $result['output'],
'error' => $result['error']
]);
$this->configManager->updateRepository($repoId, ['status' => 'conflict']);
return [
'success' => false,
'message' => 'Merge conflict detected',
'conflict' => true,
'output' => $result['output']
];
}
// Get new commit after pull
$newCommit = $this->getCurrentCommit($targetPath);
// Count changed files
$changedFiles = $this->getChangedFilesBetweenCommits($targetPath, $currentCommit, $newCommit);
if ($result['success']) {
$this->logger->success($repoId, "Pull completed successfully", [
'files_changed' => count($changedFiles),
'old_commit' => substr($currentCommit, 0, 7),
'new_commit' => substr($newCommit, 0, 7)
]);
$this->configManager->updateRepository($repoId, [
'status' => 'synced',
'last_sync' => date('Y-m-d H:i:s'),
'last_commit' => $newCommit
]);
return [
'success' => true,
'message' => 'Pull completed successfully',
'files_changed' => count($changedFiles),
'output' => $result['output']
];
} else {
$this->logger->error($repoId, "Pull failed", [
'error' => $result['error'],
'output' => $result['output']
]);
return [
'success' => false,
'message' => 'Pull failed',
'error' => $result['error']
];
}
}
/**
* Revert to specific commit
*/
public function revert($repoId, $targetPath, $commitHash) {
$this->logger->info($repoId, "Starting revert to commit $commitHash");
if (!file_exists($targetPath)) {
return [
'success' => false,
'message' => 'Repository path does not exist'
];
}
// Create revert commit
$command = sprintf(
'cd %s && git revert --no-edit %s 2>&1',
escapeshellarg($targetPath),
escapeshellarg($commitHash)
);
$result = $this->exec($command);
if ($result['success']) {
$this->logger->success($repoId, "Reverted to commit $commitHash", [
'commit' => $commitHash
]);
$newCommit = $this->getCurrentCommit($targetPath);
$this->configManager->updateRepository($repoId, [
'last_commit' => $newCommit,
'last_sync' => date('Y-m-d H:i:s')
]);
return [
'success' => true,
'message' => 'Revert completed successfully',
'output' => $result['output']
];
} else {
$this->logger->error($repoId, "Revert failed", [
'error' => $result['error']
]);
return [
'success' => false,
'message' => 'Revert failed',
'error' => $result['error']
];
}
}
/**
* Get current commit hash
*/
public function getCurrentCommit($targetPath) {
$result = $this->exec("cd " . escapeshellarg($targetPath) . " && git rev-parse HEAD 2>&1");
return $result['success'] ? trim($result['output']) : null;
}
/**
* Get commit history
*/
public function getCommitHistory($targetPath, $limit = 20) {
$command = sprintf(
'cd %s && git log --pretty=format:"%%H|%%an|%%ae|%%at|%%s" -n %d 2>&1',
escapeshellarg($targetPath),
(int)$limit
);
$result = $this->exec($command);
if (!$result['success']) {
return [];
}
$commits = [];
$lines = explode("\n", $result['output']);
foreach ($lines as $line) {
if (empty($line)) continue;
$parts = explode('|', $line);
if (count($parts) !== 5) continue;
$commits[] = [
'hash' => $parts[0],
'hash_short' => substr($parts[0], 0, 7),
'author_name' => $parts[1],
'author_email' => $parts[2],
'timestamp' => date('Y-m-d H:i:s', (int)$parts[3]),
'message' => $parts[4]
];
}
return $commits;
}
/**
* Get changed files between commits
*/
private function getChangedFilesBetweenCommits($targetPath, $oldCommit, $newCommit) {
if ($oldCommit === $newCommit) {
return [];
}
$command = sprintf(
'cd %s && git diff --name-only %s %s 2>&1',
escapeshellarg($targetPath),
escapeshellarg($oldCommit),
escapeshellarg($newCommit)
);
$result = $this->exec($command);
if (!$result['success']) {
return [];
}
return array_filter(explode("\n", $result['output']));
}
/**
* Get repository status
*/
public function getStatus($targetPath) {
$command = sprintf(
'cd %s && git status --porcelain 2>&1',
escapeshellarg($targetPath)
);
$result = $this->exec($command);
return [
'success' => $result['success'],
'clean' => $result['success'] && empty($result['output']),
'output' => $result['output']
];
}
/**
* Get current branch
*/
public function getCurrentBranch($targetPath) {
$result = $this->exec("cd " . escapeshellarg($targetPath) . " && git branch --show-current 2>&1");
return $result['success'] ? trim($result['output']) : null;
}
/**
* Fetch available branches from remote
*/
public function getRemoteBranches($repoUrl) {
$token = $this->configManager->getGitHubToken();
$gitUrl = $token ? $this->buildGitUrl($repoUrl, $token) : $repoUrl;
$command = sprintf(
'git ls-remote --heads %s 2>&1',
escapeshellarg($gitUrl)
);
$result = $this->exec($command);
if (!$result['success']) {
return [];
}
$branches = [];
$lines = explode("\n", $result['output']);
foreach ($lines as $line) {
if (preg_match('/refs\/heads\/(.+)$/', $line, $matches)) {
$branches[] = $matches[1];
}
}
return $branches;
}
}
+166
View File
@@ -0,0 +1,166 @@
<?php
/**
* Logger - Manages log entries for sync operations
*/
class Logger {
private $configManager;
private $maxLogEntries = 1000; // Keep last 1000 entries
public function __construct(ConfigManager $configManager) {
$this->configManager = $configManager;
}
/**
* Add log entry
*/
public function log($repoId, $type, $message, $details = []) {
$logs = $this->configManager->read('log.json');
if (!isset($logs['entries'])) {
$logs['entries'] = [];
}
$entry = [
'id' => uniqid('log_', true),
'timestamp' => date('Y-m-d H:i:s'),
'repo_id' => $repoId,
'type' => $type, // success, error, warning, info
'message' => $message,
'details' => $details
];
// Add to beginning of array
array_unshift($logs['entries'], $entry);
// Keep only last N entries
if (count($logs['entries']) > $this->maxLogEntries) {
$logs['entries'] = array_slice($logs['entries'], 0, $this->maxLogEntries);
}
$this->configManager->write('log.json', $logs);
// Also log to PHP error log for debugging
error_log("[GitPusher] [$type] $repoId: $message");
return $entry['id'];
}
/**
* Log success
*/
public function success($repoId, $message, $details = []) {
return $this->log($repoId, 'success', $message, $details);
}
/**
* Log error
*/
public function error($repoId, $message, $details = []) {
return $this->log($repoId, 'error', $message, $details);
}
/**
* Log warning
*/
public function warning($repoId, $message, $details = []) {
return $this->log($repoId, 'warning', $message, $details);
}
/**
* Log info
*/
public function info($repoId, $message, $details = []) {
return $this->log($repoId, 'info', $message, $details);
}
/**
* Get all log entries
*/
public function getAll($limit = 100, $offset = 0) {
$logs = $this->configManager->read('log.json');
$entries = $logs['entries'] ?? [];
return array_slice($entries, $offset, $limit);
}
/**
* Get logs for specific repository
*/
public function getByRepository($repoId, $limit = 100) {
$logs = $this->configManager->read('log.json');
$entries = $logs['entries'] ?? [];
$filtered = array_filter($entries, function($entry) use ($repoId) {
return $entry['repo_id'] === $repoId;
});
return array_slice(array_values($filtered), 0, $limit);
}
/**
* Get logs by type
*/
public function getByType($type, $limit = 100) {
$logs = $this->configManager->read('log.json');
$entries = $logs['entries'] ?? [];
$filtered = array_filter($entries, function($entry) use ($type) {
return $entry['type'] === $type;
});
return array_slice(array_values($filtered), 0, $limit);
}
/**
* Clear all logs
*/
public function clear() {
return $this->configManager->write('log.json', ['entries' => []]);
}
/**
* Clear logs for specific repository
*/
public function clearByRepository($repoId) {
$logs = $this->configManager->read('log.json');
$entries = $logs['entries'] ?? [];
$filtered = array_filter($entries, function($entry) use ($repoId) {
return $entry['repo_id'] !== $repoId;
});
$logs['entries'] = array_values($filtered);
return $this->configManager->write('log.json', $logs);
}
/**
* Get statistics
*/
public function getStats() {
$logs = $this->configManager->read('log.json');
$entries = $logs['entries'] ?? [];
$stats = [
'total' => count($entries),
'success' => 0,
'error' => 0,
'warning' => 0,
'info' => 0,
'last_24h' => 0
];
$yesterday = strtotime('-24 hours');
foreach ($entries as $entry) {
$stats[$entry['type']]++;
$timestamp = strtotime($entry['timestamp']);
if ($timestamp >= $yesterday) {
$stats['last_24h']++;
}
}
return $stats;
}
}