<?php
/**
* Perforce Swarm
*
* @copyright 2013 Perforce Software. All rights reserved.
* @license Please see LICENSE.txt in top-level folder of this distribution.
* @version <release>/<patch>
*/
namespace Jira;
use Application\Filter\Linkify;
use Jira\Model\Linkage;
use P4\Spec\Change;
use P4\Spec\Definition;
use P4\Spec\Exception\NotFoundException as SpecNotFoundException;
use P4\Spec\Job;
use Record\Exception\NotFoundException;
use Reviews\Model\Review;
use Zend\Http\Client as HttpClient;
use Zend\Json\Json;
use Zend\Mvc\MvcEvent;
use Zend\ServiceManager\ServiceLocatorInterface as ServiceLocator;
class Module
{
/**
* The JIRA module performs a few tasks assuming it has configuration data available.
*
* On worker 1 startup (so every ~10 minutes) we cache a copy of all valid JIRA project
* ids by querying the JIRA server's 'project' route. This data is used for the later
* work described below.
*
* Whenever text is linkified we link any JIRA issues that appear.
*
* When reviews are added/updated we ensure all JIRA issues they reference either in
* their description or via associated jobs have links back to the review and that the
* links labels have the current review status.
*
* When changes are committed we ensure all JIRA issues they reference either in
* their description or via associated jobs have links back to the change in Swarm.
*
* @param MvcEvent $event the bootstrap event
* @return void
*/
public function onBootstrap(MvcEvent $event)
{
$services = $event->getApplication()->getServiceManager();
$events = $services->get('queue')->getEventManager();
$config = $this->getJiraConfig($services);
$projects = $this->getProjects();
$module = $this;
// bail out if we lack a host, we won't be able to do anything
if (!$config['host']) {
return;
}
// add the linkify callback if we have projects defined
if ($projects) {
// prepare a regex based on the configured projects then register with the linkifier
$host = $config['host'];
$regex = "/^@?(?P<issue>(?:" . implode('|', array_map('preg_quote', $projects)) . ")-[0-9]+)('s)?$/";
Linkify::addCallback(
function ($value, $escaper) use ($regex, $host) {
// if it looks like a jira issue for a known project linkify
if (preg_match($regex, $value, $matches)) {
return '<a href="'
. $escaper->escapeFullUrl($host . '/browse/' . $matches['issue']) . '">'
. $escaper->escapeHtml($value) . '</a>';
}
// not a hit; tell caller we didn't handle this one
return false;
},
'jira',
min(array_map('strlen', $projects)) + 2
);
}
// connect to worker 1 startup to refresh our cache of jira project ids
$events->attach(
'worker.startup',
function ($event) use ($services, $module) {
// only run for the first worker.
if ($event->getParam('slot') !== 1) {
return;
}
// attempt to request the list of projects, if the request fails keep
// whatever list we have though as something is better than nothing.
$cacheDir = $module->getCacheDir();
$result = $module->doRequest('get', 'project', null, $services);
if ($result !== false) {
$projects = array();
foreach ((array) $result as $project) {
if (isset($project['key'])) {
$projects[] = $project['key'];
}
}
file_put_contents($cacheDir . '/projects', Json::encode($projects));
}
},
-300
);
// the remaining work requires either project ids or a defined job field to
// have any shot at functioning; if that isn't the case bail early.
if (!$projects && !$config['job_field']) {
return;
}
// when a job task flies by it may represent the job being added to or removed
// from a change or review. fetch associated changes and ensure they are linked
$events->attach(
array('task.job'),
function ($event) use ($services, $module) {
$p4Admin = $services->get('p4_admin');
$job = $event->getParam('job');
// if we don't have a job; nothing to do
if (!$job instanceof Job) {
return;
}
// figure out the changes that are, or were, impacted by this job
$linkages = Linkage::fetchAll(array(Linkage::FETCH_BY_JOB => $job->getId()), $p4Admin);
$ids = array_merge($linkages->invoke('getId'), $job->getChanges());
// fetch any items that represent submitted changes or represent reviews
// note, we only deal with JIRA links for committed changes and reviews
$changes = Change::fetchAll(
array(Change::FETCH_BY_IDS => $ids, Change::FETCH_BY_STATUS => Change::SUBMITTED_CHANGE),
$p4Admin
);
$reviews = Review::fetchAll(
array(Review::FETCH_BY_IDS => array_diff($ids, $changes->invoke('getId'))),
$p4Admin
);
// for each change/review we found, update the JIRA links
foreach ($changes->merge($reviews) as $item) {
try {
$module->updateIssueLinks($item, $services);
} catch (\Exception $e) {
$services->get('logger')->err($e);
}
}
},
-300
);
// when a change is submitted or updated, find any associated JIRA issues;
// either via associated jobs or callouts in the description, and ensure
// the JIRA issues link back to the change in Swarm.
$events->attach(
array('task.commit', 'task.change'),
function ($event) use ($services, $module) {
// task.change doesn't include the change object; fetch it if we need to
$change = $event->getParam('change');
if (!$change instanceof Change) {
try {
$change = Change::fetch($event->getParam('id'), $services->get('p4_admin'));
$event->setParam('change', $change);
} catch (SpecNotFoundException $e) {
} catch (\InvalidArgumentException $e) {
}
}
// if this isn't a submitted change; nothing to do
if (!$change instanceof Change || !$change->isSubmitted()) {
return;
}
try {
$module->updateIssueLinks($change, $services);
} catch (\Exception $e) {
$services->get('logger')->err($e);
}
},
-300
);
// when a review is created or updated, find any associated JIRA issues;
// either via associated jobs or callouts in the description, and ensure
// the JIRA issues link back to the review in Swarm.
$events->attach(
'task.review',
function ($event) use ($services, $module) {
$review = $event->getParam('review');
if (!$review instanceof Review) {
return;
}
try {
// update any associated issues
$module->updateIssueLinks($review, $services);
} catch (\Exception $e) {
$services->get('logger')->err($e);
}
},
-300
);
}
/**
* This method figures out which JIRA issues are involved with the passed review or
* change either via mentions in the description or associated jobs and updates them:
* - JIRA issues that are no longer associated have their Swarm links deleted.
* - JIRA issues that are new have Swarm links added.
* - If the link title or summary has changed, any old JIRA issue links are updated.
*
* @param Change|Review $item the change or review we're linking to
* @param ServiceLocator $services the service locator
*/
public function updateIssueLinks($item, ServiceLocator $services)
{
$p4Admin = $services->get('p4_admin');
$qualifiedUrl = $services->get('viewhelpermanager')->get('qualifiedUrl');
$truncate = $services->get('ViewHelperManager')->get('truncate');
$icon = $qualifiedUrl() . '/favicon.ico';
$summary = (string) $truncate($item->getDescription(), 80);
$linkedIssues = $this->getLinkedJobIssues($item->getId(), $services);
$callouts = $this->getJiraCallouts($item->getDescription(), $services);
$issues = array_merge($linkedIssues, $callouts);
$issues = array_values(array_unique(array_filter($issues, 'strlen')));
sort($issues);
// get the linkage details for this issue; creating a new record if needed
try {
$linkage = Linkage::fetch($item->getId(), $p4Admin);
} catch (NotFoundException $e) {
$linkage = new Linkage($p4Admin);
$linkage->setId($item->getId());
}
// the title, URL and jira global id vary by type (review/change); figure that out
if ($item instanceof Review) {
$title = 'Review ' . $item->getId() . ' - ' . $item->getStateLabel() . ', ';
$title .= $item->isCommitted() ? 'Committed' : 'Not Committed';
$url = $qualifiedUrl('review', array('review' => $item->getId()));
$jiraId = 'swarm-review-' . md5(serialize(array('review' => $item->getId())));
// if this is a legacy record where the JIRA state is stored on the
// review upgrade that data to being stored in the linkage record
if ($item->get('jira')) {
$old = $item->get('jira') + array('label', 'issues');
$linkage->set('title', $old['label'])
->set('issues', $old['issues']);
// strip the jira value off of the review so we don't do this again
$item->unsetRawValue('jira')->save();
}
} elseif ($item instanceof Change) {
$title = 'Commit ' . $item->getId();
$url = $qualifiedUrl('change', array('change' => $item->getId()));
$jiraId = 'swarm-change-' . md5(serialize(array('change' => $item->getId())));
} else {
throw new \InvalidArgumentException('Update Issue Links expects a Change or Review');
}
// pull out the 'old' issues/title/summary/jobs before we update the linkage
$old = $linkage->get();
// record the new values before we start mucking with JIRA. this should help
// ensure we don't get into a loop where we update JIRA, it tickles DTG which
// updates jobs; round and round we go.
$linkage->set('title', $title)
->set('jobs', array_keys($linkedIssues))
->set('issues', $issues)
->set('summary', $summary)
->save();
// remove links from any issues which are no longer impacted
$delete = array_diff($old['issues'], $issues);
foreach ($delete as $issue) {
$this->doRequest(
'delete',
'issue/' . $issue . '/remotelink',
array('globalId' => $jiraId),
$services
);
}
// time to deal with new/added issues
// if the title and summary are unchanged; only add new issues. otherwise we add new
// issues and update existing issues to match the new title/summary.
$updates = $issues;
if ($title == $old['title'] && $summary == $old['summary']) {
$updates = array_diff($issues, $old['issues']);
}
foreach ($updates as $issue) {
$this->doRequest(
'post',
'issue/' . $issue . '/remotelink',
array(
'globalId' => $jiraId,
'object' => array(
'url' => $url,
'title' => $title,
'summary' => $summary,
'icon' => array(
'url16x16' => $icon,
'title' => 'Swarm'
)
)
),
$services
);
}
}
/**
* Given a change or change id this method will find all associated perforce jobs
* and return the list of JIRA issue ids that appear in the 'job_field'.
*
* @param string|int|Change $change the change to examine
* @param ServiceLocator $services the service locator
* @return array an array of JIRA issues keyed on associated job id
*/
public function getLinkedJobIssues($change, ServiceLocator $services)
{
$p4Admin = $services->get('p4_admin');
$config = $this->getJiraConfig($services);
$jobField = $config['job_field'];
$change = $change instanceof Change ? $change->getId() : $change;
// nothing to do if no job field or job field isn't defined in our spec
if (!$jobField || !Definition::fetch('job', $p4Admin)->hasField($jobField)) {
return array();
}
// determine the ids of affected jobs
$jobs = $p4Admin->run('fixes', array('-c', $change))->getData();
$ids = array();
foreach ($jobs as $job) {
$ids[] = $job['Job'];
}
// fetch the jobs and collect the issues; keyed by job id
$issues = array();
$jobs = Job::fetchAll(array(Job::FETCH_BY_IDS => $ids), $p4Admin);
foreach ($jobs as $job) {
$issues[$job->getId()] = $job->get($jobField);
}
// return the trimmed non-empty values
return array_filter(array_map('trim', $issues), 'strlen');
}
/**
* Given a string of text, this method will try and locate any JIRA issue
* ids that are present either raw e.g. SW-123, at prefixed e.g. @SW-123
* or listed in a full url e.g. http://<jirahost>/browse/SW-123.
*
* @param string $value the text to examine for JIRA issue ids
* @param ServiceLocator $services the service locator
* @return array an array of unique JIRA issue ids referenced in the passed text
*/
public function getJiraCallouts($value, ServiceLocator $services)
{
$config = $this->getJiraConfig($services);
$url = $config['host'] ? $config['host'] . '/browse/' : false;
$trimPattern = '/^[”’"\'(<{\[]*@?(.+?)[.”’"\'\,!?:;)>}\]]*$/';
$projects = array_map('preg_quote', $this->getProjects());
$calloutPattern = "/^(?:" . implode('|', $projects) . ")-[0-9]+$/";
$words = preg_split('/(\s+)/', $value);
$callouts = array();
foreach ($words as $word) {
if (!strlen($word)) {
continue;
}
// strip the leading/trailing punctuation from the actual word
preg_match($trimPattern, $word, $matches);
$word = $matches[1];
// if it looks like a full JIRA url strip it down to just the potential issue id
if ($url && stripos($word, $url) === 0) {
$word = rtrim(substr($word, strlen($url)), '/');
}
// if the trimmed word isn't empty, matches our pattern and we haven't
// seen before it counts towards callouts.
if (strlen($word) && preg_match($calloutPattern, $word) && !in_array($word, $callouts)) {
$callouts[] = $word;
}
}
return $callouts;
}
/**
* Convenience function to ease RESTful interaction with the JIRA service.
*
* @param string $method one of get, post, delete
* @param string $resource the resource e.g. 'project' or 'issue/<id>/remoteLinks'
* @param mixed $data get/post data to include on the request or null/false for none
* @param ServiceLocator $services the service locator
* @return mixed the response or false if request fails
*/
public function doRequest($method, $resource, $data, ServiceLocator $services)
{
// we commonly do a number of requests and don't want one failure to bork them all,
// if anything goes wrong just log it
try {
// setup the client and request details
$config = $this->getJiraConfig($services);
$url = $config['host'] . '/rest/api/latest/' . $resource;
$client = new HttpClient;
$client->setUri($url)
->setHeaders(array('Content-Type' => 'application/json'))
->setMethod($method);
// set the http client options; including any special overrides for our host
$options = $services->get('config') + array('http_client_options' => array());
$options = (array) $options['http_client_options'];
if (isset($options['hosts'][$client->getUri()->getHost()])) {
$options = (array) $options['hosts'][$client->getUri()->getHost()] + $options;
}
unset($options['hosts']);
$client->setOptions($options);
if ($method == 'post') {
$client->setRawBody(Json::encode($data));
} else {
$client->setParameterGet((array) $data);
}
if ($config['user']) {
$client->setAuth($config['user'], $config['password']);
}
// attempt the request and log any errors
$services->get('logger')->info('JIRA making ' . $method . ' request to resource: ' . $url, (array) $data);
$response = $client->dispatch($client->getRequest());
if (!$response->isSuccess()) {
$services->get('logger')->err(
'JIRA failed to ' . $method . ' resource: ' . $url . ' (' .
$response->getStatusCode() . " - " . $response->getReasonPhrase() . ').',
array(
'request' => $client->getLastRawRequest(),
'response' => $client->getLastRawResponse()
)
);
return false;
}
// looks like it worked, return the result
return json_decode($response->getBody(), true);
} catch (\Exception $e) {
$services->get('logger')->err($e);
}
return false;
}
/**
* Get the project ids that are defined in JIRA from cache.
*
* @return array array of project ids in JIRA, empty array if cache is missing/empty
*/
public function getProjects()
{
$file = DATA_PATH . '/cache/jira/projects';
if (!file_exists($file)) {
return array();
}
return (array) json_decode(file_get_contents($file), true);
}
/**
* Get the path to write cache entries to. Ensures directory is writable.
*
* @return string the cache directory to write to
* @throws \RuntimeException if the directory cannot be created or made writable
*/
public function getCacheDir()
{
$dir = DATA_PATH . '/cache/jira';
if (!is_dir($dir)) {
@mkdir($dir, 0700, true);
}
if (!is_writable($dir)) {
@chmod($dir, 0700);
}
if (!is_dir($dir) || !is_writable($dir)) {
throw new \RuntimeException(
"Cannot write to cache directory ('" . $dir . "'). Check permissions."
);
}
return $dir;
}
/**
* Normalize and return the JIRA portion of the system configuration.
*
* @param ServiceLocator $services service locator to get at the config details
* @return array normalized JIRA config details
*/
public function getJiraConfig(ServiceLocator $services)
{
$config = $services->get('config');
$config = isset($config['jira']) ? $config['jira'] : array();
$config += array('host' => null, 'user' => null, 'password' => null, 'job_field' => null);
$config['host'] = rtrim($config['host'], '/');
if ($config['host'] && strpos(strtolower($config['host']), 'http') !== 0) {
$config['host'] = 'http://' . $config['host'];
}
return $config;
}
/**
* The config defaults.
*
* @return array the default config for this module
*/
public function getConfig()
{
return include __DIR__ . '/config/module.config.php';
}
public function getAutoloaderConfig()
{
return array(
'Zend\Loader\StandardAutoloader' => array(
'namespaces' => array(
__NAMESPACE__ => __DIR__ . '/src/' . __NAMESPACE__,
),
),
);
}
}