crm-summit/app/Libraries/GiteaSyncService.php
mahdahar ec5f2fc385 feat(gitea): add database-backed sync, API, and dashboard views
Add Gitea sync service with full and incremental modes, paged API fetch, upsert logic for users/repos/commits/PRs, and error aggregation.

Add migration for git_users, git_repositories, git_commits, git_pull_requests with indexes and unique constraints; add models and sync scripts for full/incremental jobs.

Update Gitea UI and dashboard filters (user/repo/date), aggregate commit loading across repositories, and wire routes/controllers/sidebar for dashboard and sync endpoints.
2026-04-22 16:39:30 +07:00

415 lines
11 KiB
PHP

<?php
namespace App\Libraries;
use App\Models\GitCommitsModel;
use App\Models\GitPullRequestsModel;
use App\Models\GitRepositoriesModel;
use App\Models\GitUsersModel;
class GiteaSyncService
{
private string $baseUrl;
private string $token;
private int $perPage = 100;
private GitUsersModel $usersModel;
private GitRepositoriesModel $repositoriesModel;
private GitCommitsModel $commitsModel;
private GitPullRequestsModel $pullRequestsModel;
public function __construct()
{
$this->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;
}
}
}