diff --git a/resources/sql/patches/041.heraldrepetition.sql b/resources/sql/patches/041.heraldrepetition.sql
new file mode 100644
index 000000000..ee97fd1d3
--- /dev/null
+++ b/resources/sql/patches/041.heraldrepetition.sql
@@ -0,0 +1,7 @@
+CREATE TABLE phabricator_herald.herald_ruleapplied (
+  ruleID int unsigned not null,
+  phid varchar(64) binary not null,
+  PRIMARY KEY(ruleID, phid)
+) ENGINE=InnoDB;
+
+ALTER TABLE phabricator_herald.herald_rule add repetitionPolicy int unsigned;
diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php
index 0b2c60a1e..cff72691d 100644
--- a/src/__phutil_library_map__.php
+++ b/src/__phutil_library_map__.php
@@ -1,944 +1,945 @@
 <?php
 
 /**
  * This file is automatically generated. Use 'phutil_mapper.php' to rebuild it.
  * @generated
  */
 
 phutil_register_library_map(array(
   'class' =>
   array(
     'Aphront304Response' => 'aphront/response/304',
     'Aphront400Response' => 'aphront/response/400',
     'Aphront404Response' => 'aphront/response/404',
     'AphrontAjaxResponse' => 'aphront/response/ajax',
     'AphrontApplicationConfiguration' => 'aphront/applicationconfiguration',
     'AphrontAttachedFileView' => 'view/control/attachedfile',
     'AphrontController' => 'aphront/controller',
     'AphrontCrumbsView' => 'view/layout/crumbs',
     'AphrontDatabaseConnection' => 'storage/connection/base',
     'AphrontDefaultApplicationConfiguration' => 'aphront/default/configuration',
     'AphrontDefaultApplicationController' => 'aphront/default/controller',
     'AphrontDialogResponse' => 'aphront/response/dialog',
     'AphrontDialogView' => 'view/dialog',
     'AphrontErrorView' => 'view/form/error',
     'AphrontException' => 'aphront/exception/base',
     'AphrontFilePreviewView' => 'view/layout/filepreview',
     'AphrontFileResponse' => 'aphront/response/file',
     'AphrontFormCheckboxControl' => 'view/form/control/checkbox',
     'AphrontFormControl' => 'view/form/control/base',
     'AphrontFormDividerControl' => 'view/form/control/divider',
     'AphrontFormDragAndDropUploadControl' => 'view/form/control/draganddropupload',
     'AphrontFormFileControl' => 'view/form/control/file',
     'AphrontFormMarkupControl' => 'view/form/control/markup',
     'AphrontFormPasswordControl' => 'view/form/control/password',
     'AphrontFormRecaptchaControl' => 'view/form/control/recaptcha',
     'AphrontFormSelectControl' => 'view/form/control/select',
     'AphrontFormStaticControl' => 'view/form/control/static',
     'AphrontFormSubmitControl' => 'view/form/control/submit',
     'AphrontFormTextAreaControl' => 'view/form/control/textarea',
     'AphrontFormTextControl' => 'view/form/control/text',
     'AphrontFormToggleButtonsControl' => 'view/form/control/togglebuttons',
     'AphrontFormTokenizerControl' => 'view/form/control/tokenizer',
     'AphrontFormView' => 'view/form/base',
     'AphrontHeadsupActionListView' => 'view/layout/headsup/actionlist',
     'AphrontHeadsupActionView' => 'view/layout/headsup/action',
     'AphrontIsolatedDatabaseConnection' => 'storage/connection/isolated',
     'AphrontIsolatedDatabaseConnectionTestCase' => 'storage/connection/isolated/__tests__',
     'AphrontListFilterView' => 'view/layout/listfilter',
     'AphrontMySQLDatabaseConnection' => 'storage/connection/mysql',
     'AphrontNullView' => 'view/null',
     'AphrontPageView' => 'view/page/base',
     'AphrontPagerView' => 'view/control/pager',
     'AphrontPanelView' => 'view/layout/panel',
     'AphrontQueryAccessDeniedException' => 'storage/exception/accessdenied',
     'AphrontQueryConnectionException' => 'storage/exception/connection',
     'AphrontQueryConnectionLostException' => 'storage/exception/connectionlost',
     'AphrontQueryCountException' => 'storage/exception/count',
     'AphrontQueryDuplicateKeyException' => 'storage/exception/duplicatekey',
     'AphrontQueryException' => 'storage/exception/base',
     'AphrontQueryObjectMissingException' => 'storage/exception/objectmissing',
     'AphrontQueryParameterException' => 'storage/exception/parameter',
     'AphrontQueryRecoverableException' => 'storage/exception/recoverable',
     'AphrontRedirectException' => 'aphront/exception/redirect',
     'AphrontRedirectResponse' => 'aphront/response/redirect',
     'AphrontReloadResponse' => 'aphront/response/reload',
     'AphrontRequest' => 'aphront/request',
     'AphrontRequestFailureView' => 'view/page/failure',
     'AphrontResponse' => 'aphront/response/base',
     'AphrontSideNavView' => 'view/layout/sidenav',
     'AphrontTableView' => 'view/control/table',
     'AphrontTokenizerTemplateView' => 'view/control/tokenizer',
     'AphrontTypeaheadTemplateView' => 'view/control/typeahead',
     'AphrontURIMapper' => 'aphront/mapper',
     'AphrontView' => 'view/base',
     'AphrontWebpageResponse' => 'aphront/response/webpage',
     'CelerityAPI' => 'infrastructure/celerity/api',
     'CelerityResourceController' => 'infrastructure/celerity/controller',
     'CelerityResourceMap' => 'infrastructure/celerity/map',
     'CelerityStaticResourceResponse' => 'infrastructure/celerity/response',
     'ConduitAPIMethod' => 'applications/conduit/method/base',
     'ConduitAPIRequest' => 'applications/conduit/protocol/request',
     'ConduitAPI_conduit_connect_Method' => 'applications/conduit/method/conduit/connect',
     'ConduitAPI_conduit_ping_Method' => 'applications/conduit/method/conduit/ping',
     'ConduitAPI_daemon_launched_Method' => 'applications/conduit/method/daemon/launched',
     'ConduitAPI_daemon_log_Method' => 'applications/conduit/method/daemon/log',
     'ConduitAPI_differential_creatediff_Method' => 'applications/conduit/method/differential/creatediff',
     'ConduitAPI_differential_createrevision_Method' => 'applications/conduit/method/differential/createrevision',
     'ConduitAPI_differential_find_Method' => 'applications/conduit/method/differential/find',
     'ConduitAPI_differential_getalldiffs_Method' => 'applications/conduit/method/differential/getalldiffs',
     'ConduitAPI_differential_getcommitmessage_Method' => 'applications/conduit/method/differential/getcommitmessage',
     'ConduitAPI_differential_getcommitpaths_Method' => 'applications/conduit/method/differential/getcommitpaths',
     'ConduitAPI_differential_getdiff_Method' => 'applications/conduit/method/differential/getdiff',
     'ConduitAPI_differential_getrevision_Method' => 'applications/conduit/method/differential/getrevision',
     'ConduitAPI_differential_getrevisionfeedback_Method' => 'applications/conduit/method/differential/getrevisionfeedback',
     'ConduitAPI_differential_markcommitted_Method' => 'applications/conduit/method/differential/markcommitted',
     'ConduitAPI_differential_parsecommitmessage_Method' => 'applications/conduit/method/differential/parsecommitmessage',
     'ConduitAPI_differential_setdiffproperty_Method' => 'applications/conduit/method/differential/setdiffproperty',
     'ConduitAPI_differential_updaterevision_Method' => 'applications/conduit/method/differential/updaterevision',
     'ConduitAPI_differential_updatetaskrevisionassoc_Method' => 'applications/conduit/method/differential/updatetaskrevisionassoc',
     'ConduitAPI_diffusion_getcommits_Method' => 'applications/conduit/method/diffusion/getcommits',
     'ConduitAPI_diffusion_getrecentcommitsbypath_Method' => 'applications/conduit/method/diffusion/getrecentcommitsbypath',
     'ConduitAPI_file_download_Method' => 'applications/conduit/method/file/download',
     'ConduitAPI_file_upload_Method' => 'applications/conduit/method/file/upload',
     'ConduitAPI_path_getowners_Method' => 'applications/conduit/method/path/getowners',
     'ConduitAPI_user_find_Method' => 'applications/conduit/method/user/find',
     'ConduitAPI_user_whoami_Method' => 'applications/conduit/method/user/whoami',
     'ConduitException' => 'applications/conduit/protocol/exception',
     'DarkConsole' => 'aphront/console/api',
     'DarkConsoleConfigPlugin' => 'aphront/console/plugin/config',
     'DarkConsoleController' => 'aphront/console/controller',
     'DarkConsoleCore' => 'aphront/console/core',
     'DarkConsoleErrorLogPlugin' => 'aphront/console/plugin/errorlog',
     'DarkConsoleErrorLogPluginAPI' => 'aphront/console/plugin/errorlog/api',
     'DarkConsolePlugin' => 'aphront/console/plugin/base',
     'DarkConsoleRequestPlugin' => 'aphront/console/plugin/request',
     'DarkConsoleServicesPlugin' => 'aphront/console/plugin/services',
     'DarkConsoleXHProfPlugin' => 'aphront/console/plugin/xhprof',
     'DarkConsoleXHProfPluginAPI' => 'aphront/console/plugin/xhprof/api',
     'DatabaseConfigurationProvider' => 'applications/base/storage/configuration',
     'DifferentialAction' => 'applications/differential/constants/action',
     'DifferentialAddCommentView' => 'applications/differential/view/addcomment',
     'DifferentialCCWelcomeMail' => 'applications/differential/mail/ccwelcome',
     'DifferentialChangeType' => 'applications/differential/constants/changetype',
     'DifferentialChangeset' => 'applications/differential/storage/changeset',
     'DifferentialChangesetDetailView' => 'applications/differential/view/changesetdetailview',
     'DifferentialChangesetListView' => 'applications/differential/view/changesetlistview',
     'DifferentialChangesetParser' => 'applications/differential/parser/changeset',
     'DifferentialChangesetViewController' => 'applications/differential/controller/changesetview',
     'DifferentialComment' => 'applications/differential/storage/comment',
     'DifferentialCommentEditor' => 'applications/differential/editor/comment',
     'DifferentialCommentMail' => 'applications/differential/mail/comment',
     'DifferentialCommentPreviewController' => 'applications/differential/controller/commentpreview',
     'DifferentialCommentSaveController' => 'applications/differential/controller/commentsave',
     'DifferentialCommitMessage' => 'applications/differential/parser/commitmessage',
     'DifferentialCommitMessageData' => 'applications/differential/data/commitmessage',
     'DifferentialCommitMessageParserException' => 'applications/differential/parser/commitmessage/exception',
     'DifferentialController' => 'applications/differential/controller/base',
     'DifferentialDAO' => 'applications/differential/storage/base',
     'DifferentialDiff' => 'applications/differential/storage/diff',
     'DifferentialDiffContentMail' => 'applications/differential/mail/diffcontent',
     'DifferentialDiffCreateController' => 'applications/differential/controller/diffcreate',
     'DifferentialDiffProperty' => 'applications/differential/storage/diffproperty',
     'DifferentialDiffTableOfContentsView' => 'applications/differential/view/difftableofcontents',
     'DifferentialDiffViewController' => 'applications/differential/controller/diffview',
     'DifferentialExceptionMail' => 'applications/differential/mail/exception',
     'DifferentialHunk' => 'applications/differential/storage/hunk',
     'DifferentialInlineComment' => 'applications/differential/storage/inlinecomment',
     'DifferentialInlineCommentEditController' => 'applications/differential/controller/inlinecommentedit',
     'DifferentialInlineCommentPreviewController' => 'applications/differential/controller/inlinecommentpreview',
     'DifferentialInlineCommentView' => 'applications/differential/view/inlinecomment',
     'DifferentialLintStatus' => 'applications/differential/constants/lintstatus',
     'DifferentialMail' => 'applications/differential/mail/base',
     'DifferentialMarkupEngineFactory' => 'applications/differential/parser/markup',
     'DifferentialNewDiffMail' => 'applications/differential/mail/newdiff',
     'DifferentialReplyHandler' => 'applications/differential/replyhandler',
     'DifferentialReviewRequestMail' => 'applications/differential/mail/reviewrequest',
     'DifferentialRevision' => 'applications/differential/storage/revision',
     'DifferentialRevisionCommentListView' => 'applications/differential/view/revisioncommentlist',
     'DifferentialRevisionCommentView' => 'applications/differential/view/revisioncomment',
     'DifferentialRevisionControlSystem' => 'applications/differential/constants/revisioncontrolsystem',
     'DifferentialRevisionDetailRenderer' => 'applications/differential/controller/customrenderer',
     'DifferentialRevisionDetailView' => 'applications/differential/view/revisiondetail',
     'DifferentialRevisionEditController' => 'applications/differential/controller/revisionedit',
     'DifferentialRevisionEditor' => 'applications/differential/editor/revision',
     'DifferentialRevisionListController' => 'applications/differential/controller/revisionlist',
     'DifferentialRevisionListData' => 'applications/differential/data/revisionlist',
     'DifferentialRevisionStatus' => 'applications/differential/constants/revisionstatus',
     'DifferentialRevisionUpdateHistoryView' => 'applications/differential/view/revisionupdatehistory',
     'DifferentialRevisionViewController' => 'applications/differential/controller/revisionview',
     'DifferentialSubscribeController' => 'applications/differential/controller/subscribe',
     'DifferentialTasksAttacher' => 'applications/differential/tasks',
     'DifferentialUnitStatus' => 'applications/differential/constants/unitstatus',
     'DifferentialViewTime' => 'applications/differential/storage/viewtime',
     'DiffusionBranchInformation' => 'applications/diffusion/data/branch',
     'DiffusionBranchQuery' => 'applications/diffusion/query/branch/base',
     'DiffusionBranchTableView' => 'applications/diffusion/view/branchtable',
     'DiffusionBrowseController' => 'applications/diffusion/controller/browse',
     'DiffusionBrowseFileController' => 'applications/diffusion/controller/file',
     'DiffusionBrowseQuery' => 'applications/diffusion/query/browse/base',
     'DiffusionBrowseTableView' => 'applications/diffusion/view/browsetable',
     'DiffusionChangeController' => 'applications/diffusion/controller/change',
     'DiffusionCommitChangeTableView' => 'applications/diffusion/view/commitchangetable',
     'DiffusionCommitController' => 'applications/diffusion/controller/commit',
     'DiffusionController' => 'applications/diffusion/controller/base',
     'DiffusionDiffController' => 'applications/diffusion/controller/diff',
     'DiffusionDiffQuery' => 'applications/diffusion/query/diff/base',
     'DiffusionFileContent' => 'applications/diffusion/data/filecontent',
     'DiffusionFileContentQuery' => 'applications/diffusion/query/filecontent/base',
     'DiffusionGitBranchQuery' => 'applications/diffusion/query/branch/git',
     'DiffusionGitBrowseQuery' => 'applications/diffusion/query/browse/git',
     'DiffusionGitDiffQuery' => 'applications/diffusion/query/diff/git',
     'DiffusionGitFileContentQuery' => 'applications/diffusion/query/filecontent/git',
     'DiffusionGitHistoryQuery' => 'applications/diffusion/query/history/git',
     'DiffusionGitLastModifiedQuery' => 'applications/diffusion/query/lastmodified/git',
     'DiffusionGitPathIDQuery' => 'applications/diffusion/query/pathid/base',
     'DiffusionGitRequest' => 'applications/diffusion/request/git',
     'DiffusionHistoryController' => 'applications/diffusion/controller/history',
     'DiffusionHistoryQuery' => 'applications/diffusion/query/history/base',
     'DiffusionHistoryTableView' => 'applications/diffusion/view/historytable',
     'DiffusionHomeController' => 'applications/diffusion/controller/home',
     'DiffusionLastModifiedController' => 'applications/diffusion/controller/lastmodified',
     'DiffusionLastModifiedQuery' => 'applications/diffusion/query/lastmodified/base',
     'DiffusionPathChange' => 'applications/diffusion/data/pathchange',
     'DiffusionPathChangeQuery' => 'applications/diffusion/query/pathchange/base',
     'DiffusionPathCompleteController' => 'applications/diffusion/controller/pathcomplete',
     'DiffusionPathValidateController' => 'applications/diffusion/controller/pathvalidate',
     'DiffusionRepositoryController' => 'applications/diffusion/controller/repository',
     'DiffusionRepositoryPath' => 'applications/diffusion/data/repositorypath',
     'DiffusionRequest' => 'applications/diffusion/request/base',
     'DiffusionSvnBrowseQuery' => 'applications/diffusion/query/browse/svn',
     'DiffusionSvnDiffQuery' => 'applications/diffusion/query/diff/svn',
     'DiffusionSvnFileContentQuery' => 'applications/diffusion/query/filecontent/svn',
     'DiffusionSvnHistoryQuery' => 'applications/diffusion/query/history/svn',
     'DiffusionSvnLastModifiedQuery' => 'applications/diffusion/query/lastmodified/svn',
     'DiffusionSvnRequest' => 'applications/diffusion/request/svn',
     'DiffusionView' => 'applications/diffusion/view/base',
     'HeraldAction' => 'applications/herald/storage/action',
     'HeraldActionConfig' => 'applications/herald/config/action',
     'HeraldApplyTranscript' => 'applications/herald/storage/transcript/apply',
     'HeraldCommitAdapter' => 'applications/herald/adapter/commit',
     'HeraldCondition' => 'applications/herald/storage/condition',
     'HeraldConditionConfig' => 'applications/herald/config/condition',
     'HeraldConditionTranscript' => 'applications/herald/storage/transcript/condition',
     'HeraldContentTypeConfig' => 'applications/herald/config/contenttype',
     'HeraldController' => 'applications/herald/controller/base',
     'HeraldDAO' => 'applications/herald/storage/base',
     'HeraldDeleteController' => 'applications/herald/controller/delete',
     'HeraldDifferentialRevisionAdapter' => 'applications/herald/adapter/differential',
     'HeraldDryRunAdapter' => 'applications/herald/adapter/dryrun',
     'HeraldEffect' => 'applications/herald/engine/effect',
     'HeraldEngine' => 'applications/herald/engine/engine',
     'HeraldFieldConfig' => 'applications/herald/config/field',
     'HeraldHomeController' => 'applications/herald/controller/home',
     'HeraldInvalidConditionException' => 'applications/herald/engine/engine/exception',
     'HeraldInvalidFieldException' => 'applications/herald/engine/engine/exception',
     'HeraldNewController' => 'applications/herald/controller/new',
     'HeraldObjectAdapter' => 'applications/herald/adapter/base',
     'HeraldObjectTranscript' => 'applications/herald/storage/transcript/object',
     'HeraldRecursiveConditionsException' => 'applications/herald/engine/engine/exception',
+    'HeraldRepetitionPolicyConfig' => 'applications/herald/config/repetitionpolicy',
     'HeraldRule' => 'applications/herald/storage/rule',
     'HeraldRuleController' => 'applications/herald/controller/rule',
     'HeraldRuleTranscript' => 'applications/herald/storage/transcript/rule',
     'HeraldTestConsoleController' => 'applications/herald/controller/test',
     'HeraldTranscript' => 'applications/herald/storage/transcript/base',
     'HeraldTranscriptController' => 'applications/herald/controller/transcript',
     'HeraldTranscriptListController' => 'applications/herald/controller/transcriptlist',
     'HeraldValueTypeConfig' => 'applications/herald/config/valuetype',
     'Javelin' => 'infrastructure/javelin/api',
     'LiskDAO' => 'storage/lisk/dao',
     'LiskIsolationTestCase' => 'storage/lisk/dao/__tests__',
     'LiskIsolationTestDAO' => 'storage/lisk/dao/__tests__',
     'LiskIsolationTestDAOException' => 'storage/lisk/dao/__tests__',
     'ManiphestController' => 'applications/maniphest/controller/base',
     'ManiphestDAO' => 'applications/maniphest/storage/base',
     'ManiphestReplyHandler' => 'applications/maniphest/replyhandler',
     'ManiphestTask' => 'applications/maniphest/storage/task',
     'ManiphestTaskDescriptionChangeController' => 'applications/maniphest/controller/descriptionchange',
     'ManiphestTaskDetailController' => 'applications/maniphest/controller/taskdetail',
     'ManiphestTaskEditController' => 'applications/maniphest/controller/taskedit',
     'ManiphestTaskListController' => 'applications/maniphest/controller/tasklist',
     'ManiphestTaskListView' => 'applications/maniphest/view/tasklist',
     'ManiphestTaskPriority' => 'applications/maniphest/constants/priority',
     'ManiphestTaskStatus' => 'applications/maniphest/constants/status',
     'ManiphestTaskSummaryView' => 'applications/maniphest/view/tasksummary',
     'ManiphestTransaction' => 'applications/maniphest/storage/transaction',
     'ManiphestTransactionDetailView' => 'applications/maniphest/view/transactiondetail',
     'ManiphestTransactionEditor' => 'applications/maniphest/editor/transaction',
     'ManiphestTransactionListView' => 'applications/maniphest/view/transactionlist',
     'ManiphestTransactionPreviewController' => 'applications/maniphest/controller/transactionpreview',
     'ManiphestTransactionSaveController' => 'applications/maniphest/controller/transactionsave',
     'ManiphestTransactionType' => 'applications/maniphest/constants/transactiontype',
     'Phabricator404Controller' => 'applications/base/controller/404',
     'PhabricatorAuthController' => 'applications/auth/controller/base',
     'PhabricatorConduitAPIController' => 'applications/conduit/controller/api',
     'PhabricatorConduitConnectionLog' => 'applications/conduit/storage/connectionlog',
     'PhabricatorConduitConsoleController' => 'applications/conduit/controller/console',
     'PhabricatorConduitController' => 'applications/conduit/controller/base',
     'PhabricatorConduitDAO' => 'applications/conduit/storage/base',
     'PhabricatorConduitLogController' => 'applications/conduit/controller/log',
     'PhabricatorConduitMethodCallLog' => 'applications/conduit/storage/methodcalllog',
     'PhabricatorController' => 'applications/base/controller/base',
     'PhabricatorDaemon' => 'infrastructure/daemon/base',
     'PhabricatorDaemonCombinedLogController' => 'applications/daemon/controller/combined',
     'PhabricatorDaemonConsoleController' => 'applications/daemon/controller/console',
     'PhabricatorDaemonControl' => 'infrastructure/daemon/control',
     'PhabricatorDaemonController' => 'applications/daemon/controller/base',
     'PhabricatorDaemonDAO' => 'infrastructure/daemon/storage/base',
     'PhabricatorDaemonLog' => 'infrastructure/daemon/storage/log',
     'PhabricatorDaemonLogEvent' => 'infrastructure/daemon/storage/event',
     'PhabricatorDaemonLogEventsView' => 'applications/daemon/view/daemonlogevents',
     'PhabricatorDaemonLogListController' => 'applications/daemon/controller/loglist',
     'PhabricatorDaemonLogListView' => 'applications/daemon/view/daemonloglist',
     'PhabricatorDaemonLogViewController' => 'applications/daemon/controller/logview',
     'PhabricatorDaemonReference' => 'infrastructure/daemon/control/reference',
     'PhabricatorDaemonTimelineConsoleController' => 'applications/daemon/controller/timeline',
     'PhabricatorDaemonTimelineEventController' => 'applications/daemon/controller/timelineevent',
     'PhabricatorDirectoryCategory' => 'applications/directory/storage/category',
     'PhabricatorDirectoryCategoryDeleteController' => 'applications/directory/controller/categorydelete',
     'PhabricatorDirectoryCategoryEditController' => 'applications/directory/controller/categoryedit',
     'PhabricatorDirectoryCategoryListController' => 'applications/directory/controller/categorylist',
     'PhabricatorDirectoryController' => 'applications/directory/controller/base',
     'PhabricatorDirectoryDAO' => 'applications/directory/storage/base',
     'PhabricatorDirectoryItem' => 'applications/directory/storage/item',
     'PhabricatorDirectoryItemDeleteController' => 'applications/directory/controller/itemdelete',
     'PhabricatorDirectoryItemEditController' => 'applications/directory/controller/itemedit',
     'PhabricatorDirectoryItemListController' => 'applications/directory/controller/itemlist',
     'PhabricatorDirectoryMainController' => 'applications/directory/controller/main',
     'PhabricatorDisabledUserController' => 'applications/auth/controller/disabled',
     'PhabricatorDraft' => 'applications/draft/storage/draft',
     'PhabricatorDraftDAO' => 'applications/draft/storage/base',
     'PhabricatorEditPreferencesController' => 'applications/preferences/controller/edit',
     'PhabricatorEmailLoginController' => 'applications/auth/controller/email',
     'PhabricatorEmailTokenController' => 'applications/auth/controller/emailtoken',
     'PhabricatorEnv' => 'infrastructure/env',
     'PhabricatorFile' => 'applications/files/storage/file',
     'PhabricatorFileController' => 'applications/files/controller/base',
     'PhabricatorFileDAO' => 'applications/files/storage/base',
     'PhabricatorFileDropUploadController' => 'applications/files/controller/dropupload',
     'PhabricatorFileImageMacro' => 'applications/files/storage/imagemacro',
     'PhabricatorFileListController' => 'applications/files/controller/list',
     'PhabricatorFileMacroDeleteController' => 'applications/files/controller/macrodelete',
     'PhabricatorFileMacroEditController' => 'applications/files/controller/macroedit',
     'PhabricatorFileMacroListController' => 'applications/files/controller/macrolist',
     'PhabricatorFileProxyController' => 'applications/files/controller/proxy',
     'PhabricatorFileProxyImage' => 'applications/files/storage/proxyimage',
     'PhabricatorFileStorageBlob' => 'applications/files/storage/storageblob',
     'PhabricatorFileTransformController' => 'applications/files/controller/transform',
     'PhabricatorFileURI' => 'applications/files/uri',
     'PhabricatorFileUploadController' => 'applications/files/controller/upload',
     'PhabricatorFileViewController' => 'applications/files/controller/view',
     'PhabricatorGoodForNothingWorker' => 'infrastructure/daemon/workers/worker/goodfornothing',
     'PhabricatorHandleObjectSelectorDataView' => 'applications/phid/handle/view/selector',
     'PhabricatorHelpController' => 'applications/help/controller/base',
     'PhabricatorHelpKeyboardShortcutController' => 'applications/help/controller/keyboardshortcut',
     'PhabricatorIRCBot' => 'infrastructure/daemon/irc/bot',
     'PhabricatorIRCHandler' => 'infrastructure/daemon/irc/handler/base',
     'PhabricatorIRCMessage' => 'infrastructure/daemon/irc/message',
     'PhabricatorIRCObjectNameHandler' => 'infrastructure/daemon/irc/handler/objectname',
     'PhabricatorIRCProtocolHandler' => 'infrastructure/daemon/irc/handler/protocol',
     'PhabricatorJavelinLinter' => 'infrastructure/lint/linter/javelin',
     'PhabricatorLintEngine' => 'infrastructure/lint/engine',
     'PhabricatorLiskDAO' => 'applications/base/storage/lisk',
     'PhabricatorLoginController' => 'applications/auth/controller/login',
     'PhabricatorLogoutController' => 'applications/auth/controller/logout',
     'PhabricatorMailImplementationAdapter' => 'applications/metamta/adapter/base',
     'PhabricatorMailImplementationAmazonSESAdapter' => 'applications/metamta/adapter/amazonses',
     'PhabricatorMailImplementationPHPMailerLiteAdapter' => 'applications/metamta/adapter/phpmailerlite',
     'PhabricatorMailImplementationSendGridAdapter' => 'applications/metamta/adapter/sendgrid',
     'PhabricatorMailImplementationTestAdapter' => 'applications/metamta/adapter/test',
     'PhabricatorMailReplyHandler' => 'applications/metamta/replyhandler/base',
     'PhabricatorMetaMTAController' => 'applications/metamta/controller/base',
     'PhabricatorMetaMTADAO' => 'applications/metamta/storage/base',
     'PhabricatorMetaMTADaemon' => 'applications/metamta/daemon/mta',
     'PhabricatorMetaMTAEmailBodyParser' => 'applications/metamta/parser',
     'PhabricatorMetaMTAEmailBodyParserTestCase' => 'applications/metamta/parser/__tests__',
     'PhabricatorMetaMTAListController' => 'applications/metamta/controller/list',
     'PhabricatorMetaMTAMail' => 'applications/metamta/storage/mail',
     'PhabricatorMetaMTAMailTestCase' => 'applications/metamta/storage/mail/__tests__',
     'PhabricatorMetaMTAMailingList' => 'applications/metamta/storage/mailinglist',
     'PhabricatorMetaMTAMailingListEditController' => 'applications/metamta/controller/mailinglistedit',
     'PhabricatorMetaMTAMailingListsController' => 'applications/metamta/controller/mailinglists',
     'PhabricatorMetaMTAReceiveController' => 'applications/metamta/controller/receive',
     'PhabricatorMetaMTAReceivedListController' => 'applications/metamta/controller/receivedlist',
     'PhabricatorMetaMTAReceivedMail' => 'applications/metamta/storage/receivedmail',
     'PhabricatorMetaMTASendController' => 'applications/metamta/controller/send',
     'PhabricatorMetaMTASendGridReceiveController' => 'applications/metamta/controller/sendgridreceive',
     'PhabricatorMetaMTAViewController' => 'applications/metamta/controller/view',
     'PhabricatorOAuthDefaultRegistrationController' => 'applications/auth/controller/oauthregistration/default',
     'PhabricatorOAuthDiagnosticsController' => 'applications/auth/controller/oauthdiagnostics',
     'PhabricatorOAuthFailureView' => 'applications/auth/view/oauthfailure',
     'PhabricatorOAuthLoginController' => 'applications/auth/controller/oauth',
     'PhabricatorOAuthProvider' => 'applications/auth/oauth/provider/base',
     'PhabricatorOAuthProviderFacebook' => 'applications/auth/oauth/provider/facebook',
     'PhabricatorOAuthProviderGithub' => 'applications/auth/oauth/provider/github',
     'PhabricatorOAuthRegistrationController' => 'applications/auth/controller/oauthregistration/base',
     'PhabricatorOAuthUnlinkController' => 'applications/auth/controller/unlink',
     'PhabricatorObjectHandle' => 'applications/phid/handle',
     'PhabricatorObjectHandleData' => 'applications/phid/handle/data',
     'PhabricatorObjectSelectorDialog' => 'view/control/objectselector',
     'PhabricatorOwnersController' => 'applications/owners/controller/base',
     'PhabricatorOwnersDAO' => 'applications/owners/storage/base',
     'PhabricatorOwnersDeleteController' => 'applications/owners/controller/delete',
     'PhabricatorOwnersDetailController' => 'applications/owners/controller/detail',
     'PhabricatorOwnersEditController' => 'applications/owners/controller/edit',
     'PhabricatorOwnersListController' => 'applications/owners/controller/list',
     'PhabricatorOwnersOwner' => 'applications/owners/storage/owner',
     'PhabricatorOwnersPackage' => 'applications/owners/storage/package',
     'PhabricatorOwnersPath' => 'applications/owners/storage/path',
     'PhabricatorPHID' => 'applications/phid/storage/phid',
     'PhabricatorPHIDAllocateController' => 'applications/phid/controller/allocate',
     'PhabricatorPHIDConstants' => 'applications/phid/constants',
     'PhabricatorPHIDController' => 'applications/phid/controller/base',
     'PhabricatorPHIDDAO' => 'applications/phid/storage/base',
     'PhabricatorPHIDListController' => 'applications/phid/controller/list',
     'PhabricatorPHIDLookupController' => 'applications/phid/controller/lookup',
     'PhabricatorPeopleController' => 'applications/people/controller/base',
     'PhabricatorPeopleEditController' => 'applications/people/controller/edit',
     'PhabricatorPeopleListController' => 'applications/people/controller/list',
     'PhabricatorPeopleLogsController' => 'applications/people/controller/logs',
     'PhabricatorPeopleProfileController' => 'applications/people/controller/profile',
     'PhabricatorPeopleProfileEditController' => 'applications/people/controller/profileedit',
     'PhabricatorPreferencesController' => 'applications/preferences/controller/base',
     'PhabricatorProject' => 'applications/project/storage/project',
     'PhabricatorProjectAffiliation' => 'applications/project/storage/affiliation',
     'PhabricatorProjectAffiliationEditController' => 'applications/project/controller/editaffiliation',
     'PhabricatorProjectController' => 'applications/project/controller/base',
     'PhabricatorProjectDAO' => 'applications/project/storage/base',
     'PhabricatorProjectEditController' => 'applications/project/controller/edit',
     'PhabricatorProjectListController' => 'applications/project/controller/list',
     'PhabricatorProjectProfile' => 'applications/project/storage/profile',
     'PhabricatorProjectProfileController' => 'applications/project/controller/profile',
     'PhabricatorRedirectController' => 'applications/base/controller/redirect',
     'PhabricatorRemarkupRuleDifferential' => 'infrastructure/markup/remarkup/markuprule/differential',
     'PhabricatorRemarkupRuleDiffusion' => 'infrastructure/markup/remarkup/markuprule/diffusion',
     'PhabricatorRemarkupRuleImageMacro' => 'infrastructure/markup/remarkup/markuprule/imagemacro',
     'PhabricatorRemarkupRuleManiphest' => 'infrastructure/markup/remarkup/markuprule/maniphest',
     'PhabricatorRemarkupRuleObjectName' => 'infrastructure/markup/remarkup/markuprule/objectname',
     'PhabricatorRemarkupRuleProxyImage' => 'infrastructure/markup/remarkup/markuprule/proxyimage',
     'PhabricatorRemarkupRuleYoutube' => 'infrastructure/markup/remarkup/markuprule/youtube',
     'PhabricatorRepository' => 'applications/repository/storage/repository',
     'PhabricatorRepositoryArcanistProject' => 'applications/repository/storage/arcanistproject',
     'PhabricatorRepositoryArcanistProjectEditController' => 'applications/repository/controller/arcansistprojectedit',
     'PhabricatorRepositoryCommit' => 'applications/repository/storage/commit',
     'PhabricatorRepositoryCommitChangeParserWorker' => 'applications/repository/worker/commitchangeparser/base',
     'PhabricatorRepositoryCommitData' => 'applications/repository/storage/commitdata',
     'PhabricatorRepositoryCommitDiscoveryDaemon' => 'applications/repository/daemon/commitdiscovery/base',
     'PhabricatorRepositoryCommitHeraldWorker' => 'applications/repository/worker/herald',
     'PhabricatorRepositoryCommitMessageDetailParser' => 'applications/repository/parser/base',
     'PhabricatorRepositoryCommitMessageParserWorker' => 'applications/repository/worker/commitmessageparser/base',
     'PhabricatorRepositoryCommitParserWorker' => 'applications/repository/worker/base',
     'PhabricatorRepositoryCommitTaskDaemon' => 'applications/repository/daemon/committask',
     'PhabricatorRepositoryController' => 'applications/repository/controller/base',
     'PhabricatorRepositoryCreateController' => 'applications/repository/controller/create',
     'PhabricatorRepositoryDAO' => 'applications/repository/storage/base',
     'PhabricatorRepositoryDaemon' => 'applications/repository/daemon/base',
     'PhabricatorRepositoryDefaultCommitMessageDetailParser' => 'applications/repository/parser/default',
     'PhabricatorRepositoryDeleteController' => 'applications/repository/controller/delete',
     'PhabricatorRepositoryEditController' => 'applications/repository/controller/edit',
     'PhabricatorRepositoryGitCommitChangeParserWorker' => 'applications/repository/worker/commitchangeparser/git',
     'PhabricatorRepositoryGitCommitDiscoveryDaemon' => 'applications/repository/daemon/commitdiscovery/git',
     'PhabricatorRepositoryGitCommitMessageParserWorker' => 'applications/repository/worker/commitmessageparser/git',
     'PhabricatorRepositoryGitFetchDaemon' => 'applications/repository/daemon/gitfetch',
     'PhabricatorRepositoryGitHubNotification' => 'applications/repository/storage/githubnotification',
     'PhabricatorRepositoryGitHubPostReceiveController' => 'applications/repository/controller/github-post-receive',
     'PhabricatorRepositoryListController' => 'applications/repository/controller/list',
     'PhabricatorRepositoryShortcut' => 'applications/repository/storage/shortcut',
     'PhabricatorRepositorySvnCommitChangeParserWorker' => 'applications/repository/worker/commitchangeparser/svn',
     'PhabricatorRepositorySvnCommitDiscoveryDaemon' => 'applications/repository/daemon/commitdiscovery/svn',
     'PhabricatorRepositorySvnCommitMessageParserWorker' => 'applications/repository/worker/commitmessageparser/svn',
     'PhabricatorRepositoryType' => 'applications/repository/constants/repositorytype',
     'PhabricatorSQLPatchList' => 'infrastructure/setup/sql',
     'PhabricatorSearchAbstractDocument' => 'applications/search/index/abstractdocument',
     'PhabricatorSearchAttachController' => 'applications/search/controller/attach',
     'PhabricatorSearchBaseController' => 'applications/search/controller/base',
     'PhabricatorSearchController' => 'applications/search/controller/search',
     'PhabricatorSearchDAO' => 'applications/search/storage/base',
     'PhabricatorSearchDifferentialIndexer' => 'applications/search/index/indexer/differential',
     'PhabricatorSearchDocument' => 'applications/search/storage/document/document',
     'PhabricatorSearchDocumentField' => 'applications/search/storage/document/field',
     'PhabricatorSearchDocumentIndexer' => 'applications/search/index/indexer/base',
     'PhabricatorSearchDocumentRelationship' => 'applications/search/storage/document/relationship',
     'PhabricatorSearchExecutor' => 'applications/search/execute/base',
     'PhabricatorSearchField' => 'applications/search/constants/field',
     'PhabricatorSearchManiphestIndexer' => 'applications/search/index/indexer/maniphest',
     'PhabricatorSearchMySQLExecutor' => 'applications/search/execute/mysql',
     'PhabricatorSearchQuery' => 'applications/search/storage/query',
     'PhabricatorSearchRelationship' => 'applications/search/constants/relationship',
     'PhabricatorSearchSelectController' => 'applications/search/controller/select',
     'PhabricatorSetup' => 'infrastructure/setup',
     'PhabricatorStandardPageView' => 'view/page/standard',
     'PhabricatorStatusController' => 'applications/status/base',
     'PhabricatorTaskmasterDaemon' => 'infrastructure/daemon/workers/taskmaster',
     'PhabricatorTestCase' => 'infrastructure/testing/testcase',
     'PhabricatorTimelineCursor' => 'infrastructure/daemon/timeline/storage/cursor',
     'PhabricatorTimelineDAO' => 'infrastructure/daemon/timeline/storage/base',
     'PhabricatorTimelineEvent' => 'infrastructure/daemon/timeline/storage/event',
     'PhabricatorTimelineEventData' => 'infrastructure/daemon/timeline/storage/eventdata',
     'PhabricatorTimelineIterator' => 'infrastructure/daemon/timeline/cursor/iterator',
     'PhabricatorTransformedFile' => 'applications/files/storage/transformed',
     'PhabricatorTypeaheadCommonDatasourceController' => 'applications/typeahead/controller/common',
     'PhabricatorTypeaheadDatasourceController' => 'applications/typeahead/controller/base',
     'PhabricatorUIExample' => 'applications/uiexample/examples/base',
     'PhabricatorUIExampleController' => 'applications/uiexample/controller/base',
     'PhabricatorUIExampleRenderController' => 'applications/uiexample/controller/render',
     'PhabricatorUIListFilterExample' => 'applications/uiexample/examples/listfilter',
     'PhabricatorUIPagerExample' => 'applications/uiexample/examples/pager',
     'PhabricatorUser' => 'applications/people/storage/user',
     'PhabricatorUserDAO' => 'applications/people/storage/base',
     'PhabricatorUserLog' => 'applications/people/storage/log',
     'PhabricatorUserOAuthInfo' => 'applications/people/storage/useroauthinfo',
     'PhabricatorUserPreferences' => 'applications/people/storage/preferences',
     'PhabricatorUserProfile' => 'applications/people/storage/profile',
     'PhabricatorUserSettingsController' => 'applications/people/controller/settings',
     'PhabricatorWorker' => 'infrastructure/daemon/workers/worker',
     'PhabricatorWorkerDAO' => 'infrastructure/daemon/workers/storage/base',
     'PhabricatorWorkerTask' => 'infrastructure/daemon/workers/storage/task',
     'PhabricatorWorkerTaskData' => 'infrastructure/daemon/workers/storage/taskdata',
     'PhabricatorWorkerTaskDetailController' => 'applications/daemon/controller/workertaskdetail',
     'PhabricatorXHPASTViewController' => 'applications/xhpastview/controller/base',
     'PhabricatorXHPASTViewDAO' => 'applications/xhpastview/storage/base',
     'PhabricatorXHPASTViewFrameController' => 'applications/xhpastview/controller/viewframe',
     'PhabricatorXHPASTViewFramesetController' => 'applications/xhpastview/controller/viewframeset',
     'PhabricatorXHPASTViewInputController' => 'applications/xhpastview/controller/viewinput',
     'PhabricatorXHPASTViewPanelController' => 'applications/xhpastview/controller/viewpanel',
     'PhabricatorXHPASTViewParseTree' => 'applications/xhpastview/storage/parsetree',
     'PhabricatorXHPASTViewRunController' => 'applications/xhpastview/controller/run',
     'PhabricatorXHPASTViewStreamController' => 'applications/xhpastview/controller/viewstream',
     'PhabricatorXHPASTViewTreeController' => 'applications/xhpastview/controller/viewtree',
     'PhabricatorXHProfController' => 'applications/xhprof/controller/base',
     'PhabricatorXHProfProfileController' => 'applications/xhprof/controller/profile',
     'PhabricatorXHProfProfileSymbolView' => 'applications/xhprof/view/symbol',
     'PhabricatorXHProfProfileTopLevelView' => 'applications/xhprof/view/toplevel',
   ),
   'function' =>
   array(
     '_qsprintf_check_scalar_type' => 'storage/qsprintf',
     '_qsprintf_check_type' => 'storage/qsprintf',
     'celerity_generate_unique_node_id' => 'infrastructure/celerity/api',
     'celerity_register_resource_map' => 'infrastructure/celerity/map',
     'javelin_render_tag' => 'infrastructure/javelin/markup',
     'phabricator_format_relative_time' => 'view/utils',
     'phabricator_format_timestamp' => 'view/utils',
     'phabricator_format_units_generic' => 'view/utils',
     'phabricator_render_form' => 'infrastructure/javelin/markup',
     'qsprintf' => 'storage/qsprintf',
     'queryfx' => 'storage/queryfx',
     'queryfx_all' => 'storage/queryfx',
     'queryfx_one' => 'storage/queryfx',
     'require_celerity_resource' => 'infrastructure/celerity/api',
     'vqsprintf' => 'storage/qsprintf',
     'vqueryfx' => 'storage/queryfx',
     'vqueryfx_all' => 'storage/queryfx',
     'xsprintf_query' => 'storage/qsprintf',
   ),
   'requires_class' =>
   array(
     'Aphront304Response' => 'AphrontResponse',
     'Aphront400Response' => 'AphrontResponse',
     'Aphront404Response' => 'AphrontResponse',
     'AphrontAjaxResponse' => 'AphrontResponse',
     'AphrontAttachedFileView' => 'AphrontView',
     'AphrontCrumbsView' => 'AphrontView',
     'AphrontDefaultApplicationConfiguration' => 'AphrontApplicationConfiguration',
     'AphrontDefaultApplicationController' => 'AphrontController',
     'AphrontDialogResponse' => 'AphrontResponse',
     'AphrontDialogView' => 'AphrontView',
     'AphrontErrorView' => 'AphrontView',
     'AphrontFilePreviewView' => 'AphrontView',
     'AphrontFileResponse' => 'AphrontResponse',
     'AphrontFormCheckboxControl' => 'AphrontFormControl',
     'AphrontFormControl' => 'AphrontView',
     'AphrontFormDividerControl' => 'AphrontFormControl',
     'AphrontFormDragAndDropUploadControl' => 'AphrontFormControl',
     'AphrontFormFileControl' => 'AphrontFormControl',
     'AphrontFormMarkupControl' => 'AphrontFormControl',
     'AphrontFormPasswordControl' => 'AphrontFormControl',
     'AphrontFormRecaptchaControl' => 'AphrontFormControl',
     'AphrontFormSelectControl' => 'AphrontFormControl',
     'AphrontFormStaticControl' => 'AphrontFormControl',
     'AphrontFormSubmitControl' => 'AphrontFormControl',
     'AphrontFormTextAreaControl' => 'AphrontFormControl',
     'AphrontFormTextControl' => 'AphrontFormControl',
     'AphrontFormToggleButtonsControl' => 'AphrontFormControl',
     'AphrontFormTokenizerControl' => 'AphrontFormControl',
     'AphrontFormView' => 'AphrontView',
     'AphrontHeadsupActionListView' => 'AphrontView',
     'AphrontHeadsupActionView' => 'AphrontView',
     'AphrontIsolatedDatabaseConnection' => 'AphrontDatabaseConnection',
     'AphrontIsolatedDatabaseConnectionTestCase' => 'PhabricatorTestCase',
     'AphrontListFilterView' => 'AphrontView',
     'AphrontMySQLDatabaseConnection' => 'AphrontDatabaseConnection',
     'AphrontNullView' => 'AphrontView',
     'AphrontPageView' => 'AphrontView',
     'AphrontPagerView' => 'AphrontView',
     'AphrontPanelView' => 'AphrontView',
     'AphrontQueryAccessDeniedException' => 'AphrontQueryRecoverableException',
     'AphrontQueryConnectionException' => 'AphrontQueryException',
     'AphrontQueryConnectionLostException' => 'AphrontQueryRecoverableException',
     'AphrontQueryCountException' => 'AphrontQueryException',
     'AphrontQueryDuplicateKeyException' => 'AphrontQueryException',
     'AphrontQueryObjectMissingException' => 'AphrontQueryException',
     'AphrontQueryParameterException' => 'AphrontQueryException',
     'AphrontQueryRecoverableException' => 'AphrontQueryException',
     'AphrontRedirectException' => 'AphrontException',
     'AphrontRedirectResponse' => 'AphrontResponse',
     'AphrontReloadResponse' => 'AphrontRedirectResponse',
     'AphrontRequestFailureView' => 'AphrontView',
     'AphrontSideNavView' => 'AphrontView',
     'AphrontTableView' => 'AphrontView',
     'AphrontTokenizerTemplateView' => 'AphrontView',
     'AphrontTypeaheadTemplateView' => 'AphrontView',
     'AphrontWebpageResponse' => 'AphrontResponse',
     'CelerityResourceController' => 'AphrontController',
     'ConduitAPI_conduit_connect_Method' => 'ConduitAPIMethod',
     'ConduitAPI_conduit_ping_Method' => 'ConduitAPIMethod',
     'ConduitAPI_daemon_launched_Method' => 'ConduitAPIMethod',
     'ConduitAPI_daemon_log_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_creatediff_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_createrevision_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_find_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_getalldiffs_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_getcommitmessage_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_getcommitpaths_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_getdiff_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_getrevision_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_getrevisionfeedback_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_markcommitted_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_parsecommitmessage_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_setdiffproperty_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_updaterevision_Method' => 'ConduitAPIMethod',
     'ConduitAPI_differential_updatetaskrevisionassoc_Method' => 'ConduitAPIMethod',
     'ConduitAPI_diffusion_getcommits_Method' => 'ConduitAPIMethod',
     'ConduitAPI_diffusion_getrecentcommitsbypath_Method' => 'ConduitAPIMethod',
     'ConduitAPI_file_download_Method' => 'ConduitAPIMethod',
     'ConduitAPI_file_upload_Method' => 'ConduitAPIMethod',
     'ConduitAPI_path_getowners_Method' => 'ConduitAPIMethod',
     'ConduitAPI_user_find_Method' => 'ConduitAPIMethod',
     'ConduitAPI_user_whoami_Method' => 'ConduitAPIMethod',
     'DarkConsoleConfigPlugin' => 'DarkConsolePlugin',
     'DarkConsoleController' => 'PhabricatorController',
     'DarkConsoleErrorLogPlugin' => 'DarkConsolePlugin',
     'DarkConsoleRequestPlugin' => 'DarkConsolePlugin',
     'DarkConsoleServicesPlugin' => 'DarkConsolePlugin',
     'DarkConsoleXHProfPlugin' => 'DarkConsolePlugin',
     'DifferentialAddCommentView' => 'AphrontView',
     'DifferentialCCWelcomeMail' => 'DifferentialReviewRequestMail',
     'DifferentialChangeset' => 'DifferentialDAO',
     'DifferentialChangesetDetailView' => 'AphrontView',
     'DifferentialChangesetListView' => 'AphrontView',
     'DifferentialChangesetViewController' => 'DifferentialController',
     'DifferentialComment' => 'DifferentialDAO',
     'DifferentialCommentMail' => 'DifferentialMail',
     'DifferentialCommentPreviewController' => 'DifferentialController',
     'DifferentialCommentSaveController' => 'DifferentialController',
     'DifferentialController' => 'PhabricatorController',
     'DifferentialDAO' => 'PhabricatorLiskDAO',
     'DifferentialDiff' => 'DifferentialDAO',
     'DifferentialDiffContentMail' => 'DifferentialMail',
     'DifferentialDiffCreateController' => 'DifferentialController',
     'DifferentialDiffProperty' => 'DifferentialDAO',
     'DifferentialDiffTableOfContentsView' => 'AphrontView',
     'DifferentialDiffViewController' => 'DifferentialController',
     'DifferentialExceptionMail' => 'DifferentialMail',
     'DifferentialHunk' => 'DifferentialDAO',
     'DifferentialInlineComment' => 'DifferentialDAO',
     'DifferentialInlineCommentEditController' => 'DifferentialController',
     'DifferentialInlineCommentPreviewController' => 'DifferentialController',
     'DifferentialInlineCommentView' => 'AphrontView',
     'DifferentialNewDiffMail' => 'DifferentialReviewRequestMail',
     'DifferentialReplyHandler' => 'PhabricatorMailReplyHandler',
     'DifferentialReviewRequestMail' => 'DifferentialMail',
     'DifferentialRevision' => 'DifferentialDAO',
     'DifferentialRevisionCommentListView' => 'AphrontView',
     'DifferentialRevisionCommentView' => 'AphrontView',
     'DifferentialRevisionDetailView' => 'AphrontView',
     'DifferentialRevisionEditController' => 'DifferentialController',
     'DifferentialRevisionListController' => 'DifferentialController',
     'DifferentialRevisionUpdateHistoryView' => 'AphrontView',
     'DifferentialRevisionViewController' => 'DifferentialController',
     'DifferentialSubscribeController' => 'DifferentialController',
     'DifferentialViewTime' => 'DifferentialDAO',
     'DiffusionBranchTableView' => 'DiffusionView',
     'DiffusionBrowseController' => 'DiffusionController',
     'DiffusionBrowseFileController' => 'DiffusionController',
     'DiffusionBrowseTableView' => 'DiffusionView',
     'DiffusionChangeController' => 'DiffusionController',
     'DiffusionCommitChangeTableView' => 'DiffusionView',
     'DiffusionCommitController' => 'DiffusionController',
     'DiffusionController' => 'PhabricatorController',
     'DiffusionDiffController' => 'DiffusionController',
     'DiffusionGitBranchQuery' => 'DiffusionBranchQuery',
     'DiffusionGitBrowseQuery' => 'DiffusionBrowseQuery',
     'DiffusionGitDiffQuery' => 'DiffusionDiffQuery',
     'DiffusionGitFileContentQuery' => 'DiffusionFileContentQuery',
     'DiffusionGitHistoryQuery' => 'DiffusionHistoryQuery',
     'DiffusionGitLastModifiedQuery' => 'DiffusionLastModifiedQuery',
     'DiffusionGitRequest' => 'DiffusionRequest',
     'DiffusionHistoryController' => 'DiffusionController',
     'DiffusionHistoryTableView' => 'DiffusionView',
     'DiffusionHomeController' => 'DiffusionController',
     'DiffusionLastModifiedController' => 'DiffusionController',
     'DiffusionPathCompleteController' => 'DiffusionController',
     'DiffusionPathValidateController' => 'DiffusionController',
     'DiffusionRepositoryController' => 'DiffusionController',
     'DiffusionSvnBrowseQuery' => 'DiffusionBrowseQuery',
     'DiffusionSvnDiffQuery' => 'DiffusionDiffQuery',
     'DiffusionSvnFileContentQuery' => 'DiffusionFileContentQuery',
     'DiffusionSvnHistoryQuery' => 'DiffusionHistoryQuery',
     'DiffusionSvnLastModifiedQuery' => 'DiffusionLastModifiedQuery',
     'DiffusionSvnRequest' => 'DiffusionRequest',
     'DiffusionView' => 'AphrontView',
     'HeraldAction' => 'HeraldDAO',
     'HeraldApplyTranscript' => 'HeraldDAO',
     'HeraldCommitAdapter' => 'HeraldObjectAdapter',
     'HeraldCondition' => 'HeraldDAO',
     'HeraldController' => 'PhabricatorController',
     'HeraldDAO' => 'PhabricatorLiskDAO',
     'HeraldDeleteController' => 'HeraldController',
     'HeraldDifferentialRevisionAdapter' => 'HeraldObjectAdapter',
     'HeraldDryRunAdapter' => 'HeraldObjectAdapter',
     'HeraldHomeController' => 'HeraldController',
     'HeraldNewController' => 'HeraldController',
     'HeraldRule' => 'HeraldDAO',
     'HeraldRuleController' => 'HeraldController',
     'HeraldTestConsoleController' => 'HeraldController',
     'HeraldTranscript' => 'HeraldDAO',
     'HeraldTranscriptController' => 'HeraldController',
     'HeraldTranscriptListController' => 'HeraldController',
     'LiskIsolationTestCase' => 'PhabricatorTestCase',
     'LiskIsolationTestDAO' => 'LiskDAO',
     'ManiphestController' => 'PhabricatorController',
     'ManiphestDAO' => 'PhabricatorLiskDAO',
     'ManiphestReplyHandler' => 'PhabricatorMailReplyHandler',
     'ManiphestTask' => 'ManiphestDAO',
     'ManiphestTaskDescriptionChangeController' => 'ManiphestController',
     'ManiphestTaskDetailController' => 'ManiphestController',
     'ManiphestTaskEditController' => 'ManiphestController',
     'ManiphestTaskListController' => 'ManiphestController',
     'ManiphestTaskListView' => 'AphrontView',
     'ManiphestTaskSummaryView' => 'AphrontView',
     'ManiphestTransaction' => 'ManiphestDAO',
     'ManiphestTransactionDetailView' => 'AphrontView',
     'ManiphestTransactionListView' => 'AphrontView',
     'ManiphestTransactionPreviewController' => 'ManiphestController',
     'ManiphestTransactionSaveController' => 'ManiphestController',
     'Phabricator404Controller' => 'PhabricatorController',
     'PhabricatorAuthController' => 'PhabricatorController',
     'PhabricatorConduitAPIController' => 'PhabricatorConduitController',
     'PhabricatorConduitConnectionLog' => 'PhabricatorConduitDAO',
     'PhabricatorConduitConsoleController' => 'PhabricatorConduitController',
     'PhabricatorConduitController' => 'PhabricatorController',
     'PhabricatorConduitDAO' => 'PhabricatorLiskDAO',
     'PhabricatorConduitLogController' => 'PhabricatorConduitController',
     'PhabricatorConduitMethodCallLog' => 'PhabricatorConduitDAO',
     'PhabricatorController' => 'AphrontController',
     'PhabricatorDaemon' => 'PhutilDaemon',
     'PhabricatorDaemonCombinedLogController' => 'PhabricatorDaemonController',
     'PhabricatorDaemonConsoleController' => 'PhabricatorDaemonController',
     'PhabricatorDaemonController' => 'PhabricatorController',
     'PhabricatorDaemonDAO' => 'PhabricatorLiskDAO',
     'PhabricatorDaemonLog' => 'PhabricatorDaemonDAO',
     'PhabricatorDaemonLogEvent' => 'PhabricatorDaemonDAO',
     'PhabricatorDaemonLogEventsView' => 'AphrontView',
     'PhabricatorDaemonLogListController' => 'PhabricatorDaemonController',
     'PhabricatorDaemonLogListView' => 'AphrontView',
     'PhabricatorDaemonLogViewController' => 'PhabricatorDaemonController',
     'PhabricatorDaemonTimelineConsoleController' => 'PhabricatorDaemonController',
     'PhabricatorDaemonTimelineEventController' => 'PhabricatorDaemonController',
     'PhabricatorDirectoryCategory' => 'PhabricatorDirectoryDAO',
     'PhabricatorDirectoryCategoryDeleteController' => 'PhabricatorDirectoryController',
     'PhabricatorDirectoryCategoryEditController' => 'PhabricatorDirectoryController',
     'PhabricatorDirectoryCategoryListController' => 'PhabricatorDirectoryController',
     'PhabricatorDirectoryController' => 'PhabricatorController',
     'PhabricatorDirectoryDAO' => 'PhabricatorLiskDAO',
     'PhabricatorDirectoryItem' => 'PhabricatorDirectoryDAO',
     'PhabricatorDirectoryItemDeleteController' => 'PhabricatorDirectoryController',
     'PhabricatorDirectoryItemEditController' => 'PhabricatorDirectoryController',
     'PhabricatorDirectoryItemListController' => 'PhabricatorDirectoryController',
     'PhabricatorDirectoryMainController' => 'PhabricatorDirectoryController',
     'PhabricatorDisabledUserController' => 'PhabricatorAuthController',
     'PhabricatorDraft' => 'PhabricatorDraftDAO',
     'PhabricatorDraftDAO' => 'PhabricatorLiskDAO',
     'PhabricatorEditPreferencesController' => 'PhabricatorPreferencesController',
     'PhabricatorEmailLoginController' => 'PhabricatorAuthController',
     'PhabricatorEmailTokenController' => 'PhabricatorAuthController',
     'PhabricatorFile' => 'PhabricatorFileDAO',
     'PhabricatorFileController' => 'PhabricatorController',
     'PhabricatorFileDAO' => 'PhabricatorLiskDAO',
     'PhabricatorFileDropUploadController' => 'PhabricatorFileController',
     'PhabricatorFileImageMacro' => 'PhabricatorFileDAO',
     'PhabricatorFileListController' => 'PhabricatorFileController',
     'PhabricatorFileMacroDeleteController' => 'PhabricatorFileController',
     'PhabricatorFileMacroEditController' => 'PhabricatorFileController',
     'PhabricatorFileMacroListController' => 'PhabricatorFileController',
     'PhabricatorFileProxyController' => 'PhabricatorFileController',
     'PhabricatorFileProxyImage' => 'PhabricatorFileDAO',
     'PhabricatorFileStorageBlob' => 'PhabricatorFileDAO',
     'PhabricatorFileTransformController' => 'PhabricatorFileController',
     'PhabricatorFileUploadController' => 'PhabricatorFileController',
     'PhabricatorFileViewController' => 'PhabricatorFileController',
     'PhabricatorGoodForNothingWorker' => 'PhabricatorWorker',
     'PhabricatorHelpController' => 'PhabricatorController',
     'PhabricatorHelpKeyboardShortcutController' => 'PhabricatorHelpController',
     'PhabricatorIRCBot' => 'PhabricatorDaemon',
     'PhabricatorIRCObjectNameHandler' => 'PhabricatorIRCHandler',
     'PhabricatorIRCProtocolHandler' => 'PhabricatorIRCHandler',
     'PhabricatorJavelinLinter' => 'ArcanistLinter',
     'PhabricatorLintEngine' => 'PhutilLintEngine',
     'PhabricatorLiskDAO' => 'LiskDAO',
     'PhabricatorLoginController' => 'PhabricatorAuthController',
     'PhabricatorLogoutController' => 'PhabricatorAuthController',
     'PhabricatorMailImplementationAmazonSESAdapter' => 'PhabricatorMailImplementationPHPMailerLiteAdapter',
     'PhabricatorMailImplementationPHPMailerLiteAdapter' => 'PhabricatorMailImplementationAdapter',
     'PhabricatorMailImplementationSendGridAdapter' => 'PhabricatorMailImplementationAdapter',
     'PhabricatorMailImplementationTestAdapter' => 'PhabricatorMailImplementationAdapter',
     'PhabricatorMetaMTAController' => 'PhabricatorController',
     'PhabricatorMetaMTADAO' => 'PhabricatorLiskDAO',
     'PhabricatorMetaMTADaemon' => 'PhabricatorDaemon',
     'PhabricatorMetaMTAEmailBodyParserTestCase' => 'PhabricatorTestCase',
     'PhabricatorMetaMTAListController' => 'PhabricatorMetaMTAController',
     'PhabricatorMetaMTAMail' => 'PhabricatorMetaMTADAO',
     'PhabricatorMetaMTAMailTestCase' => 'PhabricatorTestCase',
     'PhabricatorMetaMTAMailingList' => 'PhabricatorMetaMTADAO',
     'PhabricatorMetaMTAMailingListEditController' => 'PhabricatorMetaMTAController',
     'PhabricatorMetaMTAMailingListsController' => 'PhabricatorMetaMTAController',
     'PhabricatorMetaMTAReceiveController' => 'PhabricatorMetaMTAController',
     'PhabricatorMetaMTAReceivedListController' => 'PhabricatorMetaMTAController',
     'PhabricatorMetaMTAReceivedMail' => 'PhabricatorMetaMTADAO',
     'PhabricatorMetaMTASendController' => 'PhabricatorMetaMTAController',
     'PhabricatorMetaMTASendGridReceiveController' => 'PhabricatorMetaMTAController',
     'PhabricatorMetaMTAViewController' => 'PhabricatorMetaMTAController',
     'PhabricatorOAuthDefaultRegistrationController' => 'PhabricatorOAuthRegistrationController',
     'PhabricatorOAuthDiagnosticsController' => 'PhabricatorAuthController',
     'PhabricatorOAuthFailureView' => 'AphrontView',
     'PhabricatorOAuthLoginController' => 'PhabricatorAuthController',
     'PhabricatorOAuthProviderFacebook' => 'PhabricatorOAuthProvider',
     'PhabricatorOAuthProviderGithub' => 'PhabricatorOAuthProvider',
     'PhabricatorOAuthRegistrationController' => 'PhabricatorAuthController',
     'PhabricatorOAuthUnlinkController' => 'PhabricatorAuthController',
     'PhabricatorOwnersController' => 'PhabricatorController',
     'PhabricatorOwnersDAO' => 'PhabricatorLiskDAO',
     'PhabricatorOwnersDeleteController' => 'PhabricatorOwnersController',
     'PhabricatorOwnersDetailController' => 'PhabricatorOwnersController',
     'PhabricatorOwnersEditController' => 'PhabricatorOwnersController',
     'PhabricatorOwnersListController' => 'PhabricatorOwnersController',
     'PhabricatorOwnersOwner' => 'PhabricatorOwnersDAO',
     'PhabricatorOwnersPackage' => 'PhabricatorOwnersDAO',
     'PhabricatorOwnersPath' => 'PhabricatorOwnersDAO',
     'PhabricatorPHID' => 'PhabricatorPHIDDAO',
     'PhabricatorPHIDAllocateController' => 'PhabricatorPHIDController',
     'PhabricatorPHIDController' => 'PhabricatorController',
     'PhabricatorPHIDDAO' => 'PhabricatorLiskDAO',
     'PhabricatorPHIDListController' => 'PhabricatorPHIDController',
     'PhabricatorPHIDLookupController' => 'PhabricatorPHIDController',
     'PhabricatorPeopleController' => 'PhabricatorController',
     'PhabricatorPeopleEditController' => 'PhabricatorPeopleController',
     'PhabricatorPeopleListController' => 'PhabricatorPeopleController',
     'PhabricatorPeopleLogsController' => 'PhabricatorPeopleController',
     'PhabricatorPeopleProfileController' => 'PhabricatorPeopleController',
     'PhabricatorPeopleProfileEditController' => 'PhabricatorPeopleController',
     'PhabricatorPreferencesController' => 'PhabricatorController',
     'PhabricatorProject' => 'PhabricatorProjectDAO',
     'PhabricatorProjectAffiliation' => 'PhabricatorProjectDAO',
     'PhabricatorProjectAffiliationEditController' => 'PhabricatorProjectController',
     'PhabricatorProjectController' => 'PhabricatorController',
     'PhabricatorProjectDAO' => 'PhabricatorLiskDAO',
     'PhabricatorProjectEditController' => 'PhabricatorProjectController',
     'PhabricatorProjectListController' => 'PhabricatorProjectController',
     'PhabricatorProjectProfile' => 'PhabricatorProjectDAO',
     'PhabricatorProjectProfileController' => 'PhabricatorProjectController',
     'PhabricatorRedirectController' => 'PhabricatorController',
     'PhabricatorRemarkupRuleDifferential' => 'PhabricatorRemarkupRuleObjectName',
     'PhabricatorRemarkupRuleDiffusion' => 'PhutilRemarkupRule',
     'PhabricatorRemarkupRuleImageMacro' => 'PhutilRemarkupRule',
     'PhabricatorRemarkupRuleManiphest' => 'PhabricatorRemarkupRuleObjectName',
     'PhabricatorRemarkupRuleObjectName' => 'PhutilRemarkupRule',
     'PhabricatorRemarkupRuleProxyImage' => 'PhutilRemarkupRule',
     'PhabricatorRemarkupRuleYoutube' => 'PhutilRemarkupRule',
     'PhabricatorRepository' => 'PhabricatorRepositoryDAO',
     'PhabricatorRepositoryArcanistProject' => 'PhabricatorRepositoryDAO',
     'PhabricatorRepositoryArcanistProjectEditController' => 'PhabricatorRepositoryController',
     'PhabricatorRepositoryCommit' => 'PhabricatorRepositoryDAO',
     'PhabricatorRepositoryCommitChangeParserWorker' => 'PhabricatorRepositoryCommitParserWorker',
     'PhabricatorRepositoryCommitData' => 'PhabricatorRepositoryDAO',
     'PhabricatorRepositoryCommitDiscoveryDaemon' => 'PhabricatorRepositoryDaemon',
     'PhabricatorRepositoryCommitHeraldWorker' => 'PhabricatorRepositoryCommitParserWorker',
     'PhabricatorRepositoryCommitMessageParserWorker' => 'PhabricatorRepositoryCommitParserWorker',
     'PhabricatorRepositoryCommitParserWorker' => 'PhabricatorWorker',
     'PhabricatorRepositoryCommitTaskDaemon' => 'PhabricatorRepositoryDaemon',
     'PhabricatorRepositoryController' => 'PhabricatorController',
     'PhabricatorRepositoryCreateController' => 'PhabricatorRepositoryController',
     'PhabricatorRepositoryDAO' => 'PhabricatorLiskDAO',
     'PhabricatorRepositoryDaemon' => 'PhabricatorDaemon',
     'PhabricatorRepositoryDefaultCommitMessageDetailParser' => 'PhabricatorRepositoryCommitMessageDetailParser',
     'PhabricatorRepositoryDeleteController' => 'PhabricatorRepositoryController',
     'PhabricatorRepositoryEditController' => 'PhabricatorRepositoryController',
     'PhabricatorRepositoryGitCommitChangeParserWorker' => 'PhabricatorRepositoryCommitChangeParserWorker',
     'PhabricatorRepositoryGitCommitDiscoveryDaemon' => 'PhabricatorRepositoryCommitDiscoveryDaemon',
     'PhabricatorRepositoryGitCommitMessageParserWorker' => 'PhabricatorRepositoryCommitMessageParserWorker',
     'PhabricatorRepositoryGitFetchDaemon' => 'PhabricatorRepositoryDaemon',
     'PhabricatorRepositoryGitHubNotification' => 'PhabricatorRepositoryDAO',
     'PhabricatorRepositoryGitHubPostReceiveController' => 'PhabricatorRepositoryController',
     'PhabricatorRepositoryListController' => 'PhabricatorRepositoryController',
     'PhabricatorRepositoryShortcut' => 'PhabricatorRepositoryDAO',
     'PhabricatorRepositorySvnCommitChangeParserWorker' => 'PhabricatorRepositoryCommitChangeParserWorker',
     'PhabricatorRepositorySvnCommitDiscoveryDaemon' => 'PhabricatorRepositoryCommitDiscoveryDaemon',
     'PhabricatorRepositorySvnCommitMessageParserWorker' => 'PhabricatorRepositoryCommitMessageParserWorker',
     'PhabricatorSearchAttachController' => 'PhabricatorSearchController',
     'PhabricatorSearchBaseController' => 'PhabricatorController',
     'PhabricatorSearchController' => 'PhabricatorSearchBaseController',
     'PhabricatorSearchDAO' => 'PhabricatorLiskDAO',
     'PhabricatorSearchDifferentialIndexer' => 'PhabricatorSearchDocumentIndexer',
     'PhabricatorSearchDocument' => 'PhabricatorSearchDAO',
     'PhabricatorSearchDocumentField' => 'PhabricatorSearchDAO',
     'PhabricatorSearchDocumentRelationship' => 'PhabricatorSearchDAO',
     'PhabricatorSearchManiphestIndexer' => 'PhabricatorSearchDocumentIndexer',
     'PhabricatorSearchMySQLExecutor' => 'PhabricatorSearchExecutor',
     'PhabricatorSearchQuery' => 'PhabricatorSearchDAO',
     'PhabricatorSearchSelectController' => 'PhabricatorSearchController',
     'PhabricatorStandardPageView' => 'AphrontPageView',
     'PhabricatorStatusController' => 'PhabricatorController',
     'PhabricatorTaskmasterDaemon' => 'PhabricatorDaemon',
     'PhabricatorTestCase' => 'ArcanistPhutilTestCase',
     'PhabricatorTimelineCursor' => 'PhabricatorTimelineDAO',
     'PhabricatorTimelineDAO' => 'PhabricatorLiskDAO',
     'PhabricatorTimelineEvent' => 'PhabricatorTimelineDAO',
     'PhabricatorTimelineEventData' => 'PhabricatorTimelineDAO',
     'PhabricatorTransformedFile' => 'PhabricatorFileDAO',
     'PhabricatorTypeaheadCommonDatasourceController' => 'PhabricatorTypeaheadDatasourceController',
     'PhabricatorTypeaheadDatasourceController' => 'PhabricatorController',
     'PhabricatorUIExampleController' => 'PhabricatorController',
     'PhabricatorUIExampleRenderController' => 'PhabricatorUIExampleController',
     'PhabricatorUIListFilterExample' => 'PhabricatorUIExample',
     'PhabricatorUIPagerExample' => 'PhabricatorUIExample',
     'PhabricatorUser' => 'PhabricatorUserDAO',
     'PhabricatorUserDAO' => 'PhabricatorLiskDAO',
     'PhabricatorUserLog' => 'PhabricatorUserDAO',
     'PhabricatorUserOAuthInfo' => 'PhabricatorUserDAO',
     'PhabricatorUserPreferences' => 'PhabricatorUserDAO',
     'PhabricatorUserProfile' => 'PhabricatorUserDAO',
     'PhabricatorUserSettingsController' => 'PhabricatorPeopleController',
     'PhabricatorWorkerDAO' => 'PhabricatorLiskDAO',
     'PhabricatorWorkerTask' => 'PhabricatorWorkerDAO',
     'PhabricatorWorkerTaskData' => 'PhabricatorWorkerDAO',
     'PhabricatorWorkerTaskDetailController' => 'PhabricatorDaemonController',
     'PhabricatorXHPASTViewController' => 'PhabricatorController',
     'PhabricatorXHPASTViewDAO' => 'PhabricatorLiskDAO',
     'PhabricatorXHPASTViewFrameController' => 'PhabricatorXHPASTViewController',
     'PhabricatorXHPASTViewFramesetController' => 'PhabricatorXHPASTViewController',
     'PhabricatorXHPASTViewInputController' => 'PhabricatorXHPASTViewPanelController',
     'PhabricatorXHPASTViewPanelController' => 'PhabricatorXHPASTViewController',
     'PhabricatorXHPASTViewParseTree' => 'PhabricatorXHPASTViewDAO',
     'PhabricatorXHPASTViewRunController' => 'PhabricatorXHPASTViewController',
     'PhabricatorXHPASTViewStreamController' => 'PhabricatorXHPASTViewPanelController',
     'PhabricatorXHPASTViewTreeController' => 'PhabricatorXHPASTViewPanelController',
     'PhabricatorXHProfController' => 'PhabricatorController',
     'PhabricatorXHProfProfileController' => 'PhabricatorXHProfController',
     'PhabricatorXHProfProfileSymbolView' => 'AphrontView',
     'PhabricatorXHProfProfileTopLevelView' => 'AphrontView',
   ),
   'requires_interface' =>
   array(
   ),
 ));
