diff --git a/app/Clients/JenkinsClient.php b/app/Clients/JenkinsClient.php index 5d8a382..f622ed6 100644 --- a/app/Clients/JenkinsClient.php +++ b/app/Clients/JenkinsClient.php @@ -2,14 +2,18 @@ namespace App\Clients; +use Illuminate\Http\Client\PendingRequest; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; class JenkinsClient { private ?string $host; + private ?string $username; + private ?string $apiToken; + private int $timeout; public function __construct() @@ -23,28 +27,121 @@ class JenkinsClient public function isConfigured(): bool { - return !empty($this->host) && !empty($this->username) && !empty($this->apiToken); + return ! empty($this->host) && ! empty($this->username) && ! empty($this->apiToken); } public function getJobInfo(string $jobName): ?array { - return $this->request("/job/{$jobName}/api/json"); + return $this->request($this->getJobPath($jobName).'/api/json'); } public function getBuildInfo(string $jobName, int $buildNumber): ?array { - return $this->request("/job/{$jobName}/{$buildNumber}/api/json"); + return $this->request($this->getJobPath($jobName)."/{$buildNumber}/api/json"); } public function getLastBuild(string $jobName): ?array { - return $this->request("/job/{$jobName}/lastBuild/api/json"); + return $this->request($this->getJobPath($jobName).'/lastBuild/api/json'); + } + + public function getParameterDefinitions(string $jobName): array + { + $jobInfo = $this->getJobInfo($jobName); + if (! $jobInfo || empty($jobInfo['property'])) { + return []; + } + + $buildFormParameters = $this->getBuildFormParameters($jobName); + + foreach ($jobInfo['property'] as $property) { + if (empty($property['parameterDefinitions']) || ! is_array($property['parameterDefinitions'])) { + continue; + } + + return array_map(function (array $definition) use ($buildFormParameters) { + $name = $definition['name'] ?? ''; + $formParameter = $buildFormParameters[$name] ?? []; + $default = $definition['defaultParameterValue']['value'] ?? null; + if (($formParameter['multiple'] ?? false) && isset($formParameter['default'])) { + $default = $formParameter['default']; + } + + return [ + 'name' => $name, + 'type' => $definition['type'] ?? $definition['_class'] ?? 'StringParameterDefinition', + 'description' => $definition['description'] ?? '', + 'default' => $default, + 'choices' => $definition['choices'] ?? $formParameter['choices'] ?? [], + 'multiple' => (bool) ($formParameter['multiple'] ?? false), + ]; + }, array_values(array_filter($property['parameterDefinitions'], fn ($definition) => ! empty($definition['name'])))); + } + + return []; + } + + public function triggerBuild(string $jobName, array $parameters = []): array + { + if (! $this->isConfigured()) { + Log::warning('Jenkins client is not configured'); + + return [ + 'success' => false, + 'message' => 'Jenkins not configured', + ]; + } + + $path = $this->getJobPath($jobName).(empty($parameters) ? '/build' : '/buildWithParameters'); + $url = $this->host.$path; + + try { + $request = $this->http(); + $crumb = $this->getCrumb(); + if ($crumb) { + $request = $request->withHeaders([$crumb['field'] => $crumb['crumb']]); + } + + $response = empty($parameters) + ? $request->post($url) + : $request->asForm()->post($url, $parameters); + + if ($response->successful() || $response->status() === 201) { + return [ + 'success' => true, + 'queue_url' => $response->header('Location'), + 'status' => $response->status(), + ]; + } + + Log::warning('Jenkins build trigger failed', [ + 'url' => $url, + 'status' => $response->status(), + 'body' => $response->body(), + ]); + + return [ + 'success' => false, + 'message' => 'Jenkins 返回状态码 '.$response->status(), + 'status' => $response->status(), + ]; + } catch (\Throwable $e) { + Log::error('Jenkins build trigger error', [ + 'url' => $url, + 'error' => $e->getMessage(), + ]); + + return [ + 'success' => false, + 'message' => $e->getMessage(), + ]; + } } public function getBuilds(string $jobName, int $limit = 10): array { $jobInfo = $this->getJobInfo($jobName); - if (!$jobInfo || empty($jobInfo['builds'])) { + if (! $jobInfo || empty($jobInfo['builds'])) { return []; } @@ -63,17 +160,16 @@ class JenkinsClient private function request(string $path): ?array { - if (!$this->isConfigured()) { + if (! $this->isConfigured()) { Log::warning('Jenkins client is not configured'); + return null; } - $url = $this->host . $path; + $url = $this->host.$path; try { - $response = Http::timeout($this->timeout) - ->withBasicAuth($this->username, $this->apiToken) - ->get($url); + $response = $this->http()->get($url); if ($response->successful()) { return $response->json(); @@ -94,4 +190,117 @@ class JenkinsClient return null; } } + + private function requestBody(string $path, bool $allowMethodNotAllowed = false): ?string + { + if (! $this->isConfigured()) { + Log::warning('Jenkins client is not configured'); + + return null; + } + + $url = $this->host.$path; + + try { + $response = $this->http()->get($url); + + if ($response->successful() || ($allowMethodNotAllowed && $response->status() === 405)) { + return $response->body(); + } + + Log::warning('Jenkins page request failed', [ + 'url' => $url, + 'status' => $response->status(), + ]); + + return null; + } catch (\Throwable $e) { + Log::error('Jenkins page request error', [ + 'url' => $url, + 'error' => $e->getMessage(), + ]); + + return null; + } + } + + private function getBuildFormParameters(string $jobName): array + { + $html = $this->requestBody($this->getJobPath($jobName).'/build?delay=0sec', allowMethodNotAllowed: true); + if (! $html) { + return []; + } + + $parameters = []; + foreach (array_slice(preg_split('/
]*>/s', $html) ?: [], 1) as $parameterHtml) { + if (! preg_match('/]*)>(.*?)<\/select>/s', $parameterHtml, $selectMatch)) { + continue; + } + + $name = html_entity_decode($nameMatch[1], ENT_QUOTES | ENT_HTML5); + $selectAttributes = $selectMatch[1]; + $optionsHtml = $selectMatch[2]; + $multiple = str_contains($selectAttributes, 'multiple'); + + preg_match_all('/]*) value="([^"]*)"[^>]*>(.*?)<\/option>/s', $optionsHtml, $optionMatches, PREG_SET_ORDER); + + $choices = []; + $defaults = []; + foreach ($optionMatches as $optionMatch) { + $attributes = $optionMatch[1]; + $value = html_entity_decode($optionMatch[2], ENT_QUOTES | ENT_HTML5); + $label = trim(strip_tags(html_entity_decode($optionMatch[3], ENT_QUOTES | ENT_HTML5))); + $selected = str_contains($attributes, 'selected') || str_contains($label, '√'); + $label = trim(str_replace('√', '', $label)); + + $choices[] = [ + 'value' => $value, + 'label' => $label ?: $value, + 'selected' => $selected, + ]; + + if ($selected) { + $defaults[] = $value; + } + } + + $parameters[$name] = [ + 'choices' => $choices, + 'default' => $multiple ? $defaults : ($defaults[0] ?? null), + 'multiple' => $multiple, + ]; + } + + return $parameters; + } + + private function http(): PendingRequest + { + return Http::timeout($this->timeout) + ->withBasicAuth($this->username, $this->apiToken); + } + + private function getCrumb(): ?array + { + $crumb = $this->request('/crumbIssuer/api/json'); + if (! $crumb || empty($crumb['crumb']) || empty($crumb['crumbRequestField'])) { + return null; + } + + return [ + 'field' => $crumb['crumbRequestField'], + 'crumb' => $crumb['crumb'], + ]; + } + + private function getJobPath(string $jobName): string + { + $segments = array_filter(explode('/', trim($jobName, '/')), fn ($segment) => $segment !== ''); + + return '/job/'.implode('/job/', array_map('rawurlencode', $segments)); + } } diff --git a/app/Http/Controllers/Admin/JenkinsBuildController.php b/app/Http/Controllers/Admin/JenkinsBuildController.php new file mode 100644 index 0000000..f0ebffb --- /dev/null +++ b/app/Http/Controllers/Admin/JenkinsBuildController.php @@ -0,0 +1,111 @@ +jenkinsClient->isConfigured()) { + return response()->json([ + 'success' => false, + 'message' => 'Jenkins 未配置,请先配置 JENKINS_HOST、JENKINS_USERNAME、JENKINS_API_TOKEN', + ], 422); + } + + $projects = Project::getJenkinsNotifyEnabled() + ->map(function (Project $project) { + return [ + 'id' => $project->id, + 'slug' => $project->slug, + 'name' => $project->name, + 'jenkins_job_name' => $project->jenkins_job_name, + 'parameters' => $this->jenkinsClient->getParameterDefinitions($project->jenkins_job_name), + ]; + }) + ->values(); + + return response()->json([ + 'success' => true, + 'data' => [ + 'projects' => $projects, + ], + ]); + } + + public function trigger(Request $request): JsonResponse + { + if (! $this->jenkinsClient->isConfigured()) { + return response()->json([ + 'success' => false, + 'message' => 'Jenkins 未配置,请先配置 Jenkins 连接信息', + ], 422); + } + + $projectSlugs = Project::getJenkinsNotifyEnabled()->pluck('slug')->all(); + + $data = $request->validate([ + 'builds' => ['required', 'array', 'min:1'], + 'builds.*.project_slug' => ['required', 'string', Rule::in($projectSlugs)], + 'builds.*.parameters' => ['nullable', 'array'], + ]); + + $projects = Project::getJenkinsNotifyEnabled()->keyBy('slug'); + $results = []; + $successCount = 0; + + foreach ($data['builds'] as $build) { + /** @var Project $project */ + $project = $projects[$build['project_slug']]; + $parameters = $this->normalizeParameters($build['parameters'] ?? []); + $result = $this->jenkinsClient->triggerBuild($project->jenkins_job_name, $parameters); + + if ($result['success'] ?? false) { + $successCount++; + } + + $results[] = [ + 'project_slug' => $project->slug, + 'project_name' => $project->name, + 'job_name' => $project->jenkins_job_name, + 'success' => (bool) ($result['success'] ?? false), + 'message' => $result['message'] ?? null, + 'queue_url' => $result['queue_url'] ?? null, + ]; + } + + return response()->json([ + 'success' => $successCount === count($results), + 'message' => sprintf('触发完成:成功 %d 个,失败 %d 个', $successCount, count($results) - $successCount), + 'data' => [ + 'results' => $results, + ], + ], $successCount > 0 ? 200 : 422); + } + + private function normalizeParameters(array $parameters): array + { + return collect($parameters) + ->filter(fn ($value) => $value !== null && $value !== '') + ->map(function ($value) { + if (is_array($value)) { + return implode(',', array_filter($value, fn ($item) => $item !== null && $item !== '')); + } + + return is_bool($value) ? ($value ? 'true' : 'false') : $value; + }) + ->filter(fn ($value) => $value !== '') + ->all(); + } +} diff --git a/resources/js/components/admin/AdminDashboard.vue b/resources/js/components/admin/AdminDashboard.vue index 8614e77..53fcf94 100644 --- a/resources/js/components/admin/AdminDashboard.vue +++ b/resources/js/components/admin/AdminDashboard.vue @@ -79,6 +79,9 @@ + + + @@ -99,6 +102,7 @@ import OperationLogs from './OperationLogs.vue'; import IpUserMappings from './IpUserMappings.vue'; import ProjectManagement from './ProjectManagement.vue'; import ScheduledTasks from './ScheduledTasks.vue'; +import JenkinsBuilds from './JenkinsBuilds.vue'; export default { name: 'AdminDashboard', @@ -118,7 +122,8 @@ export default { OperationLogs, IpUserMappings, ProjectManagement, - ScheduledTasks + ScheduledTasks, + JenkinsBuilds }, data() { return { @@ -148,7 +153,7 @@ export default { } }, handleMenuChange(menu) { - if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks') && !this.isAdmin) { + if ((menu === 'ip-mappings' || menu === 'projects' || menu === 'scheduled-tasks' || menu === 'jenkins-builds') && !this.isAdmin) { this.redirectToDefault(); return; } @@ -171,7 +176,8 @@ export default { 'logs': '操作日志', 'ip-mappings': 'IP 用户映射', 'projects': '项目配置管理', - 'scheduled-tasks': '定时任务管理' + 'scheduled-tasks': '定时任务管理', + 'jenkins-builds': 'Jenkins 一键构建' }; this.pageTitle = titles[menu] || '环境配置管理'; @@ -211,9 +217,11 @@ export default { page = 'projects'; } else if (path === '/scheduled-tasks') { page = 'scheduled-tasks'; + } else if (path === '/jenkins-builds') { + page = 'jenkins-builds'; } - if ((page === 'ip-mappings' || page === 'projects' || page === 'scheduled-tasks') && !this.isAdmin) { + if ((page === 'ip-mappings' || page === 'projects' || page === 'scheduled-tasks' || page === 'jenkins-builds') && !this.isAdmin) { this.redirectToDefault(); return; } diff --git a/resources/js/components/admin/AdminLayout.vue b/resources/js/components/admin/AdminLayout.vue index ef64bc5..af21182 100644 --- a/resources/js/components/admin/AdminLayout.vue +++ b/resources/js/components/admin/AdminLayout.vue @@ -72,6 +72,31 @@ 定时任务 + + + + + Jenkins 构建 + + +
+
+
+

Jenkins 一键构建

+

勾选 Jenkins 通知项目,调整参数后批量触发 Build

+
+
+ + +
+
+ +
{{ message }}
+
{{ error }}
+ +
+
+
+

可构建项目

+ {{ projects.length }} 个项目 +
+ +
+ +
加载 Jenkins 项目中...
+
+ 暂无启用 Jenkins 发布通知且配置 Job 名称的项目 +
+ +
+ + + + + + + + + + + + + +
项目 / JobBuild 参数
+ + +
+ 无参数,直接触发 build +
+ +
+
+ +
+ +
+
+ +
+ + +
+
+
+
+
+
+
+ +
+
+

触发结果

+
+
+ + + + + + + + + + + + + + + + + +
项目Job状态队列
{{ result.project_name }}{{ result.job_name }} + + {{ result.success ? '已触发' : (result.message || '失败') }} + + + {{ result.queue_url }} + - +
+
+
+
+ + + diff --git a/routes/api.php b/routes/api.php index e183be3..c6c1672 100644 --- a/routes/api.php +++ b/routes/api.php @@ -1,21 +1,22 @@ group(function () { @@ -51,7 +52,6 @@ Route::prefix('jira')->group(function () { Route::get('/weekly-report/download', [JiraController::class, 'downloadWeeklyReport']); }); - // 提测邮件 API 路由 Route::prefix('test-mail')->group(function () { Route::get('/sprints', [TestMailController::class, 'sprints']); @@ -116,6 +116,8 @@ Route::prefix('admin')->middleware('admin.ip')->group(function () { Route::post('/scheduled-tasks/{name}/toggle', [ScheduledTaskController::class, 'toggle']); // Jenkins 发布历史 + Route::get('/jenkins/build-projects', [JenkinsBuildController::class, 'projects']); + Route::post('/jenkins/trigger-builds', [JenkinsBuildController::class, 'trigger']); Route::get('/jenkins/deployments', [JenkinsDeploymentController::class, 'index']); Route::get('/jenkins/deployments/{id}', [JenkinsDeploymentController::class, 'show']); }); diff --git a/routes/web.php b/routes/web.php index 36988e2..1eb882b 100644 --- a/routes/web.php +++ b/routes/web.php @@ -1,7 +1,7 @@ name('home'); @@ -22,3 +22,4 @@ Route::get('/logs', [AdminController::class, 'index'])->name('admin.logs'); Route::get('/ip-mappings', [AdminController::class, 'index'])->name('admin.ip-mappings')->middleware('admin.ip'); Route::get('/projects', [AdminController::class, 'index'])->name('admin.projects')->middleware('admin.ip'); Route::get('/scheduled-tasks', [AdminController::class, 'index'])->name('admin.scheduled-tasks')->middleware('admin.ip'); +Route::get('/jenkins-builds', [AdminController::class, 'index'])->name('admin.jenkins-builds')->middleware('admin.ip');