Files

1532 lines
58 KiB
PHP
Raw Permalink 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 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('用户名不能为空');
}
// 未来任务只展示已进入评审、排期或开发阶段,且已归入 Sprint 的需求。
$nextWeekStatuses = [
'需求已评审',
'需求已排期',
'开发中',
];
// 查询未来一周需要继续处理的需求(Story/需求类型,不包括子任务)。
// 需求既可能分给当前用户,也可能通过研发 owner 归属当前用户。
$jql = $this->buildNextWeekTasksJql(
$username,
$nextWeekStatuses,
$this->resolveDeveloperOwnerJqlField()
);
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();
}
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 字段中提取)
*/
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,
];
}
}