Skip to content

Commit 7d2a9cf

Browse files
author
Curtis Delicata
committed
feat: add Git platform and release management
1 parent 0f543b3 commit 7d2a9cf

21 files changed

Lines changed: 1309 additions & 0 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Contracts;
6+
7+
use App\Models\GitRepository;
8+
9+
interface GitPlatformClient
10+
{
11+
/** @return list<array<string, mixed>> */
12+
public function repositories(): array;
13+
14+
/** @return list<array<string, mixed>> */
15+
public function records(GitRepository $repository, string $type): array;
16+
17+
/** @return array<string, mixed> */
18+
public function createRelease(GitRepository $repository, string $version, string $name, string $changelog): array;
19+
}

app/Enums/GitProvider.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Enums;
6+
7+
enum GitProvider: string
8+
{
9+
case GitHub = 'github';
10+
case GitLab = 'gitlab';
11+
case Bitbucket = 'bitbucket';
12+
}

app/Enums/TokenAbility.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ enum TokenAbility: string
2727
case IspServicesWrite = 'isp-services:write';
2828
case VoipRead = 'voip:read';
2929
case VoipWrite = 'voip:write';
30+
case GitRead = 'git:read';
31+
case GitWrite = 'git:write';
3032

3133
/**
3234
* @return list<string>
Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,169 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Api;
6+
7+
use App\Enums\GitProvider;
8+
use App\Http\Controllers\Controller;
9+
use App\Jobs\SyncGitConnection;
10+
use App\Models\GitConnection;
11+
use App\Models\GitRelease;
12+
use App\Models\GitRepository;
13+
use App\Models\User;
14+
use App\Services\Git\GitPlatformClientFactory;
15+
use App\Services\ReleaseManagementService;
16+
use Illuminate\Database\Eloquent\Builder;
17+
use Illuminate\Http\JsonResponse;
18+
use Illuminate\Http\Request;
19+
use Illuminate\Validation\Rule;
20+
use Symfony\Component\HttpFoundation\Response;
21+
22+
class GitIntegrationController extends Controller
23+
{
24+
public function __construct(
25+
private readonly GitPlatformClientFactory $clients,
26+
private readonly ReleaseManagementService $releases,
27+
) {}
28+
29+
public function connections(Request $request): JsonResponse
30+
{
31+
return response()->json($this->connectionsQuery($request)->withCount('repositories')->paginate(25));
32+
}
33+
34+
public function storeConnection(Request $request): JsonResponse
35+
{
36+
$teamId = $this->teamId($request);
37+
$data = $request->validate([
38+
'provider' => ['required', Rule::enum(GitProvider::class)],
39+
'name' => [
40+
'required', 'string', 'max:255',
41+
Rule::unique('git_connections', 'name')->where('team_id', $teamId)
42+
->where('provider', $request->input('provider')),
43+
],
44+
'base_url' => 'required|url:https|max:2048',
45+
'access_token' => 'required|string|min:8|max:4096',
46+
'webhook_secret' => 'nullable|string|min:16|max:4096',
47+
'is_active' => 'sometimes|boolean',
48+
]);
49+
$data['team_id'] = $teamId;
50+
51+
return response()->json(GitConnection::query()->create($data), Response::HTTP_CREATED);
52+
}
53+
54+
public function updateConnection(Request $request, int $connection): JsonResponse
55+
{
56+
$model = $this->connection($request, $connection);
57+
$model->update($request->validate([
58+
'name' => 'sometimes|string|max:255',
59+
'base_url' => 'sometimes|url:https|max:2048',
60+
'access_token' => 'sometimes|string|min:8|max:4096',
61+
'webhook_secret' => 'nullable|string|min:16|max:4096',
62+
'is_active' => 'sometimes|boolean',
63+
]));
64+
65+
return response()->json($model->refresh());
66+
}
67+
68+
public function destroyConnection(Request $request, int $connection): Response
69+
{
70+
$this->connection($request, $connection)->delete();
71+
72+
return response()->noContent();
73+
}
74+
75+
public function sync(Request $request, int $connection): JsonResponse
76+
{
77+
$model = $this->connection($request, $connection);
78+
SyncGitConnection::dispatch($model->id);
79+
80+
return response()->json(['queued' => true], Response::HTTP_ACCEPTED);
81+
}
82+
83+
public function repositories(Request $request): JsonResponse
84+
{
85+
return response()->json(
86+
GitRepository::query()
87+
->whereHas('connection', fn (Builder $query) => $query->where('team_id', $this->teamId($request)))
88+
->withCount('syncRecords')
89+
->paginate(25)
90+
);
91+
}
92+
93+
public function repository(Request $request, int $repository): JsonResponse
94+
{
95+
return response()->json($this->repositoryForTeam($request, $repository)->load([
96+
'syncRecords', 'releases',
97+
]));
98+
}
99+
100+
public function createRelease(Request $request, int $repository): JsonResponse
101+
{
102+
$repo = $this->repositoryForTeam($request, $repository);
103+
$data = $request->validate([
104+
'version' => [
105+
'required', 'string', 'max:100',
106+
Rule::unique('git_releases', 'version')->where('git_repository_id', $repo->id),
107+
],
108+
'name' => 'required|string|max:255',
109+
'changelog' => 'nullable|string|max:100000',
110+
]);
111+
$release = $this->releases->create(
112+
$repo,
113+
$this->clients->make($repo->connection),
114+
$data['version'],
115+
$data['name'],
116+
$data['changelog'] ?? null
117+
);
118+
119+
return response()->json($release, Response::HTTP_CREATED);
120+
}
121+
122+
public function trackDeployment(Request $request, int $release): JsonResponse
123+
{
124+
$model = GitRelease::query()
125+
->whereHas(
126+
'repository.connection',
127+
fn (Builder $query) => $query->where('team_id', $this->teamId($request))
128+
)
129+
->findOrFail($release);
130+
$data = $request->validate([
131+
'environment' => 'required|string|max:100',
132+
'status' => 'required|string|max:100',
133+
'completed' => 'sometimes|boolean',
134+
]);
135+
136+
return response()->json($this->releases->trackDeployment(
137+
$model,
138+
$data['environment'],
139+
$data['status'],
140+
(bool) ($data['completed'] ?? false)
141+
));
142+
}
143+
144+
/** @return Builder<GitConnection> */
145+
private function connectionsQuery(Request $request): Builder
146+
{
147+
return GitConnection::query()->where('team_id', $this->teamId($request));
148+
}
149+
150+
private function connection(Request $request, int $id): GitConnection
151+
{
152+
return $this->connectionsQuery($request)->findOrFail($id);
153+
}
154+
155+
private function repositoryForTeam(Request $request, int $id): GitRepository
156+
{
157+
return GitRepository::query()
158+
->whereHas('connection', fn (Builder $query) => $query->where('team_id', $this->teamId($request)))
159+
->findOrFail($id);
160+
}
161+
162+
private function teamId(Request $request): ?int
163+
{
164+
/** @var User|null $user */
165+
$user = $request->user();
166+
167+
return $user?->current_team_id;
168+
}
169+
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Http\Controllers\Api;
6+
7+
use App\Enums\GitProvider;
8+
use App\Http\Controllers\Controller;
9+
use App\Jobs\SyncGitConnection;
10+
use App\Models\GitConnection;
11+
use Illuminate\Http\JsonResponse;
12+
use Illuminate\Http\Request;
13+
use Symfony\Component\HttpFoundation\Response;
14+
15+
class GitWebhookController extends Controller
16+
{
17+
public function __invoke(Request $request, GitConnection $connection): JsonResponse
18+
{
19+
abort_unless($connection->is_active && $this->validSignature($request, $connection), Response::HTTP_UNAUTHORIZED);
20+
21+
SyncGitConnection::dispatch($connection->id);
22+
23+
return response()->json(['accepted' => true], Response::HTTP_ACCEPTED);
24+
}
25+
26+
private function validSignature(Request $request, GitConnection $connection): bool
27+
{
28+
if ($connection->webhook_secret === null || $connection->webhook_secret === '') {
29+
return false;
30+
}
31+
32+
if ($connection->provider === GitProvider::GitLab) {
33+
$token = $request->header('X-Gitlab-Token');
34+
35+
return is_string($token) && hash_equals($connection->webhook_secret, $token);
36+
}
37+
38+
$header = $connection->provider === GitProvider::GitHub
39+
? $request->header('X-Hub-Signature-256')
40+
: $request->header('X-Hub-Signature');
41+
$expected = 'sha256='.hash_hmac('sha256', $request->getContent(), $connection->webhook_secret);
42+
43+
return is_string($header) && hash_equals($expected, $header);
44+
}
45+
}

