> */ 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> */ 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> */ 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) { // 忽略 } } } }