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:
mahdahar 2026-04-22 16:39:30 +07:00
parent 6956b3235c
commit ec5f2fc385
16 changed files with 1718 additions and 121 deletions

View 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');
}
}
}
}

View 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');
}
}
}
}

View File

@ -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');

View 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);
}
}

View File

@ -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);
} }
} }

View 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);
}
}

View 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;
}
}
}

View 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';
}

View 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';
}

View 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';
}

View 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';
}

View 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, '&lt;').replace(/>/g, '&gt;').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, '&lt;').replace(/>/g, '&gt;').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() ?>

View File

@ -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) { } 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) {
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, "&amp;") .replace(/&/g, '&amp;')
.replace(/</g, "&lt;") .replace(/</g, '&lt;')
.replace(/>/g, "&gt;") .replace(/>/g, '&gt;')
.replace(/"/g, "&quot;") .replace(/"/g, '&quot;')
.replace(/'/g, "&#039;"); .replace(/'/g, '&#039;');
} }
}); });
</script> </script>

View File

@ -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>

View 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

View 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"