/ */ 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 // 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 :(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; } }