1492 lines
57 KiB
PHP
1492 lines
57 KiB
PHP
<?php
|
||
|
||
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;
|
||
|
||
class JiraService
|
||
{
|
||
private IssueService $issueService;
|
||
|
||
private ProjectService $projectService;
|
||
|
||
private FieldService $fieldService;
|
||
|
||
private array $config;
|
||
|
||
public function __construct()
|
||
{
|
||
$this->config = config('jira');
|
||
$this->initializeJiraClient();
|
||
}
|
||
|
||
/**
|
||
* @throws JiraException
|
||
*/
|
||
private function initializeJiraClient(): void
|
||
{
|
||
$clientConfig = new ArrayConfiguration([
|
||
'jiraHost' => $this->config['host'],
|
||
'jiraUser' => $this->config['username'],
|
||
'jiraPassword' => $this->config['auth_type'] === 'token'
|
||
? $this->config['api_token']
|
||
: $this->config['password'],
|
||
'timeout' => $this->config['timeout'],
|
||
]);
|
||
|
||
$this->issueService = new IssueService($clientConfig);
|
||
$this->projectService = new ProjectService($clientConfig);
|
||
$this->fieldService = new FieldService($clientConfig);
|
||
}
|
||
|
||
/**
|
||
* 按项目组织任务数据
|
||
*/
|
||
private function organizeIssuesByProject(array $issues): Collection
|
||
{
|
||
$organized = collect();
|
||
|
||
foreach ($issues as $issue) {
|
||
$projectKey = $issue->fields->project->key;
|
||
$isSubtask = $issue->fields->issuetype->subtask ?? false;
|
||
|
||
if (! $organized->has($projectKey)) {
|
||
$organized->put($projectKey, [
|
||
'name' => $issue->fields->project->name,
|
||
'tasks' => collect(),
|
||
]);
|
||
}
|
||
|
||
if ($isSubtask && isset($issue->fields->parent)) {
|
||
// 子任务
|
||
$parentKey = $issue->fields->parent->key;
|
||
$this->addSubtask($organized[$projectKey]['tasks'], $parentKey, $issue);
|
||
} else {
|
||
// 主任务
|
||
$this->addMainTask($organized[$projectKey]['tasks'], $issue);
|
||
}
|
||
}
|
||
|
||
return $organized;
|
||
}
|
||
|
||
/**
|
||
* 获取单个任务的详细信息
|
||
*/
|
||
private function getIssueDetails(string $issueKey): ?object
|
||
{
|
||
try {
|
||
return $this->issueService->get($issueKey, [
|
||
'summary',
|
||
'status',
|
||
'project',
|
||
'issuetype',
|
||
]);
|
||
} catch (JiraException) {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
private function addMainTask(Collection $tasks, $issue): void
|
||
{
|
||
$tasks->put($issue->key, [
|
||
'key' => $issue->key,
|
||
'summary' => $issue->fields->summary,
|
||
'url' => $this->config['host'].'/browse/'.$issue->key,
|
||
'subtasks' => collect(),
|
||
]);
|
||
}
|
||
|
||
private function addSubtask(Collection $tasks, string $parentKey, $issue): void
|
||
{
|
||
if (! $tasks->has($parentKey)) {
|
||
// 获取父任务的真实信息
|
||
$parentDetails = $this->getIssueDetails($parentKey);
|
||
$parentSummary = $parentDetails ? $parentDetails->fields->summary : '父任务';
|
||
|
||
$tasks->put($parentKey, [
|
||
'key' => $parentKey,
|
||
'summary' => $parentSummary,
|
||
'url' => $this->config['host'].'/browse/'.$parentKey,
|
||
'subtasks' => collect(),
|
||
]);
|
||
}
|
||
|
||
$tasks[$parentKey]['subtasks']->put($issue->key, [
|
||
'key' => $issue->key,
|
||
'summary' => $issue->fields->summary,
|
||
'url' => $this->config['host'].'/browse/'.$issue->key,
|
||
'created' => $issue->fields->created ?? null,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* 获取未来一周的任务
|
||
*/
|
||
public function getNextWeekTasks(?string $username = null): Collection
|
||
{
|
||
$username = $username ?: $this->config['default_user'];
|
||
|
||
if (! $username) {
|
||
throw new \InvalidArgumentException('用户名不能为空');
|
||
}
|
||
|
||
// 定义已完成的状态列表(需要排除的状态)
|
||
$completedStatuses = [
|
||
'Done',
|
||
'已上线',
|
||
'已完成',
|
||
];
|
||
$statusExclusion = implode('", "', $completedStatuses);
|
||
|
||
// 查询分配给用户且未完成的需求(Story/需求类型,不包括子任务)
|
||
$jql = sprintf(
|
||
'assignee = "%s" AND status NOT IN ("%s") AND issuetype in ("Story", "需求") ORDER BY created ASC',
|
||
$username,
|
||
$statusExclusion
|
||
);
|
||
|
||
try {
|
||
$issues = $this->issueService->search($jql, 0, 50, [
|
||
'summary',
|
||
'status',
|
||
'project',
|
||
'issuetype',
|
||
'created',
|
||
]);
|
||
|
||
if (! empty($issues->issues)) {
|
||
return $this->organizeIssuesByProject($issues->issues);
|
||
}
|
||
} catch (JiraException $e) {
|
||
throw new \RuntimeException('获取未来任务失败: '.$e->getMessage());
|
||
}
|
||
|
||
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->name ?? $value->key ?? $value->displayName ?? null;
|
||
}
|
||
if (is_array($value)) {
|
||
return $value['name'] ?? $value['key'] ?? $value['displayName'] ?? null;
|
||
}
|
||
if (is_string($value) && $value !== '') {
|
||
return $value;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
/**
|
||
* 生成 Markdown 格式的周报
|
||
*/
|
||
public function generateWeeklyReport(?string $username = null, string $period = 'this_week'): string
|
||
{
|
||
$username = $username ?: $this->config['default_user'];
|
||
|
||
$reportPeriod = $this->resolveWeeklyReportRange($period);
|
||
|
||
$workLogs = $this->getWorkLogs($username, $reportPeriod['start'], $reportPeriod['end']);
|
||
$organizedTasks = $this->organizeTasksForReport($workLogs, $username);
|
||
|
||
$nextWeekTasks = $this->getNextWeekTasks($username);
|
||
|
||
$markdown = "# {$reportPeriod['title']}\n\n";
|
||
|
||
if ($organizedTasks->isEmpty()) {
|
||
$markdown .= "{$reportPeriod['empty_message']}\n\n";
|
||
} else {
|
||
// 按Sprint分类的需求
|
||
if ($organizedTasks->has('sprints') && $organizedTasks['sprints']->isNotEmpty()) {
|
||
$sortedSprints = $organizedTasks['sprints']->sortKeysUsing(
|
||
static fn ($left, $right) => strnatcasecmp((string) $left, (string) $right)
|
||
);
|
||
foreach ($sortedSprints as $sprintName => $tasks) {
|
||
$markdown .= "### {$sprintName}\n";
|
||
foreach ($tasks as $task) {
|
||
$checkbox = $this->isTaskCompleted($task['status']) ? '[x]' : '[ ]';
|
||
$markdown .= "- {$checkbox} [{$task['key']}]({$task['url']}) {$task['summary']}\n";
|
||
|
||
if ($task['subtasks']->isNotEmpty()) {
|
||
// 按创建时间排序子任务
|
||
$sortedSubtasks = $task['subtasks']->sortBy('created');
|
||
foreach ($sortedSubtasks as $subtask) {
|
||
$subtaskCheckbox = $this->isTaskCompleted($subtask['status']) ? '[x]' : '[ ]';
|
||
$markdown .= " - {$subtaskCheckbox} [{$subtask['key']}]({$subtask['url']}) {$subtask['summary']}\n";
|
||
}
|
||
}
|
||
}
|
||
$markdown .= "\n";
|
||
}
|
||
}
|
||
|
||
// 没有Sprint的需求
|
||
if ($organizedTasks->has('stories') && $organizedTasks['stories']->isNotEmpty()) {
|
||
$markdown .= "### 需求\n";
|
||
foreach ($organizedTasks['stories'] as $task) {
|
||
$checkbox = $this->isTaskCompleted($task['status']) ? '[x]' : '[ ]';
|
||
$markdown .= "- {$checkbox} [{$task['key']}]({$task['url']}) {$task['summary']}\n";
|
||
|
||
if ($task['subtasks']->isNotEmpty()) {
|
||
// 按创建时间排序子任务
|
||
$sortedSubtasks = $task['subtasks']->sortBy('created');
|
||
foreach ($sortedSubtasks as $subtask) {
|
||
$subtaskCheckbox = $this->isTaskCompleted($subtask['status']) ? '[x]' : '[ ]';
|
||
$markdown .= " - {$subtaskCheckbox} [{$subtask['key']}]({$subtask['url']}) {$subtask['summary']}\n";
|
||
}
|
||
}
|
||
}
|
||
$markdown .= "\n";
|
||
}
|
||
|
||
// 单独列出的任务
|
||
if ($organizedTasks->has('tasks') && $organizedTasks['tasks']->isNotEmpty()) {
|
||
$markdown .= "### 任务\n";
|
||
foreach ($organizedTasks['tasks'] as $task) {
|
||
$checkbox = $this->isTaskCompleted($task['status']) ? '[x]' : '[ ]';
|
||
$markdown .= "- {$checkbox} [{$task['key']}]({$task['url']}) {$task['summary']}\n";
|
||
|
||
if ($task['subtasks']->isNotEmpty()) {
|
||
// 按创建时间排序子任务
|
||
$sortedSubtasks = $task['subtasks']->sortBy('created');
|
||
foreach ($sortedSubtasks as $subtask) {
|
||
$subtaskCheckbox = $this->isTaskCompleted($subtask['status']) ? '[x]' : '[ ]';
|
||
$markdown .= " - {$subtaskCheckbox} [{$subtask['key']}]({$subtask['url']}) {$subtask['summary']}\n";
|
||
}
|
||
}
|
||
}
|
||
$markdown .= "\n";
|
||
}
|
||
|
||
// 按发现阶段分类的Bug
|
||
if ($organizedTasks->has('bugs') && $organizedTasks['bugs']->isNotEmpty()) {
|
||
foreach ($organizedTasks['bugs'] as $stage => $bugs) {
|
||
$markdown .= "### {$stage}\n";
|
||
|
||
// 按父任务分组Bug
|
||
$groupedBugs = collect($bugs)->groupBy(function ($bug) {
|
||
return $bug['parent_key'] ?? 'standalone';
|
||
});
|
||
|
||
foreach ($groupedBugs as $parentKey => $bugGroup) {
|
||
if ($parentKey === 'standalone') {
|
||
// 独立的Bug
|
||
foreach ($bugGroup as $bug) {
|
||
$checkbox = $this->isTaskCompleted($bug['status']) ? '[x]' : '[ ]';
|
||
$summary = $this->cleanSummary($bug['summary']);
|
||
// 只标记非代码错误的Bug类型,并附加修复描述
|
||
$bugTypeLabel = '';
|
||
if ($bug['bug_type'] && $bug['bug_type'] !== '代码错误') {
|
||
$bugTypeLabel = "\n - {$bug['bug_type']}";
|
||
if ($bug['bug_description']) {
|
||
$bugTypeLabel .= ";{$bug['bug_description']}";
|
||
}
|
||
}
|
||
$markdown .= "- {$checkbox} [{$bug['key']}]({$bug['url']}) {$summary}{$bugTypeLabel}\n";
|
||
}
|
||
} else {
|
||
// 有父任务的Bug
|
||
$firstBug = $bugGroup->first();
|
||
$markdown .= "- [x] {$firstBug['parent_summary']}\n";
|
||
foreach ($bugGroup as $bug) {
|
||
$checkbox = $this->isTaskCompleted($bug['status']) ? '[x]' : '[ ]';
|
||
$summary = $this->cleanSummary($bug['summary']);
|
||
// 只标记非代码错误的Bug类型,并附加修复描述
|
||
$bugTypeLabel = '';
|
||
if ($bug['bug_type'] && $bug['bug_type'] !== '代码错误') {
|
||
$bugTypeLabel = "\n - {$bug['bug_type']}";
|
||
if ($bug['bug_description']) {
|
||
$bugTypeLabel .= ";{$bug['bug_description']}";
|
||
}
|
||
}
|
||
$markdown .= " - {$checkbox} [{$bug['key']}]({$bug['url']}) {$summary}{$bugTypeLabel}\n";
|
||
}
|
||
}
|
||
}
|
||
$markdown .= "\n";
|
||
}
|
||
}
|
||
}
|
||
|
||
$markdown .= "\n# 未来一周的任务\n\n";
|
||
|
||
foreach ($nextWeekTasks as $project) {
|
||
foreach ($project['tasks'] as $task) {
|
||
$markdown .= "- [ ] [{$task['key']}]({$task['url']}) {$task['summary']}\n";
|
||
}
|
||
}
|
||
|
||
return $markdown;
|
||
}
|
||
|
||
/**
|
||
* 解析周报统计周期
|
||
*
|
||
* @return array{start: Carbon, end: Carbon, title: string, empty_message: string}
|
||
*/
|
||
private function resolveWeeklyReportRange(string $period): array
|
||
{
|
||
$normalizedPeriod = $period === 'this_week' ? 'this_week' : 'last_week';
|
||
$now = Carbon::now();
|
||
|
||
if ($normalizedPeriod === 'this_week') {
|
||
return [
|
||
'start' => $now->copy()->startOfWeek(),
|
||
'end' => $now->copy()->endOfDay(),
|
||
'title' => '本周完成的任务',
|
||
'empty_message' => '本周暂无工时记录的任务。',
|
||
];
|
||
}
|
||
|
||
return [
|
||
'start' => $now->copy()->subWeek()->startOfWeek(),
|
||
'end' => $now->copy()->subWeek()->endOfWeek(),
|
||
'title' => '上周完成的任务',
|
||
'empty_message' => '上周暂无工时记录的任务。',
|
||
];
|
||
}
|
||
|
||
/**
|
||
* 获取指定日期范围内的工时记录
|
||
*/
|
||
public function getWorkLogs(string $username, Carbon $startDate, Carbon $endDate): Collection
|
||
{
|
||
// 标准工时查询 - 注意:某些JIRA版本可能不支持worklogAuthor和worklogDate
|
||
$jql = sprintf(
|
||
'worklogAuthor = "%s" AND worklogDate >= "%s" AND worklogDate <= "%s" ORDER BY updated DESC',
|
||
$username,
|
||
$startDate->format('Y-m-d'),
|
||
$endDate->format('Y-m-d')
|
||
);
|
||
|
||
try {
|
||
$issues = $this->issueService->search($jql, 0, 100, [
|
||
'summary',
|
||
'project',
|
||
'worklog',
|
||
'parent',
|
||
'issuetype',
|
||
'status',
|
||
'created',
|
||
'fixVersions',
|
||
'labels',
|
||
'assignee', // 经办人
|
||
'customfield_10004', // Sprint字段
|
||
'customfield_10900', // Bug发现阶段
|
||
'customfield_11000', // 开发人
|
||
'customfield_11301', // 实际修复人
|
||
'customfield_12700', // Bug错误类型
|
||
'customfield_10115', // Bug修复描述
|
||
'customfield_14305', // 需求类型
|
||
]);
|
||
|
||
if (! empty($issues->issues)) {
|
||
$workLogs = $this->extractWorkLogs($issues->issues, $username, $startDate, $endDate);
|
||
|
||
if ($workLogs->isNotEmpty()) {
|
||
return $workLogs;
|
||
}
|
||
}
|
||
} catch (JiraException $e) {
|
||
throw new \RuntimeException('获取工时记录失败: '.$e->getMessage());
|
||
}
|
||
|
||
// 如果所有查询都没有结果,返回空集合
|
||
return collect();
|
||
}
|
||
|
||
/**
|
||
* 提取工时记录
|
||
*/
|
||
private function extractWorkLogs(array $issues, string $username, Carbon $startDate, Carbon $endDate): Collection
|
||
{
|
||
$workLogs = collect();
|
||
|
||
foreach ($issues as $issue) {
|
||
try {
|
||
$worklogData = $this->issueService->getWorklog($issue->key);
|
||
|
||
foreach ($worklogData->worklogs as $worklog) {
|
||
try {
|
||
$worklogDate = Carbon::parse($worklog->started);
|
||
|
||
// 处理 author 可能是数组或对象的情况
|
||
$authorName = is_array($worklog->author) ? ($worklog->author['name'] ?? '') : ($worklog->author->name ?? '');
|
||
|
||
if (! empty($authorName) && $authorName === $username &&
|
||
$worklogDate->between($startDate, $endDate)) {
|
||
|
||
// 获取父任务信息
|
||
$parentTask = null;
|
||
if (isset($issue->fields->parent)) {
|
||
$parentTask = [
|
||
'key' => $issue->fields->parent->key ?? '',
|
||
'summary' => $issue->fields->parent->fields->summary ?? '',
|
||
];
|
||
}
|
||
|
||
// 提取Sprint信息
|
||
$sprint = $this->extractSprintInfo($issue);
|
||
|
||
// 提取Bug相关信息
|
||
$bugStage = $this->extractBugStage($issue);
|
||
$bugType = $this->extractBugType($issue);
|
||
$bugDescription = $this->extractBugDescription($issue);
|
||
|
||
// 提取需求类型
|
||
$requirementType = $this->extractRequirementType($issue);
|
||
|
||
// 提取经办人、开发人、实际修复人
|
||
$assignee = $this->extractAssignee($issue);
|
||
$developer = $this->extractDeveloper($issue);
|
||
$actualFixer = $this->extractActualFixer($issue);
|
||
|
||
$workLogs->push([
|
||
'id' => $worklog->id ?? '',
|
||
'project' => $issue->fields->project->name ?? '',
|
||
'project_key' => $issue->fields->project->key ?? '',
|
||
'issue_key' => $issue->key,
|
||
'issue_summary' => $issue->fields->summary ?? '',
|
||
'issue_url' => $this->config['host'].'/browse/'.$issue->key,
|
||
'issue_status' => $issue->fields->status->name ?? 'Unknown',
|
||
'issue_type' => $issue->fields->issuetype->name ?? 'Unknown',
|
||
'issue_created' => $issue->fields->created ?? null,
|
||
'parent_task' => $parentTask,
|
||
'sprint' => $sprint,
|
||
'bug_stage' => $bugStage,
|
||
'bug_type' => $bugType,
|
||
'bug_description' => $bugDescription,
|
||
'requirement_type' => $requirementType,
|
||
'assignee' => $assignee,
|
||
'developer' => $developer,
|
||
'actual_fixer' => $actualFixer,
|
||
'date' => $worklogDate->format('Y-m-d'),
|
||
'time' => $worklogDate->format('H:i'),
|
||
'hours' => round(($worklog->timeSpentSeconds ?? 0) / 3600, 2),
|
||
'time_spent_seconds' => $worklog->timeSpentSeconds ?? 0,
|
||
'time_spent' => $worklog->timeSpent ?? '',
|
||
'comment' => $worklog->comment ?? '',
|
||
'author' => [
|
||
'name' => $authorName,
|
||
'display_name' => is_array($worklog->author) ? ($worklog->author['displayName'] ?? '') : ($worklog->author->displayName ?? ''),
|
||
'email' => is_array($worklog->author) ? ($worklog->author['emailAddress'] ?? '') : ($worklog->author->emailAddress ?? ''),
|
||
],
|
||
'created' => isset($worklog->created) ? Carbon::parse($worklog->created)->format('Y-m-d H:i:s') : '',
|
||
'updated' => isset($worklog->updated) ? Carbon::parse($worklog->updated)->format('Y-m-d H:i:s') : '',
|
||
'started' => $worklogDate->format('Y-m-d H:i:s'),
|
||
]);
|
||
}
|
||
} catch (\Exception) {
|
||
// 跳过有问题的工时记录
|
||
continue;
|
||
}
|
||
}
|
||
} catch (JiraException) {
|
||
// 跳过无法获取工时记录的任务
|
||
continue;
|
||
}
|
||
}
|
||
|
||
return $workLogs->sortByDesc('date');
|
||
}
|
||
|
||
/**
|
||
* 提取Sprint信息
|
||
*/
|
||
private function extractSprintInfo($issue): ?string
|
||
{
|
||
// 尝试从customfield_10004获取Sprint信息
|
||
if (isset($issue->fields->customFields['customfield_10004'])) {
|
||
$sprintField = $issue->fields->customFields['customfield_10004'];
|
||
|
||
// 处理数组情况
|
||
if (is_array($sprintField) && ! empty($sprintField)) {
|
||
$lastSprint = end($sprintField);
|
||
if (is_string($lastSprint)) {
|
||
// 解析Sprint字符串,格式通常为: com.atlassian.greenhopper.service.sprint.Sprint@xxx[name=十月中需求,...]
|
||
if (preg_match('/name=([^,\]]+)/', $lastSprint, $matches)) {
|
||
return $matches[1];
|
||
}
|
||
} elseif (is_object($lastSprint) && isset($lastSprint->name)) {
|
||
return $lastSprint->name;
|
||
}
|
||
}
|
||
|
||
// 处理对象情况
|
||
if (is_object($sprintField) && isset($sprintField->name)) {
|
||
return $sprintField->name;
|
||
}
|
||
|
||
// 处理字符串情况
|
||
if (is_string($sprintField)) {
|
||
if (preg_match('/name=([^,\]]+)/', $sprintField, $matches)) {
|
||
return $matches[1];
|
||
}
|
||
|
||
// 如果是纯文本,直接返回
|
||
return $sprintField;
|
||
}
|
||
}
|
||
|
||
// 尝试从fixVersions获取版本信息作为备选
|
||
if (isset($issue->fields->fixVersions) && is_array($issue->fields->fixVersions) && ! empty($issue->fields->fixVersions)) {
|
||
return $issue->fields->fixVersions[0]->name ?? null;
|
||
}
|
||
|
||
// 尝试从summary中提取Sprint信息(如果summary包含【十月中需求】这样的标记)
|
||
if (isset($issue->fields->summary)) {
|
||
$summary = $issue->fields->summary;
|
||
// 匹配【xxx需求】或【xxx】格式
|
||
if (preg_match('/【([^】]*需求)】/', $summary, $matches)) {
|
||
return $matches[1];
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 提取Bug发现阶段
|
||
*/
|
||
private function extractBugStage($issue): ?string
|
||
{
|
||
// 从customfield_10900获取Bug阶段
|
||
if (isset($issue->fields->customFields['customfield_10900'])) {
|
||
$stage = $issue->fields->customFields['customfield_10900'];
|
||
|
||
// 处理对象类型
|
||
if (is_object($stage) && isset($stage->value)) {
|
||
$stageValue = $stage->value;
|
||
} elseif (is_string($stage)) {
|
||
$stageValue = $stage;
|
||
} else {
|
||
$stageValue = null;
|
||
}
|
||
|
||
if ($stageValue && ! empty($stageValue)) {
|
||
// 标准化阶段名称
|
||
if (str_contains($stageValue, 'SIT') || str_contains($stageValue, 'sit') || $stageValue === '测试阶段') {
|
||
return 'SIT环境BUG';
|
||
}
|
||
if (str_contains($stageValue, '生产') || str_contains($stageValue, 'PROD') || str_contains($stageValue, 'prod') || $stageValue === '生产环境') {
|
||
return '生产环境BUG';
|
||
}
|
||
if (str_contains($stageValue, 'UAT') || str_contains($stageValue, 'uat')) {
|
||
return 'UAT环境BUG';
|
||
}
|
||
|
||
// 如果不匹配标准格式,直接返回原值
|
||
return $stageValue.'BUG';
|
||
}
|
||
}
|
||
|
||
// 从labels中提取Bug阶段
|
||
if (isset($issue->fields->labels) && is_array($issue->fields->labels)) {
|
||
foreach ($issue->fields->labels as $label) {
|
||
if (str_contains($label, 'SIT') || str_contains($label, 'sit')) {
|
||
return 'SIT环境BUG';
|
||
}
|
||
if (str_contains($label, '生产') || str_contains($label, 'PROD') || str_contains($label, 'prod')) {
|
||
return '生产环境BUG';
|
||
}
|
||
if (str_contains($label, 'UAT') || str_contains($label, 'uat')) {
|
||
return 'UAT环境BUG';
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 提取Bug错误类型
|
||
*/
|
||
private function extractBugType($issue): ?string
|
||
{
|
||
// 从customfield_12700获取Bug类型
|
||
if (isset($issue->fields->customFields['customfield_12700'])) {
|
||
$type = $issue->fields->customFields['customfield_12700'];
|
||
|
||
// 处理对象类型
|
||
if (is_object($type) && isset($type->value)) {
|
||
return $type->value;
|
||
} elseif (is_string($type) && ! empty($type)) {
|
||
return $type;
|
||
}
|
||
}
|
||
|
||
// 从labels中提取Bug类型
|
||
if (isset($issue->fields->labels) && is_array($issue->fields->labels)) {
|
||
$bugTypes = ['需求未说明', '沟通问题', '接口文档未说明', '数据问题', '配置问题', '环境问题'];
|
||
foreach ($issue->fields->labels as $label) {
|
||
foreach ($bugTypes as $bugType) {
|
||
if (str_contains($label, $bugType)) {
|
||
return $bugType;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 提取Bug修复描述
|
||
*/
|
||
private function extractBugDescription($issue): ?string
|
||
{
|
||
// 从customfield_10115获取Bug修复描述
|
||
if (isset($issue->fields->customFields['customfield_10115'])) {
|
||
$description = $issue->fields->customFields['customfield_10115'];
|
||
|
||
if (is_string($description) && ! empty($description)) {
|
||
return $description;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 提取需求类型
|
||
*/
|
||
private function extractRequirementType($issue): ?string
|
||
{
|
||
// 从customfield_14305获取需求类型
|
||
if (isset($issue->fields->customFields['customfield_14305'])) {
|
||
$type = $issue->fields->customFields['customfield_14305'];
|
||
if (is_array($type) && ! empty($type)) {
|
||
$firstType = $type[0];
|
||
if (is_object($firstType) && isset($firstType->value)) {
|
||
return $firstType->value;
|
||
}
|
||
} elseif (is_object($type) && isset($type->value)) {
|
||
return $type->value;
|
||
} elseif (is_string($type)) {
|
||
return $type;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 提取经办人
|
||
*/
|
||
private function extractAssignee($issue): ?string
|
||
{
|
||
// 从assignee字段获取经办人
|
||
if (isset($issue->fields->assignee)) {
|
||
$assignee = $issue->fields->assignee;
|
||
|
||
// 处理对象类型
|
||
if (is_object($assignee)) {
|
||
return $assignee->name ?? $assignee->key ?? null;
|
||
} elseif (is_string($assignee)) {
|
||
return $assignee;
|
||
}
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 提取开发人
|
||
*/
|
||
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 类型字段返回对象,兼容字符串/数组)
|
||
$developer = $this->extractUserFieldName($issue, 'customfield_11000');
|
||
if ($developer || ! $includePeopleFallback) {
|
||
return $developer;
|
||
}
|
||
|
||
return $this->extractAssignee($issue) ?: $this->extractReporter($issue);
|
||
}
|
||
|
||
/**
|
||
* 提取实际修复人
|
||
*/
|
||
private function extractActualFixer($issue): ?string
|
||
{
|
||
// 从customfield_11301获取实际修复人(User 类型字段返回对象,兼容字符串/数组)
|
||
return $this->extractUserFieldName($issue, 'customfield_11301');
|
||
}
|
||
|
||
/**
|
||
* 通用 User 类型自定义字段解析:兼容对象、关联数组、字符串
|
||
*/
|
||
private function extractUserFieldName($issue, string $fieldKey): ?string
|
||
{
|
||
if (! isset($issue->fields->customFields[$fieldKey])) {
|
||
return null;
|
||
}
|
||
|
||
$value = $issue->fields->customFields[$fieldKey];
|
||
|
||
if (is_object($value)) {
|
||
return $value->name ?? $value->key ?? $value->displayName ?? 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'] ?? $value['displayName'] ?? null;
|
||
}
|
||
|
||
if (is_string($value) && $value !== '') {
|
||
return $value;
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
/**
|
||
* 清理摘要中的图片链接
|
||
*/
|
||
private function cleanSummary(string $summary): string
|
||
{
|
||
// 移除 Jira 图片标记 !image-xxx.png! 和 !https://xxx.png!
|
||
$summary = preg_replace('/!([^!]+\.(png|jpg|jpeg|gif|bmp))!/i', '', $summary);
|
||
// 移除多余的空格和换行
|
||
$summary = preg_replace('/\s+/', ' ', $summary);
|
||
|
||
return trim($summary);
|
||
}
|
||
|
||
/**
|
||
* 判断任务状态是否应该标记为完成
|
||
*/
|
||
private function isTaskCompleted(string $status): bool
|
||
{
|
||
// 定义"未完成"的状态列表(包括未开始和进行中)
|
||
// 如果状态不在这个列表中,则认为任务已完成
|
||
$incompleteStatuses = [
|
||
'未开始',
|
||
'需求已确认',
|
||
'开发中',
|
||
'Open',
|
||
'To Do',
|
||
'In Progress',
|
||
'需求调研中',
|
||
'需求已调研',
|
||
'需求已评审',
|
||
'需求已排期',
|
||
'待提测',
|
||
'需求设计中',
|
||
];
|
||
|
||
// 如果状态不在"未完成"列表中,则标记为已完成
|
||
return ! in_array($status, $incompleteStatuses, true);
|
||
}
|
||
|
||
/**
|
||
* 组织任务数据用于周报生成
|
||
*/
|
||
private function organizeTasksForReport(Collection $workLogs, string $username): Collection
|
||
{
|
||
$organized = collect([
|
||
'sprints' => collect(),
|
||
'stories' => collect(), // 没有Sprint的需求
|
||
'tasks' => collect(),
|
||
'bugs' => collect(),
|
||
]);
|
||
|
||
$processedIssues = collect();
|
||
|
||
foreach ($workLogs as $workLog) {
|
||
$issueKey = $workLog['issue_key'];
|
||
$issueType = $workLog['issue_type'] ?? 'Unknown';
|
||
$issueSummary = $workLog['issue_summary'] ?? '';
|
||
$parentSummary = $workLog['parent_task']['summary'] ?? '';
|
||
$excludeKeyword = '工作日志';
|
||
|
||
// 排除工作日志相关的Jira
|
||
if ((is_string($issueSummary) && mb_strpos($issueSummary, $excludeKeyword) !== false)
|
||
|| (is_string($parentSummary) && mb_strpos($parentSummary, $excludeKeyword) !== false)) {
|
||
continue;
|
||
}
|
||
|
||
// 避免重复处理同一个任务
|
||
if ($processedIssues->has($issueKey)) {
|
||
continue;
|
||
}
|
||
$processedIssues->put($issueKey, true);
|
||
|
||
// 判断是否为Bug(通过issuetype判断)
|
||
$isBug = in_array($issueType, ['Bug', 'BUG', 'bug', '缺陷', 'Defect']);
|
||
|
||
// 判断是否为需求(Story类型)
|
||
$isStory = in_array($issueType, ['Story', 'story', '需求']);
|
||
|
||
// 判断是否为子任务
|
||
$isSubtask = in_array($issueType, ['Sub-task', 'sub-task', '子任务']);
|
||
|
||
if ($isBug && $workLog['bug_stage']) {
|
||
// Bug过滤逻辑:必须经办人、实际修复人或开发人是当前用户
|
||
$assignee = $workLog['assignee'] ?? null;
|
||
$developer = $workLog['developer'] ?? null;
|
||
$actualFixer = $workLog['actual_fixer'] ?? null;
|
||
|
||
// 检查是否有任一字段匹配当前用户
|
||
$isUserRelated = ($assignee === $username)
|
||
|| ($developer === $username)
|
||
|| ($actualFixer === $username);
|
||
|
||
// 如果不是当前用户相关的Bug,跳过
|
||
if (! $isUserRelated) {
|
||
continue;
|
||
}
|
||
|
||
// Bug按发现阶段分类
|
||
$stage = $workLog['bug_stage'];
|
||
if (! $organized['bugs']->has($stage)) {
|
||
$organized['bugs']->put($stage, collect());
|
||
}
|
||
|
||
$bugData = [
|
||
'key' => $workLog['issue_key'],
|
||
'summary' => $workLog['issue_summary'],
|
||
'url' => $workLog['issue_url'],
|
||
'status' => $workLog['issue_status'],
|
||
'bug_type' => $workLog['bug_type'],
|
||
'bug_description' => $workLog['bug_description'],
|
||
];
|
||
|
||
// 如果有父任务,添加父任务信息
|
||
if ($workLog['parent_task']) {
|
||
$bugData['parent_key'] = $workLog['parent_task']['key'];
|
||
$bugData['parent_summary'] = $workLog['parent_task']['summary'];
|
||
}
|
||
|
||
$organized['bugs'][$stage]->push($bugData);
|
||
} elseif (($isStory || $isSubtask) && $workLog['sprint']) {
|
||
// Story类型或子任务,且有Sprint的,按Sprint分类(需求)
|
||
$sprintName = $workLog['sprint'];
|
||
if (! $organized['sprints']->has($sprintName)) {
|
||
$organized['sprints']->put($sprintName, collect());
|
||
}
|
||
|
||
$this->addTaskToSprintOrTaskList($organized['sprints'][$sprintName], $workLog);
|
||
} elseif ($isStory && ! $workLog['sprint']) {
|
||
// Story类型但没有Sprint的,放入需求分类
|
||
$this->addTaskToSprintOrTaskList($organized['stories'], $workLog);
|
||
} elseif ($isSubtask && ! $workLog['sprint'] && $workLog['parent_task']) {
|
||
// 子任务没有Sprint,检查父任务类型来决定分类
|
||
$parentKey = $workLog['parent_task']['key'];
|
||
$parentDetails = $this->getIssueDetails($parentKey);
|
||
$parentType = $parentDetails ? ($parentDetails->fields->issuetype->name ?? '') : '';
|
||
$isParentStory = in_array($parentType, ['Story', 'story', '需求']);
|
||
|
||
if ($isParentStory) {
|
||
// 父任务是Story,放入需求分类
|
||
$this->addTaskToSprintOrTaskList($organized['stories'], $workLog);
|
||
} else {
|
||
// 父任务不是Story,放入任务分类
|
||
$this->addTaskToSprintOrTaskList($organized['tasks'], $workLog);
|
||
}
|
||
} else {
|
||
// 其他任务单独列出(非Story/子任务类型)
|
||
$this->addTaskToSprintOrTaskList($organized['tasks'], $workLog);
|
||
}
|
||
}
|
||
|
||
return $organized;
|
||
}
|
||
|
||
/**
|
||
* 添加任务到Sprint或任务列表
|
||
*/
|
||
private function addTaskToSprintOrTaskList(Collection $taskList, array $workLog): void
|
||
{
|
||
$status = $workLog['issue_status'] ?? 'Unknown';
|
||
|
||
if ($workLog['parent_task']) {
|
||
// 子任务
|
||
$parentKey = $workLog['parent_task']['key'];
|
||
|
||
if (! $taskList->has($parentKey)) {
|
||
// 获取父任务的真实信息
|
||
$parentDetails = $this->getIssueDetails($parentKey);
|
||
$parentSummary = $parentDetails ? $parentDetails->fields->summary : $workLog['parent_task']['summary'];
|
||
$parentStatus = $parentDetails ? $parentDetails->fields->status->name : 'Unknown';
|
||
|
||
$taskList->put($parentKey, [
|
||
'key' => $parentKey,
|
||
'summary' => $parentSummary,
|
||
'url' => $this->config['host'].'/browse/'.$parentKey,
|
||
'status' => $parentStatus,
|
||
'subtasks' => collect(),
|
||
]);
|
||
}
|
||
|
||
$taskList[$parentKey]['subtasks']->put($workLog['issue_key'], [
|
||
'key' => $workLog['issue_key'],
|
||
'summary' => $workLog['issue_summary'],
|
||
'url' => $workLog['issue_url'],
|
||
'status' => $status,
|
||
'created' => $workLog['issue_created'],
|
||
]);
|
||
} else {
|
||
// 主任务
|
||
if (! $taskList->has($workLog['issue_key'])) {
|
||
$taskList->put($workLog['issue_key'], [
|
||
'key' => $workLog['issue_key'],
|
||
'summary' => $workLog['issue_summary'],
|
||
'url' => $workLog['issue_url'],
|
||
'status' => $status,
|
||
'subtasks' => collect(),
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取下一个 release 版本
|
||
* 根据当前版本号,在 Jira 版本列表中找到下一个版本
|
||
*
|
||
* @param string $projectKey Jira 项目 key
|
||
* @param string|null $currentVersion 当前版本号(来自 master 分支的 version.txt)
|
||
*/
|
||
public function getUpcomingReleaseVersion(string $projectKey, ?string $currentVersion = null): ?array
|
||
{
|
||
try {
|
||
$versions = $this->projectService->getVersions($projectKey);
|
||
} catch (JiraException $e) {
|
||
throw new \RuntimeException('获取 release 版本失败: '.$e->getMessage(), previous: $e);
|
||
}
|
||
|
||
if (empty($versions)) {
|
||
return null;
|
||
}
|
||
|
||
// 按版本名称排序(假设版本号格式一致,如 1.0.0, 1.0.1, 1.1.0)
|
||
$sortedVersions = collect($versions)
|
||
->filter(fn ($version) => ! empty($version->name))
|
||
->sortBy(fn ($version) => $version->name, SORT_NATURAL)
|
||
->values();
|
||
|
||
if ($sortedVersions->isEmpty()) {
|
||
return null;
|
||
}
|
||
|
||
// 如果没有提供当前版本,返回第一个未发布的版本
|
||
if (empty($currentVersion)) {
|
||
$candidate = $sortedVersions
|
||
->filter(fn ($version) => ! ($version->released ?? false))
|
||
->first();
|
||
|
||
if (! $candidate) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'version' => $candidate->name,
|
||
'description' => $candidate->description ?? null,
|
||
'release_date' => ! empty($candidate->releaseDate)
|
||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||
: null,
|
||
];
|
||
}
|
||
|
||
// 找到当前版本在列表中的位置,返回下一个版本
|
||
$currentIndex = $sortedVersions->search(
|
||
fn ($version) => $version->name === $currentVersion
|
||
);
|
||
|
||
// 如果找不到当前版本,尝试找到第一个大于当前版本的未发布版本
|
||
if ($currentIndex === false) {
|
||
$candidate = $sortedVersions
|
||
->filter(function ($version) use ($currentVersion) {
|
||
if ($version->released ?? false) {
|
||
return false;
|
||
}
|
||
|
||
return version_compare($version->name, $currentVersion, '>');
|
||
})
|
||
->first();
|
||
|
||
if (! $candidate) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'version' => $candidate->name,
|
||
'description' => $candidate->description ?? null,
|
||
'release_date' => ! empty($candidate->releaseDate)
|
||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||
: null,
|
||
];
|
||
}
|
||
|
||
// 从当前版本的下一个开始,找到第一个未发布的版本
|
||
$candidate = $sortedVersions
|
||
->slice($currentIndex + 1)
|
||
->filter(fn ($version) => ! ($version->released ?? false))
|
||
->first();
|
||
|
||
if (! $candidate) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'version' => $candidate->name,
|
||
'description' => $candidate->description ?? null,
|
||
'release_date' => ! empty($candidate->releaseDate)
|
||
? Carbon::parse($candidate->releaseDate)->toDateString()
|
||
: null,
|
||
];
|
||
}
|
||
}
|