diff --git a/src/applications/differential/editor/revision/DifferentialRevisionEditor.php b/src/applications/differential/editor/revision/DifferentialRevisionEditor.php
index c97bed440..d095e4dbc 100644
--- a/src/applications/differential/editor/revision/DifferentialRevisionEditor.php
+++ b/src/applications/differential/editor/revision/DifferentialRevisionEditor.php
@@ -1,619 +1,618 @@
 <?php
 
 /*
  * Copyright 2011 Facebook, Inc.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
  *   http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
 /**
  * Handle major edit operations to DifferentialRevision -- adding and removing
  * reviewers, diffs, and CCs. Unlike simple edits, these changes trigger
  * complicated email workflows.
  */
 class DifferentialRevisionEditor {
 
   protected $revision;
   protected $actorPHID;
 
   protected $cc         = null;
   protected $reviewers  = null;
   protected $diff;
   protected $comments;
   protected $silentUpdate;
   protected $tasks = null;
 
   public function __construct(DifferentialRevision $revision, $actor_phid) {
     $this->revision = $revision;
     $this->actorPHID = $actor_phid;
   }
 
   public static function newRevisionFromConduitWithDiff(
     array $fields,
     DifferentialDiff $diff,
     $user_phid) {
 
     $revision = new DifferentialRevision();
     $revision->setPHID($revision->generatePHID());
 
     $revision->setAuthorPHID($user_phid);
     $revision->setStatus(DifferentialRevisionStatus::NEEDS_REVIEW);
 
     $editor = new DifferentialRevisionEditor($revision, $user_phid);
 
     $editor->copyFieldsFromConduit($fields);
 
     $editor->addDiff($diff, null);
     $editor->save();
 
     // Tasks can only be updated after revision has been saved to the
     // database. Currently tasks are updated only when a revision is created.
     // UI must be used to modify tasks after creating one.
     $editor->updateTasks();
 
     return $revision;
   }
 
   public function copyFieldsFromConduit(array $fields) {
 
     $revision = $this->revision;
 
     $revision->setTitle((string)$fields['title']);
     $revision->setSummary((string)$fields['summary']);
     $revision->setTestPlan((string)$fields['testPlan']);
     $revision->setBlameRevision((string)$fields['blameRevision']);
     $revision->setRevertPlan((string)$fields['revertPlan']);
 
     $this->setReviewers($fields['reviewerPHIDs']);
     $this->setCCPHIDs($fields['ccPHIDs']);
     $this->setTasks($fields['tasks']);
   }
 
   public function getRevision() {
     return $this->revision;
   }
 
   public function setReviewers(array $reviewers) {
     $this->reviewers = $reviewers;
     return $this;
   }
 
   public function setCCPHIDs(array $cc) {
     $this->cc = $cc;
     return $this;
   }
 
   public function setTasks(array $tasks) {
     $this->tasks = $tasks;
   }
 
   public function addDiff(DifferentialDiff $diff, $comments) {
     if ($diff->getRevisionID() &&
         $diff->getRevisionID() != $this->getRevision()->getID()) {
       $diff_id = (int)$diff->getID();
       $targ_id = (int)$this->getRevision()->getID();
       $real_id = (int)$diff->getRevisionID();
       throw new Exception(
         "Can not attach diff #{$diff_id} to Revision D{$targ_id}, it is ".
         "already attached to D{$real_id}.");
     }
     $this->diff = $diff;
     $this->comments = $comments;
     return $this;
   }
 
   protected function getDiff() {
     return $this->diff;
   }
 
   protected function getComments() {
     return $this->comments;
   }
 
   protected function getActorPHID() {
     return $this->actorPHID;
   }
 
   public function isNewRevision() {
     return !$this->getRevision()->getID();
   }
 
   /**
    * A silent update does not trigger Herald rules or send emails. This is used
    * for auto-amends at commit time.
    */
   public function setSilentUpdate($silent) {
     $this->silentUpdate = $silent;
     return $this;
   }
 
   public function save() {
     $revision = $this->getRevision();
 
 // TODO
 //    $revision->openTransaction();
 
     $is_new = $this->isNewRevision();
     if ($is_new) {
       // These fields aren't nullable; set them to sensible defaults if they
       // haven't been configured. We're just doing this so we can generate an
       // ID for the revision if we don't have one already.
       $revision->setLineCount(0);
       if ($revision->getStatus() === null) {
         $revision->setStatus(DifferentialRevisionStatus::NEEDS_REVIEW);
       }
       if ($revision->getTitle() === null) {
         $revision->setTitle('Untitled Revision');
       }
       if ($revision->getAuthorPHID() === null) {
         $revision->setAuthorPHID($this->getActorPHID());
       }
 
       $revision->save();
     }
 
     $revision->loadRelationships();
 
     if ($this->reviewers === null) {
       $this->reviewers = $revision->getReviewers();
     }
 
     if ($this->cc === null) {
       $this->cc = $revision->getCCPHIDs();
     }
 
     // We're going to build up three dictionaries: $add, $rem, and $stable. The
     // $add dictionary has added reviewers/CCs. The $rem dictionary has
     // reviewers/CCs who have been removed, and the $stable array is
     // reviewers/CCs who haven't changed. We're going to send new reviewers/CCs
     // a different ("welcome") email than we send stable reviewers/CCs.
 
     $old = array(
       'rev' => array_fill_keys($revision->getReviewers(), true),
       'ccs' => array_fill_keys($revision->getCCPHIDs(), true),
     );
 
     $diff = $this->getDiff();
 
     $xscript_header = null;
     $xscript_uri = null;
 
     $new = array(
       'rev' => array_fill_keys($this->reviewers, true),
       'ccs' => array_fill_keys($this->cc, true),
     );
 
 
     $rem_ccs = array();
     if ($diff) {
       $diff->setRevisionID($revision->getID());
       $revision->setLineCount($diff->getLineCount());
 
       $adapter = new HeraldDifferentialRevisionAdapter(
         $revision,
         $diff);
       $adapter->setExplicitCCs($new['ccs']);
       $adapter->setExplicitReviewers($new['rev']);
       $adapter->setForbiddenCCs($revision->getUnsubscribedPHIDs());
 
       $xscript = HeraldEngine::loadAndApplyRules($adapter);
       $xscript_uri = PhabricatorEnv::getProductionURI(
         '/herald/transcript/'.$xscript->getID().'/');
       $xscript_phid = $xscript->getPHID();
       $xscript_header = $xscript->getXHeraldRulesHeader();
 
       HeraldTranscript::saveXHeraldRulesHeader(
         $revision->getPHID(),
         $xscript_header);
 
       $sub = array(
         'rev' => array(),
         'ccs' => $adapter->getCCsAddedByHerald(),
       );
       $rem_ccs = $adapter->getCCsRemovedByHerald();
     } else {
       $sub = array(
         'rev' => array(),
         'ccs' => array(),
       );
     }
 
     // Remove any CCs which are prevented by Herald rules.
     $sub['ccs'] = array_diff_key($sub['ccs'], $rem_ccs);
     $new['ccs'] = array_diff_key($new['ccs'], $rem_ccs);
 
     $add = array();
     $rem = array();
     $stable = array();
     foreach (array('rev', 'ccs') as $key) {
       $add[$key] = array();
       if ($new[$key] !== null) {
         $add[$key] += array_diff_key($new[$key], $old[$key]);
       }
       $add[$key] += array_diff_key($sub[$key], $old[$key]);
 
       $combined = $sub[$key];
       if ($new[$key] !== null) {
         $combined += $new[$key];
       }
       $rem[$key] = array_diff_key($old[$key], $combined);
 
       $stable[$key] = array_diff_key($old[$key], $add[$key] + $rem[$key]);
     }
 
     self::alterReviewers(
       $revision,
       $this->reviewers,
       array_keys($rem['rev']),
       array_keys($add['rev']),
       $this->actorPHID);
 
 /*
 
     // TODO: When Herald is brought over, run through this stuff to figure
     // out which adds are Herald's fault.
 
     // TODO: Still need to do this.
 
     if ($add['ccs'] || $rem['ccs']) {
       foreach (array_keys($add['ccs']) as $id) {
         if (empty($new['ccs'][$id])) {
           $reason_phid = 'TODO';//$xscript_phid;
         } else {
           $reason_phid = $this->getActorPHID();
         }
       }
       foreach (array_keys($rem['ccs']) as $id) {
         if (empty($new['ccs'][$id])) {
           $reason_phid = $this->getActorPHID();
         } else {
           $reason_phid = 'TODO';//$xscript_phid;
         }
       }
     }
 */
     self::alterCCs(
       $revision,
       $this->cc,
       array_keys($rem['ccs']),
       array_keys($add['ccs']),
       $this->actorPHID);
 
-    // Add the author to the relevant set of users so they get a copy of the
-    // email.
+    // Add the author and users included from Herald rules to the relevant set
+    // of users so they get a copy of the email.
     if (!$this->silentUpdate) {
       if ($is_new) {
         $add['rev'][$this->getActorPHID()] = true;
+        if ($diff) {
+          $add['rev'] += $adapter->getEmailPHIDsAddedByHerald();
+        }
       } else {
         $stable['rev'][$this->getActorPHID()] = true;
+        if ($diff) {
+          $stable['rev'] += $adapter->getEmailPHIDsAddedByHerald();
+        }
       }
     }
 
     $mail = array();
 
     $phids = array($this->getActorPHID());
 
     $handles = id(new PhabricatorObjectHandleData($phids))
       ->loadHandles();
     $actor_handle = $handles[$this->getActorPHID()];
 
     $changesets = null;
     $comment = null;
     if ($diff) {
       $changesets = $diff->loadChangesets();
       // TODO: This should probably be in DifferentialFeedbackEditor?
       if (!$is_new) {
         $comment = $this->createComment();
       }
       if ($comment) {
         $mail[] = id(new DifferentialNewDiffMail(
             $revision,
             $actor_handle,
             $changesets))
           ->setIsFirstMailAboutRevision($is_new)
           ->setIsFirstMailToRecipients($is_new)
           ->setComments($this->getComments())
           ->setToPHIDs(array_keys($stable['rev']))
           ->setCCPHIDs(array_keys($stable['ccs']));
       }
 
       // Save the changes we made above.
 
       $diff->setDescription(substr($this->getComments(), 0, 80));
       $diff->save();
 
       // An updated diff should require review, as long as it's not committed
       // or accepted. The "accepted" status is "sticky" to encourage courtesy
       // re-diffs after someone accepts with minor changes/suggestions.
 
       $status = $revision->getStatus();
       if ($status != DifferentialRevisionStatus::COMMITTED &&
           $status != DifferentialRevisionStatus::ACCEPTED) {
         $revision->setStatus(DifferentialRevisionStatus::NEEDS_REVIEW);
       }
 
     } else {
       $diff = $revision->loadActiveDiff();
       if ($diff) {
         $changesets = $diff->loadChangesets();
       } else {
         $changesets = array();
       }
     }
 
     $revision->save();
 
 // TODO
 //    $revision->saveTransaction();
 
-    $event = array(
-      'revision_id' => $revision->getID(),
-      'PHID'        => $revision->getPHID(),
-      'action'      => $is_new ? 'create' : 'update',
-      'actor'       => $this->getActorPHID(),
-    );
-
 //  TODO: Move this into a worker task thing.
     PhabricatorSearchDifferentialIndexer::indexRevision($revision);
 
     if ($this->silentUpdate) {
       return;
     }
 
     $revision->loadRelationships();
 
     if ($add['rev']) {
       $message = id(new DifferentialNewDiffMail(
           $revision,
           $actor_handle,
           $changesets))
         ->setIsFirstMailAboutRevision($is_new)
         ->setIsFirstMailToRecipients(true)
         ->setToPHIDs(array_keys($add['rev']));
 
       if ($is_new) {
         // The first time we send an email about a revision, put the CCs in
         // the "CC:" field of the same "Review Requested" email that reviewers
         // get, so you don't get two initial emails if you're on a list that
         // is CC'd.
         $message->setCCPHIDs(array_keys($add['ccs']));
       }
 
       $mail[] = $message;
     }
 
     // If you were added as a reviewer and a CC, just give you the reviewer
     // email. We could go to greater lengths to prevent this, but there's
     // bunch of stuff with list subscriptions anyway. You can still get two
     // emails, but only if a revision is updated and you are added as a reviewer
     // at the same time a list you are on is added as a CC, which is rare and
     // reasonable.
     $add['ccs'] = array_diff_key($add['ccs'], $add['rev']);
 
     if (!$is_new && $add['ccs']) {
       $mail[] = id(new DifferentialCCWelcomeMail(
           $revision,
           $actor_handle,
           $changesets))
         ->setIsFirstMailToRecipients(true)
         ->setToPHIDs(array_keys($add['ccs']));
     }
 
     foreach ($mail as $message) {
       $message->setHeraldTranscriptURI($xscript_uri);
       $message->setXHeraldRulesHeader($xscript_header);
       $message->send();
     }
   }
 
   public static function addCCAndUpdateRevision(
     $revision,
     $phid,
     $reason) {
 
     self::addCC($revision, $phid, $reason);
 
     $unsubscribed = $revision->getUnsubscribed();
     if (isset($unsubscribed[$phid])) {
       unset($unsubscribed[$phid]);
       $revision->setUnsubscribed($unsubscribed);
       $revision->save();
     }
   }
 
   public static function removeCCAndUpdateRevision(
     $revision,
     $phid,
     $reason) {
 
     self::removeCC($revision, $phid, $reason);
 
     $unsubscribed = $revision->getUnsubscribed();
     if (empty($unsubscribed[$phid])) {
       $unsubscribed[$phid] = true;
       $revision->setUnsubscribed($unsubscribed);
       $revision->save();
     }
   }
 
   public static function addCC(
     DifferentialRevision $revision,
     $phid,
     $reason) {
     return self::alterCCs(
       $revision,
       $revision->getCCPHIDs(),
       $rem = array(),
       $add = array($phid),
       $reason);
   }
 
   public static function removeCC(
     DifferentialRevision $revision,
     $phid,
     $reason) {
     return self::alterCCs(
       $revision,
       $revision->getCCPHIDs(),
       $rem = array($phid),
       $add = array(),
       $reason);
   }
 
   protected static function alterCCs(
     DifferentialRevision $revision,
     array $stable_phids,
     array $rem_phids,
     array $add_phids,
     $reason_phid) {
 
     return self::alterRelationships(
       $revision,
       $stable_phids,
       $rem_phids,
       $add_phids,
       $reason_phid,
       DifferentialRevision::RELATION_SUBSCRIBED);
   }
 
 
   public static function alterReviewers(
     DifferentialRevision $revision,
     array $stable_phids,
     array $rem_phids,
     array $add_phids,
     $reason_phid) {
 
     return self::alterRelationships(
       $revision,
       $stable_phids,
       $rem_phids,
       $add_phids,
       $reason_phid,
       DifferentialRevision::RELATION_REVIEWER);
   }
 
   private static function alterRelationships(
     DifferentialRevision $revision,
     array $stable_phids,
     array $rem_phids,
     array $add_phids,
     $reason_phid,
     $relation_type) {
 
     $rem_map = array_fill_keys($rem_phids, true);
     $add_map = array_fill_keys($add_phids, true);
 
     $seq_map = array_values($stable_phids);
     $seq_map = array_flip($seq_map);
     foreach ($rem_map as $phid => $ignored) {
       if (!isset($seq_map[$phid])) {
         $seq_map[$phid] = count($seq_map);
       }
     }
     foreach ($add_map as $phid => $ignored) {
       if (!isset($seq_map[$phid])) {
         $seq_map[$phid] = count($seq_map);
       }
     }
 
     $raw = $revision->getRawRelations($relation_type);
     $raw = ipull($raw, null, 'objectPHID');
 
     $sequence = count($seq_map);
     foreach ($raw as $phid => $ignored) {
       if (isset($seq_map[$phid])) {
         $raw[$phid]['sequence'] = $seq_map[$phid];
       } else {
         $raw[$phid]['sequence'] = $sequence++;
       }
     }
     $raw = isort($raw, 'sequence');
 
     foreach ($raw as $phid => $ignored) {
       if (isset($rem_map[$phid])) {
         unset($raw[$phid]);
       }
     }
 
     foreach ($add_phids as $add) {
       $raw[$add] = array(
         'objectPHID'  => $add,
         'sequence'    => idx($seq_map, $add, $sequence++),
         'reasonPHID'  => $reason_phid,
       );
     }
 
     $conn_w = $revision->establishConnection('w');
 
     $sql = array();
     foreach ($raw as $relation) {
       $sql[] = qsprintf(
         $conn_w,
         '(%d, %s, %s, %d, %s)',
         $revision->getID(),
         $relation_type,
         $relation['objectPHID'],
         $relation['sequence'],
         $relation['reasonPHID']);
     }
 
     $conn_w->openTransaction();
       queryfx(
         $conn_w,
         'DELETE FROM %T WHERE revisionID = %d AND relation = %s',
         DifferentialRevision::RELATIONSHIP_TABLE,
         $revision->getID(),
         $relation_type);
       if ($sql) {
         queryfx(
           $conn_w,
           'INSERT INTO %T
             (revisionID, relation, objectPHID, sequence, reasonPHID)
           VALUES %Q',
           DifferentialRevision::RELATIONSHIP_TABLE,
           implode(', ', $sql));
       }
     $conn_w->saveTransaction();
   }
 
 
   private function createComment() {
     $revision_id = $this->revision->getID();
     $comment = id(new DifferentialComment())
       ->setAuthorPHID($this->getActorPHID())
       ->setRevisionID($revision_id)
       ->setContent($this->getComments())
       ->setAction('update');
     $comment->save();
 
     return $comment;
   }
 
   private function updateTasks() {
     if ($this->tasks) {
       $task_class = PhabricatorEnv::getEnvConfig(
         'differential.attach-task-class');
       if ($task_class) {
         PhutilSymbolLoader::loadClass($task_class);
         $task_attacher = newv($task_class, array());
         $ret = $task_attacher->attachTasksToRevision(
           $this->actorPHID,
           $this->revision,
           $this->tasks);
       }
     }
   }
 
 }
 
