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, ]; } }