app/Jobs/SyncGitConnection.php

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Jobs;
6+
7+
use App\Models\GitConnection;
8+
use App\Services\Git\GitPlatformClientFactory;
9+
use App\Services\GitSyncService;
10+
use Illuminate\Contracts\Queue\ShouldQueue;
11+
use Illuminate\Foundation\Queue\Queueable;
12+
13+
class SyncGitConnection implements ShouldQueue
14+
{
15+
use Queueable;
16+
17+
public int $tries = 3;
18+
19+
public function __construct(public readonly int $connectionId) {}
20+
21+
public function handle(GitSyncService $sync, GitPlatformClientFactory $clients): void
22+
{
23+
$connection = GitConnection::query()->where('is_active', true)->findOrFail($this->connectionId);
24+
$sync->syncConnection($connection, $clients->make($connection));
25+
}
26+
}

app/Models/GitConnection.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Models;
6+
7+
use App\Enums\GitProvider;
8+
use App\Traits\HasTeam;
9+
use Database\Factories\GitConnectionFactory;
10+
use Illuminate\Database\Eloquent\Attributes\Fillable;
11+
use Illuminate\Database\Eloquent\Factories\HasFactory;
12+
use Illuminate\Database\Eloquent\Model;
13+
use Illuminate\Database\Eloquent\Relations\HasMany;
14+
use Illuminate\Support\Carbon;
15+
16+
/**
17+
* @property int $id
18+
* @property int $team_id
19+
* @property GitProvider $provider
20+
* @property string $name
21+
* @property string $base_url
22+
* @property string $access_token
23+
* @property string|null $webhook_secret
24+
* @property bool $is_active
25+
* @property Carbon|null $last_synced_at
26+
*/
27+
#[Fillable([
28+
'team_id', 'provider', 'name', 'base_url', 'access_token', 'webhook_secret',
29+
'is_active', 'last_synced_at',
30+
])]
31+
class GitConnection extends Model
32+
{
33+
use HasFactory;
34+
use HasTeam;
35+
36+
protected $hidden = ['access_token', 'webhook_secret'];
37+
38+
protected static function newFactory(): GitConnectionFactory
39+
{
40+
return GitConnectionFactory::new();
41+
}
42+
43+
protected function casts(): array
44+
{
45+
return [
46+
'provider' => GitProvider::class,
47+
'access_token' => 'encrypted',
48+
'webhook_secret' => 'encrypted',
49+
'is_active' => 'boolean',
50+
'last_synced_at' => 'datetime',
51+
];
52+
}
53+
54+
public function repositories(): HasMany
55+
{
56+
return $this->hasMany(GitRepository::class);
57+
}
58+
}

app/Models/GitRelease.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace App\Models;
6+
7+
use Illuminate\Database\Eloquent\Attributes\Fillable;
8+
use Illuminate\Database\Eloquent\Model;
9+
use Illuminate\Database\Eloquent\Relations\BelongsTo;
10+
use Illuminate\Support\Carbon;
11+
12+
/**
13+
* @property int $id
14+
* @property int $git_repository_id
15+
* @property string|null $external_id
16+
* @property string $version
17+
* @property string $name
18+
* @property string|null $changelog
19+
* @property Carbon|null $released_at
20+
* @property Carbon|null $deployed_at
21+
*/
22+
#[Fillable([
23+
'git_repository_id', 'external_id', 'version', 'name', 'changelog', 'state',
24+
'web_url', 'deployment_environment', 'deployment_status', 'released_at', 'deployed_at',
25+
])]
26+
class GitRelease extends Model
27+
{
28+
protected function casts(): array
29+
{
30+
return ['released_at' => 'datetime', 'deployed_at' => 'datetime'];
31+
}
32+
33+
public function repository(): BelongsTo
34+
{
35+
return $this->belongsTo(GitRepository::class, 'git_repository_id');
36+
}
37+
}

0 commit comments

Comments
 (0)