From 3ec9424bb2ecfb10219c97de82b989494a804650 Mon Sep 17 00:00:00 2001 From: Matthieu Date: Fri, 13 Mar 2026 13:40:28 +0100 Subject: [PATCH] docs : add Gitea integration implementation plan 23 tasks across 7 chunks covering: - Backend: GiteaConfiguration entity, TokenEncryptor, GiteaApiService - API: settings CRUD, test connection, repositories list, task branches/PRs - Frontend: DTOs, service, admin tab, ProjectDrawer repo selector, TaskGitSection Co-Authored-By: Claude Opus 4.6 --- .../plans/2026-03-13-gitea-integration.md | 2248 +++++++++++++++++ 1 file changed, 2248 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-13-gitea-integration.md diff --git a/docs/superpowers/plans/2026-03-13-gitea-integration.md b/docs/superpowers/plans/2026-03-13-gitea-integration.md new file mode 100644 index 0000000..19a9e76 --- /dev/null +++ b/docs/superpowers/plans/2026-03-13-gitea-integration.md @@ -0,0 +1,2248 @@ +# Gitea Integration Implementation Plan + +> **For agentic workers:** REQUIRED: Use superpowers:subagent-driven-development (if subagents available) or superpowers:executing-plans to implement this plan. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Allow users to create Gitea branches from tickets, and view branches/commits/PRs/CI status linked to tickets — all fetched on-demand from the Gitea API. + +**Architecture:** Backend adds a `GiteaConfiguration` singleton entity (encrypted token), extends `Project` with `gitea_owner`/`gitea_repo` fields, and provides a `GiteaApiService` that wraps Gitea REST API calls. Custom API Platform endpoints expose config management (admin) and task-level git info. Frontend adds an admin tab for Gitea config, repo selection in ProjectDrawer, and a `TaskGitSection` component in TaskModal. + +**Tech Stack:** PHP 8.4, Symfony 8, API Platform 4, Doctrine ORM, Nuxt 4, Vue 3, TypeScript, Tailwind CSS + +**Spec:** `docs/superpowers/specs/2026-03-13-gitea-integration-design.md` + +--- + +## Chunk 1: Backend — Entity & Migration + +### Task 1: GiteaConfiguration Entity + +**Files:** +- Create: `src/Entity/GiteaConfiguration.php` +- Create: `src/Repository/GiteaConfigurationRepository.php` + +- [ ] **Step 1: Create GiteaConfigurationRepository** + +```php +findOneBy([]); + } +} +``` + +- [ ] **Step 2: Create GiteaConfiguration entity** + +```php +id; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function setUrl(?string $url): static + { + $this->url = $url; + + return $this; + } + + public function getEncryptedToken(): ?string + { + return $this->encryptedToken; + } + + public function setEncryptedToken(?string $encryptedToken): static + { + $this->encryptedToken = $encryptedToken; + + return $this; + } + + public function hasToken(): bool + { + return null !== $this->encryptedToken; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Entity/GiteaConfiguration.php src/Repository/GiteaConfigurationRepository.php +git commit -m "feat : add GiteaConfiguration entity with repository" +``` + +### Task 2: Add gitea fields to Project entity + +**Files:** +- Modify: `src/Entity/Project.php` + +- [ ] **Step 1: Add giteaOwner and giteaRepo fields to Project** + +Add after the `$client` property (around line 65): + +```php +#[ORM\Column(length: 255, nullable: true)] +#[Groups(['project:read', 'project:write'])] +private ?string $giteaOwner = null; + +#[ORM\Column(length: 255, nullable: true)] +#[Groups(['project:read', 'project:write'])] +private ?string $giteaRepo = null; +``` + +Add getters/setters at the end of the class: + +```php +public function getGiteaOwner(): ?string +{ + return $this->giteaOwner; +} + +public function setGiteaOwner(?string $giteaOwner): static +{ + $this->giteaOwner = $giteaOwner; + + return $this; +} + +public function getGiteaRepo(): ?string +{ + return $this->giteaRepo; +} + +public function setGiteaRepo(?string $giteaRepo): static +{ + $this->giteaRepo = $giteaRepo; + + return $this; +} + +public function hasGiteaRepo(): bool +{ + return null !== $this->giteaOwner && null !== $this->giteaRepo; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Entity/Project.php +git commit -m "feat : add gitea owner/repo fields to Project entity" +``` + +### Task 3: Generate and run migration + +**Files:** +- Create: `migrations/VersionYYYYMMDDHHMMSS.php` (auto-generated) + +- [ ] **Step 1: Generate migration** + +```bash +make shell +# Inside container: +php bin/console doctrine:migrations:diff +exit +``` + +- [ ] **Step 2: Run migration** + +```bash +make migration-migrate +``` + +- [ ] **Step 3: Commit** + +```bash +git add migrations/ +git commit -m "feat : add migration for GiteaConfiguration and Project gitea fields" +``` + +--- + +## Chunk 2: Backend — Token Encryption & GiteaApiService + +### Task 4: Token encryption service + +**Files:** +- Create: `src/Service/TokenEncryptor.php` + +- [ ] **Step 1: Add GITEA_ENCRYPTION_KEY to .env** + +Add to `.env`: + +``` +GITEA_ENCRYPTION_KEY= +``` + +- [ ] **Step 2: Create TokenEncryptor service** + +```php +key = sodium_hex2bin($encryptionKey); + + if (mb_strlen($this->key, '8bit') !== SODIUM_CRYPTO_SECRETBOX_KEYBYTES) { + throw new \InvalidArgumentException('GITEA_ENCRYPTION_KEY must be a valid sodium secret box key.'); + } + } + + public function encrypt(string $plaintext): string + { + $nonce = random_bytes(SODIUM_CRYPTO_SECRETBOX_NONCEBYTES); + $ciphertext = sodium_crypto_secretbox($plaintext, $nonce, $this->key); + + return sodium_bin2hex($nonce . $ciphertext); + } + + public function decrypt(string $encrypted): string + { + $decoded = sodium_hex2bin($encrypted); + $nonce = mb_substr($decoded, 0, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, '8bit'); + $ciphertext = mb_substr($decoded, SODIUM_CRYPTO_SECRETBOX_NONCEBYTES, null, '8bit'); + + $plaintext = sodium_crypto_secretbox_open($ciphertext, $nonce, $this->key); + + if (false === $plaintext) { + throw new \RuntimeException('Failed to decrypt token.'); + } + + return $plaintext; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/Service/TokenEncryptor.php .env +git commit -m "feat : add TokenEncryptor service with sodium encryption" +``` + +### Task 5: GiteaApiException + +**Files:** +- Create: `src/Exception/GiteaApiException.php` + +- [ ] **Step 1: Create exception class** + +```php +slugger = new AsciiSlugger('fr'); + } + + public function testConnection(): bool + { + try { + $this->request('GET', '/api/v1/version'); + + return true; + } catch (GiteaApiException) { + return false; + } + } + + /** + * @return array + */ + 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']) && count($data['data']) === 50); + + 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 + */ + 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) && count($pageBranches) === 50); + + $regex = sprintf('#^[^/]+/%s($|-.+)#', preg_quote($taskCode, '#')); + + return array_values(array_filter($allBranches, static function (array $branch) use ($regex): bool { + return 1 === preg_match($regex, $branch['name']); + })); + } + + /** + * @return array + */ + public function listCommits(Project $project, string $branch): array + { + $this->assertProjectHasRepo($project); + + return $this->request('GET', sprintf( + '/api/v1/repos/%s/%s/commits', + $project->getGiteaOwner(), + $project->getGiteaRepo(), + ), [ + 'query' => ['sha' => $branch, 'limit' => 30], + ]); + } + + /** + * @return array + */ + 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.'); + } + + return $this->tokenEncryptor->decrypt($encrypted); + } + + private function assertProjectHasRepo(Project $project): void + { + if (!$project->hasGiteaRepo()) { + throw new GiteaApiException('Project has no Gitea repository configured.'); + } + } + + /** + * @param array $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 (ExceptionInterface $e) { + throw new GiteaApiException('Gitea API error: ' . $e->getMessage(), 0, $e); + } + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add src/Service/GiteaApiService.php +git commit -m "feat : add GiteaApiService with branch/commit/PR methods" +``` + +--- + +## Chunk 3: Backend — API Endpoints + +### Task 7: Gitea Settings API Resource & Providers/Processors + +**Files:** +- Create: `src/ApiResource/GiteaSettings.php` +- Create: `src/State/GiteaSettingsProvider.php` +- Create: `src/State/GiteaSettingsProcessor.php` + +- [ ] **Step 1: Create GiteaSettings API Resource** + +```php + ['gitea_settings:read']], + provider: GiteaSettingsProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + new Put( + uriTemplate: '/settings/gitea', + denormalizationContext: ['groups' => ['gitea_settings:write']], + normalizationContext: ['groups' => ['gitea_settings:read']], + provider: GiteaSettingsProvider::class, + processor: GiteaSettingsProcessor::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class GiteaSettings +{ + #[Groups(['gitea_settings:read', 'gitea_settings:write'])] + public ?string $url = null; + + #[Groups(['gitea_settings:write'])] + public ?string $token = null; + + #[Groups(['gitea_settings:read'])] + public bool $hasToken = false; +} +``` + +- [ ] **Step 2: Create GiteaSettingsProvider** + +```php +configRepository->findSingleton(); + $dto = new GiteaSettings(); + + if (null !== $config) { + $dto->url = $config->getUrl(); + $dto->hasToken = $config->hasToken(); + } + + return $dto; + } +} +``` + +- [ ] **Step 3: Create GiteaSettingsProcessor** + +```php +configRepository->findSingleton(); + if (null === $config) { + $config = new GiteaConfiguration(); + } + + $config->setUrl($data->url); + + if (null !== $data->token && '' !== $data->token) { + $config->setEncryptedToken($this->tokenEncryptor->encrypt($data->token)); + } + + $this->em->persist($config); + $this->em->flush(); + + $result = new GiteaSettings(); + $result->url = $config->getUrl(); + $result->hasToken = $config->hasToken(); + + return $result; + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/GiteaSettings.php src/State/GiteaSettingsProvider.php src/State/GiteaSettingsProcessor.php +git commit -m "feat : add Gitea settings API resource with provider/processor" +``` + +### Task 8: Gitea Test Connection Endpoint + +**Files:** +- Create: `src/ApiResource/GiteaTestConnection.php` +- Create: `src/State/GiteaTestConnectionProvider.php` + +- [ ] **Step 1: Create GiteaTestConnection API Resource** + +```php + ['gitea_test:read']], + provider: GiteaTestConnectionProvider::class, + processor: GiteaTestConnectionProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class GiteaTestConnection +{ + #[Groups(['gitea_test:read'])] + public bool $success = false; +} +``` + +- [ ] **Step 2: Create GiteaTestConnectionProvider** + +```php +success = $this->giteaApiService->testConnection(); + + return $result; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/GiteaTestConnection.php src/State/GiteaTestConnectionProvider.php +git commit -m "feat : add Gitea test connection endpoint" +``` + +### Task 9: Gitea Repositories List Endpoint + +**Files:** +- Create: `src/ApiResource/GiteaRepository.php` +- Create: `src/State/GiteaRepositoryProvider.php` + +- [ ] **Step 1: Create GiteaRepository API Resource** + +```php + ['gitea_repo:read']], + provider: GiteaRepositoryProvider::class, + security: "is_granted('ROLE_ADMIN')", + ), + ], +)] +final class GiteaRepository +{ + #[Groups(['gitea_repo:read'])] + public string $fullName = ''; + + #[Groups(['gitea_repo:read'])] + public string $name = ''; + + #[Groups(['gitea_repo:read'])] + public string $owner = ''; +} +``` + +- [ ] **Step 2: Create GiteaRepositoryProvider** + +```php +giteaApiService->listRepositories(); + } catch (GiteaApiException) { + return []; + } + + return array_map(static function (array $repo): GiteaRepository { + $dto = new GiteaRepository(); + $dto->fullName = $repo['full_name'] ?? ''; + $dto->name = $repo['name'] ?? ''; + $dto->owner = $repo['owner']['login'] ?? ''; + + return $dto; + }, $repos); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/GiteaRepository.php src/State/GiteaRepositoryProvider.php +git commit -m "feat : add Gitea repositories list endpoint" +``` + +### Task 10: Task Gitea Branches Endpoint + +**Files:** +- Create: `src/ApiResource/GiteaBranch.php` +- Create: `src/State/GiteaBranchProvider.php` +- Create: `src/State/GiteaBranchProcessor.php` + +- [ ] **Step 1: Create GiteaBranch API Resource** + +```php + ['gitea_branch:read']], + provider: GiteaBranchProvider::class, + ), + new Post( + uriTemplate: '/tasks/{taskId}/gitea/branches', + denormalizationContext: ['groups' => ['gitea_branch:write']], + normalizationContext: ['groups' => ['gitea_branch:read']], + provider: GiteaBranchProvider::class, + processor: GiteaBranchProcessor::class, + ), + ], +)] +final class GiteaBranch +{ + #[Groups(['gitea_branch:read'])] + public string $name = ''; + + #[Groups(['gitea_branch:write'])] + public string $type = 'feature'; + + #[Groups(['gitea_branch:write'])] + public string $baseBranch = 'main'; + + /** + * @var array + */ + #[Groups(['gitea_branch:read'])] + public array $commits = []; +} +``` + +- [ ] **Step 2: Create GiteaBranchProvider** + +```php +em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0); + if (null === $task || null === $task->getProject()) { + return []; + } + + $project = $task->getProject(); + if (!$project->hasGiteaRepo()) { + return []; + } + + $taskCode = $project->getCode() . '-' . $task->getNumber(); + + try { + $branches = $this->giteaApiService->listBranches($project, $taskCode); + } catch (GiteaApiException) { + return []; + } + + $result = []; + foreach ($branches as $branch) { + $dto = new GiteaBranch(); + $dto->name = $branch['name']; + + try { + $commits = $this->giteaApiService->listCommits($project, $branch['name']); + $dto->commits = array_map(static fn(array $c): array => [ + 'sha' => substr($c['sha'] ?? '', 0, 7), + 'message' => $c['commit']['message'] ?? '', + 'author' => $c['commit']['author']['name'] ?? '', + 'date' => $c['commit']['author']['date'] ?? $c['created'] ?? '', + ], $commits); + } catch (GiteaApiException) { + $dto->commits = []; + } + + $result[] = $dto; + } + + return $result; + } +} +``` + +- [ ] **Step 3: Create GiteaBranchProcessor** + +```php +em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0); + if (null === $task || null === $task->getProject()) { + throw new NotFoundHttpException('Task not found.'); + } + + $project = $task->getProject(); + if (!$project->hasGiteaRepo()) { + throw new BadRequestHttpException('Project has no Gitea repository.'); + } + + if (!in_array($data->type, self::ALLOWED_TYPES, true)) { + throw new BadRequestHttpException('Invalid branch type.'); + } + + try { + $branchName = $this->giteaApiService->createBranch($project, $task, $data->type, $data->baseBranch); + } catch (GiteaApiException $e) { + throw new BadRequestHttpException($e->getMessage()); + } + + $result = new GiteaBranch(); + $result->name = $branchName; + + return $result; + } +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add src/ApiResource/GiteaBranch.php src/State/GiteaBranchProvider.php src/State/GiteaBranchProcessor.php +git commit -m "feat : add task Gitea branches endpoints (list + create)" +``` + +### Task 11: Task Gitea Pull Requests Endpoint + +**Files:** +- Create: `src/ApiResource/GiteaPullRequest.php` +- Create: `src/State/GiteaPullRequestProvider.php` + +- [ ] **Step 1: Create GiteaPullRequest API Resource** + +```php + ['gitea_pr:read']], + provider: GiteaPullRequestProvider::class, + ), + ], +)] +final class GiteaPullRequest +{ + #[Groups(['gitea_pr:read'])] + public int $number = 0; + + #[Groups(['gitea_pr:read'])] + public string $title = ''; + + #[Groups(['gitea_pr:read'])] + public string $state = ''; + + #[Groups(['gitea_pr:read'])] + public bool $merged = false; + + #[Groups(['gitea_pr:read'])] + public string $headBranch = ''; + + #[Groups(['gitea_pr:read'])] + public string $author = ''; + + #[Groups(['gitea_pr:read'])] + public string $url = ''; + + /** + * @var array + */ + #[Groups(['gitea_pr:read'])] + public array $ciStatuses = []; +} +``` + +- [ ] **Step 2: Create GiteaPullRequestProvider** + +```php +em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0); + if (null === $task || null === $task->getProject()) { + return []; + } + + $project = $task->getProject(); + if (!$project->hasGiteaRepo()) { + return []; + } + + $taskCode = $project->getCode() . '-' . $task->getNumber(); + + try { + $prs = $this->giteaApiService->listPullRequests($project, $taskCode); + } catch (GiteaApiException) { + return []; + } + + return array_map(static function (array $pr): GiteaPullRequest { + $dto = new GiteaPullRequest(); + $dto->number = $pr['number'] ?? 0; + $dto->title = $pr['title'] ?? ''; + $dto->state = $pr['state'] ?? ''; + $dto->merged = $pr['merged'] ?? false; + $dto->headBranch = $pr['head']['ref'] ?? ''; + $dto->author = $pr['user']['login'] ?? ''; + $dto->url = $pr['html_url'] ?? ''; + $dto->ciStatuses = array_map(static fn(array $s): array => [ + 'context' => $s['context'] ?? '', + 'status' => $s['status'] ?? '', + 'target_url' => $s['target_url'] ?? '', + ], $pr['ci_statuses'] ?? []); + + return $dto; + }, $prs); + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/GiteaPullRequest.php src/State/GiteaPullRequestProvider.php +git commit -m "feat : add task Gitea pull requests endpoint" +``` + +### Task 12: Branch Name Generation Endpoint + +**Files:** +- Create: `src/ApiResource/GiteaBranchName.php` +- Create: `src/State/GiteaBranchNameProvider.php` + +- [ ] **Step 1: Create GiteaBranchName API Resource** + +This endpoint generates the branch name without creating it (for the "copy" button). + +```php + ['gitea_branch_name:read']], + provider: GiteaBranchNameProvider::class, + ), + ], +)] +final class GiteaBranchName +{ + #[Groups(['gitea_branch_name:read'])] + public string $name = ''; +} +``` + +- [ ] **Step 2: Create GiteaBranchNameProvider** + +```php +em->getRepository(Task::class)->find($uriVariables['taskId'] ?? 0); + if (null === $task) { + throw new NotFoundHttpException('Task not found.'); + } + + $type = $uriVariables['type'] ?? 'feature'; + if (!in_array($type, self::ALLOWED_TYPES, true)) { + throw new BadRequestHttpException('Invalid branch type.'); + } + + $dto = new GiteaBranchName(); + $dto->name = $this->giteaApiService->generateBranchName($task, $type); + + return $dto; + } +} +``` + +- [ ] **Step 3: Commit** + +```bash +git add src/ApiResource/GiteaBranchName.php src/State/GiteaBranchNameProvider.php +git commit -m "feat : add branch name generation endpoint" +``` + +--- + +## Chunk 4: Frontend — Service Layer & DTOs + +### Task 13: Frontend Gitea DTOs + +**Files:** +- Create: `frontend/services/dto/gitea.ts` + +- [ ] **Step 1: Create Gitea DTOs** + +```typescript +export type GiteaSettings = { + url: string | null + hasToken: boolean +} + +export type GiteaSettingsWrite = { + url: string | null + token: string | null +} + +export type GiteaRepository = { + fullName: string + name: string + owner: string +} + +export type GiteaBranch = { + name: string + commits: GiteaCommit[] +} + +export type GiteaCommit = { + sha: string + message: string + author: string + date: string +} + +export type GiteaBranchCreate = { + type: string + baseBranch: string +} + +export type GiteaPullRequest = { + number: number + title: string + state: string + merged: boolean + headBranch: string + author: string + url: string + ciStatuses: GiteaCiStatus[] +} + +export type GiteaCiStatus = { + context: string + status: string + target_url: string +} + +export type GiteaBranchName = { + name: string +} + +export type GiteaTestResult = { + success: boolean +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/services/dto/gitea.ts +git commit -m "feat : add Gitea TypeScript DTOs" +``` + +### Task 14: Frontend Gitea Service + +**Files:** +- Create: `frontend/services/gitea.ts` + +- [ ] **Step 1: Create Gitea service** + +```typescript +import type { + GiteaSettings, + GiteaSettingsWrite, + GiteaRepository, + GiteaBranch, + GiteaBranchCreate, + GiteaPullRequest, + GiteaBranchName, + GiteaTestResult, +} from './dto/gitea' +import type { HydraCollection } from '~/utils/api' +import { extractHydraMembers } from '~/utils/api' + +export function useGiteaService() { + const api = useApi() + + async function getSettings(): Promise { + return api.get('/settings/gitea') + } + + async function saveSettings(payload: GiteaSettingsWrite): Promise { + return api.put('/settings/gitea', payload as Record, { + toastSuccessKey: 'gitea.settings.saved', + }) + } + + async function testConnection(): Promise { + return api.post('/settings/gitea/test') + } + + async function listRepositories(): Promise { + const data = await api.get>('/gitea/repositories') + return extractHydraMembers(data) + } + + async function listBranches(taskId: number): Promise { + const data = await api.get>(`/tasks/${taskId}/gitea/branches`) + return extractHydraMembers(data) + } + + async function createBranch(taskId: number, payload: GiteaBranchCreate): Promise { + return api.post(`/tasks/${taskId}/gitea/branches`, payload as Record, { + toastSuccessKey: 'gitea.branch.created', + }) + } + + async function listPullRequests(taskId: number): Promise { + const data = await api.get>(`/tasks/${taskId}/gitea/pull-requests`) + return extractHydraMembers(data) + } + + async function getBranchName(taskId: number, type: string): Promise { + return api.get(`/tasks/${taskId}/gitea/branch-name/${type}`) + } + + return { + getSettings, + saveSettings, + testConnection, + listRepositories, + listBranches, + createBranch, + listPullRequests, + getBranchName, + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/services/gitea.ts +git commit -m "feat : add Gitea frontend service" +``` + +### Task 15: Add Project gitea fields to DTO + +**Files:** +- Modify: `frontend/services/dto/project.ts` + +- [ ] **Step 1: Add giteaOwner and giteaRepo to Project type** + +Add to the `Project` type after `client`: + +```typescript +giteaOwner: string | null +giteaRepo: string | null +``` + +Add to `ProjectWrite` type after `client`: + +```typescript +giteaOwner?: string | null +giteaRepo?: string | null +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/services/dto/project.ts +git commit -m "feat : add gitea fields to Project DTO" +``` + +--- + +## Chunk 5: Frontend — Admin Gitea Tab + +### Task 16: i18n keys + +**Files:** +- Modify: `frontend/i18n/locales/fr.json` + +- [ ] **Step 1: Add Gitea i18n keys** + +Add a `"gitea"` section to the JSON: + +```json +"common": { + "cancel": "Annuler", + "loading": "Chargement..." +}, +"gitea": { + "settings": { + "title": "Configuration Gitea", + "url": "URL du serveur", + "urlPlaceholder": "https://git.example.com", + "token": "Token API", + "tokenPlaceholder": "Entrez un nouveau token", + "tokenConfigured": "Token configuré", + "save": "Enregistrer", + "saved": "Configuration Gitea sauvegardée.", + "testConnection": "Tester la connexion", + "testSuccess": "Connexion réussie.", + "testFailed": "Connexion échouée." + }, + "branch": { + "title": "Git", + "create": "Créer une branche", + "created": "Branche créée avec succès.", + "copy": "Copier le nom", + "copied": "Nom de branche copié.", + "type": "Type", + "baseBranch": "Branche de base", + "preview": "Aperçu", + "types": { + "feature": "feature", + "fix": "fix", + "refactor": "refactor", + "hotfix": "hotfix", + "chore": "chore" + }, + "noBranches": "Aucune branche liée.", + "commits": "Commits", + "noCommits": "Aucun commit." + }, + "pr": { + "title": "Pull Requests", + "noPrs": "Aucune pull request.", + "open": "Ouverte", + "merged": "Mergée", + "closed": "Fermée", + "ci": "CI/CD" + }, + "error": "Erreur de connexion à Gitea.", + "notConfigured": "Gitea non configuré pour ce projet." +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/i18n/locales/fr.json +git commit -m "feat : add Gitea i18n keys" +``` + +### Task 17: AdminGiteaTab component + +**Files:** +- Create: `frontend/components/admin/AdminGiteaTab.vue` + +- [ ] **Step 1: Create AdminGiteaTab** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/admin/AdminGiteaTab.vue +git commit -m "feat : add AdminGiteaTab component" +``` + +### Task 18: Add Gitea tab to admin page + +**Files:** +- Modify: `frontend/pages/admin.vue` + +- [ ] **Step 1: Add Gitea tab entry** + +Add to the `tabs` array: + +```typescript +{ key: 'gitea', label: 'Gitea' }, +``` + +Add the type to `TabKey`: + +Update the type union to include `'gitea'`. + +Add the component in template: + +```vue + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/pages/admin.vue +git commit -m "feat : add Gitea tab to admin page" +``` + +--- + +## Chunk 6: Frontend — ProjectDrawer & TaskModal Integration + +### Task 19: Add Gitea repo selector to ProjectDrawer + +**Files:** +- Modify: `frontend/components/project/ProjectDrawer.vue` + +- [ ] **Step 1: Add Gitea repo selection** + +Add to the template, after the ColorPicker section (around line 34), before the submit button: + +```vue +
+ +
+``` + +Add to script: + +```typescript +import { useGiteaService } from '~/services/gitea' +import type { GiteaRepository } from '~/services/dto/gitea' + +const { listRepositories } = useGiteaService() +const giteaRepos = ref([]) + +const giteaRepoOptions = computed(() => + giteaRepos.value.map(r => ({ label: r.fullName, value: r.fullName })) +) +``` + +Add `giteaRepoFullName` to the form reactive: + +```typescript +giteaRepoFullName: null as string | null, +``` + +In the watch that populates the form, add: + +```typescript +form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo + ? `${props.project.giteaOwner}/${props.project.giteaRepo}` + : null +``` + +In `handleSubmit`, parse the fullName and add to payload: + +```typescript +if (form.giteaRepoFullName) { + const [owner, repo] = form.giteaRepoFullName.split('/') + payload.giteaOwner = owner + payload.giteaRepo = repo +} else { + payload.giteaOwner = null + payload.giteaRepo = null +} +``` + +Load repos on mount: + +```typescript +onMounted(async () => { + try { + giteaRepos.value = await listRepositories() + } catch { + // Gitea not configured, ignore + } +}) +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/project/ProjectDrawer.vue +git commit -m "feat : add Gitea repo selector to ProjectDrawer" +``` + +### Task 20: Create TaskGitSection component + +**Files:** +- Create: `frontend/components/task/TaskGitSection.vue` + +- [ ] **Step 1: Create TaskGitSection** + +```vue + + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/task/TaskGitSection.vue +git commit -m "feat : add TaskGitSection component" +``` + +### Task 21: Integrate TaskGitSection into TaskModal + +**Files:** +- Modify: `frontend/components/task/TaskModal.vue` + +- [ ] **Step 1: Add TaskGitSection to TaskModal** + +Add the import in the script section: + +```typescript +import type { GiteaSettings } from '~/services/dto/gitea' +import { useGiteaService } from '~/services/gitea' +``` + +Add reactive state: + +```typescript +const giteaUrl = ref('') + +const { getSettings: getGiteaSettings } = useGiteaService() +``` + +Add a computed to check if project has gitea configured: + +```typescript +const hasGitea = computed(() => { + return !!props.task?.project?.giteaOwner && !!props.task?.project?.giteaRepo && !!giteaUrl.value +}) +``` + +Load gitea URL on mount (only if project has gitea configured): + +```typescript +onMounted(async () => { + if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo) { + try { + const settings = await getGiteaSettings() + giteaUrl.value = settings.url ?? '' + } catch { + // Gitea not available + } + } +}) +``` + +Add the component in the template, between the Description section and the Footer (after line 121, before line 123): + +```vue + + +``` + +- [ ] **Step 2: Commit** + +```bash +git add frontend/components/task/TaskModal.vue +git commit -m "feat : integrate TaskGitSection into TaskModal" +``` + +--- + +## Chunk 7: Final Wiring & Verification + +### Task 22: Generate encryption key and add to Docker env + +**Files:** +- Modify: `docker/.env.docker.local` (NOT committed — secrets only) +- Modify: `.env` + +- [ ] **Step 1: Generate a sodium key** + +```bash +php -r "echo sodium_bin2hex(sodium_crypto_secretbox_keygen());" +``` + +- [ ] **Step 2: Add generated key to `docker/.env.docker.local`** + +This file is a local override and must NOT be committed to version control: + +``` +GITEA_ENCRYPTION_KEY= +``` + +- [ ] **Step 3: Add placeholder to `.env` (committed)** + +``` +GITEA_ENCRYPTION_KEY= +``` + +Note: The `TokenEncryptor` will throw a clear error if this is empty, guiding the developer to set the real value. + +- [ ] **Step 4: Commit only `.env`** + +```bash +git add .env +git commit -m "feat : add GITEA_ENCRYPTION_KEY placeholder to .env" +``` + +### Task 23: Verify the full stack + +- [ ] **Step 1: Run migrations** + +```bash +make migration-migrate +``` + +- [ ] **Step 2: Start Nuxt dev server** + +```bash +make dev-nuxt +``` + +- [ ] **Step 3: Verify admin Gitea tab** + +Navigate to the admin page and verify the Gitea tab appears with URL/token fields and test button. + +- [ ] **Step 4: Verify ProjectDrawer** + +Open a project drawer and verify the Gitea repo selector appears (if Gitea is configured). + +- [ ] **Step 5: Verify TaskModal** + +Open a task modal for a project with a Gitea repo configured. Verify the Git section appears with create branch button and info display. + +- [ ] **Step 6: Fix PHP CS** + +```bash +make php-cs-fixer-allow-risky +``` + +- [ ] **Step 7: Final commit if needed** + +```bash +git add -A +git commit -m "fix : PHP CS Fixer adjustments for Gitea integration" +```