From 4875031cc344d7d65de7ed38112697b56711d092 Mon Sep 17 00:00:00 2001 From: tradewind Date: Fri, 29 May 2026 14:25:43 +0800 Subject: [PATCH] #feature: improve Jira planning and release branch creation --- app/Services/GitMonitorService.php | 41 +++++----- app/Services/JiraService.php | 62 ++++++++++++--- tests/Unit/GitMonitorServiceTest.php | 115 +++++++++++++++++++++++++++ tests/Unit/JiraServiceTest.php | 26 ++++++ 4 files changed, 213 insertions(+), 31 deletions(-) create mode 100644 tests/Unit/GitMonitorServiceTest.php diff --git a/app/Services/GitMonitorService.php b/app/Services/GitMonitorService.php index 36450d0..be78b35 100644 --- a/app/Services/GitMonitorService.php +++ b/app/Services/GitMonitorService.php @@ -620,6 +620,8 @@ class GitMonitorService private function ensureReleaseBranchExists(string $repoKey, array $repoConfig, string $branch, ?string $description): void { $path = $this->resolveProjectPath($repoKey, $repoConfig); + $worktreePath = null; + $worktreeCreated = false; if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) { Log::warning('Invalid git repository path for branch creation', ['repository' => $repoKey, 'path' => $path]); @@ -636,34 +638,27 @@ class GitMonitorService $version = str_replace('release/', '', $branch); try { - // 从 origin/master 创建新分支 + // 在临时 worktree 中创建并推送分支,避免切换或修改用户正在工作的仓库。 $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']); + $worktreePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'toolbox-release-' . str_replace(['/', '\\'], '-', $repoKey . '-' . $version) . '-' . bin2hex(random_bytes(4)); + $this->runGit($path, ['git', 'worktree', 'add', '--detach', $worktreePath, 'origin/master']); + $worktreeCreated = true; // 修改 version.txt 文件 - $versionFile = $path . DIRECTORY_SEPARATOR . 'version.txt'; + $versionFile = $worktreePath . 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']); + $this->runGit($worktreePath, ['git', 'add', 'version.txt']); // 构建提交信息:分支名 + 空格 + Jira 描述 $commitMessage = $branch . ($description ? ' ' . $description : ''); - $this->runGit($path, ['git', 'commit', '-m', $commitMessage]); + $this->runGit($worktreePath, ['git', 'commit', '-m', $commitMessage]); // 推送到远程 - $this->runGit($path, ['git', 'push', '-u', 'origin', $branch]); + $this->runGit($worktreePath, ['git', 'push', 'origin', 'HEAD:refs/heads/' . $branch]); Log::info('Created and pushed release branch', [ 'repository' => $repoKey, @@ -687,11 +682,17 @@ class GitMonitorService 'error' => $e->getMessage(), ]); } finally { - // 切回 develop 分支,避免影响后续操作 - try { - $this->runGit($path, ['git', 'checkout', self::DEVELOP_BRANCH]); - } catch (ProcessFailedException) { - // 忽略 + if ($worktreeCreated && $worktreePath !== null) { + try { + $this->runGit($path, ['git', 'worktree', 'remove', '--force', $worktreePath]); + } catch (ProcessFailedException $e) { + Log::warning('Failed to remove temporary release worktree', [ + 'repository' => $repoKey, + 'branch' => $branch, + 'path' => $worktreePath, + 'error' => $e->getMessage(), + ]); + } } } } diff --git a/app/Services/JiraService.php b/app/Services/JiraService.php index 64a98fc..6145d7b 100644 --- a/app/Services/JiraService.php +++ b/app/Services/JiraService.php @@ -138,19 +138,18 @@ class JiraService throw new \InvalidArgumentException('用户名不能为空'); } - // 定义已完成的状态列表(需要排除的状态) - $completedStatuses = [ - 'Done', - '已上线', - '已完成', + // 未来任务只展示已进入评审、排期或开发阶段,且已归入 Sprint 的需求。 + $nextWeekStatuses = [ + '需求已评审', + '需求已排期', + '开发中', ]; - $statusExclusion = implode('", "', $completedStatuses); - - // 查询分配给用户且未完成的需求(Story/需求类型,不包括子任务) - $jql = sprintf( - 'assignee = "%s" AND status NOT IN ("%s") AND issuetype in ("Story", "需求") ORDER BY created ASC', + // 查询未来一周需要继续处理的需求(Story/需求类型,不包括子任务)。 + // 需求既可能分给当前用户,也可能通过研发 owner 归属当前用户。 + $jql = $this->buildNextWeekTasksJql( $username, - $statusExclusion + $nextWeekStatuses, + $this->resolveDeveloperOwnerJqlField() ); try { @@ -172,7 +171,45 @@ class JiraService return collect(); } + private function buildNextWeekTasksJql(string $username, array $allowedStatuses, ?string $developerOwnerField): string + { + $statusInclusion = implode('", "', $allowedStatuses); + $ownerClause = sprintf('assignee = "%s"', $username); + if ($developerOwnerField) { + $ownerClause = sprintf('(%s OR %s = "%s")', $ownerClause, $developerOwnerField, $username); + } + + return sprintf( + '%s AND status IN ("%s") AND issuetype in ("Story", "需求") AND Sprint is not EMPTY ORDER BY created ASC', + $ownerClause, + $statusInclusion + ); + } + + private function resolveDeveloperOwnerJqlField(): ?string + { + try { + foreach ($this->fieldService->getAllFields() as $field) { + $name = $field->name ?? ''; + $id = $field->id ?? ''; + + if ($name === '' || ! $this->isDeveloperOwnerFieldName($name)) { + continue; + } + + if (preg_match('/^customfield_(\d+)$/', $id, $matches)) { + return 'cf['.$matches[1].']'; + } + + return '"'.str_replace('"', '\"', $name).'"'; + } + } catch (\Exception) { + // 字段发现失败时退回 assignee 查询,避免周报生成整体失败。 + } + + return null; + } /** * 获取 Jira Sprint 下拉选项(从最近 Story 的 Sprint 字段中提取) @@ -245,6 +282,7 @@ class JiraService $startAt += $maxResults; $total = $result->total ?? $issues->count(); } while ($issues->count() < $total && ! empty($pageIssues)); + return $issues; } catch (JiraException $e) { throw new \RuntimeException('获取 Sprint 需求失败: '.$e->getMessage()); @@ -597,6 +635,7 @@ class JiraService if (is_array($item)) { return $item['value'] ?? $item['name'] ?? $item['displayName'] ?? ''; } + return ''; })->filter()->implode(', '); } @@ -618,6 +657,7 @@ class JiraService return null; } + /** * 生成 Markdown 格式的周报 */ diff --git a/tests/Unit/GitMonitorServiceTest.php b/tests/Unit/GitMonitorServiceTest.php new file mode 100644 index 0000000..3a02d0f --- /dev/null +++ b/tests/Unit/GitMonitorServiceTest.php @@ -0,0 +1,115 @@ +tempPaths) as $path) { + if (is_dir($path)) { + $this->removeDirectory($path); + } + } + + parent::tearDown(); + } + + public function test_auto_creating_release_branch_keeps_working_tree_untouched(): void + { + $workspacePath = $this->makeTempDirectory('toolbox-git-workspace-'); + $remotePath = $this->makeTempDirectory('toolbox-git-remote-'); + $repoPath = $workspacePath.DIRECTORY_SEPARATOR.'demo-repo'; + + $this->git($remotePath, ['git', 'init', '--bare']); + $this->git($workspacePath, ['git', 'clone', $remotePath, 'demo-repo']); + $this->git($repoPath, ['git', 'config', 'user.email', 'test@example.com']); + $this->git($repoPath, ['git', 'config', 'user.name', 'Test User']); + file_put_contents($repoPath.DIRECTORY_SEPARATOR.'version.txt', '1.0.0'); + $this->git($repoPath, ['git', 'add', 'version.txt']); + $this->git($repoPath, ['git', 'commit', '-m', 'initial']); + $this->git($repoPath, ['git', 'branch', '-M', 'master']); + $this->git($repoPath, ['git', 'push', '-u', 'origin', 'master']); + $this->git($repoPath, ['git', 'checkout', '-b', 'develop']); + file_put_contents($repoPath.DIRECTORY_SEPARATOR.'work.txt', "draft\n"); + + $service = $this->makeGitMonitorService($workspacePath); + + $method = new \ReflectionMethod($service, 'ensureReleaseBranchExists'); + $method->invoke($service, 'demo-repo', ['directory' => 'demo-repo'], 'release/1.1.0', 'next release'); + + $this->assertSame('develop', $this->git($repoPath, ['git', 'branch', '--show-current'])); + $this->assertSame("draft\n", file_get_contents($repoPath.DIRECTORY_SEPARATOR.'work.txt')); + $this->assertStringContainsString('?? work.txt', $this->git($repoPath, ['git', 'status', '--short'])); + $this->assertNotEmpty($this->git($repoPath, ['git', 'ls-remote', '--heads', 'origin', 'release/1.1.0'])); + $this->assertSame('1.1.0', $this->git($repoPath, ['git', 'show', 'origin/release/1.1.0:version.txt'])); + } + + private function makeGitMonitorService(string $workspacePath): GitMonitorService + { + $reflection = new \ReflectionClass(GitMonitorService::class); + $service = $reflection->newInstanceWithoutConstructor(); + + $this->setProperty($service, 'projectsPath', $workspacePath); + $this->setProperty($service, 'gitTimeout', 60); + $this->setProperty($service, 'dingTalkService', $this->createMock(DingTalkService::class)); + + return $service; + } + + private function setProperty(object $object, string $name, mixed $value): void + { + $property = new \ReflectionProperty($object, $name); + $property->setValue($object, $value); + } + + private function makeTempDirectory(string $prefix): string + { + $path = sys_get_temp_dir().DIRECTORY_SEPARATOR.$prefix.bin2hex(random_bytes(6)); + mkdir($path, 0777, true); + $this->tempPaths[] = $path; + + return $path; + } + + private function git(string $workingDirectory, array $command): string + { + $process = new Process($command, $workingDirectory); + $process->setTimeout(60); + $process->mustRun(); + + return trim($process->getOutput()); + } + + private function removeDirectory(string $path): void + { + $items = scandir($path); + if ($items === false) { + return; + } + + foreach ($items as $item) { + if ($item === '.' || $item === '..') { + continue; + } + + $itemPath = $path.DIRECTORY_SEPARATOR.$item; + if (is_dir($itemPath) && ! is_link($itemPath)) { + $this->removeDirectory($itemPath); + + continue; + } + + unlink($itemPath); + } + + rmdir($path); + } +} diff --git a/tests/Unit/JiraServiceTest.php b/tests/Unit/JiraServiceTest.php index e00da82..441e163 100644 --- a/tests/Unit/JiraServiceTest.php +++ b/tests/Unit/JiraServiceTest.php @@ -176,6 +176,32 @@ class JiraServiceTest extends TestCase $this->assertEquals('本周完成的任务', $result['title']); } + public function test_build_next_week_tasks_jql_includes_developer_owner_for_requirements() + { + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('buildNextWeekTasksJql'); + + $result = $method->invoke($this->jiraService, 'test-user', ['需求已评审', '需求已排期', '开发中'], 'cf[11000]'); + + $this->assertEquals( + '(assignee = "test-user" OR cf[11000] = "test-user") AND status IN ("需求已评审", "需求已排期", "开发中") AND issuetype in ("Story", "需求") AND Sprint is not EMPTY ORDER BY created ASC', + $result + ); + } + + public function test_build_next_week_tasks_jql_keeps_assignee_only_when_owner_field_missing() + { + $reflection = new \ReflectionClass($this->jiraService); + $method = $reflection->getMethod('buildNextWeekTasksJql'); + + $result = $method->invoke($this->jiraService, 'test-user', ['需求已评审', '需求已排期', '开发中'], null); + + $this->assertEquals( + 'assignee = "test-user" AND status IN ("需求已评审", "需求已排期", "开发中") AND issuetype in ("Story", "需求") AND Sprint is not EMPTY ORDER BY created ASC', + $result + ); + } + public function test_extract_sprint_info_from_string() { $reflection = new \ReflectionClass($this->jiraService);