diff --git a/src/applications/herald/adapter/differential/HeraldDifferentialRevisionAdapter.php b/src/applications/herald/adapter/differential/HeraldDifferentialRevisionAdapter.php
index b4025a656..11668483f 100644
--- a/src/applications/herald/adapter/differential/HeraldDifferentialRevisionAdapter.php
+++ b/src/applications/herald/adapter/differential/HeraldDifferentialRevisionAdapter.php
@@ -1,298 +1,310 @@
 <?php
 
 /*
  * Copyright 2011 Facebook, Inc.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
  *   http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
 class HeraldDifferentialRevisionAdapter extends HeraldObjectAdapter {
 
   protected $revision;
   protected $diff;
 
   protected $explicitCCs;
   protected $explicitReviewers;
   protected $forbiddenCCs;
 
   protected $newCCs = array();
   protected $remCCs = array();
+  protected $emailPHIDs = array();
 
   protected $repository;
   protected $affectedPackages;
   protected $changesets;
 
   public function __construct(
     DifferentialRevision $revision,
     DifferentialDiff $diff) {
 
     $revision->loadRelationships();
     $this->revision = $revision;
     $this->diff = $diff;
   }
 
   public function setExplicitCCs($explicit_ccs) {
     $this->explicitCCs = $explicit_ccs;
     return $this;
   }
 
   public function setExplicitReviewers($explicit_reviewers) {
     $this->explicitReviewers = $explicit_reviewers;
     return $this;
   }
 
   public function setForbiddenCCs($forbidden_ccs) {
     $this->forbiddenCCs = $forbidden_ccs;
     return $this;
   }
 
   public function getCCsAddedByHerald() {
     return array_diff_key($this->newCCs, $this->remCCs);
   }
 
   public function getCCsRemovedByHerald() {
     return $this->remCCs;
   }
 
+  public function getEmailPHIDsAddedByHerald() {
+    return $this->emailPHIDs;
+  }
+
   public function getPHID() {
     return $this->revision->getPHID();
   }
 
   public function getHeraldName() {
     return $this->revision->getTitle();
   }
 
   public function getHeraldTypeName() {
     return HeraldContentTypeConfig::CONTENT_TYPE_DIFFERENTIAL;
   }
 
   public function loadRepository() {
     if ($this->repository === null) {
       $diff = $this->diff;
 
       $repository = false;
 
       if ($diff->getRepositoryUUID()) {
         $repository = id(new PhabricatorRepository())->loadOneWhere(
           'uuid = %s',
           $diff->getRepositoryUUID());
       }
 
       if (!$repository && $diff->getArcanistProjectPHID()) {
         $project = id(new PhabricatorRepositoryArcanistProject())->loadOneWhere(
           'phid = %s',
           $diff->getArcanistProjectPHID());
         if ($project && $project->getRepositoryID()) {
           $repository = id(new PhabricatorRepository())->load(
             $project->getRepositoryID());
         }
       }
 
       $this->repository = $repository;
     }
     return $this->repository;
   }
 
   protected function loadChangesets() {
     if ($this->changesets === null) {
       $this->changesets = $this->diff->loadChangesets();
     }
     return $this->changesets;
   }
 
   protected function loadAffectedPaths() {
     $changesets = $this->loadChangesets();
 
     $paths = array();
     foreach ($changesets as $changeset) {
       $paths[] = $this->getAbsoluteRepositoryPathForChangeset($changeset);
     }
     return $paths;
   }
 
   protected function getAbsoluteRepositoryPathForChangeset(
     DifferentialChangeset $changeset) {
 
     $repository = $this->loadRepository();
     if (!$repository) {
       return '/'.ltrim($changeset->getFilename(), '/');
     }
 
     $diff = $this->diff;
 
     return $changeset->getAbsoluteRepositoryPath($diff, $repository);
   }
 
   protected function loadContentDictionary() {
     $changesets = $this->loadChangesets();
 
     $hunks = array();
     if ($changesets) {
       $hunks = id(new DifferentialHunk())->loadAllWhere(
         'changesetID in (%Ld)',
         mpull($changesets, 'getID'));
     }
 
     $dict = array();
     $hunks = mgroup($hunks, 'getChangesetID');
     $changesets = mpull($changesets, null, 'getID');
     foreach ($changesets as $id => $changeset) {
       $path = $this->getAbsoluteRepositoryPathForChangeset($changeset);
       $content = array();
       foreach (idx($hunks, $id, array()) as $hunk) {
         $content[] = $hunk->makeChanges();
       }
       $dict[$path] = implode("\n", $content);
     }
 
     return $dict;
   }
 
   public function loadAffectedPackages() {
     if ($this->affectedPackages === null) {
       $this->affectedPackages = array();
 
       $repository = $this->loadRepository();
       if ($repository) {
         $packages = PhabricatorOwnersPackage::loadAffectedPackages(
           $repository,
           $this->loadAffectedPaths());
         $this->affectedPackages = $packages;
       }
     }
     return $this->affectedPackages;
   }
 
   public function getHeraldField($field) {
     switch ($field) {
       case HeraldFieldConfig::FIELD_TITLE:
         return $this->revision->getTitle();
         break;
       case HeraldFieldConfig::FIELD_BODY:
         return $this->revision->getSummary()."\n".
                $this->revision->getTestPlan();
         break;
       case HeraldFieldConfig::FIELD_AUTHOR:
         return $this->revision->getAuthorPHID();
         break;
       case HeraldFieldConfig::FIELD_DIFF_FILE:
         return $this->loadAffectedPaths();
       case HeraldFieldConfig::FIELD_CC:
         if (isset($this->explicitCCs)) {
           return array_keys($this->explicitCCs);
         } else {
           return $this->revision->getCCPHIDs();
         }
       case HeraldFieldConfig::FIELD_REVIEWERS:
         if (isset($this->explicitReviewers)) {
           return array_keys($this->explicitReviewers);
         } else {
           return $this->revision->getReviewers();
         }
       case HeraldFieldConfig::FIELD_REPOSITORY:
         $repository = $this->loadRepository();
         if (!$repository) {
           return null;
         }
         return $repository->getPHID();
       case HeraldFieldConfig::FIELD_DIFF_CONTENT:
         return $this->loadContentDictionary();
       case HeraldFieldConfig::FIELD_AFFECTED_PACKAGE:
         $packages = $this->loadAffectedPackages();
         return mpull($packages, 'getPHID');
       case HeraldFieldConfig::FIELD_AFFECTED_PACKAGE_OWNER:
         $packages = $this->loadAffectedPackages();
         $owners = PhabricatorOwnersOwner::loadAllForPackages($packages);
         return mpull($owners, 'getUserPHID');
       default:
         throw new Exception("Invalid field '{$field}'.");
     }
   }
 
   public function applyHeraldEffects(array $effects) {
     $result = array();
     if ($this->explicitCCs) {
       $effect = new HeraldEffect();
       $effect->setAction(HeraldActionConfig::ACTION_ADD_CC);
       $effect->setTarget(array_keys($this->explicitCCs));
       $effect->setReason(
         'CCs provided explicitly by revision author or carried over from a '.
         'previous version of the revision.');
       $result[] = new HeraldApplyTranscript(
         $effect,
         true,
         'Added addresses to CC list.');
     }
 
     $forbidden_ccs = array_fill_keys(
       nonempty($this->forbiddenCCs, array()),
       true);
 
     foreach ($effects as $effect) {
       $action = $effect->getAction();
       switch ($action) {
         case HeraldActionConfig::ACTION_NOTHING:
           $result[] = new HeraldApplyTranscript(
             $effect,
             true,
             'OK, did nothing.');
           break;
+        case HeraldActionConfig::ACTION_EMAIL:
         case HeraldActionConfig::ACTION_ADD_CC:
+          $op = ($action == HeraldActionConfig::ACTION_EMAIL) ? 'email' : 'CC';
           $base_target = $effect->getTarget();
           $forbidden = array();
           foreach ($base_target as $key => $fbid) {
             if (isset($forbidden_ccs[$fbid])) {
               $forbidden[] = $fbid;
               unset($base_target[$key]);
             } else {
-              $this->newCCs[$fbid] = true;
+              if ($action == HeraldActionConfig::ACTION_EMAIL) {
+                $this->emailPHIDs[$fbid] = true;
+              } else {
+                $this->newCCs[$fbid] = true;
+              }
             }
           }
 
           if ($forbidden) {
             $failed = clone $effect;
             $failed->setTarget($forbidden);
             if ($base_target) {
               $effect->setTarget($base_target);
               $result[] = new HeraldApplyTranscript(
                 $effect,
                 true,
-                'Added these addresses to CC list. Others could not be added.');
+                'Added these addresses to '.$op.' list. '.
+                'Others could not be added.');
             }
             $result[] = new HeraldApplyTranscript(
               $failed,
               false,
-              'CC forbidden, these addresses have unsubscribed.');
+              $op.' forbidden, these addresses have unsubscribed.');
           } else {
             $result[] = new HeraldApplyTranscript(
               $effect,
               true,
-              'Added addresses to CC list.');
+              'Added addresses to '.$op.' list.');
           }
           break;
         case HeraldActionConfig::ACTION_REMOVE_CC:
           foreach ($effect->getTarget() as $fbid) {
             $this->remCCs[$fbid] = true;
           }
           $result[] = new HeraldApplyTranscript(
             $effect,
             true,
             'Removed addresses from CC list.');
           break;
         default:
           throw new Exception("No rules to handle action '{$action}'.");
       }
     }
     return $result;
   }
 }
diff --git a/src/applications/herald/config/action/HeraldActionConfig.php b/src/applications/herald/config/action/HeraldActionConfig.php
index 6f806822c..3974c6f59 100644
--- a/src/applications/herald/config/action/HeraldActionConfig.php
+++ b/src/applications/herald/config/action/HeraldActionConfig.php
@@ -1,72 +1,73 @@
 <?php
 
 /*
  * Copyright 2011 Facebook, Inc.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
  *   http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
 class HeraldActionConfig {
 
   const ACTION_ADD_CC       = 'addcc';
   const ACTION_REMOVE_CC    = 'remcc';
   const ACTION_EMAIL        = 'email';
   const ACTION_NOTHING      = 'nothing';
 
   public static function getActionMap() {
     return array(
       self::ACTION_ADD_CC       => 'Add emails to CC',
       self::ACTION_REMOVE_CC    => 'Remove emails from CC',
       self::ACTION_EMAIL        => 'Send an email to',
       self::ACTION_NOTHING      => 'Do nothing',
     );
   }
 
   public static function getActionMapForContentType($type) {
     $map = self::getActionMap();
     switch ($type) {
       case HeraldContentTypeConfig::CONTENT_TYPE_DIFFERENTIAL:
         return array_select_keys(
           $map,
           array(
             self::ACTION_ADD_CC,
             self::ACTION_REMOVE_CC,
+            self::ACTION_EMAIL,
             self::ACTION_NOTHING,
           ));
       case HeraldContentTypeConfig::CONTENT_TYPE_COMMIT:
         return array_select_keys(
           $map,
           array(
             self::ACTION_EMAIL,
             self::ACTION_NOTHING,
           ));
       case HeraldContentTypeConfig::CONTENT_TYPE_MERGE:
         return array_select_keys(
           $map,
           array(
             self::ACTION_EMAIL,
             self::ACTION_NOTHING,
           ));
       case HeraldContentTypeConfig::CONTENT_TYPE_OWNERS:
         return array_select_keys(
           $map,
           array(
             self::ACTION_EMAIL,
             self::ACTION_NOTHING,
           ));
       default:
         throw new Exception("Unknown content type '{$type}'.");
     }
   }
 
 }
diff --git a/src/applications/herald/config/repetitionpolicy/HeraldRepetitionPolicyConfig.php b/src/applications/herald/config/repetitionpolicy/HeraldRepetitionPolicyConfig.php
new file mode 100644
index 000000000..1c37c906a
--- /dev/null
+++ b/src/applications/herald/config/repetitionpolicy/HeraldRepetitionPolicyConfig.php
@@ -0,0 +1,64 @@
+<?php
+
+/*
+ * Copyright 2011 Facebook, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *   http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+class HeraldRepetitionPolicyConfig {
+  const FIRST   = 'first';  // only execute the first time (no repeating)
+  const EVERY   = 'every';  // repeat every time
+
+  private static $policyIntMap = array(
+    self::FIRST   => 0,
+    self::EVERY   => 1,
+  );
+
+  private static $policyMap = array(
+    self::FIRST   => 'only the first time',
+    self::EVERY   => 'every time',
+  );
+
+  public static function getMap() {
+    return self::$policyMap;
+  }
+
+  public static function getMapForContentType($type) {
+    switch ($type) {
+      case HeraldContentTypeConfig::CONTENT_TYPE_DIFFERENTIAL:
+        return array_select_keys(
+          self::$policyMap,
+          array(
+            self::EVERY,
+            self::FIRST,
+        ));
+
+      case HeraldContentTypeConfig::CONTENT_TYPE_COMMIT:
+      case HeraldContentTypeConfig::CONTENT_TYPE_MERGE:
+      case HeraldContentTypeConfig::CONTENT_TYPE_OWNERS:
+        return array();
+
+      default:
+        throw new Exception("Unknown content type '{$type}'.");
+    }
+  }
+
+  public static function toInt($str) {
+    return idx(self::$policyIntMap, $str, self::$policyIntMap[self::EVERY]);
+  }
+
+  public static function toString($int) {
+    return idx(array_flip(self::$policyIntMap), $int, self::EVERY);
+  }
+}
diff --git a/src/applications/herald/config/repetitionpolicy/__init__.php b/src/applications/herald/config/repetitionpolicy/__init__.php
new file mode 100644
index 000000000..9f18e371d
--- /dev/null
+++ b/src/applications/herald/config/repetitionpolicy/__init__.php
@@ -0,0 +1,14 @@
+<?php
+/**
+ * This file is automatically generated. Lint this module to rebuild it.
+ * @generated
+ */
+
+
+
+phutil_require_module('phabricator', 'applications/herald/config/contenttype');
+
+phutil_require_module('phutil', 'utils');
+
+
+phutil_require_source('HeraldRepetitionPolicyConfig.php');
diff --git a/src/applications/herald/controller/rule/HeraldRuleController.php b/src/applications/herald/controller/rule/HeraldRuleController.php
index 27053ce0f..7c9d9d5f5 100644
--- a/src/applications/herald/controller/rule/HeraldRuleController.php
+++ b/src/applications/herald/controller/rule/HeraldRuleController.php
@@ -1,461 +1,507 @@
 <?php
 
 /*
  * Copyright 2011 Facebook, Inc.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
  *   http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
 class HeraldRuleController extends HeraldController {
 
   private $id;
 
   public function willProcessRequest(array $data) {
     $this->id = (int)idx($data, 'id');
   }
 
   public function processRequest() {
 
     $request = $this->getRequest();
     $user = $request->getUser();
 
     $content_type_map = HeraldContentTypeConfig::getContentTypeMap();
 
     if ($this->id) {
       $rule = id(new HeraldRule())->load($this->id);
       if (!$rule) {
         return new Aphront404Response();
       }
       if ($rule->getAuthorPHID() != $user->getPHID()) {
         throw new Exception("You don't own this rule and can't edit it.");
       }
     } else {
       $rule = new HeraldRule();
       $rule->setAuthorPHID($user->getPHID());
       $rule->setMustMatchAll(true);
 
       $type = $request->getStr('type');
       if (!isset($content_type_map[$type])) {
         $type = HeraldContentTypeConfig::CONTENT_TYPE_DIFFERENTIAL;
       }
       $rule->setContentType($type);
     }
 
     $local_version = id(new HeraldRule())->getConfigVersion();
     if ($rule->getConfigVersion() > $local_version) {
       throw new Exception(
         "This rule was created with a newer version of Herald. You can not ".
         "view or edit it in this older version. Try dev or wait for a push.");
     }
 
     // Upgrade rule version to our version, since we might add newly-defined
     // conditions, etc.
     $rule->setConfigVersion($local_version);
 
     $rule_conditions = $rule->loadConditions();
     $rule_actions = $rule->loadActions();
 
     $rule->attachConditions($rule_conditions);
     $rule->attachActions($rule_actions);
 
     $e_name = true;
     $errors = array();
     if ($request->isFormPost() && $request->getStr('save')) {
       $rule->setName($request->getStr('name'));
       $rule->setMustMatchAll(($request->getStr('must_match') == 'all'));
 
+      $repetition_policy_param = $request->getStr('repetition_policy');
+      $rule->setRepetitionPolicy(
+        HeraldRepetitionPolicyConfig::toInt($repetition_policy_param)
+      );
+
       if (!strlen($rule->getName())) {
         $e_name = "Required";
         $errors[] = "Rule must have a name.";
       }
 
       $data = json_decode($request->getStr('rule'), true);
       if (!is_array($data) ||
           !$data['conditions'] ||
           !$data['actions']) {
         throw new Exception("Failed to decode rule data.");
       }
 
       $conditions = array();
       foreach ($data['conditions'] as $condition) {
         $obj = new HeraldCondition();
         $obj->setFieldName($condition[0]);
         $obj->setFieldCondition($condition[1]);
 
         if (is_array($condition[2])) {
           $obj->setValue(array_keys($condition[2]));
         } else {
           $obj->setValue($condition[2]);
         }
 
         $cond_type = $obj->getFieldCondition();
 
         if ($cond_type == HeraldConditionConfig::CONDITION_REGEXP) {
           if (@preg_match($obj->getValue(), '') === false) {
             $errors[] =
               'The regular expression "'.$obj->getValue().'" is not valid. '.
               'Regular expressions must have enclosing characters (e.g. '.
               '"@/path/to/file@", not "/path/to/file") and be syntactically '.
               'correct.';
           }
         }
 
         if ($cond_type == HeraldConditionConfig::CONDITION_REGEXP_PAIR) {
           $json = json_decode($obj->getValue(), true);
           if (!is_array($json)) {
             $errors[] =
               'The regular expression pair "'.$obj->getValue().'" is not '.
               'valid JSON. Enter a valid JSON array with two elements.';
           } else {
             if (count($json) != 2) {
               $errors[] =
                 'The regular expression pair "'.$obj->getValue().'" must have '.
                 'exactly two elements.';
             } else {
               $key_regexp = array_shift($json);
               $val_regexp = array_shift($json);
 
               if (@preg_match($key_regexp, '') === false) {
                 $errors[] =
                   'The first regexp, "'.$key_regexp.'" in the regexp pair '.
                   'is not a valid regexp.';
               }
               if (@preg_match($val_regexp, '') === false) {
                 $errors[] =
                   'The second regexp, "'.$val_regexp.'" in the regexp pair '.
                   'is not a valid regexp.';
               }
             }
           }
         }
 
         $conditions[] = $obj;
       }
 
       $actions = array();
       foreach ($data['actions'] as $action) {
         $obj = new HeraldAction();
         $obj->setAction($action[0]);
 
         if (!isset($action[1])) {
           // Legitimate for any action which doesn't need a target, like
           // "Do nothing".
           $action[1] = null;
         }
 
         if (is_array($action[1])) {
           $obj->setTarget(array_keys($action[1]));
         } else {
           $obj->setTarget($action[1]);
         }
 
         $actions[] = $obj;
       }
 
       $rule->attachConditions($conditions);
       $rule->attachActions($actions);
 
       if (!$errors) {
         try {
 
 // TODO
 //          $rule->openTransaction();
             $rule->save();
             $rule->saveConditions($conditions);
             $rule->saveActions($actions);
 //          $rule->saveTransaction();
 
           $uri = '/herald/view/'.$rule->getContentType().'/';
 
           return id(new AphrontRedirectResponse())
             ->setURI($uri);
         } catch (AphrontQueryDuplicateKeyException $ex) {
           $e_name = "Not Unique";
           $errors[] = "Rule name is not unique. Choose a unique name.";
         }
       }
 
     }
 
     $phids = array();
     $phids[] = $rule->getAuthorPHID();
 
     foreach ($rule->getActions() as $action) {
       if (!is_array($action->getTarget())) {
         continue;
       }
       foreach ($action->getTarget() as $target) {
         $target = (array)$target;
         foreach ($target as $phid) {
           $phids[] = $phid;
         }
       }
     }
 
     foreach ($rule->getConditions() as $condition) {
       $value = $condition->getValue();
       if (is_array($value)) {
         foreach ($value as $phid) {
           $phids[] = $phid;
         }
       }
     }
 
     $handles = id(new PhabricatorObjectHandleData($phids))
       ->loadHandles();
 
     if ($errors) {
       $error_view = new AphrontErrorView();
       $error_view->setTitle('Form Errors');
       $error_view->setErrors($errors);
     } else {
       $error_view = null;
     }
 
     $options = array(
       'all' => 'all of',
       'any' => 'any of',
     );
 
     $selected = $rule->getMustMatchAll() ? 'all' : 'any';
 
     $must_match = array();
     foreach ($options as $key => $option) {
       $must_match[] = phutil_render_tag(
         'option',
         array(
           'selected' => ($selected == $key) ? 'selected' : null,
           'value' => $key,
         ),
         phutil_escape_html($option));
     }
     $must_match =
       '<select name="must_match">'.
         implode("\n", $must_match).
       '</select>';
 
     if ($rule->getID()) {
       $action = '/herald/rule/'.$rule->getID().'/';
     } else {
       $action = '/herald/rule/'.$rule->getID().'/';
     }
 
+    // Make the selector for choosing how often this rule should be repeated
+    $repetition_selector = "";
+    $repetition_policy = HeraldRepetitionPolicyConfig::toString(
+      $rule->getRepetitionPolicy()
+    );
+    $repetition_options = HeraldRepetitionPolicyConfig::getMapForContentType(
+      $rule->getContentType()
+    );
+
+    if (empty($repetition_options)) {
+      // default option is 'every time'
+      $repetition_selector = idx(
+        HeraldRepetitionPolicyConfig::getMap(),
+        HeraldRepetitionPolicyConfig::EVERY
+      );
+    } else if (count($repetition_options) == 1) {
+      // if there's only 1 option, just pick it for the user
+      $repetition_selector = reset($repetition_options);
+    } else {
+      // give the user all the options for this rule type
+      $tags = array();
+
+      foreach ($repetition_options as $name => $option) {
+        $tags[] = phutil_render_tag(
+          'option',
+          array (
+            'selected'  => ($repetition_policy == $name) ? 'selected' : null,
+            'value'     => $name,
+          ),
+          phutil_escape_html($option)
+        );
+      }
+
+      $repetition_selector =
+        '<select name="repetition_policy">'.
+          implode("\n", $tags).
+        '</select>';
+    }
+
     require_celerity_resource('herald-css');
 
     $type_name = $content_type_map[$rule->getContentType()];
 
     $form = id(new AphrontFormView())
       ->setUser($user)
       ->setID('herald-rule-edit-form')
       ->addHiddenInput('type', $rule->getContentType())
       ->addHiddenInput('save', 1)
       ->appendChild(
         // Build this explicitly so we can add a sigil to it.
         javelin_render_tag(
           'input',
           array(
             'type'  => 'hidden',
             'name'  => 'rule',
             'sigil' => 'rule',
           )))
       ->appendChild(
         id(new AphrontFormTextControl())
           ->setLabel('Rule Name')
           ->setName('name')
           ->setError($e_name)
           ->setValue($rule->getName()))
       ->appendChild(
         id(new AphrontFormStaticControl())
           ->setLabel('Author')
           ->setValue($handles[$rule->getAuthorPHID()]->getName()))
       ->appendChild(
         id(new AphrontFormMarkupControl())
           ->setValue(
             "This rule triggers for <strong>{$type_name}</strong>."))
       ->appendChild(
         '<h1>Conditions</h1>'.
         '<div class="aphront-form-inset">'.
           '<div style="float: right;">'.
             javelin_render_tag(
               'a',
               array(
                 'href' => '#',
                 'class' => 'button green',
                 'sigil' => 'create-condition',
                 'mustcapture' => true,
               ),
               'Create New Condition').
           '</div>'.
           '<p>When '.$must_match.' these conditions are met:</p>'.
           '<div style="clear: both;"></div>'.
           javelin_render_tag(
             'table',
             array(
               'sigil' => 'rule-conditions',
               'class' => 'herald-condition-table',
             ),
             '').
         '</div>')
       ->appendChild(
         '<h1>Action</h1>'.
         '<div class="aphront-form-inset">'.
           '<div style="float: right;">'.
           javelin_render_tag(
             'a',
             array(
               'href' => '#',
               'class' => 'button green',
               'sigil' => 'create-action',
               'mustcapture' => true,
             ),
             'Create New Action').
           '</div>'.
-          '<p>Take these actions:</p>'.
+          '<p>'.
+            'Take these actions '.$repetition_selector.' this rule matches:'.
+          '</p>'.
           '<div style="clear: both;"></div>'.
           javelin_render_tag(
             'table',
             array(
               'sigil' => 'rule-actions',
               'class' => 'herald-action-table',
             ),
             '').
         '</div>')
       ->appendChild(
         id(new AphrontFormSubmitControl())
           ->setValue('Save Rule')
           ->addCancelButton('/herald/view/'.$rule->getContentType().'/'));
 
     $serial_conditions = array(
       array('default', 'default', ''),
     );
 
     if ($rule->getConditions()) {
       $serial_conditions = array();
       foreach ($rule->getConditions() as $condition) {
 
         $value = $condition->getValue();
         if (is_array($value)) {
           $value_map = array();
           foreach ($value as $k => $fbid) {
             $value_map[$fbid] = $handles[$fbid]->getName();
           }
           $value = $value_map;
         }
 
         $serial_conditions[] = array(
           $condition->getFieldName(),
           $condition->getFieldCondition(),
           $value,
         );
       }
     }
 
     $serial_actions = array(
       array('default', ''),
     );
     if ($rule->getActions()) {
       $serial_actions = array();
       foreach ($rule->getActions() as $action) {
 
         $target_map = array();
         foreach ((array)$action->getTarget() as $fbid) {
           $target_map[$fbid] = $handles[$fbid]->getName();
         }
 
         $serial_actions[] = array(
           $action->getAction(),
           $target_map,
         );
       }
     }
 
     $all_rules = id(new HeraldRule())->loadAllWhere(
       'authorPHID = %d AND contentType = %s',
       $rule->getAuthorPHID(),
       $rule->getContentType());
     $all_rules = mpull($all_rules, 'getName', 'getID');
     asort($all_rules);
     unset($all_rules[$rule->getID()]);
 
 
     $config_info = array();
     $config_info['fields']
       = HeraldFieldConfig::getFieldMapForContentType($rule->getContentType());
     $config_info['conditions'] = HeraldConditionConfig::getConditionMap();
     foreach ($config_info['fields'] as $field => $name) {
       $config_info['conditionMap'][$field] = array_keys(
         HeraldConditionConfig::getConditionMapForField($field));
     }
     foreach ($config_info['fields'] as $field => $fname) {
       foreach ($config_info['conditions'] as $condition => $cname) {
         $config_info['values'][$field][$condition] =
           HeraldValueTypeConfig::getValueTypeForFieldAndCondition(
             $field,
             $condition);
       }
     }
 
     $config_info['actions'] =
       HeraldActionConfig::getActionMapForContentType($rule->getContentType());
 
     foreach ($config_info['actions'] as $action => $name) {
       $config_info['targets'][$action] =
         HeraldValueTypeConfig::getValueTypeForAction($action);
     }
 
     Javelin::initBehavior(
       'herald-rule-editor',
       array(
         'root' => 'herald-rule-edit-form',
         'conditions' => (object) $serial_conditions,
         'actions' => (object) $serial_actions,
         'template' => $this->buildTokenizerTemplates() + array(
           'rules' => $all_rules,
         ),
         'info' => $config_info,
       ));
 
     $panel = new AphrontPanelView();
     $panel->setHeader('Edit Herald Rule');
     $panel->setWidth(AphrontPanelView::WIDTH_WIDE);
     $panel->appendChild($form);
 
     return $this->buildStandardPageResponse(
       array(
         $error_view,
         $panel,
       ),
       array(
         'title' => 'Edit Rule',
       ));
   }
 
   protected function buildTokenizerTemplates() {
     $template = new AphrontTokenizerTemplateView();
     $template = $template->render();
 
     return array(
       'source' => array(
         'email'       => '/typeahead/common/mailable/',
         'user'        => '/typeahead/common/users/',
         'repository'  => '/typeahead/common/repositories/',
         'package'     => '/typeahead/common/packages/',
 
 /*
         'tag'         => '/datasource/tag/',
 */
       ),
       'markup' => $template,
     );
   }
 }
