From ec5f2fc385255dd4bba5b906048f543cc36025ad Mon Sep 17 00:00:00 2001 From: mahdahar <89adham@gmail.com> Date: Wed, 22 Apr 2026 16:39:30 +0700 Subject: [PATCH] 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. --- app/Commands/SyncGiteaData.php | 43 ++ app/Commands/SyncGiteaIncremental.php | 50 +++ app/Config/Routes.php | 17 +- app/Controllers/Api/GitApi.php | 236 ++++++++++ app/Controllers/Gitea.php | 15 +- .../2026-04-22-000001_CreateGitTables.php | 339 ++++++++++++++ app/Libraries/GiteaSyncService.php | 414 ++++++++++++++++++ app/Models/GitCommitsModel.php | 29 ++ app/Models/GitPullRequestsModel.php | 34 ++ app/Models/GitRepositoriesModel.php | 38 ++ app/Models/GitUsersModel.php | 28 ++ app/Views/gitea_dashboard.php | 259 +++++++++++ app/Views/gitea_index.php | 320 +++++++++----- app/Views/layouts/_sidebar.php | 2 +- scripts/gitea-sync-full.sh | 7 + scripts/gitea-sync-incremental.sh | 8 + 16 files changed, 1718 insertions(+), 121 deletions(-) create mode 100644 app/Commands/SyncGiteaData.php create mode 100644 app/Commands/SyncGiteaIncremental.php create mode 100644 app/Controllers/Api/GitApi.php create mode 100644 app/Database/Migrations/2026-04-22-000001_CreateGitTables.php create mode 100644 app/Libraries/GiteaSyncService.php create mode 100644 app/Models/GitCommitsModel.php create mode 100644 app/Models/GitPullRequestsModel.php create mode 100644 app/Models/GitRepositoriesModel.php create mode 100644 app/Models/GitUsersModel.php create mode 100644 app/Views/gitea_dashboard.php create mode 100644 scripts/gitea-sync-full.sh create mode 100644 scripts/gitea-sync-incremental.sh diff --git a/app/Commands/SyncGiteaData.php b/app/Commands/SyncGiteaData.php new file mode 100644 index 0000000..2a8dbc1 --- /dev/null +++ b/app/Commands/SyncGiteaData.php @@ -0,0 +1,43 @@ +syncFull(); + + if (!($result['success'] ?? false)) { + CLI::error('Sync failed: ' . ($result['message'] ?? 'Unknown error')); + return; + } + + $stats = $result['stats'] ?? []; + CLI::write('Sync done.', 'green'); + CLI::write('Users: ' . ($stats['users_synced'] ?? 0)); + CLI::write('Repositories: ' . ($stats['repositories_synced'] ?? 0)); + CLI::write('Commits: ' . ($stats['commits_synced'] ?? 0)); + CLI::write('Pull Requests: ' . ($stats['pull_requests_synced'] ?? 0)); + + $errors = $stats['errors'] ?? []; + if (!empty($errors)) { + CLI::newLine(); + CLI::write('Warnings / Errors:', 'yellow'); + foreach ($errors as $error) { + CLI::write('- ' . $error, 'light_red'); + } + } + } +} diff --git a/app/Commands/SyncGiteaIncremental.php b/app/Commands/SyncGiteaIncremental.php new file mode 100644 index 0000000..f34ee74 --- /dev/null +++ b/app/Commands/SyncGiteaIncremental.php @@ -0,0 +1,50 @@ +syncIncremental($days); + + if (!($result['success'] ?? false)) { + CLI::error('Sync failed: ' . ($result['message'] ?? 'Unknown error')); + return; + } + + $stats = $result['stats'] ?? []; + CLI::write('Sync done.', 'green'); + CLI::write('Mode: ' . ($stats['mode'] ?? 'incremental')); + CLI::write('Since: ' . ($stats['since'] ?? '-')); + CLI::write('Users: ' . ($stats['users_synced'] ?? 0)); + CLI::write('Repositories: ' . ($stats['repositories_synced'] ?? 0)); + CLI::write('Commits: ' . ($stats['commits_synced'] ?? 0)); + CLI::write('Pull Requests: ' . ($stats['pull_requests_synced'] ?? 0)); + + $errors = $stats['errors'] ?? []; + if (!empty($errors)) { + CLI::newLine(); + CLI::write('Warnings / Errors:', 'yellow'); + foreach ($errors as $error) { + CLI::write('- ' . $error, 'light_red'); + } + } + } +} diff --git a/app/Config/Routes.php b/app/Config/Routes.php index af914b5..17bf968 100644 --- a/app/Config/Routes.php +++ b/app/Config/Routes.php @@ -268,19 +268,22 @@ $routes->match(['get','post'],'/invtrans/reportusage/', 'InvTrans::reportusage/$ // Khusus Untuk Gitea $routes->get('/gitea', 'Gitea::index'); $routes->group('api/gitea', function($routes) { - // Mendapatkan detail user + // Legacy proxy endpoints (tetap dipertahankan) $routes->get('getuser/(:segment)', 'Gitea::getUser/$1'); - - // Mendapatkan daftar repositori milik user $routes->get('getrepos/(:segment)', 'Gitea::getRepositories/$1'); - - // Mendapatkan daftar branch dari sebuah repositori $routes->get('getbranches/(:segment)/(:segment)', 'Gitea::getBranches/$1/$2'); - - // Mendapatkan daftar commit dari sebuah repositori $routes->get('getcommits/(:segment)/(:segment)', 'Gitea::getCommits/$1/$2'); }); +$routes->group('api/git', function($routes) { + $routes->get('summary', 'Api\GitApi::summary'); + $routes->get('users', 'Api\GitApi::users'); + $routes->get('repositories', 'Api\GitApi::repositories'); + $routes->get('commits', 'Api\GitApi::commits'); + $routes->get('pull-requests', 'Api\GitApi::pullRequests'); + $routes->post('sync', 'Api\GitApi::sync'); +}); + //LQMS /* $routes->match(['get','post'],'/lqms', 'Lqms::index'); diff --git a/app/Controllers/Api/GitApi.php b/app/Controllers/Api/GitApi.php new file mode 100644 index 0000000..37322b4 --- /dev/null +++ b/app/Controllers/Api/GitApi.php @@ -0,0 +1,236 @@ +get('userid')) { + return $this->respond([ + 'status' => 'error', + 'message' => 'Unauthorized', + ], 401); + } + + return null; + } + + private function ensureAdmin() + { + $level = (int) session()->get('level'); + if (!in_array($level, [0, 1, 2], true)) { + return $this->respond([ + 'status' => 'error', + 'message' => 'Forbidden. Admin only.', + ], 403); + } + + return null; + } + + public function summary() + { + if ($response = $this->ensureLoggedIn()) { + return $response; + } + + $db = \Config\Database::connect(); + + $usersCount = $db->table('git_users')->countAllResults(); + $repositoriesCount = $db->table('git_repositories')->countAllResults(); + $commitsCount = $db->table('git_commits')->countAllResults(); + $pullRequestsCount = $db->table('git_pull_requests')->countAllResults(); + + $latestCommit = $db->table('git_commits')->selectMax('committed_at', 'latest')->get()->getRowArray(); + $latestPullRequest = $db->table('git_pull_requests')->selectMax('updated_at_gitea', 'latest')->get()->getRowArray(); + $latestRepoSync = $db->table('git_repositories')->selectMax('last_synced_at', 'latest')->get()->getRowArray(); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Summary fetched', + 'data' => [ + 'users' => $usersCount, + 'repositories' => $repositoriesCount, + 'commits' => $commitsCount, + 'pull_requests' => $pullRequestsCount, + 'latest_commit_at' => $latestCommit['latest'] ?? null, + 'latest_pull_request_at' => $latestPullRequest['latest'] ?? null, + 'last_synced_at' => $latestRepoSync['latest'] ?? null, + ], + ], 200); + } + + public function users() + { + if ($response = $this->ensureLoggedIn()) { + return $response; + } + + $db = \Config\Database::connect(); + $rows = $db->table('git_users') + ->select('id, gitea_user_id, username, full_name, email, is_active, is_admin, last_synced_at') + ->orderBy('username', 'ASC') + ->get() + ->getResultArray(); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Users fetched', + 'data' => $rows, + ], 200); + } + + public function repositories() + { + if ($response = $this->ensureLoggedIn()) { + return $response; + } + + $db = \Config\Database::connect(); + $rows = $db->table('git_repositories r') + ->select('r.id, r.gitea_repo_id, r.name, r.full_name, r.owner_username, r.default_branch, r.is_private, r.last_pushed_at, r.last_synced_at, u.username as owner_login') + ->join('git_users u', 'u.id = r.owner_user_id', 'left') + ->orderBy('r.full_name', 'ASC') + ->get() + ->getResultArray(); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Repositories fetched', + 'data' => $rows, + ], 200); + } + + public function commits() + { + if ($response = $this->ensureLoggedIn()) { + return $response; + } + + $repoId = $this->request->getGet('repo_id'); + $userId = $this->request->getGet('user_id'); + $startDate = $this->request->getGet('start_date'); + $endDate = $this->request->getGet('end_date'); + $limit = (int) ($this->request->getGet('limit') ?? 200); + if ($limit <= 0 || $limit > 1000) { + $limit = 200; + } + + $db = \Config\Database::connect(); + $builder = $db->table('git_commits c') + ->select('c.id, c.sha, c.short_sha, c.message, c.author_name, c.author_email, c.committed_at, c.html_url, r.id as repository_id, r.full_name as repository_full_name, u.id as user_id, u.username as user_username') + ->join('git_repositories r', 'r.id = c.repository_id', 'inner') + ->join('git_users u', 'u.id = c.author_user_id', 'left'); + + if (!empty($repoId)) { + $builder->where('c.repository_id', (int) $repoId); + } + + if (!empty($userId)) { + $builder->where('c.author_user_id', (int) $userId); + } + + if (!empty($startDate)) { + $builder->where('c.committed_at >=', $startDate . ' 00:00:00'); + } + + if (!empty($endDate)) { + $builder->where('c.committed_at <=', $endDate . ' 23:59:59'); + } + + $rows = $builder + ->orderBy('c.committed_at', 'DESC') + ->limit($limit) + ->get() + ->getResultArray(); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Commits fetched', + 'data' => $rows, + ], 200); + } + + public function pullRequests() + { + if ($response = $this->ensureLoggedIn()) { + return $response; + } + + $repoId = $this->request->getGet('repo_id'); + $userId = $this->request->getGet('user_id'); + $state = $this->request->getGet('state'); + $startDate = $this->request->getGet('start_date'); + $endDate = $this->request->getGet('end_date'); + $limit = (int) ($this->request->getGet('limit') ?? 200); + if ($limit <= 0 || $limit > 1000) { + $limit = 200; + } + + $db = \Config\Database::connect(); + $builder = $db->table('git_pull_requests p') + ->select('p.id, p.number, p.title, p.state, p.is_draft, p.is_merged, p.created_at_gitea, p.updated_at_gitea, p.merged_at, p.closed_at, p.html_url, r.id as repository_id, r.full_name as repository_full_name, u.id as user_id, u.username as user_username') + ->join('git_repositories r', 'r.id = p.repository_id', 'inner') + ->join('git_users u', 'u.id = p.author_user_id', 'left'); + + if (!empty($repoId)) { + $builder->where('p.repository_id', (int) $repoId); + } + + if (!empty($userId)) { + $builder->where('p.author_user_id', (int) $userId); + } + + if (!empty($state)) { + $builder->where('p.state', $state); + } + + if (!empty($startDate)) { + $builder->where('p.updated_at_gitea >=', $startDate . ' 00:00:00'); + } + + if (!empty($endDate)) { + $builder->where('p.updated_at_gitea <=', $endDate . ' 23:59:59'); + } + + $rows = $builder + ->orderBy('p.updated_at_gitea', 'DESC') + ->limit($limit) + ->get() + ->getResultArray(); + + return $this->respond([ + 'status' => 'success', + 'message' => 'Pull requests fetched', + 'data' => $rows, + ], 200); + } + + public function sync() + { + if ($response = $this->ensureLoggedIn()) { + return $response; + } + + if ($response = $this->ensureAdmin()) { + return $response; + } + + $service = new GiteaSyncService(); + $result = $service->syncAll(); + + $statusCode = $result['success'] ? 200 : 500; + return $this->respond([ + 'status' => $result['success'] ? 'success' : 'error', + 'message' => $result['message'], + 'data' => $result['stats'] ?? [], + ], $statusCode); + } +} diff --git a/app/Controllers/Gitea.php b/app/Controllers/Gitea.php index dc1ce8a..286bf04 100644 --- a/app/Controllers/Gitea.php +++ b/app/Controllers/Gitea.php @@ -10,7 +10,15 @@ class Gitea extends BaseController */ private function fetchFromGitea(string $endpoint) { - $token = env('GITEA_TOKEN') ?: '0eca3f7e42e0992ecc9af9d947bbbd7cfc6ce2e1'; + $token = env('GITEA_TOKEN'); + if (!$token) { + return [ + 'success' => false, + 'message' => 'GITEA_TOKEN missing in .env', + 'data' => null + ]; + } + $url = $this->baseUrl . $endpoint; $client = \Config\Services::curlrequest(); @@ -131,6 +139,9 @@ class Gitea extends BaseController public function index() { - return view('gitea_index'); + $level = (int) session()->get('level'); + $data['isAdmin'] = in_array($level, [0, 1, 2], true); + + return view('gitea_dashboard', $data); } } \ No newline at end of file diff --git a/app/Database/Migrations/2026-04-22-000001_CreateGitTables.php b/app/Database/Migrations/2026-04-22-000001_CreateGitTables.php new file mode 100644 index 0000000..7c1c78b --- /dev/null +++ b/app/Database/Migrations/2026-04-22-000001_CreateGitTables.php @@ -0,0 +1,339 @@ +forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'gitea_user_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => false, + ], + 'username' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + ], + 'full_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'email' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'avatar_url' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'is_active' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 1, + ], + 'is_admin' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'last_synced_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('gitea_user_id'); + $this->forge->addUniqueKey('username'); + $this->forge->createTable('git_users', true); + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'gitea_repo_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => false, + ], + 'owner_user_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'owner_username' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'name' => [ + 'type' => 'VARCHAR', + 'constraint' => 150, + ], + 'full_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + ], + 'description' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'html_url' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'clone_url' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'default_branch' => [ + 'type' => 'VARCHAR', + 'constraint' => 100, + 'null' => true, + ], + 'is_private' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'is_archived' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'is_fork' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'open_issues_count' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'stars_count' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'forks_count' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'watchers_count' => [ + 'type' => 'INT', + 'constraint' => 11, + 'default' => 0, + ], + 'last_pushed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'last_synced_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('gitea_repo_id'); + $this->forge->addUniqueKey('full_name'); + $this->forge->addKey('owner_user_id'); + $this->forge->createTable('git_repositories', true); + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'repository_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => false, + ], + 'author_user_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'sha' => [ + 'type' => 'VARCHAR', + 'constraint' => 64, + ], + 'short_sha' => [ + 'type' => 'VARCHAR', + 'constraint' => 12, + 'null' => true, + ], + 'message' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'author_name' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'author_email' => [ + 'type' => 'VARCHAR', + 'constraint' => 255, + 'null' => true, + ], + 'committed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'html_url' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey(['repository_id', 'sha']); + $this->forge->addKey('repository_id'); + $this->forge->addKey('author_user_id'); + $this->forge->addKey('committed_at'); + $this->forge->createTable('git_commits', true); + + $this->forge->addField([ + 'id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'auto_increment' => true, + ], + 'gitea_pr_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => false, + ], + 'repository_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => false, + ], + 'author_user_id' => [ + 'type' => 'BIGINT', + 'constraint' => 20, + 'unsigned' => true, + 'null' => true, + ], + 'number' => [ + 'type' => 'INT', + 'constraint' => 11, + ], + 'title' => [ + 'type' => 'TEXT', + ], + 'body' => [ + 'type' => 'LONGTEXT', + 'null' => true, + ], + 'state' => [ + 'type' => 'VARCHAR', + 'constraint' => 30, + 'default' => 'open', + ], + 'is_draft' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'is_merged' => [ + 'type' => 'TINYINT', + 'constraint' => 1, + 'default' => 0, + ], + 'merged_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'closed_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'created_at_gitea' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at_gitea' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'html_url' => [ + 'type' => 'TEXT', + 'null' => true, + ], + 'created_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + 'updated_at' => [ + 'type' => 'DATETIME', + 'null' => true, + ], + ]); + $this->forge->addKey('id', true); + $this->forge->addUniqueKey('gitea_pr_id'); + $this->forge->addKey('repository_id'); + $this->forge->addKey('author_user_id'); + $this->forge->addKey('state'); + $this->forge->addKey('updated_at_gitea'); + $this->forge->createTable('git_pull_requests', true); + } + + public function down() + { + $this->forge->dropTable('git_pull_requests', true); + $this->forge->dropTable('git_commits', true); + $this->forge->dropTable('git_repositories', true); + $this->forge->dropTable('git_users', true); + } +} diff --git a/app/Libraries/GiteaSyncService.php b/app/Libraries/GiteaSyncService.php new file mode 100644 index 0000000..c112661 --- /dev/null +++ b/app/Libraries/GiteaSyncService.php @@ -0,0 +1,414 @@ +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; + } + } +} diff --git a/app/Models/GitCommitsModel.php b/app/Models/GitCommitsModel.php new file mode 100644 index 0000000..feb7342 --- /dev/null +++ b/app/Models/GitCommitsModel.php @@ -0,0 +1,29 @@ +extend('layouts/main.php') ?> + +section('content') ?> +
+
+
+
+

Gitea Dashboard (DB)

+
+
+ + + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+
+
+
+
+
Latest Commits
+
Total Commits: 0
+
+
+ + + + + + + + + + + +
DateRepositoryUserSHAMessage
+
+
+
+
+ +
+
+
+
Latest Pull Requests
+
+ + + + + + + + + + + +
UpdatedRepositoryUserPRState
+
+
+
+
+
+
+
+endSection() ?> + +section('script') ?> + +endSection() ?> diff --git a/app/Views/gitea_index.php b/app/Views/gitea_index.php index 0de8d36..3b93761 100644 --- a/app/Views/gitea_index.php +++ b/app/Views/gitea_index.php @@ -42,9 +42,9 @@ -
+
- + - - -
- -
- -
-
+
+ + +
+ +
+ + +
+ +
@@ -78,7 +81,7 @@
- Please select a User -> Repository -> Branch first + Please select User and Date Range first (Repository is optional)
@@ -89,142 +92,234 @@ section('script') ?> diff --git a/app/Views/layouts/_sidebar.php b/app/Views/layouts/_sidebar.php index 62818c2..b4e30bd 100644 --- a/app/Views/layouts/_sidebar.php +++ b/app/Views/layouts/_sidebar.php @@ -113,7 +113,7 @@
  • Activity Text
  • -
  • +