Files
toolbox/app/Services/JiraService.php

804 lines
31 KiB
PHP
Raw 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 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('用户名不能为空');
}
// 查询分配给用户且未完成的需求Story/需求类型,不包括子任务)
$jql = sprintf(
'assignee = "%s" AND status != "Done" AND issuetype in ("Story", "需求") 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()) {
$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";
}
}
// 单独列出的任务
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
{
// 只有已完成或已取消才打勾
$completedStatuses = [
'已完成',
'完成',
'Done',
'Closed',
'Resolved',
];
$cancelledStatuses = [
'已取消',
'取消',
'Cancelled',
'Canceled',
];
return in_array($status, $completedStatuses, true)
|| in_array($status, $cancelledStatuses, 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';
$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按发现阶段分类
$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(),
];
}
}