558 lines
19 KiB
PHP
558 lines
19 KiB
PHP
<?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 {
|
||
// 先从 master 分支获取当前版本号
|
||
$currentVersion = $this->getMasterVersion($repoKey, $repoConfig);
|
||
|
||
// 根据当前版本号获取下一个版本
|
||
$version = $this->jiraService->getUpcomingReleaseVersion($projectKey, $currentVersion);
|
||
if ($version) {
|
||
$payload['repositories'][$repoKey] = [
|
||
'version' => $version['version'],
|
||
'release_date' => $version['release_date'],
|
||
'branch' => 'release/' . $version['version'],
|
||
'current_version' => $currentVersion,
|
||
];
|
||
}
|
||
} 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);
|
||
|
||
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
|
||
throw new \RuntimeException("Project path {$path} is not a valid git repository");
|
||
}
|
||
|
||
$this->synchronizeRepository($path, $branch);
|
||
$remoteBranch = 'origin/' . $branch;
|
||
$head = $this->runGit($path, ['git', 'rev-parse', $remoteBranch]);
|
||
|
||
$lastChecked = $this->configService->getNested(self::LAST_CHECKED_KEY, $repoKey);
|
||
$commits = $this->collectCommits($path, $remoteBranch, $lastChecked);
|
||
|
||
$issues = [
|
||
'develop_merges' => [],
|
||
'missing_functions' => [],
|
||
];
|
||
|
||
foreach ($commits as $commit) {
|
||
$isMerge = $this->isMergeCommit($path, $commit);
|
||
$isConflictResolution = $this->isConflictResolution($path, $commit);
|
||
|
||
// 只检测直接从 develop 合并的情况
|
||
if ($isMerge && $this->isDevelopMerge($path, $commit)) {
|
||
$issues['develop_merges'][] = $this->getCommitMetadata($path, $commit);
|
||
}
|
||
|
||
// 只在 merge 提交或冲突解决提交中检测缺失函数
|
||
if ($isMerge || $isConflictResolution) {
|
||
$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,
|
||
];
|
||
}
|
||
|
||
private function synchronizeRepository(string $path, string $branch): void
|
||
{
|
||
$this->runGit($path, ['git', 'fetch', 'origin']);
|
||
$this->runGit($path, ['git', 'fetch', 'origin', $branch]);
|
||
$this->runGit($path, ['git', 'fetch', 'origin', self::DEVELOP_BRANCH]);
|
||
|
||
$this->runGit($path, ['git', 'show-ref', '--verify', "refs/remotes/origin/{$branch}"]);
|
||
}
|
||
|
||
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))));
|
||
}
|
||
|
||
/**
|
||
* 判断是否为 merge 提交(有多个父提交)
|
||
*/
|
||
private function isMergeCommit(string $repoPath, string $commit): bool
|
||
{
|
||
$parents = trim($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%P', $commit]));
|
||
$parentShas = array_values(array_filter(explode(' ', $parents)));
|
||
|
||
return count($parentShas) >= 2;
|
||
}
|
||
|
||
/**
|
||
* 判断是否为冲突解决提交
|
||
* 通过检查 commit message 是否包含冲突相关关键词
|
||
*/
|
||
private function isConflictResolution(string $repoPath, string $commit): bool
|
||
{
|
||
$message = strtolower($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%s', $commit]));
|
||
|
||
$conflictKeywords = ['conflict', 'resolve', '冲突', '解决冲突'];
|
||
foreach ($conflictKeywords as $keyword) {
|
||
if (str_contains($message, $keyword)) {
|
||
return true;
|
||
}
|
||
}
|
||
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* 判断是否为直接从 develop 分支合并到 release 的提交
|
||
* 只检测 commit message 明确包含 "merge.*develop" 或 "develop.*into" 的情况
|
||
*/
|
||
private function isDevelopMerge(string $repoPath, string $commit): bool
|
||
{
|
||
$message = strtolower($this->runGit($repoPath, ['git', 'show', '-s', '--pretty=%s', $commit]));
|
||
|
||
// 检测 "Merge branch 'develop'" 或 "merge develop into" 等模式
|
||
// 排除 feature/xxx、hotfix/xxx 等分支的合并
|
||
if (preg_match("/merge\s+(branch\s+)?['\"]?develop['\"]?(\s+into)?/i", $message)) {
|
||
return true;
|
||
}
|
||
|
||
// 检测 "develop into release" 模式
|
||
if (preg_match("/develop\s+into\s+['\"]?release/i", $message)) {
|
||
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, '*') || str_starts_with($trimmed, '/*')) {
|
||
return null;
|
||
}
|
||
|
||
// 只匹配真正的函数定义,必须以 function 关键字开头,或者以访问修饰符开头
|
||
// 匹配: function foo(, public function foo(, private static function foo( 等
|
||
if (preg_match('/^(?:(?:public|protected|private)\s+)?(?:static\s+)?function\s+([A-Za-z_][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 ($projects === null) {
|
||
$fallback = $this->buildFallbackProjects(config('git-monitor.enabled_projects', []));
|
||
if (!empty($fallback)) {
|
||
Log::warning('configs 表未设置 workspace.repositories,已使用 git-monitor.enabled_projects。');
|
||
}
|
||
return $fallback;
|
||
}
|
||
|
||
if (!is_array($projects)) {
|
||
Log::warning('configs 表 workspace.repositories 配置格式不正确,已降级使用 git-monitor.enabled_projects。');
|
||
return $this->buildFallbackProjects(config('git-monitor.enabled_projects', []));
|
||
}
|
||
|
||
return $projects;
|
||
}
|
||
|
||
/**
|
||
* @return array<string, array<string, mixed>>
|
||
*/
|
||
private function buildFallbackProjects(array $enabled): array
|
||
{
|
||
$projects = [];
|
||
$jiraKeyMap = [
|
||
'portal-be' => 'WP',
|
||
'agent-be' => 'AM',
|
||
];
|
||
|
||
foreach ($enabled as $repoKey) {
|
||
if (!is_string($repoKey) || $repoKey === '') {
|
||
continue;
|
||
}
|
||
|
||
$projects[$repoKey] = [
|
||
'jira_project' => $jiraKeyMap[$repoKey] ?? $repoKey,
|
||
];
|
||
}
|
||
|
||
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, '/');
|
||
}
|
||
|
||
/**
|
||
* 从 master 分支读取 version.txt 获取当前版本号
|
||
*/
|
||
public function getMasterVersion(string $repoKey, array $repoConfig): ?string
|
||
{
|
||
$path = $this->resolveProjectPath($repoKey, $repoConfig);
|
||
|
||
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
|
||
Log::warning('Invalid git repository path', ['repository' => $repoKey, 'path' => $path]);
|
||
return null;
|
||
}
|
||
|
||
try {
|
||
$this->runGit($path, ['git', 'fetch', 'origin', 'master']);
|
||
$version = $this->runGit($path, ['git', 'show', 'origin/master:version.txt']);
|
||
return trim($version) ?: null;
|
||
} catch (ProcessFailedException $e) {
|
||
Log::warning('Failed to read version.txt from master branch', [
|
||
'repository' => $repoKey,
|
||
'error' => $e->getMessage(),
|
||
]);
|
||
return null;
|
||
}
|
||
}
|
||
}
|