Files
Lesstime/docs/superpowers/plans/2026-03-13-gitea-integration.md
Matthieu 3ec9424bb2 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 <noreply@anthropic.com>
2026-03-13 13:40:28 +01:00

60 KiB

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

declare(strict_types=1);

namespace App\Repository;

use App\Entity\GiteaConfiguration;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;

class GiteaConfigurationRepository extends ServiceEntityRepository
{
    public function __construct(ManagerRegistry $registry)
    {
        parent::__construct($registry, GiteaConfiguration::class);
    }

    public function findSingleton(): ?GiteaConfiguration
    {
        return $this->findOneBy([]);
    }
}
  • Step 2: Create GiteaConfiguration entity
<?php

declare(strict_types=1);

namespace App\Entity;

use App\Repository\GiteaConfigurationRepository;
use Doctrine\ORM\Mapping as ORM;

#[ORM\Entity(repositoryClass: GiteaConfigurationRepository::class)]
class GiteaConfiguration
{
    #[ORM\Id]
    #[ORM\GeneratedValue]
    #[ORM\Column]
    private ?int $id = null;

    #[ORM\Column(length: 255, nullable: true)]
    private ?string $url = null;

    #[ORM\Column(type: 'text', nullable: true)]
    private ?string $encryptedToken = null;

