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