#feature: add test mail generator
This commit is contained in:
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Models\Project;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Support\Collection;
|
||||
use JiraRestApi\Configuration\ArrayConfiguration;
|
||||
use JiraRestApi\Field\FieldService;
|
||||
use JiraRestApi\Issue\IssueService;
|
||||
use JiraRestApi\JiraException;
|
||||
use JiraRestApi\Project\ProjectService;
|
||||
@@ -15,6 +17,8 @@ class JiraService
|
||||
|
||||
private ProjectService $projectService;
|
||||
|
||||
private FieldService $fieldService;
|
||||
|
||||
private array $config;
|
||||
|
||||
public function __construct()
|
||||
@@ -39,6 +43,7 @@ class JiraService
|
||||
|
||||
$this->issueService = new IssueService($clientConfig);
|
||||
$this->projectService = new ProjectService($clientConfig);
|
||||
$this->fieldService = new FieldService($clientConfig);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -167,6 +172,452 @@ class JiraService
|
||||
return collect();
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* 获取 Jira Sprint 下拉选项(从最近 Story 的 Sprint 字段中提取)
|
||||
*/
|
||||
public function getTestMailSprintOptions(): Collection
|
||||
{
|
||||
$jql = 'project in (WP, AM, TP) AND issuetype = Story AND Sprint is not EMPTY ORDER BY updated DESC';
|
||||
|
||||
try {
|
||||
$result = $this->issueService->search($jql, 0, 80, ['summary', 'customfield_10004']);
|
||||
$options = collect();
|
||||
|
||||
foreach (($result->issues ?? []) as $issue) {
|
||||
foreach ($this->extractSprintOptionsFromIssue($issue) as $option) {
|
||||
$key = $option['id'] ?: $option['name'];
|
||||
if (! $options->has($key)) {
|
||||
$options->put($key, $option);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $options->values()->sortByDesc(function ($option) {
|
||||
return (int) preg_replace('/\D+/', '', (string) ($option['id'] ?: $option['name']));
|
||||
})->values();
|
||||
} catch (JiraException) {
|
||||
return collect();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提测邮件需要的 Sprint 需求列表
|
||||
*/
|
||||
public function getSprintTestIssues(string $sprint): Collection
|
||||
{
|
||||
$sprint = trim($sprint);
|
||||
if ($sprint === '') {
|
||||
throw new \InvalidArgumentException('Sprint 不能为空');
|
||||
}
|
||||
|
||||
$jql = sprintf(
|
||||
'project in (WP, AM, TP) AND issuetype = Story AND status in (开发中, 测试中, 需求调研中, 需求已调研, 需求已评审, 已提测, 待上线, 需求已排期, 待提测, 产品验收) AND Sprint = %s ORDER BY priority DESC, cf[10004] ASC, key ASC',
|
||||
$sprint
|
||||
);
|
||||
|
||||
$dynamicFields = $this->resolveTestMailDynamicFieldIds();
|
||||
$fields = array_values(array_unique(array_filter(array_merge([
|
||||
'summary',
|
||||
'status',
|
||||
'project',
|
||||
'issuetype',
|
||||
'priority',
|
||||
'assignee',
|
||||
'reporter',
|
||||
'fixVersions',
|
||||
'customfield_10004',
|
||||
'customfield_11000',
|
||||
'customfield_14305',
|
||||
], array_values($dynamicFields)))));
|
||||
|
||||
try {
|
||||
$issues = collect();
|
||||
$startAt = 0;
|
||||
$maxResults = 100;
|
||||
do {
|
||||
$result = $this->issueService->search($jql, $startAt, $maxResults, $fields);
|
||||
$pageIssues = $result->issues ?? [];
|
||||
foreach ($pageIssues as $issue) {
|
||||
$issues->push($this->formatSprintTestIssue($issue, $dynamicFields));
|
||||
}
|
||||
$startAt += $maxResults;
|
||||
$total = $result->total ?? $issues->count();
|
||||
} while ($issues->count() < $total && ! empty($pageIssues));
|
||||
return $issues;
|
||||
} catch (JiraException $e) {
|
||||
throw new \RuntimeException('获取 Sprint 需求失败: '.$e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取提测邮件默认模板数据,来源于 ~/Documents/提测邮件.xlsx 的当前结构
|
||||
*/
|
||||
public function getTestMailTemplateDefaults(): array
|
||||
{
|
||||
$containerGroups = [
|
||||
'agent' => [
|
||||
'label' => 'agent',
|
||||
'system' => 'PP',
|
||||
'project_slug' => 'agent-be',
|
||||
'db_project' => '/home/tradewind/Projects/agent-db-version',
|
||||
'current_version' => $this->resolveTestMailCurrentVersion('agent-be', '2.69.0.0'),
|
||||
'containers' => [
|
||||
['name' => 'agent-be-aplct', 'location' => '中国&美国'],
|
||||
['name' => 'agent-be-web', 'location' => '中国&美国'],
|
||||
['name' => 'agent-fe-web', 'location' => '中国&美国'],
|
||||
],
|
||||
],
|
||||
'portal' => [
|
||||
'label' => 'portal',
|
||||
'system' => 'SP',
|
||||
'project_slug' => 'portal-be',
|
||||
'db_project' => '/home/tradewind/Projects/portal-db-version',
|
||||
'current_version' => $this->resolveTestMailCurrentVersion('portal-be', '2.64.0.0'),
|
||||
'containers' => [
|
||||
['name' => 'portal-be-aplct', 'location' => '法兰克福&中国'],
|
||||
['name' => 'portal-be-web', 'location' => '法兰克福'],
|
||||
['name' => 'portal-fe-web', 'location' => '法兰克福'],
|
||||
],
|
||||
],
|
||||
'portal-ticket' => [
|
||||
'label' => 'portal-ticket',
|
||||
'system' => 'TP',
|
||||
'project_slug' => 'ticket-be',
|
||||
'db_project' => '/home/tradewind/Projects/ticket-db-version',
|
||||
'current_version' => $this->resolveTestMailCurrentVersion('ticket-be', '1.42.0.0'),
|
||||
'containers' => [
|
||||
['name' => 'portal-ticket-be-aplct', 'location' => '法兰克福&中国'],
|
||||
['name' => 'portal-ticket-be-web', 'location' => '法兰克福&中国'],
|
||||
['name' => 'portal-ticket-fe-web', 'location' => '法兰克福&中国'],
|
||||
],
|
||||
],
|
||||
];
|
||||
|
||||
foreach ($containerGroups as &$group) {
|
||||
$group['default_version'] = $this->nextTestMailVersion($group['current_version']);
|
||||
}
|
||||
unset($group);
|
||||
|
||||
return [
|
||||
'container_groups' => [
|
||||
...$containerGroups,
|
||||
],
|
||||
'scripts_template' => [
|
||||
['issue' => '', 'description' => '', 'container' => '', 'script' => '', 'owner' => ''],
|
||||
],
|
||||
'configs_template' => [
|
||||
['issue' => '', 'system' => '', 'key' => '', 'description' => '', 'owner' => ''],
|
||||
],
|
||||
'risks_template' => [
|
||||
['problem' => '', 'impact' => '', 'action' => ''],
|
||||
],
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveTestMailCurrentVersion(string $projectSlug, string $fallback): string
|
||||
{
|
||||
try {
|
||||
$version = Project::query()
|
||||
->where('slug', $projectSlug)
|
||||
->value('git_current_version');
|
||||
} catch (\Throwable) {
|
||||
$version = null;
|
||||
}
|
||||
|
||||
return trim((string) ($version ?: $fallback));
|
||||
}
|
||||
|
||||
private function nextTestMailVersion(string $version): string
|
||||
{
|
||||
$parts = explode('.', trim($version));
|
||||
if (count($parts) < 2 || ! ctype_digit($parts[1])) {
|
||||
return $version;
|
||||
}
|
||||
|
||||
$parts[1] = (string) ((int) $parts[1] + 1);
|
||||
|
||||
return implode('.', $parts);
|
||||
}
|
||||
|
||||
public function buildTestMailDatabases(array $selectedGroups, array $versions): array
|
||||
{
|
||||
$defaults = $this->getTestMailTemplateDefaults();
|
||||
$rows = [];
|
||||
|
||||
foreach ($selectedGroups as $groupKey) {
|
||||
if (! isset($defaults['container_groups'][$groupKey])) {
|
||||
continue;
|
||||
}
|
||||
$group = $defaults['container_groups'][$groupKey];
|
||||
$version = trim((string) ($versions[$groupKey] ?? $group['default_version'] ?? ''));
|
||||
$branch = $version !== '' ? 'release/'.$version : '';
|
||||
$exists = $branch !== '' && $this->gitBranchExists($group['db_project'], $branch);
|
||||
|
||||
$rows[] = [
|
||||
'system' => $group['system'],
|
||||
'has_database' => $exists ? '有' : '无',
|
||||
'branch' => $exists ? basename($group['db_project']).' '.$branch : '',
|
||||
'group' => $groupKey,
|
||||
'version' => $version,
|
||||
];
|
||||
}
|
||||
|
||||
return $rows;
|
||||
}
|
||||
|
||||
private function gitBranchExists(string $repo, string $branch): bool
|
||||
{
|
||||
if (! is_dir($repo)) {
|
||||
return false;
|
||||
}
|
||||
$command = sprintf('cd %s && git show-ref --verify --quiet %s', escapeshellarg($repo), escapeshellarg('refs/heads/'.$branch));
|
||||
exec($command, $output, $code);
|
||||
if ($code === 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$command = sprintf('cd %s && git ls-remote --exit-code --heads origin %s >/dev/null 2>&1', escapeshellarg($repo), escapeshellarg($branch));
|
||||
exec($command, $output, $code);
|
||||
|
||||
return $code === 0;
|
||||
}
|
||||
|
||||
private function formatSprintTestIssue($issue, array $dynamicFields = []): array
|
||||
{
|
||||
$fixVersions = [];
|
||||
if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions)) {
|
||||
foreach ($issue->fields->fixVersions as $version) {
|
||||
if (isset($version->name)) {
|
||||
$fixVersions[] = $version->name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$estimatedTestAtField = $dynamicFields['estimated_test_at'] ?? null;
|
||||
$estimatedReleaseAtField = $dynamicFields['estimated_release_at'] ?? null;
|
||||
$developerOwnerField = $dynamicFields['developer_owner'] ?? null;
|
||||
|
||||
return [
|
||||
'key' => $issue->key,
|
||||
'url' => $this->config['host'].'/browse/'.$issue->key,
|
||||
'summary' => $issue->fields->summary ?? '',
|
||||
'project_key' => $issue->fields->project->key ?? '',
|
||||
'project_name' => $issue->fields->project->name ?? '',
|
||||
'status' => $issue->fields->status->name ?? '',
|
||||
'priority' => $issue->fields->priority->name ?? '',
|
||||
'reporter' => $this->extractReporter($issue),
|
||||
'assignee' => $this->extractAssignee($issue),
|
||||
'developer' => $this->extractDeveloper($issue, $developerOwnerField, true),
|
||||
'requirement_type' => $this->extractRequirementType($issue),
|
||||
'sprint' => $this->extractSprintInfo($issue),
|
||||
'fix_versions' => $fixVersions,
|
||||
'estimated_test_at' => $estimatedTestAtField ? $this->extractCustomFieldText($issue, $estimatedTestAtField) : '',
|
||||
'estimated_release_at' => $estimatedReleaseAtField ? $this->extractCustomFieldText($issue, $estimatedReleaseAtField) : '',
|
||||
];
|
||||
}
|
||||
|
||||
private function resolveTestMailDynamicFieldIds(): array
|
||||
{
|
||||
$fields = [
|
||||
'estimated_test_at' => null,
|
||||
'estimated_release_at' => null,
|
||||
'developer_owner' => null,
|
||||
];
|
||||
|
||||
try {
|
||||
foreach ($this->fieldService->getAllFields() as $field) {
|
||||
$name = $field->name ?? '';
|
||||
$id = $field->id ?? '';
|
||||
if ($id === '') {
|
||||
continue;
|
||||
}
|
||||
if ($fields['estimated_test_at'] === null && $this->isEstimatedTestAtFieldName($name)) {
|
||||
$fields['estimated_test_at'] = $id;
|
||||
}
|
||||
if ($fields['estimated_release_at'] === null && preg_match('/预计.*发布|预计.*上线|发布.*时间|上线.*时间|计划.*发布|计划.*上线/u', $name)) {
|
||||
$fields['estimated_release_at'] = $id;
|
||||
}
|
||||
if ($fields['developer_owner'] === null && $this->isDeveloperOwnerFieldName($name)) {
|
||||
$fields['developer_owner'] = $id;
|
||||
}
|
||||
}
|
||||
} catch (\Exception) {
|
||||
// 字段发现失败时保持为空,页面允许后续手工校验。
|
||||
}
|
||||
|
||||
return array_filter($fields);
|
||||
}
|
||||
|
||||
private function isEstimatedTestAtFieldName(string $name): bool
|
||||
{
|
||||
$normalized = mb_strtolower(preg_replace('/\s+/u', '', $name));
|
||||
|
||||
if (preg_match('/实际|完成/u', $normalized)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return preg_match('/预计.*提测|计划.*提测/u', $normalized) === 1;
|
||||
}
|
||||
|
||||
private function isDeveloperOwnerFieldName(string $name): bool
|
||||
{
|
||||
$normalized = mb_strtolower(preg_replace('/\s+/u', '', $name));
|
||||
|
||||
return preg_match('/研发owner|开发owner|rdowner|研发负责人|开发负责人/u', $normalized) === 1;
|
||||
}
|
||||
|
||||
public function resolveTestMailSprintPeriod(string $sprint, array|Collection $issues = []): ?string
|
||||
{
|
||||
$candidates = [$sprint];
|
||||
|
||||
foreach ($issues instanceof Collection ? $issues->all() : $issues as $issue) {
|
||||
if (is_array($issue)) {
|
||||
$candidates[] = $issue['sprint'] ?? '';
|
||||
} elseif (is_object($issue)) {
|
||||
$candidates[] = $issue->sprint ?? '';
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($candidates as $candidate) {
|
||||
$period = $this->normalizeTestMailSprintPeriod((string) $candidate);
|
||||
if ($period !== null) {
|
||||
return $period;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function normalizeTestMailSprintPeriod(string $value): ?string
|
||||
{
|
||||
$value = trim($value);
|
||||
if ($value === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (preg_match('/[A-Z]+(\d{4})(中|底)迭代/iu', $value, $matches)) {
|
||||
return sprintf('Sprint%s月%s', $matches[1], $matches[2]);
|
||||
}
|
||||
|
||||
if (preg_match('/(?:Sprint\s*)?(\d{4})\s*月\s*(中|底)/iu', $value, $matches)) {
|
||||
return sprintf('Sprint%s月%s', $matches[1], $matches[2]);
|
||||
}
|
||||
|
||||
if (preg_match('/(?:20)?(\d{2})\s*年\s*0?([1-9]|1[0-2])\s*月\s*(中|底)/u', $value, $matches)) {
|
||||
return sprintf('Sprint%s%02d月%s', $matches[1], (int) $matches[2], $matches[3]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractSprintOptionsFromIssue($issue): array
|
||||
{
|
||||
$value = $issue->fields->customFields['customfield_10004'] ?? null;
|
||||
$items = is_array($value) ? $value : ($value ? [$value] : []);
|
||||
$options = [];
|
||||
|
||||
foreach ($items as $item) {
|
||||
if (is_string($item)) {
|
||||
$name = null;
|
||||
$id = null;
|
||||
if (preg_match('/name=([^,\]]+)/', $item, $m)) {
|
||||
$name = $m[1];
|
||||
}
|
||||
if (preg_match('/(?:id|sequence)=([0-9]+)/', $item, $m)) {
|
||||
$id = $m[1];
|
||||
}
|
||||
if ($name || $id) {
|
||||
$options[] = [
|
||||
'id' => $id ?: $name,
|
||||
'name' => $name ?: $id,
|
||||
'label' => ($id ? $id.' - ' : '').($name ?: $id),
|
||||
'period' => $name ? $this->normalizeTestMailSprintPeriod($name) : null,
|
||||
];
|
||||
}
|
||||
} elseif (is_object($item)) {
|
||||
$id = isset($item->id) ? (string) $item->id : (isset($item->sequence) ? (string) $item->sequence : '');
|
||||
$name = $item->name ?? $id;
|
||||
if ($name || $id) {
|
||||
$options[] = [
|
||||
'id' => $id ?: $name,
|
||||
'name' => $name ?: $id,
|
||||
'label' => ($id ? $id.' - ' : '').($name ?: $id),
|
||||
'period' => $this->normalizeTestMailSprintPeriod((string) $name),
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $options;
|
||||
}
|
||||
|
||||
private function extractReporter($issue): ?string
|
||||
{
|
||||
if (isset($issue->fields->reporter)) {
|
||||
return $this->extractUserValue($issue->fields->reporter);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractCustomFieldText($issue, string $fieldKey): string
|
||||
{
|
||||
if (! isset($issue->fields->customFields[$fieldKey])) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$value = $issue->fields->customFields[$fieldKey];
|
||||
if ($value === null) {
|
||||
return '';
|
||||
}
|
||||
if (is_scalar($value)) {
|
||||
return (string) $value;
|
||||
}
|
||||
if (is_object($value)) {
|
||||
if (isset($value->value)) {
|
||||
return (string) $value->value;
|
||||
}
|
||||
if (isset($value->name)) {
|
||||
return (string) $value->name;
|
||||
}
|
||||
if (isset($value->displayName)) {
|
||||
return (string) $value->displayName;
|
||||
}
|
||||
}
|
||||
if (is_array($value)) {
|
||||
return collect($value)->map(function ($item) {
|
||||
if (is_scalar($item)) {
|
||||
return (string) $item;
|
||||
}
|
||||
if (is_object($item)) {
|
||||
return $item->value ?? $item->name ?? $item->displayName ?? '';
|
||||
}
|
||||
if (is_array($item)) {
|
||||
return $item['value'] ?? $item['name'] ?? $item['displayName'] ?? '';
|
||||
}
|
||||
return '';
|
||||
})->filter()->implode(', ');
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
private function extractUserValue($value): ?string
|
||||
{
|
||||
if (is_object($value)) {
|
||||
return $value->displayName ?? $value->name ?? $value->key ?? null;
|
||||
}
|
||||
if (is_array($value)) {
|
||||
return $value['displayName'] ?? $value['name'] ?? $value['key'] ?? null;
|
||||
}
|
||||
if (is_string($value) && $value !== '') {
|
||||
return $value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
/**
|
||||
* 生成 Markdown 格式的周报
|
||||
*/
|
||||
@@ -683,10 +1134,22 @@ class JiraService
|
||||
/**
|
||||
* 提取开发人
|
||||
*/
|
||||
private function extractDeveloper($issue): ?string
|
||||
private function extractDeveloper($issue, ?string $developerOwnerField = null, bool $includePeopleFallback = false): ?string
|
||||
{
|
||||
if ($developerOwnerField) {
|
||||
$developer = $this->extractUserFieldName($issue, $developerOwnerField);
|
||||
if ($developer) {
|
||||
return $developer;
|
||||
}
|
||||
}
|
||||
|
||||
// 从customfield_11000获取开发人(User 类型字段返回对象,兼容字符串/数组)
|
||||
return $this->extractUserFieldName($issue, 'customfield_11000');
|
||||
$developer = $this->extractUserFieldName($issue, 'customfield_11000');
|
||||
if ($developer || ! $includePeopleFallback) {
|
||||
return $developer;
|
||||
}
|
||||
|
||||
return $this->extractAssignee($issue) ?: $this->extractReporter($issue);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -710,12 +1173,16 @@ class JiraService
|
||||
$value = $issue->fields->customFields[$fieldKey];
|
||||
|
||||
if (is_object($value)) {
|
||||
return $value->name ?? $value->key ?? null;
|
||||
return $value->displayName ?? $value->name ?? $value->key ?? null;
|
||||
}
|
||||
|
||||
if (is_array($value)) {
|
||||
if (array_is_list($value)) {
|
||||
return collect($value)->map(fn ($item) => $this->extractUserValue($item))->filter()->implode(', ') ?: null;
|
||||
}
|
||||
|
||||
// JIRA 用户字段以关联数组形式返回时
|
||||
return $value['name'] ?? $value['key'] ?? null;
|
||||
return $value['displayName'] ?? $value['name'] ?? $value['key'] ?? null;
|
||||
}
|
||||
|
||||
if (is_string($value) && $value !== '') {
|
||||
|
||||
Reference in New Issue
Block a user