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>
2249 lines
60 KiB
Markdown
2249 lines
60 KiB
Markdown
# 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
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/Entity/GiteaConfiguration.php src/Repository/GiteaConfigurationRepository.php
|
|
git commit -m "feat : add GiteaConfiguration entity with repository"
|
|
```
|
|
|
|
### Task 2: Add gitea fields to Project entity
|
|
|
|
**Files:**
|
|
- Modify: `src/Entity/Project.php`
|
|
|
|
- [ ] **Step 1: Add giteaOwner and giteaRepo fields to Project**
|
|
|
|
Add after the `$client` property (around line 65):
|
|
|
|
```php
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
#[Groups(['project:read', 'project:write'])]
|
|
private ?string $giteaOwner = null;
|
|
|
|
#[ORM\Column(length: 255, nullable: true)]
|
|
#[Groups(['project:read', 'project:write'])]
|
|
private ?string $giteaRepo = null;
|
|
```
|
|
|
|
Add getters/setters at the end of the class:
|
|
|
|
```php
|
|
public function getGiteaOwner(): ?string
|
|
{
|
|
return $this->giteaOwner;
|
|
}
|
|
|
|
public function setGiteaOwner(?string $giteaOwner): static
|
|
{
|
|
$this->giteaOwner = $giteaOwner;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function getGiteaRepo(): ?string
|
|
{
|
|
return $this->giteaRepo;
|
|
}
|
|
|
|
public function setGiteaRepo(?string $giteaRepo): static
|
|
{
|
|
$this->giteaRepo = $giteaRepo;
|
|
|
|
return $this;
|
|
}
|
|
|
|
public function hasGiteaRepo(): bool
|
|
{
|
|
return null !== $this->giteaOwner && null !== $this->giteaRepo;
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add src/Entity/Project.php
|
|
git commit -m "feat : add gitea owner/repo fields to Project entity"
|
|
```
|
|
|
|
### Task 3: Generate and run migration
|
|
|
|
**Files:**
|
|
- Create: `migrations/VersionYYYYMMDDHHMMSS.php` (auto-generated)
|
|
|
|
- [ ] **Step 1: Generate migration**
|
|
|
|
```bash
|
|
make shell
|
|
# Inside container:
|
|
php bin/console doctrine:migrations:diff
|
|
exit
|
|
```
|
|
|
|
- [ ] **Step 2: Run migration**
|
|
|
|
```bash
|
|
make migration-migrate
|
|
```
|
|
|
|
- [ ] **Step 3: Commit**
|
|
|
|
```bash
|
|
git add migrations/
|
|
git commit -m "feat : add migration for GiteaConfiguration and Project gitea fields"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 2: Backend — Token Encryption & GiteaApiService
|
|
|
|
### Task 4: Token encryption service
|
|
|
|
**Files:**
|
|
- Create: `src/Service/TokenEncryptor.php`
|
|
|
|
- [ ] **Step 1: Add GITEA_ENCRYPTION_KEY to .env**
|
|
|
|
Add to `.env`:
|
|
|
|
```
|
|
GITEA_ENCRYPTION_KEY=
|
|
```
|
|
|
|
- [ ] **Step 2: Create TokenEncryptor service**
|
|
|
|
```php
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/Service/TokenEncryptor.php .env
|
|
git commit -m "feat : add TokenEncryptor service with sodium encryption"
|
|
```
|
|
|
|
### Task 5: GiteaApiException
|
|
|
|
**Files:**
|
|
- Create: `src/Exception/GiteaApiException.php`
|
|
|
|
- [ ] **Step 1: Create exception class**
|
|
|
|
```php
|
|
<?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**
|
|
|
|
```bash
|
|
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
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/Service/GiteaApiService.php
|
|
git commit -m "feat : add GiteaApiService with branch/commit/PR methods"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 3: Backend — API Endpoints
|
|
|
|
### Task 7: Gitea Settings API Resource & Providers/Processors
|
|
|
|
**Files:**
|
|
- Create: `src/ApiResource/GiteaSettings.php`
|
|
- Create: `src/State/GiteaSettingsProvider.php`
|
|
- Create: `src/State/GiteaSettingsProcessor.php`
|
|
|
|
- [ ] **Step 1: Create GiteaSettings API Resource**
|
|
|
|
```php
|
|
<?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
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/ApiResource/GiteaSettings.php src/State/GiteaSettingsProvider.php src/State/GiteaSettingsProcessor.php
|
|
git commit -m "feat : add Gitea settings API resource with provider/processor"
|
|
```
|
|
|
|
### Task 8: Gitea Test Connection Endpoint
|
|
|
|
**Files:**
|
|
- Create: `src/ApiResource/GiteaTestConnection.php`
|
|
- Create: `src/State/GiteaTestConnectionProvider.php`
|
|
|
|
- [ ] **Step 1: Create GiteaTestConnection API Resource**
|
|
|
|
```php
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/ApiResource/GiteaTestConnection.php src/State/GiteaTestConnectionProvider.php
|
|
git commit -m "feat : add Gitea test connection endpoint"
|
|
```
|
|
|
|
### Task 9: Gitea Repositories List Endpoint
|
|
|
|
**Files:**
|
|
- Create: `src/ApiResource/GiteaRepository.php`
|
|
- Create: `src/State/GiteaRepositoryProvider.php`
|
|
|
|
- [ ] **Step 1: Create GiteaRepository API Resource**
|
|
|
|
```php
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/ApiResource/GiteaRepository.php src/State/GiteaRepositoryProvider.php
|
|
git commit -m "feat : add Gitea repositories list endpoint"
|
|
```
|
|
|
|
### Task 10: Task Gitea Branches Endpoint
|
|
|
|
**Files:**
|
|
- Create: `src/ApiResource/GiteaBranch.php`
|
|
- Create: `src/State/GiteaBranchProvider.php`
|
|
- Create: `src/State/GiteaBranchProcessor.php`
|
|
|
|
- [ ] **Step 1: Create GiteaBranch API Resource**
|
|
|
|
```php
|
|
<?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
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/ApiResource/GiteaBranch.php src/State/GiteaBranchProvider.php src/State/GiteaBranchProcessor.php
|
|
git commit -m "feat : add task Gitea branches endpoints (list + create)"
|
|
```
|
|
|
|
### Task 11: Task Gitea Pull Requests Endpoint
|
|
|
|
**Files:**
|
|
- Create: `src/ApiResource/GiteaPullRequest.php`
|
|
- Create: `src/State/GiteaPullRequestProvider.php`
|
|
|
|
- [ ] **Step 1: Create GiteaPullRequest API Resource**
|
|
|
|
```php
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/ApiResource/GiteaPullRequest.php src/State/GiteaPullRequestProvider.php
|
|
git commit -m "feat : add task Gitea pull requests endpoint"
|
|
```
|
|
|
|
### Task 12: Branch Name Generation Endpoint
|
|
|
|
**Files:**
|
|
- Create: `src/ApiResource/GiteaBranchName.php`
|
|
- Create: `src/State/GiteaBranchNameProvider.php`
|
|
|
|
- [ ] **Step 1: Create GiteaBranchName API Resource**
|
|
|
|
This endpoint generates the branch name without creating it (for the "copy" button).
|
|
|
|
```php
|
|
<?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
|
|
<?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**
|
|
|
|
```bash
|
|
git add src/ApiResource/GiteaBranchName.php src/State/GiteaBranchNameProvider.php
|
|
git commit -m "feat : add branch name generation endpoint"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 4: Frontend — Service Layer & DTOs
|
|
|
|
### Task 13: Frontend Gitea DTOs
|
|
|
|
**Files:**
|
|
- Create: `frontend/services/dto/gitea.ts`
|
|
|
|
- [ ] **Step 1: Create Gitea DTOs**
|
|
|
|
```typescript
|
|
export type GiteaSettings = {
|
|
url: string | null
|
|
hasToken: boolean
|
|
}
|
|
|
|
export type GiteaSettingsWrite = {
|
|
url: string | null
|
|
token: string | null
|
|
}
|
|
|
|
export type GiteaRepository = {
|
|
fullName: string
|
|
name: string
|
|
owner: string
|
|
}
|
|
|
|
export type GiteaBranch = {
|
|
name: string
|
|
commits: GiteaCommit[]
|
|
}
|
|
|
|
export type GiteaCommit = {
|
|
sha: string
|
|
message: string
|
|
author: string
|
|
date: string
|
|
}
|
|
|
|
export type GiteaBranchCreate = {
|
|
type: string
|
|
baseBranch: string
|
|
}
|
|
|
|
export type GiteaPullRequest = {
|
|
number: number
|
|
title: string
|
|
state: string
|
|
merged: boolean
|
|
headBranch: string
|
|
author: string
|
|
url: string
|
|
ciStatuses: GiteaCiStatus[]
|
|
}
|
|
|
|
export type GiteaCiStatus = {
|
|
context: string
|
|
status: string
|
|
target_url: string
|
|
}
|
|
|
|
export type GiteaBranchName = {
|
|
name: string
|
|
}
|
|
|
|
export type GiteaTestResult = {
|
|
success: boolean
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add frontend/services/dto/gitea.ts
|
|
git commit -m "feat : add Gitea TypeScript DTOs"
|
|
```
|
|
|
|
### Task 14: Frontend Gitea Service
|
|
|
|
**Files:**
|
|
- Create: `frontend/services/gitea.ts`
|
|
|
|
- [ ] **Step 1: Create Gitea service**
|
|
|
|
```typescript
|
|
import type {
|
|
GiteaSettings,
|
|
GiteaSettingsWrite,
|
|
GiteaRepository,
|
|
GiteaBranch,
|
|
GiteaBranchCreate,
|
|
GiteaPullRequest,
|
|
GiteaBranchName,
|
|
GiteaTestResult,
|
|
} from './dto/gitea'
|
|
import type { HydraCollection } from '~/utils/api'
|
|
import { extractHydraMembers } from '~/utils/api'
|
|
|
|
export function useGiteaService() {
|
|
const api = useApi()
|
|
|
|
async function getSettings(): Promise<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**
|
|
|
|
```bash
|
|
git add frontend/services/gitea.ts
|
|
git commit -m "feat : add Gitea frontend service"
|
|
```
|
|
|
|
### Task 15: Add Project gitea fields to DTO
|
|
|
|
**Files:**
|
|
- Modify: `frontend/services/dto/project.ts`
|
|
|
|
- [ ] **Step 1: Add giteaOwner and giteaRepo to Project type**
|
|
|
|
Add to the `Project` type after `client`:
|
|
|
|
```typescript
|
|
giteaOwner: string | null
|
|
giteaRepo: string | null
|
|
```
|
|
|
|
Add to `ProjectWrite` type after `client`:
|
|
|
|
```typescript
|
|
giteaOwner?: string | null
|
|
giteaRepo?: string | null
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add frontend/services/dto/project.ts
|
|
git commit -m "feat : add gitea fields to Project DTO"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 5: Frontend — Admin Gitea Tab
|
|
|
|
### Task 16: i18n keys
|
|
|
|
**Files:**
|
|
- Modify: `frontend/i18n/locales/fr.json`
|
|
|
|
- [ ] **Step 1: Add Gitea i18n keys**
|
|
|
|
Add a `"gitea"` section to the JSON:
|
|
|
|
```json
|
|
"common": {
|
|
"cancel": "Annuler",
|
|
"loading": "Chargement..."
|
|
},
|
|
"gitea": {
|
|
"settings": {
|
|
"title": "Configuration Gitea",
|
|
"url": "URL du serveur",
|
|
"urlPlaceholder": "https://git.example.com",
|
|
"token": "Token API",
|
|
"tokenPlaceholder": "Entrez un nouveau token",
|
|
"tokenConfigured": "Token configuré",
|
|
"save": "Enregistrer",
|
|
"saved": "Configuration Gitea sauvegardée.",
|
|
"testConnection": "Tester la connexion",
|
|
"testSuccess": "Connexion réussie.",
|
|
"testFailed": "Connexion échouée."
|
|
},
|
|
"branch": {
|
|
"title": "Git",
|
|
"create": "Créer une branche",
|
|
"created": "Branche créée avec succès.",
|
|
"copy": "Copier le nom",
|
|
"copied": "Nom de branche copié.",
|
|
"type": "Type",
|
|
"baseBranch": "Branche de base",
|
|
"preview": "Aperçu",
|
|
"types": {
|
|
"feature": "feature",
|
|
"fix": "fix",
|
|
"refactor": "refactor",
|
|
"hotfix": "hotfix",
|
|
"chore": "chore"
|
|
},
|
|
"noBranches": "Aucune branche liée.",
|
|
"commits": "Commits",
|
|
"noCommits": "Aucun commit."
|
|
},
|
|
"pr": {
|
|
"title": "Pull Requests",
|
|
"noPrs": "Aucune pull request.",
|
|
"open": "Ouverte",
|
|
"merged": "Mergée",
|
|
"closed": "Fermée",
|
|
"ci": "CI/CD"
|
|
},
|
|
"error": "Erreur de connexion à Gitea.",
|
|
"notConfigured": "Gitea non configuré pour ce projet."
|
|
}
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add frontend/i18n/locales/fr.json
|
|
git commit -m "feat : add Gitea i18n keys"
|
|
```
|
|
|
|
### Task 17: AdminGiteaTab component
|
|
|
|
**Files:**
|
|
- Create: `frontend/components/admin/AdminGiteaTab.vue`
|
|
|
|
- [ ] **Step 1: Create AdminGiteaTab**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
git add frontend/components/admin/AdminGiteaTab.vue
|
|
git commit -m "feat : add AdminGiteaTab component"
|
|
```
|
|
|
|
### Task 18: Add Gitea tab to admin page
|
|
|
|
**Files:**
|
|
- Modify: `frontend/pages/admin.vue`
|
|
|
|
- [ ] **Step 1: Add Gitea tab entry**
|
|
|
|
Add to the `tabs` array:
|
|
|
|
```typescript
|
|
{ key: 'gitea', label: 'Gitea' },
|
|
```
|
|
|
|
Add the type to `TabKey`:
|
|
|
|
Update the type union to include `'gitea'`.
|
|
|
|
Add the component in template:
|
|
|
|
```vue
|
|
<AdminGiteaTab v-if="activeTab === 'gitea'" />
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add frontend/pages/admin.vue
|
|
git commit -m "feat : add Gitea tab to admin page"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 6: Frontend — ProjectDrawer & TaskModal Integration
|
|
|
|
### Task 19: Add Gitea repo selector to ProjectDrawer
|
|
|
|
**Files:**
|
|
- Modify: `frontend/components/project/ProjectDrawer.vue`
|
|
|
|
- [ ] **Step 1: Add Gitea repo selection**
|
|
|
|
Add to the template, after the ColorPicker section (around line 34), before the submit button:
|
|
|
|
```vue
|
|
<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:
|
|
|
|
```typescript
|
|
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:
|
|
|
|
```typescript
|
|
giteaRepoFullName: null as string | null,
|
|
```
|
|
|
|
In the watch that populates the form, add:
|
|
|
|
```typescript
|
|
form.giteaRepoFullName = props.project?.giteaOwner && props.project?.giteaRepo
|
|
? `${props.project.giteaOwner}/${props.project.giteaRepo}`
|
|
: null
|
|
```
|
|
|
|
In `handleSubmit`, parse the fullName and add to payload:
|
|
|
|
```typescript
|
|
if (form.giteaRepoFullName) {
|
|
const [owner, repo] = form.giteaRepoFullName.split('/')
|
|
payload.giteaOwner = owner
|
|
payload.giteaRepo = repo
|
|
} else {
|
|
payload.giteaOwner = null
|
|
payload.giteaRepo = null
|
|
}
|
|
```
|
|
|
|
Load repos on mount:
|
|
|
|
```typescript
|
|
onMounted(async () => {
|
|
try {
|
|
giteaRepos.value = await listRepositories()
|
|
} catch {
|
|
// Gitea not configured, ignore
|
|
}
|
|
})
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add frontend/components/project/ProjectDrawer.vue
|
|
git commit -m "feat : add Gitea repo selector to ProjectDrawer"
|
|
```
|
|
|
|
### Task 20: Create TaskGitSection component
|
|
|
|
**Files:**
|
|
- Create: `frontend/components/task/TaskGitSection.vue`
|
|
|
|
- [ ] **Step 1: Create TaskGitSection**
|
|
|
|
```vue
|
|
<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**
|
|
|
|
```bash
|
|
git add frontend/components/task/TaskGitSection.vue
|
|
git commit -m "feat : add TaskGitSection component"
|
|
```
|
|
|
|
### Task 21: Integrate TaskGitSection into TaskModal
|
|
|
|
**Files:**
|
|
- Modify: `frontend/components/task/TaskModal.vue`
|
|
|
|
- [ ] **Step 1: Add TaskGitSection to TaskModal**
|
|
|
|
Add the import in the script section:
|
|
|
|
```typescript
|
|
import type { GiteaSettings } from '~/services/dto/gitea'
|
|
import { useGiteaService } from '~/services/gitea'
|
|
```
|
|
|
|
Add reactive state:
|
|
|
|
```typescript
|
|
const giteaUrl = ref('')
|
|
|
|
const { getSettings: getGiteaSettings } = useGiteaService()
|
|
```
|
|
|
|
Add a computed to check if project has gitea configured:
|
|
|
|
```typescript
|
|
const hasGitea = computed(() => {
|
|
return !!props.task?.project?.giteaOwner && !!props.task?.project?.giteaRepo && !!giteaUrl.value
|
|
})
|
|
```
|
|
|
|
Load gitea URL on mount (only if project has gitea configured):
|
|
|
|
```typescript
|
|
onMounted(async () => {
|
|
if (props.task?.project?.giteaOwner && props.task?.project?.giteaRepo) {
|
|
try {
|
|
const settings = await getGiteaSettings()
|
|
giteaUrl.value = settings.url ?? ''
|
|
} catch {
|
|
// Gitea not available
|
|
}
|
|
}
|
|
})
|
|
```
|
|
|
|
Add the component in the template, between the Description section and the Footer (after line 121, before line 123):
|
|
|
|
```vue
|
|
<!-- Git section -->
|
|
<TaskGitSection
|
|
v-if="hasGitea && isEditing && task"
|
|
:task="task"
|
|
:gitea-url="giteaUrl"
|
|
/>
|
|
```
|
|
|
|
- [ ] **Step 2: Commit**
|
|
|
|
```bash
|
|
git add frontend/components/task/TaskModal.vue
|
|
git commit -m "feat : integrate TaskGitSection into TaskModal"
|
|
```
|
|
|
|
---
|
|
|
|
## Chunk 7: Final Wiring & Verification
|
|
|
|
### Task 22: Generate encryption key and add to Docker env
|
|
|
|
**Files:**
|
|
- Modify: `docker/.env.docker.local` (NOT committed — secrets only)
|
|
- Modify: `.env`
|
|
|
|
- [ ] **Step 1: Generate a sodium key**
|
|
|
|
```bash
|
|
php -r "echo sodium_bin2hex(sodium_crypto_secretbox_keygen());"
|
|
```
|
|
|
|
- [ ] **Step 2: Add generated key to `docker/.env.docker.local`**
|
|
|
|
This file is a local override and must NOT be committed to version control:
|
|
|
|
```
|
|
GITEA_ENCRYPTION_KEY=<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`**
|
|
|
|
```bash
|
|
git add .env
|
|
git commit -m "feat : add GITEA_ENCRYPTION_KEY placeholder to .env"
|
|
```
|
|
|
|
### Task 23: Verify the full stack
|
|
|
|
- [ ] **Step 1: Run migrations**
|
|
|
|
```bash
|
|
make migration-migrate
|
|
```
|
|
|
|
- [ ] **Step 2: Start Nuxt dev server**
|
|
|
|
```bash
|
|
make dev-nuxt
|
|
```
|
|
|
|
- [ ] **Step 3: Verify admin Gitea tab**
|
|
|
|
Navigate to the admin page and verify the Gitea tab appears with URL/token fields and test button.
|
|
|
|
- [ ] **Step 4: Verify ProjectDrawer**
|
|
|
|
Open a project drawer and verify the Gitea repo selector appears (if Gitea is configured).
|
|
|
|
- [ ] **Step 5: Verify TaskModal**
|
|
|
|
Open a task modal for a project with a Gitea repo configured. Verify the Git section appears with create branch button and info display.
|
|
|
|
- [ ] **Step 6: Fix PHP CS**
|
|
|
|
```bash
|
|
make php-cs-fixer-allow-risky
|
|
```
|
|
|
|
- [ ] **Step 7: Final commit if needed**
|
|
|
|
```bash
|
|
git add -A
|
|
git commit -m "fix : PHP CS Fixer adjustments for Gitea integration"
|
|
```
|