791 lines
30 KiB
PHP
791 lines
30 KiB
PHP
<?php
|
||
|
||
namespace App\Services;
|
||
|
||
use JiraRestApi\Configuration\ArrayConfiguration;
|
||
use JiraRestApi\Issue\IssueService;
|
||
use JiraRestApi\JiraException;
|
||
use JiraRestApi\Project\ProjectService;
|
||
use Carbon\Carbon;
|
||
use Illuminate\Support\Collection;
|
||
|
||
class JiraService
|
||
{
|
||
private IssueService $issueService;
|
||
private ProjectService $projectService;
|
||
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);
|
||
}
|
||
|
||
|
||
/**
|
||
* 按项目组织任务数据
|
||
*/
|
||
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(),
|
||
]);
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 获取最近的 release 版本
|
||
*/
|
||
public function getUpcomingReleaseVersion(string $projectKey): ?array
|
||
{
|
||
try {
|
||
$versions = $this->projectService->getVersions($projectKey);
|
||
} catch (JiraException $e) {
|
||
throw new \RuntimeException('获取 release 版本失败: ' . $e->getMessage(), previous: $e);
|
||
}
|
||
|
||
if (empty($versions)) {
|
||
return null;
|
||
}
|
||
|
||
$now = Carbon::now()->startOfDay();
|
||
|
||
$candidate = collect($versions)
|
||
->filter(function ($version) use ($now) {
|
||
if (($version->released ?? false) || empty($version->releaseDate)) {
|
||
return false;
|
||
}
|
||
|
||
try {
|
||
return Carbon::parse($version->releaseDate)->greaterThanOrEqualTo($now);
|
||
} catch (\Throwable) {
|
||
return false;
|
||
}
|
||
})
|
||
->sortBy(function ($version) {
|
||
return Carbon::parse($version->releaseDate);
|
||
})
|
||
->first();
|
||
|
||
if (!$candidate) {
|
||
return null;
|
||
}
|
||
|
||
return [
|
||
'version' => $candidate->name,
|
||
'release_date' => Carbon::parse($candidate->releaseDate)->toDateString(),
|
||
];
|
||
}
|
||
}
|