<?php
/**
* Perforce Swarm
*
* @copyright 2014 Perforce Software. All rights reserved.
* @license Please see LICENSE.txt in top-level folder of this distribution.
* @version <release>/<patch>
*/
namespace Workshop;
use Application\Filter\StringToId;
use P4\Filter\Utf8;
use Projects\Validator\BranchPath as BranchPathValidator;
use Projects\Model\Project as Project;
use Users\Model\User;
use Zend\Console\Request as ConsoleRequest;
use Zend\Mvc\MvcEvent;
use Zend\View\Model\JsonModel;
class Module
{
/**
* Bootstrap events.
*
* @param MvcEvent $event the bootstrap event
* @return void
*/
public function onBootstrap(MvcEvent $event)
{
$application = $event->getApplication();
// attach to project add/delete events
$events = $application->getEventManager();
$events->attach(
array(MvcEvent::EVENT_FINISH),
array($this, 'updateJobSpec'),
-200
);
// update the project filter
$this->updateProjectFilter($event);
// update the job spec to Workshop standards
$events->attach(
array(MvcEvent::EVENT_ROUTE),
array($this, 'setJobSpec'),
-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__,
),
),
);
}
/**
* If the job spec does not match the expected workshop spec, update it.
*
* @param MvcEvent $event
*/
public function setJobSpec(MvcEvent $event)
{
$routeMatch = $event->getRouteMatch();
if (!$routeMatch) {
return;
}
$application = $event->getApplication();
$services = $application->getServiceManager();
$p4 = $services->get('p4');
if (!$p4) {
return;
}
$route = $routeMatch->getMatchedRouteName();
$request = $event->getRequest();
if ($request instanceof ConsoleRequest) {
return;
}
$method = $request->getMethod();
// only proceed if submitting the add project form or forking a project
if (!(($route == 'projectAddAvatar' || $route == 'add-project') && $method == 'POST')
&& !($route == 'forkProject' && $method == 'GET')) {
return;
}
$spec = \P4\Spec\Definition::fetch('job', $p4);
$fields = $spec->getFields();
if (array_key_exists('Project', $fields)) {
return;
}
// remove these unused fields
unset($fields['User']);
unset($fields['Date']);
$fields['Project'] = array (
'code' => '106',
'dataType' => 'select',
'options' => '',
'displayLength' => '10',
'fieldType' => 'required',
'default' => 'setme',
);
$fields['Severity'] = array (
'code' => '109',
'dataType' => 'select',
'options' => array('A', 'B', 'C'),
'displayLength' => '10',
'fieldType' => 'required',
'default' => 'C',
);
$fields['ReportedBy'] = array (
'code' => '103',
'dataType' => 'word',
'displayLength' => '32',
'fieldType' => 'required',
'default' => '$user',
);
$fields['ReportedDate'] = array (
'code' => '104',
'dataType' => 'date',
'displayLength' => '20',
'fieldType' => 'once',
'default' => '$now',
);
$fields['ModifiedBy'] = array (
'code' => '110',
'dataType' => 'word',
'displayLength' => '20',
'fieldType' => 'always',
'default' => '$user',
);
$fields['ModifiedDate'] = array (
'code' => '111',
'dataType' => 'date',
'displayLength' => '20',
'fieldType' => 'always',
'default' => '$now',
);
$fields['OwnedBy'] = array (
'code' => '108',
'dataType' => 'word',
'displayLength' => '32',
'fieldType' => 'required',
);
$fields['DevNotes'] = array (
'code' => '107',
'dataType' => 'text',
'displayLength' => '0',
'fieldType' => 'optional',
);
$fields['Type'] = array (
'code' => '112',
'dataType' => 'select',
'options' => array('Bug', 'Feature'),
'displayLength' => '7',
'fieldType' => 'required',
'default' => 'Bug',
);
$spec->setFields($fields);
$spec->save();
// set the list of job ids
$this->updateJobSpec($event);
}
/**
* Modify the project filter to:
* - allow and set the default value for the creator field on add
* - enforce the Workshop naming scheme for projects
* - enforce the Workshop id scheme for projects
* - fill in the jobspec for projects, if empty
*
* @param MvcEvent $event
*/
public function updateProjectFilter(MvcEvent $event)
{
$application = $event->getApplication();
$services = $application->getServiceManager();
$toId = new StringToId;
// Modify the project filter
$filters = $services->get('InputFilterManager');
$p4 = $services->get('p4');
$projectFilter = $filters->get('ProjectFilter');
// callback to create branch id from name
$toBranchId = function ($name) {
// don't just use $toId->filter() as we want to allow . and _ characters
// Attempt to replace uppercase unicode with dashes
// if the mbstring extension is not installed.
$utf8 = new Utf8;
$id = function_exists('mb_strtolower')
? mb_strtolower($name, 'UTF-8')
: strtolower($name);
$id = preg_replace(
'/[�\p{Lu}]+/u',
'-',
$utf8->filter($id)
);
// replace anything except the matching characters with - characters
$id = trim(
preg_replace('/[^a-z0-9\x80-\xFF\_\.]+/', '-', $id),
'-'
);
return $id;
};
// copy callback to validate users (as its used on multiple elements)
$usersValidatorCallback = function ($value) use ($p4) {
if (in_array(false, array_map('is_string', $value))) {
return 'User ids must be strings';
}
$unknownIds = array_diff($value, User::exists($value, $p4));
if (count($unknownIds)) {
return 'Unknown user id(s) ' . implode(', ', $unknownIds);
}
return true;
};
$generateProjectId = function ($value = null) use ($p4, $projectFilter, $toId, $services) {
if ($value === null) {
$value = $toId($projectFilter->getRawValue('name'));
}
if ($projectFilter->getMode() !== $projectFilter::MODE_ADD) {
$user = Project::fetch($projectFilter->getRawValue('id'), $p4)->get('creator');
} else {
$user = $services->get('user')->getId();
}
return $user . '-' . $toId($value);
};
// add the creator field and set its value to the current user's id, if we're creating a project
// if editing the project, use the provided id to fetch the project's creator and return it, ensuring
// the creator can never be changed by the user
$projectFilter->add(
array(
'name' => 'creator',
'required' => true,
'filters' => array(
array(
'name' => 'Callback',
'options' => array(
'callback' => function ($value) use ($projectFilter, $services) {
if ($projectFilter->getMode() !== $projectFilter::MODE_ADD) {
$p4Admin = $services->get('p4admin');
$id = $projectFilter->getRawValue('id');
$project = Project::fetch($id, $p4Admin);
$currentCreator = $project->get('creator');
return $currentCreator;
}
$user = $services->get('user');
return $user->getId();
}
)
)
),
)
);
// prepend the id with the current user's id if adding a project
// only run through StringToId if adding, already verified on edit.
$projectFilter->remove('id')->add(
array(
'name' => 'id',
'filters' => array(
array(
'name' => 'Callback',
'options' => array(
'callback' => function ($value) use ($projectFilter, $generateProjectId) {
if ($projectFilter->getMode() !== $projectFilter::MODE_ADD) {
return $value;
}
return $generateProjectId($value);
}
)
)
)
)
);
// copied from original job filter
$reserved = array('add', 'edit', 'delete');
// ensure name is given and produces a usable/unique id.
$projectFilter->remove('name')->add(
array(
'name' => 'name',
'filters' => array('trim'),
'validators' => array(
array(
'name' => 'NotEmpty',
'options' => array(
'message' => "Name is required and can't be empty."
)
),
array(
'name' => '\Application\Validator\Callback',
'options' => array(
'callback' => function ($value) use ($p4, $reserved, $projectFilter, $generateProjectId) {
if (empty($value)) {
return 'Name must contain at least one letter or number.';
}
// if it isn't an add, we assume the caller will take care
// of ensuring existence.
if ($projectFilter->getMode() !== $projectFilter::MODE_ADD) {
return true;
}
$id = $generateProjectId($value);
// try to get project (including deleted) matching the name
$matchingProjects = Project::fetchAll(
array(
Project::FETCH_INCLUDE_DELETED => true,
Project::FETCH_BY_IDS => array($id)
),
$p4
);
if ($matchingProjects->count() || in_array($id, $reserved)) {
return 'This name is taken. Please pick a different name.';
}
return true;
}
)
)
)
)
);
$projectFilter->remove('branches')->add(
array(
'name' => 'branches',
'required' => false,
'filters' => array(
array(
'name' => 'Callback',
'options' => array(
'callback' => function ($value) use ($p4, $toId, $toBranchId, $projectFilter, $services) {
// normalize the posted branch details to only contain our expected keys
// also, generate an id (based on name) for entries lacking one
$normalized = array();
$defaults = array(
'id' => null,
'name' => null,
'paths' => '',
'moderators' => array()
);
// do not use generateProjectId as we don't want the user prepended
$project = $toId($projectFilter->getRawValue('name'));
if ($projectFilter->getMode() == $projectFilter::MODE_ADD) {
$user = $services->get('user')->getId();
} else {
$user = Project::fetch($projectFilter->getRawValue('id'), $p4)->get('creator');
}
foreach ((array) $value as $branch) {
$branch = (array) $branch + $defaults;
$branch = array_intersect_key($branch, $defaults);
if (!strlen($branch['id'])
|| $projectFilter->getMode() == $projectFilter::MODE_ADD) {
$branch['id'] = $toBranchId($branch['name']);
}
// turn our paths text input into an array based on Workshop rules
$branch['paths'] = array(
'//guest/' . $user . '/' . $project . '/' . $branch['id'] . '/...'
);
$normalized[] = $branch;
}
return $normalized;
}
)
)
),
'validators' => array(
array(
'name' => '\Application\Validator\Callback',
'options' => array(
'callback' => function ($value) use ($usersValidatorCallback, $p4) {
// ensure all branches have a name and id.
// also ensure that no id is used more than once.
$ids = array();
$branchPath = new BranchPathValidator(array('connection' => $p4));
foreach ((array) $value as $branch) {
if (!strlen($branch['name'])) {
return "All branches require a name.";
}
// given our normalization, we assume an empty id results from a bad name
if (!strlen($branch['id'])) {
return 'Branch name must contain at least one letter or number.';
}
if (in_array($branch['id'], $ids)) {
return "Two branches cannot have the same id. '"
. $branch['id'] . "' is already in use for this project.";
}
// validate branch paths
if (!$branchPath->isValid($branch['paths'])) {
return "Error in '" . $branch['name'] . "' branch: "
. implode(' ', $branchPath->getMessages());
}
// verify branch moderators
$moderatorsCheck = $usersValidatorCallback($branch['moderators']);
if ($moderatorsCheck !== true) {
return $moderatorsCheck;
}
$ids[] = $branch['id'];
}
return true;
}
)
)
)
)
);
// enforce Workshop naming scheme
$projectFilter->add(
array(
'name' => 'name',
'validators' => array(
array(
'name'=> 'regex',
'options' => array(
'pattern' => '/^[\w\s\-]+$/',
'message' => 'Name must contain only alphanumeric and underscore characters.',
)
)
)
)
);
// replace default jobview validator to fill in if empty
$projectFilter->remove('jobview')->add(
array(
'name' => 'jobview',
'required' => false,
'filters' => array(
'trim',
array(
'name' => 'Callback',
'options' => array(
'callback' => function ($value) use ($generateProjectId) {
if (empty($value)) {
return 'project=' . $generateProjectId();
}
return $value;
}
)
)
),
'validators' => array(
array(
'name' => '\Application\Validator\Callback',
'options' => array(
'callback' => function ($value) {
if (!strlen($value)) {
return true;
}
$filters = preg_split('/\s+/', $value);
foreach ($filters as $filter) {
if (!preg_match('/^([^=()|]+)=([^=()|]+)$/', $filter)) {
return "Job filter only supports key=value conditions and the '*' wildcard.";
}
}
return true;
}
)
)
)
)
);
$filters->setService('ProjectFilter', $projectFilter);
}
/**
* For the add-project route, only when the form is submitted (vs displayed)
* OR for the delete-project route,
* OR for the fork route,
* if the response is JSON formatted and the result is valid,
* then update the job spec with the list of current projects.
*
* @param MvcEvent $event
*/
public function updateJobSpec(MvcEvent $event)
{
$routeMatch = $event->getRouteMatch();
if (!$routeMatch) {
return;
}
$application = $event->getApplication();
$services = $application->getServiceManager();
$p4Admin = $services->get('p4_admin');
if (!$p4Admin) {
return;
}
$route = $routeMatch->getMatchedRouteName();
$request = $event->getRequest();
if ($request instanceof ConsoleRequest) {
return;
}
$method = $request->getMethod();
$result = $event->getResult();
if ((($route == 'add-project' && $method == 'POST') || $route == 'delete-project' || $route == 'forkProject')
&& ($result instanceof JsonModel && $result->isValid)) {
$spec = \P4\Spec\Definition::fetch('job', $p4Admin);
$fields = $spec->getFields();
if ($spec->hasField('Project')) {
$projects = Project::fetchAll(array(), $p4Admin)->toArray();
$fields['Project']['options'] = array_keys($projects);
$spec->setFields($fields);
try {
$spec->save();
} catch (Exception $e) {
}
}
}
}
}