#feature: improve Jira planning and release branch creation
This commit is contained in:
@@ -620,6 +620,8 @@ class GitMonitorService
|
|||||||
private function ensureReleaseBranchExists(string $repoKey, array $repoConfig, string $branch, ?string $description): void
|
private function ensureReleaseBranchExists(string $repoKey, array $repoConfig, string $branch, ?string $description): void
|
||||||
{
|
{
|
||||||
$path = $this->resolveProjectPath($repoKey, $repoConfig);
|
$path = $this->resolveProjectPath($repoKey, $repoConfig);
|
||||||
|
$worktreePath = null;
|
||||||
|
$worktreeCreated = false;
|
||||||
|
|
||||||
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
|
if (!is_dir($path) || !is_dir($path . DIRECTORY_SEPARATOR . '.git')) {
|
||||||
Log::warning('Invalid git repository path for branch creation', ['repository' => $repoKey, 'path' => $path]);
|
Log::warning('Invalid git repository path for branch creation', ['repository' => $repoKey, 'path' => $path]);
|
||||||
@@ -636,34 +638,27 @@ class GitMonitorService
|
|||||||
$version = str_replace('release/', '', $branch);
|
$version = str_replace('release/', '', $branch);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 从 origin/master 创建新分支
|
// 在临时 worktree 中创建并推送分支,避免切换或修改用户正在工作的仓库。
|
||||||
$this->runGit($path, ['git', 'fetch', 'origin', 'master']);
|
$this->runGit($path, ['git', 'fetch', 'origin', 'master']);
|
||||||
|
$worktreePath = sys_get_temp_dir() . DIRECTORY_SEPARATOR . 'toolbox-release-' . str_replace(['/', '\\'], '-', $repoKey . '-' . $version) . '-' . bin2hex(random_bytes(4));
|
||||||
// 创建本地分支(基于 origin/master)
|
$this->runGit($path, ['git', 'worktree', 'add', '--detach', $worktreePath, 'origin/master']);
|
||||||
try {
|
$worktreeCreated = true;
|
||||||
// 先尝试删除可能存在的本地分支
|
|
||||||
$this->runGit($path, ['git', 'branch', '-D', $branch]);
|
|
||||||
} catch (ProcessFailedException) {
|
|
||||||
// 忽略,分支可能不存在
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->runGit($path, ['git', 'checkout', '-b', $branch, 'origin/master']);
|
|
||||||
|
|
||||||
// 修改 version.txt 文件
|
// 修改 version.txt 文件
|
||||||
$versionFile = $path . DIRECTORY_SEPARATOR . 'version.txt';
|
$versionFile = $worktreePath . DIRECTORY_SEPARATOR . 'version.txt';
|
||||||
if (!file_put_contents($versionFile, $version)) {
|
if (!file_put_contents($versionFile, $version)) {
|
||||||
throw new \RuntimeException("Failed to write version.txt");
|
throw new \RuntimeException("Failed to write version.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 添加并提交更改
|
// 添加并提交更改
|
||||||
$this->runGit($path, ['git', 'add', 'version.txt']);
|
$this->runGit($worktreePath, ['git', 'add', 'version.txt']);
|
||||||
|
|
||||||
// 构建提交信息:分支名 + 空格 + Jira 描述
|
// 构建提交信息:分支名 + 空格 + Jira 描述
|
||||||
$commitMessage = $branch . ($description ? ' ' . $description : '');
|
$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', [
|
Log::info('Created and pushed release branch', [
|
||||||
'repository' => $repoKey,
|
'repository' => $repoKey,
|
||||||
@@ -687,11 +682,17 @@ class GitMonitorService
|
|||||||
'error' => $e->getMessage(),
|
'error' => $e->getMessage(),
|
||||||
]);
|
]);
|
||||||
} finally {
|
} finally {
|
||||||
// 切回 develop 分支,避免影响后续操作
|
if ($worktreeCreated && $worktreePath !== null) {
|
||||||
try {
|
try {
|
||||||
$this->runGit($path, ['git', 'checkout', self::DEVELOP_BRANCH]);
|
$this->runGit($path, ['git', 'worktree', 'remove', '--force', $worktreePath]);
|
||||||
} catch (ProcessFailedException) {
|
} catch (ProcessFailedException $e) {
|
||||||
// 忽略
|
Log::warning('Failed to remove temporary release worktree', [
|
||||||
|
'repository' => $repoKey,
|
||||||
|
'branch' => $branch,
|
||||||
|
'path' => $worktreePath,
|
||||||
|
'error' => $e->getMessage(),
|
||||||
|
]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -138,19 +138,18 @@ class JiraService
|
|||||||
throw new \InvalidArgumentException('用户名不能为空');
|
throw new \InvalidArgumentException('用户名不能为空');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义已完成的状态列表(需要排除的状态)
|
// 未来任务只展示已进入评审、排期或开发阶段,且已归入 Sprint 的需求。
|
||||||
$completedStatuses = [
|
$nextWeekStatuses = [
|
||||||
'Done',
|
'需求已评审',
|
||||||
'已上线',
|
'需求已排期',
|
||||||
'已完成',
|
'开发中',
|
||||||
];
|
];
|
||||||
$statusExclusion = implode('", "', $completedStatuses);
|
// 查询未来一周需要继续处理的需求(Story/需求类型,不包括子任务)。
|
||||||
|
// 需求既可能分给当前用户,也可能通过研发 owner 归属当前用户。
|
||||||
// 查询分配给用户且未完成的需求(Story/需求类型,不包括子任务)
|
$jql = $this->buildNextWeekTasksJql(
|
||||||
$jql = sprintf(
|
|
||||||
'assignee = "%s" AND status NOT IN ("%s") AND issuetype in ("Story", "需求") ORDER BY created ASC',
|
|
||||||
$username,
|
$username,
|
||||||
$statusExclusion
|
$nextWeekStatuses,
|
||||||
|
$this->resolveDeveloperOwnerJqlField()
|
||||||
);
|
);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -172,7 +171,45 @@ class JiraService
|
|||||||
return collect();
|
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 字段中提取)
|
* 获取 Jira Sprint 下拉选项(从最近 Story 的 Sprint 字段中提取)
|
||||||
@@ -245,6 +282,7 @@ class JiraService
|
|||||||
$startAt += $maxResults;
|
$startAt += $maxResults;
|
||||||
$total = $result->total ?? $issues->count();
|
$total = $result->total ?? $issues->count();
|
||||||
} while ($issues->count() < $total && ! empty($pageIssues));
|
} while ($issues->count() < $total && ! empty($pageIssues));
|
||||||
|
|
||||||
return $issues;
|
return $issues;
|
||||||
} catch (JiraException $e) {
|
} catch (JiraException $e) {
|
||||||
throw new \RuntimeException('获取 Sprint 需求失败: '.$e->getMessage());
|
throw new \RuntimeException('获取 Sprint 需求失败: '.$e->getMessage());
|
||||||
@@ -597,6 +635,7 @@ class JiraService
|
|||||||
if (is_array($item)) {
|
if (is_array($item)) {
|
||||||
return $item['value'] ?? $item['name'] ?? $item['displayName'] ?? '';
|
return $item['value'] ?? $item['name'] ?? $item['displayName'] ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '';
|
return '';
|
||||||
})->filter()->implode(', ');
|
})->filter()->implode(', ');
|
||||||
}
|
}
|
||||||
@@ -618,6 +657,7 @@ class JiraService
|
|||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 生成 Markdown 格式的周报
|
* 生成 Markdown 格式的周报
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,115 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Tests\Unit;
|
||||||
|
|
||||||
|
use App\Services\DingTalkService;
|
||||||
|
use App\Services\GitMonitorService;
|
||||||
|
use Symfony\Component\Process\Process;
|
||||||
|
use Tests\TestCase;
|
||||||
|
|
||||||
|
class GitMonitorServiceTest extends TestCase
|
||||||
|
{
|
||||||
|
private array $tempPaths = [];
|
||||||
|
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
foreach (array_reverse($this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -176,6 +176,32 @@ class JiraServiceTest extends TestCase
|
|||||||
$this->assertEquals('本周完成的任务', $result['title']);
|
$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()
|
public function test_extract_sprint_info_from_string()
|
||||||
{
|
{
|
||||||
$reflection = new \ReflectionClass($this->jiraService);
|
$reflection = new \ReflectionClass($this->jiraService);
|
||||||
|
|||||||
Reference in New Issue
Block a user