diff --git a/resources/sql/autopatches/20190226.harbor.01.planprops.sql b/resources/sql/autopatches/20190226.harbor.01.planprops.sql new file mode 100644 --- /dev/null +++ b/resources/sql/autopatches/20190226.harbor.01.planprops.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_harbormaster.harbormaster_buildplan + ADD properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190226.harbor.02.planvalue.sql b/resources/sql/autopatches/20190226.harbor.02.planvalue.sql new file mode 100644 --- /dev/null +++ b/resources/sql/autopatches/20190226.harbor.02.planvalue.sql @@ -0,0 +1,2 @@ +UPDATE {$NAMESPACE}_harbormaster.harbormaster_buildplan + SET properties = '{}' WHERE properties = ''; diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -1328,6 +1328,9 @@ 'HarbormasterBuildMessageQuery' => 'applications/harbormaster/query/HarbormasterBuildMessageQuery.php', 'HarbormasterBuildPHIDType' => 'applications/harbormaster/phid/HarbormasterBuildPHIDType.php', 'HarbormasterBuildPlan' => 'applications/harbormaster/storage/configuration/HarbormasterBuildPlan.php', + 'HarbormasterBuildPlanBehavior' => 'applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php', + 'HarbormasterBuildPlanBehaviorOption' => 'applications/harbormaster/plan/HarbormasterBuildPlanBehaviorOption.php', + 'HarbormasterBuildPlanBehaviorTransaction' => 'applications/harbormaster/xaction/plan/HarbormasterBuildPlanBehaviorTransaction.php', 'HarbormasterBuildPlanDatasource' => 'applications/harbormaster/typeahead/HarbormasterBuildPlanDatasource.php', 'HarbormasterBuildPlanDefaultEditCapability' => 'applications/harbormaster/capability/HarbormasterBuildPlanDefaultEditCapability.php', 'HarbormasterBuildPlanDefaultViewCapability' => 'applications/harbormaster/capability/HarbormasterBuildPlanDefaultViewCapability.php', @@ -1424,6 +1427,7 @@ 'HarbormasterMessageType' => 'applications/harbormaster/engine/HarbormasterMessageType.php', 'HarbormasterObject' => 'applications/harbormaster/storage/HarbormasterObject.php', 'HarbormasterOtherBuildStepGroup' => 'applications/harbormaster/stepgroup/HarbormasterOtherBuildStepGroup.php', + 'HarbormasterPlanBehaviorController' => 'applications/harbormaster/controller/HarbormasterPlanBehaviorController.php', 'HarbormasterPlanController' => 'applications/harbormaster/controller/HarbormasterPlanController.php', 'HarbormasterPlanDisableController' => 'applications/harbormaster/controller/HarbormasterPlanDisableController.php', 'HarbormasterPlanEditController' => 'applications/harbormaster/controller/HarbormasterPlanEditController.php', @@ -6942,6 +6946,9 @@ 'PhabricatorConduitResultInterface', 'PhabricatorProjectInterface', ), + 'HarbormasterBuildPlanBehavior' => 'Phobject', + 'HarbormasterBuildPlanBehaviorOption' => 'Phobject', + 'HarbormasterBuildPlanBehaviorTransaction' => 'HarbormasterBuildPlanTransactionType', 'HarbormasterBuildPlanDatasource' => 'PhabricatorTypeaheadDatasource', 'HarbormasterBuildPlanDefaultEditCapability' => 'PhabricatorPolicyCapability', 'HarbormasterBuildPlanDefaultViewCapability' => 'PhabricatorPolicyCapability', @@ -7057,6 +7064,7 @@ 'HarbormasterMessageType' => 'Phobject', 'HarbormasterObject' => 'HarbormasterDAO', 'HarbormasterOtherBuildStepGroup' => 'HarbormasterBuildStepGroup', + 'HarbormasterPlanBehaviorController' => 'HarbormasterPlanController', 'HarbormasterPlanController' => 'HarbormasterController', 'HarbormasterPlanDisableController' => 'HarbormasterPlanController', 'HarbormasterPlanEditController' => 'HarbormasterPlanController', diff --git a/src/applications/harbormaster/application/PhabricatorHarbormasterApplication.php b/src/applications/harbormaster/application/PhabricatorHarbormasterApplication.php --- a/src/applications/harbormaster/application/PhabricatorHarbormasterApplication.php +++ b/src/applications/harbormaster/application/PhabricatorHarbormasterApplication.php @@ -83,6 +83,8 @@ => 'HarbormasterPlanEditController', 'order/(?:(?P\d+)/)?' => 'HarbormasterPlanOrderController', 'disable/(?P\d+)/' => 'HarbormasterPlanDisableController', + 'behavior/(?P\d+)/(?P[^/]+)/' => + 'HarbormasterPlanBehaviorController', 'run/(?P\d+)/' => 'HarbormasterPlanRunController', '(?P\d+)/' => 'HarbormasterPlanViewController', ), diff --git a/src/applications/harbormaster/controller/HarbormasterPlanBehaviorController.php b/src/applications/harbormaster/controller/HarbormasterPlanBehaviorController.php new file mode 100644 --- /dev/null +++ b/src/applications/harbormaster/controller/HarbormasterPlanBehaviorController.php @@ -0,0 +1,92 @@ +getViewer(); + + $plan = id(new HarbormasterBuildPlanQuery()) + ->setViewer($viewer) + ->withIDs(array($request->getURIData('id'))) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$plan) { + return new Aphront404Response(); + } + + $behavior_key = $request->getURIData('behaviorKey'); + $metadata_key = HarbormasterBuildPlanBehavior::getTransactionMetadataKey(); + + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + $behavior = idx($behaviors, $behavior_key); + if (!$behavior) { + return new Aphront404Response(); + } + + $plan_uri = $plan->getURI(); + + $v_option = $behavior->getPlanOption($plan)->getKey(); + if ($request->isFormPost()) { + $v_option = $request->getStr('option'); + + $xactions = array(); + + $xactions[] = id(new HarbormasterBuildPlanTransaction()) + ->setTransactionType( + HarbormasterBuildPlanBehaviorTransaction::TRANSACTIONTYPE) + ->setMetadataValue($metadata_key, $behavior_key) + ->setNewValue($v_option); + + $editor = id(new HarbormasterBuildPlanEditor()) + ->setActor($viewer) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true) + ->setContentSourceFromRequest($request); + + $editor->applyTransactions($plan, $xactions); + + return id(new AphrontRedirectResponse())->setURI($plan_uri); + } + + $select_control = id(new AphrontFormRadioButtonControl()) + ->setName('option') + ->setValue($v_option) + ->setLabel(pht('Option')); + + foreach ($behavior->getOptions() as $option) { + $icon = id(new PHUIIconView()) + ->setIcon($option->getIcon()); + + $select_control->addButton( + $option->getKey(), + array( + $icon, + ' ', + $option->getName(), + ), + $option->getDescription()); + } + + $form = id(new AphrontFormView()) + ->setViewer($viewer) + ->appendInstructions( + pht( + 'Choose a build plan behavior for "%s".', + phutil_tag('strong', array(), $behavior->getName()))) + ->appendRemarkupInstructions($behavior->getEditInstructions()) + ->appendControl($select_control); + + return $this->newDialog() + ->setTitle(pht('Edit Behavior: %s', $behavior->getName())) + ->appendForm($form) + ->setWidth(AphrontDialogView::WIDTH_FORM) + ->addSubmitButton(pht('Save Changes')) + ->addCancelButton($plan_uri); + } + +} diff --git a/src/applications/harbormaster/controller/HarbormasterPlanViewController.php b/src/applications/harbormaster/controller/HarbormasterPlanViewController.php --- a/src/applications/harbormaster/controller/HarbormasterPlanViewController.php +++ b/src/applications/harbormaster/controller/HarbormasterPlanViewController.php @@ -61,6 +61,7 @@ } $builds_view = $this->newBuildsView($plan); + $options_view = $this->newOptionsView($plan); $timeline = $this->buildTransactionTimeline( $plan, @@ -74,6 +75,7 @@ array( $error, $step_list, + $options_view, $builds_view, $timeline, )); @@ -484,4 +486,75 @@ ->appendChild($list); } + + private function newOptionsView(HarbormasterBuildPlan $plan) { + $viewer = $this->getViewer(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $plan, + PhabricatorPolicyCapability::CAN_EDIT); + + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + + $rows = array(); + foreach ($behaviors as $behavior) { + $option = $behavior->getPlanOption($plan); + + $icon = $option->getIcon(); + $icon = id(new PHUIIconView())->setIcon($icon); + + $edit_uri = new PhutilURI( + $this->getApplicationURI( + urisprintf( + 'plan/behavior/%d/%s/', + $plan->getID(), + $behavior->getKey()))); + + $edit_button = id(new PHUIButtonView()) + ->setTag('a') + ->setColor(PHUIButtonView::GREY) + ->setSize(PHUIButtonView::SMALL) + ->setDisabled(!$can_edit) + ->setWorkflow(true) + ->setText(pht('Edit')) + ->setHref($edit_uri); + + $rows[] = array( + $icon, + $behavior->getName(), + $option->getName(), + $option->getDescription(), + $edit_button, + ); + } + + $table = id(new AphrontTableView($rows)) + ->setHeaders( + array( + null, + pht('Name'), + pht('Behavior'), + pht('Details'), + null, + )) + ->setColumnClasses( + array( + null, + 'pri', + null, + 'wide', + null, + )); + + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Plan Behaviors')); + + return id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->setTable($table); + } + } diff --git a/src/applications/harbormaster/editor/HarbormasterBuildPlanEditEngine.php b/src/applications/harbormaster/editor/HarbormasterBuildPlanEditEngine.php --- a/src/applications/harbormaster/editor/HarbormasterBuildPlanEditEngine.php +++ b/src/applications/harbormaster/editor/HarbormasterBuildPlanEditEngine.php @@ -77,7 +77,7 @@ } protected function buildCustomEditFields($object) { - return array( + $fields = array( id(new PhabricatorTextEditField()) ->setKey('name') ->setLabel(pht('Name')) @@ -89,6 +89,36 @@ ->setConduitTypeDescription(pht('New plan name.')) ->setValue($object->getName()), ); + + + $metadata_key = HarbormasterBuildPlanBehavior::getTransactionMetadataKey(); + + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + foreach ($behaviors as $behavior) { + $key = $behavior->getKey(); + + // Get the raw key off the object so that we don't reset stuff to + // default values by mistake if a behavior goes missing somehow. + $storage_key = HarbormasterBuildPlanBehavior::getStorageKeyForBehaviorKey( + $key); + $behavior_option = $object->getPlanProperty($storage_key); + + if (!strlen($behavior_option)) { + $behavior_option = $behavior->getPlanOption($object)->getKey(); + } + + $fields[] = id(new PhabricatorSelectEditField()) + ->setIsFormField(false) + ->setKey(sprintf('behavior.%s', $behavior->getKey())) + ->setMetadataValue($metadata_key, $behavior->getKey()) + ->setLabel(pht('Behavior: %s', $behavior->getName())) + ->setTransactionType( + HarbormasterBuildPlanBehaviorTransaction::TRANSACTIONTYPE) + ->setValue($behavior_option) + ->setOptions($behavior->getOptionMap()); + } + + return $fields; } } diff --git a/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php new file mode 100644 --- /dev/null +++ b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php @@ -0,0 +1,348 @@ +key = $key; + return $this; + } + + public function getKey() { + return $this->key; + } + + public function setName($name) { + $this->name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + public function setEditInstructions($edit_instructions) { + $this->editInstructions = $edit_instructions; + return $this; + } + + public function getEditInstructions() { + return $this->editInstructions; + } + + public function getOptionMap() { + return mpull($this->options, 'getName', 'getKey'); + } + + public function setOptions(array $options) { + assert_instances_of($options, 'HarbormasterBuildPlanBehaviorOption'); + + $key_map = array(); + $default = null; + + foreach ($options as $option) { + $key = $option->getKey(); + + if (isset($key_map[$key])) { + throw new Exception( + pht( + 'Multiple behavior options (for behavior "%s") have the same '. + 'key ("%s"). Each option must have a unique key.', + $this->getKey(), + $key)); + } + $key_map[$key] = true; + + if ($option->getIsDefault()) { + if ($default === null) { + $default = $key; + } else { + throw new Exception( + pht( + 'Multiple behavior options (for behavior "%s") are marked as '. + 'default options ("%s" and "%s"). Exactly one option must be '. + 'marked as the default option.', + $this->getKey(), + $default, + $key)); + } + } + } + + if ($default === null) { + throw new Exception( + pht( + 'No behavior option is marked as the default option (for '. + 'behavior "%s"). Exactly one option must be marked as the '. + 'default option.', + $this->getKey())); + } + + $this->options = mpull($options, null, 'getKey'); + $this->defaultKey = $default; + + return $this; + } + + public function getOptions() { + return $this->options; + } + + public function getPlanOption(HarbormasterBuildPlan $plan) { + $behavior_key = $this->getKey(); + $storage_key = self::getStorageKeyForBehaviorKey($behavior_key); + + $plan_value = $plan->getPlanProperty($storage_key); + if (isset($this->options[$plan_value])) { + return $this->options[$plan_value]; + } + + return idx($this->options, $this->defaultKey); + } + + public static function getTransactionMetadataKey() { + return 'behavior-key'; + } + + public static function getStorageKeyForBehaviorKey($behavior_key) { + return sprintf('behavior.%s', $behavior_key); + } + + public static function newPlanBehaviors() { + $draft_options = array( + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('always') + ->setIcon('fa-check-circle-o green') + ->setName(pht('Always')) + ->setIsDefault(true) + ->setDescription( + pht( + 'Revisions are not sent for review until the build completes, '. + 'and are returned to the author for updates if the build fails.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('building') + ->setIcon('fa-pause-circle-o yellow') + ->setName(pht('If Building')) + ->setDescription( + pht( + 'Revisions are not sent for review until the build completes, '. + 'but they will be sent for review even if it fails.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('never') + ->setIcon('fa-circle-o red') + ->setName(pht('Never')) + ->setDescription( + pht( + 'Revisions are sent for review regardless of the status of the '. + 'build.')), + ); + + $land_options = array( + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('always') + ->setIcon('fa-check-circle-o green') + ->setName(pht('Always')) + ->setIsDefault(true) + ->setDescription( + pht( + '"arc land" warns if the build is still running or has '. + 'failed.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('building') + ->setIcon('fa-pause-circle-o yellow') + ->setName(pht('If Building')) + ->setDescription( + pht( + '"arc land" warns if the build is still running, but ignores '. + 'the build if it has failed.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('complete') + ->setIcon('fa-dot-circle-o yellow') + ->setName(pht('If Complete')) + ->setDescription( + pht( + '"arc land" warns if the build has failed, but ignores the '. + 'build if it is still running.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('never') + ->setIcon('fa-circle-o red') + ->setName(pht('Never')) + ->setDescription( + pht( + '"arc land" never warns that the build is still running or '. + 'has failed.')), + ); + + $aggregate_options = array( + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('always') + ->setIcon('fa-check-circle-o green') + ->setName(pht('Always')) + ->setIsDefault(true) + ->setDescription( + pht( + 'The buildable waits for the build, and fails if the '. + 'build fails.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('building') + ->setIcon('fa-pause-circle-o yellow') + ->setName(pht('If Building')) + ->setDescription( + pht( + 'The buildable waits for the build, but does not fail '. + 'if the build fails.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('never') + ->setIcon('fa-circle-o red') + ->setName(pht('Never')) + ->setDescription( + pht( + 'The buildable does not wait for the build.')), + ); + + $restart_options = array( + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('always') + ->setIcon('fa-repeat green') + ->setName(pht('Always')) + ->setIsDefault(true) + ->setDescription( + pht('The build may be restarted.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('never') + ->setIcon('fa-times red') + ->setName(pht('Never')) + ->setDescription( + pht('The build may not be restarted.')), + ); + + $run_options = array( + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('edit') + ->setIcon('fa-pencil green') + ->setName(pht('If Editable')) + ->setIsDefault(true) + ->setDescription( + pht('Only users who can edit the plan can run it manually.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey('view') + ->setIcon('fa-exclamation-triangle yellow') + ->setName(pht('If Viewable')) + ->setDescription( + pht( + 'Any user who can view the plan can run it manually.')), + ); + + $behaviors = array( + id(new self()) + ->setKey('hold-drafts') + ->setName(pht('Hold Drafts')) + ->setEditInstructions( + pht( + 'When users create revisions in Differential, the default '. + 'behavior is to hold them in the "Draft" state until all builds '. + 'pass. Once builds pass, the revisions promote and are sent for '. + 'review, which notifies reviewers.'. + "\n\n". + 'The general intent of this workflow is to make sure reviewers '. + 'are only spending time on review once changes survive automated '. + 'tests. If a change does not pass tests, it usually is not '. + 'really ready for review.'. + "\n\n". + 'If you want to promote revisions out of "Draft" before builds '. + 'pass, or promote revisions even when builds fail, you can '. + 'change the promotion behavior. This may be useful if you have '. + 'very long-running builds, or some builds which are not very '. + 'important.'. + "\n\n". + 'Users may always use "Request Review" to promote a "Draft" '. + 'revision, even if builds have failed or are still in progress.')) + ->setOptions($draft_options), + id(new self()) + ->setKey('arc-land') + ->setName(pht('Warn When Landing')) + ->setEditInstructions( + pht( + 'When a user attempts to `arc land` a revision and that revision '. + 'has ongoing or failed builds, the default behavior of `arc` is '. + 'to warn them about those builds and give them a chance to '. + 'reconsider: they may want to wait for ongoing builds to '. + 'complete, or fix failed builds before landing the change.'. + "\n\n". + 'If you do not want to warn users about this build, you can '. + 'change the warning behavior. This may be useful if the build '. + 'takes a long time to run (so you do not expect users to wait '. + 'for it) or the outcome is not important.'. + "\n\n". + 'This warning is only advisory. Users may always elect to ignore '. + 'this warning and continue, even if builds have failed.')) + ->setOptions($land_options), + id(new self()) + ->setKey('buildable') + ->setEditInstructions( + pht( + 'The overall state of a buildable (like a commit or revision) is '. + 'normally the aggregation of the individual states of all builds '. + 'that have run against it.'. + "\n\n". + 'Buildables are "building" until all builds pass (which changes '. + 'them to "pass"), or any build fails (which changes them to '. + '"fail").'. + "\n\n". + 'You can change this behavior if you do not want to wait for this '. + 'build, or do not care if it fails.')) + ->setName(pht('Affects Buildable')) + ->setOptions($aggregate_options), + id(new self()) + ->setKey('restartable') + ->setEditInstructions( + pht( + 'Usually, builds may be restarted. This may be useful if you '. + 'suspect a build has failed for environmental or circumstantial '. + 'reasons unrelated to the actual code, and want to give it '. + 'another chance at glory.'. + "\n\n". + 'If you want to prevent a build from being restarted, you can '. + 'change the behavior here. This may be useful to prevent '. + 'accidents where a build with a dangerous side effect (like '. + 'deployment) is restarted improperly.')) + ->setName(pht('Restartable')) + ->setOptions($restart_options), + id(new self()) + ->setKey('runnable') + ->setEditInstructions( + pht( + 'To run a build manually, you normally must have permission to '. + 'edit the related build plan. If you would prefer that anyone who '. + 'can see the build plan be able to run and restart the build, you '. + 'can change the behavior here.'. + "\n\n". + 'Note that this affects both {nav Run Plan Manually} and '. + '{nav Restart Build}, since the two actions are largely '. + 'equivalent.'. + "\n\n". + 'WARNING: This may be unsafe, particularly if the build has '. + 'side effects like deployment.'. + "\n\n". + 'If you weaken this policy, an attacker with control of an '. + 'account that has "Can View" permission but not "Can Edit" '. + 'permission can manually run this build against any old version '. + 'of the code, including versions with known security issues.'. + "\n\n". + 'If running the build has a side effect like deploying code, '. + 'they can force deployment of a vulnerable version and then '. + 'escalate into an attack against the deployed service.')) + ->setName(pht('Runnable')) + ->setOptions($run_options), + ); + + return mpull($behaviors, null, 'getKey'); + } + +} diff --git a/src/applications/harbormaster/plan/HarbormasterBuildPlanBehaviorOption.php b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehaviorOption.php new file mode 100644 --- /dev/null +++ b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehaviorOption.php @@ -0,0 +1,57 @@ +name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + public function setKey($key) { + $this->key = $key; + return $this; + } + + public function getKey() { + return $this->key; + } + + public function setDescription($description) { + $this->description = $description; + return $this; + } + + public function getDescription() { + return $this->description; + } + + public function setIsDefault($is_default) { + $this->isDefault = $is_default; + return $this; + } + + public function getIsDefault() { + return $this->isDefault; + } + + public function setIcon($icon) { + $this->icon = $icon; + return $this; + } + + public function getIcon() { + return $this->icon; + } + +} diff --git a/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlan.php b/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlan.php --- a/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlan.php +++ b/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlan.php @@ -17,6 +17,7 @@ protected $planAutoKey; protected $viewPolicy; protected $editPolicy; + protected $properties = array(); const STATUS_ACTIVE = 'active'; const STATUS_DISABLED = 'disabled'; @@ -45,6 +46,9 @@ protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, + self::CONFIG_SERIALIZATION => array( + 'properties' => self::SERIALIZATION_JSON, + ), self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'sort128', 'planStatus' => 'text32', @@ -94,6 +98,15 @@ return pht('Plan %d', $this->getID()); } + public function getPlanProperty($key, $default = null) { + return idx($this->properties, $key, $default); + } + + public function setPlanProperty($key, $value) { + $this->properties[$key] = $value; + return $this; + } + /* -( Autoplans )---------------------------------------------------------- */ diff --git a/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanBehaviorTransaction.php b/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanBehaviorTransaction.php new file mode 100644 --- /dev/null +++ b/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanBehaviorTransaction.php @@ -0,0 +1,127 @@ +getBehavior(); + return $behavior->getPlanOption($object)->getKey(); + } + + public function applyInternalEffects($object, $value) { + $key = $this->getStorageKey(); + return $object->setPlanProperty($key, $value); + } + + public function getTitle() { + $old_value = $this->getOldValue(); + $new_value = $this->getNewValue(); + + $behavior = $this->getBehavior(); + if ($behavior) { + $behavior_name = $behavior->getName(); + + $options = $behavior->getOptions(); + if (isset($options[$old_value])) { + $old_value = $options[$old_value]->getName(); + } + + if (isset($options[$new_value])) { + $new_value = $options[$new_value]->getName(); + } + } else { + $behavior_name = $this->getBehaviorKey(); + } + + return pht( + '%s changed the %s behavior for this plan from %s to %s.', + $this->renderAuthor(), + $this->renderValue($behavior_name), + $this->renderValue($old_value), + $this->renderValue($new_value)); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + $behaviors = mpull($behaviors, null, 'getKey'); + + foreach ($xactions as $xaction) { + $key = $this->getBehaviorKeyForTransaction($xaction); + + if (!isset($behaviors[$key])) { + $errors[] = $this->newInvalidError( + pht( + 'No behavior with key "%s" exists. Valid keys are: %s.', + $key, + implode(', ', array_keys($behaviors))), + $xaction); + continue; + } + + $behavior = $behaviors[$key]; + $options = $behavior->getOptions(); + + $storage_key = HarbormasterBuildPlanBehavior::getStorageKeyForBehaviorKey( + $key); + $old = $object->getPlanProperty($storage_key); + $new = $xaction->getNewValue(); + + if ($old === $new) { + continue; + } + + if (!isset($options[$new])) { + $errors[] = $this->newInvalidError( + pht( + 'Value "%s" is not a valid option for behavior "%s". Valid '. + 'options are: %s.', + $new, + $key, + implode(', ', array_keys($options))), + $xaction); + continue; + } + } + + return $errors; + } + + public function getTransactionTypeForConduit($xaction) { + return 'behavior'; + } + + public function getFieldValuesForConduit($xaction, $data) { + return array( + 'key' => $this->getBehaviorKeyForTransaction($xaction), + 'old' => $xaction->getOldValue(), + 'new' => $xaction->getNewValue(), + ); + } + + private function getBehaviorKeyForTransaction( + PhabricatorApplicationTransaction $xaction) { + $metadata_key = HarbormasterBuildPlanBehavior::getTransactionMetadataKey(); + return $xaction->getMetadataValue($metadata_key); + } + + private function getBehaviorKey() { + $metadata_key = HarbormasterBuildPlanBehavior::getTransactionMetadataKey(); + return $this->getMetadataValue($metadata_key); + } + + private function getBehavior() { + $behavior_key = $this->getBehaviorKey(); + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + return idx($behaviors, $behavior_key); + } + + private function getStorageKey() { + return HarbormasterBuildPlanBehavior::getStorageKeyForBehaviorKey( + $this->getBehaviorKey()); + } + +}