diff --git a/src/applications/herald/controller/rule/__init__.php b/src/applications/herald/controller/rule/__init__.php
index 6e4663485..4ee071a42 100644
--- a/src/applications/herald/controller/rule/__init__.php
+++ b/src/applications/herald/controller/rule/__init__.php
@@ -1,37 +1,38 @@
 <?php
 /**
  * This file is automatically generated. Lint this module to rebuild it.
  * @generated
  */
 
 
 
 phutil_require_module('phabricator', 'aphront/response/404');
 phutil_require_module('phabricator', 'aphront/response/redirect');
 phutil_require_module('phabricator', 'applications/herald/config/action');
 phutil_require_module('phabricator', 'applications/herald/config/condition');
 phutil_require_module('phabricator', 'applications/herald/config/contenttype');
 phutil_require_module('phabricator', 'applications/herald/config/field');
+phutil_require_module('phabricator', 'applications/herald/config/repetitionpolicy');
 phutil_require_module('phabricator', 'applications/herald/config/valuetype');
 phutil_require_module('phabricator', 'applications/herald/controller/base');
 phutil_require_module('phabricator', 'applications/herald/storage/action');
 phutil_require_module('phabricator', 'applications/herald/storage/condition');
 phutil_require_module('phabricator', 'applications/herald/storage/rule');
 phutil_require_module('phabricator', 'applications/phid/handle/data');
 phutil_require_module('phabricator', 'infrastructure/celerity/api');
 phutil_require_module('phabricator', 'infrastructure/javelin/api');
 phutil_require_module('phabricator', 'infrastructure/javelin/markup');
 phutil_require_module('phabricator', 'view/control/tokenizer');
 phutil_require_module('phabricator', 'view/form/base');
 phutil_require_module('phabricator', 'view/form/control/markup');
 phutil_require_module('phabricator', 'view/form/control/static');
 phutil_require_module('phabricator', 'view/form/control/submit');
 phutil_require_module('phabricator', 'view/form/control/text');
 phutil_require_module('phabricator', 'view/form/error');
 phutil_require_module('phabricator', 'view/layout/panel');
 
 phutil_require_module('phutil', 'markup');
 phutil_require_module('phutil', 'utils');
 
 
 phutil_require_source('HeraldRuleController.php');
