> */ 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); 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) { 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, ]; } 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)))); } 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> */ 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> */ 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, '/'); } }