config = config('jira'); $this->initializeJiraClient(); } private function initializeJiraClient(): void { $jiraConfig = 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($jiraConfig); } /** * 按项目组织任务数据 */ 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('用户名不能为空'); } // 查询分配给用户且未完成的任务(不包括子任务) $jql = sprintf( 'assignee = "%s" AND status != "Done" AND issuetype != "Sub-task" ORDER BY created ASC', $username ); 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(); } /** * 生成 Markdown 格式的周报 */ public function generateWeeklyReport(?string $username = null): string { $username = $username ?: $this->config['default_user']; // 获取上周的工时记录 $now = Carbon::now(); $startOfWeek = $now->copy()->subWeek()->startOfWeek(); $endOfWeek = $now->copy()->subWeek()->endOfWeek(); $workLogs = $this->getWorkLogs($username, $startOfWeek, $endOfWeek); $organizedTasks = $this->organizeTasksForReport($workLogs); $nextWeekTasks = $this->getNextWeekTasks($username); $markdown = "# 过去一周的任务\n\n"; if ($organizedTasks->isEmpty()) { $markdown .= "本周暂无工时记录的任务。\n\n"; } else { // 按Sprint分类的需求 if ($organizedTasks->has('sprints') && $organizedTasks['sprints']->isNotEmpty()) { foreach ($organizedTasks['sprints'] 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"; } } // 单独列出的任务 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; } /** * 获取指定日期范围内的工时记录 */ 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', 'customfield_10004', // Sprint字段 'customfield_10900', // Bug发现阶段 '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); $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, '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 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 = [ '开发中', '需求已排期', '需求已评审', 'In Progress', 'To Do', 'Open', 'Reopened', 'In Review', 'Code Review', 'Testing', 'Ready for Testing' ]; return !in_array($status, $incompleteStatuses, true); } /** * 组织任务数据用于周报生成 */ private function organizeTasksForReport(Collection $workLogs): Collection { $organized = collect([ 'sprints' => collect(), 'tasks' => collect(), 'bugs' => collect(), ]); $processedIssues = collect(); foreach ($workLogs as $workLog) { $issueKey = $workLog['issue_key']; $issueType = $workLog['issue_type'] ?? 'Unknown'; // 避免重复处理同一个任务 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按发现阶段分类 $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); } else { // 其他任务单独列出(非Story/子任务类型或没有Sprint的) $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(), ]); } } } }