Files
toolbox/app/Services/GitMonitorService.php

558 lines
19 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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;
}
}
}