#add git monitor
This commit is contained in:
@@ -101,3 +101,6 @@ AGENT_TIMEOUT=30
|
||||
# Mono Service Configuration
|
||||
MONO_URL=http://localhost:8081
|
||||
MONO_TIMEOUT=30
|
||||
|
||||
# Git Monitor Configuration
|
||||
GIT_MONITOR_PROJECTS="service,portal-be,agent-be"
|
||||
|
||||
@@ -18,16 +18,15 @@ class EnvCommand extends Command
|
||||
|
||||
private EnvService $envManager;
|
||||
|
||||
public function __construct(EnvService $envManager)
|
||||
public function __construct()
|
||||
{
|
||||
parent::__construct();
|
||||
$this->envManager = $envManager;
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
{
|
||||
$action = $this->argument('action');
|
||||
|
||||
$this->envManager ??= app(EnvService::class);
|
||||
try {
|
||||
switch ($action) {
|
||||
case 'list':
|
||||
@@ -93,7 +92,7 @@ class EnvCommand extends Command
|
||||
private function listEnvironments(): int
|
||||
{
|
||||
$project = $this->option('project');
|
||||
|
||||
|
||||
if (!$project) {
|
||||
$this->error('请指定项目名称: --project=项目名');
|
||||
return 1;
|
||||
@@ -136,7 +135,7 @@ class EnvCommand extends Command
|
||||
|
||||
if ($this->confirm("确定要将 {$environment} 环境应用到项目 {$project} 吗?")) {
|
||||
$success = $this->envManager->applyEnv($project, $environment);
|
||||
|
||||
|
||||
if ($success) {
|
||||
$this->info("成功将 {$environment} 环境应用到项目 {$project}");
|
||||
return 0;
|
||||
@@ -178,7 +177,7 @@ class EnvCommand extends Command
|
||||
}
|
||||
|
||||
$success = $this->envManager->saveEnv($project, $environment, $content);
|
||||
|
||||
|
||||
if ($success) {
|
||||
$this->info("成功保存环境配置 {$project}/{$environment}");
|
||||
return 0;
|
||||
@@ -202,7 +201,7 @@ class EnvCommand extends Command
|
||||
}
|
||||
|
||||
$success = $this->envManager->importFromProject($project, $environment);
|
||||
|
||||
|
||||
if ($success) {
|
||||
$this->info("成功从项目 {$project} 导入环境配置为 {$environment}");
|
||||
return 0;
|
||||
@@ -227,7 +226,7 @@ class EnvCommand extends Command
|
||||
|
||||
if ($this->confirm("确定要删除环境配置 {$project}/{$environment} 吗?")) {
|
||||
$success = $this->envManager->deleteEnv($project, $environment);
|
||||
|
||||
|
||||
if ($success) {
|
||||
$this->info("成功删除环境配置 {$project}/{$environment}");
|
||||
return 0;
|
||||
@@ -359,9 +358,9 @@ class EnvCommand extends Command
|
||||
$bytes = max($bytes, 0);
|
||||
$pow = floor(($bytes ? log($bytes) : 0) / log(1024));
|
||||
$pow = min($pow, count($units) - 1);
|
||||
|
||||
|
||||
$bytes /= pow(1024, $pow);
|
||||
|
||||
|
||||
return round($bytes, 2) . ' ' . $units[$pow];
|
||||
}
|
||||
}
|
||||
|
||||
28
app/Console/Commands/GitMonitorCacheCommand.php
Normal file
28
app/Console/Commands/GitMonitorCacheCommand.php
Normal file
@@ -0,0 +1,28 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\GitMonitorService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GitMonitorCacheCommand extends Command
|
||||
{
|
||||
protected $signature = 'git-monitor:cache';
|
||||
|
||||
protected $description = '刷新 release 版本缓存,保存到 configs 表';
|
||||
|
||||
public function handle(GitMonitorService $monitor): void
|
||||
{
|
||||
$cache = $monitor->refreshReleaseCache(true);
|
||||
|
||||
if (empty($cache)) {
|
||||
$this->warn('未获取到任何 release 版本信息,请检查配置。');
|
||||
return;
|
||||
}
|
||||
|
||||
$this->info(sprintf(
|
||||
'已缓存 %d 个仓库的 release 分支信息。',
|
||||
count($cache['repositories'] ?? [])
|
||||
));
|
||||
}
|
||||
}
|
||||
48
app/Console/Commands/GitMonitorCheckCommand.php
Normal file
48
app/Console/Commands/GitMonitorCheckCommand.php
Normal file
@@ -0,0 +1,48 @@
|
||||
<?php
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use App\Services\GitMonitorService;
|
||||
use Illuminate\Console\Command;
|
||||
|
||||
class GitMonitorCheckCommand extends Command
|
||||
{
|
||||
protected $signature = 'git-monitor:check
|
||||
{--force-cache : 强制从 JIRA 刷新 release 缓存后再检查}';
|
||||
|
||||
protected $description = '巡检 release 分支是否包含 develop merge 或因为冲突导致的函数缺失';
|
||||
|
||||
public function handle(GitMonitorService $monitor): void
|
||||
{
|
||||
if ($this->option('force-cache')) {
|
||||
$monitor->refreshReleaseCache(true);
|
||||
} else {
|
||||
$monitor->ensureReleaseCache();
|
||||
}
|
||||
|
||||
$results = $monitor->checkRepositories(false);
|
||||
|
||||
foreach ($results as $repo => $result) {
|
||||
if (isset($result['error'])) {
|
||||
$this->error(sprintf('[%s] %s', $repo, $result['error']));
|
||||
continue;
|
||||
}
|
||||
|
||||
$this->line(sprintf(
|
||||
'[%s] 分支 %s 已对齐 %s,扫描 %d 个提交。',
|
||||
$repo,
|
||||
$result['branch'],
|
||||
$result['head'],
|
||||
$result['commits_scanned']
|
||||
));
|
||||
|
||||
if (!empty($result['issues']['develop_merges'])) {
|
||||
$this->warn(sprintf(' - 检测到 %d 个 develop merge', count($result['issues']['develop_merges'])));
|
||||
}
|
||||
|
||||
if (!empty($result['issues']['missing_functions'])) {
|
||||
$this->warn(sprintf(' - 检测到 %d 个疑似缺失函数的提交', count($result['issues']['missing_functions'])));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
21
app/Models/Config.php
Normal file
21
app/Models/Config.php
Normal file
@@ -0,0 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace App\Models;
|
||||
|
||||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class Config extends Model
|
||||
{
|
||||
use HasFactory;
|
||||
|
||||
protected $fillable = [
|
||||
'key',
|
||||
'value',
|
||||
'description',
|
||||
];
|
||||
|
||||
protected $casts = [
|
||||
'value' => 'array',
|
||||
];
|
||||
}
|
||||
@@ -11,8 +11,11 @@ class AppServiceProvider extends ServiceProvider
|
||||
*/
|
||||
public function register(): void
|
||||
{
|
||||
// 注册 JIRA 服务
|
||||
// 注册应用服务
|
||||
$this->app->singleton(\App\Services\JiraService::class);
|
||||
$this->app->singleton(\App\Services\ConfigService::class);
|
||||
$this->app->singleton(\App\Services\DingTalkService::class);
|
||||
$this->app->singleton(\App\Services\GitMonitorService::class);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
25
app/Providers/GitMonitorServiceProvider.php
Normal file
25
app/Providers/GitMonitorServiceProvider.php
Normal file
@@ -0,0 +1,25 @@
|
||||
<?php
|
||||
|
||||
namespace App\Providers;
|
||||
|
||||
use App\Console\Commands\GitMonitorCacheCommand;
|
||||
use App\Console\Commands\GitMonitorCheckCommand;
|
||||
use Illuminate\Support\ServiceProvider;
|
||||
|
||||
class GitMonitorServiceProvider extends ServiceProvider
|
||||
{
|
||||
public function register(): void
|
||||
{
|
||||
//
|
||||
}
|
||||
|
||||
public function boot(): void
|
||||
{
|
||||
if ($this->app->runningInConsole()) {
|
||||
$this->commands([
|
||||
GitMonitorCheckCommand::class,
|
||||
GitMonitorCacheCommand::class,
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
43
app/Services/ConfigService.php
Normal file
43
app/Services/ConfigService.php
Normal file
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Config;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
class ConfigService
|
||||
{
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$config = Config::query()->where('key', $key)->first();
|
||||
|
||||
return $config?->value ?? $default;
|
||||
}
|
||||
|
||||
public function getNested(string $key, string $path, mixed $default = null): mixed
|
||||
{
|
||||
$value = $this->get($key);
|
||||
|
||||
return Arr::get($value ?? [], $path, $default);
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value, ?string $description = null): Config
|
||||
{
|
||||
return Config::query()->updateOrCreate(
|
||||
['key' => $key],
|
||||
[
|
||||
'value' => $value,
|
||||
'description' => $description,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
public function setNested(string $key, string $path, mixed $value, ?string $description = null): Config
|
||||
{
|
||||
$payload = $this->get($key, []);
|
||||
Arr::set($payload, $path, $value);
|
||||
|
||||
return $this->set($key, $payload, $description);
|
||||
}
|
||||
}
|
||||
56
app/Services/DingTalkService.php
Normal file
56
app/Services/DingTalkService.php
Normal file
@@ -0,0 +1,56 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class DingTalkService
|
||||
{
|
||||
private ?string $webhook;
|
||||
private ?string $secret;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$config = config('services.dingtalk', []);
|
||||
$this->webhook = $config['webhook'] ?? null;
|
||||
$this->secret = $config['secret'] ?? null;
|
||||
}
|
||||
|
||||
public function sendText(string $message, array $atMobiles = [], bool $atAll = false): void
|
||||
{
|
||||
if (empty($this->webhook)) {
|
||||
Log::warning('DingTalk webhook is not configured, skip sending alert.');
|
||||
return;
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'msgtype' => 'text',
|
||||
'text' => [
|
||||
'content' => $message,
|
||||
],
|
||||
'at' => [
|
||||
'atMobiles' => $atMobiles,
|
||||
'isAtAll' => $atAll,
|
||||
],
|
||||
];
|
||||
|
||||
$url = $this->webhook;
|
||||
if (!empty($this->secret)) {
|
||||
$timestamp = (int) round(microtime(true) * 1000);
|
||||
$stringToSign = $timestamp . "\n" . $this->secret;
|
||||
$sign = base64_encode(hash_hmac('sha256', $stringToSign, $this->secret, true));
|
||||
$encodedSign = urlencode($sign);
|
||||
$separator = str_contains($url, '?') ? '&' : '?';
|
||||
$url .= "{$separator}timestamp={$timestamp}&sign={$encodedSign}";
|
||||
}
|
||||
|
||||
try {
|
||||
Http::timeout(10)->asJson()->post($url, $payload);
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to send DingTalk alert', [
|
||||
'message' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,11 +2,10 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use InvalidArgumentException;
|
||||
use RuntimeException;
|
||||
use Carbon\Carbon;
|
||||
|
||||
class EnvService
|
||||
{
|
||||
@@ -14,23 +13,32 @@ class EnvService
|
||||
private string $envStoragePath;
|
||||
private string $backupStoragePath;
|
||||
|
||||
public function __construct()
|
||||
public function __construct(private readonly ConfigService $configService)
|
||||
{
|
||||
$this->projectsPath = '/home/tradewind/Projects';
|
||||
$this->projectsPath = $this->resolveProjectsPath();
|
||||
$this->envStoragePath = storage_path('app/env');
|
||||
$this->backupStoragePath = storage_path('app/env/backups');
|
||||
|
||||
// 确保env存储目录存在
|
||||
if (!File::exists($this->envStoragePath)) {
|
||||
File::makeDirectory($this->envStoragePath, 0755, true);
|
||||
}
|
||||
|
||||
// 确保备份存储目录存在
|
||||
if (!File::exists($this->backupStoragePath)) {
|
||||
File::makeDirectory($this->backupStoragePath, 0755, true);
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveProjectsPath(): string
|
||||
{
|
||||
$path = $this->configService->get('workspace.projects_path');
|
||||
|
||||
if (empty($path)) {
|
||||
throw new RuntimeException('configs 表未设置 workspace.projects_path。');
|
||||
}
|
||||
|
||||
return rtrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取所有项目列表
|
||||
*/
|
||||
|
||||
487
app/Services/GitMonitorService.php
Normal file
487
app/Services/GitMonitorService.php
Normal file
@@ -0,0 +1,487 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Arr;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Symfony\Component\Process\Exception\ProcessFailedException;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
class GitMonitorService
|
||||
{
|
||||
private const RELEASE_CACHE_KEY = 'git_monitor.current_versions';
|
||||
private const LAST_CHECKED_KEY = 'git_monitor.last_checked_commits';
|
||||
private const DEVELOP_BRANCH = 'develop';
|
||||
|
||||
/**
|
||||
* 项目配置(只包含允许巡检的项目)
|
||||
* @var array<string, array<string, mixed>>
|
||||
*/
|
||||
private array $projects = [];
|
||||
private string $projectsPath;
|
||||
private int $commitScanLimit;
|
||||
private int $gitTimeout;
|
||||
|
||||
public function __construct(
|
||||
private readonly ConfigService $configService,
|
||||
private readonly JiraService $jiraService,
|
||||
private readonly DingTalkService $dingTalkService
|
||||
) {
|
||||
$this->projectsPath = $this->resolveProjectsPath();
|
||||
$this->projects = $this->resolveProjects();
|
||||
|
||||
$enabled = config('git-monitor.enabled_projects', []);
|
||||
if (!empty($enabled)) {
|
||||
$this->projects = array_intersect_key($this->projects, array_flip($enabled));
|
||||
}
|
||||
|
||||
$this->commitScanLimit = (int) config('git-monitor.commit_scan_limit', 30);
|
||||
$this->gitTimeout = (int) config('git-monitor.git_timeout', 180);
|
||||
}
|
||||
|
||||
public function refreshReleaseCache(bool $force = false): array
|
||||
{
|
||||
if (empty($this->projects)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (!$force) {
|
||||
$cached = $this->configService->get(self::RELEASE_CACHE_KEY);
|
||||
if (!empty($cached) && !$this->shouldRefreshCache($cached)) {
|
||||
return $cached;
|
||||
}
|
||||
}
|
||||
|
||||
$payload = [
|
||||
'cached_at' => Carbon::now()->toDateTimeString(),
|
||||
'repositories' => [],
|
||||
];
|
||||
|
||||
foreach ($this->projects as $repoKey => $repoConfig) {
|
||||
$projectKey = $repoConfig['jira_project'] ?? null;
|
||||
if (empty($projectKey)) {
|
||||
Log::warning('Jira project key missing for repository', ['repository' => $repoKey]);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$version = $this->jiraService->getUpcomingReleaseVersion($projectKey);
|
||||
if ($version) {
|
||||
$payload['repositories'][$repoKey] = [
|
||||
'version' => $version['version'],
|
||||
'release_date' => $version['release_date'],
|
||||
'branch' => 'release/' . $version['version'],
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Failed to fetch release version from Jira', [
|
||||
'project' => $projectKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
$this->configService->set(
|
||||
self::RELEASE_CACHE_KEY,
|
||||
$payload,
|
||||
'Cached release versions fetched from Jira'
|
||||
);
|
||||
|
||||
return $payload;
|
||||
}
|
||||
|
||||
public function ensureReleaseCache(): array
|
||||
{
|
||||
$cached = $this->configService->get(self::RELEASE_CACHE_KEY);
|
||||
|
||||
if (empty($cached) || $this->shouldRefreshCache($cached)) {
|
||||
return $this->refreshReleaseCache(true);
|
||||
}
|
||||
|
||||
return $cached;
|
||||
}
|
||||
|
||||
public function checkRepositories(bool $refreshCacheIfNeeded = true): array
|
||||
{
|
||||
$releaseCache = $refreshCacheIfNeeded
|
||||
? $this->ensureReleaseCache()
|
||||
: ($this->configService->get(self::RELEASE_CACHE_KEY) ?? []);
|
||||
|
||||
$results = [];
|
||||
$alerts = [];
|
||||
|
||||
foreach ($this->projects as $repoKey => $repoConfig) {
|
||||
$branch = data_get($releaseCache, "repositories.{$repoKey}.branch");
|
||||
if (empty($branch)) {
|
||||
Log::warning('Missing release branch info for repository', ['repository' => $repoKey]);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
$result = $this->inspectRepository($repoKey, $repoConfig, $branch);
|
||||
$results[$repoKey] = $result;
|
||||
|
||||
if ($this->hasIssues($result['issues'])) {
|
||||
$alerts[$repoKey] = $result;
|
||||
}
|
||||
} catch (\Throwable $e) {
|
||||
Log::error('Git monitoring failed for repository', [
|
||||
'repository' => $repoKey,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
|
||||
$results[$repoKey] = [
|
||||
'repository' => $repoKey,
|
||||
'branch' => $branch,
|
||||
'error' => $e->getMessage(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($alerts)) {
|
||||
$this->dingTalkService->sendText($this->buildAlertMessage($alerts));
|
||||
}
|
||||
|
||||
return $results;
|
||||
}
|
||||
|
||||
private function shouldRefreshCache(array $cached): bool
|
||||
{
|
||||
$cachedAt = Arr::get($cached, 'cached_at');
|
||||
if (empty($cachedAt)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($cachedAt)->lt(Carbon::now()->startOfDay());
|
||||
} catch (\Throwable) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
private function inspectRepository(string $repoKey, array $repoConfig, string $branch): array
|
||||
{
|
||||
$path = $this->resolveProjectPath($repoKey, $repoConfig);
|
||||
$restoreBranch = null;
|
||||
|
||||
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
|
||||
throw new \RuntimeException("Project path {$path} is not a valid git repository");
|
||||
}
|
||||
|
||||
try {
|
||||
$restoreBranch = $this->synchronizeRepository($path, $branch);
|
||||
$head = $this->runGit($path, ['git', 'rev-parse', 'HEAD']);
|
||||
|
||||
$lastChecked = $this->configService->getNested(self::LAST_CHECKED_KEY, $repoKey);
|
||||
$commits = $this->collectCommits($path, $branch, $lastChecked);
|
||||
|
||||
$issues = [
|
||||
'develop_merges' => [],
|
||||
'missing_functions' => [],
|
||||
];
|
||||
|
||||
foreach ($commits as $commit) {
|
||||
if ($this->isDevelopMerge($path, $commit)) {
|
||||
$issues['develop_merges'][] = $this->getCommitMetadata($path, $commit);
|
||||
}
|
||||
|
||||
$missingFunctions = $this->detectMissingFunctions($path, $commit);
|
||||
if (!empty($missingFunctions)) {
|
||||
$issues['missing_functions'][] = [
|
||||
'commit' => $this->getCommitMetadata($path, $commit),
|
||||
'details' => $missingFunctions,
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
$this->updateLastChecked($repoKey, $head);
|
||||
|
||||
return [
|
||||
'repository' => $repoKey,
|
||||
'display' => $repoConfig['display'] ?? ucfirst($repoKey),
|
||||
'branch' => $branch,
|
||||
'path' => $path,
|
||||
'head' => $head,
|
||||
'commits_scanned' => count($commits),
|
||||
'issues' => $issues,
|
||||
];
|
||||
} finally {
|
||||
if ($restoreBranch) {
|
||||
try {
|
||||
$this->runGit($path, ['git', 'checkout', $restoreBranch]);
|
||||
} catch (\Throwable $e) {
|
||||
Log::warning('Failed to restore branch', [
|
||||
'repository' => $repoKey,
|
||||
'branch' => $restoreBranch,
|
||||
'error' => $e->getMessage(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function synchronizeRepository(string $path, string $branch): ?string
|
||||
{
|
||||
$currentBranch = trim($this->runGit($path, ['git', 'rev-parse', '--abbrev-ref', 'HEAD']));
|
||||
$restoreBranch = $currentBranch !== $branch && $currentBranch !== 'HEAD'
|
||||
? $currentBranch
|
||||
: null;
|
||||
|
||||
$this->runGit($path, ['git', 'fetch', 'origin']);
|
||||
$this->runGit($path, ['git', 'fetch', 'origin', $branch]);
|
||||
$this->runGit($path, ['git', 'fetch', 'origin', self::DEVELOP_BRANCH]);
|
||||
|
||||
$this->checkoutBranch($path, $branch);
|
||||
$this->runGit($path, ['git', 'pull', '--ff-only', 'origin', $branch]);
|
||||
|
||||
return $restoreBranch;
|
||||
}
|
||||
|
||||
private function checkoutBranch(string $path, string $branch): void
|
||||
{
|
||||
try {
|
||||
$this->runGit($path, ['git', 'show-ref', '--verify', "refs/heads/{$branch}"]);
|
||||
$this->runGit($path, ['git', 'checkout', $branch]);
|
||||
} catch (ProcessFailedException) {
|
||||
$this->runGit($path, ['git', 'checkout', '-B', $branch, "origin/{$branch}"]);
|
||||
}
|
||||
}
|
||||
|
||||
private function collectCommits(string $repoPath, string $branch, ?string $lastChecked): array
|
||||
{
|
||||
try {
|
||||
$output = $lastChecked
|
||||
? $this->runGit($repoPath, ['git', 'log', "{$lastChecked}..{$branch}", '--pretty=%H'])
|
||||
: $this->runGit($repoPath, ['git', 'log', $branch, '-n', (string) $this->commitScanLimit, '--pretty=%H']);
|
||||
} catch (ProcessFailedException) {
|
||||
$output = $this->runGit($repoPath, ['git', 'log', '-n', (string) $this->commitScanLimit, '--pretty=%H']);
|
||||
}
|
||||
|
||||
return array_values(array_filter(array_map('trim', explode("\n", $output))));
|
||||
}
|
||||
|
||||
private function isDevelopMerge(string $repoPath, string $commit): bool
|
||||
{
|
||||
$parents = trim($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%P', $commit]));
|
||||
$parentShas = array_values(array_filter(explode(' ', $parents)));
|
||||
|
||||
if (count($parentShas) < 2) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$message = strtolower($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%s', $commit]));
|
||||
if (str_contains($message, self::DEVELOP_BRANCH)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$target = 'origin/' . self::DEVELOP_BRANCH;
|
||||
|
||||
foreach ($parentShas as $parent) {
|
||||
$branches = $this->runGit($repoPath, ['git', 'branch', '-r', '--contains', $parent]);
|
||||
foreach (preg_split('/\R/', $branches) as $branchLine) {
|
||||
$branchLine = trim(str_replace('*', '', $branchLine));
|
||||
if (empty($branchLine)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ($branchLine === $target || str_ends_with($branchLine, '/' . self::DEVELOP_BRANCH)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function detectMissingFunctions(string $repoPath, string $commit): array
|
||||
{
|
||||
$diff = $this->runGit($repoPath, ['git', 'show', $commit, '--pretty=format:', '--unified=0']);
|
||||
$currentFile = null;
|
||||
$removed = [];
|
||||
$added = [];
|
||||
|
||||
foreach (preg_split('/\R/', $diff) as $line) {
|
||||
if (str_starts_with($line, 'diff --git')) {
|
||||
$currentFile = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($line, '+++ b/')) {
|
||||
$currentFile = substr($line, 6);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($line, '--- a/')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!$currentFile || !str_ends_with($currentFile, '.php')) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($line, '-')) {
|
||||
$function = $this->extractFunctionName(substr($line, 1));
|
||||
if ($function) {
|
||||
$removed[$currentFile][] = $function;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (str_starts_with($line, '+')) {
|
||||
$function = $this->extractFunctionName(substr($line, 1));
|
||||
if ($function) {
|
||||
$added[$currentFile][] = $function;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$issues = [];
|
||||
foreach ($removed as $file => $functions) {
|
||||
$diffed = array_diff($functions, $added[$file] ?? []);
|
||||
if (!empty($diffed)) {
|
||||
$issues[] = [
|
||||
'file' => $file,
|
||||
'functions' => array_values(array_unique($diffed)),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
return $issues;
|
||||
}
|
||||
|
||||
private function extractFunctionName(string $line): ?string
|
||||
{
|
||||
$trimmed = trim($line);
|
||||
if (str_starts_with($trimmed, '//') || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '*')) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/function\s+([A-Za-z0-9_]+)\s*\(/', $trimmed, $matches)) {
|
||||
return $matches[1];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function updateLastChecked(string $repoKey, string $sha): void
|
||||
{
|
||||
$current = $this->configService->get(self::LAST_CHECKED_KEY, []);
|
||||
$current[$repoKey] = $sha;
|
||||
|
||||
$this->configService->set(
|
||||
self::LAST_CHECKED_KEY,
|
||||
$current,
|
||||
'Last scanned release commits'
|
||||
);
|
||||
}
|
||||
|
||||
private function getCommitMetadata(string $repoPath, string $commit): array
|
||||
{
|
||||
$separator = "\x1F";
|
||||
$format = "%H{$separator}%an{$separator}%ad{$separator}%s";
|
||||
$raw = $this->runGit($repoPath, ['git', 'show', '-s', "--date=iso-strict", "--pretty={$format}", $commit]);
|
||||
$parts = array_pad(explode($separator, $raw, 4), 4, '');
|
||||
|
||||
return [
|
||||
'hash' => $parts[0],
|
||||
'author' => $parts[1],
|
||||
'date' => $parts[2],
|
||||
'subject' => $parts[3],
|
||||
];
|
||||
}
|
||||
|
||||
private function hasIssues(array $issues): bool
|
||||
{
|
||||
return !empty($issues['develop_merges']) || !empty($issues['missing_functions']);
|
||||
}
|
||||
|
||||
private function buildAlertMessage(array $alerts): string
|
||||
{
|
||||
$lines = ['【Git监控告警】'];
|
||||
|
||||
foreach ($alerts as $result) {
|
||||
$lines[] = sprintf('%s(%s)', $result['display'], $result['branch']);
|
||||
|
||||
if (!empty($result['issues']['develop_merges'])) {
|
||||
$lines[] = ' - 检测到 develop 合并:';
|
||||
foreach ($result['issues']['develop_merges'] as $commit) {
|
||||
$lines[] = sprintf(
|
||||
' • %s %s (%s)',
|
||||
substr($commit['hash'], 0, 8),
|
||||
$commit['subject'],
|
||||
$commit['author']
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!empty($result['issues']['missing_functions'])) {
|
||||
$lines[] = ' - 疑似缺失函数:';
|
||||
|
||||
foreach ($result['issues']['missing_functions'] as $issue) {
|
||||
$lines[] = sprintf(
|
||||
' • %s %s',
|
||||
substr($issue['commit']['hash'], 0, 8),
|
||||
$issue['commit']['subject']
|
||||
);
|
||||
|
||||
foreach ($issue['details'] as $detail) {
|
||||
$functions = implode(', ', array_slice($detail['functions'], 0, 5));
|
||||
$lines[] = sprintf(' %s => %s', $detail['file'], $functions);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return implode("\n", $lines);
|
||||
}
|
||||
|
||||
private function runGit(string $path, array $command): string
|
||||
{
|
||||
return $this->runProcess($command, $path);
|
||||
}
|
||||
|
||||
private function runProcess(array $command, ?string $workingDirectory = null): string
|
||||
{
|
||||
$process = new Process($command, $workingDirectory);
|
||||
$process->setTimeout($this->gitTimeout);
|
||||
$process->mustRun();
|
||||
|
||||
return trim($process->getOutput());
|
||||
}
|
||||
|
||||
private function resolveProjectsPath(): string
|
||||
{
|
||||
$path = $this->configService->get('workspace.projects_path');
|
||||
|
||||
if (empty($path)) {
|
||||
throw new \RuntimeException('configs 表未设置 workspace.projects_path。');
|
||||
}
|
||||
|
||||
return rtrim($path, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, array<string, mixed>>
|
||||
*/
|
||||
private function resolveProjects(): array
|
||||
{
|
||||
$projects = $this->configService->get('workspace.repositories');
|
||||
|
||||
if (!is_array($projects) || empty($projects)) {
|
||||
throw new \RuntimeException('configs 表未设置 workspace.repositories。');
|
||||
}
|
||||
|
||||
return $projects;
|
||||
}
|
||||
|
||||
private function resolveProjectPath(string $repoKey, array $repoConfig): string
|
||||
{
|
||||
if (!empty($repoConfig['path'])) {
|
||||
return rtrim($repoConfig['path'], '/');
|
||||
}
|
||||
|
||||
$directory = $repoConfig['directory'] ?? $repoKey;
|
||||
return $this->projectsPath . '/' . ltrim($directory, '/');
|
||||
}
|
||||
}
|
||||
@@ -5,12 +5,14 @@ namespace App\Services;
|
||||
use JiraRestApi\Configuration\ArrayConfiguration;
|
||||
use JiraRestApi\Issue\IssueService;
|
||||
use JiraRestApi\JiraException;
|
||||
use JiraRestApi\Project\ProjectService;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
|
||||
class JiraService
|
||||
{
|
||||
private IssueService $issueService;
|
||||
private ProjectService $projectService;
|
||||
private array $config;
|
||||
|
||||
public function __construct()
|
||||
@@ -19,9 +21,12 @@ class JiraService
|
||||
$this->initializeJiraClient();
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws JiraException
|
||||
*/
|
||||
private function initializeJiraClient(): void
|
||||
{
|
||||
$jiraConfig = new ArrayConfiguration([
|
||||
$clientConfig = new ArrayConfiguration([
|
||||
'jiraHost' => $this->config['host'],
|
||||
'jiraUser' => $this->config['username'],
|
||||
'jiraPassword' => $this->config['auth_type'] === 'token'
|
||||
@@ -30,7 +35,8 @@ class JiraService
|
||||
'timeout' => $this->config['timeout'],
|
||||
]);
|
||||
|
||||
$this->issueService = new IssueService($jiraConfig);
|
||||
$this->issueService = new IssueService($clientConfig);
|
||||
$this->projectService = new ProjectService($clientConfig);
|
||||
}
|
||||
|
||||
|
||||
@@ -738,5 +744,47 @@ class JiraService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取最近的 release 版本
|
||||
*/
|
||||
public function getUpcomingReleaseVersion(string $projectKey): ?array
|
||||
{
|
||||
try {
|
||||
$versions = $this->projectService->getVersions($projectKey);
|
||||
} catch (JiraException $e) {
|
||||
throw new \RuntimeException('获取 release 版本失败: ' . $e->getMessage(), previous: $e);
|
||||
}
|
||||
|
||||
if (empty($versions)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$now = Carbon::now()->startOfDay();
|
||||
|
||||
$candidate = collect($versions)
|
||||
->filter(function ($version) use ($now) {
|
||||
if (($version->released ?? false) || empty($version->releaseDate)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return Carbon::parse($version->releaseDate)->greaterThanOrEqualTo($now);
|
||||
} catch (\Throwable) {
|
||||
return false;
|
||||
}
|
||||
})
|
||||
->sortBy(function ($version) {
|
||||
return Carbon::parse($version->releaseDate);
|
||||
})
|
||||
->first();
|
||||
|
||||
if (!$candidate) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'version' => $candidate->name,
|
||||
'release_date' => Carbon::parse($candidate->releaseDate)->toDateString(),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Console\Scheduling\Schedule;
|
||||
use Illuminate\Foundation\Application;
|
||||
use Illuminate\Foundation\Configuration\Exceptions;
|
||||
use Illuminate\Foundation\Configuration\Middleware;
|
||||
@@ -22,4 +23,15 @@ return Application::configure(basePath: dirname(__DIR__))
|
||||
})
|
||||
->withExceptions(function (Exceptions $exceptions): void {
|
||||
//
|
||||
})->create();
|
||||
})
|
||||
->withSchedule(function (Schedule $schedule): void {
|
||||
$schedule->command('git-monitor:check')
|
||||
->everyTenMinutes()
|
||||
->withoutOverlapping()
|
||||
->runInBackground();
|
||||
|
||||
$schedule->command('git-monitor:cache')
|
||||
->dailyAt('02:00')
|
||||
->withoutOverlapping();
|
||||
})
|
||||
->create();
|
||||
|
||||
@@ -4,4 +4,5 @@ return [
|
||||
App\Providers\AppServiceProvider::class,
|
||||
App\Providers\ClientServiceProvider::class,
|
||||
App\Providers\EnvServiceProvider::class,
|
||||
App\Providers\GitMonitorServiceProvider::class,
|
||||
];
|
||||
|
||||
12
config/git-monitor.php
Normal file
12
config/git-monitor.php
Normal file
@@ -0,0 +1,12 @@
|
||||
<?php
|
||||
|
||||
return [
|
||||
'enabled_projects' => array_values(array_filter(array_map(
|
||||
'trim',
|
||||
explode(',', env('GIT_MONITOR_PROJECTS', 'service,portal-be,agent-be'))
|
||||
))),
|
||||
|
||||
'commit_scan_limit' => 30,
|
||||
|
||||
'git_timeout' => 180,
|
||||
];
|
||||
@@ -45,4 +45,9 @@ return [
|
||||
'timeout' => env('MONO_TIMEOUT', 30),
|
||||
],
|
||||
|
||||
'dingtalk' => [
|
||||
'webhook' => env('DINGTALK_WEBHOOK'),
|
||||
'secret' => env('DINGTALK_SECRET'),
|
||||
],
|
||||
|
||||
];
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
Schema::create('configs', function (Blueprint $table) {
|
||||
$table->id();
|
||||
$table->string('key')->unique();
|
||||
$table->json('value')->nullable();
|
||||
$table->string('description')->nullable();
|
||||
$table->timestamps();
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
Schema::dropIfExists('configs');
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
use App\Models\Config;
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
/**
|
||||
* Run the migrations.
|
||||
*/
|
||||
public function up(): void
|
||||
{
|
||||
if (!Schema::hasTable('configs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
$exists = Config::query()->where('key', 'workspace.projects_path')->exists();
|
||||
|
||||
if (!$exists) {
|
||||
Config::query()->create([
|
||||
'key' => 'workspace.projects_path',
|
||||
'value' => '/home/tradewind/Projects',
|
||||
'description' => '默认的工作空间项目路径',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse the migrations.
|
||||
*/
|
||||
public function down(): void
|
||||
{
|
||||
if (!Schema::hasTable('configs')) {
|
||||
return;
|
||||
}
|
||||
|
||||
Config::query()->where('key', 'workspace.projects_path')->delete();
|
||||
}
|
||||
};
|
||||
Reference in New Issue
Block a user