
- Updated validation rules for 'custom_user' and 'custom_port' fields to be nullable in the GithubController. - Refactored API request handling in GithubController, GithubPrivateRepository, and helper functions to use a consistent Http::GitHub method with timeout and retry logic. - Improved error handling for repository and branch loading processes.
662 lines
25 KiB
PHP
662 lines
25 KiB
PHP
<?php
|
|
|
|
namespace App\Http\Controllers\Api;
|
|
|
|
use App\Http\Controllers\Controller;
|
|
use App\Models\GithubApp;
|
|
use App\Models\PrivateKey;
|
|
use Illuminate\Http\Request;
|
|
use Illuminate\Support\Facades\Http;
|
|
use Illuminate\Support\Str;
|
|
use OpenApi\Attributes as OA;
|
|
|
|
class GithubController extends Controller
|
|
{
|
|
#[OA\Post(
|
|
summary: 'Create GitHub App',
|
|
description: 'Create a new GitHub app.',
|
|
path: '/github-apps',
|
|
operationId: 'create-github-app',
|
|
security: [
|
|
['bearerAuth' => []],
|
|
],
|
|
tags: ['GitHub Apps'],
|
|
requestBody: new OA\RequestBody(
|
|
description: 'GitHub app creation payload.',
|
|
required: true,
|
|
content: [
|
|
new OA\MediaType(
|
|
mediaType: 'application/json',
|
|
schema: new OA\Schema(
|
|
type: 'object',
|
|
properties: [
|
|
'name' => ['type' => 'string', 'description' => 'Name of the GitHub app.'],
|
|
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'Organization to associate the app with.'],
|
|
'api_url' => ['type' => 'string', 'description' => 'API URL for the GitHub app (e.g., https://api.github.com).'],
|
|
'html_url' => ['type' => 'string', 'description' => 'HTML URL for the GitHub app (e.g., https://github.com).'],
|
|
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH access (default: git).'],
|
|
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH access (default: 22).'],
|
|
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID from GitHub.'],
|
|
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID.'],
|
|
'client_id' => ['type' => 'string', 'description' => 'GitHub OAuth App Client ID.'],
|
|
'client_secret' => ['type' => 'string', 'description' => 'GitHub OAuth App Client Secret.'],
|
|
'webhook_secret' => ['type' => 'string', 'description' => 'Webhook secret for GitHub webhooks.'],
|
|
'private_key_uuid' => ['type' => 'string', 'description' => 'UUID of an existing private key for GitHub App authentication.'],
|
|
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is this app system-wide (cloud only).'],
|
|
],
|
|
required: ['name', 'api_url', 'html_url', 'app_id', 'installation_id', 'client_id', 'client_secret', 'private_key_uuid'],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
responses: [
|
|
new OA\Response(
|
|
response: 201,
|
|
description: 'GitHub app created successfully.',
|
|
content: [
|
|
new OA\MediaType(
|
|
mediaType: 'application/json',
|
|
schema: new OA\Schema(
|
|
type: 'object',
|
|
properties: [
|
|
'id' => ['type' => 'integer'],
|
|
'uuid' => ['type' => 'string'],
|
|
'name' => ['type' => 'string'],
|
|
'organization' => ['type' => 'string', 'nullable' => true],
|
|
'api_url' => ['type' => 'string'],
|
|
'html_url' => ['type' => 'string'],
|
|
'custom_user' => ['type' => 'string'],
|
|
'custom_port' => ['type' => 'integer'],
|
|
'app_id' => ['type' => 'integer'],
|
|
'installation_id' => ['type' => 'integer'],
|
|
'client_id' => ['type' => 'string'],
|
|
'private_key_id' => ['type' => 'integer'],
|
|
'is_system_wide' => ['type' => 'boolean'],
|
|
'team_id' => ['type' => 'integer'],
|
|
]
|
|
)
|
|
),
|
|
]
|
|
),
|
|
new OA\Response(
|
|
response: 400,
|
|
ref: '#/components/responses/400',
|
|
),
|
|
new OA\Response(
|
|
response: 401,
|
|
ref: '#/components/responses/401',
|
|
),
|
|
new OA\Response(
|
|
response: 422,
|
|
ref: '#/components/responses/422',
|
|
),
|
|
]
|
|
)]
|
|
public function create_github_app(Request $request)
|
|
{
|
|
$teamId = getTeamIdFromToken();
|
|
if (is_null($teamId)) {
|
|
return invalidTokenResponse();
|
|
}
|
|
$return = validateIncomingRequest($request);
|
|
if ($return instanceof \Illuminate\Http\JsonResponse) {
|
|
return $return;
|
|
}
|
|
|
|
$allowedFields = [
|
|
'name',
|
|
'organization',
|
|
'api_url',
|
|
'html_url',
|
|
'custom_user',
|
|
'custom_port',
|
|
'app_id',
|
|
'installation_id',
|
|
'client_id',
|
|
'client_secret',
|
|
'webhook_secret',
|
|
'private_key_uuid',
|
|
'is_system_wide',
|
|
];
|
|
|
|
$validator = customApiValidator($request->all(), [
|
|
'name' => 'required|string|max:255',
|
|
'organization' => 'nullable|string|max:255',
|
|
'api_url' => 'required|string|url',
|
|
'html_url' => 'required|string|url',
|
|
'custom_user' => 'nullable|string|max:255',
|
|
'custom_port' => 'nullable|integer|min:1|max:65535',
|
|
'app_id' => 'required|integer',
|
|
'installation_id' => 'required|integer',
|
|
'client_id' => 'required|string|max:255',
|
|
'client_secret' => 'required|string',
|
|
'webhook_secret' => 'required|string',
|
|
'private_key_uuid' => 'required|string',
|
|
'is_system_wide' => 'boolean',
|
|
]);
|
|
|
|
$extraFields = array_diff(array_keys($request->all()), $allowedFields);
|
|
if ($validator->fails() || ! empty($extraFields)) {
|
|
$errors = $validator->errors();
|
|
if (! empty($extraFields)) {
|
|
foreach ($extraFields as $field) {
|
|
$errors->add($field, 'This field is not allowed.');
|
|
}
|
|
}
|
|
|
|
return response()->json([
|
|
'message' => 'Validation failed.',
|
|
'errors' => $errors,
|
|
], 422);
|
|
}
|
|
|
|
try {
|
|
// Verify the private key belongs to the team
|
|
$privateKey = PrivateKey::where('uuid', $request->input('private_key_uuid'))
|
|
->where('team_id', $teamId)
|
|
->first();
|
|
|
|
if (! $privateKey) {
|
|
return response()->json([
|
|
'message' => 'Private key not found or does not belong to your team.',
|
|
], 404);
|
|
}
|
|
|
|
$payload = [
|
|
'uuid' => Str::uuid(),
|
|
'name' => $request->input('name'),
|
|
'organization' => $request->input('organization'),
|
|
'api_url' => $request->input('api_url'),
|
|
'html_url' => $request->input('html_url'),
|
|
'custom_user' => $request->input('custom_user', 'git'),
|
|
'custom_port' => $request->input('custom_port', 22),
|
|
'app_id' => $request->input('app_id'),
|
|
'installation_id' => $request->input('installation_id'),
|
|
'client_id' => $request->input('client_id'),
|
|
'client_secret' => $request->input('client_secret'),
|
|
'webhook_secret' => $request->input('webhook_secret'),
|
|
'private_key_id' => $privateKey->id,
|
|
'is_public' => false,
|
|
'team_id' => $teamId,
|
|
];
|
|
|
|
if (! isCloud()) {
|
|
$payload['is_system_wide'] = $request->input('is_system_wide', false);
|
|
}
|
|
|
|
$githubApp = GithubApp::create($payload);
|
|
|
|
return response()->json($githubApp, 201);
|
|
} catch (\Throwable $e) {
|
|
return handleError($e);
|
|
}
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/github-apps/{github_app_id}/repositories',
|
|
summary: 'Load Repositories for a GitHub App',
|
|
description: 'Fetch repositories from GitHub for a given GitHub app.',
|
|
operationId: 'load-repositories',
|
|
tags: ['GitHub Apps'],
|
|
security: [
|
|
['bearerAuth' => []],
|
|
],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'github_app_id',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'integer'),
|
|
description: 'GitHub App ID'
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Repositories loaded successfully.',
|
|
content: new OA\MediaType(
|
|
mediaType: 'application/json',
|
|
schema: new OA\Schema(
|
|
type: 'object',
|
|
properties: [
|
|
'repositories' => new OA\Items(
|
|
type: 'array',
|
|
items: new OA\Schema(type: 'object')
|
|
),
|
|
]
|
|
)
|
|
)
|
|
),
|
|
new OA\Response(
|
|
response: 400,
|
|
ref: '#/components/responses/400',
|
|
),
|
|
new OA\Response(
|
|
response: 401,
|
|
ref: '#/components/responses/401',
|
|
),
|
|
new OA\Response(
|
|
response: 404,
|
|
ref: '#/components/responses/404',
|
|
),
|
|
]
|
|
)]
|
|
public function load_repositories($github_app_id)
|
|
{
|
|
$teamId = getTeamIdFromToken();
|
|
if (is_null($teamId)) {
|
|
return invalidTokenResponse();
|
|
}
|
|
|
|
try {
|
|
$githubApp = GithubApp::where('id', $github_app_id)
|
|
->where('team_id', $teamId)
|
|
->firstOrFail();
|
|
|
|
$token = generateGithubInstallationToken($githubApp);
|
|
$repositories = collect();
|
|
$page = 1;
|
|
$maxPages = 100; // Safety limit: max 10,000 repositories
|
|
|
|
while ($page <= $maxPages) {
|
|
$response = Http::GitHub($githubApp->api_url, $token)
|
|
->timeout(20)
|
|
->retry(3, 200, throw: false)
|
|
->get('/installation/repositories', [
|
|
'per_page' => 100,
|
|
'page' => $page,
|
|
]);
|
|
|
|
if ($response->status() !== 200) {
|
|
return response()->json([
|
|
'message' => $response->json()['message'] ?? 'Failed to load repositories',
|
|
], $response->status());
|
|
}
|
|
|
|
$json = $response->json();
|
|
$repos = $json['repositories'] ?? [];
|
|
|
|
if (empty($repos)) {
|
|
break; // No more repositories to load
|
|
}
|
|
|
|
$repositories = $repositories->concat($repos);
|
|
$page++;
|
|
}
|
|
|
|
return response()->json([
|
|
'repositories' => $repositories->sortBy('name')->values(),
|
|
]);
|
|
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
|
return response()->json(['message' => 'GitHub app not found'], 404);
|
|
} catch (\Throwable $e) {
|
|
return handleError($e);
|
|
}
|
|
}
|
|
|
|
#[OA\Get(
|
|
path: '/github-apps/{github_app_id}/repositories/{owner}/{repo}/branches',
|
|
summary: 'Load Branches for a GitHub Repository',
|
|
description: 'Fetch branches from GitHub for a given repository.',
|
|
operationId: 'load-branches',
|
|
tags: ['GitHub Apps'],
|
|
security: [
|
|
['bearerAuth' => []],
|
|
],
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'github_app_id',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'integer'),
|
|
description: 'GitHub App ID'
|
|
),
|
|
new OA\Parameter(
|
|
name: 'owner',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string'),
|
|
description: 'Repository owner'
|
|
),
|
|
new OA\Parameter(
|
|
name: 'repo',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'string'),
|
|
description: 'Repository name'
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'Branches loaded successfully.',
|
|
content: new OA\MediaType(
|
|
mediaType: 'application/json',
|
|
schema: new OA\Schema(
|
|
type: 'object',
|
|
properties: [
|
|
'branches' => new OA\Items(
|
|
type: 'array',
|
|
items: new OA\Schema(type: 'object')
|
|
),
|
|
]
|
|
)
|
|
)
|
|
),
|
|
new OA\Response(
|
|
response: 400,
|
|
ref: '#/components/responses/400',
|
|
),
|
|
new OA\Response(
|
|
response: 401,
|
|
ref: '#/components/responses/401',
|
|
),
|
|
new OA\Response(
|
|
response: 404,
|
|
ref: '#/components/responses/404',
|
|
),
|
|
]
|
|
)]
|
|
public function load_branches($github_app_id, $owner, $repo)
|
|
{
|
|
$teamId = getTeamIdFromToken();
|
|
if (is_null($teamId)) {
|
|
return invalidTokenResponse();
|
|
}
|
|
|
|
try {
|
|
$githubApp = GithubApp::where('id', $github_app_id)
|
|
->where('team_id', $teamId)
|
|
->firstOrFail();
|
|
|
|
$token = generateGithubInstallationToken($githubApp);
|
|
|
|
$response = Http::GitHub($githubApp->api_url, $token)
|
|
->timeout(20)
|
|
->retry(3, 200, throw: false)
|
|
->get("/repos/{$owner}/{$repo}/branches");
|
|
|
|
if ($response->status() !== 200) {
|
|
return response()->json([
|
|
'message' => 'Error loading branches from GitHub.',
|
|
'error' => $response->json('message'),
|
|
], $response->status());
|
|
}
|
|
|
|
$branches = $response->json();
|
|
|
|
return response()->json([
|
|
'branches' => $branches,
|
|
]);
|
|
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
|
return response()->json(['message' => 'GitHub app not found'], 404);
|
|
} catch (\Throwable $e) {
|
|
return handleError($e);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update a GitHub app.
|
|
*/
|
|
#[OA\Patch(
|
|
path: '/github-apps/{github_app_id}',
|
|
operationId: 'updateGithubApp',
|
|
security: [
|
|
['api_token' => []],
|
|
],
|
|
tags: ['GitHub Apps'],
|
|
summary: 'Update GitHub App',
|
|
description: 'Update an existing GitHub app.',
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'github_app_id',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'integer'),
|
|
description: 'GitHub App ID'
|
|
),
|
|
],
|
|
requestBody: new OA\RequestBody(
|
|
required: true,
|
|
content: new OA\MediaType(
|
|
mediaType: 'application/json',
|
|
schema: new OA\Schema(
|
|
type: 'object',
|
|
properties: [
|
|
'name' => ['type' => 'string', 'description' => 'GitHub App name'],
|
|
'organization' => ['type' => 'string', 'nullable' => true, 'description' => 'GitHub organization'],
|
|
'api_url' => ['type' => 'string', 'description' => 'GitHub API URL'],
|
|
'html_url' => ['type' => 'string', 'description' => 'GitHub HTML URL'],
|
|
'custom_user' => ['type' => 'string', 'description' => 'Custom user for SSH'],
|
|
'custom_port' => ['type' => 'integer', 'description' => 'Custom port for SSH'],
|
|
'app_id' => ['type' => 'integer', 'description' => 'GitHub App ID'],
|
|
'installation_id' => ['type' => 'integer', 'description' => 'GitHub Installation ID'],
|
|
'client_id' => ['type' => 'string', 'description' => 'GitHub Client ID'],
|
|
'client_secret' => ['type' => 'string', 'description' => 'GitHub Client Secret'],
|
|
'webhook_secret' => ['type' => 'string', 'description' => 'GitHub Webhook Secret'],
|
|
'private_key_uuid' => ['type' => 'string', 'description' => 'Private key UUID'],
|
|
'is_system_wide' => ['type' => 'boolean', 'description' => 'Is system wide (non-cloud instances only)'],
|
|
]
|
|
)
|
|
)
|
|
),
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'GitHub app updated successfully',
|
|
content: new OA\MediaType(
|
|
mediaType: 'application/json',
|
|
schema: new OA\Schema(
|
|
type: 'object',
|
|
properties: [
|
|
'message' => ['type' => 'string', 'example' => 'GitHub app updated successfully'],
|
|
'data' => ['type' => 'object', 'description' => 'Updated GitHub app data'],
|
|
]
|
|
)
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthorized'),
|
|
new OA\Response(response: 404, description: 'GitHub app not found'),
|
|
new OA\Response(response: 422, description: 'Validation error'),
|
|
]
|
|
)]
|
|
public function update_github_app(Request $request, $github_app_id)
|
|
{
|
|
$teamId = getTeamIdFromToken();
|
|
if (is_null($teamId)) {
|
|
return invalidTokenResponse();
|
|
}
|
|
|
|
try {
|
|
$githubApp = GithubApp::where('id', $github_app_id)
|
|
->where('team_id', $teamId)
|
|
->firstOrFail();
|
|
|
|
// Define allowed fields for update
|
|
$allowedFields = [
|
|
'name',
|
|
'organization',
|
|
'api_url',
|
|
'html_url',
|
|
'custom_user',
|
|
'custom_port',
|
|
'app_id',
|
|
'installation_id',
|
|
'client_id',
|
|
'client_secret',
|
|
'webhook_secret',
|
|
'private_key_uuid',
|
|
];
|
|
|
|
if (! isCloud()) {
|
|
$allowedFields[] = 'is_system_wide';
|
|
}
|
|
|
|
$payload = $request->only($allowedFields);
|
|
|
|
// Validate the request
|
|
$rules = [];
|
|
if (isset($payload['name'])) {
|
|
$rules['name'] = 'string';
|
|
}
|
|
if (isset($payload['organization'])) {
|
|
$rules['organization'] = 'nullable|string';
|
|
}
|
|
if (isset($payload['api_url'])) {
|
|
$rules['api_url'] = 'url';
|
|
}
|
|
if (isset($payload['html_url'])) {
|
|
$rules['html_url'] = 'url';
|
|
}
|
|
if (isset($payload['custom_user'])) {
|
|
$rules['custom_user'] = 'string';
|
|
}
|
|
if (isset($payload['custom_port'])) {
|
|
$rules['custom_port'] = 'integer|min:1|max:65535';
|
|
}
|
|
if (isset($payload['app_id'])) {
|
|
$rules['app_id'] = 'integer';
|
|
}
|
|
if (isset($payload['installation_id'])) {
|
|
$rules['installation_id'] = 'integer';
|
|
}
|
|
if (isset($payload['client_id'])) {
|
|
$rules['client_id'] = 'string';
|
|
}
|
|
if (isset($payload['client_secret'])) {
|
|
$rules['client_secret'] = 'string';
|
|
}
|
|
if (isset($payload['webhook_secret'])) {
|
|
$rules['webhook_secret'] = 'string';
|
|
}
|
|
if (isset($payload['private_key_uuid'])) {
|
|
$rules['private_key_uuid'] = 'string|uuid';
|
|
}
|
|
if (! isCloud() && isset($payload['is_system_wide'])) {
|
|
$rules['is_system_wide'] = 'boolean';
|
|
}
|
|
|
|
$validator = customApiValidator($payload, $rules);
|
|
if ($validator->fails()) {
|
|
return response()->json([
|
|
'message' => 'Validation error',
|
|
'errors' => $validator->errors(),
|
|
], 422);
|
|
}
|
|
|
|
// Handle private_key_uuid -> private_key_id conversion
|
|
if (isset($payload['private_key_uuid'])) {
|
|
$privateKey = PrivateKey::where('team_id', $teamId)
|
|
->where('uuid', $payload['private_key_uuid'])
|
|
->first();
|
|
|
|
if (! $privateKey) {
|
|
return response()->json([
|
|
'message' => 'Private key not found or does not belong to your team',
|
|
], 404);
|
|
}
|
|
|
|
unset($payload['private_key_uuid']);
|
|
$payload['private_key_id'] = $privateKey->id;
|
|
}
|
|
|
|
// Update the GitHub app
|
|
$githubApp->update($payload);
|
|
|
|
return response()->json([
|
|
'message' => 'GitHub app updated successfully',
|
|
'data' => $githubApp,
|
|
]);
|
|
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
|
return response()->json([
|
|
'message' => 'GitHub app not found',
|
|
], 404);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Delete a GitHub app.
|
|
*/
|
|
#[OA\Delete(
|
|
path: '/github-apps/{github_app_id}',
|
|
operationId: 'deleteGithubApp',
|
|
security: [
|
|
['api_token' => []],
|
|
],
|
|
tags: ['GitHub Apps'],
|
|
summary: 'Delete GitHub App',
|
|
description: 'Delete a GitHub app if it\'s not being used by any applications.',
|
|
parameters: [
|
|
new OA\Parameter(
|
|
name: 'github_app_id',
|
|
in: 'path',
|
|
required: true,
|
|
schema: new OA\Schema(type: 'integer'),
|
|
description: 'GitHub App ID'
|
|
),
|
|
],
|
|
responses: [
|
|
new OA\Response(
|
|
response: 200,
|
|
description: 'GitHub app deleted successfully',
|
|
content: new OA\MediaType(
|
|
mediaType: 'application/json',
|
|
schema: new OA\Schema(
|
|
type: 'object',
|
|
properties: [
|
|
'message' => ['type' => 'string', 'example' => 'GitHub app deleted successfully'],
|
|
]
|
|
)
|
|
)
|
|
),
|
|
new OA\Response(response: 401, description: 'Unauthorized'),
|
|
new OA\Response(response: 404, description: 'GitHub app not found'),
|
|
new OA\Response(
|
|
response: 409,
|
|
description: 'Conflict - GitHub app is in use',
|
|
content: new OA\MediaType(
|
|
mediaType: 'application/json',
|
|
schema: new OA\Schema(
|
|
type: 'object',
|
|
properties: [
|
|
'message' => ['type' => 'string', 'example' => 'This GitHub app is being used by 5 application(s). Please delete all applications first.'],
|
|
]
|
|
)
|
|
)
|
|
),
|
|
]
|
|
)]
|
|
public function delete_github_app($github_app_id)
|
|
{
|
|
$teamId = getTeamIdFromToken();
|
|
if (is_null($teamId)) {
|
|
return invalidTokenResponse();
|
|
}
|
|
|
|
try {
|
|
$githubApp = GithubApp::where('id', $github_app_id)
|
|
->where('team_id', $teamId)
|
|
->firstOrFail();
|
|
|
|
// Check if the GitHub app is being used by any applications
|
|
if ($githubApp->applications->isNotEmpty()) {
|
|
$count = $githubApp->applications->count();
|
|
|
|
return response()->json([
|
|
'message' => "This GitHub app is being used by {$count} application(s). Please delete all applications first.",
|
|
], 409);
|
|
}
|
|
|
|
$githubApp->delete();
|
|
|
|
return response()->json([
|
|
'message' => 'GitHub app deleted successfully',
|
|
]);
|
|
} catch (\Illuminate\Database\Eloquent\ModelNotFoundException $e) {
|
|
return response()->json([
|
|
'message' => 'GitHub app not found',
|
|
], 404);
|
|
}
|
|
}
|
|
}
|