diff --git a/resources/sql/autopatches/20140528.chronicle.1.sql b/resources/sql/autopatches/20140528.chronicle.1.sql new file mode 100644 --- /dev/null +++ b/resources/sql/autopatches/20140528.chronicle.1.sql @@ -0,0 +1,14 @@ +CREATE TABLE {$NAMESPACE}_chronicle.chronicle_trigger ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARCHAR(64) NOT NULL COLLATE utf8_bin, + name VARCHAR(254) NOT NULL COLLATE utf8_bin, + epochNext INT UNSIGNED NOT NULL, + epochConfig VARCHAR(4000) NOT NULL COLLATE utf8_bin, + actionClass VARCHAR(254) NOT NULL COLLATE utf8_bin, + actionData VARCHAR(4000) NOT NULL COLLATE utf8_bin, + viewPolicy VARCHAR(64) NOT NULL COLLATE utf8_bin, + editPolicy VARCHAR(64) NOT NULL COLLATE utf8_bin, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_phid` (phid) +) ENGINE=InnoDB, COLLATE utf8_general_ci; diff --git a/resources/sql/autopatches/20140619.chronicle.2.sql b/resources/sql/autopatches/20140619.chronicle.2.sql new file mode 100644 --- /dev/null +++ b/resources/sql/autopatches/20140619.chronicle.2.sql @@ -0,0 +1,21 @@ +CREATE TABLE {$NAMESPACE}_chronicle.chronicle_triggertransaction ( + id INT UNSIGNED NOT NULL PRIMARY KEY AUTO_INCREMENT, + phid VARCHAR(64) NOT NULL COLLATE utf8_bin, + authorPHID VARCHAR(64) NOT NULL COLLATE utf8_bin, + objectPHID VARCHAR(64) NOT NULL COLLATE utf8_bin, + viewPolicy VARCHAR(64) NOT NULL COLLATE utf8_bin, + editPolicy VARCHAR(64) NOT NULL COLLATE utf8_bin, + commentPHID VARCHAR(64) COLLATE utf8_bin, + commentVersion INT UNSIGNED NOT NULL, + transactionType VARCHAR(32) NOT NULL COLLATE utf8_bin, + oldValue LONGTEXT NOT NULL COLLATE utf8_bin, + newValue LONGTEXT NOT NULL COLLATE utf8_bin, + contentSource LONGTEXT NOT NULL COLLATE utf8_bin, + metadata LONGTEXT NOT NULL COLLATE utf8_bin, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + + UNIQUE KEY `key_phid` (phid), + KEY `key_object` (objectPHID) + +) ENGINE=InnoDB, COLLATE utf8_general_ci; diff --git a/src/applications/chronicle/action/ChronicleAction.php b/src/applications/chronicle/action/ChronicleAction.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/action/ChronicleAction.php @@ -0,0 +1,83 @@ +setAncestorClass('ChronicleAction') + ->loadObjects(); + } + + public static function getImplementation($class) { + $base = idx(self::getImplementations(), $class); + + if ($base) { + return (clone $base); + } + + return null; + } + + public static function requireImplementation($class) { + if (!$class) { + throw new Exception(pht('No implementation is specified!')); + } + + $implementation = self::getImplementation($class); + if (!$implementation) { + throw new Exception(pht('No such implementation "%s" exists!', $class)); + } + + return $implementation; + } + + /** + * The name of the implementation. + */ + abstract public function getName(); + + /** + * The description of the implementation. + */ + public function getDescription() { + return ''; + } + + /** + * Run the build target against the specified build. + */ + abstract public function execute(ChronicleTrigger $trigger); + + /** + * Gets the settings for this build step. + */ + public function getSettings() { + return $this->settings; + } + + public function getSetting($key, $default = null) { + return idx($this->settings, $key, $default); + } + + /** + * Loads the settings for this build step implementation from a build + * step or target. + */ + public final function loadSettings($action_data) { + $this->settings = $action_data; + return $this; + } + + public function getFieldSpecifications() { + return array(); + } + + /** + * Returns whether or not the current user is allowed to create a new + * trigger or edit existing triggers with this action. + */ + public function canCreateOrEdit(PhabricatorUser $viewer) { + return true; + } + +} diff --git a/src/applications/chronicle/action/ChronicleSendEmailAction.php b/src/applications/chronicle/action/ChronicleSendEmailAction.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/action/ChronicleSendEmailAction.php @@ -0,0 +1,70 @@ +getSetting('recipients')); + if (count($recipients) === 0) { + return; + } + + $body = $this->getSetting('message'); + + $body .= <<getID().'/'); + + $mail = id(new PhabricatorMetaMTAMail()) + ->addTos($recipients) + ->setSubject('[Chronicle] [Automatic Email] '.$this->getSetting('title')) + ->setBody($body) + ->saveAndSend(); + } + + public function getFieldSpecifications() { + return array( + 'recipients' => array( + 'name' => pht('Recipients'), + 'type' => 'users', + 'required' => true, + ), + 'title' => array( + 'name' => pht('Title'), + 'type' => 'text' + ), + 'message' => array( + 'name' => pht('Message'), + 'type' => 'text' + ), + ); + } + + public function canCreateOrEdit(PhabricatorUser $viewer) { + if (PhabricatorPolicyFilter::hasCapability( + $viewer, + new PhabricatorApplicationHerald(), + HeraldCapabilityManageGlobalRules::CAPABILITY)) { + + return true; + } + + // Anyone in the recipients list can also edit the trigger. + $recipients = phutil_json_decode($this->getSetting('recipients')); + return in_array($viewer->getPHID(), $recipients); + } + + +} diff --git a/src/applications/chronicle/action/ChronicleStartHarbormasterBuildAction.php b/src/applications/chronicle/action/ChronicleStartHarbormasterBuildAction.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/action/ChronicleStartHarbormasterBuildAction.php @@ -0,0 +1,95 @@ +getSetting('callsign'); + $branch = $this->getSetting('branch'); + $plan_id = $this->getSetting('planid'); + + // Get commit identifier. + $refs = id(new ConduitCall( + 'diffusion.resolverefs', + array( + 'refs' => array($branch), + 'callsign' => $callsign + ))) + ->setUser(PhabricatorUser::getOmnipotentUser()) + ->execute(); + $candidate_refs = $refs[$branch]; + $commit_ref = null; + foreach ($candidate_refs as $candidate_ref) { + if ($candidate_ref['type'] === 'commit') { + $commit_ref = $candidate_ref['identifier']; + } + } + + if ($commit_ref === null) { + return; + } + + $commit = id(new DiffusionCommitQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withIdentifiers(array($commit_ref)) + ->executeOne(); + + if ($commit === null) { + return; + } + + $build_plan = id(new HarbormasterBuildPlanQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withIDs(array($plan_id)) + ->executeOne(); + + if ($build_plan === null) { + return; + } + + $buildable = HarbormasterBuildable::createOrLoadExisting( + PhabricatorUser::getOmnipotentUser(), + $commit->getPHID(), + $commit->getRepository()->getPHID()); + $buildable->applyPlan($build_plan); + + return; + } + + public function getFieldSpecifications() { + return array( + 'callsign' => array( + 'name' => pht('Repository Callsign'), + 'type' => 'text', + 'required' => true, + ), + 'branch' => array( + 'name' => pht('Branch or Reference'), + 'type' => 'text', + 'required' => true + ), + 'planid' => array( + 'name' => pht('Build Plan ID'), + 'type' => 'text', + 'required' => true, + ), + ); + } + + public function canCreateOrEdit(PhabricatorUser $viewer) { + return PhabricatorPolicyFilter::hasCapability( + $viewer, + new PhabricatorApplicationHarbormaster(), + HarbormasterCapabilityManagePlans::CAPABILITY); + } + + +} diff --git a/src/applications/chronicle/application/PhabricatorApplicationChronicle.php b/src/applications/chronicle/application/PhabricatorApplicationChronicle.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/application/PhabricatorApplicationChronicle.php @@ -0,0 +1,51 @@ + array( + '(?:query/(?P[^/]+)/)?' + => 'ChronicleTriggerListController', + 'trigger/' => array( + 'add/(?:(?P\d+)/)?' => 'ChronicleTriggerAddController', + 'new/(?P[^/]+)/' + => 'ChronicleTriggerEditController', + 'edit/(?:(?P\d+)/)?' => 'ChronicleTriggerEditController', + '(?P\d+)/' => 'ChronicleTriggerViewController', + ), + ), + ); + } + + public function isLaunchable() { + // TODO: This is just concealing the application from launch views for + // now since it's not really beta yet. + return false; + } + +} diff --git a/src/applications/chronicle/controller/ChronicleController.php b/src/applications/chronicle/controller/ChronicleController.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/controller/ChronicleController.php @@ -0,0 +1,18 @@ +addAction( + id(new PHUIListItemView()) + ->setName(pht('New Trigger')) + ->setHref($this->getApplicationURI('trigger/add/')) + ->setIcon('fa-plus-square') + ->setWorkflow(true)); + + return $crumbs; + } + +} diff --git a/src/applications/chronicle/controller/ChronicleTriggerAddController.php b/src/applications/chronicle/controller/ChronicleTriggerAddController.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/controller/ChronicleTriggerAddController.php @@ -0,0 +1,54 @@ +getRequest(); + $viewer = $request->getUser(); + + $cancel_uri = $this->getApplicationURI('/'); + + $errors = array(); + if ($request->isFormPost()) { + $class = $request->getStr('class'); + if (!ChronicleAction::getImplementation($class)) { + $errors[] = pht( + 'Choose the type of build step you want to add.'); + } + if (!$errors) { + $new_uri = $this->getApplicationURI("trigger/new/{$class}/"); + return id(new AphrontRedirectResponse())->setURI($new_uri); + } + } + + $control = id(new AphrontFormRadioButtonControl()) + ->setName('class'); + + $all = ChronicleAction::getImplementations(); + foreach ($all as $class => $implementation) { + $allowed = !$implementation->canCreateOrEdit($viewer); + + $control->addButton( + $class, + $implementation->getName(), + $implementation->getDescription(), + null, + $allowed); + } + + if ($errors) { + $errors = id(new AphrontErrorView()) + ->setErrors($errors); + } + + return $this->newDialog() + ->setTitle(pht('Create New Trigger')) + ->addSubmitButton(pht('Create New Trigger')) + ->addCancelButton($cancel_uri) + ->appendChild($errors) + ->appendParagraph(pht('Choose a type of build step to add:')) + ->appendChild($control); + } + +} diff --git a/src/applications/chronicle/controller/ChronicleTriggerController.php b/src/applications/chronicle/controller/ChronicleTriggerController.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/controller/ChronicleTriggerController.php @@ -0,0 +1,5 @@ +id = idx($data, 'id'); + $this->className = idx($data, 'class'); + } + + public function processRequest() { + $request = $this->getRequest(); + $viewer = $request->getUser(); + + if ($this->id) { + $trigger = id(new ChronicleTriggerQuery()) + ->setViewer($viewer) + ->withIDs(array($this->id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$trigger) { + return new Aphront404Response(); + } + + $is_new = false; + } else { + $impl = ChronicleAction::getImplementation( + $this->className); + if (!$impl) { + return new Aphront404Response(); + } + + $trigger = ChronicleTrigger::initializeNewTrigger($this->className); + + $is_new = true; + } + + $implementation = $trigger->getActionImplementation(); + + if (!$implementation->canCreateOrEdit($viewer)) { + return new Aphront404Response(); + } + + $field_list = PhabricatorCustomField::getObjectFields( + $trigger, + PhabricatorCustomField::ROLE_EDIT); + $field_list + ->setViewer($viewer) + ->readFieldsFromStorage($trigger); + + if ($trigger->getViewPolicy() === null) { + $trigger->setViewPolicy($viewer->getPHID()); + } + + if ($trigger->getEditPolicy() === null) { + $trigger->setEditPolicy($viewer->getPHID()); + } + + $e_name = true; + $e_config = true; + $e_view_policy = true; + $e_edit_policy = true; + $v_name = $trigger->getName(); + $v_config = json_encode($trigger->getEpochConfig()); + $v_view_policy = $trigger->getViewPolicy(); + $v_edit_policy = $trigger->getEditPolicy(); + $validation_exception = null; + if ($request->isFormPost()) { + $xactions = $field_list->buildFieldTransactionsFromRequest( + new ChronicleTriggerTransaction(), + $request); + + $e_name = null; + $e_config = null; + $e_view_policy = null; + $e_edit_policy = null; + $v_name = $request->getStr('name'); + $v_config = $request->getStr('config'); + $v_view_policy = $request->getStr('viewPolicy'); + $v_edit_policy = $request->getStr('editPolicy'); + + if (strlen($v_name) === 0) { + $e_name = pht('You must provide a name.'); + $v_name = null; + } + + $decoded = phutil_json_decode($v_config, null); + + if ($decoded === null) { + $e_config = pht('You must provide a valid JSON config.'); + $v_config = null; + } + + if (!$e_name && !$e_config && !$e_view_policy && !$e_edit_policy) { + $editor = id(new ChronicleTriggerEditor()) + ->setActor($viewer) + ->setContinueOnNoEffect(true) + ->setContentSourceFromRequest($request); + + $edit_policy_xaction = id(new ChronicleTriggerTransaction()) + ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) + ->setNewValue($v_edit_policy); + array_unshift($xactions, $edit_policy_xaction); + + $view_policy_xaction = id(new ChronicleTriggerTransaction()) + ->setTransactionType(PhabricatorTransactions::TYPE_VIEW_POLICY) + ->setNewValue($v_view_policy); + array_unshift($xactions, $view_policy_xaction); + + $config_xaction = id(new ChronicleTriggerTransaction()) + ->setTransactionType(ChronicleTriggerTransaction::TYPE_CONFIG) + ->setNewValue($decoded); + array_unshift($xactions, $config_xaction); + + $name_xaction = id(new ChronicleTriggerTransaction()) + ->setTransactionType(ChronicleTriggerTransaction::TYPE_NAME) + ->setNewValue($v_name); + array_unshift($xactions, $name_xaction); + + if ($is_new) { + // When creating a new step, make sure we have a create transaction + // so we'll apply the transactions even if the step has no + // configurable options. + $create_xaction = id(new ChronicleTriggerTransaction()) + ->setTransactionType(ChronicleTriggerTransaction::TYPE_CREATE); + array_unshift($xactions, $create_xaction); + } + + try { + $editor->applyTransactions($trigger, $xactions); + + // TODO This user could be removing themselves from a trigger + // sending email (where they don't otherwise have edit access). + // If the user doesn't have permission to view the object after + // editing it, then redirect them to the application home page. + + return id(new AphrontRedirectResponse())->setURI( + $this->getApplicationURI('/trigger/'.$trigger->getID().'/')); + } catch (PhabricatorApplicationTransactionValidationException $ex) { + $validation_exception = $ex; + } + } + } + + $policies = id(new PhabricatorPolicyQuery()) + ->setViewer($viewer) + ->setObject($trigger) + ->execute(); + + $form = id(new AphrontFormView()) + ->setUser($viewer) + ->appendChild( + id(new AphrontFormTextControl()) + ->setLabel('Name') + ->setName('name') + ->setError($e_name) + ->setValue($v_name)) + ->appendChild( + id(new AphrontFormTextAreaControl()) + ->setLabel('Epoch Configuration') + ->setName('config') + ->setError($e_config) + ->setValue($v_config)) + ->appendChild( + id(new AphrontFormPolicyControl()) + ->setUser($viewer) + ->setName('viewPolicy') + ->setPolicyObject($trigger) + ->setPolicies($policies) + ->setCapability(PhabricatorPolicyCapability::CAN_VIEW)) + ->appendChild( + id(new AphrontFormPolicyControl()) + ->setUser($viewer) + ->setName('editPolicy') + ->setPolicyObject($trigger) + ->setPolicies($policies) + ->setCapability(PhabricatorPolicyCapability::CAN_EDIT)); + + $field_list->appendFieldsToForm($form); + + if ($is_new) { + $submit = pht('Create Trigger'); + $header = pht('New Trigger: %s', $implementation->getName()); + $crumb = pht('Add Trigger'); + } else { + $submit = pht('Save Trigger'); + $header = pht('Edit Trigger: %s', $implementation->getName()); + $crumb = pht('Edit Trigger'); + } + + $form->appendChild( + id(new AphrontFormSubmitControl()) + ->setValue($submit) + ->addCancelButton($this->getApplicationURI('/'))); + + $box = id(new PHUIObjectBoxView()) + ->setHeaderText($header) + ->setValidationException($validation_exception) + ->setForm($form); + + $crumbs = $this->buildApplicationCrumbs(); + $crumbs->addTextCrumb($crumb); + + return $this->buildApplicationPage( + array( + $crumbs, + $box, + ), + array( + 'title' => $implementation->getName(), + 'device' => true, + )); + } + + +} diff --git a/src/applications/chronicle/controller/ChronicleTriggerListController.php b/src/applications/chronicle/controller/ChronicleTriggerListController.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/controller/ChronicleTriggerListController.php @@ -0,0 +1,44 @@ +queryKey = idx($data, 'queryKey'); + } + + public function processRequest() { + $request = $this->getRequest(); + $controller = id(new PhabricatorApplicationSearchController($request)) + ->setQueryKey($this->queryKey) + ->setSearchEngine(new ChronicleTriggerSearchEngine()) + ->setNavigation($this->buildSideNavView()); + + return $this->delegateToController($controller); + } + + public function buildSideNavView($for_app = false) { + $user = $this->getRequest()->getUser(); + + $nav = new AphrontSideNavFilterView(); + $nav->setBaseURI(new PhutilURI($this->getApplicationURI())); + + id(new ChronicleTriggerSearchEngine()) + ->setViewer($user) + ->addNavigationItems($nav->getMenu()); + + $nav->selectFilter(null); + + return $nav; + } + + public function buildApplicationMenu() { + return $this->buildSideNavView(true)->getMenu(); + } + +} diff --git a/src/applications/chronicle/controller/ChronicleTriggerViewController.php b/src/applications/chronicle/controller/ChronicleTriggerViewController.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/controller/ChronicleTriggerViewController.php @@ -0,0 +1,138 @@ +id = $data['id']; + } + + public function processRequest() { + $request = $this->getRequest(); + $viewer = $request->getUser(); + + $id = $this->id; + + $trigger = id(new ChronicleTriggerQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->executeOne(); + if (!$trigger) { + return new Aphront404Response(); + } + + $xactions = id(new ChronicleTriggerTransactionQuery()) + ->setViewer($viewer) + ->withObjectPHIDs(array($trigger->getPHID())) + ->execute(); + + $xaction_view = id(new PhabricatorApplicationTransactionView()) + ->setUser($viewer) + ->setObjectPHID($trigger->getPHID()) + ->setTransactions($xactions) + ->setShouldTerminate(true); + + $title = pht('Trigger %d', $id); + + $header = id(new PHUIHeaderView()) + ->setHeader($trigger->getName()) + ->setUser($viewer) + ->setPolicyObject($trigger); + + $box = id(new PHUIObjectBoxView()) + ->setHeader($header); + + $actions = $this->buildActionList($trigger); + $this->buildPropertyLists($box, $trigger, $actions); + + $crumbs = $this->buildApplicationCrumbs(); + $crumbs->addTextCrumb($title); + + return $this->buildApplicationPage( + array( + $crumbs, + $box, + $xaction_view, + ), + array( + 'title' => $title, + 'device' => true, + )); + } + + private function buildActionList(ChronicleTrigger $trigger) { + $request = $this->getRequest(); + $viewer = $request->getUser(); + $id = $trigger->getID(); + + $list = id(new PhabricatorActionListView()) + ->setUser($viewer) + ->setObject($trigger) + ->setObjectURI($this->getApplicationURI("trigger/{$id}/")); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $trigger, + PhabricatorPolicyCapability::CAN_EDIT); + + $list->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Edit Trigger')) + ->setHref($this->getApplicationURI("trigger/edit/{$id}/")) + ->setWorkflow(!$can_edit) + ->setDisabled(!$can_edit) + ->setIcon('fa-pencil')); + + return $list; + } + + private function buildPropertyLists( + PHUIObjectBoxView $box, + ChronicleTrigger $trigger, + PhabricatorActionListView $actions) { + $request = $this->getRequest(); + $viewer = $request->getUser(); + + $properties = id(new PHUIPropertyListView()) + ->setUser($viewer) + ->setObject($trigger) + ->setActionList($actions); + $box->addPropertyList($properties); + + $properties->addProperty( + pht('Epoch Type'), + ucfirst($trigger->getEpochType())); + + if ($trigger->getEpochNext() == ChronicleTrigger::EPOCH_NEVER) { + $properties->addProperty( + pht('Next Epoch'), + phutil_tag('em', array(), pht('Never'))); + $properties->addProperty( + pht('Triggers In'), + phutil_tag('em', array(), pht('Never'))); + } else { + $properties->addProperty( + pht('Next Epoch'), + phabricator_datetime($trigger->getEpochNext(), $viewer)); + + if (time() < $trigger->getEpochNext()) { + $properties->addProperty( + pht('Triggers In'), + phabricator_format_relative_time($trigger->getEpochNext() - time())); + } else { + $properties->addProperty( + pht('Triggers In'), + phutil_tag('em', array(), pht('Overdue'))); + } + } + + $properties->addProperty( + pht('Action'), + $trigger->getActionClass()); + + } + + +} diff --git a/src/applications/chronicle/customfield/ChronicleTriggerCoreCustomField.php b/src/applications/chronicle/customfield/ChronicleTriggerCoreCustomField.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/customfield/ChronicleTriggerCoreCustomField.php @@ -0,0 +1,43 @@ +getActionImplementation(); + $specs = $impl->getFieldSpecifications(); + + return PhabricatorStandardCustomField::buildStandardFields($this, $specs); + } + + public function shouldUseStorage() { + return false; + } + + public function readValueFromObject(PhabricatorCustomFieldInterface $object) { + $key = $this->getProxy()->getRawStandardFieldKey(); + $this->setValueFromStorage($object->getActionConfigSetting($key)); + } + + public function applyApplicationTransactionInternalEffects( + PhabricatorApplicationTransaction $xaction) { + $object = $this->getObject(); + $key = $this->getProxy()->getRawStandardFieldKey(); + + $this->setValueFromApplicationTransactions($xaction->getNewValue()); + $value = $this->getValueForStorage(); + + $object->setActionConfigSetting($key, $value); + } + + public function applyApplicationTransactionExternalEffects( + PhabricatorApplicationTransaction $xaction) { + return; + } + +} diff --git a/src/applications/chronicle/customfield/ChronicleTriggerCustomField.php b/src/applications/chronicle/customfield/ChronicleTriggerCustomField.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/customfield/ChronicleTriggerCustomField.php @@ -0,0 +1,6 @@ +getTableName(); + $conn = $table->establishConnection('r'); + + do { + // Get the next trigger to fire. + $triggers = queryfx_all( + $conn, + 'SELECT id, epochNext '. + 'FROM %T '. + 'WHERE epochNext != %d '. + 'ORDER BY epochNext '. + 'ASC LIMIT 1;', + $table_name, + ChronicleTrigger::EPOCH_NEVER); + + // If there are no triggers, wait 60 seconds and poll again. + if (count($triggers) === 0) { + $this->sleep(60); + continue; + } + + // Get the next trigger to fire from the results. + $next_trigger = $triggers[0]; + + // If the next trigger is greater than 65 seconds away, wait + // 60 seconds and poll again. + if ($next_trigger['epochNext'] - time() > 65) { + $this->sleep(60); + continue; + } + + // Otherwise, load the trigger object and then wait until it's + // epoch. + $trigger = id(new ChronicleTriggerQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withIDs(array($next_trigger['id'])) + ->executeOne(); + $implementation = $trigger->getActionImplementation(); + + // Sleep for the required amount of time. + $time_to_sleep = $trigger->getEpochNext() - time(); + if ($time_to_sleep > 0) { + $this->sleep($time_to_sleep); + } + + // Execute the implementation. + $implementation->execute($trigger); + + // Recalculate the next epoch. + $trigger->setEpochNext($trigger->calculateNextEpoch()); + $trigger->save(); + + // Poll again. + } while (true); + } + +} diff --git a/src/applications/chronicle/editor/ChronicleTriggerEditor.php b/src/applications/chronicle/editor/ChronicleTriggerEditor.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/editor/ChronicleTriggerEditor.php @@ -0,0 +1,110 @@ +getTransactionType()) { + case ChronicleTriggerTransaction::TYPE_CREATE: + return null; + case ChronicleTriggerTransaction::TYPE_NAME: + if ($this->getIsNewObject()) { + return null; + } + return $object->getName(); + case ChronicleTriggerTransaction::TYPE_CONFIG: + if ($this->getIsNewObject()) { + return null; + } + return $object->getEpochConfig(); + case PhabricatorTransactions::TYPE_VIEW_POLICY: + if ($this->getIsNewObject()) { + return null; + } + return $object->getViewPolicy(); + case PhabricatorTransactions::TYPE_EDIT_POLICY: + if ($this->getIsNewObject()) { + return null; + } + return $object->getEditPolicy(); + } + + return parent::getCustomTransactionOldValue($object, $xaction); + } + + protected function getCustomTransactionNewValue( + PhabricatorLiskDAO $object, + PhabricatorApplicationTransaction $xaction) { + + switch ($xaction->getTransactionType()) { + case ChronicleTriggerTransaction::TYPE_CREATE: + return true; + case ChronicleTriggerTransaction::TYPE_NAME: + case ChronicleTriggerTransaction::TYPE_CONFIG: + return $xaction->getNewValue(); + } + + return parent::getCustomTransactionNewValue($object, $xaction); + } + + protected function applyCustomInternalTransaction( + PhabricatorLiskDAO $object, + PhabricatorApplicationTransaction $xaction) { + + switch ($xaction->getTransactionType()) { + case ChronicleTriggerTransaction::TYPE_CREATE: + return $object->setEpochNext($object->calculateNextEpoch()); + case ChronicleTriggerTransaction::TYPE_NAME: + return $object->setName($xaction->getNewValue()); + case ChronicleTriggerTransaction::TYPE_CONFIG: + return $object->setEpochConfig($xaction->getNewValue()); + case PhabricatorTransactions::TYPE_EDIT_POLICY: + return $object->setEditPolicy($xaction->getNewValue()); + case PhabricatorTransactions::TYPE_VIEW_POLICY: + return $object->setViewPolicy($xaction->getNewValue()); + } + + return parent::applyCustomInternalTransaction($object, $xaction); + } + + protected function applyCustomExternalTransaction( + PhabricatorLiskDAO $object, + PhabricatorApplicationTransaction $xaction) { + + switch ($xaction->getTransactionType()) { + case ChronicleTriggerTransaction::TYPE_CREATE: + case ChronicleTriggerTransaction::TYPE_NAME: + case ChronicleTriggerTransaction::TYPE_CONFIG: + case PhabricatorTransactions::TYPE_EDIT_POLICY: + case PhabricatorTransactions::TYPE_VIEW_POLICY: + return; + } + + return parent::applyCustomExternalTransaction($object, $xaction); + } + + protected function applyFinalEffects( + PhabricatorLiskDAO $object, + array $xactions) { + + $object->setEpochNext($object->calculateNextEpoch()); + $object->save(); + + return $xactions; + } +} diff --git a/src/applications/chronicle/phid/ChroniclePHIDTypeTrigger.php b/src/applications/chronicle/phid/ChroniclePHIDTypeTrigger.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/phid/ChroniclePHIDTypeTrigger.php @@ -0,0 +1,44 @@ +withPHIDs($phids); + } + + public function loadHandles( + PhabricatorHandleQuery $query, + array $handles, + array $objects) { + + foreach ($handles as $phid => $handle) { + $trigger = $objects[$phid]; + + $id = $trigger->getID(); + $name = $trigger->getName(); + + $handle->setURI("/chronicle/trigger/view/{$id}/"); + $handle->setName("Chronicle Trigger {$id}"); + $handle->setFullName("Chronicle Trigger {$id}: ".$name); + } + } + +} diff --git a/src/applications/chronicle/query/ChronicleTriggerQuery.php b/src/applications/chronicle/query/ChronicleTriggerQuery.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/query/ChronicleTriggerQuery.php @@ -0,0 +1,60 @@ +ids = $ids; + return $this; + } + + public function withPHIDs(array $phids) { + $this->phids = $phids; + return $this; + } + + protected function loadPage() { + $table = new ChronicleTrigger(); + $conn_r = $table->establishConnection('r'); + + $data = queryfx_all( + $conn_r, + 'SELECT * FROM %T %Q %Q %Q', + $table->getTableName(), + $this->buildWhereClause($conn_r), + $this->buildOrderClause($conn_r), + $this->buildLimitClause($conn_r)); + + return $table->loadAllFromArray($data); + } + + private function buildWhereClause(AphrontDatabaseConnection $conn_r) { + $where = array(); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn_r, + 'id IN (%Ld)', + $this->ids); + } + + if ($this->phids !== null) { + $where[] = qsprintf( + $conn_r, + 'phid IN (%Ls)', + $this->phids); + } + + $where[] = $this->buildPagingClause($conn_r); + + return $this->formatWhereClause($where); + } + + public function getQueryApplicationClass() { + return 'PhabricatorApplicationChronicle'; + } + +} diff --git a/src/applications/chronicle/query/ChronicleTriggerSearchEngine.php b/src/applications/chronicle/query/ChronicleTriggerSearchEngine.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/query/ChronicleTriggerSearchEngine.php @@ -0,0 +1,84 @@ + pht('All Triggers'), + ); + + return $names; + } + + public function buildSavedQueryFromBuiltin($query_key) { + + $query = $this->newSavedQuery(); + $query->setQueryKey($query_key); + + switch ($query_key) { + case 'all': + return $query; + } + + return parent::buildSavedQueryFromBuiltin($query_key); + } + + protected function renderResultList( + array $triggers, + PhabricatorSavedQuery $query, + array $handles) { + assert_instances_of($triggers, 'ChronicleTrigger'); + + $viewer = $this->requireViewer(); + + $list = new PHUIObjectItemListView(); + foreach ($triggers as $trigger) { + $id = $trigger->getID(); + + $item = id(new PHUIObjectItemView()) + ->setObjectName(pht('Trigger %d', $id)) + ->setHeader($trigger->getName()); + + if ($id) { + $item->setHref("/chronicle/trigger/{$id}/"); + } + + $list->addItem($item); + + } + + return $list; + } +} diff --git a/src/applications/chronicle/query/ChronicleTriggerTransactionQuery.php b/src/applications/chronicle/query/ChronicleTriggerTransactionQuery.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/query/ChronicleTriggerTransactionQuery.php @@ -0,0 +1,10 @@ +setEpochType(self::TYPE_NEVER) + ->setActionClass($class); + } + + public function getConfiguration() { + return array( + self::CONFIG_AUX_PHID => true, + self::CONFIG_SERIALIZATION => array( + 'actionData' => self::SERIALIZATION_JSON, + 'epochConfig' => self::SERIALIZATION_JSON + ) + ) + parent::getConfiguration(); + } + + public function generatePHID() { + return PhabricatorPHID::generateNewPHID( + ChroniclePHIDTypeTrigger::TYPECONST); + } + + public function getEpochType() { + return $this->getEpochConfigSetting('type', self::TYPE_NEVER); + } + + public function setEpochType($type) { + return $this->setEpochConfigSetting('type', $type); + } + + public function getEpochConfigSetting($key, $default = null) { + return idx($this->epochConfig, $key, $default); + } + + public function setEpochConfigSetting($key, $value) { + $this->epochConfig[$key] = $value; + return $this; + } + + public function getActionConfigSetting($key, $default = null) { + return idx($this->actionData, $key, $default); + } + + public function setActionConfigSetting($key, $value) { + $this->actionData[$key] = $value; + return $this; + } + + public function getActionImplementation() { + if ($this->implementation === null) { + $obj = ChronicleAction::requireImplementation( + $this->actionClass); + $obj->loadSettings($this->getActionData()); + $this->implementation = $obj; + } + + return $this->implementation; + } + + public function calculateNextEpoch($now = null) { + if ($now === null) { + $now = time(); + } + + switch ($this->getEpochType()) { + case self::TYPE_NEVER: + return self::EPOCH_NEVER; + case self::TYPE_ONCE: + $epoch = $this->getEpochConfigSetting('epoch'); + if ($epoch > $now) { + return $epoch; + } else { + return self::EPOCH_NEVER; + } + case self::TYPE_DAILY: + $days = array( + self::DAY_SUNDAY => 'Sunday', + self::DAY_MONDAY => 'Monday', + self::DAY_TUESDAY => 'Tuesday', + self::DAY_WEDNESDAY => 'Wednesday', + self::DAY_THURSDAY => 'Thursday', + self::DAY_FRIDAY => 'Friday', + self::DAY_SATURDAY => 'Saturday'); + + $days_active = $this->getEpochConfigSetting('days'); + $times_active = $this->getEpochConfigSetting('times'); + + $current_next = null; + foreach ($days as $key => $value) { + if (in_array($key, $days_active)) { + $midnight = strtotime('midnight '.$value, $now); + + foreach ($times_active as $time) { + $time_of_day = $midnight + (int)($time * 60 * 60); + + if ($time_of_day <= $now) { + $midnight = strtotime('midnight next '.$value, $now); + $time_of_day = $midnight + (int)($time * 60 * 60); + } + + if ($current_next === null || $time_of_day < $current_next) { + $current_next = $time_of_day; + } + } + } + } + + return $current_next; + } + } + + +/* -( PhabricatorPolicyInterface )----------------------------------------- */ + + + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + ); + } + + public function getPolicy($capability) { + switch ($capability) { + case PhabricatorPolicyCapability::CAN_VIEW: + return $this->getViewPolicy(); + case PhabricatorPolicyCapability::CAN_EDIT: + return $this->getEditPolicy(); + } + } + + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { + $impl = $this->getActionImplementation(); + return $impl->canCreateOrEdit($viewer); + } + + public function describeAutomaticCapability($capability) { + return pht( + 'Users which are provided create or edit permissions by the action '. + 'can also edit the trigger.'); + } + + +/* -( PhabricatorCustomFieldInterface )------------------------------------ */ + + + public function getCustomFieldSpecificationForRole($role) { + return array(); + } + + public function getCustomFieldBaseClass() { + return 'ChronicleTriggerCustomField'; + } + + public function getCustomFields() { + return $this->assertAttached($this->customFields); + } + + public function attachCustomFields(PhabricatorCustomFieldAttachment $fields) { + $this->customFields = $fields; + return $this; + } + + +} diff --git a/src/applications/chronicle/storage/ChronicleTriggerTransaction.php b/src/applications/chronicle/storage/ChronicleTriggerTransaction.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/storage/ChronicleTriggerTransaction.php @@ -0,0 +1,79 @@ +getAuthorPHID(); + + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + switch ($this->getTransactionType()) { + case self::TYPE_CREATE: + return pht( + '%s created this trigger.', + $this->renderHandleLink($author_phid)); + case self::TYPE_NAME: + if ($old === null) { + return pht( + '%s set the name to "%s".', + $this->renderHandleLink($author_phid), + $new); + } else { + return pht( + '%s changed the name from "%s" to "%s".', + $this->renderHandleLink($author_phid), + $old, + $new); + } + case self::TYPE_CONFIG: + return pht( + '%s updated the trigger configuration.', + $this->renderHandleLink($author_phid)); + } + + return parent::getTitle(); + } + + public function getIcon() { + $author_phid = $this->getAuthorPHID(); + + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + switch ($this->getTransactionType()) { + case self::TYPE_CREATE: + return 'fa-plus'; + } + + return parent::getIcon(); + } + + public function getColor() { + $author_phid = $this->getAuthorPHID(); + + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + switch ($this->getTransactionType()) { + case self::TYPE_CREATE: + return 'green'; + } + + return parent::getColor(); + } + +} diff --git a/src/applications/chronicle/storage/__tests__/ChronicleTriggerNextEpochTestCase.php b/src/applications/chronicle/storage/__tests__/ChronicleTriggerNextEpochTestCase.php new file mode 100644 --- /dev/null +++ b/src/applications/chronicle/storage/__tests__/ChronicleTriggerNextEpochTestCase.php @@ -0,0 +1,100 @@ +makeEphemeral(); + + $this->assertEqual( + ChronicleTrigger::EPOCH_NEVER, + $trigger->calculateNextEpoch()); + } + + public function testNeverEpoch() { + $trigger = id(new ChronicleTrigger()) + ->setEpochType(ChronicleTrigger::TYPE_NEVER) + ->makeEphemeral(); + + $this->assertEqual( + ChronicleTrigger::EPOCH_NEVER, + $trigger->calculateNextEpoch()); + } + + public function testOnceEpoch() { + $now = time(); + + $trigger = id(new ChronicleTrigger()) + ->setEpochType(ChronicleTrigger::TYPE_ONCE) + ->setEpochConfigSetting('epoch', $now) + ->makeEphemeral(); + + $this->assertEqual( + $now, + $trigger->calculateNextEpoch($now - 1)); + $this->assertEqual( + ChronicleTrigger::EPOCH_NEVER, + $trigger->calculateNextEpoch($now)); + $this->assertEqual( + ChronicleTrigger::EPOCH_NEVER, + $trigger->calculateNextEpoch($now + 1)); + } + + public function testDailyEpoch() { + $now = 1401271590; + + $next_monday = 1401638400; + $next_wednesday = 1401811200; + $next_thursday = 1401292800; + $next_sunday = 1401552000; + + $trigger = id(new ChronicleTrigger()) + ->setEpochType(ChronicleTrigger::TYPE_DAILY) + ->setEpochConfigSetting('days', array( + ChronicleTrigger::DAY_MONDAY, + ChronicleTrigger::DAY_WEDNESDAY, + ChronicleTrigger::DAY_THURSDAY, + ChronicleTrigger::DAY_SUNDAY)) + ->setEpochConfigSetting('times', array(2)) // 2am + ->makeEphemeral(); + + $this->assertEqual( + $next_thursday, + $trigger->calculateNextEpoch($now)); + + $this->assertEqual( + $next_thursday, + $trigger->calculateNextEpoch($next_thursday - 1)); + $this->assertEqual( + $next_sunday, + $trigger->calculateNextEpoch($next_thursday)); + $this->assertEqual( + $next_sunday, + $trigger->calculateNextEpoch($next_thursday + 1)); + + $this->assertEqual( + $next_sunday, + $trigger->calculateNextEpoch($next_sunday - 1)); + $this->assertEqual( + $next_monday, + $trigger->calculateNextEpoch($next_sunday)); + $this->assertEqual( + $next_monday, + $trigger->calculateNextEpoch($next_sunday + 1)); + + $this->assertEqual( + $next_monday, + $trigger->calculateNextEpoch($next_monday - 1)); + $this->assertEqual( + $next_wednesday, + $trigger->calculateNextEpoch($next_monday)); + $this->assertEqual( + $next_wednesday, + $trigger->calculateNextEpoch($next_monday + 1)); + + $this->assertEqual( + $next_wednesday, + $trigger->calculateNextEpoch($next_wednesday - 1)); + } + +} diff --git a/src/applications/daemon/management/PhabricatorDaemonManagementWorkflow.php b/src/applications/daemon/management/PhabricatorDaemonManagementWorkflow.php --- a/src/applications/daemon/management/PhabricatorDaemonManagementWorkflow.php +++ b/src/applications/daemon/management/PhabricatorDaemonManagementWorkflow.php @@ -268,6 +268,7 @@ $daemons = array( array('PhabricatorRepositoryPullLocalDaemon', array()), array('PhabricatorGarbageCollectorDaemon', array()), + array('ChronicleDaemon', array()), ); $taskmasters = PhabricatorEnv::getEnvConfig('phd.start-taskmasters');