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.
415 lines
11 KiB
PHP
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;
|
|
}
|
|
}
|
|
}
|