Files
toolbox/app/Services/GitMonitorService.php
2025-12-18 10:18:25 +08:00

488 lines
16 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 {
$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);
$restoreBranch = null;
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
throw new \RuntimeException("Project path {$path} is not a valid git repository");
}
try {
$restoreBranch = $this->synchronizeRepository($path, $branch);
$head = $this->runGit($path, ['git', 'rev-parse', 'HEAD']);
$lastChecked = $this->configService->getNested(self::LAST_CHECKED_KEY, $repoKey);
$commits = $this->collectCommits($path, $branch, $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,
];
} finally {
if ($restoreBranch) {
try {
$this->runGit($path, ['git', 'checkout', $restoreBranch]);
} catch (\Throwable $e) {
Log::warning('Failed to restore branch', [
'repository' => $repoKey,
'branch' => $restoreBranch,
'error' => $e->getMessage(),
]);
}
}
}
}
private function synchronizeRepository(string $path, string $branch): ?string
{
$currentBranch = trim($this->runGit($path, ['git', 'rev-parse', '--abbrev-ref', 'HEAD']));
$restoreBranch = $currentBranch !== $branch && $currentBranch !== 'HEAD'
? $currentBranch
: null;
$this->runGit($path, ['git', 'fetch', 'origin']);
$this->runGit($path, ['git', 'fetch', 'origin', $branch]);
$this->runGit($path, ['git', 'fetch', 'origin', self::DEVELOP_BRANCH]);
$this->checkoutBranch($path, $branch);
$this->runGit($path, ['git', 'pull', '--ff-only', 'origin', $branch]);
return $restoreBranch;
}
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<string, array<string, mixed>>
*/
private function resolveProjects(): array
{
$projects = $this->configService->get('workspace.repositories');
if (!is_array($projects) || empty($projects)) {
throw new \RuntimeException('configs 表未设置 workspace.repositories。');
}
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, '/');
}
}