Files
Lesstime/src/Service/GiteaApiService.php
matthieu e4fc34b90f refactor : simplify codebase and fix critical issues
Backend:
- Add MCP Serializer to centralize entity-to-array conversion (~300 lines deduped)
- Fix race condition in task/ticket number generation (SELECT FOR UPDATE + transaction)
- Add unique constraint on task (project_id, number) with migration
- Fix MIME type validation: use server-detected finfo instead of client-supplied type
- Add allowlist of permitted MIME types for uploads
- Fix TaskDocumentDownloadController: allow ROLE_CLIENT access, add priority:1
- Fix notification sent even when ticket status unchanged
- Remove redundant exception constructors
- Simplify services (BookStackApi double fetch, TokenEncryptor, GiteaApi)
- Consolidate duplicate checks in processors

Frontend:
- Fix useApi isHandlingUnauthorized scope (module-level to prevent double 401 redirect)
- Fix client-tickets toast key copy-paste bug
- Merge duplicated tasks service methods (getByProject + getByProjectArchived)
- Extract shared uploadWithRelation helper in task-documents service
- Extract formatFileSize utility from duplicated component code
- Extract status transition logic into useClientTicketHelpers composable
- Remove dead code (unused router, handleLogout, empty script blocks)
- Merge duplicate watchers and onMounted calls
- Normalize arrow functions to function declarations per convention

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 22:09:16 +01:00

270 lines
8.2 KiB
PHP