diff --git a/src/applications/herald/engine/engine/HeraldEngine.php b/src/applications/herald/engine/engine/HeraldEngine.php
index 5cc8f8338..14339f0e3 100644
--- a/src/applications/herald/engine/engine/HeraldEngine.php
+++ b/src/applications/herald/engine/engine/HeraldEngine.php
@@ -1,453 +1,478 @@
 <?php
 
 /*
  * Copyright 2011 Facebook, Inc.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
  *   http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
 class HeraldEngine {
 
   protected $rules = array();
   protected $results = array();
   protected $stack = array();
   protected $activeRule = null;
 
   protected $fieldCache = array();
   protected $object = null;
 
   public static function loadAndApplyRules(HeraldObjectAdapter $object) {
     $content_type = $object->getHeraldTypeName();
     $rules = HeraldRule::loadAllByContentTypeWithFullData($content_type);
 
     $engine = new HeraldEngine();
     $effects = $engine->applyRules($rules, $object);
     $engine->applyEffects($effects, $object);
 
     return $engine->getTranscript();
   }
 
   public function applyRules(array $rules, HeraldObjectAdapter $object) {
     $t_start = microtime(true);
 
     $rules = mpull($rules, null, 'getID');
 
     $this->transcript = new HeraldTranscript();
     $this->transcript->setObjectPHID((string)$object->getPHID());
     $this->fieldCache = array();
     $this->results = array();
     $this->rules   = $rules;
     $this->object  = $object;
 
     $effects = array();
     foreach ($rules as $id => $rule) {
-
-
       $this->stack = array();
       try {
-        $rule_matches = $this->doesRuleMatch($rule, $object);
+        if (($rule->getRepetitionPolicy() ==
+             HeraldRepetitionPolicyConfig::FIRST) &&
+            $rule->getRuleApplied($object->getPHID())) {
+          // This rule is only supposed to be applied a single time, and it's
+          // aleady been applied, so this is an automatic failure.
+          $xscript = id(new HeraldRuleTranscript())
+            ->setRuleID($id)
+            ->setResult(false)
+            ->setRuleName($rule->getName())
+            ->setRuleOwner($rule->getAuthorPHID())
+            ->setReason(
+              "This rule is only supposed to be repeated a single time, ".
+              "and it has already been applied."
+            );
+          $this->transcript->addRuleTranscript($xscript);
+          $rule_matches = false;
+        } else {
+          $rule_matches = $this->doesRuleMatch($rule, $object);
+        }
       } catch (HeraldRecursiveConditionsException $ex) {
         $names = array();
         foreach ($this->stack as $rule_id => $ignored) {
           $names[] = '"'.$rules[$rule_id]->getName().'"';
         }
         $names = implode(', ', $names);
         foreach ($this->stack as $rule_id => $ignored) {
           $xscript = new HeraldRuleTranscript();
           $xscript->setRuleID($rule_id);
           $xscript->setResult(false);
           $xscript->setReason(
             "Rules {$names} are recursively dependent upon one another! ".
             "Don't do this! You have formed an unresolvable cycle in the ".
             "dependency graph!");
           $xscript->setRuleName($rules[$rule_id]->getName());
           $xscript->setRuleOwner($rules[$rule_id]->getAuthorPHID());
           $this->transcript->addRuleTranscript($xscript);
         }
         $rule_matches = false;
       }
       $this->results[$id] = $rule_matches;
 
       if ($rule_matches) {
         foreach ($this->getRuleEffects($rule, $object) as $effect) {
           $effects[] = $effect;
         }
       }
     }
 
     $object_transcript = new HeraldObjectTranscript();
     $object_transcript->setPHID($object->getPHID());
     $object_transcript->setName($object->getHeraldName());
     $object_transcript->setType($object->getHeraldTypeName());
     $object_transcript->setFields($this->fieldCache);
 
     $this->transcript->setObjectTranscript($object_transcript);
 
     $t_end = microtime(true);
 
     $this->transcript->setDuration($t_end - $t_start);
 
     return $effects;
   }
 
   public function applyEffects(array $effects, HeraldObjectAdapter $object) {
-    if ($object instanceof DryRunHeraldable) {
-      $this->transcript->setDryRun(true);
-    } else {
-      $this->transcript->setDryRun(false);
-    }
-    foreach ($object->applyHeraldEffects($effects) as $apply_xscript) {
+    $this->transcript->setDryRun($object instanceof HeraldDryRunAdapter);
+
+    $xscripts = $object->applyHeraldEffects($effects);
+    foreach ($xscripts as $apply_xscript) {
       if (!($apply_xscript instanceof HeraldApplyTranscript)) {
         throw new Exception(
           "Heraldable must return HeraldApplyTranscripts from ".
           "applyHeraldEffect().");
       }
       $this->transcript->addApplyTranscript($apply_xscript);
     }
+
+    if (!$this->transcript->getDryRun()) {
+      // Mark all the rules that have had their effects applied as having been
+      // executed for the current object.
+      $ruleIDs = mpull($xscripts, 'getRuleID');
+      foreach ($ruleIDs as $ruleID) {
+        id(new HeraldRule())
+          ->setID($ruleID)
+          ->saveRuleApplied($object->getPHID());
+      }
+    }
   }
 
   public function getTranscript() {
     $this->transcript->save();
     return $this->transcript;
   }
 
   protected function doesRuleMatch(
     HeraldRule $rule,
     HeraldObjectAdapter $object) {
 
     $id = $rule->getID();
 
     if (isset($this->results[$id])) {
       // If we've already evaluated this rule because another rule depends
       // on it, we don't need to reevaluate it.
       return $this->results[$id];
     }
 
     if (isset($this->stack[$id])) {
       // We've recursed, fail all of the rules on the stack. This happens when
       // there's a dependency cycle with "Rule conditions match for rule ..."
       // conditions.
       foreach ($this->stack as $rule_id => $ignored) {
         $this->results[$rule_id] = false;
       }
       throw new HeraldRecursiveConditionsException();
     }
 
     $this->stack[$id] = true;
 
     $all = $rule->getMustMatchAll();
 
     $conditions = $rule->getConditions();
 
     $result = null;
 
     $local_version = id(new HeraldRule())->getConfigVersion();
     if ($rule->getConfigVersion() > $local_version) {
       $reason = "Rule could not be processed, it was created with a newer ".
                 "version of Herald.";
       $result = false;
     } else if (!$conditions) {
       $reason = "Rule failed automatically because it has no conditions.";
       $result = false;
 /* TOOD: Restore this in some form?
     } else if (!is_fb_employee($rule->getAuthorPHID())) {
       $reason = "Rule failed automatically because its owner is not an ".
                 "active employee.";
       $result = false;
 */
     } else {
       foreach ($conditions as $condition) {
         $match = $this->doesConditionMatch($rule, $condition, $object);
 
         if (!$all && $match) {
           $reason = "Any condition matched.";
           $result = true;
           break;
         }
 
         if ($all && !$match) {
           $reason = "Not all conditions matched.";
           $result = false;
           break;
         }
       }
 
       if ($result === null) {
         if ($all) {
           $reason = "All conditions matched.";
           $result = true;
         } else {
           $reason = "No conditions matched.";
           $result = false;
         }
       }
     }
 
     $rule_transcript = new HeraldRuleTranscript();
     $rule_transcript->setRuleID($rule->getID());
     $rule_transcript->setResult($result);
     $rule_transcript->setReason($reason);
     $rule_transcript->setRuleName($rule->getName());
     $rule_transcript->setRuleOwner($rule->getAuthorPHID());
 
     $this->transcript->addRuleTranscript($rule_transcript);
 
     return $result;
   }
 
   protected function doesConditionMatch(
     HeraldRule $rule,
     HeraldCondition $condition,
     HeraldObjectAdapter $object) {
 
     $object_value = $this->getConditionObjectValue($condition, $object);
     $test_value   = $condition->getValue();
 
     $cond = $condition->getFieldCondition();
 
     $transcript = new HeraldConditionTranscript();
     $transcript->setRuleID($rule->getID());
     $transcript->setConditionID($condition->getID());
     $transcript->setFieldName($condition->getFieldName());
     $transcript->setCondition($cond);
     $transcript->setTestValue($test_value);
 
     $result = null;
     switch ($cond) {
       case HeraldConditionConfig::CONDITION_CONTAINS:
         // "Contains" can take an array of strings, as in "Any changed
         // filename" for diffs.
         foreach ((array)$object_value as $value) {
           $result = (stripos($value, $test_value) !== false);
           if ($result) {
             break;
           }
         }
         break;
       case HeraldConditionConfig::CONDITION_NOT_CONTAINS:
         $result = (stripos($object_value, $test_value) === false);
         break;
       case HeraldConditionConfig::CONDITION_IS:
         $result = ($object_value == $test_value);
         break;
       case HeraldConditionConfig::CONDITION_IS_NOT:
         $result = ($object_value != $test_value);
         break;
       case HeraldConditionConfig::CONDITION_IS_ME:
         $result = ($object_value == $rule->getAuthorPHID());
         break;
       case HeraldConditionConfig::CONDITION_IS_NOT_ME:
         $result = ($object_value != $rule->getAuthorPHID());
         break;
       case HeraldConditionConfig::CONDITION_IS_ANY:
         $test_value = array_flip($test_value);
         $result = isset($test_value[$object_value]);
         break;
       case HeraldConditionConfig::CONDITION_IS_NOT_ANY:
         $test_value = array_flip($test_value);
         $result = !isset($test_value[$object_value]);
         break;
       case HeraldConditionConfig::CONDITION_INCLUDE_ALL:
         if (!is_array($object_value)) {
           $transcript->setNote('Object produced bad value!');
           $result = false;
         } else {
           $have = array_select_keys(array_flip($object_value),
                                     $test_value);
           $result = (count($have) == count($test_value));
         }
         break;
       case HeraldConditionConfig::CONDITION_INCLUDE_ANY:
         $result = (bool)array_select_keys(array_flip($object_value),
                                           $test_value);
         break;
       case HeraldConditionConfig::CONDITION_INCLUDE_NONE:
         $result = !array_select_keys(array_flip($object_value),
                                      $test_value);
         break;
       case HeraldConditionConfig::CONDITION_EXISTS:
         $result = (bool)$object_value;
         break;
       case HeraldConditionConfig::CONDITION_NOT_EXISTS:
         $result = !$object_value;
         break;
       case HeraldConditionConfig::CONDITION_REGEXP:
         foreach ((array)$object_value as $value) {
           $result = @preg_match($test_value, $value);
           if ($result === false) {
             $transcript->setNote(
               "Regular expression is not valid!");
             break;
           }
           if ($result) {
             break;
           }
         }
         $result = (bool)$result;
         break;
       case HeraldConditionConfig::CONDITION_REGEXP_PAIR:
         // Match a JSON-encoded pair of regular expressions against a
         // dictionary. The first regexp must match the dictionary key, and the
         // second regexp must match the dictionary value. If any key/value pair
         // in the dictionary matches both regexps, the condition is satisfied.
         $regexp_pair = json_decode($test_value, true);
         if (!is_array($regexp_pair)) {
           $result = false;
           $transcript->setNote("Regular expression pair is not valid JSON!");
           break;
         }
         if (count($regexp_pair) != 2) {
           $result = false;
           $transcript->setNote("Regular expression pair is not a pair!");
           break;
         }
 
         $key_regexp   = array_shift($regexp_pair);
         $value_regexp = array_shift($regexp_pair);
 
         foreach ((array)$object_value as $key => $value) {
           $key_matches = @preg_match($key_regexp, $key);
           if ($key_matches === false) {
             $result = false;
             $transcript->setNote("First regular expression is invalid!");
             break 2;
           }
           if ($key_matches) {
             $value_matches = @preg_match($value_regexp, $value);
             if ($value_matches === false) {
               $result = false;
               $transcript->setNote("Second regular expression is invalid!");
               break 2;
             }
             if ($value_matches) {
               $result = true;
               break 2;
             }
           }
         }
         $result = false;
         break;
       case HeraldConditionConfig::CONDITION_RULE:
       case HeraldConditionConfig::CONDITION_NOT_RULE:
 
         $rule = idx($this->rules, $test_value);
         if (!$rule) {
           $transcript->setNote(
             "Condition references a rule which does not exist!");
           $result = false;
         } else {
           $is_not = ($cond == HeraldConditionConfig::CONDITION_NOT_RULE);
           $result = $this->doesRuleMatch($rule, $object);
           if ($is_not) {
             $result = !$result;
           }
         }
         break;
       default:
         throw new HeraldInvalidConditionException(
           "Unknown condition '{$cond}'.");
     }
 
     $transcript->setResult($result);
 
     $this->transcript->addConditionTranscript($transcript);
 
     return $result;
   }
 
   protected function getConditionObjectValue(
     HeraldCondition $condition,
     HeraldObjectAdapter $object) {
 
     $field = $condition->getFieldName();
 
     return $this->getObjectFieldValue($field);
   }
 
   public function getObjectFieldValue($field) {
     if (isset($this->fieldCache[$field])) {
       return $this->fieldCache[$field];
     }
 
     $result = null;
     switch ($field) {
       case HeraldFieldConfig::FIELD_RULE:
         $result = null;
         break;
       case HeraldFieldConfig::FIELD_TITLE:
       case HeraldFieldConfig::FIELD_BODY:
       case HeraldFieldConfig::FIELD_DIFF_FILE:
       case HeraldFieldConfig::FIELD_DIFF_CONTENT:
         // TODO: Type should be string.
         $result = $this->object->getHeraldField($field);
         break;
       case HeraldFieldConfig::FIELD_AUTHOR:
       case HeraldFieldConfig::FIELD_REPOSITORY:
       case HeraldFieldConfig::FIELD_MERGE_REQUESTER:
         // TODO: Type should be PHID.
         $result = $this->object->getHeraldField($field);
         break;
       case HeraldFieldConfig::FIELD_TAGS:
       case HeraldFieldConfig::FIELD_REVIEWER:
       case HeraldFieldConfig::FIELD_REVIEWERS:
       case HeraldFieldConfig::FIELD_CC:
       case HeraldFieldConfig::FIELD_DIFFERENTIAL_REVIEWERS:
       case HeraldFieldConfig::FIELD_DIFFERENTIAL_CCS:
         // TODO: Type should be list.
         $result = $this->object->getHeraldField($field);
         break;
       case HeraldFieldConfig::FIELD_AFFECTED_PACKAGE:
       case HeraldFieldConfig::FIELD_AFFECTED_PACKAGE_OWNER:
         $result = $this->object->getHeraldField($field);
         if (!is_array($result)) {
           throw new HeraldInvalidFieldException(
             "Value of field type {$field} is not an array!");
         }
         break;
       case HeraldFieldConfig::FIELD_DIFFERENTIAL_REVISION:
         // TODO: Type should be boolean I guess.
         $result = $this->object->getHeraldField($field);
         break;
       default:
         throw new HeraldInvalidConditionException(
           "Unknown field type '{$field}'!");
     }
 
     $this->fieldCache[$field] = $result;
     return $result;
   }
 
   protected function getRuleEffects(
     HeraldRule $rule,
     HeraldObjectAdapter $object) {
 
     $effects = array();
     foreach ($rule->getActions() as $action) {
       $effect = new HeraldEffect();
       $effect->setObjectPHID($object->getPHID());
       $effect->setAction($action->getAction());
       $effect->setTarget($action->getTarget());
 
       $effect->setRuleID($rule->getID());
 
       $name = $rule->getName();
       $id   = $rule->getID();
       $effect->setReason(
         'Conditions were met for Herald rule "'.$name.'" (#'.$id.').');
 
       $effects[] = $effect;
     }
     return $effects;
   }
 
 }
