- <?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 Reviews\Model;
-
- use P4\Connection\ConnectionInterface as Connection;
- use P4\Connection\CommandResult;
- use P4\Connection\Exception\CommandException;
- use P4\Log\Logger;
- use P4\OutputHandler\Limit;
- use P4\Spec\Client;
- use P4\Spec\Depot;
- use P4\Spec\Exception\NotFoundException;
- use P4\Spec\Change;
- use P4\Uuid\Uuid;
- use Users\Model\User;
- use Projects\Filter\ProjectList as ProjectListFilter;
- use Projects\Model\Project;
- use Record\Exception\Exception;
- use Record\Key\AbstractKey as KeyRecord;
-
- /**
- * Provides persistent storage and indexing of reviews.
- */
- class Review extends KeyRecord
- {
- const KEY_PREFIX = 'swarm-review-';
- const UPGRADE_LEVEL = 4;
-
- const LOCK_CHANGE_PREFIX = 'change-review-';
-
- const FETCH_BY_AUTHOR = 'author';
- const FETCH_BY_CHANGE = 'change';
- const FETCH_BY_PARTICIPANTS = 'participants';
- const FETCH_BY_HAS_REVIEWER = 'hasReviewer';
- const FETCH_BY_PROJECT = 'project';
- const FETCH_BY_GROUP = 'group';
- const FETCH_BY_STATE = 'state';
- const FETCH_BY_TEST_STATUS = 'testStatus';
-
- const STATE_NEEDS_REVIEW = 'needsReview';
- const STATE_NEEDS_REVISION = 'needsRevision';
- const STATE_APPROVED = 'approved';
- const STATE_REJECTED = 'rejected';
- const STATE_ARCHIVED = 'archived';
-
- const COMMIT_CREDIT_AUTHOR = 'creditAuthor';
- const COMMIT_DESCRIPTION = 'description';
- const COMMIT_JOBS = 'jobs';
- const COMMIT_FIX_STATUS = 'fixStatus';
-
- const TEST_STATUS_PASS = 'pass';
- const TEST_STATUS_FAIL = 'fail';
-
- protected $userObjects = array();
- protected $fields = array(
- 'type' => array(
- 'accessor' => 'getType',
- 'readOnly' => true
- ),
- 'changes' => array( // changes associated with this review
- 'index' => 1301,
- 'accessor' => 'getChanges',
- 'mutator' => 'setChanges'
- ),
- 'commits' => array(
- 'accessor' => 'getCommits',
- 'mutator' => 'setCommits'
- ),
- 'versions' => array(
- 'hidden' => true,
- 'accessor' => 'getVersions',
- 'mutator' => 'setVersions'
- ),
- 'author' => array( // author of code under review
- 'index' => 1302
- ),
- 'participants' => array( // anyone who has touched the review (workflow change, commented on, etc.)
- 'index' => 1304, // we return just user ids but properties (e.g. votes) are stored here too
- 'indexOnlyKeys' => true,
- 'accessor' => 'getParticipants',
- 'mutator' => 'setParticipants'
- ),
- 'participantsData' => array(
- 'accessor' => 'getParticipantsData',
- 'mutator' => 'setParticipantsData',
- 'unstored' => true
- ),
- 'hasReviewer' => array( // flag to indicate if review has one or more reviewers
- 'index' => 1305 // necessary to avoid using wildcards in p4 search
- ),
- 'description' => array( // change description
- 'accessor' => 'getDescription',
- 'mutator' => 'setDescription',
- 'index' => 1306,
- 'indexWords' => true
- ),
- 'created', // timestamp when the review was created
- 'updated', // timestamp when the review was last updated
- 'projects' => array( // an array with project id's as keys and branches as values
- 'index' => 1307,
- 'accessor' => 'getProjects',
- 'mutator' => 'setProjects'
- ),
- 'state' => array( // one of: needsReview, needsRevision, approved, rejected
- 'index' => 1308,
- 'default' => 'needsReview',
- 'accessor' => 'getState',
- 'mutator' => 'setState'
- ),
- 'stateLabel' => array(
- 'accessor' => 'getStateLabel',
- 'unstored' => true
- ),
- 'testStatus' => array( // one of: pass, fail
- 'index' => 1309
- ),
- 'testDetails' => array(
- 'accessor' => 'getTestDetails',
- 'mutator' => 'setTestDetails'
- ),
- 'deployStatus', // one of: success, fail
- 'deployDetails' => array(
- 'accessor' => 'getDeployDetails',
- 'mutator' => 'setDeployDetails'
- ),
- 'pending' => array(
- 'index' => 1310,
- 'accessor' => 'isPending',
- 'mutator' => 'setPending'
- ),
- 'commitStatus' => array(
- 'accessor' => 'getCommitStatus',
- 'mutator' => 'setCommitStatus'
- ),
- 'token' => array(
- 'accessor' => 'getToken',
- 'mutator' => 'setToken',
- 'hidden' => true
- ),
- 'upgrade' => array(
- 'accessor' => 'getUpgrade',
- 'hidden' => true
- ),
- 'groups' => array( // an array with associated groups
- 'index' => 1311,
- 'accessor' => 'getGroups',
- 'mutator' => 'setGroups'
- ),
- );
-
- /**
- * Retrieves all records that match the passed options.
- * Extends parent to compose a search query when fetching by various fields.
- *
- * @param array $options an optional array of search conditions and/or options
- * supported options are:
- * FETCH_BY_CHANGE - set to a 'changes' value(s) to limit results
- * FETCH_BY_HAS_REVIEWER - set to limit results to include only records that:
- * * have at least one reviewer (if value is '1')
- * * don't have any reviewers (if value is '0')
- * FETCH_BY_STATE - set to a 'state' value(s) to limit results
- * FETCH_BY_TEST_STATUS - set to a 'testStatus' values(s) to limit results
- * @param Connection $p4 the perforce connection to use
- * @return \P4\Model\Fielded\Iterator the list of zero or more matching review objects
- */
- public static function fetchAll(array $options, $p4)
- {
- // normalize options
- $options += array(
- static::FETCH_BY_AUTHOR => null,
- static::FETCH_BY_CHANGE => null,
- static::FETCH_BY_PARTICIPANTS => null,
- static::FETCH_BY_HAS_REVIEWER => null,
- static::FETCH_BY_PROJECT => null,
- static::FETCH_BY_GROUP => null,
- static::FETCH_BY_STATE => null,
- static::FETCH_BY_TEST_STATUS => null
- );
-
- // build the search expression
- $options[static::FETCH_SEARCH] = static::makeSearchExpression(
- array(
- 'author' => $options[static::FETCH_BY_AUTHOR],
- 'changes' => $options[static::FETCH_BY_CHANGE],
- 'participants' => $options[static::FETCH_BY_PARTICIPANTS],
- 'hasReviewer' => $options[static::FETCH_BY_HAS_REVIEWER],
- 'projects' => $options[static::FETCH_BY_PROJECT],
- 'groups' => $options[static::FETCH_BY_GROUP],
- 'state' => $options[static::FETCH_BY_STATE],
- 'testStatus' => $options[static::FETCH_BY_TEST_STATUS]
- )
- );
-
- return parent::fetchAll($options, $p4);
- }
-
- /**
- * Return new review instance populated from the given change.
- *
- * @param Change|string $change change to populate review record from
- * @param Connection $p4 the perforce connection to use
- * @return Reviews instance of this model
- */
- public static function createFromChange($change, $p4 = null)
- {
- if (!$change instanceof Change) {
- $change = Change::fetch($change, $p4);
- }
-
- // refuse to create reviews for un-promoted remote edge shelves
- if ($change->isRemoteEdgeShelf()) {
- throw new \InvalidArgumentException(
- "Cannot create review. The change is not promoted and appears to be on a remote edge server."
- );
- }
-
- // populate data from the change
- $model = new static($p4);
- $model->set('author', $change->getUser());
- $model->set('description', $change->getDescription());
- $model->addParticipant($change->getUser());
-
- // add the change as either a pending or committed value
- if ($change->isSubmitted()) {
- $model->setPending(false);
- $model->addCommit($change);
- $model->addChange($change->getOriginalId());
- } else {
- $model->setPending(true);
- $model->addChange($change);
- }
-
- return $model;
- }
-
- /**
- * Updates this review record using the passed change.
- *
- * Add the change as a participant in this review and, if its pending,
- * updates the swarm managed shelf with the changes shelved content.
- *
- * @param Change|string $change change to populate review record from
- * @param bool $unapproveModified whether approved reviews can be unapproved if they contain
- * modified files
- * @return Review instance of this model
- * @throws \Exception re-throws any exceptions which occur during re-shelving
- */
- public function updateFromChange($change, $unapproveModified = true)
- {
- // normalize change to an object
- $p4 = $this->getConnection();
- if (!$change instanceof Change) {
- $change = Change::fetch($change, $p4);
- }
-
- // our managed shelf should always be pending but defend anyways
- $shelf = Change::fetch($this->getId(), $p4);
- if ($shelf->isSubmitted()) {
- throw new Exception(
- 'Cannot update review; the shelved change we manage is unexpectedly committed.'
- );
- }
-
- // add the passed change's id to the review
- if ($change->isSubmitted()) {
- // if we've already added a committed version for this change, nothing to do
- foreach ($this->getVersions() as $version) {
- if ($version['change'] == $change->getId() && !$version['pending']) {
- return $this;
- }
- }
-
- $this->addCommit($change);
- $this->addChange($change->getOriginalId());
- } else {
- $this->addChange($change);
- }
-
- // ensure the change user is now a participant
- $this->addParticipant($change->getUser());
-
- // clear commit status if:
- // - this review isn't mid-commit (intended to clear old errors)
- // - this review is in the process of committing the given change
- if (!$this->isCommitting() || $this->getCommitStatus('change') == $change->getOriginalId()) {
- $this->setCommitStatus(null);
- }
-
- // try to determine the stream by inspecting the user's client
- // if the client is already deleted we're left with a hard false
- // indicating we couldn't tell one way or the other.
- $stream = false;
- try {
- $stream = Client::fetch($change->getClient())->getStream();
- } catch (\InvalidArgumentException $e) {
- } catch (NotFoundException $e) {
- // failed to get stream from client (client might be on an edge)
- // we don't consider this fatal and will try another approach
- unset($e);
-
- // try to determine the stream by looking at the path to the first file
- $stream = $this->guessStreamFromChange($change);
- }
-
- // we'll need a client for this next bit, we're going to update our shelved files
- $p4->getService('clients')->grab();
- try {
- // try and hard reset the client to ensure a clean environment
- $p4->getService('clients')->reset(true, $stream);
-
- // update metadata on the canonical shelf:
- // - swap its client over to the one we grabbed
- // - match type (public/restricted) of updating change
- // - add any jobs from the updating change
- $shelf->setClient($p4->getClient())
- ->setType($change->getType())
- ->setJobs(array_unique(array_merge($change->getJobs(), $shelf->getJobs())))
- ->setUser($p4->getUser())
- ->save(true);
-
- // if the current contents of the canonical shelf are pending,
- // but not archived (as is the case for pre-versioning reviews),
- // attempt to archive it before we clobber it with this update.
- $head = end($this->getVersions());
- if ($head
- && $head['pending']
- && $head['change'] == $shelf->getId()
- && !isset($head['archiveChange'])
- ) {
- try {
- $this->retroactiveArchive($shelf);
- } catch (\Exception $e) {
- // well at least we tried!
- }
- }
-
- // evaluate whether the new version differs, we get back a flag indicating the amount of change:
- // 0 - no changes, 1 - modified name, type or digest, 2 - modified only insignificant fields
- $changesDiffer = $this->changesDiffer($shelf, $change);
-
- // revert state back to 'Needs Review' if auto state reversion is enabled, the review
- // was approved and the new version is different
- if ($this->getState() === static::STATE_APPROVED && $unapproveModified && $changesDiffer === 1) {
- $this->setState(static::STATE_NEEDS_REVIEW);
- }
-
- // if the contributing change is a commit:
- // - empty out our shelf
- // - add a version entry for the commit
- // - clear our pending flag (review is committed now)
- if ($change->isSubmitted()) {
- // forcibly delete files in our shelf (in case another client has pending resolves)
- // silence the expected exceptions that occur if no shelved files were present
- // (e.g. user commits to a committed review) or files can't be deleted due to
- // pending resolves in another client (still an issue if server version <2014.2)
- try {
- $p4->run('shelve', array('-d', '-f', '-c', $shelf->getId()));
- } catch (CommandException $e) {
- if (preg_match('/needs resolve\sShelve aborted/', $e->getMessage())) {
- Logger::log(Logger::ERR, $e);
- } elseif (strpos($e->getMessage(), 'No shelved files in changelist to delete.') === false) {
- throw $e;
- }
- unset($e);
- }
-
- // write a new version entry for this commit
- // only include the stream if we could determine its value
- $this->addVersion(
- array(
- 'change' => $change->getId(),
- 'user' => $change->getUser(),
- 'time' => $change->getTime(),
- 'pending' => false,
- 'difference' => $changesDiffer
- )
- + ($stream !== false ? array('stream' => $stream) : array())
- );
-
- // at this point we have no shelved files, clear our isPending status
- $this->setPending(false);
- }
-
- // if the contributing change is pending and files have been updated:
- // - unshelve it and check if that opened any files
- // - bypass exclusive locks if supported, always check and throw if need be
- // - update the canonical shelf with opened files
- // - create a new archive/version for posterity
- // - set the pending flag (review is not committed now)
- if ($change->isPending() && $changesDiffer) {
- $flags = $this->canBypassLocks() ? array('--bypass-exclusive-lock') : array();
- $flags = array_merge($flags, array('-s', $change->getId(), '-c', $shelf->getId()));
- $result = $p4->run('unshelve', $flags);
- $opened = array_filter($result->getData(), 'is_array') && !$result->hasWarnings();
- $this->exclusiveOpenCheck($result);
- if ($opened) {
- // shelve opened files to the canonical shelf
- $p4->run('shelve', array('-r', '-c', $shelf->getId()));
-
- // we know we've shelved some files so update our 'pending' status
- $this->setPending(true);
-
- // make a new archive/version for this update
- $this->archiveShelf(
- $change,
- array('difference' => $changesDiffer)
- + ($stream !== false ? array('stream' => $stream) : array())
- );
-
- // we're done with the workspace files, be friendly and remove them
- $p4->getService('clients')->clearFiles();
- }
- }
- } catch (\Exception $e) {
- }
-
- // send workspace files to Garbage Compactor 3263827 before releasing the client
- try {
- $p4->getService('clients')->clearFiles();
- } catch (\Exception $clearException) {
- // we're in a whole world of hurt right now, but let's log before the sweet release of death
- $message = 'Could not clear files on client: ' . $p4->getClient() . ' ' . $clearException->getMessage();
- Logger::log(Logger::ERR, $message);
- }
-
- $p4->getService('clients')->release();
-
- // if badnesses occurred re-throw now that we have released our client lock
- if (isset($e)) {
- throw $e;
- }
-
- return $this;
- }
-
- /**
- * Commits this review's pending work to perforce.
- *
- * You'll need to call 'update from change' after running this to have
- * the new change added to the review record.
- *
- * @param array $options optional - currently supported options are:
- * COMMIT_CREDIT_AUTHOR - credit change to review author
- * COMMIT_DESCRIPTION - change description
- * COMMIT_JOBS - list of jobs to attach to the committing change
- * COMMIT_FIX_STATUS - status to set on jobs upon commit
- * @param Connection|null $p4 optional - connection to use for the submit or null for default
- * it is recommend this be done as the user committing.
- * @return Change the submitted change object; useful for passing to update from change
- * @throws Exception if there are no pending files to commit
- * @throws \Exception re-throws any errors that occur during commit
- */
- public function commit(array $options = array(), Connection $p4 = null)
- {
- // normalize connection to use, we may have received null
- $p4 = $p4 ?: $this->getConnection();
-
- // normalize options
- $options += array(
- static::COMMIT_CREDIT_AUTHOR => null,
- static::COMMIT_DESCRIPTION => null,
- static::COMMIT_JOBS => null,
- static::COMMIT_FIX_STATUS => null
- );
-
- // ensure commit status is set
- $this->setCommitStatus(array('start' => time()))->save();
-
- // we'll need a client for this next bit
- $p4->getService('clients')->grab();
- try {
- // try and hard reset the client to ensure a clean environment
- // if the change is against a stream; make sure we're on it
- $p4->getService('clients')->reset(true, $this->getHeadStream());
-
- // get the authoritative shelf, we need to examine if its restricted when creating the submit
- $shelf = Change::fetch($this->getId(), $p4);
-
- // create a new 'commit' change, we never commit the managed change
- // as we may later need to re-open this review.
- $commit = new Change($p4);
- $commit->setDescription($options[static::COMMIT_DESCRIPTION] ?: $this->get('description'))
- ->setJobs($options[static::COMMIT_JOBS])
- ->setFixStatus($options[static::COMMIT_FIX_STATUS])
- ->setType($shelf->getType())
- ->save();
-
- // update status with our change id, state and committer
- $this->setCommitStatus('change', $commit->getId())
- ->setCommitStatus('status', 'Unshelving')
- ->setCommitStatus('committer', $p4->getUser())
- ->save();
-
- // unshelve our managed change and check if that opened any files.
- // bypass exclusive locks if supported, always check and throw if need be.
- $flags = $this->canBypassLocks() ? array('--bypass-exclusive-lock') : array();
- $flags = array_merge($flags, array('-s', $this->getId(), '-c', $commit->getId()));
- $result = $p4->run('unshelve', $flags);
- $opened = $result->hasData() && !$result->hasWarnings();
- $this->exclusiveOpenCheck($result);
-
- // if we didn't unshelve any files blow up.
- if (!$opened) {
- throw new Exception(
- "Review doesn't contain any files to commit."
- );
- }
-
- // we need to get the change id in as a commit early to
- // avoid having issues with double reporting activity.
- // also a good opportunity to update the state.
- $this->addCommit($commit->getId())
- ->setCommitStatus('status', 'Committing')
- ->save();
-
- // we must have unshelved some work, lets commit it.
- $commit->submit();
-
- $this->setCommitStatus('end', time())
- ->setCommitStatus('status', 'Committed')
- ->save();
- } catch (\Exception $e) {
- // if we got far enough to create the commit, remove it from the
- // list of 'commits' for this review as we didn't make it in.
- if (isset($commit) && $commit->getId()) {
- $this->setCommits(array_diff($this->getCommits(), array($commit->getId())));
- $this->setChanges(array_diff($this->getChanges(), array($commit->getId())));
- }
-
- // clear out the commit status but convey that we failed
- // we only use the first line of the exception as they get a bit too
- // detailed later on when not mergable.
- $this->setCommitStatus(array('error' => strtok($e->getMessage(), "\n")))
- ->save();
-
- // as something went wrong we might be leaving files behind; cleanup
- $p4->getService('clients')->clearFiles();
-
- // delete the commit change we created; it's no longer needed
- // suppress exceptions without overwriting the one that got us here
- try {
- isset($commit) && $commit->delete();
- } catch (\Exception $ignore) {
- }
- }
-
- $p4->getService('clients')->release();
-
- // if badnesses occurred re-throw now that we have released our client lock
- if (isset($e)) {
- throw $e;
- }
-
- // if the credit author flag is set, re-own the change so the review creator gets credit
- if ($options[static::COMMIT_CREDIT_AUTHOR] && $p4->getUser() != $this->get('author')) {
- $p4Admin = $this->getConnection();
- $p4Admin->getService('clients')->grab();
- try {
- $commit->setConnection($p4Admin)
- ->setUser($this->get('author'))
- ->save(true);
- } catch (\Exception $e) {
- Logger::log(Logger::ERR, 'Failed to re-own change ' . $commit->getId() . ' to ' . $this->get('author'));
- }
-
- // ensure client gets released and we stop using the admin connection even if an exception occurred
- $p4Admin->getService('clients')->release();
- $commit->setConnection($p4);
- }
-
- return $commit;
- }
-
- /**
- * Returns the type of review we're dealing with.
- *
- * @return string the 'type' of this review, one of default or git
- */
- public function getType()
- {
- return $this->getRawValue('type') ?: 'default';
- }
-
- /**
- * Get the commit status for this code review
- *
- * @param string|null $field a specific key to retrieve or null for all commit status
- * if a field is specified which doesn't exist null is returned.
- * @return string Current state of this code review
- */
- public function getCommitStatus($field = null)
- {
- $status = (array) $this->getRawValue('commitStatus');
-
- // validate commit status
- // detect race-condition where commit-status is not empty, but the commit has been processed
- // if the commit is in changes and versions, we have processed it and status should be empty
- if (isset($status['change']) && in_array($status['change'], $this->getChanges())) {
- // extract commits from versions so we can look for the commit in question
- $commits = array();
- foreach ((array) $this->getRawValue('versions') as $version) {
- $version += array('change' => null, 'pending' => null);
- if (!$version['pending'] && $version['change'] >= $status['change']) {
- $commits[] = '@=' . $version['change'];
- }
- }
-
- // if the commit was not renumbered the number could match exactly
- // if we don't get an exact match, we could still match the original id
- if ($commits && in_array('@=' . $status['change'], $commits)) {
- $status = array();
- } elseif ($commits) {
- try {
- foreach ($this->getConnection()->run('changes', $commits)->getData() as $change) {
- if (isset($change['oldChange']) && $status['change'] == $change['oldChange']) {
- $status = array();
- }
- }
- } catch (\Exception $e) {
- // not worth breaking things to possibly fix a race condition
- }
- }
- }
-
- if (!$field) {
- return $status;
- }
-
- return isset($status[$field]) ? $status[$field] : null;
- }
-
- /**
- * Set the commit status for this code review.
- *
- * @param string|array $fieldOrValues a specific field name or an array of all new values
- * @param mixed $value if a field was specified in param 1, the new value to use
- * @return Review to maintain a fluent interface
- */
- public function setCommitStatus($fieldOrValues, $value = null)
- {
- // if param 1 isn't a string it our new commit status
- if (!is_string($fieldOrValues)) {
- return $this->setRawValue('commitStatus', (array) $fieldOrValues);
- }
-
- // param 1 was a string, lets treat it as specific key to update
- $status = $this->getCommitStatus();
- $status[$fieldOrValues] = $value;
- return $this->setRawValue('commitStatus', $status);
- }
-
- /**
- * This method will determine if a commit is presently in progress based on the
- * data held in commit status.
- *
- * @return bool true if commit is actively in progress, false otherwise
- */
- public function isCommitting()
- {
- return $this->getCommitStatus() && !$this->getCommitStatus('error');
- }
-
- /**
- * Get the current state for this code review e.g. needsReview
- *
- * @return string Current state of this code review
- */
- public function getState()
- {
- return $this->getRawValue('state');
- }
-
- /**
- * Set the current state for this code review e.g. needsReview
- *
- * @param string $state Current state of this code review
- * @return Review to maintain a fluent interface
- */
- public function setState($state)
- {
- // if we got approved:commit, simply store approved, the second
- // half is a queue to our caller that they aught to commit us.
- if ($state == 'approved:commit') {
- $state = 'approved';
- }
-
- return $this->setRawValue('state', $state);
- }
-
- /**
- * Get the participant data. Note the values are stored under the 'participants' field
- * but that accessor only exposes the IDs, this accessor exposes... _everything_.
- * The author is automatically included.
- *
- * User ids will be keys and each will have an array of properties associated to it
- * (such as vote, required, etc.).
- * If a specific 'field' is specified the user ids will be keys and each will have
- * just the specified property associated to it. Users lacking the specified field
- * will not be returned.
- *
- * @param null|string $field optional - limit returned data to only 'field'; users lacking
- * the specified field will not be included in the result.
- * @return array participant ids as keys each associated with properties array.
- */
- public function getParticipantsData($field = null)
- {
- // handle upgrade to v3 (2014.2)
- // - numerically indexed user ids become arrays keyed on user id
- // - votes move into participant array
- if ((int) $this->get('upgrade') < 3) {
- $participants = array();
- foreach ((array) $this->getRawValue('participants') as $key => $value) {
- if (is_string($value)) {
- $key = $value;
- $value = array();
- }
-
- $participants[$key] = $value;
- }
-
- // move votes into participant metadata
- if ($this->issetRawValue('votes')) {
- // note we only honor votes for 'reviewers' if you are not a reviewer
- // your vote would have been ignored by getVotes and should be ignored here
- $author = $this->get('author');
- foreach ((array) $this->getRawValue('votes') as $user => $vote) {
- if (isset($participants[$user]) && $user !== $author) {
- $participants[$user] = array('vote' => $vote);
- }
- }
- $this->unsetRawValue('votes');
- }
-
- $this->setRawValue('participants', $participants);
- }
-
- // handle upgrade to v4 (2014.3)
- // - single vote values become structured arrays with version info, e.g. [value => 1, version => 3]
- if ((int) $this->get('upgrade') < 4) {
- $participants = $this->getRawValue('participants');
- foreach ($participants as $user => $data) {
- if (isset($data['vote'])) {
- $participants[$user]['vote'] = $this->normalizeVote($user, $data['vote'], true);
- if (!$participants[$user]['vote']) {
- unset($participants[$user]['vote']);
- }
- }
- }
-
- $this->setRawValue('participants', $participants);
- }
-
- $participants = $this->normalizeParticipants($this->getRawValue('participants'));
-
- // if a specific field was specified, only include participants
- // that have that value and only include the one requested field
- if ($field) {
- foreach ($participants as $id => $data) {
- if (!isset($data[$field])) {
- unset($participants[$id]);
- } else {
- $participants[$id] = $data[$field];
- }
- }
- }
-
- return $participants;
- }
-
- /**
- * If only values is specified, updates all participant data.
- * In that usage values should appear similar to:
- * $values => array('gnicol' => array(), 'slord' => array('required' => true))
- *
- * If both a values and field are specified, updates the specific property on the
- * participants array. Any participants not specified in the updated values array
- * will have the property removed if its already present. They will not be removed
- * as a participant though. We will then ensure a participate entry is present for
- * all specified users and that the value reflects what was passed.
- * In that usage values should appear similar to:
- * $values => array('slord' => true), $field => 'required'
- *
- * @param array|null $values the updated id/value(s) array
- * @param null|string $field optional - a specific field we are updating (e.g. vote)
- * @return Review to maintain a fluent interface
- */
- public function setParticipantsData(array $values = null, $field = null)
- {
- // if no field was specified; we're updating everything just normalize, set, return
- if ($field === null) {
- return $this->setRawValue('participants', $this->normalizeParticipants($values, true));
- }
-
- // looks like we're just doing one specific field; make the update
- // first remove the specified field from all participants that are not listed
- $values = (array) $values;
- $participants = $this->getParticipantsData();
- foreach (array_diff_key($participants, $values) as $id => $value) {
- unset($participants[$id][$field]);
- }
-
- // ensure a participant entry exists for all specified users and update value
- foreach ($values as $id => $value) {
- $participants += array($id => array());
- $participants[$id][$field] = $value;
- }
-
- return $this->setRawValue('participants', $this->normalizeParticipants($participants, true));
- }
-
- /**
- * Update value(s) for a specific participant.
- *
- * If no field is specified, this clobbers the existing data for the given
- * participant with the new value.
- * If a field is specified, only the specific field is updated; any other
- * fields present on the participant are unchanged.
- *
- * @param string $user the user we are setting data on
- * @param mixed $value an array of all values (if no field was specified) otherwise the new value for $field
- * @param mixed $field optional - if specified the specific field to update
- * @return Review to maintain a fluent interface
- */
- public function setParticipantData($user, $value, $field = null)
- {
- $participants = $this->getParticipantsData();
- $participants += array($user => array());
-
- // if a specific field was specified; maintain all other properties
- if ($field) {
- $value = array($field => $value) + $participants[$user];
- }
-
- $participants[$user] = (array) $value;
-
- return $this->setParticipantsData($participants);
- }
-
- /**
- * Get list of participants associated with this review.
- * The current author is automatically included.
- *
- * @return array list of participants associated with this record
- */
- public function getParticipants()
- {
- return array_keys($this->getParticipantsData());
- }
-
- /**
- * Set participants associated with this review record.
- * If we have existing entries for any of the specified participants we will persist
- * their properties (e.g. votes) not throw them away.
- *
- * @param string|array $participants list of participants
- * @return Review to maintain a fluent interface
- */
- public function setParticipants($participants)
- {
- $participants = array_filter((array) $participants);
- $participants = array_fill_keys($participants, array()) + array($this->get('author') => array());
- $participants = array_intersect_key($this->getParticipantsData(), $participants) + $participants;
-
- return $this->setRawValue('participants', $this->normalizeParticipants($participants, true));
- }
-
- /**
- * Get the description of this review.
- *
- * @return string|null the review's description
- */
- public function getDescription()
- {
- return $this->getRawValue('description');
- }
-
- /**
- * Set the description for this review.
- *
- * @param string|null $description the new description for this review
- * @return Review to maintain a fluent interface
- */
- public function setDescription($description)
- {
- return $this->setRawValue('description', $description);
- }
-
- /**
- * Get list of reviewers (all participants excluding the author).
- *
- * @return array list of reviewers associated with this record
- */
- public function getReviewers()
- {
- return array_values(array_diff($this->getParticipants(), array($this->get('author'))));
- }
-
- /**
- * Add one or more participants to this review record.
- *
- * @param string|array $participant participant(s) to add
- * @return Review to maintain a fluent interface
- */
- public function addParticipant($participant)
- {
- return $this->setParticipants(
- array_merge($this->getParticipants(), (array) $participant)
- );
- }
-
- /**
- * Add one or more required reviewers to this review record.
- *
- * @param string|array $required required reviewer(s) to add
- * @return Review to maintain a fluent interface
- */
- public function addRequired($required)
- {
- return $this->setParticipantsData(
- array_fill_keys(
- array_merge(
- array_keys(array_filter($this->getParticipantsData('required'))),
- (array) $required
- ),
- true
- ),
- 'required'
- );
- }
-
- /**
- * Get list of votes (including stale votes)
- *
- * @return array list of votes left of this record
- */
- public function getVotes()
- {
- return $this->getParticipantsData('vote');
- }
-
- /**
- * Set votes on this review record
- *
- * @param array $votes list of votes
- * @return Review to maintain a fluent interface
- */
- public function setVotes($votes)
- {
- return $this->setParticipantsData($votes, 'vote');
- }
-
- /**
- * This method is used to ensure arrays of changes always contain integers
- *
- * It will make an attempt to cast string integers to real integers,
- * it will detect Change objects and convert them to Change IDs,
- * and failures will be eliminated.
- *
- * @param array $changes the array of Changes/IDs to be normalized
- * @return array the normalized array of Change IDs
- */
- protected function normalizeChanges($changes)
- {
- $changes = (array) $changes;
-
- foreach ($changes as $key => $change) {
- if ($change instanceof Change) {
- $change = $change->getId();
- }
-
- if (!ctype_digit((string) $change)) {
- unset($changes[$key]);
- } else {
- $changes[$key] = (int) $change;
- }
- }
-
- return array_values(array_unique($changes));
- }
-
- /**
- * Add a user's vote to this review record
- *
- * @param string $user userid of the user to add
- * @param int $vote vote (-1/0/1) to associate with the user
- * @param int|null $version optional - version to add vote for
- * defaults to current (head) version
- */
- public function addVote($user, $vote, $version = null)
- {
- $vote = array('value' => (int) $vote, 'version' => $version);
- return $this->setVotes(
- array_merge($this->getVotes(), array($user => $vote))
- );
- }
-
- /**
- * Returns a list of positive non-stale votes
- *
- * @return array list of votes
- */
- public function getUpVotes()
- {
- return array_filter(
- $this->getVotes(),
- function ($vote) {
- return $vote['value'] > 0 && !$vote['isStale'];
- }
- );
- }
-
- /**
- * Returns a list of negative non-stale votes
- *
- * @return array list of votes
- */
- public function getDownVotes()
- {
- return array_filter(
- $this->getVotes(),
- function ($vote) {
- return $vote['value'] < 0 && !$vote['isStale'];
- }
- );
- }
-
- /**
- * Get list of changes associated with this review.
- * This includes both pending and committed changes.
- *
- * @return array list of changes associated with this record
- */
- public function getChanges()
- {
- return $this->normalizeChanges($this->getRawValue('changes'));
- }
-
- /**
- * Set changes associated with this review record.
- *
- * @param string|array $changes list of changes
- * @return Review to maintain a fluent interface
- */
- public function setChanges($changes)
- {
- return $this->setRawValue('changes', $this->normalizeChanges($changes));
- }
-
- /**
- * Add a change associated with this review record.
- *
- * @param string $change the change to add
- * @return Review to maintain a fluent interface
- */
- public function addChange($change)
- {
- $changes = $this->getChanges();
- $changes[] = $change;
- return $this->setChanges($changes);
- }
-
- /**
- * Get list of committed changes associated with this review.
- *
- * If a change contributes to this review and is later submitted
- * that won't automatically count. We only count changes which
- * were in a submitted state at the point they updated this review.
- *
- * @return array list of commits associated with this record
- */
- public function getCommits()
- {
- return $this->normalizeChanges($this->getRawValue('commits'));
- }
-
- /**
- * Set list of committed changes associated with this review.
- *
- * See @getCommits for details.
- *
- * @param string|array $changes list of changes
- * @return Review to maintain a fluent interface
- */
- public function setCommits($changes)
- {
- $changes = $this->normalizeChanges($changes);
-
- // ensure all commits are also listed as being changes
- $this->setChanges(
- array_merge($this->getChanges(), $changes)
- );
-
- return $this->setRawValue('commits', $changes);
- }
-
- /**
- * Add a commit associated with this review record.
- *
- * @param string $change the commit to add
- * @return Review to maintain a fluent interface
- */
- public function addCommit($change)
- {
- $changes = $this->getCommits();
- $changes[] = $change;
- return $this->setCommits($changes);
- }
-
- /**
- * Get versions of this review (a version is created anytime files are updated).
- *
- * @return array a list of versions from oldest to newest
- * each version is an array containing change, user, time and pending
- */
- public function getVersions()
- {
- $versions = (array) $this->getRawValue('versions');
-
- // if there are no versions and this is an old record (level<2)
- // try fabricating versions from commits + current pending work
- // for pending work, we don't know who actually did it, so we
- // assume it was the review author.
- if (!$versions && $this->get('upgrade') < 2) {
- $versions = array();
- $changes = array();
- if ($this->getCommits() || $this->isPending()) {
- $changes = $this->getCommits();
- sort($changes, SORT_NUMERIC);
- if ($this->isPending()) {
- $changes[] = $this->getId();
- }
- $changes = Change::fetchAll(
- array(Change::FETCH_BY_IDS => $changes),
- $this->getConnection()
- );
- }
-
- foreach ($changes as $change) {
- $versions[] = array(
- 'change' => $change->getId(),
- 'user' => $change->isSubmitted() ? $change->getUser() : $this->get('author'),
- 'time' => $change->getTime(),
- 'pending' => $change->isPending()
- );
- }
-
- // hang on to the fabricated versions so we don't query changes again
- $this->setRawValue('versions', $versions);
- }
-
- // ensure head rev points to the canonical shelf, but older revs do not.
- $versions = $this->normalizeVersions($versions);
-
- return $versions;
- }
-
- /**
- * Set the list of versions. Each element must specify change, user, time and pending.
- *
- * @param array|null $versions the list of versions
- * @return Review provides fluent interface
- * @throws \InvalidArgumentException if any version doesn't contain change, user, time or pending.
- */
- public function setVersions(array $versions = null)
- {
- $versions = (array) $versions;
- foreach ($versions as $key => $version) {
- if (!isset($version['change'], $version['user'], $version['time'], $version['pending'])) {
- throw new \InvalidArgumentException(
- "Cannot set versions. Each version must specify a change, user, time and pending."
- );
- }
-
- // normalize pending to an int for consistency with the review's pending flag.
- $version['pending'] = (int) $version['pending'];
- }
-
- // ensure head rev points to the canonical shelf, but older revs do not.
- $versions = $this->normalizeVersions($versions);
-
- return $this->setRawValue('versions', $versions);
- }
-
- /**
- * Add a version to the list of versions.
- *
- * @param array $version the version details (change, user, time, pending)
- * @return Review provides fluent interface
- * @throws \InvalidArgumentException if the version doesn't contain change, user, time or pending.
- */
- public function addVersion(array $version)
- {
- $versions = $this->getVersions();
- $versions[] = $version;
-
- return $this->setVersions($versions);
- }
-
- /**
- * Get highest version number.
- *
- * @return int max version number
- */
- public function getHeadVersion()
- {
- return count($this->getVersions());
- }
-
- /**
- * Convenience method to get the revision number for a given change id.
- *
- * @param int|string|Change $change the change to get the rev number of.
- * @return int the rev number of the change or false if no such change version
- */
- public function getVersionOfChange($change)
- {
- $change = $change instanceof Change ? $change->getId() : $change;
- $versionNumber = false;
- foreach ($this->getVersions() as $key => $version) {
- if ($change == $version['change']
- || (isset($version['archiveChange']) && $change == $version['archiveChange'])
- ) {
- $versionNumber = $key + 1;
- }
- }
-
- return $versionNumber;
- }
-
- /**
- * Convenience method to get the change number for a given version.
- *
- * @param int $version the version to get the change number of.
- * @param bool $archive optional - pass true to get the archive change if available
- * by default returns the review id for pending head versions
- * @return int the change number of the given version
- * @throws Exception if no such version
- */
- public function getChangeOfVersion($version, $archive = false)
- {
- $versions = $this->getVersions();
- if (isset($versions[$version - 1]['change'])) {
- $version = $versions[$version - 1];
- return $archive && isset($version['archiveChange']) ? $version['archiveChange'] : $version['change'];
- }
-
- throw new Exception("Cannot get change of version $version. No such version.");
- }
-
- /**
- * Convenience method to get the change number of the latest version.
- *
- * @param bool $archive optional - pass true to get the archive change if available
- * by default returns the review id for pending head versions
- * @return int|null the change id of the latest version or null if no associated changes
- */
- public function getHeadChange($archive = false)
- {
- $head = end($this->getVersions());
- if (is_array($head) && isset($head['change'])) {
- return $archive && isset($head['archiveChange']) ? $head['archiveChange'] : $head['change'];
- }
-
- // if no versions, could be a new review that hasn't processed its change
- if ($this->getChanges()) {
- return max($this->getChanges());
- }
-
- return null;
- }
-
- /**
- * Convenience method to check if a given version exists.
- *
- * @param int $version the version to check for (one-based)
- * @return bool true if the version exists, false otherwise
- */
- public function hasVersion($version)
- {
- $versions = $this->getVersions();
- return $version && isset($versions[$version - 1]);
- }
-
- /**
- * Get changes associated with this review record which were in a pending
- * state when they were associated with the review.
- *
- * This is a convenience method it calculates the result by diffing
- * the full change list and the committed list.
- *
- * Note, this is a historical representation; just because there are
- * pending changes associated doesn't mean the review 'isPending'.
- *
- * @return array list of changes associated with this record
- */
- public function getPending()
- {
- return array_values(
- array_diff($this->getChanges(), $this->getCommits())
- );
- }
-
- /**
- * Set this review to pending to indicate it has un-committed files.
- * Ensures the raw value is consistently stored as a 1 or 0.
- *
- * Note: this is not directly related to getPending().
- *
- * @param bool $pending true if pending work is present false otherwise.
- * @return Review provides fluent interface
- */
- public function setPending($pending)
- {
- return $this->setRawValue('pending', $pending ? 1 : 0);
- }
-
- /**
- * This method lets you know if the review has any pending work in the
- * swarm managed change.
- *
- * Note, getPending returns a list of changes that were pending at the
- * time they were associated. It is quite possible getPending would return
- * items but 'isPending' would say no pending work presently exists.
- *
- * @return bool true if pending work is present false otherwise.
- */
- public function isPending()
- {
- return (bool) $this->getRawValue('pending');
- }
-
- /**
- * If the review has at least one committed change associated with it and
- * has no swarm managed pending work we consider it to be committed.
- *
- * @return bool true if review is committed false otherwise.
- */
- public function isCommitted()
- {
- return $this->getCommits() && !$this->isPending();
- }
-
- /**
- * Get the projects this review record is associated with.
- * Each entry in the resulting array will have the project id as the key and
- * an array of zero or more branches as the value. An empty branch array is
- * intended to indicate the project is affected but not a specific branch.
- *
- * @return array the projects set on this record.
- */
- public function getProjects()
- {
- $projects = (array) $this->getRawValue('projects');
-
- // remove deleted projects
- foreach ($projects as $project => $branches) {
- if (!Project::exists($project, $this->getConnection())) {
- unset($projects[$project]);
- }
- }
-
- return $projects;
- }
-
- /**
- * Set the projects (and their associated branches) that are impacted by this review.
- * @see ProjectListFilter for details on input format.
- *
- * @param array|string $projects the projects to associate with this review.
- * @return Review provides fluent interface
- * @throws \InvalidArgumentException if input is not correctly formatted.
- */
- public function setProjects($projects)
- {
- $filter = new ProjectListFilter;
- return $this->setRawValue('projects', $filter->filter($projects));
- }
-
- /**
- * Add one or more projects (and optionally associated branches)
- *
- * @param string|array $projects one or more projects
- * @return Review provides fluent interface
- */
- public function addProjects($projects)
- {
- $filter = new ProjectListFilter;
- return $this->setRawValue('projects', $filter->merge($this->getRawValue('projects'), $projects));
- }
-
- /**
- * Get groups this review record is associated with.
- *
- * @return array the groups set on this record.
- */
- public function getGroups()
- {
- $groups = (array) $this->getRawValue('groups');
- return array_values(array_unique(array_filter($groups, 'strlen')));
- }
-
- /**
- * Set the groups that are impacted by this review.
- *
- * @param array|string $groups the groups to associate with this review.
- * @return Review provides fluent interface
- */
- public function setGroups($groups)
- {
- $groups = array_values(array_unique(array_filter($groups, 'strlen')));
- return $this->setRawValue('groups', $groups);
- }
-
- /**
- * Add one or more groups.
- *
- * @param string|array $groups one or more groups
- * @return Review provides fluent interface
- */
- public function addGroups($groups)
- {
- return $this->setGroups(array_merge($this->getGroups(), (array) $groups));
- }
-
- /**
- * Get API token associated with this review and the latest version.
- * Note: A token is automatically created on save if one isn't already present.
- *
- * The token is intended to provide authorization when performing
- * unauthenticated updates to reviews (e.g. setting test status).
- * It also ensures that updates pertain to the latest version.
- *
- * @return array the token for this review with a version suffix
- */
- public function getToken()
- {
- return $this->getRawValue('token') . '.v' . $this->getHeadVersion();
- }
-
- /**
- * Set API token associated with this review. This method would not
- * normally be used; On save a token will automatically be created if
- * one isn't already set on the review.
- *
- * @param string|null $token the token for this review
- * @return Review provides fluent interface
- * @throws \InvalidArgumentException if token is not a valid type
- */
- public function setToken($token)
- {
- if (!is_null($token) && !is_string($token)) {
- throw new \InvalidArgumentException(
- 'Tokens must be a string or null'
- );
- }
-
- return $this->setRawValue('token', $token);
- }
-
- /**
- * Get the test details for this code review.
- *
- * @param bool $normalize optional - flag to denote whether we normalize details
- * to include version and duration keys, false by default
- * @return array test details for this code review
- */
- public function getTestDetails($normalize = false)
- {
- $raw = (array) $this->getRawValue('testDetails');
- return $normalize
- ? $raw + array('version' => null, 'startTimes' => array(), 'endTimes' => array(), 'averageLapse' => null)
- : $raw;
- }
-
- /**
- * Set the test details for this code review.
- *
- * @param array|null $details test details to set
- */
- public function setTestDetails($details = null)
- {
- return $this->setRawValue('testDetails', (array) $details);
- }
-
- /**
- * Get the deploy details for this code review.
- *
- * @return array test details for this code review
- */
- public function getDeployDetails()
- {
- return (array) $this->getRawValue('deployDetails');
- }
-
- /**
- * Set the deploy details for this code review.
- *
- * @param array|null $details test details to set
- * @return Review to maintain a fluent interface
- */
- public function setDeployDetails($details = null)
- {
- return $this->setRawValue('deployDetails', (array) $details);
- }
-
- /**
- * Extends the basic save behavior to also:
- * - update hasReviewer value based on presence of 'reviewers'
- * - set create timestamp to current time if no value was provided
- * - create an api token if we don't already have one
- * - set update timestamp to current time
- *
- * @return Review to maintain a fluent interface
- */
- public function save()
- {
- // if upgrade level is higher than anticipated, throw hard!
- // if we were to proceed we could do some damage.
- if ((int) $this->get('upgrade') > static::UPGRADE_LEVEL) {
- throw new Exception('Cannot save. Upgrade level is too high.');
- }
-
- // add author to the list of participants
- $this->addParticipant($this->get('author'));
-
- // set hasReviewer flag
- $this->set('hasReviewer', $this->getReviewers() ? 1 : 0);
-
- // if no create time is already set, use now as a default
- $this->set('created', $this->get('created') ?: time());
-
- // create a token if we don't already have any
- $this->set('token', $this->getRawValue('token') ?: strtoupper(new Uuid));
-
- // always set update time to now
- $this->set('updated', time());
-
- return parent::save();
- }
-
- /**
- * Get the current upgrade level of this record.
- *
- * @return int|null the upgrade level when this record was created or last saved
- */
- public function getUpgrade()
- {
- // if this record did not come from a perforce key (ie. storage)
- // assume it was just made and default to the current upgrade level.
- if (!$this->isFromKey && $this->getRawValue('upgrade') === null) {
- return static::UPGRADE_LEVEL;
- }
-
- return $this->getRawValue('upgrade');
- }
-
- /**
- * Upgrade this record on save.
- *
- * @param KeyRecord|null $stored an instance of the old record from storage or null if adding
- */
- protected function upgrade(KeyRecord $stored = null)
- {
- // if record is new, default to latest upgrade level
- if (!$stored) {
- $this->set('upgrade', $this->getRawValue('upgrade') ?: static::UPGRADE_LEVEL);
- return;
- }
-
- // if record is already at the latest upgrade level, nothing to do
- if ((int) $stored->get('upgrade') >= static::UPGRADE_LEVEL) {
- return;
- }
-
- // looks like we're upgrading - clear 'original' values so all fields get written
- // @todo move this down to abstract key when/if it gets smart enough to detect upgrades
- $this->original = null;
-
- // upgrade from 0/unset to 1:
- // - the 'reviewer' field has been removed
- // - the 'assigned' field has been renamed to 'hasReviewers' and is now a bool of count(reviewers)
- // - words in the description field are now indexed in lowercase (for case-insensitive matches)
- // with leading/trailing punctuation removed and using a slightly different split pattern.
- if ((int) $stored->get('upgrade') === 0) {
- unset($this->values['reviewer']);
- unset($this->values['assigned']);
-
- // need to de-index old 'assigned' field (can only have two possible values 0/1)
- $this->getConnection()->run(
- 'index',
- array('-a', 1305, '-d', $this->id),
- '30 31'
- );
- $stored->set('hasReviewer', null);
-
- // need to de-index description the old way
- $words = array_unique(array_filter(preg_split('/[\s,]+/', $stored->get('description')), 'strlen'));
- if ($words) {
- $this->getConnection()->run(
- 'index',
- array('-a', 1306, '-d', $this->id),
- implode(' ', array_map('strtoupper', array_map('bin2hex', $words))) ?: 'EMPTY'
- );
-
- // clear old value to force re-indexing of non-empty descriptions.
- $stored->set('description', null);
- }
- $this->set('upgrade', 1);
- }
-
- // upgrade to 2
- // - versions field has been introduced, get/set it to tickle upgrade code
- if ((int) $stored->get('upgrade') < 2) {
- $this->setVersions($this->getVersions());
- $this->set('upgrade', 2);
- }
-
- // upgrade to 3
- // - votes merged into participants field, get/set it to tickle upgrade
- if ((int) $stored->get('upgrade') < 3) {
- $this->setParticipantsData($this->getParticipantsData());
- $this->set('upgrade', 3);
- }
-
- // upgrade to 4
- // - votes expanded to array with 'value' and 'version' keys, get/set it to tickle upgrade
- if ((int) $stored->get('upgrade') < 4) {
- $this->setVotes($this->getVotes());
- $this->set('upgrade', 4);
- }
- }
-
- /**
- * Get topic for this review (used for comments).
- *
- * @return string topic for this review
- * @todo add a getTopics which includes the associated change topics
- */
- public function getTopic()
- {
- return 'reviews/' . $this->getId();
- }
-
- /**
- * Try to fetch the associated author user as a user spec object.
- *
- * @return User the associated author user object
- * @throws NotFoundException if user does not exist
- */
- public function getAuthorObject()
- {
- return $this->getUserObject('author');
- }
-
- /**
- * Check if the associated author user is valid (exists).
- *
- * @return bool true if the author user exists, false otherwise.
- */
- public function isValidAuthor()
- {
- return $this->isValidUser('author');
- }
-
- /**
- * Get a human-friendly label for the current state.
- *
- * @return string
- */
- public function getStateLabel()
- {
- $state = $this->get('state');
- return ucfirst(preg_replace('/([A-Z])/', ' \\1', $state));
- }
-
- /**
- * Get a list of valid transitions for this review.
- *
- * @return array a list with target states as keys and transition labels as values
- */
- public function getTransitions()
- {
- $translator = $this->getConnection()->getService('translator');
- $transitions = array(
- static::STATE_NEEDS_REVIEW => $translator->t('Needs Review'),
- static::STATE_NEEDS_REVISION => $translator->t('Needs Revision'),
- static::STATE_APPROVED => $translator->t('Approve'),
- static::STATE_APPROVED . ':commit' => $translator->t('Approve and Commit'),
- static::STATE_REJECTED => $translator->t('Reject'),
- static::STATE_ARCHIVED => $translator->t('Archive')
- );
-
- // exclude current state
- unset($transitions[$this->get('state')]);
-
- // exclude approve and commit if we lack pending work or are already committing
- if (!$this->isPending() || $this->isCommitting()) {
- unset($transitions[static::STATE_APPROVED . ':commit']);
- }
-
- // if we are pending but already approved tweak the approve
- // and commit wording to just say 'Commit'
- if ($this->isPending() && $this->get('state') == static::STATE_APPROVED) {
- $transitions[static::STATE_APPROVED . ':commit'] = 'Commit';
- }
-
- return $transitions;
- }
-
- /**
- * Deletes the current review and attempts to remove indexes.
- * Extends parent to also delete the swarm managed shelf.
- *
- * @return Review to maintain a fluent interface
- * @throws Exception if no id is set
- * @throws \Exception re-throws any exceptions caused during delete
- * @todo remove archive changes as well as canonical change
- */
- public function delete()
- {
- if (!$this->getId()) {
- throw new Exception(
- 'Cannot delete review, no ID has been set.'
- );
- }
-
- // attempt to get the associated shelved change we manage
- // if no such change exists, just let parent delete this record
- $p4 = $this->getConnection();
- try {
- $shelf = Change::fetch($this->getId(), $p4);
- } catch (NotFoundException $e) {
- return parent::delete();
- }
-
- if ($shelf->isSubmitted()) {
- throw new Exception(
- 'Cannot delete review; the shelved change we manage is unexpectedly committed.'
- );
- }
-
- // we'll need a valid client for this next bit.
- $p4->getService('clients')->grab();
- try {
- // try and hard reset the client to ensure a clean environment
- $p4->getService('clients')->reset(true, $this->getHeadStream());
-
- // if the shelf associated with this review isn't already on
- // the right client, likely won't be, swap it over and save.
- if ($shelf->getClient() != $p4->getClient() || $shelf->getUser() != $p4->getUser()) {
- $shelf->setClient($p4->getClient())->setUser($p4->getUser())->save(true);
- }
-
- // attempt to delete any shelved files off the swarm managed change
- // silence the expected exception that occurs when no shelved files were present
- try {
- $p4->run('shelve', array('-d', '-f', '-c', $this->getId()));
- } catch (CommandException $e) {
- if (strpos($e->getMessage(), 'No shelved files in changelist to delete.') === false) {
- throw $e;
- }
- unset($e);
- }
-
- // now that the shelved files are gone try and delete the actual change
- $p4->run("change", array("-d", "-f", $this->getId()));
- } catch (\Exception $e) {
- }
- $p4->getService('clients')->release();
-
- if (isset($e)) {
- throw $e;
- }
-
- // let parent wrap up by deleting the key record and indexes
- return parent::delete();
- }
-
- /**
- * Attempts to figure out what stream (if any) the head version of this review
- * is against. Useful for committing the work as you'll need to be on said stream.
- *
- * @return null|string the streams path as a string, if we can identify one, otherwise null
- */
- protected function getHeadStream()
- {
- // try to determine the stream we aught to use from the version history
- $version = end($this->getVersions());
- if (array_key_exists('stream', $version)) {
- return $version['stream'];
- }
-
- // if its not recorded and the head version is a pending change
- // we can try to guess the stream from the shelved file paths.
- if (isset($version['change'], $version['pending']) && $version['pending']) {
- return $this->guessStreamFromChange($version['change']);
- }
-
- // looks like we don't have a clue; lets assume not a stream
- return null;
- }
-
- /**
- * Checks the first file in a change to see if it points to a streams depot.
- * Note, this check may not work reliably on streams with writable imports.
- *
- * @param int|string|Change $change the change to look at for our guess
- * @return null|string the streams path as a string, if we can identify one, otherwise null
- */
- protected function guessStreamFromChange($change)
- {
- $p4 = $this->getConnection();
- $change = $change instanceof Change ? $change : Change::fetch($change, $p4);
- $id = $change->getId();
- $flags = $change->isPending() ? array('-Rs') : array();
- $flags = array_merge($flags, array('-e', $id, '-m1', '-T', 'depotFile', '//...@=' . $id));
- $result = $p4->run('fstat', $flags);
- $file = $result->getData(0, 'depotFile');
-
- // if the change is empty, we can't do the check
- if ($file === false) {
- return null;
- }
-
- // grab the depot off the first file and check if it points to a stream depot
- // if so, return the //<depot> followed by path components equal to stream depth (this
- // field is present only on new servers, on older ones we take just the first one)
- $pathComponents = array_filter(explode('/', $file));
- $depot = Depot::fetch(current($pathComponents), $p4);
- if ($depot->get('Type') == 'stream') {
- $depth = $depot->hasField('StreamDepth') ? $depot->getStreamDepth() : 1;
- return count($pathComponents) > $depth
- ? '//' . implode('/', array_slice($pathComponents, 0, $depth + 1))
- : null;
- }
-
- return null;
- }
-
- /**
- * Synchronizes the current review's description as well as the descriptions of any associated changes.
- *
- * @param string $reviewDescription the description to use for the review (review keywords stripped)
- * @param string $changeDescription the description to use for the change (review keywords intact)
- * @param Connection|null $connection the perforce connection to use - should be p4 admin, since the
- * current user may not own all the associated changes
- * @return bool true if the review description was modified, false otherwise
- */
- public function syncDescription($reviewDescription, $changeDescription, $connection = null)
- {
- $wasModified = false;
-
- // update the review with the new review description, if needed
- if ($this->getDescription() != $reviewDescription) {
- $this->setDescription($reviewDescription)->save();
-
- // since we changed the description, we've modified this review
- $wasModified = true;
- }
-
- // update descriptions for all changes associated with the review
- try {
- $connection = $connection ?: $this->getConnection();
- $connection->getService('clients')->grab();
- foreach ($this->getChanges() as $changeId) {
- $change = Change::fetch($changeId, $connection);
-
- // note: we only want to save the change if the description was changed, since this will trigger
- // an infinite number of changesave events otherwise
- if ($change->getDescription() != $changeDescription) {
- $change->setDescription($changeDescription)
- ->save(true);
- }
- }
- } catch (\Exception $e) {
- Logger::log(Logger::ERR, $e);
- }
-
- $connection->getService('clients')->release();
- return $wasModified;
- }
-
- /**
- * Try to fetch the associated user (for given field) as a user spec object.
- *
- * @param string $userField name of the field to get user object for
- * @return User the associated user object
- * @throws NotFoundException if user does not exist
- */
- protected function getUserObject($userField)
- {
- if (!isset($this->userObjects[$userField])) {
- $this->userObjects[$userField] = User::fetch(
- $this->get($userField),
- $this->getConnection()
- );
- }
-
- return $this->userObjects[$userField];
- }
-
- /**
- * Check if the associated user (for given field) is valid (exists).
- *
- * @param string $userField name of the field to check user for
- * @return bool true if the author user exists, false otherwise.
- */
- protected function isValidUser($userField)
- {
- try {
- $this->getUserObject($userField);
- } catch (NotFoundException $e) {
- return false;
- }
-
- return true;
- }
-
- /**
- * Override parent to prepare 'project' field values for indexing.
- *
- * @param int $code the index code/number of the field
- * @param string $name the field/name of the index
- * @param string|array|null $value one or more values to index
- * @param string|array|null $remove one or more old values that need to be de-indexed
- * @return Review provides fluent interface
- */
- protected function index($code, $name, $value, $remove)
- {
- // convert 'projects' field values into the form suitable for indexing
- // we index projects by project-id, but also by project-id:branch-id.
- if ($name === 'projects') {
- $value = array_merge(array_keys((array) $value), static::flattenForIndex((array) $value));
- $remove = array_merge(array_keys((array) $remove), static::flattenForIndex((array) $remove));
- }
-
- return parent::index($code, $name, $value, $remove);
- }
-
- /**
- * Called when an auto-generated ID is required for an entry.
- *
- * Extends parent to create a new changelist and use its change id
- * as the identifier for the review record.
- *
- * @return string a new auto-generated id. the id will be 'encoded'.
- * @throws \Exception re-throws any errors which occur during change save operation
- */
- protected function makeId()
- {
- $p4 = $this->getConnection();
- $shelf = new Change($p4);
- $shelf->setDescription($this->get('description'));
-
- // we grab the client tightly around save to avoid
- // locking it for any longer than we have to.
- $p4->getService('clients')->grab();
- try {
- $shelf->save();
- } catch (\Exception $e) {
- }
- $p4->getService('clients')->release();
-
- if (isset($e)) {
- throw $e;
- }
-
- return $this->encodeId($shelf->getId());
- }
-
- /**
- * Extends parent to flip the ids ordering and hex encode.
- *
- * @param string|int $id the user facing id
- * @return string the stored id used by p4 key
- */
- protected static function encodeId($id)
- {
- // nothing to do if the id is null
- if (!strlen($id)) {
- return null;
- }
-
- // subtract our id from max 32 bit int value to ensure proper sorting
- // we use a 32 bit value even on 64 bit systems to allow interoperability.
- $id = 0xFFFFFFFF - $id;
-
- // start with our prefix and follow up with hex encoded id
- // (the higher base makes it slightly shorter)
- $id = str_pad(dechex($id), 8, '0', STR_PAD_LEFT);
- return static::KEY_PREFIX . $id;
- }
-
- /**
- * Extends parent to undo our flip logic and hex decode.
- *
- * @param string $id the stored id used by p4 key
- * @return string|int the user facing id
- */
- protected static function decodeId($id)
- {
- // nothing to do if the id is null
- if ($id === null) {
- return null;
- }
-
- // strip off our key prefix
- $id = substr($id, strlen(static::KEY_PREFIX));
-
- // hex decode it and subtract from 32 bit int to undo our sorting trick
- return (int) (0xFFFFFFFF - hexdec($id));
- }
-
- /**
- * Produces a 'p4 search' expression for the given field/value pairs.
- *
- * Extends parent to allow including pending status in the state filter.
- * The syntax is <state>:(isPending|notPending) e.g.:
- * approved:notPending
- *
- * @param array $conditions field/value pairs to search for
- * @return string a query expression suitable for use with p4 search
- */
- protected static function makeSearchExpression($conditions)
- {
- // normalize conditions and pull out the 'states' for us to deal with
- $conditions += array(static::FETCH_BY_STATE => '');
- $states = $conditions[static::FETCH_BY_STATE];
-
- // start by letting parent handle all other fields
- unset($conditions[static::FETCH_BY_STATE]);
- $expression = parent::makeSearchExpression($conditions);
-
- // go over all state(s) and utilize parent to build expression for the state and
- // optional isPending/notPending field. We do them one at a time to allow us to
- // bracket the output when the expression has both state and pending.
- $expressions = array();
- foreach ((array) $states as $state) {
- $conditions = array();
- $parts = explode(':', $state);
-
- // if state appears to contain an isPending or notPending component split it
- // into separate state and pending conditions, otherwise simply keep it as is.
- if (count($parts) == 2 && ($parts[1] == 'isPending' || $parts[1] == 'notPending')) {
- $conditions[static::FETCH_BY_STATE] = $parts[0];
- $conditions['pending'] = $parts[1] == 'isPending' ? 1 : 0;
- } else {
- $conditions[static::FETCH_BY_STATE] = $state;
- }
-
- // use parent to make the state's expression then add it to the pile and
- // bracket it if we asked for both the state and pending filter
- $state = parent::makeSearchExpression($conditions);
- $expressions[] = count($conditions) > 1 ? '(' . $state . ')' : $state;
- }
-
- // now that we've collected up all the state expressions, implode and bracket
- // the whole thing if more than one state's involved
- $states = implode(' | ', $expressions);
- $expression .= ' ' . (count($expressions) > 1 ? '(' . $states . ')' : $states);
-
- return trim($expression);
- }
-
- /**
- * Turn the passed key into a record.
- * Extends parent to detect review type and create the appropriate review class.
- *
- * @param Key $key the key to record'ize
- * @param string|callable $className optional - class name to use, static by default
- * @return Review the record based on the passed key's data
- */
- protected static function keyToModel($key, $className = null)
- {
- return parent::keyToModel(
- $key,
- $className ?: function ($data) {
- // if the data includes a type of git; make a git model
- // otherwise create a standard review.
- return isset($data['type']) && $data['type'] === 'git'
- ? '\Reviews\Model\GitReview'
- : '\Reviews\Model\Review';
- }
- );
- }
-
- /**
- * Copy files and description to a new shelved change and add a version entry.
- *
- * We use shelved changes for versioning so that users can un-shelve old versions
- * and so that the rest of our diff/etc. code works with them seamlessly.
- *
- * This method is only intended to be called from updateFromChange().
- *
- * @param Change $shelf the shelved change to archive files from
- * @param array $versionDetails extra details to include in version entry, e.g. difference => true
- * @return Review provides fluent interface
- */
- protected function archiveShelf(Change $shelf, $versionDetails)
- {
- // make a new change matching the shelf's type and description.
- $p4 = $this->getConnection();
- $change = new Change($p4);
- $change->setType($shelf->getType())
- ->setDescription($shelf->getDescription())
- ->save();
-
- // to avoid any ambiguity when the shelve-commit trigger fires we add the
- // new archive change/version to the review record before we shelve
- $version = $versionDetails + array(
- 'change' => $change->getId(),
- 'user' => $shelf->getUser(),
- 'time' => time(),
- 'pending' => true
- );
- $this->addVersion($version)
- ->addChange($change->getId())
- ->save();
-
- // now we can move the files into our archive change and shelve them.
- $p4->run('reopen', array('-c', $change->getId(), '//...'));
- $p4->run('shelve', array('-c', $change->getId()));
-
- return $this;
- }
-
- /**
- * Rescue files from a pre-versioning review (upgrade scenario).
- *
- * Copy files and description to a new shelved change and update the latest
- * version in our versions metadata to point to the new change.
- *
- * This method is only intended to be called from updateFromChange().
- *
- * @param Change $shelf the canonical shelved change to archive files from
- * @return Review provides fluent interface
- * @todo centralize this more robust unshelve logic and use it elsewhere
- */
- protected function retroactiveArchive(Change $shelf)
- {
- // determine if we have any files to archive
- // we expect some files may fail to unshelve (this happens on <13.1
- // servers with added files that are now submitted) we capture these
- // files and sync/edit/print them manually to save the file contents
- $p4 = $this->getConnection();
- $result = $p4->run('unshelve', array('-s', $shelf->getId()));
- $opened = 0;
- $failed = array();
- $pattern = "/^Can't unshelve (.*) to open for [a-z\/]+: file already exists.$/";
- foreach ($result->getData() as $data) {
- if (is_array($data)) {
- $opened++;
- } elseif (preg_match($pattern, $data, $matches)) {
- $failed[] = $matches[1];
- }
- }
-
- // if there were no files to unshelve, exit early.
- if (!$opened && !$failed) {
- return $this;
- }
-
- // emulate unshelve for out-dated adds on <13.1 servers
- if ($failed) {
- $p4->run('sync', array_merge(array('-k'), $failed));
- $p4->run('edit', array_merge(array('-k'), $failed));
- foreach ($failed as $file) {
- $local = $p4->run('where', $file)->getData(0, 'path');
- $p4->run('print', array('-o', $local, $file . '@=' . $shelf->getId()));
- }
- }
-
- // now that we know we have files to rescue - make a new change for them.
- $change = new Change($p4);
- $change->setType($shelf->getType())
- ->setDescription($shelf->getDescription())
- ->save();
-
- // to avoid any ambiguity when the shelve-commit trigger fires we add the
- // new archive change/version to the review record before we shelve
- $versions = $this->getVersions();
- $versions[count($versions) - 1]['archiveChange'] = $change->getId();
- $this->setVersions($versions)
- ->addChange($change->getId())
- ->save();
-
- // now we can move the files into our archive change and shelve them.
- $p4->run('reopen', array('-c', $change->getId(), '//...'));
- $p4->run('shelve', array('-c', $change->getId()));
-
- // shelving leaves files open in the workspace, we need to clean those up
- // otherwise they will interfere with updating the canonical shelf later
- $p4->getService('clients')->clearFiles();
-
- return $this;
- }
-
- /**
- * Pending head revisions are stored twice, once in the canonical shelf and again in an archive shelf.
- * This method ensures the head version points to the canonical shelf, but older versions do not.
- *
- * @param array $versions the list of versions to normalize
- * @return array the normalized versions with head/non-head change issues sorted
- */
- protected function normalizeVersions(array $versions)
- {
- $last = end(array_keys($versions));
- foreach ($versions as $key => $version) {
- // if we see a pending head rev that does not point to the canonical shelf,
- // update it to point there and capture the archive change for later use.
- if ($version['pending'] && $version['change'] != $this->getId() && $key == $last) {
- $versions[$key]['archiveChange'] = $version['change'];
- $versions[$key]['change'] = $this->getId();
- }
-
- // if we find a non-head rev that points to the canonical shelf, update it
- // to reference the archive change or drop it if it has no archive change
- // if it has no archive change, it is most likely cruft from the upgrade code
- if ($version['change'] == $this->getId() && $key != end(array_keys($versions))) {
- if (isset($version['archiveChange'])) {
- $versions[$key]['change'] = $version['archiveChange'];
- unset($versions[$key]['archiveChange']);
- } else {
- unset($versions[$key]);
- }
- }
- }
-
- return array_values($versions);
- }
-
- /**
- * Determine if files in the given changes (pending or submitted) are different in any meaningful way.
- * We compare following properties:
- * - file names
- * - file contents (digests)
- * - file types
- * - actions
- * - working (head) revs
- * - resolved/unresolved states
- * and return an integer based on the results:
- * 0 if changes don't differ in any of compared properties
- * 1 if any file names, contents or types differ
- * 2 if changes differ in any other compared properties.
- *
- * @param Change|int $a pending or submitted change to compare
- * @param Change|int $b pending or submitted change to compare
- * @return int 0 if changes don't differ
- * 1 if changes differ in file names, types or digests
- * 2 if changes differ in any other compared fields
- */
- protected function changesDiffer($a, $b)
- {
- $p4 = $this->getConnection();
- $a = $a instanceof Change ? $a : Change::fetch($a, $p4);
- $b = $b instanceof Change ? $b : Change::fetch($b, $p4);
- $aId = $a->getId();
- $bId = $b->getId();
-
- $flags = array(
- '-Ol', // include digests
- '-T', // only the fields we want:
- 'depotFile,headAction,headType,headRev,resolved,unresolved,digest'
- );
-
- // add '-Rs' flag for pending changes
- $flagsA = array_merge($a->isPending() ? array('-Rs') : array(), $flags);
- $flagsB = array_merge($b->isPending() ? array('-Rs') : array(), $flags);
-
- $a = $p4->run(
- 'fstat',
- array_merge(array('-e', $a->getId()), $flagsA, array('//...@=' . $a->getId()))
- );
- $b = $p4->run(
- 'fstat',
- array_merge(array('-e', $b->getId()), $flagsB, array('//...@=' . $b->getId()))
- );
-
- // remove trailing change descriptions - we don't care if they differ
- $a = $a->getData(-1, 'desc') !== false ? array_slice($a->getData(), 0, -1) : $a->getData();
- $b = $b->getData(-1, 'desc') !== false ? array_slice($b->getData(), 0, -1) : $b->getData();
-
- if ($a == $b) {
- return 0;
- }
-
- // the fstat reported digests for ktext files are not what we want.
- // they are based on the text with keywords expanded which is apt to harmlessly flux.
- // if it looks worthwhile, we want to recalculate md5s without expansion.
- if ($this->shouldFixDigests($a, $b)) {
- $a = $this->fixKeywordExpandedDigests($a, $aId);
- $b = $this->fixKeywordExpandedDigests($b, $bId);
- }
-
- // our ktext related md5 updates may have cleared the difference; if so we're done!
- if ($a == $b) {
- return 0;
- }
-
- // screen down to only the 'major' difference fields
- $whitelist = array('depotFile' => null, 'headType' => null, 'digest' => null);
- foreach ($a as $block => $data) {
- $a[$block] = array_intersect_key($data, $whitelist);
- }
- foreach ($b as $block => $data) {
- $b[$block] = array_intersect_key($data, $whitelist);
- }
-
- // if the data are same now, it means that differences must have been within
- // action, revs or resolved/unresolved; otherwise changes must differ in other fields
- return $a == $b ? 2 : 1;
- }
-
- /**
- * This is a helper method for changesDiffer. We determine if touching up keyword expanded
- * digests is worthwhile.
- *
- * @param array $a fstat output with list of files to potentially update for old change
- * @param array $b fstat output with list of files to potentially update for new change
- * @return bool true if calling fixKeywordExpandedDigests is likely worthwhile, false otherwise
- */
- protected function shouldFixDigests($a, $b)
- {
- // differing counts means changesDiffer will always report 1; no need to fix digests
- if (count($a) != count($b)) {
- return false;
- }
-
- // index all 'b' blocks by depotFile so we can correlate them later
- $bByFile = array();
- foreach ($b as $key => $block) {
- if (isset($block['depotFile'])) {
- $bByFile[$block['depotFile']] = $block;
- }
- }
-
- $hasKtext = false;
- $normalize = array('depotFile' => null, 'digest' => null, 'headType' => null);
- foreach ($a as $blockA) {
- // if the 'b' set doesn't include this file, no need to fix digests
- $blockA += $normalize;
- $file = $blockA['depotFile'];
- if (!isset($bByFile[$file])) {
- return false;
- }
- $blockB = $bByFile[$file] + $normalize;
-
- // if type has changed on any file, no need to fix digests
- if ($blockA['headType'] != $blockB['headType']) {
- return false;
- }
-
- // if a single non-ktext file has a changed digest, no need to fixup
- $isKtext = preg_match('/kx?text|.+\+.*k/i', $blockA['headType']);
- if (!$isKtext && $blockA['digest'] != $blockB['digest']) {
- return false;
- }
-
- // track if we've hit any ktext files
- $hasKtext = $hasKtext || $isKtext;
- }
-
- // if we made it this far, fixing ktext digests is likely worthwhile if we've seen any
- return $hasKtext;
- }
-
- /**
- * This is a helper method for changesDiffer. We get passed in the fstat output for one
- * of the changes being examined and locate any ktext files located in it. We then print
- * all of the ktext files and recalculate the md5 values with the keywords not expanded.
- *
- * This will allow the changes differ method to tell if the ktext files fundamentally
- * differ (as opposed to simply differ in the expanded keywords).
- *
- * @param array $blocks fstat output with list of files to potentially update
- * @param int $changeId change id to use for revspec when printing files
- * @return array the provided blocks array with ktext digests updated
- */
- protected function fixKeywordExpandedDigests($blocks, $changeId)
- {
- // we cannot do squat on pre 2012.2 servers as they don't support printing with
- // keywords unexpanded. if we're on an old server, simply return.
- $p4 = $this->getConnection();
- if (!$p4->isServerMinVersion('2012.2')) {
- return $blocks;
- }
-
- // first collect the key and depotPath for all ktext entries and a list of filespecs with revspec
- $ktexts = array();
- $filespecs = array();
- foreach ($blocks as $block => $data) {
- // note ktext filetypes include things like: ktext, text+ko, text+mko, kxtext, etc.
- if (isset($data['headType'], $data['depotFile']) && preg_match('/kx?text|.+\+.*k/i', $data['headType'])) {
- $file = $data['depotFile'];
- $ktexts[$file] = $block;
- $filespecs[] = $file . '@=' . $changeId;
- }
- }
-
- // if we didn't detect any ktext files we need to update, we're done!
- if (!$filespecs) {
- return $blocks;
- }
-
- // now setup an output handler to process the print output for all ktext files (with keywords unexpanded)
- // and do a streaming calculation of the md5 for all ktext files
- $file = null;
- $hash = null;
- $handler = new Limit;
- $handler->setOutputCallback(
- function ($data, $type) use (&$blocks, &$file, &$hash, $ktexts) {
- // if its an array with depotFile; we're swapping files
- if (is_array($data) && isset($data['depotFile'])) {
- // if we were already on a file, finalize its hash update
- if ($file !== null) {
- $blocks[$ktexts[$file]]['digest'] = hash_final($hash);
- }
-
- // record the new file we're on and (re)init the streaming hash
- $file = $data['depotFile'];
- $hash = hash_init('md5');
- return Limit::HANDLER_HANDLED;
- }
-
- // if we have an unexpected type, skip it
- if ($type !== 'text' && $type !== 'binary') {
- return Limit::HANDLER_HANDLED;
- }
-
- // update the hash with our new block of content
- hash_update($hash, $data);
-
- return Limit::HANDLER_HANDLED;
- }
- );
-
- // print via our handler, note we pass -k to avoid expanding keywords
- // thanks to our output handler this will update the digest values in the $blocks array
- $p4->runHandler($handler, 'print', array_merge(array('-k'), $filespecs));
-
- // we're likely to have a final file to wrap up the hash update on, do that
- if ($file) {
- $blocks[$ktexts[$file]]['digest'] = hash_final($hash);
- }
-
- return $blocks;
- }
-
- /**
- * General normalization of participants data.
- *
- * @param array|null $participants the participants array to normalize
- * @param bool $forStorage optional - flag to denote whether we normalize for storage
- * passed to normalizeVote(), false by default
- * @return array normalized participants data
- */
- protected function normalizeParticipants($participants, $forStorage = false)
- {
- // - ensure value is an array
- // - ensure each entry is an array
- // - ensure the author is always present
- // - ensure we're sorted by user id
- // - ensure properties are sorted by key
- // - drop empty properties, at present we only store votes/required and
- // its a waste of space (and less normalized) to store empty versions
- $participants = array_filter((array) $participants, 'is_array');
- $participants += array($this->get('author') => array());
- uksort($participants, 'strnatcasecmp');
-
- foreach ($participants as $id => $participant) {
- $participant += array('vote' => array());
- $participant['vote'] = $this->normalizeVote($id, $participant['vote'], $forStorage);
- $participants[$id] = array_filter($participant);
-
- uksort($participants[$id], 'strnatcasecmp');
- }
-
- return $participants;
- }
-
- /**
- * If we were passed vote with valid 'value', we will ensure 'version' and 'isStale' is also present
- * ('isStale' is always recalculated).
- * If a non-array is passed, we will move the passed value under the 'value' key.
- * If no version is present, we will set the version to head.
- *
- * @param string $user user of the vote
- * @param array|string $vote vote to normalize
- * @param bool $forStorage flag to denote whether we normalize for storage or not
- * false by default; if true, then 'isStale' property will
- * not be included
- * @return array|false normalized vote as array with 'value', 'version' and optionally
- * 'isStale' keys or false if 'value' was invalid or user is the author
- */
- protected function normalizeVote($user, $vote, $forStorage)
- {
- // for non-array, shift the input under the 'value' key
- $vote = is_array($vote) ? $vote : array('value' => $vote);
-
- // if the user is the author or the vote is missing/invalid bail
- if ($user === $this->get('author') || !isset($vote['value']) || !in_array($vote['value'], array(1, -1))) {
- return false;
- }
-
- if (!isset($vote['version']) || !ctype_digit((string) $vote['version'])) {
- $vote['version'] = $this->getHeadVersion();
- }
- $vote['version'] = (int) $vote['version'];
-
- if ($forStorage) {
- unset($vote['isStale']);
- } else {
- $vote['isStale'] = $this->isStaleVote($vote);
- }
-
- return $vote;
- }
-
- /**
- * If the vote is out-dated and a newer version of the review has file changes, the vote is stale.
- * Otherwise you have voted on the same files as the latest version, so the vote is not stale.
- *
- * @param array $vote vote to check
- * @return boolean true if vote is stale, false otherwise
- */
- protected function isStaleVote(array $vote)
- {
- // loop over the versions, oldest to newest
- $votedOn = isset($vote['version']) ? (int) $vote['version'] : 0;
- foreach ($this->getVersions() as $key => $version) {
- // skip old versions and the version voted on
- // note key starts at zero, votedOn starts at 1
- if ($key < $votedOn) {
- continue;
- }
-
- // if 'difference' isn't present or its invalid, assume its different and return stale
- if (!isset($version['difference'])
- || !ctype_digit((string) $version['difference'])
- || !in_array($version['difference'], array(0, 1, 2))
- ) {
- return true;
- }
-
- // return stale if significant change occurred, otherwise keep scanning
- // 0 - no changes, 1 - modified name, type or digest, 2 - modified only insignificant fields
- if ($version['difference'] == 1) {
- return true;
- }
- }
-
- // the vote is not stale
- return false;
- }
-
- /**
- * Check for files that cannot be opened because they are already exclusively open.
- * We need an explicit check for this because it is not reported as an error or a warning.
- *
- * @param CommandResult $result the command output to examine
- * @throws Exception if any of the files are already open exclusively elsewhere
- */
- protected function exclusiveOpenCheck(CommandResult $result)
- {
- foreach ($result->getData() as $block) {
- if (is_string($block) && strpos($block, 'exclusive file already opened')) {
- throw new Exception(
- 'Cannot unshelve review (' . $this->getId() . '). ' .
- 'One or more files are exclusively open. ' .
- 'Ensure you have Perforce Server version 2014.2/1073410+ ' .
- 'with the filetype.bypasslock configurable enabled.'
- );
- }
- }
- }
-
- /**
- * Check if the server we are talking to supports bypassing +l
- *
- * @return bool true if the server is newer than 2014.2/1073410
- */
- protected function canBypassLocks()
- {
- $p4 = $this->getConnection();
- $identity = $p4->getServerIdentity();
-
- return $p4->isServerMinVersion('2014.2') && $identity['build'] >= 1073410;
- }
- }