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.
This commit is contained in:
parent
6956b3235c
commit
ec5f2fc385
43
app/Commands/SyncGiteaData.php
Normal file
43
app/Commands/SyncGiteaData.php
Normal file
@ -0,0 +1,43 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Libraries\GiteaSyncService;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
class SyncGiteaData extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Gitea';
|
||||||
|
protected $name = 'gitea:sync';
|
||||||
|
protected $description = 'Full sync all users, repositories, commits, and pull requests from Gitea into local database.';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
CLI::write('Starting Gitea full sync...', 'yellow');
|
||||||
|
|
||||||
|
$service = new GiteaSyncService();
|
||||||
|
$result = $service->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
app/Commands/SyncGiteaIncremental.php
Normal file
50
app/Commands/SyncGiteaIncremental.php
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Commands;
|
||||||
|
|
||||||
|
use App\Libraries\GiteaSyncService;
|
||||||
|
use CodeIgniter\CLI\BaseCommand;
|
||||||
|
use CodeIgniter\CLI\CLI;
|
||||||
|
|
||||||
|
class SyncGiteaIncremental extends BaseCommand
|
||||||
|
{
|
||||||
|
protected $group = 'Gitea';
|
||||||
|
protected $name = 'gitea:sync-incremental';
|
||||||
|
protected $description = 'Incremental sync from Gitea (default last 1 day). Usage: php spark gitea:sync-incremental [days]';
|
||||||
|
|
||||||
|
public function run(array $params)
|
||||||
|
{
|
||||||
|
$days = isset($params[0]) ? (int) $params[0] : 1;
|
||||||
|
if ($days < 1) {
|
||||||
|
$days = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
CLI::write('Starting Gitea incremental sync (last ' . $days . ' day)...', 'yellow');
|
||||||
|
|
||||||
|
$service = new GiteaSyncService();
|
||||||
|
$result = $service->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -268,19 +268,22 @@ $routes->match(['get','post'],'/invtrans/reportusage/', 'InvTrans::reportusage/$
|
|||||||
// Khusus Untuk Gitea
|
// Khusus Untuk Gitea
|
||||||
$routes->get('/gitea', 'Gitea::index');
|
$routes->get('/gitea', 'Gitea::index');
|
||||||
$routes->group('api/gitea', function($routes) {
|
$routes->group('api/gitea', function($routes) {
|
||||||
// Mendapatkan detail user
|
// Legacy proxy endpoints (tetap dipertahankan)
|
||||||
$routes->get('getuser/(:segment)', 'Gitea::getUser/$1');
|
$routes->get('getuser/(:segment)', 'Gitea::getUser/$1');
|
||||||
|
|
||||||
// Mendapatkan daftar repositori milik user
|
|
||||||
$routes->get('getrepos/(:segment)', 'Gitea::getRepositories/$1');
|
$routes->get('getrepos/(:segment)', 'Gitea::getRepositories/$1');
|
||||||
|
|
||||||
// Mendapatkan daftar branch dari sebuah repositori
|
|
||||||
$routes->get('getbranches/(:segment)/(:segment)', 'Gitea::getBranches/$1/$2');
|
$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->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
|
//LQMS
|
||||||
/*
|
/*
|
||||||
$routes->match(['get','post'],'/lqms', 'Lqms::index');
|
$routes->match(['get','post'],'/lqms', 'Lqms::index');
|
||||||
|
|||||||
236
app/Controllers/Api/GitApi.php
Normal file
236
app/Controllers/Api/GitApi.php
Normal file
@ -0,0 +1,236 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controllers\Api;
|
||||||
|
|
||||||
|
use App\Controllers\BaseController;
|
||||||
|
use App\Libraries\GiteaSyncService;
|
||||||
|
use CodeIgniter\API\ResponseTrait;
|
||||||
|
|
||||||
|
class GitApi extends BaseController
|
||||||
|
{
|
||||||
|
use ResponseTrait;
|
||||||
|
|
||||||
|
private function ensureLoggedIn()
|
||||||
|
{
|
||||||
|
if (!session()->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,7 +10,15 @@ class Gitea extends BaseController
|
|||||||
*/
|
*/
|
||||||
private function fetchFromGitea(string $endpoint)
|
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;
|
$url = $this->baseUrl . $endpoint;
|
||||||
|
|
||||||
$client = \Config\Services::curlrequest();
|
$client = \Config\Services::curlrequest();
|
||||||
@ -131,6 +139,9 @@ class Gitea extends BaseController
|
|||||||
|
|
||||||
public function index()
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
339
app/Database/Migrations/2026-04-22-000001_CreateGitTables.php
Normal file
339
app/Database/Migrations/2026-04-22-000001_CreateGitTables.php
Normal file
@ -0,0 +1,339 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Database\Migrations;
|
||||||
|
|
||||||
|
use CodeIgniter\Database\Migration;
|
||||||
|
|
||||||
|
class CreateGitTables extends Migration
|
||||||
|
{
|
||||||
|
public function up()
|
||||||
|
{
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
414
app/Libraries/GiteaSyncService.php
Normal file
414
app/Libraries/GiteaSyncService.php
Normal file
@ -0,0 +1,414 @@
|
|||||||
|
<?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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
29
app/Models/GitCommitsModel.php
Normal file
29
app/Models/GitCommitsModel.php
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
<?php namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class GitCommitsModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'git_commits';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = true;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $protectFields = true;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'repository_id',
|
||||||
|
'author_user_id',
|
||||||
|
'sha',
|
||||||
|
'short_sha',
|
||||||
|
'message',
|
||||||
|
'author_name',
|
||||||
|
'author_email',
|
||||||
|
'committed_at',
|
||||||
|
'html_url',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $dateFormat = 'datetime';
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
}
|
||||||
34
app/Models/GitPullRequestsModel.php
Normal file
34
app/Models/GitPullRequestsModel.php
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
<?php namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class GitPullRequestsModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'git_pull_requests';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = true;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $protectFields = true;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'gitea_pr_id',
|
||||||
|
'repository_id',
|
||||||
|
'author_user_id',
|
||||||
|
'number',
|
||||||
|
'title',
|
||||||
|
'body',
|
||||||
|
'state',
|
||||||
|
'is_draft',
|
||||||
|
'is_merged',
|
||||||
|
'merged_at',
|
||||||
|
'closed_at',
|
||||||
|
'created_at_gitea',
|
||||||
|
'updated_at_gitea',
|
||||||
|
'html_url',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $dateFormat = 'datetime';
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
}
|
||||||
38
app/Models/GitRepositoriesModel.php
Normal file
38
app/Models/GitRepositoriesModel.php
Normal file
@ -0,0 +1,38 @@
|
|||||||
|
<?php namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class GitRepositoriesModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'git_repositories';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = true;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $protectFields = true;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'gitea_repo_id',
|
||||||
|
'owner_user_id',
|
||||||
|
'owner_username',
|
||||||
|
'name',
|
||||||
|
'full_name',
|
||||||
|
'description',
|
||||||
|
'html_url',
|
||||||
|
'clone_url',
|
||||||
|
'default_branch',
|
||||||
|
'is_private',
|
||||||
|
'is_archived',
|
||||||
|
'is_fork',
|
||||||
|
'open_issues_count',
|
||||||
|
'stars_count',
|
||||||
|
'forks_count',
|
||||||
|
'watchers_count',
|
||||||
|
'last_pushed_at',
|
||||||
|
'last_synced_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $dateFormat = 'datetime';
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
}
|
||||||
28
app/Models/GitUsersModel.php
Normal file
28
app/Models/GitUsersModel.php
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
<?php namespace App\Models;
|
||||||
|
|
||||||
|
use CodeIgniter\Model;
|
||||||
|
|
||||||
|
class GitUsersModel extends Model
|
||||||
|
{
|
||||||
|
protected $table = 'git_users';
|
||||||
|
protected $primaryKey = 'id';
|
||||||
|
protected $useAutoIncrement = true;
|
||||||
|
protected $returnType = 'array';
|
||||||
|
protected $useSoftDeletes = false;
|
||||||
|
protected $protectFields = true;
|
||||||
|
protected $allowedFields = [
|
||||||
|
'gitea_user_id',
|
||||||
|
'username',
|
||||||
|
'full_name',
|
||||||
|
'email',
|
||||||
|
'avatar_url',
|
||||||
|
'is_active',
|
||||||
|
'is_admin',
|
||||||
|
'last_synced_at',
|
||||||
|
];
|
||||||
|
|
||||||
|
protected $useTimestamps = true;
|
||||||
|
protected $dateFormat = 'datetime';
|
||||||
|
protected $createdField = 'created_at';
|
||||||
|
protected $updatedField = 'updated_at';
|
||||||
|
}
|
||||||
259
app/Views/gitea_dashboard.php
Normal file
259
app/Views/gitea_dashboard.php
Normal file
@ -0,0 +1,259 @@
|
|||||||
|
<?= $this->extend('layouts/main.php') ?>
|
||||||
|
|
||||||
|
<?= $this->section('content') ?>
|
||||||
|
<div class="page-wrapper">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<div class="row page-titles">
|
||||||
|
<div class="col-md-6 align-self-center">
|
||||||
|
<h4 class="text-themecolor">Gitea Dashboard (DB)</h4>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6 text-end">
|
||||||
|
<?php if (!empty($isAdmin)): ?>
|
||||||
|
<button id="btnSync" class="btn btn-primary">Sync Now</button>
|
||||||
|
<?php endif; ?>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
<div class="row g-3 mb-3">
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">User</label>
|
||||||
|
<select id="filterUser" class="form-select"><option value="">All User</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-3">
|
||||||
|
<label class="form-label">Repository</label>
|
||||||
|
<select id="filterRepo" class="form-select"><option value="">All Repository</option></select>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">Start Date</label>
|
||||||
|
<input type="date" id="filterStart" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2">
|
||||||
|
<label class="form-label">End Date</label>
|
||||||
|
<input type="date" id="filterEnd" class="form-control">
|
||||||
|
</div>
|
||||||
|
<div class="col-md-2 d-flex align-items-end">
|
||||||
|
<button id="btnApplyFilter" class="btn btn-success w-100">Apply</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-12 mb-3">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||||
|
<h5 class="mb-0">Latest Commits</h5>
|
||||||
|
<div><strong>Total Commits:</strong> <span id="totalCommits">0</span></div>
|
||||||
|
</div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered" id="tableCommits">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Date</th>
|
||||||
|
<th>Repository</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>SHA</th>
|
||||||
|
<th>Message</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-body">
|
||||||
|
<h5>Latest Pull Requests</h5>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm table-bordered" id="tablePrs">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Updated</th>
|
||||||
|
<th>Repository</th>
|
||||||
|
<th>User</th>
|
||||||
|
<th>PR</th>
|
||||||
|
<th>State</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
|
|
||||||
|
<?= $this->section('script') ?>
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
|
const totalCommits = document.getElementById('totalCommits');
|
||||||
|
|
||||||
|
const filterRepo = document.getElementById('filterRepo');
|
||||||
|
const filterUser = document.getElementById('filterUser');
|
||||||
|
const filterStart = document.getElementById('filterStart');
|
||||||
|
const filterEnd = document.getElementById('filterEnd');
|
||||||
|
const btnApplyFilter = document.getElementById('btnApplyFilter');
|
||||||
|
const btnSync = document.getElementById('btnSync');
|
||||||
|
|
||||||
|
setDefaultDateRange();
|
||||||
|
await loadUsers();
|
||||||
|
await loadRepos();
|
||||||
|
await loadTables();
|
||||||
|
|
||||||
|
btnApplyFilter.addEventListener('click', loadTables);
|
||||||
|
|
||||||
|
if (btnSync) {
|
||||||
|
btnSync.addEventListener('click', async () => {
|
||||||
|
btnSync.disabled = true;
|
||||||
|
btnSync.innerText = 'Syncing...';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`<?= base_url('api/git/sync') ?>`, {
|
||||||
|
method: 'POST'
|
||||||
|
});
|
||||||
|
const result = await response.json();
|
||||||
|
if (response.ok) {
|
||||||
|
alert('Sync complete');
|
||||||
|
await loadTables();
|
||||||
|
} else {
|
||||||
|
alert(result.message || 'Sync failed');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
alert('Sync request failed');
|
||||||
|
}
|
||||||
|
|
||||||
|
btnSync.disabled = false;
|
||||||
|
btnSync.innerText = 'Sync Now';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDefaultDateRange() {
|
||||||
|
const today = new Date();
|
||||||
|
const startOfYear = new Date(today.getFullYear(), 0, 1);
|
||||||
|
filterStart.value = toInputDate(startOfYear);
|
||||||
|
filterEnd.value = toInputDate(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toInputDate(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUsers() {
|
||||||
|
const response = await fetch(`<?= base_url('api/git/users') ?>`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !Array.isArray(result.data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.data.forEach(item => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = item.id;
|
||||||
|
option.textContent = item.username;
|
||||||
|
filterUser.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRepos() {
|
||||||
|
const response = await fetch(`<?= base_url('api/git/repositories') ?>`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !Array.isArray(result.data)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
result.data.forEach(item => {
|
||||||
|
const option = document.createElement('option');
|
||||||
|
option.value = item.id;
|
||||||
|
option.textContent = item.full_name;
|
||||||
|
filterRepo.appendChild(option);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTables() {
|
||||||
|
await loadCommits();
|
||||||
|
await loadPullRequests();
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildParams() {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
if (filterRepo.value) params.set('repo_id', filterRepo.value);
|
||||||
|
if (filterUser.value) params.set('user_id', filterUser.value);
|
||||||
|
if (filterStart.value) params.set('start_date', filterStart.value);
|
||||||
|
if (filterEnd.value) params.set('end_date', filterEnd.value);
|
||||||
|
params.set('limit', '200');
|
||||||
|
return params.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadCommits() {
|
||||||
|
const tbody = document.querySelector('#tableCommits tbody');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">Loading...</td></tr>';
|
||||||
|
|
||||||
|
const response = await fetch(`<?= base_url('api/git/commits') ?>?${buildParams()}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !Array.isArray(result.data)) {
|
||||||
|
totalCommits.innerText = '0';
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">Failed loading commits</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
totalCommits.innerText = String(result.data.length);
|
||||||
|
|
||||||
|
if (!result.data.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">No commits found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = result.data.map(item => {
|
||||||
|
const msg = (item.message || '').replace(/</g, '<').replace(/>/g, '>').slice(0, 120);
|
||||||
|
const sha = item.short_sha || '';
|
||||||
|
const link = item.html_url ? `<a href="${item.html_url}" target="_blank">${sha}</a>` : sha;
|
||||||
|
return `<tr>
|
||||||
|
<td>${item.committed_at || ''}</td>
|
||||||
|
<td>${item.repository_full_name || ''}</td>
|
||||||
|
<td>${item.user_username || item.author_name || ''}</td>
|
||||||
|
<td>${link}</td>
|
||||||
|
<td>${msg}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPullRequests() {
|
||||||
|
const tbody = document.querySelector('#tablePrs tbody');
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">Loading...</td></tr>';
|
||||||
|
|
||||||
|
const response = await fetch(`<?= base_url('api/git/pull-requests') ?>?${buildParams()}`);
|
||||||
|
const result = await response.json();
|
||||||
|
if (!response.ok || !Array.isArray(result.data)) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">Failed loading pull requests</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.data.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="5">No pull requests found</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
tbody.innerHTML = result.data.map(item => {
|
||||||
|
const title = (item.title || '').replace(/</g, '<').replace(/>/g, '>').slice(0, 100);
|
||||||
|
const prText = `#${item.number} ${title}`;
|
||||||
|
const link = item.html_url ? `<a href="${item.html_url}" target="_blank">${prText}</a>` : prText;
|
||||||
|
return `<tr>
|
||||||
|
<td>${item.updated_at_gitea || ''}</td>
|
||||||
|
<td>${item.repository_full_name || ''}</td>
|
||||||
|
<td>${item.user_username || ''}</td>
|
||||||
|
<td>${link}</td>
|
||||||
|
<td>${item.state || ''}</td>
|
||||||
|
</tr>`;
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
<?= $this->endSection() ?>
|
||||||
@ -42,9 +42,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row mb-4 bg-light p-3 rounded shadow-sm">
|
<div class="row mb-4 bg-light p-3 rounded shadow-sm g-3">
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label fw-bold">1. Select User</label>
|
<label class="form-label fw-bold">1. Select User <span class="text-danger">*</span></label>
|
||||||
<select id="selectUser" class="form-select">
|
<select id="selectUser" class="form-select">
|
||||||
<option value="">-- Select User --</option>
|
<option value="">-- Select User --</option>
|
||||||
<option value="alamdh22">alamdh22</option>
|
<option value="alamdh22">alamdh22</option>
|
||||||
@ -55,22 +55,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-3">
|
||||||
<label class="form-label fw-bold">2. Select Repository</label>
|
<label class="form-label fw-bold">2. Select Repository (Optional)</label>
|
||||||
<select id="selectRepo" class="form-select" disabled>
|
<select id="selectRepo" class="form-select" disabled>
|
||||||
<option value="">-- Select Repository --</option>
|
<option value="">-- All Repositories --</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3">
|
<div class="col-md-2">
|
||||||
<label class="form-label fw-bold">3. Select Branch</label>
|
<label class="form-label fw-bold">3. Start Date <span class="text-danger">*</span></label>
|
||||||
<select id="selectBranch" class="form-select" disabled>
|
<input type="date" id="startDate" class="form-control">
|
||||||
<option value="">-- Select Branch --</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-3 d-flex align-items-end">
|
<div class="col-md-2">
|
||||||
|
<label class="form-label fw-bold">4. End Date <span class="text-danger">*</span></label>
|
||||||
|
<input type="date" id="endDate" class="form-control">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-md-2 d-flex align-items-end">
|
||||||
<button id="btnLoadCommits" class="btn btn-success w-100" disabled>
|
<button id="btnLoadCommits" class="btn btn-success w-100" disabled>
|
||||||
Show All Commit
|
Filter Commits
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -78,7 +81,7 @@
|
|||||||
<div class="row justify-content-center">
|
<div class="row justify-content-center">
|
||||||
<div class="col-12" id="commits-container">
|
<div class="col-12" id="commits-container">
|
||||||
<div class="alert alert-secondary text-center">
|
<div class="alert alert-secondary text-center">
|
||||||
Please select a User -> Repository -> Branch first
|
Please select User and Date Range first (Repository is optional)
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -89,36 +92,32 @@
|
|||||||
<?= $this->section('script') ?>
|
<?= $this->section('script') ?>
|
||||||
<script>
|
<script>
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
// Ambil elemen
|
|
||||||
const selectUser = document.getElementById('selectUser');
|
const selectUser = document.getElementById('selectUser');
|
||||||
const selectRepo = document.getElementById('selectRepo');
|
const selectRepo = document.getElementById('selectRepo');
|
||||||
const selectBranch = document.getElementById('selectBranch');
|
const startDate = document.getElementById('startDate');
|
||||||
|
const endDate = document.getElementById('endDate');
|
||||||
const btnLoadCommits = document.getElementById('btnLoadCommits');
|
const btnLoadCommits = document.getElementById('btnLoadCommits');
|
||||||
const commitsContainer = document.getElementById('commits-container');
|
const commitsContainer = document.getElementById('commits-container');
|
||||||
|
|
||||||
// 1. Cari Repo otomatis saat User dipilih di Dropdown
|
let availableRepos = [];
|
||||||
|
|
||||||
|
setDefaultDateRange();
|
||||||
|
updateButtonState();
|
||||||
|
|
||||||
selectUser.addEventListener('change', async () => {
|
selectUser.addEventListener('change', async () => {
|
||||||
const username = selectUser.value;
|
const username = selectUser.value;
|
||||||
|
|
||||||
// Reset pilihan di bawahnya jika user kembali memilih "-- Select User --"
|
|
||||||
if (!username) {
|
if (!username) {
|
||||||
selectRepo.innerHTML = '<option value="">-- Select Repository --</option>';
|
availableRepos = [];
|
||||||
selectRepo.disabled = true;
|
resetRepoSelect('-- All Repositories --', true);
|
||||||
selectBranch.innerHTML = '<option value="">-- Select Branch --</option>';
|
updateButtonState();
|
||||||
selectBranch.disabled = true;
|
|
||||||
btnLoadCommits.disabled = true;
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tampilkan loading
|
resetRepoSelect('Loading repositories...', true);
|
||||||
selectRepo.innerHTML = '<option value="">Loading...</option>';
|
updateButtonState();
|
||||||
selectRepo.disabled = true;
|
|
||||||
selectBranch.innerHTML = '<option value="">-- Select Branch --</option>';
|
|
||||||
selectBranch.disabled = true;
|
|
||||||
btnLoadCommits.disabled = true;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// PERBAIKAN: Menggunakan getrepos sesuai route kamu
|
|
||||||
const url = `<?= base_url('api/gitea/getrepos') ?>/${username}`;
|
const url = `<?= base_url('api/gitea/getrepos') ?>/${username}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
|
|
||||||
@ -127,103 +126,199 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const response = await res.json();
|
const response = await res.json();
|
||||||
|
availableRepos = (response.success && Array.isArray(response.data))
|
||||||
|
? response.data.map(repo => repo.name)
|
||||||
|
: [];
|
||||||
|
|
||||||
if (response.success && response.data.length > 0) {
|
renderRepoOptions(availableRepos);
|
||||||
selectRepo.innerHTML = '<option value="">-- Select Repository --</option>';
|
|
||||||
response.data.forEach(repo => {
|
|
||||||
selectRepo.innerHTML += `<option value="${repo.name}">${repo.name}</option>`;
|
|
||||||
});
|
|
||||||
selectRepo.disabled = false;
|
|
||||||
} else {
|
|
||||||
selectRepo.innerHTML = '<option value="">Repository not found</option>';
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error fetching repos:', error);
|
console.error('Error fetching repos:', error);
|
||||||
selectRepo.innerHTML = '<option value="">Error loading repository</option>';
|
availableRepos = [];
|
||||||
|
resetRepoSelect('Error loading repository', true);
|
||||||
alert('Failed to fetch repository data from the server.');
|
alert('Failed to fetch repository data from the server.');
|
||||||
|
} finally {
|
||||||
|
updateButtonState();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// 2. Ambil Branch ketika Repo dipilih
|
startDate.addEventListener('change', updateButtonState);
|
||||||
selectRepo.addEventListener('change', async () => {
|
endDate.addEventListener('change', updateButtonState);
|
||||||
const username = selectUser.value;
|
|
||||||
const repo = selectRepo.value;
|
|
||||||
if (!repo) return;
|
|
||||||
|
|
||||||
selectBranch.innerHTML = '<option value="">Loading...</option>';
|
|
||||||
selectBranch.disabled = true;
|
|
||||||
btnLoadCommits.disabled = true;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// PERBAIKAN: Menggunakan getbranches sesuai route kamu
|
|
||||||
const url = `<?= base_url('api/gitea/getbranches') ?>/${username}/${repo}`;
|
|
||||||
const res = await fetch(url);
|
|
||||||
const response = await res.json();
|
|
||||||
|
|
||||||
if (response.success && response.data.length > 0) {
|
|
||||||
selectBranch.innerHTML = '<option value="">-- Select Branch --</option>';
|
|
||||||
response.data.forEach(branch => {
|
|
||||||
selectBranch.innerHTML += `<option value="${branch.name}">${branch.name}</option>`;
|
|
||||||
});
|
|
||||||
selectBranch.disabled = false;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error fetching branches:', error);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
// 3. Enable tombol Commit ketika branch dipilih
|
|
||||||
selectBranch.addEventListener('change', () => {
|
|
||||||
btnLoadCommits.disabled = !selectBranch.value;
|
|
||||||
});
|
|
||||||
|
|
||||||
// 4. Ambil Commits dan Render HTML
|
|
||||||
btnLoadCommits.addEventListener('click', async () => {
|
btnLoadCommits.addEventListener('click', async () => {
|
||||||
const username = selectUser.value;
|
const username = selectUser.value;
|
||||||
const repo = selectRepo.value;
|
const selectedRepo = selectRepo.value;
|
||||||
const branch = selectBranch.value;
|
|
||||||
|
|
||||||
commitsContainer.innerHTML = '<div class="text-center"><div class="spinner-border text-primary" role="status"></div><p>Fetching commits...</p></div>';
|
if (!username || !startDate.value || !endDate.value) {
|
||||||
|
commitsContainer.innerHTML = `<div class="alert alert-warning">User and date range are required.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = new Date(`${startDate.value}T00:00:00`);
|
||||||
|
const end = new Date(`${endDate.value}T23:59:59.999`);
|
||||||
|
|
||||||
|
if (start > end) {
|
||||||
|
commitsContainer.innerHTML = `<div class="alert alert-warning">Start date must be earlier than or equal to end date.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const targetRepos = selectedRepo ? [selectedRepo] : availableRepos;
|
||||||
|
|
||||||
|
if (!targetRepos.length) {
|
||||||
|
commitsContainer.innerHTML = `<div class="alert alert-info">No repositories found for selected user.</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
commitsContainer.innerHTML = `<div class="text-center"><div class="spinner-border text-primary" role="status"></div><p>Fetching commits from ${targetRepos.length} repos...</p></div>`;
|
||||||
|
|
||||||
|
const limit = 100;
|
||||||
|
const failedRepos = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// set limit=50
|
const results = await Promise.all(targetRepos.map(async (repoName) => {
|
||||||
const limit = 50;
|
try {
|
||||||
const url = `<?= base_url('api/gitea/getcommits') ?>/${username}/${repo}?sha=${encodeURIComponent(branch)}&limit=${limit}`;
|
const url = `<?= base_url('api/gitea/getcommits') ?>/${username}/${repoName}?limit=${limit}`;
|
||||||
const res = await fetch(url);
|
const res = await fetch(url);
|
||||||
const response = await res.json();
|
|
||||||
|
|
||||||
if (response.success) {
|
if (!res.ok) {
|
||||||
renderCommits(response.data);
|
throw new Error(`HTTP ${res.status}`);
|
||||||
} else {
|
}
|
||||||
commitsContainer.innerHTML = `<div class="alert alert-danger">${response.message}</div>`;
|
|
||||||
}
|
const response = await res.json();
|
||||||
|
if (!response.success || !Array.isArray(response.data)) {
|
||||||
|
throw new Error(response.message || 'Invalid response');
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data.map(item => ({ ...item, __repo: repoName }));
|
||||||
|
} catch (error) {
|
||||||
|
failedRepos.push(repoName);
|
||||||
|
console.error(`Error fetching commits for repo ${repoName}:`, error);
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mergedCommits = results.flat();
|
||||||
|
const filteredCommits = mergedCommits
|
||||||
|
.filter(item => commitMatchesUser(item, username))
|
||||||
|
.filter(item => isDateInRange(item, start, end))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const dateA = getCommitDate(a)?.getTime() ?? 0;
|
||||||
|
const dateB = getCommitDate(b)?.getTime() ?? 0;
|
||||||
|
return dateB - dateA;
|
||||||
|
});
|
||||||
|
|
||||||
|
renderCommits(filteredCommits, failedRepos);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
console.error('Error filtering commits:', error);
|
||||||
commitsContainer.innerHTML = `<div class="alert alert-danger">Failed to contact the server.</div>`;
|
commitsContainer.innerHTML = `<div class="alert alert-danger">Failed to contact the server.</div>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Fungsi untuk me-render HTML persis seperti template PHP yang kamu buat
|
function setDefaultDateRange() {
|
||||||
function renderCommits(commits) {
|
const today = new Date();
|
||||||
|
const currentYear = today.getFullYear();
|
||||||
|
const januaryFirst = new Date(currentYear, 0, 1);
|
||||||
|
|
||||||
|
startDate.value = formatDateInput(januaryFirst);
|
||||||
|
endDate.value = formatDateInput(today);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateInput(date) {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const day = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${year}-${month}-${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetRepoSelect(message, disabled = true) {
|
||||||
|
selectRepo.innerHTML = `<option value="">${message}</option>`;
|
||||||
|
selectRepo.disabled = disabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRepoOptions(repos) {
|
||||||
|
selectRepo.innerHTML = '<option value="">-- All Repositories --</option>';
|
||||||
|
|
||||||
|
if (!repos.length) {
|
||||||
|
selectRepo.innerHTML = '<option value="">Repository not found</option>';
|
||||||
|
selectRepo.disabled = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
repos.forEach(repoName => {
|
||||||
|
selectRepo.innerHTML += `<option value="${repoName}">${repoName}</option>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
selectRepo.disabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateButtonState() {
|
||||||
|
const hasUser = Boolean(selectUser.value);
|
||||||
|
const hasStartDate = Boolean(startDate.value);
|
||||||
|
const hasEndDate = Boolean(endDate.value);
|
||||||
|
btnLoadCommits.disabled = !(hasUser && hasStartDate && hasEndDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCommitDate(item) {
|
||||||
|
const rawDate = item.commit?.author?.date || item.commit?.committer?.date;
|
||||||
|
if (!rawDate) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsed = new Date(rawDate);
|
||||||
|
return Number.isNaN(parsed.getTime()) ? null : parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDateInRange(item, start, end) {
|
||||||
|
const commitDate = getCommitDate(item);
|
||||||
|
if (!commitDate) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return commitDate >= start && commitDate <= end;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitMatchesUser(item, selectedUsername) {
|
||||||
|
const username = (selectedUsername || '').toLowerCase();
|
||||||
|
const authorUsername = (item.author?.username || '').toLowerCase();
|
||||||
|
const commitAuthorName = (item.commit?.author?.name || '').toLowerCase();
|
||||||
|
const commitAuthorEmail = (item.commit?.author?.email || '').toLowerCase();
|
||||||
|
const emailPrefix = commitAuthorEmail.includes('@') ? commitAuthorEmail.split('@')[0] : commitAuthorEmail;
|
||||||
|
|
||||||
|
return [authorUsername, commitAuthorName, emailPrefix].some(value => value && value === username);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderCommits(commits, failedRepos = []) {
|
||||||
if (!commits || commits.length === 0) {
|
if (!commits || commits.length === 0) {
|
||||||
commitsContainer.innerHTML = `
|
const warning = failedRepos.length
|
||||||
|
? `<div class="alert alert-warning text-center shadow-sm"><i class="bi bi-exclamation-triangle me-2"></i> Failed to load ${failedRepos.length} repository data.</div>`
|
||||||
|
: '';
|
||||||
|
|
||||||
|
commitsContainer.innerHTML = `${warning}
|
||||||
<div class="alert alert-info text-center shadow-sm">
|
<div class="alert alert-info text-center shadow-sm">
|
||||||
<i class="bi bi-info-circle me-2"></i> No commit history available for this branch.
|
<i class="bi bi-info-circle me-2"></i> No commits found for selected filters.
|
||||||
</div>`;
|
</div>`;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
let html = '';
|
let html = '';
|
||||||
|
|
||||||
|
if (failedRepos.length) {
|
||||||
|
html += `<div class="alert alert-warning text-center shadow-sm"><i class="bi bi-exclamation-triangle me-2"></i> Failed to load ${failedRepos.length} repository data: ${escapeHtml(failedRepos.join(', '))}</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
commits.forEach((item, index) => {
|
commits.forEach((item, index) => {
|
||||||
const message = item.commit?.message || 'No commit message';
|
const message = item.commit?.message || 'No commit message';
|
||||||
const authorName = item.author?.username || item.commit?.author?.name || 'Unknown';
|
const authorName = item.author?.username || item.commit?.author?.name || 'Unknown';
|
||||||
const avatar = item.author?.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(authorName)}&background=random`;
|
const avatar = item.author?.avatar_url || `https://ui-avatars.com/api/?name=${encodeURIComponent(authorName)}&background=random`;
|
||||||
|
|
||||||
const dateObj = new Date(item.commit?.author?.date);
|
const dateObj = getCommitDate(item);
|
||||||
const dateStr = dateObj.toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' }) + ', ' +
|
const dateStr = dateObj
|
||||||
dateObj.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' });
|
? dateObj.toLocaleDateString('id-ID', { day: '2-digit', month: 'short', year: 'numeric' }) + ', ' +
|
||||||
|
dateObj.toLocaleTimeString('id-ID', { hour: '2-digit', minute: '2-digit' })
|
||||||
|
: '-';
|
||||||
|
|
||||||
const shaShort = item.sha ? item.sha.substring(0, 10) : '';
|
const shaShort = item.sha ? item.sha.substring(0, 10) : '';
|
||||||
const commitUrl = item.html_url || '#';
|
const commitUrl = item.html_url || '#';
|
||||||
|
const repoName = item.__repo || '-';
|
||||||
|
|
||||||
const additions = item.stats?.additions || 0;
|
const additions = item.stats?.additions || 0;
|
||||||
const deletions = item.stats?.deletions || 0;
|
const deletions = item.stats?.deletions || 0;
|
||||||
@ -244,6 +339,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
<div class="text-muted small mt-1 text-truncate">
|
<div class="text-muted small mt-1 text-truncate">
|
||||||
<i class="bi bi-person-fill"></i> <strong>${escapeHtml(authorName)}</strong> melakukan commit pada ${dateStr}
|
<i class="bi bi-person-fill"></i> <strong>${escapeHtml(authorName)}</strong> melakukan commit pada ${dateStr}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="mt-1">
|
||||||
|
<span class="badge bg-primary-subtle text-primary border">Repo: ${escapeHtml(repoName)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -300,13 +398,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(unsafe) {
|
function escapeHtml(unsafe) {
|
||||||
if(!unsafe) return '';
|
if (!unsafe) return '';
|
||||||
return unsafe
|
return unsafe
|
||||||
.replace(/&/g, "&")
|
.replace(/&/g, '&')
|
||||||
.replace(/</g, "<")
|
.replace(/</g, '<')
|
||||||
.replace(/>/g, ">")
|
.replace(/>/g, '>')
|
||||||
.replace(/"/g, """)
|
.replace(/"/g, '"')
|
||||||
.replace(/'/g, "'");
|
.replace(/'/g, ''');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -113,7 +113,7 @@
|
|||||||
<li><a href="<?=base_url();?>acttext">Activity Text</a></li>
|
<li><a href="<?=base_url();?>acttext">Activity Text</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
<li> <a class="waves-effect waves-dark" href='<?=base_url();?>gitea' aria-expanded="false"> <i class="fa-brands fa-git-alt"></i><span class='hide-menu'>Commits</span></a></li>
|
<li> <a class="waves-effect waves-dark" href='<?=base_url();?>gitea' aria-expanded="false"> <i class="fa-brands fa-git-alt"></i><span class='hide-menu'>Gitea Dashboard</span></a></li>
|
||||||
|
|
||||||
<?php elseif ($isPS): ?>
|
<?php elseif ($isPS): ?>
|
||||||
<!-- <li> <a class="has-arrow waves-effect waves-dark" href="javascript:void(0)" aria-expanded="false"> <i class="fas fa-user"></i><span class="hide-menu">User</span> </a>
|
<!-- <li> <a class="has-arrow waves-effect waves-dark" href="javascript:void(0)" aria-expanded="false"> <i class="fas fa-user"></i><span class="hide-menu">User</span> </a>
|
||||||
|
|||||||
7
scripts/gitea-sync-full.sh
Normal file
7
scripts/gitea-sync-full.sh
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
php spark gitea:sync
|
||||||
8
scripts/gitea-sync-incremental.sh
Normal file
8
scripts/gitea-sync-incremental.sh
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
DAYS="${1:-1}"
|
||||||
|
php spark gitea:sync-incremental "$DAYS"
|
||||||
Loading…
x
Reference in New Issue
Block a user