baseUrl = rtrim((string) (env('GITEA_BASE_URL') ?: 'https://gitea.services-summit.my.id/api/v1'), '/'); $this->token = (string) env('GITEA_TOKEN'); $this->usersModel = new GitUsersModel(); $this->repositoriesModel = new GitRepositoriesModel(); $this->commitsModel = new GitCommitsModel(); $this->pullRequestsModel = new GitPullRequestsModel(); } public function syncAll(): array { return $this->syncFull(); } public function syncFull(): array { return $this->performSync(); } public function syncIncremental(int $days = 1): array { if ($days < 1) { $days = 1; } $sinceIso = (new \DateTimeImmutable('now')) ->modify('-' . $days . ' days') ->format(DATE_ATOM); return $this->performSync($sinceIso, $days); } private function performSync(?string $sinceIso = null, ?int $days = null): array { if ($this->token === '') { return [ 'success' => false, 'message' => 'GITEA_TOKEN missing in .env', 'stats' => [], ]; } $stats = [ 'mode' => $sinceIso === null ? 'full' : 'incremental', 'since' => $sinceIso, 'users_synced' => 0, 'repositories_synced' => 0, 'commits_synced' => 0, 'pull_requests_synced' => 0, 'errors' => [], ]; $users = $this->fetchPaged('/users/search', [], ['data']); foreach ($users['items'] as $user) { $this->upsertUser($user); $stats['users_synced']++; } $stats['errors'] = array_merge($stats['errors'], $users['errors']); $repositories = $this->fetchPaged('/repos/search', [], ['data']); $stats['errors'] = array_merge($stats['errors'], $repositories['errors']); foreach ($repositories['items'] as $repo) { $localRepoId = $this->upsertRepository($repo); $stats['repositories_synced']++; if ($localRepoId === null) { $stats['errors'][] = 'Skip repo because upsert failed: ' . ($repo['full_name'] ?? 'unknown'); continue; } $owner = $repo['owner']['username'] ?? $repo['owner']['login'] ?? null; $name = $repo['name'] ?? null; if ($owner === null || $name === null) { $stats['errors'][] = 'Skip repo because owner/name missing: ' . ($repo['full_name'] ?? 'unknown'); continue; } $commitQuery = []; if ($sinceIso !== null) { $commitQuery['since'] = $sinceIso; } $commits = $this->fetchPaged("/repos/{$owner}/{$name}/commits", $commitQuery, []); $stats['errors'] = array_merge($stats['errors'], $commits['errors']); foreach ($commits['items'] as $commit) { if ($this->upsertCommit($localRepoId, $commit)) { $stats['commits_synced']++; } } $pullRequests = $this->fetchPaged("/repos/{$owner}/{$name}/pulls", ['state' => 'all', 'sort' => 'recentupdate', 'direction' => 'desc'], []); $stats['errors'] = array_merge($stats['errors'], $pullRequests['errors']); foreach ($pullRequests['items'] as $pullRequest) { if ($sinceIso !== null && !$this->isOnOrAfterSince($pullRequest['updated_at'] ?? null, $sinceIso)) { continue; } if ($this->upsertPullRequest($localRepoId, $pullRequest)) { $stats['pull_requests_synced']++; } } $this->repositoriesModel->update($localRepoId, [ 'last_synced_at' => date('Y-m-d H:i:s'), ]); } $message = $sinceIso === null ? 'Gitea full sync completed' : 'Gitea incremental sync completed (last ' . ($days ?? 1) . ' day)'; return [ 'success' => true, 'message' => $message, 'stats' => $stats, ]; } private function fetchPaged(string $endpoint, array $query = [], array $listKeys = ['data']): array { $page = 1; $items = []; $errors = []; do { $payload = array_merge($query, [ 'limit' => $this->perPage, 'page' => $page, ]); $response = $this->request('GET', $endpoint, $payload); if ($response['success'] === false) { $errors[] = $response['message']; break; } $chunk = $this->extractList($response['data'], $listKeys); if (empty($chunk)) { break; } $items = array_merge($items, $chunk); $page++; } while (count($chunk) >= $this->perPage); return [ 'items' => $items, 'errors' => $errors, ]; } private function extractList($data, array $listKeys): array { if (is_array($data) && array_is_list($data)) { return $data; } if (!is_array($data)) { return []; } foreach ($listKeys as $key) { if (isset($data[$key]) && is_array($data[$key])) { return $data[$key]; } } if (isset($data['items']) && is_array($data['items'])) { return $data['items']; } return []; } private function upsertUser(array $user): ?int { $giteaUserId = isset($user['id']) ? (int) $user['id'] : null; $username = $user['username'] ?? $user['login'] ?? null; if ($giteaUserId === null || $username === null) { return null; } $data = [ 'gitea_user_id' => $giteaUserId, 'username' => $username, 'full_name' => $user['full_name'] ?? null, 'email' => $user['email'] ?? null, 'avatar_url' => $user['avatar_url'] ?? null, 'is_active' => isset($user['active']) ? (int) $user['active'] : 1, 'is_admin' => isset($user['is_admin']) ? (int) $user['is_admin'] : 0, 'last_synced_at' => date('Y-m-d H:i:s'), ]; $existing = $this->usersModel->where('gitea_user_id', $giteaUserId)->first(); if ($existing) { $this->usersModel->update((int) $existing['id'], $data); return (int) $existing['id']; } $this->usersModel->insert($data); return (int) $this->usersModel->getInsertID(); } private function upsertRepository(array $repo): ?int { $giteaRepoId = isset($repo['id']) ? (int) $repo['id'] : null; if ($giteaRepoId === null) { return null; } $ownerUserId = null; if (isset($repo['owner']) && is_array($repo['owner'])) { $ownerUserId = $this->upsertUser($repo['owner']); } $data = [ 'gitea_repo_id' => $giteaRepoId, 'owner_user_id' => $ownerUserId, 'owner_username' => $repo['owner']['username'] ?? $repo['owner']['login'] ?? null, 'name' => $repo['name'] ?? '', 'full_name' => $repo['full_name'] ?? '', 'description' => $repo['description'] ?? null, 'html_url' => $repo['html_url'] ?? null, 'clone_url' => $repo['clone_url'] ?? null, 'default_branch' => $repo['default_branch'] ?? null, 'is_private' => isset($repo['private']) ? (int) $repo['private'] : 0, 'is_archived' => isset($repo['archived']) ? (int) $repo['archived'] : 0, 'is_fork' => isset($repo['fork']) ? (int) $repo['fork'] : 0, 'open_issues_count' => (int) ($repo['open_issues_count'] ?? 0), 'stars_count' => (int) ($repo['stars_count'] ?? 0), 'forks_count' => (int) ($repo['forks_count'] ?? 0), 'watchers_count' => (int) ($repo['watchers_count'] ?? 0), 'last_pushed_at' => $this->normalizeDate($repo['updated_at'] ?? $repo['pushed_at'] ?? null), 'last_synced_at' => date('Y-m-d H:i:s'), ]; $existing = $this->repositoriesModel->where('gitea_repo_id', $giteaRepoId)->first(); if ($existing) { $this->repositoriesModel->update((int) $existing['id'], $data); return (int) $existing['id']; } $this->repositoriesModel->insert($data); return (int) $this->repositoriesModel->getInsertID(); } private function upsertCommit(int $repositoryId, array $commit): bool { $sha = $commit['sha'] ?? null; if ($sha === null) { return false; } $authorUserId = null; if (isset($commit['author']) && is_array($commit['author']) && isset($commit['author']['id'])) { $authorUserId = $this->upsertUser($commit['author']); } $data = [ 'repository_id' => $repositoryId, 'author_user_id' => $authorUserId, 'sha' => $sha, 'short_sha' => substr($sha, 0, 10), 'message' => $commit['commit']['message'] ?? null, 'author_name' => $commit['commit']['author']['name'] ?? null, 'author_email' => $commit['commit']['author']['email'] ?? null, 'committed_at' => $this->normalizeDate($commit['commit']['author']['date'] ?? null), 'html_url' => $commit['html_url'] ?? null, ]; $existing = $this->commitsModel ->where('repository_id', $repositoryId) ->where('sha', $sha) ->first(); if ($existing) { $this->commitsModel->update((int) $existing['id'], $data); return true; } $this->commitsModel->insert($data); return true; } private function upsertPullRequest(int $repositoryId, array $pullRequest): bool { $giteaPrId = isset($pullRequest['id']) ? (int) $pullRequest['id'] : null; if ($giteaPrId === null) { return false; } $authorUserId = null; if (isset($pullRequest['user']) && is_array($pullRequest['user']) && isset($pullRequest['user']['id'])) { $authorUserId = $this->upsertUser($pullRequest['user']); } $data = [ 'gitea_pr_id' => $giteaPrId, 'repository_id' => $repositoryId, 'author_user_id' => $authorUserId, 'number' => (int) ($pullRequest['number'] ?? 0), 'title' => $pullRequest['title'] ?? '', 'body' => $pullRequest['body'] ?? null, 'state' => $pullRequest['state'] ?? 'open', 'is_draft' => isset($pullRequest['draft']) ? (int) $pullRequest['draft'] : 0, 'is_merged' => isset($pullRequest['merged']) ? (int) $pullRequest['merged'] : 0, 'merged_at' => $this->normalizeDate($pullRequest['merged_at'] ?? null), 'closed_at' => $this->normalizeDate($pullRequest['closed_at'] ?? null), 'created_at_gitea' => $this->normalizeDate($pullRequest['created_at'] ?? null), 'updated_at_gitea' => $this->normalizeDate($pullRequest['updated_at'] ?? null), 'html_url' => $pullRequest['html_url'] ?? null, ]; $existing = $this->pullRequestsModel->where('gitea_pr_id', $giteaPrId)->first(); if ($existing) { $this->pullRequestsModel->update((int) $existing['id'], $data); return true; } $this->pullRequestsModel->insert($data); return true; } private function isOnOrAfterSince(?string $value, string $sinceIso): bool { if ($value === null || $value === '') { return false; } try { return (new \DateTimeImmutable($value)) >= (new \DateTimeImmutable($sinceIso)); } catch (\Throwable $e) { return false; } } private function request(string $method, string $endpoint, array $query = []): array { $url = $this->baseUrl . $endpoint; if (!empty($query)) { $url .= '?' . http_build_query($query); } $client = \Config\Services::curlrequest(); try { $response = $client->request($method, $url, [ 'headers' => [ 'Authorization' => 'token ' . $this->token, 'Accept' => 'application/json', ], 'http_errors' => false, ]); $statusCode = $response->getStatusCode(); $body = json_decode($response->getBody(), true); if ($statusCode < 200 || $statusCode >= 300) { return [ 'success' => false, 'message' => 'Gitea request failed [' . $statusCode . '] ' . $endpoint, 'data' => $body, ]; } return [ 'success' => true, 'message' => 'ok', 'data' => $body, ]; } catch (\Throwable $e) { return [ 'success' => false, 'message' => 'Gitea request exception: ' . $e->getMessage(), 'data' => null, ]; } } private function normalizeDate(?string $value): ?string { if ($value === null || $value === '') { return null; } try { return (new \DateTime($value))->format('Y-m-d H:i:s'); } catch (\Throwable $e) { return null; } } }