Page MenuHomePhabricator

D9637.diff
No OneTemporary

D9637.diff

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 @@
+<?php
+
+abstract class ChronicleAction {
+
+ public static function getImplementations() {
+ return id(new PhutilSymbolLoader())
+ ->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 @@
+<?php
+
+final class ChronicleSendEmailAction
+ extends ChronicleAction {
+
+ public function getName() {
+ return pht('Send Email');
+ }
+
+ public function getDescription() {
+ return pht('Sends an email to one or more users');
+ }
+
+ public function execute(ChronicleTrigger $trigger) {
+ $recipients = phutil_json_decode($this->getSetting('recipients'));
+ if (count($recipients) === 0) {
+ return;
+ }
+
+ $body = $this->getSetting('message');
+
+ $body .= <<<EOF
+
+
+Message triggered by:
+EOF;
+ $body .= PhabricatorEnv::getURI(
+ '/chronicle/trigger/'.$trigger->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 @@
+<?php
+
+final class ChronicleStartHarbormasterBuildAction
+ extends ChronicleAction {
+
+ public function getName() {
+ return pht('Start Harbormaster Build');
+ }
+
+ public function getDescription() {
+ return pht('Starts a Harbormaster build against a branch in a repository');
+ }
+
+ public function execute(ChronicleTrigger $trigger) {
+ $callsign = $this->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 @@
+<?php
+
+final class PhabricatorApplicationChronicle extends PhabricatorApplication {
+
+ public function getBaseURI() {
+ return '/chronicle/';
+ }
+
+ public function getShortDescription() {
+ return pht('Timed Events');
+ }
+
+ public function getIconName() {
+ return 'chronicle';
+ }
+
+ public function getTitleGlyph() {
+ return "\xE2\x99\xBB";
+ }
+
+ public function getApplicationGroup() {
+ return self::GROUP_UTILITIES;
+ }
+
+ public function isBeta() {
+ return true;
+ }
+
+ public function getRoutes() {
+ return array(
+ '/chronicle/' => array(
+ '(?:query/(?P<queryKey>[^/]+)/)?'
+ => 'ChronicleTriggerListController',
+ 'trigger/' => array(
+ 'add/(?:(?P<id>\d+)/)?' => 'ChronicleTriggerAddController',
+ 'new/(?P<class>[^/]+)/'
+ => 'ChronicleTriggerEditController',
+ 'edit/(?:(?P<id>\d+)/)?' => 'ChronicleTriggerEditController',
+ '(?P<id>\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 @@
+<?php
+
+abstract class ChronicleController extends PhabricatorController {
+
+ public function buildApplicationCrumbs() {
+ $crumbs = parent::buildApplicationCrumbs();
+
+ $crumbs->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 @@
+<?php
+
+final class ChronicleTriggerAddController
+ extends ChronicleTriggerController {
+
+ public function processRequest() {
+ $request = $this->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 @@
+<?php
+
+abstract class ChronicleTriggerController extends ChronicleController {
+
+}
diff --git a/src/applications/chronicle/controller/ChronicleTriggerEditController.php b/src/applications/chronicle/controller/ChronicleTriggerEditController.php
new file mode 100644
--- /dev/null
+++ b/src/applications/chronicle/controller/ChronicleTriggerEditController.php
@@ -0,0 +1,223 @@
+<?php
+
+final class ChronicleTriggerEditController
+ extends ChronicleController {
+
+ private $id;
+ private $className;
+
+ public function willProcessRequest(array $data) {
+ $this->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 @@
+<?php
+
+final class ChronicleTriggerListController extends ChronicleTriggerController {
+
+ private $queryKey;
+
+ public function shouldAllowPublic() {
+ return true;
+ }
+
+ public function willProcessRequest(array $data) {
+ $this->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 @@
+<?php
+
+final class ChronicleTriggerViewController
+ extends ChronicleController {
+
+ private $id;
+
+ public function willProcessRequest(array $data) {
+ $this->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 @@
+<?php
+
+final class ChronicleTriggerCoreCustomField
+ extends ChronicleTriggerCustomField
+ implements PhabricatorStandardCustomFieldInterface {
+
+ public function getStandardCustomFieldNamespace() {
+ return 'chronicle:core';
+ }
+
+ public function createFields($object) {
+ $impl = $object->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 @@
+<?php
+
+abstract class ChronicleTriggerCustomField
+ extends PhabricatorCustomField {
+
+}
diff --git a/src/applications/chronicle/daemon/ChronicleDaemon.php b/src/applications/chronicle/daemon/ChronicleDaemon.php
new file mode 100644
--- /dev/null
+++ b/src/applications/chronicle/daemon/ChronicleDaemon.php
@@ -0,0 +1,63 @@
+<?php
+
+final class ChronicleDaemon extends PhabricatorDaemon {
+
+ public function run() {
+ $table = new ChronicleTrigger();
+ $table_name = $table->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 @@
+<?php
+
+final class ChronicleTriggerEditor
+ extends PhabricatorApplicationTransactionEditor {
+
+ public function getTransactionTypes() {
+ $types = parent::getTransactionTypes();
+
+ $types[] = ChronicleTriggerTransaction::TYPE_CREATE;
+ $types[] = ChronicleTriggerTransaction::TYPE_NAME;
+ $types[] = ChronicleTriggerTransaction::TYPE_CONFIG;
+ $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY;
+ $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY;
+
+ return $types;
+ }
+
+ protected function getCustomTransactionOldValue(
+ PhabricatorLiskDAO $object,
+ PhabricatorApplicationTransaction $xaction) {
+
+ switch ($xaction->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 @@
+<?php
+
+final class ChroniclePHIDTypeTrigger extends PhabricatorPHIDType {
+
+ const TYPECONST = 'CHRT';
+
+ public function getTypeConstant() {
+ return self::TYPECONST;
+ }
+
+ public function getTypeName() {
+ return pht('Chronicle Trigger');
+ }
+
+ public function newObject() {
+ return new ChronicleTrigger();
+ }
+
+ protected function buildQueryForObjects(
+ PhabricatorObjectQuery $query,
+ array $phids) {
+
+ return id(new ChronicleTriggerQuery())
+ ->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 @@
+<?php
+
+final class ChronicleTriggerQuery
+ extends PhabricatorCursorPagedPolicyAwareQuery {
+
+ private $ids;
+ private $phids;
+
+ public function withIDs(array $ids) {
+ $this->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 @@
+<?php
+
+final class ChronicleTriggerSearchEngine
+ extends PhabricatorApplicationSearchEngine {
+
+ public function getApplicationClassName() {
+ return 'PhabricatorApplicationChronicle';
+ }
+
+ public function getResultTypeDescription() {
+ return pht('Chronicle Triggers');
+ }
+
+ public function buildSavedQueryFromRequest(AphrontRequest $request) {
+ $saved = new PhabricatorSavedQuery();
+
+ return $saved;
+ }
+
+ public function buildQueryFromSavedQuery(PhabricatorSavedQuery $saved) {
+ $query = id(new ChronicleTriggerQuery());
+
+ return $query;
+ }
+
+ public function buildSearchForm(
+ AphrontFormView $form,
+ PhabricatorSavedQuery $saved_query) {
+
+ // TODO
+ }
+
+ protected function getURI($path) {
+ return '/chronicle/'.$path;
+ }
+
+ public function getBuiltinQueryNames() {
+ $names = array(
+ 'all' => 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 @@
+<?php
+
+final class ChronicleTriggerTransactionQuery
+ extends PhabricatorApplicationTransactionQuery {
+
+ public function getTemplateApplicationTransaction() {
+ return new ChronicleTriggerTransaction();
+ }
+
+}
diff --git a/src/applications/chronicle/storage/ChronicleDAO.php b/src/applications/chronicle/storage/ChronicleDAO.php
new file mode 100644
--- /dev/null
+++ b/src/applications/chronicle/storage/ChronicleDAO.php
@@ -0,0 +1,9 @@
+<?php
+
+abstract class ChronicleDAO extends PhabricatorLiskDAO {
+
+ public function getApplicationName() {
+ return 'chronicle';
+ }
+
+}
diff --git a/src/applications/chronicle/storage/ChronicleTrigger.php b/src/applications/chronicle/storage/ChronicleTrigger.php
new file mode 100644
--- /dev/null
+++ b/src/applications/chronicle/storage/ChronicleTrigger.php
@@ -0,0 +1,196 @@
+<?php
+
+final class ChronicleTrigger extends ChronicleDAO
+ implements
+ PhabricatorPolicyInterface,
+ PhabricatorCustomFieldInterface {
+
+ protected $name;
+ protected $epochNext;
+ protected $epochConfig = array();
+ protected $actionClass;
+ protected $actionData = array();
+ protected $viewPolicy;
+ protected $editPolicy;
+
+ private $implementation;
+ private $customFields = self::ATTACHABLE;
+
+ const EPOCH_NEVER = 0;
+
+ const TYPE_NEVER = 'never';
+ const TYPE_ONCE = 'once';
+ const TYPE_DAILY = 'daily';
+
+ const DAY_SUNDAY = 0;
+ const DAY_MONDAY = 1;
+ const DAY_TUESDAY = 2;
+ const DAY_WEDNESDAY = 3;
+ const DAY_THURSDAY = 4;
+ const DAY_FRIDAY = 5;
+ const DAY_SATURDAY = 6;
+
+ public static function initializeNewTrigger($class) {
+ return id(new ChronicleTrigger())
+ ->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 @@
+<?php
+
+final class ChronicleTriggerTransaction
+ extends PhabricatorApplicationTransaction {
+
+ const TYPE_CREATE = 'trigger:create';
+ const TYPE_NAME = 'trigger:name';
+ const TYPE_CONFIG = 'trigger:config';
+
+ public function getApplicationName() {
+ return 'chronicle';
+ }
+
+ public function getApplicationTransactionType() {
+ return ChroniclePHIDTypeTrigger::TYPECONST;
+ }
+
+ public function getTitle() {
+ $author_phid = $this->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 @@
+<?php
+
+final class ChronicleTriggerNextEpochTestCase extends PhabricatorTestCase {
+
+ public function testUnsetEpoch() {
+ $trigger = id(new ChronicleTrigger())
+ ->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');

File Metadata

Mime Type
text/plain
Expires
Wed, Mar 19, 11:36 PM (2 w, 2 d ago)
Storage Engine
amazon-s3
Storage Format
Encrypted (AES-256-CBC)
Storage Handle
phabricator/secure/oc/lb/l4k5h5fitwel2nk5
Default Alt Text
D9637.diff (50 KB)

Event Timeline