feat: Implement cache management for Git repository contributions and commits

This commit is contained in:
olli 2025-11-12 16:58:04 +01:00
parent 404dc2c73a
commit c1278d2e45
6 changed files with 230 additions and 48 deletions

View File

@ -130,9 +130,6 @@ const contributionGrid = computed(() => {
contributionMap[day.date] = day.count
})
// Debug: Log contribution map
console.log('Contribution map sample:', Object.entries(contributionMap).slice(0, 10))
let col = 1
let currentDate = new Date(firstSunday)
const lastDay = new Date(currentYear.value, 11, 31)
@ -173,11 +170,6 @@ const contributionGrid = computed(() => {
currentDate.setDate(currentDate.getDate() + 1)
}
// Debug: Log grid sample
const gridWithCommits = grid.filter(g => g.count > 0)
console.log('Grid cells with commits:', gridWithCommits.length)
console.log('Sample cells with commits:', gridWithCommits.slice(0, 5))
return grid
})
@ -309,11 +301,6 @@ async function loadContributions() {
const data = await response.json()
contributionData.value = data.contributions || []
// Debug: Log the data to check format
console.log('Contribution data received:', contributionData.value.slice(0, 5))
console.log('Total days:', contributionData.value.length)
console.log('Days with commits:', contributionData.value.filter(d => d.count > 0).length)
} catch (err) {
console.error('Error loading contributions:', err)
error.value = err.message
@ -433,6 +420,11 @@ defineExpose({
transition: all 0.15s ease;
min-height: 13px;
min-width: 13px;
border: 1px solid rgba(0, 0, 0, 0.06);
}
:global(.dark) .contribution-cell {
border: 1px solid rgba(255, 255, 255, 0.08);
}
.contribution-cell:not(.intensity-empty):hover {

View File

@ -711,6 +711,15 @@
:use-grouping="false"
class="w-10rem"
/>
<Button
v-if="selectedRepository"
icon="pi pi-refresh"
label="Cache aktualisieren"
size="small"
outlined
:loading="refreshingCache"
@click="refreshRepositoryCache"
/>
</div>
<!-- Contribution Chart -->
@ -782,6 +791,7 @@ const gitRepositories = ref([])
const selectedRepository = ref(null)
const selectedBranch = ref('main')
const selectedYear = ref(new Date().getFullYear())
const refreshingCache = ref(false)
// Git Repository Management
const editGitRepositories = ref([])
@ -1302,6 +1312,48 @@ async function deleteProject() {
}
}
async function refreshRepositoryCache() {
if (!selectedRepository.value) return
refreshingCache.value = true
try {
const response = await fetch(`/api/git-repos/${selectedRepository.value.id}/refresh-cache`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
}
})
if (!response.ok) {
const errorData = await response.json()
throw new Error(errorData.error || 'Cache konnte nicht aktualisiert werden')
}
const result = await response.json()
toast.add({
severity: 'success',
summary: 'Erfolg',
detail: result.message || 'Cache wurde aktualisiert',
life: 3000
})
// Reload git data
await loadGitRepositories(viewingProject.value.id)
} catch (error) {
console.error('Error refreshing cache:', error)
toast.add({
severity: 'error',
summary: 'Fehler',
detail: error.message || 'Cache konnte nicht aktualisiert werden',
life: 3000
})
} finally {
refreshingCache.value = false
}
}
function formatDate(dateString) {
if (!dateString) return ''
const date = new Date(dateString)

View File

@ -20,6 +20,7 @@
"phpstan/phpdoc-parser": "^2.3",
"symfony/asset": "7.1.*",
"symfony/asset-mapper": "7.1.*",
"symfony/cache": "7.1.*",
"symfony/console": "7.1.*",
"symfony/doctrine-messenger": "7.1.*",
"symfony/dotenv": "7.1.*",

2
composer.lock generated
View File

@ -4,7 +4,7 @@
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
"This file is @generated automatically"
],
"content-hash": "af57c523401fba0e523501b76e0629f0",
"content-hash": "c70cc2a152b707d0dcf4c562bd06f8d5",
"packages": [
{
"name": "api-platform/core",

View File

@ -12,6 +12,8 @@ use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
#[Route('/api/git-repos')]
class GitRepositoryController extends AbstractController
@ -20,7 +22,8 @@ class GitRepositoryController extends AbstractController
private GitService $gitService,
private GitHubService $githubService,
private GiteaService $giteaService,
private EntityManagerInterface $em
private EntityManagerInterface $em,
private CacheInterface $cache
) {
}
@ -31,22 +34,33 @@ class GitRepositoryController extends AbstractController
$limit = (int)$request->query->get('limit', 100);
$branch = $request->query->get('branch', $gitRepo->getBranch() ?? 'main');
// Choose service based on provider
if ($gitRepo->getProvider() === 'github') {
$commits = $this->getCommitsFromGitHub($gitRepo, $branch, $limit);
} elseif ($gitRepo->getProvider() === 'gitea') {
$commits = $this->getCommitsFromGitea($gitRepo, $branch, $limit);
} elseif ($gitRepo->getLocalPath()) {
$commits = $this->gitService->getCommits(
$gitRepo->getLocalPath(),
$branch,
$limit
);
} else {
return $this->json([
'error' => 'No data source configured (neither provider API nor local path)'
], Response::HTTP_BAD_REQUEST);
}
// Cache key unique per repository, branch, and limit
$cacheKey = sprintf(
'git_commits_%d_%s_%d',
$gitRepo->getId(),
md5($branch),
$limit
);
$commits = $this->cache->get($cacheKey, function (ItemInterface $item) use ($gitRepo, $branch, $limit) {
// Cache for 15 minutes (commits change frequently during active development)
$item->expiresAfter(900);
// Choose service based on provider
if ($gitRepo->getProvider() === 'github') {
return $this->getCommitsFromGitHub($gitRepo, $branch, $limit);
} elseif ($gitRepo->getProvider() === 'gitea') {
return $this->getCommitsFromGitea($gitRepo, $branch, $limit);
} elseif ($gitRepo->getLocalPath()) {
return $this->gitService->getCommits(
$gitRepo->getLocalPath(),
$branch,
$limit
);
}
throw new \Exception('No data source configured (neither provider API nor local path)');
});
// Update last sync timestamp
$gitRepo->setLastSync(new \DateTime());
@ -89,23 +103,35 @@ class GitRepositoryController extends AbstractController
$author = $request->query->get('author');
$year = (int)$request->query->get('year', date('Y'));
// Choose service based on provider
if ($gitRepo->getProvider() === 'github') {
$contributions = $this->getContributionsFromGitHub($gitRepo, $year);
} elseif ($gitRepo->getProvider() === 'gitea') {
$contributions = $this->getContributionsFromGitea($gitRepo, $branch, $author, $year);
} elseif ($gitRepo->getLocalPath()) {
$contributions = $this->gitService->getContributions(
$gitRepo->getLocalPath(),
$branch,
$author,
$year
);
} else {
return $this->json([
'error' => 'No data source configured (neither provider API nor local path)'
], Response::HTTP_BAD_REQUEST);
}
// Cache key unique per repository, branch, author, and year
$cacheKey = sprintf(
'git_contributions_%d_%s_%s_%d',
$gitRepo->getId(),
md5($branch),
md5($author ?? 'all'),
$year
);
$contributions = $this->cache->get($cacheKey, function (ItemInterface $item) use ($gitRepo, $branch, $author, $year) {
// Cache for 1 hour (contributions data is relatively stable)
$item->expiresAfter(3600);
// Choose service based on provider
if ($gitRepo->getProvider() === 'github') {
return $this->getContributionsFromGitHub($gitRepo, $year);
} elseif ($gitRepo->getProvider() === 'gitea') {
return $this->getContributionsFromGitea($gitRepo, $branch, $author, $year);
} elseif ($gitRepo->getLocalPath()) {
return $this->gitService->getContributions(
$gitRepo->getLocalPath(),
$branch,
$author,
$year
);
}
throw new \Exception('No data source configured (neither provider API nor local path)');
});
// Update last sync timestamp
$gitRepo->setLastSync(new \DateTime());
@ -141,6 +167,55 @@ class GitRepositoryController extends AbstractController
return $this->giteaService->getContributions($parsed['baseUrl'], $parsed['owner'], $parsed['repo'], $branch, $author, $year);
}
#[Route('/{id}/refresh-cache', name: 'api_git_repo_refresh_cache', methods: ['POST'])]
public function refreshCache(GitRepository $gitRepo): JsonResponse
{
$this->denyAccessUnlessGranted('IS_AUTHENTICATED_FULLY');
try {
// Invalidate all cache entries for this repository
$repoId = $gitRepo->getId();
$years = range(date('Y') - 2, date('Y') + 1); // Last 2 years + current + next
$deletedKeys = [];
// Delete contribution caches for multiple years
foreach ($years as $year) {
$patterns = [
sprintf('git_contributions_%d_*_%d', $repoId, $year),
];
foreach ($patterns as $pattern) {
// Note: Pattern deletion requires specific cache adapter support
// For filesystem cache, we need to delete specific known keys
$key = sprintf('git_contributions_%d_%s_all_%d', $repoId, md5('main'), $year);
if ($this->cache->delete($key)) {
$deletedKeys[] = $key;
}
}
}
// Delete commit caches
$commitKey = sprintf('git_commits_%d_%s_100', $repoId, md5('main'));
if ($this->cache->delete($commitKey)) {
$deletedKeys[] = $commitKey;
}
// Update last sync timestamp
$gitRepo->setLastSync(new \DateTime());
$this->em->flush();
return $this->json([
'success' => true,
'message' => 'Cache erfolgreich invalidiert. Nächste Anfrage lädt frische Daten.',
'deletedKeys' => count($deletedKeys)
]);
} catch (\Exception $e) {
return $this->json([
'success' => false,
'error' => 'Cache-Invalidierung fehlgeschlagen: ' . $e->getMessage()
], Response::HTTP_INTERNAL_SERVER_ERROR);
}
}
#[Route('/{id}/branches', name: 'api_git_repo_branches', methods: ['GET'])]
public function getBranches(GitRepository $gitRepo): JsonResponse
{

View File

@ -0,0 +1,62 @@
<?php
namespace App\EventListener;
use App\Entity\GitRepository;
use Doctrine\Bundle\DoctrineBundle\Attribute\AsEntityListener;
use Doctrine\ORM\Event\PreUpdateEventArgs;
use Doctrine\ORM\Event\PreRemoveEventArgs;
use Doctrine\ORM\Events;
use Symfony\Contracts\Cache\CacheInterface;
#[AsEntityListener(event: Events::preUpdate, entity: GitRepository::class)]
#[AsEntityListener(event: Events::preRemove, entity: GitRepository::class)]
class GitRepositoryCacheListener
{
public function __construct(
private CacheInterface $cache
) {
}
public function preUpdate(GitRepository $gitRepo, PreUpdateEventArgs $event): void
{
// Only invalidate if relevant fields changed
if ($event->hasChangedField('branch') ||
$event->hasChangedField('url') ||
$event->hasChangedField('localPath') ||
$event->hasChangedField('provider')) {
$this->invalidateRepositoryCache($gitRepo);
}
}
public function preRemove(GitRepository $gitRepo, PreRemoveEventArgs $event): void
{
$this->invalidateRepositoryCache($gitRepo);
}
private function invalidateRepositoryCache(GitRepository $gitRepo): void
{
// Delete all cache entries for this repository
$repoId = $gitRepo->getId();
$years = range(date('Y') - 2, date('Y') + 1);
// Delete contribution caches
foreach ($years as $year) {
// Delete for common branches and author combinations
$branches = ['main', 'master', 'develop'];
foreach ($branches as $branch) {
$key = sprintf('git_contributions_%d_%s_all_%d', $repoId, md5($branch), $year);
$this->cache->delete($key);
}
}
// Delete commit caches
$limits = [50, 100, 200];
foreach ($limits as $limit) {
foreach (['main', 'master', 'develop'] as $branch) {
$key = sprintf('git_commits_%d_%s_%d', $repoId, md5($branch), $limit);
$this->cache->delete($key);
}
}
}
}