    public function getId(): ?int
    {
        return $this->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
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):

#[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:

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
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

make shell
# Inside container:
php bin/console doctrine:migrations:diff
exit
  • Step 2: Run migration
make migration-migrate
  • Step 3: Commit
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

declare(strict_types=1);

namespace App\Service;

use Symfony\Component\DependencyInjection\Attribute\Autowire;

final readonly class TokenEncryptor
{
    private string $key;

    public function __construct(
        #[Autowire('%env(GITEA_ENCRYPTION_KEY)%')]
        string $encryptionKey,
    ) {
        if ('' === $encryptionKey) {
            throw new \InvalidArgumentException('GITEA_ENCRYPTION_KEY environment variable must be set.');
        }

        $this->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
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

declare(strict_types=1);

namespace App\Exception;

final class GiteaApiException extends \RuntimeException
{
    public function __construct(string $message, int $code = 0, ?\Throwable $previous = null)
    {
        parent::__construct($message, $code, $previous);
    }
}
  • Step 2: Commit
git add src/Exception/GiteaApiException.php
git commit -m "feat : add GiteaApiException"

Task 6: GiteaApiService

Files:

  • Create: src/Service/GiteaApiService.php

  • Step 1: Create GiteaApiService

<?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\HttpClientInterface;

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']) && 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<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) && 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<array{sha: string, commit: array{message: string, author: array}, created: string}>
     */
    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<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.');
        }

        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<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 (ExceptionInterface $e) {
            throw new GiteaApiException('Gitea API error: ' . $e->getMessage(), 0, $e);
        }
    }
}
  • Step 2: Commit
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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use ApiPlatform\Metadata\Put;
use App\State\GiteaSettingsProcessor;
use App\State\GiteaSettingsProvider;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/settings/gitea',
            normalizationContext: ['groups' => ['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

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\GiteaSettings;
use App\Repository\GiteaConfigurationRepository;

final readonly class GiteaSettingsProvider implements ProviderInterface
{
    public function __construct(
        private GiteaConfigurationRepository $configRepository,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): GiteaSettings
    {
        $config = $this->configRepository->findSingleton();
        $dto = new GiteaSettings();

        if (null !== $config) {
            $dto->url = $config->getUrl();
            $dto->hasToken = $config->hasToken();
        }

        return $dto;
    }
}
  • Step 3: Create GiteaSettingsProcessor
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\GiteaSettings;
use App\Entity\GiteaConfiguration;
use App\Repository\GiteaConfigurationRepository;
use App\Service\TokenEncryptor;
use Doctrine\ORM\EntityManagerInterface;

final readonly class GiteaSettingsProcessor implements ProcessorInterface
{
    public function __construct(
        private EntityManagerInterface $em,
        private GiteaConfigurationRepository $configRepository,
        private TokenEncryptor $tokenEncryptor,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GiteaSettings
    {
        assert($data instanceof GiteaSettings);

        $config = $this->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
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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Post;
use App\State\GiteaTestConnectionProvider;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new Post(
            uriTemplate: '/settings/gitea/test',
            input: false,
            normalizationContext: ['groups' => ['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

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\GiteaTestConnection;
use App\Service\GiteaApiService;

final readonly class GiteaTestConnectionProvider implements ProviderInterface, ProcessorInterface
{
    public function __construct(
        private GiteaApiService $giteaApiService,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): GiteaTestConnection
    {
        return new GiteaTestConnection();
    }

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GiteaTestConnection
    {
        $result = new GiteaTestConnection();
        $result->success = $this->giteaApiService->testConnection();

        return $result;
    }
}
  • Step 3: Commit
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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\GiteaRepositoryProvider;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(
            uriTemplate: '/gitea/repositories',
            normalizationContext: ['groups' => ['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

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\GiteaRepository;
use App\Exception\GiteaApiException;
use App\Service\GiteaApiService;

final readonly class GiteaRepositoryProvider implements ProviderInterface
{
    public function __construct(
        private GiteaApiService $giteaApiService,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
    {
        try {
            $repos = $this->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
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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use ApiPlatform\Metadata\Post;
use App\State\GiteaBranchProcessor;
use App\State\GiteaBranchProvider;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(
            uriTemplate: '/tasks/{taskId}/gitea/branches',
            normalizationContext: ['groups' => ['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<array{sha: string, message: string, author: string, date: string}>
     */
    #[Groups(['gitea_branch:read'])]
    public array $commits = [];
}
  • Step 2: Create GiteaBranchProvider
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\GiteaBranch;
use App\Entity\Task;
use App\Exception\GiteaApiException;
use App\Service\GiteaApiService;
use Doctrine\ORM\EntityManagerInterface;

final readonly class GiteaBranchProvider implements ProviderInterface
{
    public function __construct(
        private GiteaApiService $giteaApiService,
        private EntityManagerInterface $em,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|GiteaBranch
    {
        if ($operation instanceof \ApiPlatform\Metadata\Post) {
            return new GiteaBranch();
        }

        $task = $this->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

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProcessorInterface;
use App\ApiResource\GiteaBranch;
use App\Entity\Task;
use App\Exception\GiteaApiException;
use App\Service\GiteaApiService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

final readonly class GiteaBranchProcessor implements ProcessorInterface
{
    private const array ALLOWED_TYPES = ['feature', 'fix', 'refactor', 'hotfix', 'chore'];

    public function __construct(
        private GiteaApiService $giteaApiService,
        private EntityManagerInterface $em,
    ) {}

    public function process(mixed $data, Operation $operation, array $uriVariables = [], array $context = []): GiteaBranch
    {
        assert($data instanceof GiteaBranch);

        $task = $this->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
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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\GetCollection;
use App\State\GiteaPullRequestProvider;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new GetCollection(
            uriTemplate: '/tasks/{taskId}/gitea/pull-requests',
            normalizationContext: ['groups' => ['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<array{context: string, status: string, target_url: string}>
     */
    #[Groups(['gitea_pr:read'])]
    public array $ciStatuses = [];
}
  • Step 2: Create GiteaPullRequestProvider
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\GiteaPullRequest;
use App\Entity\Task;
use App\Exception\GiteaApiException;
use App\Service\GiteaApiService;
use Doctrine\ORM\EntityManagerInterface;

final readonly class GiteaPullRequestProvider implements ProviderInterface
{
    public function __construct(
        private GiteaApiService $giteaApiService,
        private EntityManagerInterface $em,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): array
    {
        $task = $this->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
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

declare(strict_types=1);

namespace App\ApiResource;

use ApiPlatform\Metadata\ApiResource;
use ApiPlatform\Metadata\Get;
use App\State\GiteaBranchNameProvider;
use Symfony\Component\Serializer\Attribute\Groups;

#[ApiResource(
    operations: [
        new Get(
            uriTemplate: '/tasks/{taskId}/gitea/branch-name/{type}',
            normalizationContext: ['groups' => ['gitea_branch_name:read']],
            provider: GiteaBranchNameProvider::class,
        ),
    ],
)]
final class GiteaBranchName
{
    #[Groups(['gitea_branch_name:read'])]
    public string $name = '';
}
  • Step 2: Create GiteaBranchNameProvider
<?php

declare(strict_types=1);

namespace App\State;

use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use App\ApiResource\GiteaBranchName;
use App\Entity\Task;
use App\Service\GiteaApiService;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;

final readonly class GiteaBranchNameProvider implements ProviderInterface
{
    private const array ALLOWED_TYPES = ['feature', 'fix', 'refactor', 'hotfix', 'chore'];

    public function __construct(
        private GiteaApiService $giteaApiService,
        private EntityManagerInterface $em,
    ) {}

    public function provide(Operation $operation, array $uriVariables = [], array $context = []): GiteaBranchName
    {
        $task = $this->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
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

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
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

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<GiteaSettings> {
        return api.get<GiteaSettings>('/settings/gitea')
    }

    async function saveSettings(payload: GiteaSettingsWrite): Promise<GiteaSettings> {
        return api.put<GiteaSettings>('/settings/gitea', payload as Record<string, unknown>, {
            toastSuccessKey: 'gitea.settings.saved',
        })
    }

    async function testConnection(): Promise<GiteaTestResult> {
        return api.post<GiteaTestResult>('/settings/gitea/test')
    }

    async function listRepositories(): Promise<GiteaRepository[]> {
        const data = await api.get<HydraCollection<GiteaRepository>>('/gitea/repositories')
        return extractHydraMembers(data)
    }

    async function listBranches(taskId: number): Promise<GiteaBranch[]> {
        const data = await api.get<HydraCollection<GiteaBranch>>(`/tasks/${taskId}/gitea/branches`)
        return extractHydraMembers(data)
    }

    async function createBranch(taskId: number, payload: GiteaBranchCreate): Promise<GiteaBranch> {
        return api.post<GiteaBranch>(`/tasks/${taskId}/gitea/branches`, payload as Record<string, unknown>, {
            toastSuccessKey: 'gitea.branch.created',
        })
    }

    async function listPullRequests(taskId: number): Promise<GiteaPullRequest[]> {
        const data = await api.get<HydraCollection<GiteaPullRequest>>(`/tasks/${taskId}/gitea/pull-requests`)
        return extractHydraMembers(data)
    }

    async function getBranchName(taskId: number, type: string): Promise<GiteaBranchName> {
        return api.get<GiteaBranchName>(`/tasks/${taskId}/gitea/branch-name/${type}`)
    }

    return {
        getSettings,
        saveSettings,
        testConnection,
        listRepositories,
        listBranches,
        createBranch,
        listPullRequests,
        getBranchName,
    }
}
  • Step 2: Commit
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:

giteaOwner: string | null
giteaRepo: string | null

Add to ProjectWrite type after client:

giteaOwner?: string | null
giteaRepo?: string | null
  • Step 2: Commit
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:

"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
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

<template>
    <div>
        <h2 class="text-lg font-bold text-neutral-900">{{ $t('gitea.settings.title') }}</h2>

        <form class="mt-6 max-w-lg space-y-4" @submit.prevent="handleSave">
            <MalioInputText
                v-model="form.url"
                :label="$t('gitea.settings.url')"
                :placeholder="$t('gitea.settings.urlPlaceholder')"
                input-class="w-full"
            />

            <div>
                <MalioInputText
                    v-model="form.token"
                    :label="$t('gitea.settings.token')"
                    :placeholder="$t('gitea.settings.tokenPlaceholder')"
                    input-class="w-full"
                    type="password"
                />
                <p v-if="hasToken && !form.token" class="mt-1 text-xs text-green-600">
                    {{ $t('gitea.settings.tokenConfigured') }}
                </p>
            </div>

            <div class="flex gap-3">
                <button
                    type="submit"
                    class="rounded-md bg-primary-500 px-4 py-2 text-sm font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
                    :disabled="isSaving"
                >
                    {{ $t('gitea.settings.save') }}
                </button>
                <button
                    type="button"
                    class="rounded-md border border-neutral-300 px-4 py-2 text-sm font-semibold text-neutral-700 hover:bg-neutral-50 disabled:opacity-50"
                    :disabled="isTesting"
                    @click="handleTest"
                >
                    {{ $t('gitea.settings.testConnection') }}
                </button>
            </div>

            <p v-if="testResult !== null" class="text-sm font-medium" :class="testResult ? 'text-green-600' : 'text-red-600'">
                {{ testResult ? $t('gitea.settings.testSuccess') : $t('gitea.settings.testFailed') }}
            </p>
        </form>
    </div>
</template>

<script setup lang="ts">
import { useGiteaService } from '~/services/gitea'

const { getSettings, saveSettings, testConnection } = useGiteaService()

const form = reactive({
    url: '',
    token: '',
})

const hasToken = ref(false)
const isSaving = ref(false)
const isTesting = ref(false)
const testResult = ref<boolean | null>(null)

async function loadSettings() {
    const settings = await getSettings()
    form.url = settings.url ?? ''
    hasToken.value = settings.hasToken
}

async function handleSave() {
    isSaving.value = true
    try {
        const result = await saveSettings({
            url: form.url.trim() || null,
            token: form.token || null,
        })
        hasToken.value = result.hasToken
        form.token = ''
        testResult.value = null
    } finally {
        isSaving.value = false
    }
}

async function handleTest() {
    isTesting.value = true
    testResult.value = null
    try {
        const result = await testConnection()
        testResult.value = result.success
    } catch {
        testResult.value = false
    } finally {
        isTesting.value = false
    }
}

onMounted(() => {
    loadSettings()
})
</script>
  • Step 2: Commit
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:

{ key: 'gitea', label: 'Gitea' },

Add the type to TabKey:

Update the type union to include 'gitea'.

Add the component in template:

<AdminGiteaTab v-if="activeTab === 'gitea'" />
  • Step 2: Commit
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:

<div v-if="giteaRepos.length" class="mt-4">
    <MalioSelect
        v-model="form.giteaRepoFullName"
        :options="giteaRepoOptions"
        label="Dépôt Gitea"
        empty-option-label="Aucun dépôt"
        min-width="w-full"
    />
</div>

Add to script:

import { useGiteaService } from '~/services/gitea'
import type { GiteaRepository } from '~/services/dto/gitea'

const { listRepositories } = useGiteaService()
const giteaRepos = ref<GiteaRepository[]>([])

const giteaRepoOptions = computed(() =>
    giteaRepos.value.map(r => ({ label: r.fullName, value: r.fullName }))
)

Add giteaRepoFullName to the form reactive:

giteaRepoFullName: null as string | null,

In the watch that populates the form, add:

form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo
    ? `${props.project.giteaOwner}/${props.project.giteaRepo}`
    : null

In handleSubmit, parse the fullName and add to payload:

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:

onMounted(async () => {
    try {
        giteaRepos.value = await listRepositories()
    } catch {
        // Gitea not configured, ignore
    }
})
  • Step 2: Commit
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

<template>
    <div class="mt-5 rounded-lg border border-neutral-200 bg-neutral-50 p-4">
        <h3 class="text-sm font-bold text-neutral-900">{{ $t('gitea.branch.title') }}</h3>

        <!-- Error state -->
        <p v-if="error" class="mt-2 text-sm text-red-500">{{ $t('gitea.error') }}</p>

        <!-- Create branch form -->
        <div v-if="!showCreateForm" class="mt-3 flex gap-2">
            <button
                type="button"
                class="rounded-md bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-secondary-500"
                @click="showCreateForm = true"
            >
                {{ $t('gitea.branch.create') }}
            </button>
            <button
                type="button"
                class="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-semibold text-neutral-600 hover:bg-neutral-100"
                @click="handleCopy"
            >
                {{ $t('gitea.branch.copy') }}
            </button>
        </div>

        <div v-if="showCreateForm" class="mt-3 space-y-3 rounded-md border border-neutral-200 bg-white p-3">
            <div class="grid grid-cols-2 gap-3">
                <MalioSelect
                    v-model="branchForm.type"
                    :options="typeOptions"
                    :label="$t('gitea.branch.type')"
                    min-width="w-full"
                />
                <MalioInputText
                    v-model="branchForm.baseBranch"
                    :label="$t('gitea.branch.baseBranch')"
                    input-class="w-full"
                />
            </div>
            <div>
                <p class="text-xs text-neutral-500">{{ $t('gitea.branch.preview') }}</p>
                <code class="mt-1 block rounded bg-neutral-100 px-2 py-1 text-xs text-neutral-800">
                    {{ branchPreview }}
                </code>
            </div>
            <div class="flex gap-2">
                <button
                    type="button"
                    class="rounded-md bg-primary-500 px-3 py-1.5 text-xs font-semibold text-white hover:bg-secondary-500 disabled:opacity-50"
                    :disabled="isCreating"
                    @click="handleCreate"
                >
                    {{ $t('gitea.branch.create') }}
                </button>
                <button
                    type="button"
                    class="rounded-md border border-neutral-300 px-3 py-1.5 text-xs font-semibold text-neutral-600 hover:bg-neutral-100"
                    @click="showCreateForm = false"
                >
                    {{ $t('common.cancel') }}
                </button>
            </div>
        </div>

        <!-- Loading -->
        <div v-if="isLoading" class="mt-3 text-xs text-neutral-400">{{ $t('common.loading') }}</div>

        <!-- Branches list -->
        <div v-if="!isLoading && branches.length" class="mt-4 space-y-3">
            <div v-for="branch in branches" :key="branch.name" class="rounded-md border border-neutral-200 bg-white p-3">
                <div class="flex items-center gap-2">
                    <Icon name="mdi:source-branch" size="16" class="text-primary-500" />
                    <a
                        :href="branchUrl(branch.name)"
                        target="_blank"
                        class="text-sm font-medium text-primary-500 hover:underline"
                    >
                        {{ branch.name }}
                    </a>
                </div>

                <!-- Commits -->
                <div v-if="branch.commits.length" class="ml-6 mt-2 space-y-1">
                    <p class="text-xs font-semibold text-neutral-500">{{ $t('gitea.branch.commits') }}</p>
                    <div
                        v-for="commit in branch.commits.slice(0, 5)"
                        :key="commit.sha"
                        class="flex items-baseline gap-2 text-xs"
                    >
                        <code class="text-primary-500">{{ commit.sha }}</code>
                        <span class="truncate text-neutral-700">{{ commitFirstLine(commit.message) }}</span>
                        <span class="whitespace-nowrap text-neutral-400">{{ commit.author }}</span>
                    </div>
                </div>
            </div>
        </div>

        <p v-if="!isLoading && !branches.length && !error" class="mt-3 text-xs text-neutral-400">
            {{ $t('gitea.branch.noBranches') }}
        </p>

        <!-- Pull Requests -->
        <div v-if="!isLoadingPrs && pullRequests.length" class="mt-4">
            <h4 class="text-xs font-bold text-neutral-700">{{ $t('gitea.pr.title') }}</h4>
            <div class="mt-2 space-y-2">
                <div v-for="pr in pullRequests" :key="pr.number" class="rounded-md border border-neutral-200 bg-white p-3">
                    <div class="flex items-center gap-2">
                        <span
                            class="rounded-full px-2 py-0.5 text-xs font-semibold text-white"
                            :class="prStatusClass(pr)"
                        >
                            {{ prStatusLabel(pr) }}
                        </span>
                        <a
                            :href="pr.url"
                            target="_blank"
                            class="text-sm font-medium text-primary-500 hover:underline"
                        >
                            #{{ pr.number }} {{ pr.title }}
                        </a>
                        <span class="text-xs text-neutral-400">{{ pr.author }}</span>
                    </div>

                    <!-- CI statuses -->
                    <div v-if="pr.ciStatuses.length" class="ml-6 mt-2 flex flex-wrap gap-2">
                        <a
                            v-for="ci in pr.ciStatuses"
                            :key="ci.context"
                            :href="ci.target_url"
                            target="_blank"
                            class="flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
                            :class="ciStatusClass(ci.status)"
                        >
                            <Icon :name="ciStatusIcon(ci.status)" size="12" />
                            {{ ci.context }}
                        </a>
                    </div>
                </div>
            </div>
        </div>

        <p v-if="!isLoadingPrs && !pullRequests.length && branches.length && !error" class="mt-3 text-xs text-neutral-400">
            {{ $t('gitea.pr.noPrs') }}
        </p>
    </div>
</template>

<script setup lang="ts">
import type { Task } from '~/services/dto/task'
import type { GiteaBranch, GiteaPullRequest, GiteaCiStatus } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea'

const { t } = useI18n()
const props = defineProps<{
    task: Task
    giteaUrl: string
}>()

const { listBranches, createBranch, listPullRequests, getBranchName } = useGiteaService()

const branches = ref<GiteaBranch[]>([])
const pullRequests = ref<GiteaPullRequest[]>([])
const isLoading = ref(true)
const isLoadingPrs = ref(true)
const isCreating = ref(false)
const error = ref(false)
const showCreateForm = ref(false)

const branchForm = reactive({
    type: 'feature',
    baseBranch: 'develop',
})

const typeOptions = [
    { label: t('gitea.branch.types.feature'), value: 'feature' },
    { label: t('gitea.branch.types.fix'), value: 'fix' },
    { label: t('gitea.branch.types.refactor'), value: 'refactor' },
    { label: t('gitea.branch.types.hotfix'), value: 'hotfix' },
    { label: t('gitea.branch.types.chore'), value: 'chore' },
]

const branchPreview = computed(() => {
    if (!props.task.project?.code || !props.task.number) return ''
    const slug = props.task.title
        .toLowerCase()
        .normalize('NFD')
        .replace(/[\u0300-\u036f]/g, '')
        .replace(/[^a-z0-9]+/g, '-')
        .replace(/^-|-$/g, '')
        .slice(0, 50)
    return `${branchForm.type}/${props.task.project.code}-${props.task.number}-${slug}`
})

function branchUrl(name: string): string {
    const project = props.task.project
    if (!project?.giteaOwner || !project?.giteaRepo) return '#'
    return `${props.giteaUrl}/${project.giteaOwner}/${project.giteaRepo}/src/branch/${encodeURIComponent(name)}`
}

function commitFirstLine(message: string): string {
    return message.split('\n')[0]
}

function prStatusClass(pr: GiteaPullRequest): string {
    if (pr.merged) return 'bg-purple-500'
    if (pr.state === 'open') return 'bg-green-500'
    return 'bg-red-500'
}

function prStatusLabel(pr: GiteaPullRequest): string {
    if (pr.merged) return t('gitea.pr.merged')
    if (pr.state === 'open') return t('gitea.pr.open')
    return t('gitea.pr.closed')
}

function ciStatusClass(status: string): string {
    if (status === 'success') return 'bg-green-100 text-green-700'
    if (status === 'failure' || status === 'error') return 'bg-red-100 text-red-700'
    return 'bg-yellow-100 text-yellow-700'
}

function ciStatusIcon(status: string): string {
    if (status === 'success') return 'mdi:check-circle'
    if (status === 'failure' || status === 'error') return 'mdi:close-circle'
    return 'mdi:clock-outline'
}

async function loadData() {
    if (!props.task.id) return

    isLoading.value = true
    isLoadingPrs.value = true
    error.value = false

    try {
        branches.value = await listBranches(props.task.id)
    } catch {
        error.value = true
    } finally {
        isLoading.value = false
    }

    try {
        pullRequests.value = await listPullRequests(props.task.id)
    } catch {
        // PR errors don't block branch display
    } finally {
        isLoadingPrs.value = false
    }
}

async function handleCreate() {
    isCreating.value = true
    try {
        await createBranch(props.task.id, {
            type: branchForm.type,
            baseBranch: branchForm.baseBranch,
        })
        showCreateForm.value = false
        await loadData()
    } finally {
        isCreating.value = false
    }
}

async function handleCopy() {
    try {
        const result = await getBranchName(props.task.id, branchForm.type)
        await navigator.clipboard.writeText(result.name)
        const { success } = useToast()
        success(t('gitea.branch.copied'))
    } catch {
        // Silently fail
    }
}

onMounted(() => {
    loadData()
})
</script>
  • Step 2: Commit
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:

import type { GiteaSettings } from '~/services/dto/gitea'
import { useGiteaService } from '~/services/gitea'

Add reactive state:

const giteaUrl = ref('')

const { getSettings: getGiteaSettings } = useGiteaService()

Add a computed to check if project has gitea configured:

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):

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):

<!-- Git section -->
<TaskGitSection
    v-if="hasGitea && isEditing && task"
    :task="task"
    :gitea-url="giteaUrl"
/>
  • Step 2: Commit
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

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=<generated_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
git add .env
git commit -m "feat : add GITEA_ENCRYPTION_KEY placeholder to .env"

Task 23: Verify the full stack

  • Step 1: Run migrations
make migration-migrate
  • Step 2: Start Nuxt dev server
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
make php-cs-fixer-allow-risky
  • Step 7: Final commit if needed
git add -A
git commit -m "fix : PHP CS Fixer adjustments for Gitea integration"