<?php
/**
* Perforce Swarm
*
* @copyright 2012 Perforce Software. All rights reserved.
* @license Please see LICENSE.txt in top-level folder of this distribution.
* @version <release>/<patch>
*/
namespace Changes;
use Activity\Model\Activity;
use Application\Filter\Linkify;
use P4\Connection\Exception\CommandException;
use P4\Spec\Change;
use Projects\Model\Project;
use Reviews\Model\Review;
use Users\Model\Group;
use Users\Model\User;
use Zend\Mvc\MvcEvent;
class Module
{
/**
* Connect to queue event manager to handle changes.
*
* @param MvcEvent $event the bootstrap event
* @return void
*/
public function onBootstrap(MvcEvent $event)
{
$application = $event->getApplication();
$services = $application->getServiceManager();
$events = $services->get('queue')->getEventManager();
// subscribe very early to simply fetch the change and verify its worth processing at this time.
//
// we delay processing changes owned by 'git-fusion-user'. git-fusion commits changes as itself but
// re-credits them to the author or pusher. There is a window where changes are still owned by
// git-fusion-user and we don't want to process them during this period.
//
// further, we stop processing for changes against the .git-fusion depot. swarm cannot presently
// show diffs effectively for the light weight branch work done by git fusion and we also want
// to hide the changes related to git objects and other git-fusion infrastructure work.
//
// the below listener gets in very early (prior to even the impacted projects being calculated) and
// requeues changes in this state into the future.
$events->attach(
array('task.commit', 'task.shelve'),
function ($event) use ($services) {
$p4Admin = $services->get('p4_admin');
$id = $event->getParam('id');
$data = (array) $event->getParam('data') + array('retries' => null);
$config = $services->get('config') + array('git_fusion' => array());
$gitConfig = $config['git_fusion'] + array('user' => null, 'depot' => null, 'reown' => array());
$gitConfig['reown'] += array('retries' => null, 'max_wait' => null);
try {
$change = Change::fetch($id, $p4Admin);
$event->setParam('change', $change);
// if we don't know where the git-fusion depot is, just process as-is
if (!$gitConfig['depot']) {
return;
}
// if the change is under the .git-fusion depot, we don't want activity for it, abort processing
try {
$path = $p4Admin->run(
'fstat',
array('-TdepotFile', '-m1', '//' . trim($gitConfig['depot'], '/') . '/...@=' . $id)
)->getData(0, 'depotFile');
// if we got a hit this is a .git-fusion depot change and we want to ignore it
// stop the event to prevent activity/email/etc from being created and return
if ($path) {
$event->stopPropagation();
return;
}
} catch (CommandException $e) {
// if this is a ".git-fusion depot doesn't exist" type exception just eat it otherwise rethrow
if (strpos($e->getMessage(), 'must refer to client') === false) {
throw $e;
}
}
// if we don't know who the git-fusion-user is, don't delay processing
if (!$gitConfig['user']) {
return;
}
// if the change isn't owned by the git-fusion-user, no need to delay just return
if ($change->getUser() != $gitConfig['user']) {
return;
}
// if we've already maxed out our retries, don't delay further just return
if ($data['retries'] >= $gitConfig['reown']['retries']) {
$services->get('logger')->err('Max git-fusion reown retries/delay exceeded for change ' . $id);
return;
}
// at this point we have established the change is owned by the git-fusion-user
// and it isn't under the .git-fusion depot. we want to abort processing and
// re-queue the event to be re-considered in the near future
// our delay gets exponentially larger up to a max (by default 60 seconds)
// by default, at most we'll re-queue 20 times for a delay of 16 minutes 2 seconds
$data['retries'] += 1;
$services->get('queue')->addTask(
$event->getParam('type'),
$event->getParam('id'),
$data,
time() + min(pow(2, $data['retries']), $gitConfig['reown']['max_wait'])
);
// stop further processing
$event->stopPropagation();
} catch (\Exception $e) {
$services->get('logger')->err($e);
}
},
300
);
// when a change is committed, determine the impacted projects and prepare activity record.
$events->attach(
'task.commit',
function ($event) use ($services) {
$p4Admin = $services->get('p4_admin');
$keywords = $services->get('review_keywords');
$change = $event->getParam('change');
try {
// ignore invalid/pending changes.
if (!$change instanceof Change || $change->getStatus() !== 'submitted') {
return;
}
// prepare list of projects affected by the change
$impacted = Project::getAffectedByChange($change, $p4Admin);
// prepare data model for activity streams
$changeId = $change->getId();
$activity = new Activity;
$activity->set(
array(
'type' => 'change',
'link' => array('change', array('change' => $changeId)),
'user' => $change->getUser(),
'action' => 'committed',
'target' => 'change ' . $changeId,
'preposition' => 'into',
'description' => $keywords->filter($change->getDescription()),
'topic' => 'changes/' . $change->getOriginalId(),
'time' => $change->getTime(),
'projects' => $impacted,
'change' => $changeId
)
);
// ensure any @mention'ed users are included in both the activity and the email
$mentions = User::filter(Linkify::getCallouts($change->getDescription()), $p4Admin);
$toUsers = $mentions;
$activity->addFollowers($mentions);
// if this change is being committed on behalf of someone else, include them
$review = $event->getParam('review');
if ($review instanceof Review && $review->get('author')) {
$toUsers = array_merge($toUsers, array($review->get('author')));
}
// notify members, moderators and followers of affected projects via activity and email
if ($impacted) {
$projects = Project::fetchAll(array(Project::FETCH_BY_IDS => array_keys($impacted)), $p4Admin);
$groups = Group::getCachedData($p4Admin);
foreach ($projects as $projectId => $project) {
$members = $project->getAllMembers(false, $groups);
$followers = $project->getFollowers($members);
$branches = isset($impacted[$projectId]) ? $impacted[$projectId] : array();
$moderators = $branches ? $project->getModerators($branches) : null;
$activity->addFollowers($members);
$activity->addFollowers($moderators);
$activity->addFollowers($followers);
// email notification can be disabled per project
$emailUsers = $project->getEmailFlag('change_email_project_users');
if ($emailUsers || $emailUsers === null) {
$toUsers = array_merge($toUsers, $members, $moderators, $followers);
}
}
}
// if change was renumbered, update 'change' field on related activity records
if ($changeId !== $change->getOriginalId()) {
$options = array(Activity::FETCH_BY_CHANGE => $change->getOriginalId());
foreach (Activity::fetchAll($options, $p4Admin) as $record) {
$record->set('change', $changeId)->save();
}
}
$event->setParam('activity', $activity);
$event->setParam('mail', array('toUsers' => $toUsers));
} catch (\Exception $e) {
$services->get('logger')->err($e);
}
},
200
);
// prepare commit notification for the email module
// we do this quite late (low-priority) - after the activity module
// processes this task so we can take advantage of prepared activity data
$events->attach(
'task.commit',
function ($event) use ($services) {
$p4Admin = $services->get('p4_admin');
$config = $services->get('config');
$keywords = $services->get('review_keywords');
$change = $event->getParam('change');
$activity = $event->getParam('activity');
// if no change or no activity, nothing to do
if (!$change instanceof Change || !$activity instanceof Activity) {
return;
}
// normalize notifications config
$notifications = isset($config['notifications']) ? $config['notifications'] : array();
$notifications += array(
'honor_p4_reviews' => false,
'opt_in_review_path' => null,
'disable_change_emails' => false
);
// if sending change emails is disabled, nothing to do
if ($notifications['disable_change_emails']) {
return;
}
try {
// determine who to send email notifications to:
// - start with the users already set up in the prior task (where the activity was created)
// - include users subscribed to review files if that option is explicitly enabled in config
// - exclude users who don't review the 'opt_in_review_path' (if set)
$mail = $event->getParam('mail');
$toUsers = isset($mail['toUsers']) ? $mail['toUsers'] : array();
$reviewPath = $notifications['opt_in_review_path'];
if ($notifications['honor_p4_reviews']) {
$data = $p4Admin->run('reviews', array('-c', $change->getId()))->getData();
$toUsers = array_merge($toUsers, array_map('current', $data));
}
if ($reviewPath && is_string($reviewPath)) {
$data = $p4Admin->run('reviews', array($reviewPath))->getData();
$toUsers = array_intersect($toUsers, array_map('current', $data));
}
// collapse multiple occurrences of certain characters (e.g. ascii lines) for the subject
$subject = preg_replace('/([=_+@#%^*-])\1+/', '\1', $keywords->filter($change->getDescription()));
// configure a message for mail module to deliver
$event->setParam(
'mail',
array(
'subject' => 'Commit @' . $change->getId() . ' - ' . $subject,
'cropSubject' => 80,
'toUsers' => $toUsers,
'fromUser' => $activity->get('user') ?: $change->getUser(),
'messageId' => '<change-' . $change->getId() . '-' . time() . '@swarm>',
'inReplyTo' => '<topic-' . $activity->get('topic') . '@swarm>',
'htmlTemplate' => __DIR__ . '/view/mail/commit-html.phtml',
'textTemplate' => __DIR__ . '/view/mail/commit-text.phtml',
)
);
} catch (\Exception $e) {
$services->get('logger')->err($e);
}
},
-190
);
// since the 'changesave' event has a bug that causes it to fire before the change is actually saved,
// we schedule a future task that gets run after a 5 second delay
// NOTE: if you want to subscribe to an event that fires when the change has been saved, you will want to
// add a task for the 'changesaved' event
$events->attach(
'task.changesave',
function ($event) use ($services) {
// schedule the task in the future to handle the synchronization
$services->get('queue')->addTask(
'changesaved',
$event->getParam('id'),
$event->getParam('data'),
time() + 5
);
},
200
);
// this task actually does the description synchronization work between reviews and changes
$events->attach(
'task.changesaved',
function ($event) use ($services) {
$id = $event->getParam('id');
$p4Admin = $services->get('p4_admin');
$config = $services->get('config');
// if we're not configured to synchronize descriptions, bail out
if (!isset($config['reviews']['sync_descriptions']) || !$config['reviews']['sync_descriptions']) {
return;
}
// nothing to synchronize if this is a new change
if ($id === 'default') {
return;
}
try {
$change = Change::fetch($id, $p4Admin);
// find any associated reviews with this change, and ensure they are updated (along with
// any other changes associated with those reviews)
$reviews = Review::fetchAll(array(Review::FETCH_BY_CHANGE => $id), $p4Admin);
$keywords = $services->get('review_keywords');
foreach ($reviews as $review) {
$description = $review->getDescription();
if (!$review->syncDescription(
$keywords->filter($change->getDescription()),
$keywords->update($change->getDescription(), array('id' => $review->getId()))
)) {
continue;
}
// schedule task.review so @mentions get updated
$services->get('queue')->addTask(
'review',
$review->getId(),
array(
'previous' => array('description' => $description),
'isDescriptionChange' => true
)
);
}
} catch (\Exception $e) {
$services->get('logger')->err($e);
}
},
200
);
}
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__,
),
),
);
}
}