Compare commits

...
Author SHA1 Message Date
tradewind adf728adfc #feature: add agent consumer delay monitoring 2026-09-01 15:53:29 +08:00
tradewindandCursor c45d68e201 #feature: Jenkins 构建入口与自动创建 release 分支
加入 crm-web/crm-opm 并可跳转 Jenkins 发布页;自动创建 release 时 commit 使用 release-版本号,并跳过本地 git hook,避免 agent-be 被拦下。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-14 09:14:57 +08:00
14 changed files with 638 additions and 50 deletions
+11
View File
@@ -45,6 +45,17 @@ class JenkinsClient
return $this->request($this->getJobPath($jobName).'/lastBuild/api/json'); return $this->request($this->getJobPath($jobName).'/lastBuild/api/json');
} }
public function getJobUrl(string $jobName, ?int $buildNumber = null): ?string
{
if (empty($this->host) || trim($jobName) === '') {
return null;
}
$url = $this->host.$this->getJobPath($jobName).'/';
return $buildNumber ? $url.$buildNumber.'/' : $url;
}
public function getParameterDefinitions(string $jobName): array public function getParameterDefinitions(string $jobName): array
{ {
$jobInfo = $this->getJobInfo($jobName); $jobInfo = $this->getJobInfo($jobName);
@@ -0,0 +1,39 @@
<?php
namespace App\Console\Commands;
use App\Services\AgentConsumerDelayMonitorService;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Log;
class AgentConsumerDelayMonitorCommand extends Command
{
protected $signature = 'agent-consumer:monitor-delay';
protected $description = '检查 Agent 消息消费延迟并发送钉钉告警或恢复通知';
public function handle(AgentConsumerDelayMonitorService $service): int
{
try {
$result = $service->check();
Log::channel('agent-consumer-monitor')->info('Agent 消费延迟检查完成', $result);
$this->info(sprintf(
'检查完成:eventname=%s,延迟=%s 分钟,%d 个新告警,%d 个恢复。',
$result['event_name'] ?? '-',
$result['delay_minutes'] ?? '-',
$result['alerted_count'],
$result['recovered_count']
));
return self::SUCCESS;
} catch (\Throwable $e) {
Log::channel('agent-consumer-monitor')->error('Agent 消费延迟检查失败', [
'message' => $e->getMessage(),
]);
$this->error($e->getMessage());
return self::FAILURE;
}
}
}
@@ -31,6 +31,7 @@ class JenkinsBuildController extends Controller
'slug' => $project->slug, 'slug' => $project->slug,
'name' => $project->name, 'name' => $project->name,
'jenkins_job_name' => $project->jenkins_job_name, 'jenkins_job_name' => $project->jenkins_job_name,
'jenkins_job_url' => $this->jenkinsClient->getJobUrl($project->jenkins_job_name),
'parameters' => $this->jenkinsClient->getParameterDefinitions($project->jenkins_job_name), 'parameters' => $this->jenkinsClient->getParameterDefinitions($project->jenkins_job_name),
]; ];
}) })
@@ -39,6 +40,7 @@ class JenkinsBuildController extends Controller
return response()->json([ return response()->json([
'success' => true, 'success' => true,
'data' => [ 'data' => [
'jenkins_host' => rtrim((string) config('jenkins.host'), '/'),
'projects' => $projects, 'projects' => $projects,
], ],
]); ]);
@@ -79,10 +81,14 @@ class JenkinsBuildController extends Controller
'project_slug' => $project->slug, 'project_slug' => $project->slug,
'project_name' => $project->name, 'project_name' => $project->name,
'job_name' => $project->jenkins_job_name, 'job_name' => $project->jenkins_job_name,
'jenkins_job_url' => $this->jenkinsClient->getJobUrl($project->jenkins_job_name),
'success' => (bool) ($result['success'] ?? false), 'success' => (bool) ($result['success'] ?? false),
'message' => $result['message'] ?? null, 'message' => $result['message'] ?? null,
'queue_url' => $result['queue_url'] ?? null, 'queue_url' => $result['queue_url'] ?? null,
'build_number' => $result['build_number'] ?? null, 'build_number' => $result['build_number'] ?? null,
'build_url' => isset($result['build_number'])
? $this->jenkinsClient->getJobUrl($project->jenkins_job_name, (int) $result['build_number'])
: null,
]; ];
} }
@@ -120,14 +126,23 @@ class JenkinsBuildController extends Controller
/** @var Project $project */ /** @var Project $project */
$project = $projects[$build['project_slug']]; $project = $projects[$build['project_slug']];
$status = $this->jenkinsClient->getBuildStatus(
$project->jenkins_job_name,
$build['queue_url'] ?? null,
isset($build['build_number']) ? (int) $build['build_number'] : null
);
$buildNumber = $status['build_number'] ?? ($build['build_number'] ?? null);
$results[] = [ $results[] = [
'id' => $build['id'], 'id' => $build['id'],
'project_slug' => $project->slug, 'project_slug' => $project->slug,
'job_name' => $project->jenkins_job_name, 'job_name' => $project->jenkins_job_name,
...$this->jenkinsClient->getBuildStatus( 'jenkins_job_url' => $this->jenkinsClient->getJobUrl($project->jenkins_job_name),
$project->jenkins_job_name, ...$status,
$build['queue_url'] ?? null, 'build_url' => $status['build_url'] ?? (
isset($build['build_number']) ? (int) $build['build_number'] : null $buildNumber
? $this->jenkinsClient->getJobUrl($project->jenkins_job_name, (int) $buildNumber)
: null
), ),
]; ];
} }
@@ -0,0 +1,98 @@
<?php
namespace App\Services;
use Carbon\CarbonImmutable;
use Illuminate\Database\DatabaseManager;
class AgentConsumerDelayMonitorService
{
public const STATE_CONFIG_KEY = 'agent_consumer_delay_monitor.alerts';
private const DELAY_THRESHOLD_MINUTES = 5;
public function __construct(
private readonly DatabaseManager $database,
private readonly DingTalkService $dingTalkService,
private readonly ConfigService $configService
) {}
public function check(): array
{
$now = CarbonImmutable::now();
$latestPending = $this->database->connection('agentslave')
->table('crm_event_consumer')
->select(['event_name', 'created'])
->where('status', '<>', 1)
->orderByDesc('id')
->first();
$eventName = $latestPending?->event_name;
$created = $latestPending ? CarbonImmutable::parse($latestPending->created) : null;
$delayMinutes = $created?->diffInMinutes($now, false);
$isDelayed = $delayMinutes !== null && $delayMinutes > self::DELAY_THRESHOLD_MINUTES;
$previousState = $this->configService->get(self::STATE_CONFIG_KEY, []);
$previousState = is_array($previousState) ? $previousState : [];
$wasDelayed = $previousState !== [];
$alertedCount = 0;
$recoveredCount = 0;
if ($isDelayed && ! $wasDelayed
&& $this->dingTalkService->sendText($this->buildDelayAlert($eventName, $created, $delayMinutes, $now))) {
$this->configService->set(self::STATE_CONFIG_KEY, [
'event_name' => $eventName,
'delayed_since' => $created->toDateTimeString(),
], 'Agent 消费延迟告警状态');
$alertedCount = 1;
} elseif (! $isDelayed && $wasDelayed
&& $this->dingTalkService->sendText($this->buildRecoveryNotice($eventName, $delayMinutes, $now))) {
$this->configService->set(self::STATE_CONFIG_KEY, [], 'Agent 消费延迟告警状态');
$recoveredCount = 1;
}
return [
'checked_at' => $now->toDateTimeString(),
'event_name' => $eventName,
'consumer_time' => $created?->toDateTimeString(),
'delay_minutes' => $delayMinutes === null ? null : (int) floor($delayMinutes),
'alerted_count' => $alertedCount,
'recovered_count' => $recoveredCount,
];
}
private function buildDelayAlert(
string $eventName,
CarbonImmutable $created,
float $delayMinutes,
CarbonImmutable $now
): string {
return implode("\n", [
'⚠️ 【Agent 消费延迟告警】',
'eventname: '.$eventName,
'消费到: '.$created->toDateTimeString(),
sprintf('延迟: %d 分钟', (int) floor($delayMinutes)),
'检查时间: '.$now->toDateTimeString(),
]);
}
private function buildRecoveryNotice(
?string $eventName,
?float $delayMinutes,
CarbonImmutable $now
): string {
$lines = [
'✅ 【Agent 消费延迟恢复】',
'检查时间: '.$now->toDateTimeString(),
];
if ($eventName !== null && $delayMinutes !== null) {
$lines[] = '当前 eventname: '.$eventName;
$lines[] = sprintf('当前延迟: %d 分钟', (int) floor($delayMinutes));
} else {
$lines[] = '当前无待消费消息';
}
return implode("\n", $lines);
}
}
+3 -3
View File
@@ -18,7 +18,7 @@ class DingTalkService
$this->secret = $config['secret'] ?? null; $this->secret = $config['secret'] ?? null;
} }
public function sendText(string $message, array $atMobiles = [], bool $atAll = false): void public function sendText(string $message, array $atMobiles = [], bool $atAll = false): bool
{ {
if (empty($this->webhook)) { if (empty($this->webhook)) {
Log::warning('DingTalk webhook is not configured, skip sending alert. Alert content logged below.', [ Log::warning('DingTalk webhook is not configured, skip sending alert. Alert content logged below.', [
@@ -27,10 +27,10 @@ class DingTalkService
'atAll' => $atAll, 'atAll' => $atAll,
]); ]);
return; return false;
} }
$this->sendTextToWebhook($this->webhook, $message, $atMobiles, $atAll, $this->secret); return $this->sendTextToWebhook($this->webhook, $message, $atMobiles, $atAll, $this->secret);
} }
public function sendTextToToken(string $token, string $message, array $atMobiles = [], bool $atAll = false): bool public function sendTextToToken(string $token, string $message, array $atMobiles = [], bool $atAll = false): bool
+48 -29
View File
@@ -4,7 +4,6 @@ namespace App\Services;
use App\Models\Project; use App\Models\Project;
use Carbon\Carbon; use Carbon\Carbon;
use Illuminate\Support\Arr;
use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Log;
use Symfony\Component\Process\Exception\ProcessFailedException; use Symfony\Component\Process\Exception\ProcessFailedException;
use Symfony\Component\Process\Process; use Symfony\Component\Process\Process;
@@ -12,15 +11,20 @@ use Symfony\Component\Process\Process;
class GitMonitorService class GitMonitorService
{ {
private const DEVELOP_BRANCH = 'develop'; private const DEVELOP_BRANCH = 'develop';
private const RELEASE_CACHE_KEY = 'git-monitor.release_cache'; private const RELEASE_CACHE_KEY = 'git-monitor.release_cache';
/** /**
* 项目配置(只包含允许巡检的项目) * 项目配置(只包含允许巡检的项目)
*
* @var array<string, array<string, mixed>> * @var array<string, array<string, mixed>>
*/ */
private array $projects = []; private array $projects = [];
private string $projectsPath; private string $projectsPath;
private int $commitScanLimit; private int $commitScanLimit;
private int $gitTimeout; private int $gitTimeout;
public function __construct( public function __construct(
@@ -41,9 +45,9 @@ class GitMonitorService
} }
// 检查是否需要刷新缓存 // 检查是否需要刷新缓存
if (!$force) { if (! $force) {
$anyProject = Project::query()->whereNotNull('git_version_cached_at')->first(); $anyProject = Project::query()->whereNotNull('git_version_cached_at')->first();
if ($anyProject && !$this->shouldRefreshCache($anyProject->git_version_cached_at)) { if ($anyProject && ! $this->shouldRefreshCache($anyProject->git_version_cached_at)) {
return $this->buildCachePayload(); return $this->buildCachePayload();
} }
} }
@@ -57,6 +61,7 @@ class GitMonitorService
$projectKey = $repoConfig['jira_project'] ?? null; $projectKey = $repoConfig['jira_project'] ?? null;
if (empty($projectKey)) { if (empty($projectKey)) {
Log::warning('Jira project key missing for repository', ['repository' => $repoKey]); Log::warning('Jira project key missing for repository', ['repository' => $repoKey]);
continue; continue;
} }
@@ -67,7 +72,7 @@ class GitMonitorService
// 根据当前版本号获取下一个版本 // 根据当前版本号获取下一个版本
$version = $this->jiraService->getUpcomingReleaseVersion($projectKey, $currentVersion); $version = $this->jiraService->getUpcomingReleaseVersion($projectKey, $currentVersion);
if ($version) { if ($version) {
$branch = 'release/' . $version['version']; $branch = 'release/'.$version['version'];
$payload['repositories'][$repoKey] = [ $payload['repositories'][$repoKey] = [
'version' => $version['version'], 'version' => $version['version'],
'description' => $version['description'] ?? null, 'description' => $version['description'] ?? null,
@@ -106,7 +111,7 @@ class GitMonitorService
{ {
$anyProject = Project::query()->whereNotNull('git_version_cached_at')->first(); $anyProject = Project::query()->whereNotNull('git_version_cached_at')->first();
if (!$anyProject || $this->shouldRefreshCache($anyProject->git_version_cached_at)) { if (! $anyProject || $this->shouldRefreshCache($anyProject->git_version_cached_at)) {
return $this->refreshReleaseCache(true); return $this->refreshReleaseCache(true);
} }
@@ -152,6 +157,7 @@ class GitMonitorService
$branch = data_get($releaseCache, "repositories.{$repoKey}.branch"); $branch = data_get($releaseCache, "repositories.{$repoKey}.branch");
if (empty($branch)) { if (empty($branch)) {
Log::warning('Missing release branch info for repository', ['repository' => $repoKey]); Log::warning('Missing release branch info for repository', ['repository' => $repoKey]);
continue; continue;
} }
@@ -176,7 +182,7 @@ class GitMonitorService
} }
} }
if (!empty($alerts)) { if (! empty($alerts)) {
$this->dingTalkService->sendText($this->buildAlertMessage($alerts)); $this->dingTalkService->sendText($this->buildAlertMessage($alerts));
} }
@@ -191,6 +197,7 @@ class GitMonitorService
try { try {
$cachedTime = $cachedAt instanceof Carbon ? $cachedAt : Carbon::parse($cachedAt); $cachedTime = $cachedAt instanceof Carbon ? $cachedAt : Carbon::parse($cachedAt);
return $cachedTime->lt(Carbon::now()->startOfDay()); return $cachedTime->lt(Carbon::now()->startOfDay());
} catch (\Throwable) { } catch (\Throwable) {
return true; return true;
@@ -201,12 +208,12 @@ class GitMonitorService
{ {
$path = $this->resolveProjectPath($repoKey, $repoConfig); $path = $this->resolveProjectPath($repoKey, $repoConfig);
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) { if (! is_dir($path) || ! is_dir($path.DIRECTORY_SEPARATOR.'.git')) {
throw new \RuntimeException("Project path {$path} is not a valid git repository"); throw new \RuntimeException("Project path {$path} is not a valid git repository");
} }
$this->synchronizeRepository($path, $branch); $this->synchronizeRepository($path, $branch);
$remoteBranch = 'origin/' . $branch; $remoteBranch = 'origin/'.$branch;
$head = $this->runGit($path, ['git', 'rev-parse', $remoteBranch]); $head = $this->runGit($path, ['git', 'rev-parse', $remoteBranch]);
// 从 Project 模型获取 lastChecked // 从 Project 模型获取 lastChecked
@@ -231,7 +238,7 @@ class GitMonitorService
// 只在 merge 提交或冲突解决提交中检测缺失函数 // 只在 merge 提交或冲突解决提交中检测缺失函数
if ($isMerge || $isConflictResolution) { if ($isMerge || $isConflictResolution) {
$missingFunctions = $this->detectMissingFunctions($path, $commit); $missingFunctions = $this->detectMissingFunctions($path, $commit);
if (!empty($missingFunctions)) { if (! empty($missingFunctions)) {
$issues['missing_functions'][] = [ $issues['missing_functions'][] = [
'commit' => $this->getCommitMetadata($path, $commit), 'commit' => $this->getCommitMetadata($path, $commit),
'details' => $missingFunctions, 'details' => $missingFunctions,
@@ -346,11 +353,13 @@ class GitMonitorService
foreach (preg_split('/\R/', $diff) as $line) { foreach (preg_split('/\R/', $diff) as $line) {
if (str_starts_with($line, 'diff --git')) { if (str_starts_with($line, 'diff --git')) {
$currentFile = null; $currentFile = null;
continue; continue;
} }
if (str_starts_with($line, '+++ b/')) { if (str_starts_with($line, '+++ b/')) {
$currentFile = substr($line, 6); $currentFile = substr($line, 6);
continue; continue;
} }
@@ -358,7 +367,7 @@ class GitMonitorService
continue; continue;
} }
if (!$currentFile || !str_ends_with($currentFile, '.php')) { if (! $currentFile || ! str_ends_with($currentFile, '.php')) {
continue; continue;
} }
@@ -367,6 +376,7 @@ class GitMonitorService
if ($function) { if ($function) {
$removed[$currentFile][] = $function; $removed[$currentFile][] = $function;
} }
continue; continue;
} }
@@ -381,7 +391,7 @@ class GitMonitorService
$issues = []; $issues = [];
foreach ($removed as $file => $functions) { foreach ($removed as $file => $functions) {
$diffed = array_diff($functions, $added[$file] ?? []); $diffed = array_diff($functions, $added[$file] ?? []);
if (!empty($diffed)) { if (! empty($diffed)) {
$issues[] = [ $issues[] = [
'file' => $file, 'file' => $file,
'functions' => array_values(array_unique($diffed)), 'functions' => array_values(array_unique($diffed)),
@@ -422,7 +432,7 @@ class GitMonitorService
{ {
$separator = "\x1F"; $separator = "\x1F";
$format = "%H{$separator}%an{$separator}%ad{$separator}%s"; $format = "%H{$separator}%an{$separator}%ad{$separator}%s";
$raw = $this->runGit($repoPath, ['git', 'show', '-s', "--date=iso-strict", "--pretty={$format}", $commit]); $raw = $this->runGit($repoPath, ['git', 'show', '-s', '--date=iso-strict', "--pretty={$format}", $commit]);
$parts = array_pad(explode($separator, $raw, 4), 4, ''); $parts = array_pad(explode($separator, $raw, 4), 4, '');
return [ return [
@@ -435,7 +445,7 @@ class GitMonitorService
private function hasIssues(array $issues): bool private function hasIssues(array $issues): bool
{ {
return !empty($issues['develop_merges']) || !empty($issues['missing_functions']); return ! empty($issues['develop_merges']) || ! empty($issues['missing_functions']);
} }
private function buildAlertMessage(array $alerts): string private function buildAlertMessage(array $alerts): string
@@ -445,7 +455,7 @@ class GitMonitorService
foreach ($alerts as $result) { foreach ($alerts as $result) {
$lines[] = sprintf('%s%s', $result['display'], $result['branch']); $lines[] = sprintf('%s%s', $result['display'], $result['branch']);
if (!empty($result['issues']['develop_merges'])) { if (! empty($result['issues']['develop_merges'])) {
$lines[] = ' - 检测到 develop 合并:'; $lines[] = ' - 检测到 develop 合并:';
foreach ($result['issues']['develop_merges'] as $commit) { foreach ($result['issues']['develop_merges'] as $commit) {
$lines[] = sprintf( $lines[] = sprintf(
@@ -457,7 +467,7 @@ class GitMonitorService
} }
} }
if (!empty($result['issues']['missing_functions'])) { if (! empty($result['issues']['missing_functions'])) {
$lines[] = ' - 疑似缺失函数:'; $lines[] = ' - 疑似缺失函数:';
foreach ($result['issues']['missing_functions'] as $issue) { foreach ($result['issues']['missing_functions'] as $issue) {
@@ -523,6 +533,7 @@ class GitMonitorService
'display' => $project->name, 'display' => $project->name,
]; ];
} }
return $projects; return $projects;
} }
@@ -531,9 +542,10 @@ class GitMonitorService
if ($configProjects !== null && is_array($configProjects)) { if ($configProjects !== null && is_array($configProjects)) {
$enabled = config('git-monitor.enabled_projects', []); $enabled = config('git-monitor.enabled_projects', []);
if (!empty($enabled)) { if (! empty($enabled)) {
return array_intersect_key($configProjects, array_flip($enabled)); return array_intersect_key($configProjects, array_flip($enabled));
} }
return $configProjects; return $configProjects;
} }
@@ -553,7 +565,7 @@ class GitMonitorService
]; ];
foreach ($enabled as $repoKey) { foreach ($enabled as $repoKey) {
if (!is_string($repoKey) || $repoKey === '') { if (! is_string($repoKey) || $repoKey === '') {
continue; continue;
} }
@@ -567,12 +579,13 @@ class GitMonitorService
private function resolveProjectPath(string $repoKey, array $repoConfig): string private function resolveProjectPath(string $repoKey, array $repoConfig): string
{ {
if (!empty($repoConfig['path'])) { if (! empty($repoConfig['path'])) {
return rtrim($repoConfig['path'], '/'); return rtrim($repoConfig['path'], '/');
} }
$directory = $repoConfig['directory'] ?? $repoKey; $directory = $repoConfig['directory'] ?? $repoKey;
return $this->projectsPath . '/' . ltrim($directory, '/');
return $this->projectsPath.'/'.ltrim($directory, '/');
} }
/** /**
@@ -582,20 +595,23 @@ class GitMonitorService
{ {
$path = $this->resolveProjectPath($repoKey, $repoConfig); $path = $this->resolveProjectPath($repoKey, $repoConfig);
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) { if (! is_dir($path) || ! is_dir($path.DIRECTORY_SEPARATOR.'.git')) {
Log::warning('Invalid git repository path', ['repository' => $repoKey, 'path' => $path]); Log::warning('Invalid git repository path', ['repository' => $repoKey, 'path' => $path]);
return null; return null;
} }
try { try {
$this->runGit($path, ['git', 'fetch', 'origin', 'master']); $this->runGit($path, ['git', 'fetch', 'origin', 'master']);
$version = $this->runGit($path, ['git', 'show', 'origin/master:version.txt']); $version = $this->runGit($path, ['git', 'show', 'origin/master:version.txt']);
return trim($version) ?: null; return trim($version) ?: null;
} catch (ProcessFailedException $e) { } catch (ProcessFailedException $e) {
Log::warning('Failed to read version.txt from master branch', [ Log::warning('Failed to read version.txt from master branch', [
'repository' => $repoKey, 'repository' => $repoKey,
'error' => $e->getMessage(), 'error' => $e->getMessage(),
]); ]);
return null; return null;
} }
} }
@@ -608,6 +624,7 @@ class GitMonitorService
try { try {
$this->runGit($path, ['git', 'fetch', 'origin']); $this->runGit($path, ['git', 'fetch', 'origin']);
$this->runGit($path, ['git', 'ls-remote', '--exit-code', '--heads', 'origin', $branch]); $this->runGit($path, ['git', 'ls-remote', '--exit-code', '--heads', 'origin', $branch]);
return true; return true;
} catch (ProcessFailedException) { } catch (ProcessFailedException) {
return false; return false;
@@ -623,14 +640,16 @@ class GitMonitorService
$worktreePath = null; $worktreePath = null;
$worktreeCreated = false; $worktreeCreated = false;
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) { if (! is_dir($path) || ! is_dir($path.DIRECTORY_SEPARATOR.'.git')) {
Log::warning('Invalid git repository path for branch creation', ['repository' => $repoKey, 'path' => $path]); Log::warning('Invalid git repository path for branch creation', ['repository' => $repoKey, 'path' => $path]);
return; return;
} }
// 检查远程分支是否已存在 // 检查远程分支是否已存在
if ($this->remoteBranchExists($path, $branch)) { if ($this->remoteBranchExists($path, $branch)) {
Log::info('Release branch already exists on remote', ['repository' => $repoKey, 'branch' => $branch]); Log::info('Release branch already exists on remote', ['repository' => $repoKey, 'branch' => $branch]);
return; return;
} }
@@ -640,25 +659,25 @@ class GitMonitorService
try { try {
// 在临时 worktree 中创建并推送分支,避免切换或修改用户正在工作的仓库。 // 在临时 worktree 中创建并推送分支,避免切换或修改用户正在工作的仓库。
$this->runGit($path, ['git', 'fetch', 'origin', 'master']); $this->runGit($path, ['git', 'fetch', 'origin', 'master']);
$worktreePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'toolbox-release-' . str_replace(['/', '\\'], '-', $repoKey . '-' . $version) . '-' . bin2hex(random_bytes(4)); $worktreePath = sys_get_temp_dir().DIRECTORY_SEPARATOR.'toolbox-release-'.str_replace(['/', '\\'], '-', $repoKey.'-'.$version).'-'.bin2hex(random_bytes(4));
$this->runGit($path, ['git', 'worktree', 'add', '--detach', $worktreePath, 'origin/master']); $this->runGit($path, ['git', 'worktree', 'add', '--detach', $worktreePath, 'origin/master']);
$worktreeCreated = true; $worktreeCreated = true;
// 修改 version.txt 文件 // 修改 version.txt 文件
$versionFile = $worktreePath . DIRECTORY_SEPARATOR . 'version.txt'; $versionFile = $worktreePath.DIRECTORY_SEPARATOR.'version.txt';
if (!file_put_contents($versionFile, $version)) { if (! file_put_contents($versionFile, $version)) {
throw new \RuntimeException("Failed to write version.txt"); throw new \RuntimeException('Failed to write version.txt');
} }
// 添加并提交更改 // 添加并提交更改
$this->runGit($worktreePath, ['git', 'add', 'version.txt']); $this->runGit($worktreePath, ['git', 'add', 'version.txt']);
// 构建提交信息:分支名 + 空格 + Jira 描述 // 构建提交信息:release-版本号 + 空格 + Jira 描述
$commitMessage = $branch . ($description ? ' ' . $description : ''); $commitMessage = 'release-'.$version.($description ? ' '.$description : '');
$this->runGit($worktreePath, ['git', 'commit', '-m', $commitMessage]); $this->runGit($worktreePath, ['git', 'commit', '--no-verify', '-m', $commitMessage]);
// 推送到远程 // 推送到远程
$this->runGit($worktreePath, ['git', 'push', 'origin', 'HEAD:refs/heads/' . $branch]); $this->runGit($worktreePath, ['git', 'push', '--no-verify', 'origin', 'HEAD:refs/heads/'.$branch]);
Log::info('Created and pushed release branch', [ Log::info('Created and pushed release branch', [
'repository' => $repoKey, 'repository' => $repoKey,
+1
View File
@@ -154,6 +154,7 @@ class ScheduledTaskService
'daily-log-analysis' => 'SLS 日志分析 - 每日分析过去 24 小时日志', 'daily-log-analysis' => 'SLS 日志分析 - 每日分析过去 24 小时日志',
'frequent-log-analysis' => 'SLS 日志分析 - 定期分析过去 6 小时日志', 'frequent-log-analysis' => 'SLS 日志分析 - 定期分析过去 6 小时日志',
'jenkins-monitor' => 'Jenkins 发布监控 - 检查新构建并发送通知', 'jenkins-monitor' => 'Jenkins 发布监控 - 检查新构建并发送通知',
'agent-consumer-delay-monitor' => 'Agent 消费监控 - 按 eventname 检查消费延迟并发送通知',
'erp-request-report' => 'ERP 请求日报 - 汇总前一天 OpenAPI 请求并发送钉钉', 'erp-request-report' => 'ERP 请求日报 - 汇总前一天 OpenAPI 请求并发送钉钉',
'scheduled-task-refresh' => '定时任务管理 - 刷新定时任务列表', 'scheduled-task-refresh' => '定时任务管理 - 刷新定时任务列表',
'logs-cleanup' => '日志清理 - 自动删除 7 天前的定时任务日志', 'logs-cleanup' => '日志清理 - 自动删除 7 天前的定时任务日志',
+8
View File
@@ -143,6 +143,14 @@ return [
'replace_placeholders' => true, 'replace_placeholders' => true,
], ],
'agent-consumer-monitor' => [
'driver' => 'daily',
'path' => storage_path('logs/scheduled-tasks/agent-consumer-monitor.log'),
'level' => env('LOG_LEVEL', 'debug'),
'days' => 7,
'replace_placeholders' => true,
],
'git-monitor' => [ 'git-monitor' => [
'driver' => 'daily', 'driver' => 'daily',
'path' => storage_path('logs/scheduled-tasks/git-monitor.log'), 'path' => storage_path('logs/scheduled-tasks/git-monitor.log'),
@@ -0,0 +1,57 @@
<?php
use App\Models\Project;
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
public function up(): void
{
if (! Schema::hasTable('projects')) {
return;
}
$projects = [
[
'slug' => 'crm-web',
'name' => 'Crm web',
'directory' => 'crm-web',
'jenkins_job_name' => 'crm-dev-crm_web',
],
[
'slug' => 'crm-opm',
'name' => 'Crm opm',
'directory' => 'crm-opm',
'jenkins_job_name' => 'crm-dev-crm_opm',
],
];
foreach ($projects as $data) {
$project = Project::query()->firstOrCreate(
['slug' => $data['slug']],
[
'name' => $data['name'],
'directory' => $data['directory'],
]
);
$project->update([
'jenkins_job_name' => $data['jenkins_job_name'],
'jenkins_notify_enabled' => true,
]);
}
}
public function down(): void
{
if (! Schema::hasTable('projects')) {
return;
}
Project::query()
->whereIn('slug', ['crm-web', 'crm-opm'])
->whereIn('jenkins_job_name', ['crm-dev-crm_web', 'crm-dev-crm_opm'])
->delete();
}
};
+101 -14
View File
@@ -80,7 +80,22 @@
<span class="font-mono text-sm font-semibold text-gray-800 truncate">{{ project.slug }}</span> <span class="font-mono text-sm font-semibold text-gray-800 truncate">{{ project.slug }}</span>
<span class="text-[11px] text-gray-500 truncate" :title="project.name">{{ project.name }}</span> <span class="text-[11px] text-gray-500 truncate" :title="project.name">{{ project.name }}</span>
</span> </span>
<span class="block font-mono text-[11px] text-gray-400 truncate" :title="project.jenkins_job_name">{{ project.jenkins_job_name }}</span> <a
v-if="jenkinsPageUrl(project)"
:href="jenkinsPageUrl(project)"
target="_blank"
rel="noopener noreferrer"
class="mt-0.5 inline-flex max-w-full items-center gap-1 font-mono text-[11px] text-blue-600 hover:text-blue-800 hover:underline"
:title="'打开 Jenkins 发布页:' + project.jenkins_job_name"
@click.stop
>
<span class="truncate">{{ project.jenkins_job_name }}</span>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-3 w-3 shrink-0">
<path fill-rule="evenodd" d="M4.25 5.5a.75.75 0 00-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 00.75-.75v-4a.75.75 0 011.5 0v4A2.25 2.25 0 0112.75 17h-8.5A2.25 2.25 0 012 14.75v-8.5A2.25 2.25 0 014.25 4h5a.75.75 0 010 1.5h-5z" clip-rule="evenodd" />
<path fill-rule="evenodd" d="M6.194 12.753a.75.75 0 001.06.053L16.5 4.44v2.81a.75.75 0 001.5 0v-4.5a.75.75 0 00-.75-.75h-4.5a.75.75 0 000 1.5h2.553l-9.056 8.194a.75.75 0 00-.053 1.06z" clip-rule="evenodd" />
</svg>
</a>
<span v-else class="block font-mono text-[11px] text-gray-400 truncate" :title="project.jenkins_job_name">{{ project.jenkins_job_name }}</span>
</span> </span>
</label> </label>
</td> </td>
@@ -174,12 +189,34 @@
<div class="mt-1 flex items-center gap-1.5 text-xs text-gray-700 min-w-0"> <div class="mt-1 flex items-center gap-1.5 text-xs text-gray-700 min-w-0">
<span class="font-mono font-semibold truncate" :title="record.project_slug">{{ record.project_slug || '-' }}</span> <span class="font-mono font-semibold truncate" :title="record.project_slug">{{ record.project_slug || '-' }}</span>
<span class="text-[11px] text-gray-400 truncate" :title="record.project_name">{{ record.project_name }}</span> <span class="text-[11px] text-gray-400 truncate" :title="record.project_name">{{ record.project_name }}</span>
<a
v-if="jenkinsPageUrl(record)"
:href="jenkinsPageUrl(record)"
target="_blank"
rel="noopener noreferrer"
class="ml-auto inline-flex shrink-0 items-center gap-0.5 text-[11px] text-blue-600 hover:text-blue-800 hover:underline"
title="打开 Jenkins 发布页"
>
Jenkins
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="h-3 w-3">
<path fill-rule="evenodd" d="M4.25 5.5a.75.75 0 00-.75.75v8.5c0 .414.336.75.75.75h8.5a.75.75 0 00.75-.75v-4a.75.75 0 011.5 0v4A2.25 2.25 0 0112.75 17h-8.5A2.25 2.25 0 012 14.75v-8.5A2.25 2.25 0 014.25 4h5a.75.75 0 010 1.5h-5z" clip-rule="evenodd" />
<path fill-rule="evenodd" d="M6.194 12.753a.75.75 0 001.06.053L16.5 4.44v2.81a.75.75 0 001.5 0v-4.5a.75.75 0 00-.75-.75h-4.5a.75.75 0 000 1.5h2.553l-9.056 8.194a.75.75 0 00-.053 1.06z" clip-rule="evenodd" />
</svg>
</a>
</div> </div>
<div class="mt-0.5 grid grid-cols-[3.25rem_minmax(0,1fr)] gap-1 text-[11px] text-gray-500"> <div class="mt-0.5 grid grid-cols-[3.25rem_minmax(0,1fr)] gap-1 text-[11px] text-gray-500">
<span class="text-gray-400">project</span> <span class="text-gray-400">project</span>
<span class="font-mono truncate" :title="record.project_parameter">{{ record.project_parameter || '-' }}</span> <span class="font-mono truncate" :title="record.project_parameter">{{ record.project_parameter || '-' }}</span>
<span class="text-gray-400">构建号</span> <span class="text-gray-400">构建号</span>
<span class="font-mono truncate">{{ record.build_number ? `#${record.build_number}` : '-' }}</span> <a
v-if="jenkinsBuildUrl(record)"
:href="jenkinsBuildUrl(record)"
target="_blank"
rel="noopener noreferrer"
class="font-mono truncate text-blue-600 hover:text-blue-800 hover:underline"
:title="'打开 Jenkins 构建 #' + record.build_number"
>#{{ record.build_number }}</a>
<span v-else class="font-mono truncate">{{ record.build_number ? `#${record.build_number}` : '-' }}</span>
</div> </div>
<div v-if="record.message" class="mt-1 text-[11px] text-gray-400 truncate" :title="record.message"> <div v-if="record.message" class="mt-1 text-[11px] text-gray-400 truncate" :title="record.message">
{{ record.message }} {{ record.message }}
@@ -298,7 +335,7 @@ export default {
ParameterControl ParameterControl
}, },
preferenceKey: 'toolbox.jenkinsBuilds.preferences.v1', preferenceKey: 'toolbox.jenkinsBuilds.preferences.v1',
cacheKey: 'toolbox.jenkinsBuilds.projectsCache.v1', cacheKey: 'toolbox.jenkinsBuilds.projectsCache.v2',
operationRecordsKey: 'toolbox.jenkinsBuilds.operationRecords.v1', operationRecordsKey: 'toolbox.jenkinsBuilds.operationRecords.v1',
hiddenParameterNames: ['sql', 'masterCheck'], hiddenParameterNames: ['sql', 'masterCheck'],
data() { data() {
@@ -308,6 +345,7 @@ export default {
triggering: false, triggering: false,
statusChecking: false, statusChecking: false,
statusPollingTimer: null, statusPollingTimer: null,
jenkinsHost: '',
projects: [], projects: [],
operationRecords: [], operationRecords: [],
primaryParameterOrder: ['env', 'branchName', 'deploy', 'deployVersion'], primaryParameterOrder: ['env', 'branchName', 'deploy', 'deployVersion'],
@@ -344,17 +382,16 @@ export default {
if (freshCachedProjects) { if (freshCachedProjects) {
cachedBuildProjects = freshCachedProjects; cachedBuildProjects = freshCachedProjects;
this.applyProjects(cachedBuildProjects); this.applyProjects(cachedBuildProjects);
return;
}
if (!cachedBuildProjects) {
cachedBuildProjects = this.loadProjectsCache({ allowExpired: true });
}
if (cachedBuildProjects) {
this.applyProjects(cachedBuildProjects);
} else { } else {
this.loading = true; if (!cachedBuildProjects) {
cachedBuildProjects = this.loadProjectsCache({ allowExpired: true });
}
if (cachedBuildProjects) {
this.applyProjects(cachedBuildProjects);
} else {
this.loading = true;
}
} }
this.refreshProjects({ silent: Boolean(cachedBuildProjects) }); this.refreshProjects({ silent: Boolean(cachedBuildProjects) });
@@ -384,6 +421,7 @@ export default {
} }
cachedBuildProjects = data.data.projects || []; cachedBuildProjects = data.data.projects || [];
this.jenkinsHost = data.data.jenkins_host || this.jenkinsHost;
this.saveProjectsCache(cachedBuildProjects); this.saveProjectsCache(cachedBuildProjects);
this.applyProjects(cachedBuildProjects, { preserveCurrentValues: true }); this.applyProjects(cachedBuildProjects, { preserveCurrentValues: true });
@@ -431,6 +469,10 @@ export default {
return null; return null;
} }
if (cache.jenkins_host) {
this.jenkinsHost = cache.jenkins_host;
}
return cache.projects; return cache.projects;
} catch (error) { } catch (error) {
return null; return null;
@@ -439,6 +481,7 @@ export default {
saveProjectsCache(projects) { saveProjectsCache(projects) {
window.localStorage.setItem(this.$options.cacheKey, JSON.stringify({ window.localStorage.setItem(this.$options.cacheKey, JSON.stringify({
date: this.todayKey(), date: this.todayKey(),
jenkins_host: this.jenkinsHost,
projects projects
})); }));
}, },
@@ -515,6 +558,46 @@ export default {
preferenceProjectKey(project) { preferenceProjectKey(project) {
return project.jenkins_job_name || project.slug; return project.jenkins_job_name || project.slug;
}, },
jenkinsPageUrl(item) {
if (!item) {
return null;
}
if (item.jenkins_job_url) {
return item.jenkins_job_url;
}
return this.buildJenkinsUrl(item.jenkins_job_name || item.job_name);
},
jenkinsBuildUrl(record) {
if (!record?.build_number) {
return null;
}
if (record.build_url) {
return record.build_url;
}
return this.buildJenkinsUrl(record.job_name || record.jenkins_job_name, record.build_number);
},
buildJenkinsUrl(jobName, buildNumber = null) {
if (!jobName) {
return null;
}
const segments = String(jobName).split('/').map((segment) => segment.trim()).filter(Boolean);
if (segments.length === 0) {
return null;
}
const host = (this.jenkinsHost || '').replace(/\/$/, '');
if (!host) {
return null;
}
const path = '/job/' + segments.map(encodeURIComponent).join('/job/') + '/';
return buildNumber ? `${host}${path}${buildNumber}/` : `${host}${path}`;
},
isBooleanParameter(parameter) { isBooleanParameter(parameter) {
return String(parameter.type || '').toLowerCase().includes('boolean'); return String(parameter.type || '').toLowerCase().includes('boolean');
}, },
@@ -690,11 +773,14 @@ export default {
project_slug: result.project_slug || requested.project_slug || '', project_slug: result.project_slug || requested.project_slug || '',
project_name: result.project_name || requested.project_name || '', project_name: result.project_name || requested.project_name || '',
job_name: result.job_name || requested.job_name || '', job_name: result.job_name || requested.job_name || '',
jenkins_job_url: result.jenkins_job_url || this.buildJenkinsUrl(result.job_name || requested.job_name),
project_parameter: this.formatParameterValue(requested.parameters?.project), project_parameter: this.formatParameterValue(requested.parameters?.project),
status: result.success ? (canTrackBuild ? 'PENDING' : 'UNKNOWN') : 'FAILURE', status: result.success ? (canTrackBuild ? 'PENDING' : 'UNKNOWN') : 'FAILURE',
queue_url: result.queue_url || null, queue_url: result.queue_url || null,
build_number: result.build_number || null, build_number: result.build_number || null,
build_url: null, build_url: result.build_url || (result.build_number
? this.buildJenkinsUrl(result.job_name || requested.job_name, result.build_number)
: null),
parameters: requested.parameters || {}, parameters: requested.parameters || {},
message: result.success message: result.success
? (canTrackBuild ? '已提交 Jenkins,等待发布结果' : '已提交 Jenkins,但未返回队列地址,无法自动跟踪或取消') ? (canTrackBuild ? '已提交 Jenkins,等待发布结果' : '已提交 Jenkins,但未返回队列地址,无法自动跟踪或取消')
@@ -788,6 +874,7 @@ export default {
status: nextStatus, status: nextStatus,
build_number: status.build_number || record.build_number, build_number: status.build_number || record.build_number,
build_url: status.build_url || record.build_url, build_url: status.build_url || record.build_url,
jenkins_job_url: status.jenkins_job_url || record.jenkins_job_url,
message: status.message || null, message: status.message || null,
cancelling: false cancelling: false
}; };
+8
View File
@@ -61,6 +61,14 @@ Schedule::command('jenkins:monitor')
->description('jenkins-monitor') ->description('jenkins-monitor')
->when(fn () => \App\Services\ScheduledTaskService::isEnabled('jenkins-monitor')); ->when(fn () => \App\Services\ScheduledTaskService::isEnabled('jenkins-monitor'));
// Agent Consumer Monitor - 每分钟检查各 eventname 的消费延迟
Schedule::command('agent-consumer:monitor-delay')
->everyMinute()
->withoutOverlapping(10)
->runInBackground()
->description('agent-consumer-delay-monitor')
->when(fn () => \App\Services\ScheduledTaskService::isEnabled('agent-consumer-delay-monitor'));
// ERP OpenAPI 请求日报 - 每天早上 8 点统计前一天请求并发送钉钉 // ERP OpenAPI 请求日报 - 每天早上 8 点统计前一天请求并发送钉钉
Schedule::command('erp-request-report:send') Schedule::command('erp-request-report:send')
->dailyAt('08:00') ->dailyAt('08:00')
@@ -0,0 +1,195 @@
<?php
namespace Tests\Unit;
use App\Services\AgentConsumerDelayMonitorService;
use App\Services\ConfigService;
use App\Services\DingTalkService;
use Carbon\CarbonImmutable;
use Illuminate\Database\Connection;
use Illuminate\Database\DatabaseManager;
use Illuminate\Database\Query\Builder;
use Mockery;
use Tests\TestCase;
class AgentConsumerDelayMonitorServiceTest extends TestCase
{
public function test_it_alerts_when_the_latest_pending_message_is_more_than_five_minutes_old(): void
{
CarbonImmutable::setTestNow('2026-09-01 15:00:00');
try {
[$database, $query] = $this->mockLatestPendingQuery();
$dingTalk = Mockery::mock(DingTalkService::class);
$config = Mockery::mock(ConfigService::class);
$query->shouldReceive('first')->once()->andReturn((object) [
'event_name' => 'CASE_CREATE',
'created' => '2026-09-01 14:52:00',
]);
$config->shouldReceive('get')
->once()
->with(AgentConsumerDelayMonitorService::STATE_CONFIG_KEY, [])
->andReturn([]);
$dingTalk->shouldReceive('sendText')
->once()
->with(Mockery::on(fn (string $message) => str_contains($message, 'Agent 消费延迟告警')
&& str_contains($message, 'CASE_CREATE')
&& str_contains($message, '14:52:00')
&& str_contains($message, '8 分钟')))
->andReturnTrue();
$config->shouldReceive('set')
->once()
->with(
AgentConsumerDelayMonitorService::STATE_CONFIG_KEY,
['event_name' => 'CASE_CREATE', 'delayed_since' => '2026-09-01 14:52:00'],
'Agent 消费延迟告警状态'
);
$result = (new AgentConsumerDelayMonitorService($database, $dingTalk, $config))->check();
$this->assertSame('CASE_CREATE', $result['event_name']);
$this->assertSame(8, $result['delay_minutes']);
$this->assertSame(1, $result['alerted_count']);
$this->assertSame(0, $result['recovered_count']);
} finally {
CarbonImmutable::setTestNow();
}
}
public function test_it_does_not_repeat_an_active_delay_alert(): void
{
CarbonImmutable::setTestNow('2026-09-01 15:00:00');
try {
[$database, $query] = $this->mockLatestPendingQuery();
$dingTalk = Mockery::mock(DingTalkService::class);
$config = Mockery::mock(ConfigService::class);
$state = ['event_name' => 'CASE_CREATE', 'delayed_since' => '2026-09-01 14:50:00'];
$query->shouldReceive('first')->once()->andReturn((object) [
'event_name' => 'DOCTOR_CREATE',
'created' => '2026-09-01 14:52:00',
]);
$config->shouldReceive('get')
->once()
->with(AgentConsumerDelayMonitorService::STATE_CONFIG_KEY, [])
->andReturn($state);
$dingTalk->shouldNotReceive('sendText');
$config->shouldNotReceive('set');
$result = (new AgentConsumerDelayMonitorService($database, $dingTalk, $config))->check();
$this->assertSame('DOCTOR_CREATE', $result['event_name']);
$this->assertSame(0, $result['alerted_count']);
$this->assertSame(0, $result['recovered_count']);
} finally {
CarbonImmutable::setTestNow();
}
}
public function test_it_notifies_recovery_when_the_latest_pending_message_is_within_five_minutes(): void
{
CarbonImmutable::setTestNow('2026-09-01 15:00:00');
try {
[$database, $query] = $this->mockLatestPendingQuery();
$dingTalk = Mockery::mock(DingTalkService::class);
$config = Mockery::mock(ConfigService::class);
$query->shouldReceive('first')->once()->andReturn((object) [
'event_name' => 'CASE_CREATE',
'created' => '2026-09-01 14:57:00',
]);
$config->shouldReceive('get')
->once()
->with(AgentConsumerDelayMonitorService::STATE_CONFIG_KEY, [])
->andReturn(['event_name' => 'CASE_CREATE', 'delayed_since' => '2026-09-01 14:50:00']);
$dingTalk->shouldReceive('sendText')
->once()
->with(Mockery::on(fn (string $message) => str_contains($message, 'Agent 消费延迟恢复')))
->andReturnTrue();
$config->shouldReceive('set')
->once()
->with(AgentConsumerDelayMonitorService::STATE_CONFIG_KEY, [], 'Agent 消费延迟告警状态');
$result = (new AgentConsumerDelayMonitorService($database, $dingTalk, $config))->check();
$this->assertSame(0, $result['alerted_count']);
$this->assertSame(1, $result['recovered_count']);
} finally {
CarbonImmutable::setTestNow();
}
}
public function test_it_notifies_recovery_when_there_are_no_pending_messages(): void
{
CarbonImmutable::setTestNow('2026-09-01 15:00:00');
try {
[$database, $query] = $this->mockLatestPendingQuery();
$dingTalk = Mockery::mock(DingTalkService::class);
$config = Mockery::mock(ConfigService::class);
$query->shouldReceive('first')->once()->andReturnNull();
$config->shouldReceive('get')
->once()
->with(AgentConsumerDelayMonitorService::STATE_CONFIG_KEY, [])
->andReturn(['event_name' => 'CASE_CREATE', 'delayed_since' => '2026-09-01 14:50:00']);
$dingTalk->shouldReceive('sendText')->once()->andReturnTrue();
$config->shouldReceive('set')
->once()
->with(AgentConsumerDelayMonitorService::STATE_CONFIG_KEY, [], 'Agent 消费延迟告警状态');
$result = (new AgentConsumerDelayMonitorService($database, $dingTalk, $config))->check();
$this->assertNull($result['event_name']);
$this->assertSame(1, $result['recovered_count']);
} finally {
CarbonImmutable::setTestNow();
}
}
public function test_it_retries_notifications_when_dingtalk_sending_fails(): void
{
CarbonImmutable::setTestNow('2026-09-01 15:00:00');
try {
[$database, $query] = $this->mockLatestPendingQuery();
$dingTalk = Mockery::mock(DingTalkService::class);
$config = Mockery::mock(ConfigService::class);
$query->shouldReceive('first')->once()->andReturn((object) [
'event_name' => 'CASE_CREATE',
'created' => '2026-09-01 14:50:00',
]);
$config->shouldReceive('get')
->once()
->with(AgentConsumerDelayMonitorService::STATE_CONFIG_KEY, [])
->andReturn([]);
$dingTalk->shouldReceive('sendText')->once()->andReturnFalse();
$config->shouldNotReceive('set');
$result = (new AgentConsumerDelayMonitorService($database, $dingTalk, $config))->check();
$this->assertSame(0, $result['alerted_count']);
} finally {
CarbonImmutable::setTestNow();
}
}
private function mockLatestPendingQuery(): array
{
$database = Mockery::mock(DatabaseManager::class);
$connection = Mockery::mock(Connection::class);
$query = Mockery::mock(Builder::class);
$database->shouldReceive('connection')->once()->with('agentslave')->andReturn($connection);
$connection->shouldReceive('table')->once()->with('crm_event_consumer')->andReturn($query);
$query->shouldReceive('select')->once()->with(['event_name', 'created'])->andReturnSelf();
$query->shouldReceive('where')->once()->with('status', '<>', 1)->andReturnSelf();
$query->shouldReceive('orderByDesc')->once()->with('id')->andReturnSelf();
return [$database, $query];
}
}
+32
View File
@@ -50,6 +50,38 @@ class GitMonitorServiceTest extends TestCase
$this->assertStringContainsString('?? work.txt', $this->git($repoPath, ['git', 'status', '--short'])); $this->assertStringContainsString('?? work.txt', $this->git($repoPath, ['git', 'status', '--short']));
$this->assertNotEmpty($this->git($repoPath, ['git', 'ls-remote', '--heads', 'origin', 'release/1.1.0'])); $this->assertNotEmpty($this->git($repoPath, ['git', 'ls-remote', '--heads', 'origin', 'release/1.1.0']));
$this->assertSame('1.1.0', $this->git($repoPath, ['git', 'show', 'origin/release/1.1.0:version.txt'])); $this->assertSame('1.1.0', $this->git($repoPath, ['git', 'show', 'origin/release/1.1.0:version.txt']));
$this->assertSame('release-1.1.0 next release', $this->git($repoPath, ['git', 'log', '-1', '--pretty=%s', 'origin/release/1.1.0']));
}
public function test_auto_creating_release_branch_skips_local_git_hooks(): void
{
$workspacePath = $this->makeTempDirectory('toolbox-git-workspace-');
$remotePath = $this->makeTempDirectory('toolbox-git-remote-');
$repoPath = $workspacePath.DIRECTORY_SEPARATOR.'demo-repo';
$this->git($remotePath, ['git', 'init', '--bare']);
$this->git($workspacePath, ['git', 'clone', $remotePath, 'demo-repo']);
$this->git($repoPath, ['git', 'config', 'user.email', 'test@example.com']);
$this->git($repoPath, ['git', 'config', 'user.name', 'Test User']);
file_put_contents($repoPath.DIRECTORY_SEPARATOR.'version.txt', '1.0.0');
$this->git($repoPath, ['git', 'add', 'version.txt']);
$this->git($repoPath, ['git', 'commit', '-m', 'initial']);
$this->git($repoPath, ['git', 'branch', '-M', 'master']);
$this->git($repoPath, ['git', 'push', '-u', 'origin', 'master']);
$hooksPath = $repoPath.DIRECTORY_SEPARATOR.'.git'.DIRECTORY_SEPARATOR.'hooks';
file_put_contents($hooksPath.DIRECTORY_SEPARATOR.'commit-msg', "#!/bin/sh\necho hook-blocked-commit >&2\nexit 1\n");
file_put_contents($hooksPath.DIRECTORY_SEPARATOR.'pre-push', "#!/bin/sh\necho hook-blocked-push >&2\nexit 1\n");
chmod($hooksPath.DIRECTORY_SEPARATOR.'commit-msg', 0755);
chmod($hooksPath.DIRECTORY_SEPARATOR.'pre-push', 0755);
$this->git($repoPath, ['git', 'config', 'core.hooksPath', $hooksPath]);
$service = $this->makeGitMonitorService($workspacePath);
$method = new \ReflectionMethod($service, 'ensureReleaseBranchExists');
$method->invoke($service, 'demo-repo', ['directory' => 'demo-repo'], 'release/1.1.0', 'next release');
$this->assertNotEmpty($this->git($repoPath, ['git', 'ls-remote', '--heads', 'origin', 'release/1.1.0']));
$this->assertSame('1.1.0', $this->git($repoPath, ['git', 'show', 'origin/release/1.1.0:version.txt']));
} }
private function makeGitMonitorService(string $workspacePath): GitMonitorService private function makeGitMonitorService(string $workspacePath): GitMonitorService
+18
View File
@@ -111,4 +111,22 @@ class JenkinsClientTest extends TestCase
&& $request->url() === 'https://jenkins.example.com/queue/cancelItem?id=456'; && $request->url() === 'https://jenkins.example.com/queue/cancelItem?id=456';
}); });
} }
public function test_get_job_url_builds_job_and_build_pages(): void
{
$client = app(JenkinsClient::class);
$this->assertSame(
'https://jenkins.example.com/job/crm-dev-crm_web/',
$client->getJobUrl('crm-dev-crm_web')
);
$this->assertSame(
'https://jenkins.example.com/job/crm-dev-crm_web/42/',
$client->getJobUrl('crm-dev-crm_web', 42)
);
$this->assertSame(
'https://jenkins.example.com/job/folder/job/nested-job/',
$client->getJobUrl('folder/nested-job')
);
}
} }