404 lines
15 KiB
PHP
Executable File
404 lines
15 KiB
PHP
Executable File
<?php
|
|
|
|
declare(strict_types=1);
|
|
|
|
namespace App\Service;
|
|
|
|
use App\Entity\JiraAssignee;
|
|
use App\Entity\JiraIssueType;
|
|
use App\Entity\JiraStatus;
|
|
use App\Entity\KanbanBoard;
|
|
use App\Entity\KanbanEntry;
|
|
use App\Entity\Team;
|
|
use App\Entity\WatchedIssue;
|
|
use App\Entity\WatchedIssueComment;
|
|
use Zend\Cache\Storage\StorageInterface;
|
|
use Zend\Config\Config;
|
|
use Zend\Expressive\Router\RouterInterface;
|
|
use Zend\Http\Client;
|
|
use Zend\Json\Decoder;
|
|
use Zend\Json\Json;
|
|
|
|
class JiraCollectorService
|
|
{
|
|
const CACHE_KEY_KANBANBOARD = 'kanbanBoard';
|
|
const CACHE_KEY_WATCHED = 'watchedIssues';
|
|
const BACKLOG_FIELD_DELIMITER = ';';
|
|
|
|
const EPIC_TICKET_LINK = 'customfield_11711';
|
|
const EPIC_NAME_FIELD = 'customfield_11712';
|
|
|
|
const STATUS_DONE = 'done';
|
|
const STATUS_CLOSED = 'closed';
|
|
const STATUS_ANSWERED = 'answered';
|
|
const STATUS_RESOLVED = 'resolved';
|
|
|
|
const IGNORED_STATUSES = [
|
|
self::STATUS_DONE,
|
|
self::STATUS_CLOSED,
|
|
self::STATUS_ANSWERED,
|
|
self::STATUS_RESOLVED,
|
|
];
|
|
|
|
const WATCH_FILTER = 'status NOT IN (%s) AND watcher in (%s) AND "Last change" not in (%s) ORDER BY "Last Comment"';
|
|
|
|
/** @var StorageInterface */
|
|
private $cache;
|
|
|
|
/** @var Config */
|
|
private $config;
|
|
|
|
/** @var Client */
|
|
private $httpClient;
|
|
|
|
/** @var RouterInterface */
|
|
private $router;
|
|
|
|
/** @var TeamService */
|
|
private $teamService;
|
|
|
|
/** @var array */
|
|
private $cachedEpics = [];
|
|
|
|
/**
|
|
* JiraClientService constructor.
|
|
* @param StorageInterface $cache
|
|
* @param Client $client
|
|
* @param Config $config
|
|
* @param RouterInterface $router
|
|
* @param TeamService $teamService
|
|
*/
|
|
public function __construct(StorageInterface $cache,
|
|
Client $client,
|
|
Config $config,
|
|
RouterInterface $router,
|
|
TeamService $teamService)
|
|
{
|
|
$this->cache = $cache;
|
|
$this->router = $router;
|
|
$this->httpClient = $client;
|
|
$this->config = $config;
|
|
$this->teamService = $teamService;
|
|
}
|
|
|
|
/**
|
|
* @param int $teamId
|
|
* @param bool $forceReload
|
|
* @return KanbanBoard
|
|
*/
|
|
public function getKanbanBoard(int $teamId, bool $forceReload = false): KanbanBoard
|
|
{
|
|
$team = $this->teamService->getTeam($teamId);
|
|
$teamName = $team->getName();
|
|
$kanbanBoard = $this->cache->getItem(sprintf("%s-%s", self::CACHE_KEY_KANBANBOARD, $teamName));
|
|
if ($forceReload || null === $kanbanBoard) {
|
|
$user = $this->config->get('jira.user');
|
|
$password = $this->config->get('jira.password');
|
|
|
|
/** @var Config $kanbanBoardUriParams */
|
|
$kanbanBoardUri = $this->config->get('url.jiraKanbanBoard');
|
|
$kanbanBoardFilter = $this->config->get('jira.filterFields')->toArray();
|
|
|
|
$kanbanBoardUri = sprintf(
|
|
$kanbanBoardUri,
|
|
$team->getFilterId(),
|
|
implode(",", $kanbanBoardFilter)
|
|
);
|
|
|
|
$response = $this->httpClient
|
|
->setUri($kanbanBoardUri)
|
|
->setAuth($user, $password)
|
|
->send();
|
|
|
|
if (!$response->isSuccess()) {
|
|
throw new \UnexpectedValueException(sprintf(
|
|
"Bad JIRA result for URL:\n%s",
|
|
$kanbanBoardUri
|
|
), $response->getStatusCode());
|
|
}
|
|
|
|
$parsedJsonData = Decoder::decode($response->getBody(), Json::TYPE_ARRAY);
|
|
|
|
$kanbanBoard = $this->hydrateKanbanBoard($team, $parsedJsonData);
|
|
$this->cache->setItem(sprintf("%s-%s", self::CACHE_KEY_KANBANBOARD, $teamName), serialize($kanbanBoard));
|
|
return $kanbanBoard;
|
|
}
|
|
return unserialize($kanbanBoard);
|
|
}
|
|
|
|
/**
|
|
* @param int $teamId
|
|
* @param bool $forceReload
|
|
* @return array
|
|
* @throws \Exception
|
|
*/
|
|
public function getTeamWatchedIssues(int $teamId, bool $forceReload = false)
|
|
{
|
|
$team = $this->teamService->getTeam($teamId);
|
|
$teamName = $team->getName();
|
|
$watchedIssues = $this->cache->getItem(sprintf("%s-%s", self::CACHE_KEY_WATCHED, $teamName));
|
|
if ($forceReload || null === $watchedIssues) {
|
|
$members = array_map(function (array $member): string {
|
|
return $member['signum'];
|
|
}, $team->getMembers());
|
|
$preparedMembers = sprintf('"%s"', implode('","', $members));
|
|
|
|
$filter = sprintf(
|
|
self::WATCH_FILTER,
|
|
sprintf('"%s"', implode('","', self::IGNORED_STATUSES)),
|
|
$preparedMembers, $preparedMembers
|
|
);
|
|
|
|
$user = $this->config->get('jira.user');
|
|
$password = $this->config->get('jira.password');
|
|
/** @var Config $kanbanBoardUriParams */
|
|
$jiraWatchedIssues = $this->config->get('url.jiraWatchedIssues');
|
|
$kanbanBoardFilterFields = [
|
|
'assignee',
|
|
'summary',
|
|
'comment',
|
|
];
|
|
$issueFields = implode(",", $kanbanBoardFilterFields);
|
|
|
|
$jiraIssueUri = sprintf($jiraWatchedIssues, $filter, $issueFields);
|
|
$response = $this->httpClient
|
|
->setUri($jiraIssueUri)
|
|
->setAuth($user, $password)
|
|
->send();
|
|
|
|
if (!$response->isSuccess()) {
|
|
throw new \UnexpectedValueException(sprintf(
|
|
"Bad JIRA result for URL:\n%s",
|
|
$jiraIssueUri
|
|
), $response->getStatusCode());
|
|
}
|
|
|
|
$watchedIssues = $this->hydrateWatchedIssues(Decoder::decode($response->getBody(), Json::TYPE_ARRAY), $members);
|
|
$this->cache->setItem(sprintf("%s-%s", self::CACHE_KEY_WATCHED, $teamName), serialize($watchedIssues));
|
|
return $watchedIssues;
|
|
}
|
|
return unserialize($watchedIssues);
|
|
}
|
|
|
|
/**
|
|
* @param array $parsedJson
|
|
* @param array $members
|
|
* @return array
|
|
* @throws \Exception
|
|
*/
|
|
private function hydrateWatchedIssues(array $parsedJson, array $members)
|
|
{
|
|
/** @var WatchedIssue[] $hydratedResult */
|
|
$hydratedResult = [];
|
|
|
|
foreach ($parsedJson['issues'] as $issueJson) {
|
|
$issueItem = new WatchedIssue();
|
|
$issueItem->setIssue($issueJson['key'])
|
|
->setAssignee($issueJson['fields']['assignee']['name'])
|
|
->setSummary(html_entity_decode($issueJson['fields']['summary']));
|
|
|
|
$issueComments = [];
|
|
foreach ($issueJson['fields']['comment']['comments'] as $commentJson) {
|
|
$issueComment = new WatchedIssueComment();
|
|
$issueComment->setSignum($commentJson['updateAuthor']['name'])
|
|
->setName($commentJson['updateAuthor']['displayName'])
|
|
->setContent(html_entity_decode($commentJson['body']))
|
|
->setDate(new \DateTimeImmutable($commentJson['updated']));
|
|
$issueComments[] = $issueComment;
|
|
}
|
|
|
|
usort($issueComments, function(WatchedIssueComment $a, WatchedIssueComment $b) {
|
|
return $a->getDate() <=> $b->getDate();
|
|
});
|
|
|
|
$lastComment = array_pop($issueComments);
|
|
unset($issueComments);
|
|
$issueItem->setComment($lastComment);
|
|
|
|
$hydratedResult[] = $issueItem;
|
|
}
|
|
|
|
/**
|
|
* sanity check, we only want items where last change was a comment, but that is not possible
|
|
* with JIRA jql at the moment.
|
|
*/
|
|
return array_filter($hydratedResult, function(WatchedIssue $issue) use ($members) {
|
|
return null !== $issue->getComment()
|
|
? !in_array($issue->getComment()->getSignum(), $members)
|
|
: false;
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @param string $parentKey
|
|
* @return null|string
|
|
*/
|
|
private function getEpicNameFromParent(string $parentKey): ?string
|
|
{
|
|
if (array_key_exists($parentKey, $this->cachedEpics)) {
|
|
return $this->cachedEpics[$parentKey];
|
|
}
|
|
|
|
$user = $this->config->get('jira.user');
|
|
$password = $this->config->get('jira.password');
|
|
/** @var Config $kanbanBoardUriParams */
|
|
$jiraIssueBaseUrl = $this->config->get('url.jiraIssue');
|
|
$kanbanBoardFilter = $this->config->get('jira.filterFields')->toArray();
|
|
$kanbanBoardFilterString = implode(",", $kanbanBoardFilter);
|
|
$jiraIssueUri = sprintf($jiraIssueBaseUrl, $parentKey, $kanbanBoardFilterString);
|
|
|
|
$response = $this->httpClient
|
|
->setUri($jiraIssueUri)
|
|
->setAuth($user, $password)
|
|
->send();
|
|
|
|
if (!$response->isSuccess()) {
|
|
throw new \UnexpectedValueException("Bad JIRA result: $jiraIssueUri", $response->getStatusCode());
|
|
}
|
|
|
|
$parsedJsonParentData = Decoder::decode($response->getBody(), Json::TYPE_ARRAY);
|
|
if ($parsedJsonParentData['fields'][self::EPIC_TICKET_LINK]) {
|
|
$jiraIssueUri = sprintf(
|
|
$jiraIssueBaseUrl,
|
|
$parsedJsonParentData['fields'][self::EPIC_TICKET_LINK],
|
|
$kanbanBoardFilterString
|
|
);
|
|
$response = $this->httpClient
|
|
->setUri($jiraIssueUri)
|
|
->setAuth($user, $password)
|
|
->send();
|
|
|
|
if (!$response->isSuccess()) {
|
|
throw new \UnexpectedValueException("Bad JIRA result", $response->getStatusCode());
|
|
}
|
|
|
|
$parsedJsonEpicData = Decoder::decode($response->getBody(), Json::TYPE_ARRAY);
|
|
$this->cachedEpics[$parentKey] = $parsedJsonEpicData['fields'][self::EPIC_NAME_FIELD];
|
|
return $this->cachedEpics[$parentKey];
|
|
}
|
|
$this->cachedEpics[$parentKey] = null;
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* @param Team $team
|
|
* @param $parsedJsonData
|
|
* @return KanbanBoard
|
|
* @todo check if avatar has to be locally cached
|
|
*/
|
|
private function hydrateKanbanBoard(Team $team, $parsedJsonData): KanbanBoard
|
|
{
|
|
$kanbanBoard = new KanbanBoard();
|
|
|
|
$teamBacklogColumns = explode(self::BACKLOG_FIELD_DELIMITER, $team->getBacklogColumn()["jiraStatusName"]);
|
|
$teamInprogressColumns = explode(self::BACKLOG_FIELD_DELIMITER, $team->getInprogressColumn()["jiraStatusName"]);
|
|
$teamVerificationColumns = explode(self::BACKLOG_FIELD_DELIMITER, $team->getVerificationColumn()["jiraStatusName"]);
|
|
$teamDoneColumns = explode(self::BACKLOG_FIELD_DELIMITER, $team->getDoneColumn()["jiraStatusName"]);
|
|
|
|
foreach ($parsedJsonData['issues'] as $jsonIssue) {
|
|
set_time_limit(30);
|
|
$kanbanEntry = new KanbanEntry();
|
|
$kanbanEntry->setId(intval($jsonIssue['id']))
|
|
->setKey($jsonIssue['key'])
|
|
->setSummary($jsonIssue['fields']['summary'])
|
|
->setIssuePriority($jsonIssue['fields']['priority']['name'])
|
|
->setIssuePriorityIcon($jsonIssue['fields']['priority']['iconUrl'])
|
|
->setLabels($jsonIssue['fields']['labels'])
|
|
->setFixVersions($jsonIssue['fields']['fixVersions']);
|
|
|
|
$spikeTimeSpent = 0;
|
|
array_map(function ($worklog) use (&$spikeTimeSpent) {
|
|
$spikeTimeSpent += strtoupper($worklog['comment']) == 'BLOCKED'
|
|
? 0
|
|
: $worklog['timeSpentSeconds'];
|
|
}, $jsonIssue['fields']['worklog']['worklogs']);
|
|
$kanbanEntry->setWorklog((int)ceil($spikeTimeSpent / 3600));
|
|
|
|
$secondsBlocked = 0;
|
|
array_map(function ($worklog) use (&$secondsBlocked) {
|
|
$secondsBlocked += strtoupper($worklog['comment']) == 'BLOCKED'
|
|
? $worklog['timeSpentSeconds']
|
|
: 0;
|
|
}, $jsonIssue['fields']['worklog']['worklogs']);
|
|
$kanbanEntry->setDaysBlocked((int)ceil($secondsBlocked / 28800));
|
|
|
|
// additional assignees : customfield_10401
|
|
if (isset($jsonIssue['fields']['customfield_10401'])) {
|
|
foreach ($jsonIssue['fields']['customfield_10401'] as $assignee) {
|
|
$avatarUrl = $this->router->generateUri('avatar.image', [
|
|
'signum' => $assignee['key'],
|
|
]);
|
|
|
|
$jiraAssignee = new JiraAssignee();
|
|
$jiraAssignee->setName($assignee['displayName'])
|
|
->setSignum($assignee['key'])
|
|
->setEmail(strtolower($assignee['emailAddress']))
|
|
->setAvatar($avatarUrl)
|
|
->setActive($assignee['active']);
|
|
$kanbanEntry->addAdditionalAssignee($jiraAssignee);
|
|
}
|
|
}
|
|
|
|
// epicName: have to fetch 2 extra records
|
|
if (isset($jsonIssue['fields'][self::EPIC_TICKET_LINK])) {
|
|
$epicName = $this->getEpicNameFromParent($jsonIssue['key']);
|
|
$kanbanEntry->setEpicName($epicName);
|
|
} elseif (isset($jsonIssue['fields']['parent'])) {
|
|
$epicName = $this->getEpicNameFromParent($jsonIssue['fields']['parent']['key']);
|
|
$kanbanEntry->setEpicName($epicName);
|
|
}
|
|
|
|
// jira status
|
|
$jiraStatus = new JiraStatus();
|
|
$jiraStatus->setName($jsonIssue['fields']['status']['name'])
|
|
->setColor($jsonIssue['fields']['status']['statusCategory']['colorName']);
|
|
$kanbanEntry->setStatus($jiraStatus);
|
|
|
|
// assignee
|
|
if ($jsonIssue['fields']['assignee']) {
|
|
$avatarUrl = $this->router->generateUri('avatar.image', [
|
|
'signum' => $jsonIssue['fields']['assignee']['key'],
|
|
]);
|
|
|
|
$jiraAssignee = new JiraAssignee();
|
|
$jiraAssignee->setName($jsonIssue['fields']['assignee']['displayName'])
|
|
->setSignum($jsonIssue['fields']['assignee']['key'])
|
|
->setEmail(strtolower($jsonIssue['fields']['assignee']['emailAddress']))
|
|
->setAvatar($avatarUrl)
|
|
->setActive($jsonIssue['fields']['assignee']['active']);
|
|
|
|
$kanbanEntry->setAssignee($jiraAssignee);
|
|
unset($jiraAssignee);
|
|
}
|
|
|
|
// issue type
|
|
if ($jsonIssue['fields']['issuetype']) {
|
|
$jiraIssueType = new JiraIssueType();
|
|
$jiraIssueType->setName($jsonIssue['fields']['issuetype']['name'])
|
|
->setDescription($jsonIssue['fields']['issuetype']['description'])
|
|
->setIcon($jsonIssue['fields']['issuetype']['iconUrl']);
|
|
|
|
$kanbanEntry->setIssueType($jiraIssueType);
|
|
unset($jiraIssueType);
|
|
}
|
|
|
|
$kanbanEntry->setUpdatedAt(new \DateTime($jsonIssue['fields']['updated']));
|
|
if (in_array($jiraStatus->getName(), $teamBacklogColumns)) {
|
|
$kanbanBoard->addInbox($kanbanEntry);
|
|
}
|
|
elseif (in_array($jiraStatus->getName(), $teamInprogressColumns)) {
|
|
$kanbanBoard->addInProgress($kanbanEntry);
|
|
}
|
|
elseif (in_array($jiraStatus->getName(), $teamVerificationColumns)) {
|
|
$kanbanBoard->addVerification($kanbanEntry);
|
|
}
|
|
elseif (in_array($jiraStatus->getName(), $teamDoneColumns)) {
|
|
$kanbanBoard->addDone($kanbanEntry);
|
|
}
|
|
unset($kanbanEntry);
|
|
}
|
|
|
|
return $kanbanBoard;
|
|
}
|
|
}
|