Files
toolbox/app/Clients/JenkinsClient.php
T
tradewind ba916f5ce1 #feature: add Jenkins batch build trigger
Add an admin Jenkins build page that reuses notification-enabled project config, loads Jenkins build parameters, supports persisted selections, and triggers selected jobs in one action.
2026-06-01 18:03:33 +08:00

307 lines
9.5 KiB
PHP

<?php
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()
{
$config = config('jenkins', []);
$this->host = rtrim($config['host'] ?? '', '/');
$this->username = $config['username'] ?? null;
$this->apiToken = $config['api_token'] ?? null;
$this->timeout = $config['timeout'] ?? 30;
}
public function isConfigured(): bool
{
return ! empty($this->host) && ! empty($this->username) && ! empty($this->apiToken);
}
public function getJobInfo(string $jobName): ?array
{
return $this->request($this->getJobPath($jobName).'/api/json');
}
public function getBuildInfo(string $jobName, int $buildNumber): ?array
{
return $this->request($this->getJobPath($jobName)."/{$buildNumber}/api/json");
}
public function getLastBuild(string $jobName): ?array
{
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'])) {
return [];
}
$builds = array_slice($jobInfo['builds'], 0, $limit);
$result = [];
foreach ($builds as $build) {
$buildInfo = $this->getBuildInfo($jobName, $build['number']);
if ($buildInfo) {
$result[] = $buildInfo;
}
}
return $result;
}
private function request(string $path): ?array
{
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()) {
return $response->json();
}
Log::warning('Jenkins API request failed', [
'url' => $url,
'status' => $response->status(),
]);
return null;
} catch (\Throwable $e) {
Log::error('Jenkins API request error', [
'url' => $url,
'error' => $e->getMessage(),
]);
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('/<div name="parameter"[^>]*>/s', $html) ?: [], 1) as $parameterHtml) {
if (! preg_match('/<input name="name" type="hidden" value="([^"]+)"/s', $parameterHtml, $nameMatch)) {
continue;
}
if (! preg_match('/<select([^>]*)>(.*?)<\/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('/<option([^>]*) 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));
}
}