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>
270 lines
8.2 KiB
PHP
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);
|
|
}
|
|
}
|
|
}
|