Files
toolbox/app/Services/GitMonitorService.php

699 lines
24 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 App\Models\Project;
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 DEVELOP_BRANCH = 'develop';
private const RELEASE_CACHE_KEY = 'git-monitor.release_cache';
/**
* 项目配置(只包含允许巡检的项目)
* @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();
$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) {
$anyProject = Project::query()->whereNotNull('git_version_cached_at')->first();
if ($anyProject && !$this->shouldRefreshCache($anyProject->git_version_cached_at)) {
return $this->buildCachePayload();
}
}
$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) {
$branch = 'release/' . $version['version'];
$payload['repositories'][$repoKey] = [
'version' => $version['version'],
'description' => $version['description'] ?? null,
'release_date' => $version['release_date'],
'branch' => $branch,
'current_version' => $currentVersion,
];
// 更新 Project 模型
$project = Project::findBySlug($repoKey);
if ($project) {
$project->update([
'git_current_version' => $currentVersion,
'git_release_branch' => $branch,
'git_version_cached_at' => now(),
]);
// 如果启用了自动创建 release 分支,检查并创建
if ($project->auto_create_release_branch) {
$this->ensureReleaseBranchExists($repoKey, $repoConfig, $branch, $version['description']);
}
}
}
} catch (\Throwable $e) {
Log::error('Failed to fetch release version from Jira', [
'project' => $projectKey,
'error' => $e->getMessage(),
]);
}
}
return $payload;
}
public function ensureReleaseCache(): array
{
$anyProject = Project::query()->whereNotNull('git_version_cached_at')->first();
if (!$anyProject || $this->shouldRefreshCache($anyProject->git_version_cached_at)) {
return $this->refreshReleaseCache(true);
}
return $this->buildCachePayload();
}
/**
* 从 Project 模型构建缓存数据
*/
private function buildCachePayload(): array
{
$projects = Project::query()
->where('git_monitor_enabled', true)
->whereNotNull('git_release_branch')
->get();
$payload = [
'cached_at' => $projects->first()?->git_version_cached_at?->toDateTimeString() ?? now()->toDateTimeString(),
'repositories' => [],
];
foreach ($projects as $project) {
$payload['repositories'][$project->slug] = [
'version' => $project->git_current_version,
'branch' => $project->git_release_branch,
'current_version' => $project->git_current_version,
];
}
return $payload;
}
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(Carbon|\DateTimeInterface|string|null $cachedAt): bool
{
if (empty($cachedAt)) {
return true;
}
try {
$cachedTime = $cachedAt instanceof Carbon ? $cachedAt : Carbon::parse($cachedAt);
return $cachedTime->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]);
// 从 Project 模型获取 lastChecked
$project = Project::findBySlug($repoKey);
$lastChecked = $project?->git_last_checked_commit;
$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
{
$project = Project::findBySlug($repoKey);
if ($project) {
$project->update(['git_last_checked_commit' => $sha]);
}
}
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
{
// 优先从 Project 模型获取启用 Git 监控的项目
$projectModels = Project::query()
->where('git_monitor_enabled', true)
->get();
if ($projectModels->isNotEmpty()) {
$projects = [];
foreach ($projectModels as $project) {
$projects[$project->slug] = [
'jira_project' => $project->jira_project_code,
'directory' => $project->directory ?? $project->slug,
'path' => $project->absolute_path,
'display' => $project->name,
];
}
return $projects;
}
// 回退到旧的配置方式(兼容迁移前的情况)
$configProjects = $this->configService->get('workspace.repositories');
if ($configProjects !== null && is_array($configProjects)) {
$enabled = config('git-monitor.enabled_projects', []);
if (!empty($enabled)) {
return array_intersect_key($configProjects, array_flip($enabled));
}
return $configProjects;
}
// 最后回退到环境变量配置
return $this->buildFallbackProjects(config('git-monitor.enabled_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;
}
}
/**
* 检查远程是否存在指定分支
*/
private function remoteBranchExists(string $path, string $branch): bool
{
try {
$this->runGit($path, ['git', 'fetch', 'origin']);
$this->runGit($path, ['git', 'ls-remote', '--exit-code', '--heads', 'origin', $branch]);
return true;
} catch (ProcessFailedException) {
return false;
}
}
/**
* 确保 release 分支存在,如果不存在则从 master 创建并推送
*/
private function ensureReleaseBranchExists(string $repoKey, array $repoConfig, string $branch, ?string $description): void
{
$path = $this->resolveProjectPath($repoKey, $repoConfig);
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
Log::warning('Invalid git repository path for branch creation', ['repository' => $repoKey, 'path' => $path]);
return;
}
// 检查远程分支是否已存在
if ($this->remoteBranchExists($path, $branch)) {
Log::info('Release branch already exists on remote', ['repository' => $repoKey, 'branch' => $branch]);
return;
}
// 从分支名中提取版本号release/2.63.0.0 -> 2.63.0.0
$version = str_replace('release/', '', $branch);
try {
// 从 origin/master 创建新分支
$this->runGit($path, ['git', 'fetch', 'origin', 'master']);
// 创建本地分支(基于 origin/master
try {
// 先尝试删除可能存在的本地分支
$this->runGit($path, ['git', 'branch', '-D', $branch]);
} catch (ProcessFailedException) {
// 忽略,分支可能不存在
}
$this->runGit($path, ['git', 'checkout', '-b', $branch, 'origin/master']);
// 修改 version.txt 文件
$versionFile = $path . DIRECTORY_SEPARATOR . 'version.txt';
if (!file_put_contents($versionFile, $version)) {
throw new \RuntimeException("Failed to write version.txt");
}
// 添加并提交更改
$this->runGit($path, ['git', 'add', 'version.txt']);
// 构建提交信息:分支名 + 空格 + Jira 描述
$commitMessage = $branch . ($description ? ' ' . $description : '');
$this->runGit($path, ['git', 'commit', '-m', $commitMessage]);
// 推送到远程
$this->runGit($path, ['git', 'push', '-u', 'origin', $branch]);
Log::info('Created and pushed release branch', [
'repository' => $repoKey,
'branch' => $branch,
'version' => $version,
'message' => $commitMessage,
]);
// 发送钉钉通知
$this->dingTalkService->sendText(sprintf(
"【Release 分支创建】\n项目: %s\n分支: %s\n版本: %s\n描述: %s",
$repoConfig['display'] ?? $repoKey,
$branch,
$version,
$description ?? '无'
));
} catch (ProcessFailedException $e) {
Log::error('Failed to create release branch', [
'repository' => $repoKey,
'branch' => $branch,
'error' => $e->getMessage(),
]);
} finally {
// 切回 develop 分支,避免影响后续操作
try {
$this->runGit($path, ['git', 'checkout', self::DEVELOP_BRANCH]);
} catch (ProcessFailedException) {
// 忽略
}
}
}
}