<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\GiteaConfiguration;
use App\Entity\Project;
use App\Entity\Task;
use App\Exception\GiteaApiException;
use App\Repository\GiteaConfigurationRepository;
use Symfony\Component\String\Slugger\AsciiSlugger;
use Symfony\Component\String\Slugger\SluggerInterface;
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
use Symfony\Contracts\HttpClient\Exception\HttpExceptionInterface;
use Symfony\Contracts\HttpClient\HttpClientInterface;
use Throwable;
final readonly class GiteaApiService
{
private SluggerInterface $slugger;
public function __construct(
private HttpClientInterface $httpClient,
private GiteaConfigurationRepository $configRepository,
private TokenEncryptor $tokenEncryptor,
) {
$this->slugger = new AsciiSlugger('fr');
}
public function testConnection(): bool
{
try {
$this->request('GET', '/api/v1/version');
return true;
} catch (GiteaApiException) {
return false;
}
}
/**
* @return array<array{full_name: string, name: string, owner: array{login: string}}>
*/
public function listRepositories(): array
{
$result = [];
$page = 1;
do {
$data = $this->request('GET', '/api/v1/repos/search', [
'query' => ['page' => $page, 'limit' => 50],
]);
$result = array_merge($result, $data['data'] ?? []);
++$page;
} while (!empty($data['data']) && 50 === count($data['data']));
return $result;
}
public function getDefaultBranch(Project $project): string
{
$this->assertProjectHasRepo($project);
$data = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
));
return $data['default_branch'] ?? 'main';
}
public function createBranch(Project $project, Task $task, string $type, string $baseBranch): string
{
$this->assertProjectHasRepo($project);
$branchName = $this->generateBranchName($task, $type);
$this->request('POST', sprintf(
'/api/v1/repos/%s/%s/branches',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
), [
'json' => [
'new_branch_name' => $branchName,
'old_branch_name' => $baseBranch,
],
]);
return $branchName;
}
public function generateBranchName(Task $task, string $type): string
{
$project = $task->getProject();
if (null === $project) {
throw new GiteaApiException('Task has no project.');
}
$slug = $this->slugger->slug($task->getTitle())->lower()->truncate(50)->toString();
$slug = rtrim($slug, '-');
return sprintf('%s/%s-%d-%s', $type, $project->getCode(), $task->getNumber(), $slug);
}
/**
* @return array<array{name: string, commit: array}>
*/
public function listBranches(Project $project, string $taskCode): array
{
$this->assertProjectHasRepo($project);
$allBranches = [];
$page = 1;
do {
$pageBranches = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/branches',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
), [
'query' => ['page' => $page, 'limit' => 50],
]);
$allBranches = array_merge($allBranches, $pageBranches);
++$page;
} while (!empty($pageBranches) && 50 === count($pageBranches));
$regex = sprintf('#^[^/]+/%s($|-.+)#', preg_quote($taskCode, '#'));
return array_values(array_filter(
$allBranches,
static fn (array $branch): bool => 1 === preg_match($regex, $branch['name']),
));
}
/**
* @return array<array{sha: string, commit: array{message: string, author: array}, created: string}>
*/
public function listBranchCommits(Project $project, string $branch): array
{
$this->assertProjectHasRepo($project);
$defaultBranch = $this->getDefaultBranch($project);
$data = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/compare/%s...%s',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
$defaultBranch,
urlencode($branch),
));
return $data['commits'] ?? [];
}
/**
* @return array<array{number: int, title: string, state: string, head: array, user: array, merged: bool}>
*/
public function listPullRequests(Project $project, string $taskCode): array
{
$this->assertProjectHasRepo($project);
$branches = $this->listBranches($project, $taskCode);
$prs = [];
foreach ($branches as $branch) {
$branchPrs = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/pulls',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
), [
'query' => ['state' => 'all', 'head' => $branch['name']],
]);
$prs = array_merge($prs, $branchPrs);
}
// Fetch CI status for each PR
foreach ($prs as &$pr) {
$sha = $pr['head']['sha'] ?? null;
if (null !== $sha) {
try {
$pr['ci_statuses'] = $this->request('GET', sprintf(
'/api/v1/repos/%s/%s/commits/%s/statuses',
$project->getGiteaOwner(),
$project->getGiteaRepo(),
$sha,
));
} catch (GiteaApiException) {
$pr['ci_statuses'] = [];
}
}
}
return $prs;
}
private function getConfiguration(): GiteaConfiguration
{
$config = $this->configRepository->findSingleton();
if (null === $config) {
throw new GiteaApiException('Gitea is not configured.');
}
return $config;
}
private function getDecryptedToken(GiteaConfiguration $config): string
{
$encrypted = $config->getEncryptedToken();
if (null === $encrypted) {
throw new GiteaApiException('Gitea token is not set.');
}
try {
return $this->tokenEncryptor->decrypt($encrypted);
} catch (Throwable $e) {
throw new GiteaApiException('Failed to decrypt Gitea token: '.$e->getMessage(), 0, $e);
}
}
private function extractGiteaError(HttpExceptionInterface $e): string
{
try {
$body = $e->getResponse()->getContent(false);
$data = json_decode($body, true);
if (is_array($data)) {
return $data['message'] ?? $data['error'] ?? $body;
}
return $body ?: 'Unknown Gitea error';
} catch (ExceptionInterface) {
return 'Gitea API error (HTTP '.$e->getResponse()->getStatusCode().')';
}
}
private function assertProjectHasRepo(Project $project): void
{
if (!$project->hasGiteaRepo()) {
throw new GiteaApiException('Project has no Gitea repository configured.');
}
}
/**
* @param array<string, mixed> $options
*/
private function request(string $method, string $path, array $options = []): array
{
$config = $this->getConfiguration();
$token = $this->getDecryptedToken($config);
$options['headers'] = array_merge($options['headers'] ?? [], [
'Authorization' => 'token '.$token,
'Accept' => 'application/json',
]);
$options['timeout'] = 10;
try {
$response = $this->httpClient->request($method, rtrim($config->getUrl(), '/').$path, $options);
return $response->toArray();
} catch (HttpExceptionInterface $e) {
$message = $this->extractGiteaError($e);
throw new GiteaApiException($message, $e->getResponse()->getStatusCode(), $e);
} catch (ExceptionInterface $e) {
throw new GiteaApiException('Gitea API error: '.$e->getMessage(), 0, $e);
}
}
}