diff --git a/src/applications/herald/engine/engine/__init__.php b/src/applications/herald/engine/engine/__init__.php
index 5b7eab228..3898e3bb6 100644
--- a/src/applications/herald/engine/engine/__init__.php
+++ b/src/applications/herald/engine/engine/__init__.php
@@ -1,22 +1,23 @@
 <?php
 /**
  * This file is automatically generated. Lint this module to rebuild it.
  * @generated
  */
 
 
 
 phutil_require_module('phabricator', 'applications/herald/config/condition');
 phutil_require_module('phabricator', 'applications/herald/config/field');
+phutil_require_module('phabricator', 'applications/herald/config/repetitionpolicy');
 phutil_require_module('phabricator', 'applications/herald/engine/effect');
 phutil_require_module('phabricator', 'applications/herald/engine/engine/exception');
 phutil_require_module('phabricator', 'applications/herald/storage/rule');
 phutil_require_module('phabricator', 'applications/herald/storage/transcript/base');
 phutil_require_module('phabricator', 'applications/herald/storage/transcript/condition');
 phutil_require_module('phabricator', 'applications/herald/storage/transcript/object');
 phutil_require_module('phabricator', 'applications/herald/storage/transcript/rule');
 
 phutil_require_module('phutil', 'utils');
 
 
 phutil_require_source('HeraldEngine.php');
