Module.php #1

  • //
  • guest/
  • thomas_gray/
  • jambox/
  • main/
  • swarm/
  • module/
  • Jira/
  • Module.php
  • View
  • Commits
  • Open Download .zip Download (22 KB)
<?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__,
                ),
            ),
        );
    }
}
# Change User Description Committed
#1 18334 Liz Lam initial add of jambox