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

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

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

411 lines
17 KiB
PHP

<?= $this->extend('layouts/main.php') ?>
<?= $this->section('content') ?>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<style>
body {
background-color: #f8f9fa;
}
.commit-card {
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
border-left: 4px solid #0d6efd; /* Aksen warna biru di kiri */
}
.commit-card:hover {
transform: translateY(-3px);
box-shadow: 0 .5rem 1rem rgba(0,0,0,.1)!important;
}
.avatar {
width: 48px;
height: 48px;
object-fit: cover;
}
.commit-message {
font-size: 1.1rem;
font-weight: 600;
color: #212529;
text-decoration: none;
}
.commit-message:hover {
text-decoration: underline;
color: #0d6efd;
}
.file-list {
max-height: 200px;
overflow-y: auto;
}
</style>
<div class="page-wrapper">
<div class="container-fluid">
<div class="row page-titles">
<div class="col-md-5 align-self-center">
<h4 class="text-themecolor">Commit History</h4>
</div>
</div>
<div class="row mb-4 bg-light p-3 rounded shadow-sm g-3">
<div class="col-md-3">
<label class="form-label fw-bold">1. Select User <span class="text-danger">*</span></label>
<select id="selectUser" class="form-select">
<option value="">-- Select User --</option>
<option value="alamdh22">alamdh22</option>
<option value="faiztyanirh">faiztyanirh</option>
<option value="mahdahar">mahdahar</option>
<option value="mikael-zakaria">mikael-zakaria</option>
</select>
</div>
<div class="col-md-3">
<label class="form-label fw-bold">2. Select Repository (Optional)</label>
<select id="selectRepo" class="form-select" disabled>
<option value="">-- All Repositories --</option>
</select>
</div>
<div class="col-md-2">
<label class="form-label fw-bold">3. Start Date <span class="text-danger">*</span></label>
<input type="date" id="startDate" class="form-control">
</div>
<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>
Filter Commits
</button>
</div>
</div>
<div class="row justify-content-center">
<div class="col-12" id="commits-container">
<div class="alert alert-secondary text-center">
Please select User and Date Range first (Repository is optional)
</div>
</div>
</div>
</div>
</div>
<?= $this->endSection() ?>
<?= $this->section('script') ?>
<script>
document.addEventListener('DOMContentLoaded', () => {
const selectUser = document.getElementById('selectUser');
const selectRepo = document.getElementById('selectRepo');
const startDate = document.getElementById('startDate');
const endDate = document.getElementById('endDate');
const btnLoadCommits = document.getElementById('btnLoadCommits');
const commitsContainer = document.getElementById('commits-container');
let availableRepos = [];
setDefaultDateRange();
updateButtonState();
selectUser.addEventListener('change', async () => {
const username = selectUser.value;
if (!username) {
availableRepos = [];
resetRepoSelect('-- All Repositories --', true);
updateButtonState();
return;
}
resetRepoSelect('Loading repositories...', true);
updateButtonState();
try {
const url = `<?= base_url('api/gitea/getrepos') ?>/${username}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP error! status: ${res.status}`);
}
const response = await res.json();
availableRepos = (response.success && Array.isArray(response.data))
? response.data.map(repo => repo.name)
: [];
renderRepoOptions(availableRepos);
} catch (error) {
console.error('Error fetching repos:', error);
availableRepos = [];
resetRepoSelect('Error loading repository', true);
alert('Failed to fetch repository data from the server.');
} finally {
updateButtonState();
}
});
startDate.addEventListener('change', updateButtonState);
endDate.addEventListener('change', updateButtonState);
btnLoadCommits.addEventListener('click', async () => {
const username = selectUser.value;
const selectedRepo = selectRepo.value;
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 {
const results = await Promise.all(targetRepos.map(async (repoName) => {
try {
const url = `<?= base_url('api/gitea/getcommits') ?>/${username}/${repoName}?limit=${limit}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`HTTP ${res.status}`);
}
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) {
console.error('Error filtering commits:', error);
commitsContainer.innerHTML = `<div class="alert alert-danger">Failed to contact the server.</div>`;
}
});
function setDefaultDateRange() {
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) {
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">
<i class="bi bi-info-circle me-2"></i> No commits found for selected filters.
</div>`;
return;
}
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) => {
const message = item.commit?.message || 'No commit message';
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 dateObj = getCommitDate(item);
const dateStr = dateObj
? 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 commitUrl = item.html_url || '#';
const repoName = item.__repo || '-';
const additions = item.stats?.additions || 0;
const deletions = item.stats?.deletions || 0;
const files = item.files || [];
html += `
<div class="card shadow-sm mb-3 border-0 commit-card">
<div class="card-body">
<div class="d-flex justify-content-between align-items-start gap-3 flex-sm-row flex-column">
<div class="d-flex align-items-start flex-grow-1 overflow-hidden w-100">
<img src="${avatar}" class="rounded-circle avatar border me-3 flex-shrink-0" alt="Avatar ${authorName}" width="40" height="40">
<div class="overflow-hidden w-100">
<a href="${commitUrl}" target="_blank" class="commit-message text-truncate d-block fw-bold text-decoration-none text-dark" title="${escapeHtml(message)}">
${escapeHtml(message)}
</a>
<div class="text-muted small mt-1 text-truncate">
<i class="bi bi-person-fill"></i> <strong>${escapeHtml(authorName)}</strong> melakukan commit pada ${dateStr}
</div>
<div class="mt-1">
<span class="badge bg-primary-subtle text-primary border">Repo: ${escapeHtml(repoName)}</span>
</div>
</div>
</div>
<div class="text-sm-end flex-shrink-0 mt-3 mt-sm-0">
<a href="${commitUrl}" target="_blank" class="badge bg-light text-dark border font-monospace text-decoration-none fs-6 mb-2 d-inline-block">
<i class="bi bi-hash"></i>${shaShort}
</a>
<div class="stats small font-monospace fw-bold">
<span class="text-success me-2" title="Additions"><i class="bi bi-plus-square-fill"></i> ${additions}</span>
<span class="text-danger" title="Deletions"><i class="bi bi-dash-square-fill"></i> ${deletions}</span>
</div>
</div>
</div>`;
if (files.length > 0) {
html += `
<div class="mt-3 border-top pt-2 d-flex justify-content-between align-items-center">
<span class="text-muted small"><i class="bi bi-files"></i> Mengubah ${files.length} file</span>
<button class="btn btn-sm btn-outline-secondary rounded-pill" type="button" data-bs-toggle="collapse" data-bs-target="#files-${index}" aria-expanded="false">
Lihat Detail File <i class="bi bi-chevron-down"></i>
</button>
</div>
<div class="collapse mt-2" id="files-${index}">
<div class="card card-body bg-light border-0 p-3 file-list">
<ul class="list-unstyled mb-0 small font-monospace">`;
files.forEach(file => {
let statusClass = 'text-secondary';
let icon = 'bi-file-earmark';
if (file.status === 'added') { statusClass = 'text-success'; icon = 'bi-file-earmark-plus-fill'; }
else if (file.status === 'modified') { statusClass = 'text-warning'; icon = 'bi-file-earmark-diff-fill'; }
else if (file.status === 'removed' || file.status === 'deleted') { statusClass = 'text-danger'; icon = 'bi-file-earmark-minus-fill'; }
html += `
<li class="mb-1 d-flex align-items-center">
<i class="bi ${icon} ${statusClass} me-2 fs-6"></i>
<span class="text-truncate" title="${escapeHtml(file.filename)}">
${escapeHtml(file.filename)}
</span>
</li>`;
});
html += ` </ul>
</div>
</div>`;
}
html += `
</div>
</div>`;
});
commitsContainer.innerHTML = html;
}
function escapeHtml(unsafe) {
if (!unsafe) return '';
return unsafe
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
});
</script>
<?= $this->endSection() ?>