diff --git a/src/applications/herald/storage/rule/HeraldRule.php b/src/applications/herald/storage/rule/HeraldRule.php
index 76b11a8aa..fd43ec9bb 100644
--- a/src/applications/herald/storage/rule/HeraldRule.php
+++ b/src/applications/herald/storage/rule/HeraldRule.php
@@ -1,149 +1,192 @@
 <?php
 
 /*
  * Copyright 2011 Facebook, Inc.
  *
  * Licensed under the Apache License, Version 2.0 (the "License");
  * you may not use this file except in compliance with the License.
  * You may obtain a copy of the License at
  *
  *   http://www.apache.org/licenses/LICENSE-2.0
  *
  * Unless required by applicable law or agreed to in writing, software
  * distributed under the License is distributed on an "AS IS" BASIS,
  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
  * See the License for the specific language governing permissions and
  * limitations under the License.
  */
 
 class HeraldRule extends HeraldDAO {
 
+  const TABLE_RULE_APPLIED = 'herald_ruleapplied';
+
   protected $name;
   protected $authorPHID;
 
   protected $contentType;
   protected $mustMatchAll;
+  protected $repetitionPolicy;
+
+  protected $configVersion = 8;
 
-  protected $configVersion = 7;
+  private $ruleApplied = array(); // phids for which this rule has been applied
 
   public static function loadAllByContentTypeWithFullData($content_type) {
     $rules = id(new HeraldRule())->loadAllWhere(
       'contentType = %s',
       $content_type);
 
     if (!$rules) {
       return array();
     }
 
     $rule_ids = mpull($rules, 'getID');
 
     $conditions = id(new HeraldCondition())->loadAllWhere(
       'ruleID in (%Ld)',
       $rule_ids);
 
     $actions = id(new HeraldAction())->loadAllWhere(
       'ruleID in (%Ld)',
       $rule_ids);
 
+    $applied = queryfx_all(
+      id(new HeraldRule())->establishConnection('r'),
+      'SELECT * FROM %T WHERE ruleID in (%Ld)',
+      self::TABLE_RULE_APPLIED, $rule_ids
+    );
+
     $conditions = mgroup($conditions, 'getRuleID');
     $actions = mgroup($actions, 'getRuleID');
+    $applied = igroup($applied, 'ruleID');
+
 
     foreach ($rules as $rule) {
+      $rule->attachAllRuleApplied(idx($applied, $rule->getID(), array()));
       $rule->attachConditions(idx($conditions, $rule->getID(), array()));
       $rule->attachActions(idx($actions, $rule->getID(), array()));
     }
 
     return $rules;
   }
 
+  public function getRuleApplied($phid) {
+    // defaults to false because (ruleID, phid) pairs not in the db imply
+    // a rule that's not been applied before
+    return idx($this->ruleApplied, $phid, false);
+  }
+
+  public function setRuleApplied($phid) {
+    $this->ruleApplied[$phid] = true;
+  }
+
+  public function attachAllRuleApplied(array $applied) {
+    // turn array of array(ruleID, phid) into array of ruleID => true
+    $this->ruleApplied = array_fill_keys(ipull($applied, 'phid'), true);
+ }
+
+  public function saveRuleApplied($phid) {
+    if (!$this->getID()) {
+      throw new Exception("Save rule before saving children.");
+    }
+
+    queryfx(
+      $this->establishConnection('w'),
+      'INSERT IGNORE INTO %T (phid, ruleID) VALUES (%s, %d)',
+      self::TABLE_RULE_APPLIED, $phid, $this->getID()
+    );
+
+    $this->setRuleApplied($phid);
+  }
+
   public function loadConditions() {
     if (!$this->getID()) {
       return array();
     }
     return id(new HeraldCondition())->loadAllWhere(
       'ruleID = %d',
       $this->getID());
   }
 
   public function attachConditions(array $conditions) {
     $this->conditions = $conditions;
     return $this;
   }
 
   public function getConditions() {
     // TODO: validate conditions have been attached.
     return $this->conditions;
   }
 
   public function loadActions() {
     if (!$this->getID()) {
       return array();
     }
     return id(new HeraldAction())->loadAllWhere(
       'ruleID = %d',
       $this->getID());
   }
 
   public function attachActions(array $actions) {
     // TODO: validate actions have been attached.
     $this->actions = $actions;
     return $this;
   }
 
   public function getActions() {
     return $this->actions;
   }
 
   public function saveConditions(array $conditions) {
     return $this->saveChildren(
       id(new HeraldCondition())->getTableName(),
       $conditions);
   }
 
   public function saveActions(array $actions) {
     return $this->saveChildren(
       id(new HeraldAction())->getTableName(),
       $actions);
   }
 
   protected function saveChildren($table_name, array $children) {
     if (!$this->getID()) {
       throw new Exception("Save rule before saving children.");
     }
 
     foreach ($children as $child) {
       $child->setRuleID($this->getID());
     }
 
 // TODO:
 //    $this->openTransaction();
       queryfx(
         $this->establishConnection('w'),
         'DELETE FROM %T WHERE ruleID = %d',
         $table_name,
         $this->getID());
       foreach ($children as $child) {
         $child->save();
       }
 //    $this->saveTransaction();
   }
 
   public function delete() {
 
 // TODO:
 //    $this->openTransaction();
       queryfx(
         $this->establishConnection('w'),
         'DELETE FROM %T WHERE ruleID = %d',
         id(new HeraldCondition())->getTableName(),
         $this->getID());
       queryfx(
         $this->establishConnection('w'),
         'DELETE FROM %T WHERE ruleID = %d',
         id(new HeraldAction())->getTableName(),
         $this->getID());
       parent::delete();
 //    $this->saveTransaction();
   }
 
 }