diff --git a/resources/celerity/map.php b/resources/celerity/map.php index c6a1269686..e4d5c8928c 100644 --- a/resources/celerity/map.php +++ b/resources/celerity/map.php @@ -9,14 +9,14 @@ return array( 'names' => array( 'conpherence.pkg.css' => '3c8a0668', 'conpherence.pkg.js' => '020aebcf', - 'core.pkg.css' => 'aa3df929', - 'core.pkg.js' => '5c737607', - 'differential.pkg.css' => 'b8df73d4', - 'differential.pkg.js' => '67c9ea4c', + 'core.pkg.css' => 'd81a600e', + 'core.pkg.js' => 'a747b035', + 'differential.pkg.css' => '8d8360fb', + 'differential.pkg.js' => '67e02996', 'diffusion.pkg.css' => '42c75c37', - 'diffusion.pkg.js' => '91192d85', + 'diffusion.pkg.js' => 'a98c0bf7', 'maniphest.pkg.css' => '35995d6d', - 'maniphest.pkg.js' => '286955ae', + 'maniphest.pkg.js' => 'c9308721', 'rsrc/audio/basic/alert.mp3' => '17889334', 'rsrc/audio/basic/bing.mp3' => 'a817a0c3', 'rsrc/audio/basic/pock.mp3' => '0fa843d0', @@ -30,7 +30,7 @@ return array( 'rsrc/css/aphront/notification.css' => '30240bd2', 'rsrc/css/aphront/panel-view.css' => '46923d46', 'rsrc/css/aphront/phabricator-nav-view.css' => 'f8a0c1bf', - 'rsrc/css/aphront/table-view.css' => '76eda3f8', + 'rsrc/css/aphront/table-view.css' => '7dc3a9c2', 'rsrc/css/aphront/tokenizer.css' => 'b52d0668', 'rsrc/css/aphront/tooltip.css' => 'e3f2412f', 'rsrc/css/aphront/typeahead-browse.css' => 'b7ed02d2', @@ -38,7 +38,7 @@ return array( 'rsrc/css/application/almanac/almanac.css' => '2e050f4f', 'rsrc/css/application/auth/auth.css' => 'add92fd8', 'rsrc/css/application/base/main-menu-view.css' => '69c1c2c1', - 'rsrc/css/application/base/notification-menu.css' => 'e6962e89', + 'rsrc/css/application/base/notification-menu.css' => '4df1ee30', 'rsrc/css/application/base/phui-theme.css' => '35883b37', 'rsrc/css/application/base/standard-page-view.css' => '8a295cb9', 'rsrc/css/application/chatlog/chatlog.css' => 'abdc76ee', @@ -46,7 +46,7 @@ return array( 'rsrc/css/application/config/config-options.css' => '16c920ae', 'rsrc/css/application/config/config-template.css' => '20babf50', 'rsrc/css/application/config/setup-issue.css' => '5eed85b2', - 'rsrc/css/application/config/unhandled-exception.css' => '9da8fdab', + 'rsrc/css/application/config/unhandled-exception.css' => '9ecfc00d', 'rsrc/css/application/conpherence/color.css' => 'b17746b0', 'rsrc/css/application/conpherence/durable-column.css' => '2d57072b', 'rsrc/css/application/conpherence/header-pane.css' => 'c9a3db8e', @@ -61,8 +61,8 @@ return array( 'rsrc/css/application/dashboard/dashboard.css' => '4267d6c6', 'rsrc/css/application/diff/inline-comment-summary.css' => '81eb368d', 'rsrc/css/application/differential/add-comment.css' => '7e5900d9', - 'rsrc/css/application/differential/changeset-view.css' => '73660575', - 'rsrc/css/application/differential/core.css' => 'bdb93065', + 'rsrc/css/application/differential/changeset-view.css' => 'bde53589', + 'rsrc/css/application/differential/core.css' => '7300a73e', 'rsrc/css/application/differential/phui-inline-comment.css' => '48acce5b', 'rsrc/css/application/differential/revision-comment.css' => '7dbc8d1d', 'rsrc/css/application/differential/revision-history.css' => '8aa3eac5', @@ -99,7 +99,8 @@ return array( 'rsrc/css/application/policy/policy-transaction-detail.css' => 'c02b8384', 'rsrc/css/application/policy/policy.css' => 'ceb56a08', 'rsrc/css/application/ponder/ponder-view.css' => '05a09d0a', - 'rsrc/css/application/project/project-card-view.css' => '3b1f7b20', + 'rsrc/css/application/project/project-card-view.css' => '4e7371cd', + 'rsrc/css/application/project/project-triggers.css' => 'cb866c2d', 'rsrc/css/application/project/project-view.css' => '567858b3', 'rsrc/css/application/releeph/releeph-core.css' => 'f81ff2db', 'rsrc/css/application/releeph/releeph-preview-branch.css' => '22db5c07', @@ -112,7 +113,7 @@ return array( 'rsrc/css/application/uiexample/example.css' => 'b4795059', 'rsrc/css/core/core.css' => '1b29ed61', 'rsrc/css/core/remarkup.css' => '9e627d41', - 'rsrc/css/core/syntax.css' => '8a16f91b', + 'rsrc/css/core/syntax.css' => '4234f572', 'rsrc/css/core/z-index.css' => '99c0f5eb', 'rsrc/css/diviner/diviner-shared.css' => '4bd263b0', 'rsrc/css/font/font-awesome.css' => '3883938a', @@ -127,13 +128,13 @@ return array( 'rsrc/css/phui/calendar/phui-calendar-list.css' => 'ccd7e4e2', 'rsrc/css/phui/calendar/phui-calendar-month.css' => 'cb758c42', 'rsrc/css/phui/calendar/phui-calendar.css' => 'f11073aa', - 'rsrc/css/phui/object-item/phui-oi-big-ui.css' => '9e037c7a', + 'rsrc/css/phui/object-item/phui-oi-big-ui.css' => '534f1757', 'rsrc/css/phui/object-item/phui-oi-color.css' => 'b517bfa0', 'rsrc/css/phui/object-item/phui-oi-drag-ui.css' => 'da15d3dc', 'rsrc/css/phui/object-item/phui-oi-flush-ui.css' => '490e2e2e', - 'rsrc/css/phui/object-item/phui-oi-list-view.css' => '909f3844', + 'rsrc/css/phui/object-item/phui-oi-list-view.css' => 'f14f2422', 'rsrc/css/phui/object-item/phui-oi-simple-ui.css' => '6a30fa46', - 'rsrc/css/phui/phui-action-list.css' => 'c1a7631d', + 'rsrc/css/phui/phui-action-list.css' => 'c4972757', 'rsrc/css/phui/phui-action-panel.css' => '6c386cbf', 'rsrc/css/phui/phui-badge.css' => '666e25ad', 'rsrc/css/phui/phui-basic-nav-view.css' => '56ebd66d', @@ -151,20 +152,20 @@ return array( 'rsrc/css/phui/phui-document.css' => '52b748a5', 'rsrc/css/phui/phui-feed-story.css' => 'a0c05029', 'rsrc/css/phui/phui-fontkit.css' => '9b714a5e', - 'rsrc/css/phui/phui-form-view.css' => '81158a04', + 'rsrc/css/phui/phui-form-view.css' => 'a8e0a1ab', 'rsrc/css/phui/phui-form.css' => '159e2d9c', 'rsrc/css/phui/phui-head-thing.css' => 'd7f293df', - 'rsrc/css/phui/phui-header-view.css' => '93cea4ec', + 'rsrc/css/phui/phui-header-view.css' => '285c9139', 'rsrc/css/phui/phui-hovercard.css' => '6ca90fa0', 'rsrc/css/phui/phui-icon-set-selector.css' => '7aa5f3ec', - 'rsrc/css/phui/phui-icon.css' => '281f964d', + 'rsrc/css/phui/phui-icon.css' => '4cbc684a', 'rsrc/css/phui/phui-image-mask.css' => '62c7f4d2', 'rsrc/css/phui/phui-info-view.css' => '37b8d9ce', 'rsrc/css/phui/phui-invisible-character-view.css' => 'c694c4a4', 'rsrc/css/phui/phui-left-right.css' => '68513c34', 'rsrc/css/phui/phui-lightbox.css' => '4ebf22da', 'rsrc/css/phui/phui-list.css' => '470b1adb', - 'rsrc/css/phui/phui-object-box.css' => '9b58483d', + 'rsrc/css/phui/phui-object-box.css' => 'f434b6be', 'rsrc/css/phui/phui-pager.css' => 'd022c7ad', 'rsrc/css/phui/phui-pinboard-view.css' => '1f08f5d8', 'rsrc/css/phui/phui-property-list-view.css' => 'cad62236', @@ -172,13 +173,13 @@ return array( 'rsrc/css/phui/phui-segment-bar-view.css' => '5166b370', 'rsrc/css/phui/phui-spacing.css' => 'b05cadc3', 'rsrc/css/phui/phui-status.css' => 'e5ff8be0', - 'rsrc/css/phui/phui-tag-view.css' => 'a42fe34f', + 'rsrc/css/phui/phui-tag-view.css' => '29409667', 'rsrc/css/phui/phui-timeline-view.css' => '1e348e4b', 'rsrc/css/phui/phui-two-column-view.css' => '01e6991e', 'rsrc/css/phui/workboards/phui-workboard-color.css' => 'e86de308', 'rsrc/css/phui/workboards/phui-workboard.css' => '74fc9d98', - 'rsrc/css/phui/workboards/phui-workcard.css' => '8c536f90', - 'rsrc/css/phui/workboards/phui-workpanel.css' => 'bd546a49', + 'rsrc/css/phui/workboards/phui-workcard.css' => '9e9eb0df', + 'rsrc/css/phui/workboards/phui-workpanel.css' => '3ae89b20', 'rsrc/css/sprite-login.css' => '18b368a6', 'rsrc/css/sprite-tokens.css' => 'f1896dc5', 'rsrc/css/syntax/syntax-default.css' => '055fc231', @@ -250,7 +251,7 @@ return array( 'rsrc/externals/javelin/lib/Routable.js' => '6a18c42e', 'rsrc/externals/javelin/lib/Router.js' => '32755edb', 'rsrc/externals/javelin/lib/Scrollbar.js' => 'a43ae2ae', - 'rsrc/externals/javelin/lib/Sound.js' => 'e562708c', + 'rsrc/externals/javelin/lib/Sound.js' => 'd4cc2d2a', 'rsrc/externals/javelin/lib/URI.js' => '2e255291', 'rsrc/externals/javelin/lib/Vector.js' => 'e9c80beb', 'rsrc/externals/javelin/lib/WebSocket.js' => 'fdc13e4e', @@ -277,6 +278,8 @@ return array( 'rsrc/image/checker_dark.png' => '7fc8fa7b', 'rsrc/image/checker_light.png' => '3157a202', 'rsrc/image/checker_lighter.png' => 'c45928c1', + 'rsrc/image/chevron-in.png' => '1aa2f88f', + 'rsrc/image/chevron-out.png' => 'c815e272', 'rsrc/image/controls/checkbox-checked.png' => '1770d7a0', 'rsrc/image/controls/checkbox-unchecked.png' => 'e1deba0a', 'rsrc/image/d5d8e1.png' => '6764616e', @@ -374,17 +377,16 @@ return array( 'rsrc/js/application/dashboard/behavior-dashboard-move-panels.js' => '076bd092', 'rsrc/js/application/dashboard/behavior-dashboard-query-panel-select.js' => '1e413dc9', 'rsrc/js/application/dashboard/behavior-dashboard-tab-panel.js' => '9b1cbd76', - 'rsrc/js/application/diff/DiffChangeset.js' => 'e7cf10d6', - 'rsrc/js/application/diff/DiffChangesetList.js' => 'b91204e9', + 'rsrc/js/application/diff/DiffChangeset.js' => 'd0a85a85', + 'rsrc/js/application/diff/DiffChangesetList.js' => '04023d82', 'rsrc/js/application/diff/DiffInline.js' => 'a4a14a94', 'rsrc/js/application/diff/behavior-preview-link.js' => 'f51e9c17', 'rsrc/js/application/differential/behavior-diff-radios.js' => '925fe8cd', 'rsrc/js/application/differential/behavior-populate.js' => 'dfa1d313', - 'rsrc/js/application/differential/behavior-user-select.js' => 'e18685c0', 'rsrc/js/application/diffusion/DiffusionLocateFileSource.js' => '94243d89', 'rsrc/js/application/diffusion/behavior-audit-preview.js' => 'b7b73831', 'rsrc/js/application/diffusion/behavior-commit-branches.js' => '4b671572', - 'rsrc/js/application/diffusion/behavior-commit-graph.js' => '1c88f154', + 'rsrc/js/application/diffusion/behavior-commit-graph.js' => 'ef836bf2', 'rsrc/js/application/diffusion/behavior-locate-file.js' => '87428eb2', 'rsrc/js/application/diffusion/behavior-pull-lastmodified.js' => 'c715c123', 'rsrc/js/application/doorkeeper/behavior-doorkeeper-tag.js' => '6a85bc5a', @@ -396,10 +398,9 @@ return array( 'rsrc/js/application/herald/HeraldRuleEditor.js' => '27daef73', 'rsrc/js/application/herald/PathTypeahead.js' => 'ad486db3', 'rsrc/js/application/herald/herald-rule-editor.js' => '0922e81d', - 'rsrc/js/application/maniphest/behavior-batch-selector.js' => 'cffd39b4', + 'rsrc/js/application/maniphest/behavior-batch-selector.js' => '139ef688', 'rsrc/js/application/maniphest/behavior-line-chart.js' => 'c8147a20', 'rsrc/js/application/maniphest/behavior-list-edit.js' => 'c687e867', - 'rsrc/js/application/maniphest/behavior-subpriorityeditor.js' => '8400307c', 'rsrc/js/application/owners/OwnersPathEditor.js' => '2a8b62d9', 'rsrc/js/application/owners/owners-path-editor.js' => 'ff688a7a', 'rsrc/js/application/passphrase/passphrase-credential-control.js' => '48fe33d0', @@ -410,17 +411,22 @@ return array( 'rsrc/js/application/phortune/phortune-credit-card-form.js' => 'd12d214f', 'rsrc/js/application/policy/behavior-policy-control.js' => '0eaa33a9', 'rsrc/js/application/policy/behavior-policy-rule-editor.js' => '9347f172', - 'rsrc/js/application/projects/WorkboardBoard.js' => '45d0b2b1', - 'rsrc/js/application/projects/WorkboardCard.js' => '9a513421', - 'rsrc/js/application/projects/WorkboardColumn.js' => '8573dc1b', + 'rsrc/js/application/projects/WorkboardBoard.js' => 'c02a5497', + 'rsrc/js/application/projects/WorkboardCard.js' => '0392a5d8', + 'rsrc/js/application/projects/WorkboardCardTemplate.js' => '2a61f8d4', + 'rsrc/js/application/projects/WorkboardColumn.js' => 'c3d24e63', 'rsrc/js/application/projects/WorkboardController.js' => '42c7a5a7', - 'rsrc/js/application/projects/behavior-project-boards.js' => '05c74d65', + 'rsrc/js/application/projects/WorkboardDropEffect.js' => '8e0aa661', + 'rsrc/js/application/projects/WorkboardHeader.js' => '111bfd2d', + 'rsrc/js/application/projects/WorkboardHeaderTemplate.js' => 'ebe83a6b', + 'rsrc/js/application/projects/WorkboardOrderTemplate.js' => '03e8891f', + 'rsrc/js/application/projects/behavior-project-boards.js' => 'aad45445', 'rsrc/js/application/projects/behavior-project-create.js' => '34c53422', 'rsrc/js/application/projects/behavior-reorder-columns.js' => '8ac32fd9', 'rsrc/js/application/releeph/releeph-preview-branch.js' => '75184d68', 'rsrc/js/application/releeph/releeph-request-state-change.js' => '9f081f05', 'rsrc/js/application/releeph/releeph-request-typeahead.js' => 'aa3a100c', - 'rsrc/js/application/repository/repository-crossreference.js' => 'db0c0214', + 'rsrc/js/application/repository/repository-crossreference.js' => 'c15122b4', 'rsrc/js/application/search/behavior-reorder-profile-menu-items.js' => 'e5bdb730', 'rsrc/js/application/search/behavior-reorder-queries.js' => 'b86f297f', 'rsrc/js/application/transactions/behavior-comment-actions.js' => '4dffaeb2', @@ -429,13 +435,18 @@ return array( 'rsrc/js/application/transactions/behavior-show-older-transactions.js' => '600f440c', 'rsrc/js/application/transactions/behavior-transaction-comment-form.js' => '2bdadf1a', 'rsrc/js/application/transactions/behavior-transaction-list.js' => '9cec214e', + 'rsrc/js/application/trigger/TriggerRule.js' => '1c60c3fc', + 'rsrc/js/application/trigger/TriggerRuleControl.js' => '5faf27b9', + 'rsrc/js/application/trigger/TriggerRuleEditor.js' => 'b49fd60c', + 'rsrc/js/application/trigger/TriggerRuleType.js' => '4feea7d3', + 'rsrc/js/application/trigger/trigger-rule-editor.js' => '398fdf13', 'rsrc/js/application/typeahead/behavior-typeahead-browse.js' => '70245195', 'rsrc/js/application/typeahead/behavior-typeahead-search.js' => '7b139193', 'rsrc/js/application/uiexample/gesture-example.js' => '242dedd0', 'rsrc/js/application/uiexample/notification-example.js' => '29819b75', 'rsrc/js/core/Busy.js' => '5202e831', 'rsrc/js/core/DragAndDropFileUpload.js' => '4370900d', - 'rsrc/js/core/DraggableList.js' => '3c6bd549', + 'rsrc/js/core/DraggableList.js' => 'c9ad6f70', 'rsrc/js/core/Favicon.js' => '7930776a', 'rsrc/js/core/FileUpload.js' => 'ab85e184', 'rsrc/js/core/Hovercard.js' => '074f0783', @@ -443,7 +454,7 @@ return array( 'rsrc/js/core/KeyboardShortcutManager.js' => '37b8a04a', 'rsrc/js/core/MultirowRowManager.js' => '5b54c823', 'rsrc/js/core/Notification.js' => 'a9b91e3f', - 'rsrc/js/core/Prefab.js' => 'bf457520', + 'rsrc/js/core/Prefab.js' => '5793d835', 'rsrc/js/core/ShapedRequest.js' => 'abf88db8', 'rsrc/js/core/TextAreaUtils.js' => 'f340a484', 'rsrc/js/core/Title.js' => '43bc9360', @@ -473,7 +484,7 @@ return array( 'rsrc/js/core/behavior-linked-container.js' => '74446546', 'rsrc/js/core/behavior-more.js' => '506aa3f4', 'rsrc/js/core/behavior-object-selector.js' => 'a4af0b4a', - 'rsrc/js/core/behavior-oncopy.js' => '418f6684', + 'rsrc/js/core/behavior-oncopy.js' => 'ff7b3f22', 'rsrc/js/core/behavior-phabricator-nav.js' => 'f166c949', 'rsrc/js/core/behavior-phabricator-remarkup-assist.js' => '2f80333f', 'rsrc/js/core/behavior-read-only-warning.js' => 'b9109f8f', @@ -489,7 +500,7 @@ return array( 'rsrc/js/core/behavior-select-on-click.js' => '66365ee2', 'rsrc/js/core/behavior-setup-check-https.js' => '01384686', 'rsrc/js/core/behavior-time-typeahead.js' => '5803b9e7', - 'rsrc/js/core/behavior-toggle-class.js' => 'f5c78ae3', + 'rsrc/js/core/behavior-toggle-class.js' => '32db8374', 'rsrc/js/core/behavior-tokenizer.js' => '3b4899b0', 'rsrc/js/core/behavior-tooltip.js' => '73ecc1f8', 'rsrc/js/core/behavior-user-menu.js' => '60cd9241', @@ -504,9 +515,10 @@ return array( 'rsrc/js/phui/behavior-phui-selectable-list.js' => 'b26a41e4', 'rsrc/js/phui/behavior-phui-submenu.js' => 'b5e9bff9', 'rsrc/js/phui/behavior-phui-tab-group.js' => '242aa08b', + 'rsrc/js/phui/behavior-phui-timer-control.js' => 'f84bcbf4', 'rsrc/js/phuix/PHUIXActionListView.js' => 'c68f183f', 'rsrc/js/phuix/PHUIXActionView.js' => 'aaa08f3b', - 'rsrc/js/phuix/PHUIXAutocomplete.js' => '58cc4ab8', + 'rsrc/js/phuix/PHUIXAutocomplete.js' => '2fbe234d', 'rsrc/js/phuix/PHUIXButtonView.js' => '55a24e84', 'rsrc/js/phuix/PHUIXDropdownMenu.js' => 'bdce4d78', 'rsrc/js/phuix/PHUIXExample.js' => 'c2c500a7', @@ -521,7 +533,7 @@ return array( 'aphront-list-filter-view-css' => 'feb64255', 'aphront-multi-column-view-css' => 'fbc00ba3', 'aphront-panel-view-css' => '46923d46', - 'aphront-table-view-css' => '76eda3f8', + 'aphront-table-view-css' => '7dc3a9c2', 'aphront-tokenizer-control-css' => 'b52d0668', 'aphront-tooltip-css' => 'e3f2412f', 'aphront-typeahead-control-css' => '8779483d', @@ -540,8 +552,8 @@ return array( 'conpherence-thread-manager' => 'aec8e38c', 'conpherence-transaction-css' => '3a3f5e7e', 'd3' => 'd67475f5', - 'differential-changeset-view-css' => '73660575', - 'differential-core-view-css' => 'bdb93065', + 'differential-changeset-view-css' => 'bde53589', + 'differential-core-view-css' => '7300a73e', 'differential-revision-add-comment-css' => '7e5900d9', 'differential-revision-comment-css' => '7dbc8d1d', 'differential-revision-history-css' => '8aa3eac5', @@ -595,9 +607,8 @@ return array( 'javelin-behavior-diff-preview-link' => 'f51e9c17', 'javelin-behavior-differential-diff-radios' => '925fe8cd', 'javelin-behavior-differential-populate' => 'dfa1d313', - 'javelin-behavior-differential-user-select' => 'e18685c0', 'javelin-behavior-diffusion-commit-branches' => '4b671572', - 'javelin-behavior-diffusion-commit-graph' => '1c88f154', + 'javelin-behavior-diffusion-commit-graph' => 'ef836bf2', 'javelin-behavior-diffusion-locate-file' => '87428eb2', 'javelin-behavior-diffusion-pull-lastmodified' => 'c715c123', 'javelin-behavior-document-engine' => '243d6c22', @@ -618,9 +629,8 @@ return array( 'javelin-behavior-lightbox-attachments' => 'c7e748bf', 'javelin-behavior-line-chart' => 'c8147a20', 'javelin-behavior-linked-container' => '74446546', - 'javelin-behavior-maniphest-batch-selector' => 'cffd39b4', + 'javelin-behavior-maniphest-batch-selector' => '139ef688', 'javelin-behavior-maniphest-list-editor' => 'c687e867', - 'javelin-behavior-maniphest-subpriority-editor' => '8400307c', 'javelin-behavior-owners-path-editor' => 'ff688a7a', 'javelin-behavior-passphrase-credential-control' => '48fe33d0', 'javelin-behavior-phabricator-active-nav' => '7353f43d', @@ -635,7 +645,7 @@ return array( 'javelin-behavior-phabricator-nav' => 'f166c949', 'javelin-behavior-phabricator-notification-example' => '29819b75', 'javelin-behavior-phabricator-object-selector' => 'a4af0b4a', - 'javelin-behavior-phabricator-oncopy' => '418f6684', + 'javelin-behavior-phabricator-oncopy' => 'ff7b3f22', 'javelin-behavior-phabricator-remarkup-assist' => '2f80333f', 'javelin-behavior-phabricator-reveal-content' => 'b105a3a6', 'javelin-behavior-phabricator-search-typeahead' => '1cb7d027', @@ -652,10 +662,11 @@ return array( 'javelin-behavior-phui-selectable-list' => 'b26a41e4', 'javelin-behavior-phui-submenu' => 'b5e9bff9', 'javelin-behavior-phui-tab-group' => '242aa08b', + 'javelin-behavior-phui-timer-control' => 'f84bcbf4', 'javelin-behavior-phuix-example' => 'c2c500a7', 'javelin-behavior-policy-control' => '0eaa33a9', 'javelin-behavior-policy-rule-editor' => '9347f172', - 'javelin-behavior-project-boards' => '05c74d65', + 'javelin-behavior-project-boards' => 'aad45445', 'javelin-behavior-project-create' => '34c53422', 'javelin-behavior-quicksand-blacklist' => '5a6f6a06', 'javelin-behavior-read-only-warning' => 'b9109f8f', @@ -669,7 +680,7 @@ return array( 'javelin-behavior-reorder-applications' => 'aa371860', 'javelin-behavior-reorder-columns' => '8ac32fd9', 'javelin-behavior-reorder-profile-menu-items' => 'e5bdb730', - 'javelin-behavior-repository-crossreference' => 'db0c0214', + 'javelin-behavior-repository-crossreference' => 'c15122b4', 'javelin-behavior-scrollbar' => '92388bae', 'javelin-behavior-search-reorder-queries' => 'b86f297f', 'javelin-behavior-select-content' => 'e8240b50', @@ -678,8 +689,9 @@ return array( 'javelin-behavior-stripe-payment-form' => '02cb4398', 'javelin-behavior-test-payment-form' => '4a7fb02b', 'javelin-behavior-time-typeahead' => '5803b9e7', - 'javelin-behavior-toggle-class' => 'f5c78ae3', + 'javelin-behavior-toggle-class' => '32db8374', 'javelin-behavior-toggle-widget' => '8f959ad0', + 'javelin-behavior-trigger-rule-editor' => '398fdf13', 'javelin-behavior-typeahead-browse' => '70245195', 'javelin-behavior-typeahead-search' => '7b139193', 'javelin-behavior-user-menu' => '60cd9241', @@ -708,7 +720,7 @@ return array( 'javelin-routable' => '6a18c42e', 'javelin-router' => '32755edb', 'javelin-scrollbar' => 'a43ae2ae', - 'javelin-sound' => 'e562708c', + 'javelin-sound' => 'd4cc2d2a', 'javelin-stratcom' => '0889b835', 'javelin-tokenizer' => '89a1ae3a', 'javelin-typeahead' => 'a4356cde', @@ -727,10 +739,15 @@ return array( 'javelin-view-renderer' => '9aae2b66', 'javelin-view-visitor' => '308f9fe4', 'javelin-websocket' => 'fdc13e4e', - 'javelin-workboard-board' => '45d0b2b1', - 'javelin-workboard-card' => '9a513421', - 'javelin-workboard-column' => '8573dc1b', + 'javelin-workboard-board' => 'c02a5497', + 'javelin-workboard-card' => '0392a5d8', + 'javelin-workboard-card-template' => '2a61f8d4', + 'javelin-workboard-column' => 'c3d24e63', 'javelin-workboard-controller' => '42c7a5a7', + 'javelin-workboard-drop-effect' => '8e0aa661', + 'javelin-workboard-header' => '111bfd2d', + 'javelin-workboard-header-template' => 'ebe83a6b', + 'javelin-workboard-order-template' => '03e8891f', 'javelin-workflow' => '958e9045', 'maniphest-report-css' => '3d53188b', 'maniphest-task-edit-css' => '272daa84', @@ -742,7 +759,7 @@ return array( 'path-typeahead' => 'ad486db3', 'people-picture-menu-item-css' => 'fe8e07cf', 'people-profile-css' => '2ea2daa1', - 'phabricator-action-list-view-css' => 'c1a7631d', + 'phabricator-action-list-view-css' => 'c4972757', 'phabricator-busy' => '5202e831', 'phabricator-chatlog-css' => 'abdc76ee', 'phabricator-content-source-view-css' => 'cdf0d579', @@ -751,11 +768,11 @@ return array( 'phabricator-darklog' => '3b869402', 'phabricator-darkmessage' => '26cd4b73', 'phabricator-dashboard-css' => '4267d6c6', - 'phabricator-diff-changeset' => 'e7cf10d6', - 'phabricator-diff-changeset-list' => 'b91204e9', + 'phabricator-diff-changeset' => 'd0a85a85', + 'phabricator-diff-changeset-list' => '04023d82', 'phabricator-diff-inline' => 'a4a14a94', 'phabricator-drag-and-drop-file-upload' => '4370900d', - 'phabricator-draggable-list' => '3c6bd549', + 'phabricator-draggable-list' => 'c9ad6f70', 'phabricator-fatal-config-template-css' => '20babf50', 'phabricator-favicon' => '7930776a', 'phabricator-feed-css' => 'd8b6e3f8', @@ -768,10 +785,10 @@ return array( 'phabricator-nav-view-css' => 'f8a0c1bf', 'phabricator-notification' => 'a9b91e3f', 'phabricator-notification-css' => '30240bd2', - 'phabricator-notification-menu-css' => 'e6962e89', + 'phabricator-notification-menu-css' => '4df1ee30', 'phabricator-object-selector-css' => 'ee77366f', 'phabricator-phtize' => '2f1db1ed', - 'phabricator-prefab' => 'bf457520', + 'phabricator-prefab' => '5793d835', 'phabricator-remarkup-css' => '9e627d41', 'phabricator-search-results-css' => '9ea70ace', 'phabricator-shaped-request' => 'abf88db8', @@ -820,13 +837,13 @@ return array( 'phui-font-icon-base-css' => 'd7994e06', 'phui-fontkit-css' => '9b714a5e', 'phui-form-css' => '159e2d9c', - 'phui-form-view-css' => '81158a04', + 'phui-form-view-css' => 'a8e0a1ab', 'phui-head-thing-view-css' => 'd7f293df', - 'phui-header-view-css' => '93cea4ec', + 'phui-header-view-css' => '285c9139', 'phui-hovercard' => '074f0783', 'phui-hovercard-view-css' => '6ca90fa0', 'phui-icon-set-selector-css' => '7aa5f3ec', - 'phui-icon-view-css' => '281f964d', + 'phui-icon-view-css' => '4cbc684a', 'phui-image-mask-css' => '62c7f4d2', 'phui-info-view-css' => '37b8d9ce', 'phui-inline-comment-view-css' => '48acce5b', @@ -834,12 +851,12 @@ return array( 'phui-left-right-css' => '68513c34', 'phui-lightbox-css' => '4ebf22da', 'phui-list-view-css' => '470b1adb', - 'phui-object-box-css' => '9b58483d', - 'phui-oi-big-ui-css' => '9e037c7a', + 'phui-object-box-css' => 'f434b6be', + 'phui-oi-big-ui-css' => '534f1757', 'phui-oi-color-css' => 'b517bfa0', 'phui-oi-drag-ui-css' => 'da15d3dc', 'phui-oi-flush-ui-css' => '490e2e2e', - 'phui-oi-list-view-css' => '909f3844', + 'phui-oi-list-view-css' => 'f14f2422', 'phui-oi-simple-ui-css' => '6a30fa46', 'phui-pager-css' => 'd022c7ad', 'phui-pinboard-view-css' => '1f08f5d8', @@ -848,17 +865,17 @@ return array( 'phui-segment-bar-view-css' => '5166b370', 'phui-spacing-css' => 'b05cadc3', 'phui-status-list-view-css' => 'e5ff8be0', - 'phui-tag-view-css' => 'a42fe34f', + 'phui-tag-view-css' => '29409667', 'phui-theme-css' => '35883b37', 'phui-timeline-view-css' => '1e348e4b', 'phui-two-column-view-css' => '01e6991e', 'phui-workboard-color-css' => 'e86de308', 'phui-workboard-view-css' => '74fc9d98', - 'phui-workcard-view-css' => '8c536f90', - 'phui-workpanel-view-css' => 'bd546a49', + 'phui-workcard-view-css' => '9e9eb0df', + 'phui-workpanel-view-css' => '3ae89b20', 'phuix-action-list-view' => 'c68f183f', 'phuix-action-view' => 'aaa08f3b', - 'phuix-autocomplete' => '58cc4ab8', + 'phuix-autocomplete' => '2fbe234d', 'phuix-button-view' => '55a24e84', 'phuix-dropdown-menu' => 'bdce4d78', 'phuix-form-control-view' => '38c1f3fb', @@ -867,7 +884,8 @@ return array( 'policy-edit-css' => '8794e2ed', 'policy-transaction-detail-css' => 'c02b8384', 'ponder-view-css' => '05a09d0a', - 'project-card-view-css' => '3b1f7b20', + 'project-card-view-css' => '4e7371cd', + 'project-triggers-css' => 'cb866c2d', 'project-view-css' => '567858b3', 'releeph-core' => 'f81ff2db', 'releeph-preview-branch' => '22db5c07', @@ -877,10 +895,14 @@ return array( 'sprite-login-css' => '18b368a6', 'sprite-tokens-css' => 'f1896dc5', 'syntax-default-css' => '055fc231', - 'syntax-highlighting-css' => '8a16f91b', + 'syntax-highlighting-css' => '4234f572', 'tokens-css' => 'ce5a50bd', + 'trigger-rule' => '1c60c3fc', + 'trigger-rule-control' => '5faf27b9', + 'trigger-rule-editor' => 'b49fd60c', + 'trigger-rule-type' => '4feea7d3', 'typeahead-browse-css' => 'b7ed02d2', - 'unhandled-exception-css' => '9da8fdab', + 'unhandled-exception-css' => '9ecfc00d', ), 'requires' => array( '01384686' => array( @@ -906,21 +928,22 @@ return array( 'javelin-uri', 'javelin-util', ), + '0392a5d8' => array( + 'javelin-install', + ), + '03e8891f' => array( + 'javelin-install', + ), + '04023d82' => array( + 'javelin-install', + 'phuix-button-view', + ), '04f8a1e3' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', 'javelin-workflow', ), - '05c74d65' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-util', - 'javelin-vector', - 'javelin-stratcom', - 'javelin-workflow', - 'javelin-workboard-controller', - ), '05d290ef' => array( 'javelin-install', 'javelin-util', @@ -995,19 +1018,23 @@ return array( 'javelin-workflow', 'phuix-icon-view', ), + '111bfd2d' => array( + 'javelin-install', + ), '1325b731' => array( 'javelin-behavior', 'javelin-uri', 'phabricator-keyboard-shortcut', ), - '1c850a26' => array( - 'javelin-install', - 'javelin-util', - ), - '1c88f154' => array( + '139ef688' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', + 'javelin-util', + ), + '1c850a26' => array( + 'javelin-install', + 'javelin-util', ), '1cab0e9a' => array( 'javelin-behavior', @@ -1104,6 +1131,9 @@ return array( 'javelin-stratcom', 'javelin-behavior', ), + '2a61f8d4' => array( + 'javelin-install', + ), '2a8b62d9' => array( 'multirow-row-manager', 'javelin-install', @@ -1146,6 +1176,12 @@ return array( 'phuix-autocomplete', 'javelin-mask', ), + '2fbe234d' => array( + 'javelin-install', + 'javelin-dom', + 'phuix-icon-view', + 'phabricator-prefab', + ), '308f9fe4' => array( 'javelin-install', 'javelin-util', @@ -1154,6 +1190,11 @@ return array( 'javelin-install', 'javelin-util', ), + '32db8374' => array( + 'javelin-behavior', + 'javelin-stratcom', + 'javelin-dom', + ), 34450586 => array( 'javelin-color', 'javelin-install', @@ -1185,18 +1226,19 @@ return array( 'javelin-install', 'javelin-dom', ), + '398fdf13' => array( + 'javelin-behavior', + 'trigger-rule-editor', + 'trigger-rule', + 'trigger-rule-type', + ), + '3ae89b20' => array( + 'phui-workcard-view-css', + ), '3b4899b0' => array( 'javelin-behavior', 'phabricator-prefab', ), - '3c6bd549' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-stratcom', - 'javelin-util', - 'javelin-vector', - 'javelin-magical-init', - ), '3dc5ad43' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1217,9 +1259,8 @@ return array( 'javelin-behavior', 'javelin-uri', ), - '418f6684' => array( - 'javelin-behavior', - 'javelin-dom', + '4234f572' => array( + 'syntax-default-css', ), '42c7a5a7' => array( 'javelin-install', @@ -1251,15 +1292,6 @@ return array( '43bc9360' => array( 'javelin-install', ), - '45d0b2b1' => array( - 'javelin-install', - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - 'javelin-workboard-column', - ), '46116c01' => array( 'javelin-request', 'javelin-behavior', @@ -1333,6 +1365,9 @@ return array( 'javelin-sound', 'phabricator-notification', ), + '4feea7d3' => array( + 'trigger-rule-control', + ), '506aa3f4' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1343,6 +1378,9 @@ return array( 'javelin-dom', 'javelin-fx', ), + '534f1757' => array( + 'phui-oi-list-view-css', + ), '541f81c3' => array( 'javelin-install', ), @@ -1355,6 +1393,18 @@ return array( 'javelin-stratcom', 'javelin-dom', ), + '5793d835' => array( + 'javelin-install', + 'javelin-util', + 'javelin-dom', + 'javelin-typeahead', + 'javelin-tokenizer', + 'javelin-typeahead-preloaded-source', + 'javelin-typeahead-ondemand-source', + 'javelin-dom', + 'javelin-stratcom', + 'javelin-util', + ), '5803b9e7' => array( 'javelin-behavior', 'javelin-util', @@ -1363,12 +1413,6 @@ return array( 'javelin-vector', 'javelin-typeahead-static-source', ), - '58cc4ab8' => array( - 'javelin-install', - 'javelin-dom', - 'phuix-icon-view', - 'phabricator-prefab', - ), '5902260c' => array( 'javelin-util', 'javelin-magical-init', @@ -1409,6 +1453,9 @@ return array( 'javelin-dom', 'phuix-dropdown-menu', ), + '5faf27b9' => array( + 'phuix-form-control-view', + ), '600f440c' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1488,9 +1535,6 @@ return array( 'javelin-dom', 'javelin-uri', ), - 73660575 => array( - 'phui-inline-comment-view-css', - ), '73ecc1f8' => array( 'javelin-behavior', 'javelin-behavior-device', @@ -1550,17 +1594,6 @@ return array( 'javelin-dom', 'javelin-vector', ), - '8400307c' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-stratcom', - 'javelin-workflow', - 'phabricator-draggable-list', - ), - '8573dc1b' => array( - 'javelin-install', - 'javelin-workboard-card', - ), '87428eb2' => array( 'javelin-behavior', 'javelin-diffusion-locate-file-source', @@ -1579,9 +1612,6 @@ return array( 'javelin-stratcom', 'javelin-install', ), - '8a16f91b' => array( - 'syntax-default-css', - ), '8ac32fd9' => array( 'javelin-behavior', 'javelin-stratcom', @@ -1609,6 +1639,10 @@ return array( 'phabricator-shaped-request', 'conpherence-thread-manager', ), + '8e0aa661' => array( + 'javelin-install', + 'javelin-dom', + ), '8f959ad0' => array( 'javelin-behavior', 'javelin-dom', @@ -1693,9 +1727,6 @@ return array( 'javelin-dom', 'javelin-router', ), - '9a513421' => array( - 'javelin-install', - ), '9aae2b66' => array( 'javelin-install', 'javelin-util', @@ -1713,9 +1744,6 @@ return array( 'javelin-uri', 'phabricator-textareautils', ), - '9e037c7a' => array( - 'phui-oi-list-view-css', - ), '9f081f05' => array( 'javelin-behavior', 'javelin-dom', @@ -1801,6 +1829,16 @@ return array( 'javelin-dom', 'javelin-util', ), + 'aad45445' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-util', + 'javelin-vector', + 'javelin-stratcom', + 'javelin-workflow', + 'javelin-workboard-controller', + 'javelin-workboard-drop-effect', + ), 'ab85e184' => array( 'javelin-install', 'javelin-dom', @@ -1844,6 +1882,10 @@ return array( 'b347a301' => array( 'javelin-behavior', ), + 'b49fd60c' => array( + 'multirow-row-manager', + 'trigger-rule', + ), 'b517bfa0' => array( 'phui-oi-list-view-css', ), @@ -1882,13 +1924,6 @@ return array( 'javelin-uri', 'phabricator-notification', ), - 'b91204e9' => array( - 'javelin-install', - 'phuix-button-view', - ), - 'bd546a49' => array( - 'phui-workcard-view-css', - ), 'bdce4d78' => array( 'javelin-install', 'javelin-util', @@ -1896,21 +1931,30 @@ return array( 'javelin-vector', 'javelin-stratcom', ), - 'bf457520' => array( + 'bde53589' => array( + 'phui-inline-comment-view-css', + ), + 'c02a5497' => array( 'javelin-install', + 'javelin-dom', 'javelin-util', - 'javelin-dom', - 'javelin-typeahead', - 'javelin-tokenizer', - 'javelin-typeahead-preloaded-source', - 'javelin-typeahead-ondemand-source', - 'javelin-dom', 'javelin-stratcom', - 'javelin-util', + 'javelin-workflow', + 'phabricator-draggable-list', + 'javelin-workboard-column', + 'javelin-workboard-header-template', + 'javelin-workboard-card-template', + 'javelin-workboard-order-template', ), 'c03f2fb4' => array( 'javelin-install', ), + 'c15122b4' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-stratcom', + 'javelin-uri', + ), 'c2c500a7' => array( 'javelin-install', 'javelin-dom', @@ -1922,6 +1966,11 @@ return array( 'phabricator-phtize', 'javelin-dom', ), + 'c3d24e63' => array( + 'javelin-install', + 'javelin-workboard-card', + 'javelin-workboard-header', + ), 'c687e867' => array( 'javelin-behavior', 'javelin-dom', @@ -1961,16 +2010,29 @@ return array( 'javelin-util', 'phabricator-keyboard-shortcut-manager', ), + 'c9ad6f70' => array( + 'javelin-install', + 'javelin-dom', + 'javelin-stratcom', + 'javelin-util', + 'javelin-vector', + 'javelin-magical-init', + ), 'cf32921f' => array( 'javelin-behavior', 'javelin-dom', 'javelin-stratcom', ), - 'cffd39b4' => array( - 'javelin-behavior', + 'd0a85a85' => array( 'javelin-dom', - 'javelin-stratcom', 'javelin-util', + 'javelin-stratcom', + 'javelin-install', + 'javelin-workflow', + 'javelin-router', + 'javelin-behavior-device', + 'javelin-vector', + 'phabricator-diff-inline', ), 'd12d214f' => array( 'javelin-install', @@ -1982,6 +2044,9 @@ return array( 'd3799cb4' => array( 'javelin-install', ), + 'd4cc2d2a' => array( + 'javelin-install', + ), 'd8a86cfb' => array( 'javelin-behavior', 'javelin-dom', @@ -1996,12 +2061,6 @@ return array( 'javelin-uri', 'phabricator-notification', ), - 'db0c0214' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-stratcom', - 'javelin-uri', - ), 'dfa1d313' => array( 'javelin-behavior', 'javelin-dom', @@ -2022,14 +2081,6 @@ return array( 'javelin-dom', 'javelin-history', ), - 'e18685c0' => array( - 'javelin-behavior', - 'javelin-dom', - 'javelin-stratcom', - ), - 'e562708c' => array( - 'javelin-install', - ), 'e5bdb730' => array( 'javelin-behavior', 'javelin-stratcom', @@ -2037,17 +2088,6 @@ return array( 'javelin-dom', 'phabricator-draggable-list', ), - 'e7cf10d6' => array( - 'javelin-dom', - 'javelin-util', - 'javelin-stratcom', - 'javelin-install', - 'javelin-workflow', - 'javelin-router', - 'javelin-behavior-device', - 'javelin-vector', - 'phabricator-diff-inline', - ), 'e8240b50' => array( 'javelin-behavior', 'javelin-stratcom', @@ -2068,6 +2108,9 @@ return array( 'javelin-install', 'javelin-event', ), + 'ebe83a6b' => array( + 'javelin-install', + ), 'ec4e31c0' => array( 'phui-timeline-view-css', ), @@ -2079,6 +2122,11 @@ return array( 'phabricator-keyboard-shortcut', 'javelin-stratcom', ), + 'ef836bf2' => array( + 'javelin-behavior', + 'javelin-dom', + 'javelin-stratcom', + ), 'f166c949' => array( 'javelin-behavior', 'javelin-behavior-device', @@ -2109,7 +2157,7 @@ return array( 'javelin-stratcom', 'javelin-dom', ), - 'f5c78ae3' => array( + 'f84bcbf4' => array( 'javelin-behavior', 'javelin-stratcom', 'javelin-dom', @@ -2141,6 +2189,10 @@ return array( 'owners-path-editor', 'javelin-behavior', ), + 'ff7b3f22' => array( + 'javelin-behavior', + 'javelin-dom', + ), ), 'packages' => array( 'conpherence.pkg.css' => array( @@ -2333,7 +2385,6 @@ return array( 'javelin-behavior-aphront-drag-and-drop-textarea', 'javelin-behavior-phabricator-object-selector', 'javelin-behavior-repository-crossreference', - 'javelin-behavior-differential-user-select', 'javelin-behavior-aphront-more', 'phabricator-diff-inline', 'phabricator-diff-changeset', @@ -2352,7 +2403,6 @@ return array( ), 'maniphest.pkg.js' => array( 'javelin-behavior-maniphest-batch-selector', - 'javelin-behavior-maniphest-subpriority-editor', 'javelin-behavior-maniphest-list-editor', ), ), diff --git a/resources/celerity/packages.php b/resources/celerity/packages.php index 4005e064ba..6dbb662288 100644 --- a/resources/celerity/packages.php +++ b/resources/celerity/packages.php @@ -199,7 +199,6 @@ return array( 'javelin-behavior-phabricator-object-selector', 'javelin-behavior-repository-crossreference', - 'javelin-behavior-differential-user-select', 'javelin-behavior-aphront-more', 'phabricator-diff-inline', @@ -219,7 +218,6 @@ return array( ), 'maniphest.pkg.js' => array( 'javelin-behavior-maniphest-batch-selector', - 'javelin-behavior-maniphest-subpriority-editor', 'javelin-behavior-maniphest-list-editor', ), ); diff --git a/resources/sql/autopatches/20151221.search.3.reindex.php b/resources/sql/autopatches/20151221.search.3.reindex.php index 09556d5ea0..623ba7bf6a 100644 --- a/resources/sql/autopatches/20151221.search.3.reindex.php +++ b/resources/sql/autopatches/20151221.search.3.reindex.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20160221.almanac.2.devicei.php b/resources/sql/autopatches/20160221.almanac.2.devicei.php index aea17d0ad6..623ba7bf6a 100644 --- a/resources/sql/autopatches/20160221.almanac.2.devicei.php +++ b/resources/sql/autopatches/20160221.almanac.2.devicei.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20160221.almanac.4.servicei.php b/resources/sql/autopatches/20160221.almanac.4.servicei.php index 97211ca7b5..623ba7bf6a 100644 --- a/resources/sql/autopatches/20160221.almanac.4.servicei.php +++ b/resources/sql/autopatches/20160221.almanac.4.servicei.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20160221.almanac.6.networki.php b/resources/sql/autopatches/20160221.almanac.6.networki.php index 263defbb33..623ba7bf6a 100644 --- a/resources/sql/autopatches/20160221.almanac.6.networki.php +++ b/resources/sql/autopatches/20160221.almanac.6.networki.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20160227.harbormaster.2.plani.php b/resources/sql/autopatches/20160227.harbormaster.2.plani.php index 6dea004c06..623ba7bf6a 100644 --- a/resources/sql/autopatches/20160227.harbormaster.2.plani.php +++ b/resources/sql/autopatches/20160227.harbormaster.2.plani.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20160303.drydock.2.bluei.php b/resources/sql/autopatches/20160303.drydock.2.bluei.php index c0b68c2262..623ba7bf6a 100644 --- a/resources/sql/autopatches/20160303.drydock.2.bluei.php +++ b/resources/sql/autopatches/20160303.drydock.2.bluei.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20160308.nuance.04.sourcei.php b/resources/sql/autopatches/20160308.nuance.04.sourcei.php index eb0d1da113..623ba7bf6a 100644 --- a/resources/sql/autopatches/20160308.nuance.04.sourcei.php +++ b/resources/sql/autopatches/20160308.nuance.04.sourcei.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20160406.badges.ngrams.php b/resources/sql/autopatches/20160406.badges.ngrams.php index ce8d8896ef..623ba7bf6a 100644 --- a/resources/sql/autopatches/20160406.badges.ngrams.php +++ b/resources/sql/autopatches/20160406.badges.ngrams.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20160927.phurl.ngrams.php b/resources/sql/autopatches/20160927.phurl.ngrams.php index 74cf61efa5..623ba7bf6a 100644 --- a/resources/sql/autopatches/20160927.phurl.ngrams.php +++ b/resources/sql/autopatches/20160927.phurl.ngrams.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20161011.conpherence.ngrams.php b/resources/sql/autopatches/20161011.conpherence.ngrams.php index 457143f6c7..623ba7bf6a 100644 --- a/resources/sql/autopatches/20161011.conpherence.ngrams.php +++ b/resources/sql/autopatches/20161011.conpherence.ngrams.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20161216.dashboard.ngram.02.php b/resources/sql/autopatches/20161216.dashboard.ngram.02.php index a7abc99b23..623ba7bf6a 100644 --- a/resources/sql/autopatches/20161216.dashboard.ngram.02.php +++ b/resources/sql/autopatches/20161216.dashboard.ngram.02.php @@ -1,21 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} - -$table_dbp = new PhabricatorDashboardPanel(); - -foreach (new LiskMigrationIterator($table_dbp) as $panel) { - PhabricatorSearchWorker::queueDocumentForIndexing( - $panel->getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20170526.milestones.php b/resources/sql/autopatches/20170526.milestones.php index 2e30ac4775..623ba7bf6a 100644 --- a/resources/sql/autopatches/20170526.milestones.php +++ b/resources/sql/autopatches/20170526.milestones.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20171026.ferret.05.ponder.index.php b/resources/sql/autopatches/20171026.ferret.05.ponder.index.php index 20489846d2..623ba7bf6a 100644 --- a/resources/sql/autopatches/20171026.ferret.05.ponder.index.php +++ b/resources/sql/autopatches/20171026.ferret.05.ponder.index.php @@ -1,11 +1,3 @@ getPHID(), - array( - 'force' => true, - )); -} +// This was an old reindexing migration that has been obsoleted. See T13253. diff --git a/resources/sql/autopatches/20190206.external.01.legalpad.sql b/resources/sql/autopatches/20190206.external.01.legalpad.sql new file mode 100644 index 0000000000..8afa9dd9ff --- /dev/null +++ b/resources/sql/autopatches/20190206.external.01.legalpad.sql @@ -0,0 +1,2 @@ +UPDATE {$NAMESPACE}_legalpad.legalpad_documentsignature + SET signerPHID = NULL WHERE signerPHID LIKE 'PHID-XUSR-%'; diff --git a/resources/sql/autopatches/20190206.external.02.email.sql b/resources/sql/autopatches/20190206.external.02.email.sql new file mode 100644 index 0000000000..14f5f4791f --- /dev/null +++ b/resources/sql/autopatches/20190206.external.02.email.sql @@ -0,0 +1,2 @@ +DELETE FROM {$NAMESPACE}_user.user_externalaccount + WHERE accountType = 'email'; diff --git a/resources/sql/autopatches/20190206.external.03.providerphid.sql b/resources/sql/autopatches/20190206.external.03.providerphid.sql new file mode 100644 index 0000000000..0b2f498e02 --- /dev/null +++ b/resources/sql/autopatches/20190206.external.03.providerphid.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_user.user_externalaccount + ADD providerConfigPHID VARBINARY(64) NOT NULL; diff --git a/resources/sql/autopatches/20190206.external.04.providerlink.php b/resources/sql/autopatches/20190206.external.04.providerlink.php new file mode 100644 index 0000000000..e4a2e2d4bf --- /dev/null +++ b/resources/sql/autopatches/20190206.external.04.providerlink.php @@ -0,0 +1,36 @@ +establishConnection('w'); +$table_name = $account_table->getTableName(); + +$config_table = new PhabricatorAuthProviderConfig(); +$config_conn = $config_table->establishConnection('w'); + +foreach (new LiskRawMigrationIterator($account_conn, $table_name) as $row) { + if (strlen($row['providerConfigPHID'])) { + continue; + } + + $config_row = queryfx_one( + $config_conn, + 'SELECT phid + FROM %R + WHERE providerType = %s AND providerDomain = %s + LIMIT 1', + $config_table, + $row['accountType'], + $row['accountDomain']); + if (!$config_row) { + continue; + } + + queryfx( + $account_conn, + 'UPDATE %R + SET providerConfigPHID = %s + WHERE id = %d', + $account_table, + $config_row['phid'], + $row['id']); +} diff --git a/resources/sql/autopatches/20190207.packages.01.state.sql b/resources/sql/autopatches/20190207.packages.01.state.sql new file mode 100644 index 0000000000..0e74f269ba --- /dev/null +++ b/resources/sql/autopatches/20190207.packages.01.state.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_owners.owners_package + ADD auditingState VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190207.packages.02.migrate.sql b/resources/sql/autopatches/20190207.packages.02.migrate.sql new file mode 100644 index 0000000000..60bf364ac1 --- /dev/null +++ b/resources/sql/autopatches/20190207.packages.02.migrate.sql @@ -0,0 +1,2 @@ +UPDATE {$NAMESPACE}_owners.owners_package + SET auditingState = IF(auditingEnabled = 0, 'none', 'audit'); diff --git a/resources/sql/autopatches/20190207.packages.03.drop.sql b/resources/sql/autopatches/20190207.packages.03.drop.sql new file mode 100644 index 0000000000..24d0ce1a4f --- /dev/null +++ b/resources/sql/autopatches/20190207.packages.03.drop.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_owners.owners_package + DROP auditingEnabled; diff --git a/resources/sql/autopatches/20190207.packages.04.xactions.php b/resources/sql/autopatches/20190207.packages.04.xactions.php new file mode 100644 index 0000000000..5a8609166e --- /dev/null +++ b/resources/sql/autopatches/20190207.packages.04.xactions.php @@ -0,0 +1,41 @@ +establishConnection('w'); +$iterator = new LiskRawMigrationIterator($conn, $table->getTableName()); + +// Migrate "Auditing State" transactions for Owners Packages from old values +// (which were "0" or "1", as JSON integer literals, without quotes) to new +// values (which are JSON strings, with quotes). + +foreach ($iterator as $row) { + if ($row['transactionType'] !== 'owners.auditing') { + continue; + } + + $old_value = (int)$row['oldValue']; + $new_value = (int)$row['newValue']; + + if (!$old_value) { + $old_value = 'none'; + } else { + $old_value = 'audit'; + } + + if (!$new_value) { + $new_value = 'none'; + } else { + $new_value = 'audit'; + } + + $old_value = phutil_json_encode($old_value); + $new_value = phutil_json_encode($new_value); + + queryfx( + $conn, + 'UPDATE %R SET oldValue = %s, newValue = %s WHERE id = %d', + $table, + $old_value, + $new_value, + $row['id']); +} diff --git a/resources/sql/autopatches/20190215.daemons.01.dropdataid.php b/resources/sql/autopatches/20190215.daemons.01.dropdataid.php new file mode 100644 index 0000000000..05cc4adfee --- /dev/null +++ b/resources/sql/autopatches/20190215.daemons.01.dropdataid.php @@ -0,0 +1,21 @@ +establishConnection('w'); + +try { + queryfx( + $conn, + 'ALTER TABLE %R DROP KEY %T', + $table, + 'dataID'); +} catch (AphrontQueryException $ex) { + // Ignore. +} diff --git a/resources/sql/autopatches/20190215.daemons.02.nulldataid.sql b/resources/sql/autopatches/20190215.daemons.02.nulldataid.sql new file mode 100644 index 0000000000..19be602efe --- /dev/null +++ b/resources/sql/autopatches/20190215.daemons.02.nulldataid.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_worker.worker_activetask + CHANGE dataID dataID INT UNSIGNED NOT NULL; diff --git a/resources/sql/autopatches/20190215.harbor.01.stringindex.sql b/resources/sql/autopatches/20190215.harbor.01.stringindex.sql new file mode 100644 index 0000000000..e94b240bab --- /dev/null +++ b/resources/sql/autopatches/20190215.harbor.01.stringindex.sql @@ -0,0 +1,6 @@ +CREATE TABLE {$NAMESPACE}_harbormaster.harbormaster_string ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + stringIndex BINARY(12) NOT NULL, + stringValue LONGTEXT NOT NULL, + UNIQUE KEY `key_string` (stringIndex) +) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190215.harbor.02.stringcol.sql b/resources/sql/autopatches/20190215.harbor.02.stringcol.sql new file mode 100644 index 0000000000..acfdb0f869 --- /dev/null +++ b/resources/sql/autopatches/20190215.harbor.02.stringcol.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_harbormaster.harbormaster_buildunitmessage + ADD nameIndex BINARY(12) NOT NULL; diff --git a/resources/sql/autopatches/20190220.daemon_worker.completed.01.sql b/resources/sql/autopatches/20190220.daemon_worker.completed.01.sql new file mode 100644 index 0000000000..37f5a89bba --- /dev/null +++ b/resources/sql/autopatches/20190220.daemon_worker.completed.01.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_worker.worker_archivetask + ADD archivedEpoch INT UNSIGNED NULL; diff --git a/resources/sql/autopatches/20190220.daemon_worker.completed.02.sql b/resources/sql/autopatches/20190220.daemon_worker.completed.02.sql new file mode 100644 index 0000000000..f0040576a9 --- /dev/null +++ b/resources/sql/autopatches/20190220.daemon_worker.completed.02.sql @@ -0,0 +1,3 @@ +ALTER TABLE {$NAMESPACE}_worker.worker_activetask + ADD dateCreated int unsigned NOT NULL, + ADD dateModified int unsigned NOT NULL; diff --git a/resources/sql/autopatches/20190226.harbor.01.planprops.sql b/resources/sql/autopatches/20190226.harbor.01.planprops.sql new file mode 100644 index 0000000000..324139669e --- /dev/null +++ b/resources/sql/autopatches/20190226.harbor.01.planprops.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_harbormaster.harbormaster_buildplan + ADD properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190226.harbor.02.planvalue.sql b/resources/sql/autopatches/20190226.harbor.02.planvalue.sql new file mode 100644 index 0000000000..b1929abf59 --- /dev/null +++ b/resources/sql/autopatches/20190226.harbor.02.planvalue.sql @@ -0,0 +1,2 @@ +UPDATE {$NAMESPACE}_harbormaster.harbormaster_buildplan + SET properties = '{}' WHERE properties = ''; diff --git a/resources/sql/autopatches/20190307.herald.01.comments.sql b/resources/sql/autopatches/20190307.herald.01.comments.sql new file mode 100644 index 0000000000..ff9cb9af88 --- /dev/null +++ b/resources/sql/autopatches/20190307.herald.01.comments.sql @@ -0,0 +1 @@ +DROP TABLE {$NAMESPACE}_herald.herald_ruletransaction_comment; diff --git a/resources/sql/autopatches/20190312.triggers.01.trigger.sql b/resources/sql/autopatches/20190312.triggers.01.trigger.sql new file mode 100644 index 0000000000..301a3a62cd --- /dev/null +++ b/resources/sql/autopatches/20190312.triggers.01.trigger.sql @@ -0,0 +1,9 @@ +CREATE TABLE {$NAMESPACE}_project.project_trigger ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARBINARY(64) NOT NULL, + name VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT}, + editPolicy VARBINARY(64) NOT NULL, + ruleset LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT}, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL +) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190312.triggers.02.xaction.sql b/resources/sql/autopatches/20190312.triggers.02.xaction.sql new file mode 100644 index 0000000000..1a6034c4b1 --- /dev/null +++ b/resources/sql/autopatches/20190312.triggers.02.xaction.sql @@ -0,0 +1,19 @@ +CREATE TABLE {$NAMESPACE}_project.project_triggertransaction ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARBINARY(64) NOT NULL, + authorPHID VARBINARY(64) NOT NULL, + objectPHID VARBINARY(64) NOT NULL, + viewPolicy VARBINARY(64) NOT NULL, + editPolicy VARBINARY(64) NOT NULL, + commentPHID VARBINARY(64) DEFAULT NULL, + commentVersion INT UNSIGNED NOT NULL, + transactionType VARCHAR(32) NOT NULL, + oldValue LONGTEXT NOT NULL, + newValue LONGTEXT NOT NULL, + contentSource LONGTEXT NOT NULL, + metadata LONGTEXT NOT NULL, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_phid` (`phid`), + KEY `key_object` (`objectPHID`) +) ENGINE=InnoDB DEFAULT CHARSET={$CHARSET} COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190312.triggers.03.triggerphid.sql b/resources/sql/autopatches/20190312.triggers.03.triggerphid.sql new file mode 100644 index 0000000000..271d679cfa --- /dev/null +++ b/resources/sql/autopatches/20190312.triggers.03.triggerphid.sql @@ -0,0 +1,2 @@ +ALTER TABLE {$NAMESPACE}_project.project_column + ADD triggerPHID VARBINARY(64); diff --git a/resources/sql/autopatches/20190322.triggers.01.usage.sql b/resources/sql/autopatches/20190322.triggers.01.usage.sql new file mode 100644 index 0000000000..643ebbbfff --- /dev/null +++ b/resources/sql/autopatches/20190322.triggers.01.usage.sql @@ -0,0 +1,8 @@ +CREATE TABLE {$NAMESPACE}_project.project_triggerusage ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + triggerPHID VARBINARY(64) NOT NULL, + examplePHID VARBINARY(64), + columnCount INT UNSIGNED NOT NULL, + activeColumnCount INT UNSIGNED NOT NULL, + UNIQUE KEY `key_trigger` (triggerPHID) +) ENGINE=InnoDB DEFAULT CHARSET={$CHARSET} COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190329.portals.01.create.sql b/resources/sql/autopatches/20190329.portals.01.create.sql new file mode 100644 index 0000000000..d7d1e6138f --- /dev/null +++ b/resources/sql/autopatches/20190329.portals.01.create.sql @@ -0,0 +1,11 @@ +CREATE TABLE {$NAMESPACE}_dashboard.dashboard_portal ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARBINARY(64) NOT NULL, + name VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT}, + status VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT}, + viewPolicy VARBINARY(64) NOT NULL, + editPolicy VARBINARY(64) NOT NULL, + properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT}, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL +) ENGINE=InnoDB DEFAULT CHARSET={$CHARSET} COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/autopatches/20190329.portals.02.xaction.sql b/resources/sql/autopatches/20190329.portals.02.xaction.sql new file mode 100644 index 0000000000..057df69e2d --- /dev/null +++ b/resources/sql/autopatches/20190329.portals.02.xaction.sql @@ -0,0 +1,19 @@ +CREATE TABLE {$NAMESPACE}_dashboard.dashboard_portaltransaction ( + id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + phid VARBINARY(64) NOT NULL, + authorPHID VARBINARY(64) NOT NULL, + objectPHID VARBINARY(64) NOT NULL, + viewPolicy VARBINARY(64) NOT NULL, + editPolicy VARBINARY(64) NOT NULL, + commentPHID VARBINARY(64) DEFAULT NULL, + commentVersion INT UNSIGNED NOT NULL, + transactionType VARCHAR(32) NOT NULL, + oldValue LONGTEXT NOT NULL, + newValue LONGTEXT NOT NULL, + contentSource LONGTEXT NOT NULL, + metadata LONGTEXT NOT NULL, + dateCreated INT UNSIGNED NOT NULL, + dateModified INT UNSIGNED NOT NULL, + UNIQUE KEY `key_phid` (`phid`), + KEY `key_object` (`objectPHID`) +) ENGINE=InnoDB DEFAULT CHARSET={$CHARSET} COLLATE {$COLLATE_TEXT}; diff --git a/resources/sql/patches/133.imagemacro.sql b/resources/sql/patches/133.imagemacro.sql index 01852c6b48..1477fd879f 100644 --- a/resources/sql/patches/133.imagemacro.sql +++ b/resources/sql/patches/133.imagemacro.sql @@ -1,2 +1,2 @@ -ALTER IGNORE TABLE `{$NAMESPACE}_file`.`file_imagemacro` - ADD UNIQUE `name` (`name`); +ALTER TABLE `{$NAMESPACE}_file`.`file_imagemacro` + ADD UNIQUE KEY `name` (`name`); diff --git a/resources/sql/patches/20130611.migrateoauth.php b/resources/sql/patches/20130611.migrateoauth.php index 3622b2772e..92fe854cfd 100644 --- a/resources/sql/patches/20130611.migrateoauth.php +++ b/resources/sql/patches/20130611.migrateoauth.php @@ -1,66 +1,14 @@ establishConnection('w'); $table_name = 'user_oauthinfo'; -$conn_w = $table->establishConnection('w'); -$xaccount = new PhabricatorExternalAccount(); - -echo pht('Migrating OAuth to %s...', 'ExternalAccount')."\n"; - -$domain_map = array( - 'disqus' => 'disqus.com', - 'facebook' => 'facebook.com', - 'github' => 'github.com', - 'google' => 'google.com', -); - -try { - $phabricator_oauth_uri = new PhutilURI( - PhabricatorEnv::getEnvConfig('phabricator.oauth-uri')); - $domain_map['phabricator'] = $phabricator_oauth_uri->getDomain(); -} catch (Exception $ex) { - // Ignore; this likely indicates that we have removed `phabricator.oauth-uri` - // in some future diff. +foreach (new LiskRawMigrationIterator($conn, $table_name) as $row) { + throw new Exception( + pht( + 'Your Phabricator install has ancient OAuth account data and is '. + 'too old to upgrade directly to a modern version of Phabricator. '. + 'Upgrade to a version released between June 2013 and February 2019 '. + 'first, then upgrade to a modern version.')); } - -$rows = queryfx_all( - $conn_w, - 'SELECT * FROM user_oauthinfo'); -foreach ($rows as $row) { - echo pht('Migrating row ID #%d.', $row['id'])."\n"; - $user = id(new PhabricatorUser())->loadOneWhere( - 'id = %d', - $row['userID']); - if (!$user) { - echo pht('Bad user ID!')."\n"; - continue; - } - - $domain = idx($domain_map, $row['oauthProvider']); - if (empty($domain)) { - echo pht('Unknown OAuth provider!')."\n"; - continue; - } - - - $xaccount = id(new PhabricatorExternalAccount()) - ->setUserPHID($user->getPHID()) - ->setAccountType($row['oauthProvider']) - ->setAccountDomain($domain) - ->setAccountID($row['oauthUID']) - ->setAccountURI($row['accountURI']) - ->setUsername($row['accountName']) - ->setDateCreated($row['dateCreated']); - - try { - $xaccount->save(); - } catch (Exception $ex) { - phlog($ex); - } -} - -echo pht('Done.')."\n"; diff --git a/resources/sql/patches/20130611.nukeldap.php b/resources/sql/patches/20130611.nukeldap.php index 3f225cfa84..0f0b976a58 100644 --- a/resources/sql/patches/20130611.nukeldap.php +++ b/resources/sql/patches/20130611.nukeldap.php @@ -1,41 +1,14 @@ establishConnection('w'); $table_name = 'user_ldapinfo'; -$conn_w = $table->establishConnection('w'); -$xaccount = new PhabricatorExternalAccount(); - -echo pht('Migrating LDAP to %s...', 'ExternalAccount')."\n"; - -$rows = queryfx_all($conn_w, 'SELECT * FROM %T', $table_name); -foreach ($rows as $row) { - echo pht('Migrating row ID #%d.', $row['id'])."\n"; - $user = id(new PhabricatorUser())->loadOneWhere( - 'id = %d', - $row['userID']); - if (!$user) { - echo pht('Bad user ID!')."\n"; - continue; - } - - - $xaccount = id(new PhabricatorExternalAccount()) - ->setUserPHID($user->getPHID()) - ->setAccountType('ldap') - ->setAccountDomain('self') - ->setAccountID($row['ldapUsername']) - ->setUsername($row['ldapUsername']) - ->setDateCreated($row['dateCreated']); - - try { - $xaccount->save(); - } catch (Exception $ex) { - phlog($ex); - } +foreach (new LiskRawMigrationIterator($conn, $table_name) as $row) { + throw new Exception( + pht( + 'Your Phabricator install has ancient LDAP account data and is '. + 'too old to upgrade directly to a modern version of Phabricator. '. + 'Upgrade to a version released between June 2013 and February 2019 '. + 'first, then upgrade to a modern version.')); } - -echo pht('Done.')."\n"; diff --git a/src/__phutil_library_map__.php b/src/__phutil_library_map__.php index 36b4442065..0358c27a9c 100644 --- a/src/__phutil_library_map__.php +++ b/src/__phutil_library_map__.php @@ -653,6 +653,7 @@ phutil_register_library_map(array( 'DifferentialRevisionUpdateTransaction' => 'applications/differential/xaction/DifferentialRevisionUpdateTransaction.php', 'DifferentialRevisionViewController' => 'applications/differential/controller/DifferentialRevisionViewController.php', 'DifferentialRevisionVoidTransaction' => 'applications/differential/xaction/DifferentialRevisionVoidTransaction.php', + 'DifferentialRevisionWrongBuildsTransaction' => 'applications/differential/xaction/DifferentialRevisionWrongBuildsTransaction.php', 'DifferentialRevisionWrongStateTransaction' => 'applications/differential/xaction/DifferentialRevisionWrongStateTransaction.php', 'DifferentialSchemaSpec' => 'applications/differential/storage/DifferentialSchemaSpec.php', 'DifferentialSetDiffPropertyConduitAPIMethod' => 'applications/differential/conduit/DifferentialSetDiffPropertyConduitAPIMethod.php', @@ -1328,18 +1329,26 @@ phutil_register_library_map(array( 'HarbormasterBuildMessageQuery' => 'applications/harbormaster/query/HarbormasterBuildMessageQuery.php', 'HarbormasterBuildPHIDType' => 'applications/harbormaster/phid/HarbormasterBuildPHIDType.php', 'HarbormasterBuildPlan' => 'applications/harbormaster/storage/configuration/HarbormasterBuildPlan.php', + 'HarbormasterBuildPlanBehavior' => 'applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php', + 'HarbormasterBuildPlanBehaviorOption' => 'applications/harbormaster/plan/HarbormasterBuildPlanBehaviorOption.php', + 'HarbormasterBuildPlanBehaviorTransaction' => 'applications/harbormaster/xaction/plan/HarbormasterBuildPlanBehaviorTransaction.php', 'HarbormasterBuildPlanDatasource' => 'applications/harbormaster/typeahead/HarbormasterBuildPlanDatasource.php', 'HarbormasterBuildPlanDefaultEditCapability' => 'applications/harbormaster/capability/HarbormasterBuildPlanDefaultEditCapability.php', 'HarbormasterBuildPlanDefaultViewCapability' => 'applications/harbormaster/capability/HarbormasterBuildPlanDefaultViewCapability.php', + 'HarbormasterBuildPlanEditAPIMethod' => 'applications/harbormaster/conduit/HarbormasterBuildPlanEditAPIMethod.php', 'HarbormasterBuildPlanEditEngine' => 'applications/harbormaster/editor/HarbormasterBuildPlanEditEngine.php', 'HarbormasterBuildPlanEditor' => 'applications/harbormaster/editor/HarbormasterBuildPlanEditor.php', 'HarbormasterBuildPlanNameNgrams' => 'applications/harbormaster/storage/configuration/HarbormasterBuildPlanNameNgrams.php', + 'HarbormasterBuildPlanNameTransaction' => 'applications/harbormaster/xaction/plan/HarbormasterBuildPlanNameTransaction.php', 'HarbormasterBuildPlanPHIDType' => 'applications/harbormaster/phid/HarbormasterBuildPlanPHIDType.php', + 'HarbormasterBuildPlanPolicyCodex' => 'applications/harbormaster/codex/HarbormasterBuildPlanPolicyCodex.php', 'HarbormasterBuildPlanQuery' => 'applications/harbormaster/query/HarbormasterBuildPlanQuery.php', 'HarbormasterBuildPlanSearchAPIMethod' => 'applications/harbormaster/conduit/HarbormasterBuildPlanSearchAPIMethod.php', 'HarbormasterBuildPlanSearchEngine' => 'applications/harbormaster/query/HarbormasterBuildPlanSearchEngine.php', + 'HarbormasterBuildPlanStatusTransaction' => 'applications/harbormaster/xaction/plan/HarbormasterBuildPlanStatusTransaction.php', 'HarbormasterBuildPlanTransaction' => 'applications/harbormaster/storage/configuration/HarbormasterBuildPlanTransaction.php', 'HarbormasterBuildPlanTransactionQuery' => 'applications/harbormaster/query/HarbormasterBuildPlanTransactionQuery.php', + 'HarbormasterBuildPlanTransactionType' => 'applications/harbormaster/xaction/plan/HarbormasterBuildPlanTransactionType.php', 'HarbormasterBuildQuery' => 'applications/harbormaster/query/HarbormasterBuildQuery.php', 'HarbormasterBuildRequest' => 'applications/harbormaster/engine/HarbormasterBuildRequest.php', 'HarbormasterBuildSearchConduitAPIMethod' => 'applications/harbormaster/conduit/HarbormasterBuildSearchConduitAPIMethod.php', @@ -1365,6 +1374,8 @@ phutil_register_library_map(array( 'HarbormasterBuildTransactionEditor' => 'applications/harbormaster/editor/HarbormasterBuildTransactionEditor.php', 'HarbormasterBuildTransactionQuery' => 'applications/harbormaster/query/HarbormasterBuildTransactionQuery.php', 'HarbormasterBuildUnitMessage' => 'applications/harbormaster/storage/build/HarbormasterBuildUnitMessage.php', + 'HarbormasterBuildUnitMessageQuery' => 'applications/harbormaster/query/HarbormasterBuildUnitMessageQuery.php', + 'HarbormasterBuildView' => 'applications/harbormaster/view/HarbormasterBuildView.php', 'HarbormasterBuildViewController' => 'applications/harbormaster/controller/HarbormasterBuildViewController.php', 'HarbormasterBuildWorker' => 'applications/harbormaster/worker/HarbormasterBuildWorker.php', 'HarbormasterBuildable' => 'applications/harbormaster/storage/HarbormasterBuildable.php', @@ -1418,6 +1429,7 @@ phutil_register_library_map(array( 'HarbormasterMessageType' => 'applications/harbormaster/engine/HarbormasterMessageType.php', 'HarbormasterObject' => 'applications/harbormaster/storage/HarbormasterObject.php', 'HarbormasterOtherBuildStepGroup' => 'applications/harbormaster/stepgroup/HarbormasterOtherBuildStepGroup.php', + 'HarbormasterPlanBehaviorController' => 'applications/harbormaster/controller/HarbormasterPlanBehaviorController.php', 'HarbormasterPlanController' => 'applications/harbormaster/controller/HarbormasterPlanController.php', 'HarbormasterPlanDisableController' => 'applications/harbormaster/controller/HarbormasterPlanDisableController.php', 'HarbormasterPlanEditController' => 'applications/harbormaster/controller/HarbormasterPlanEditController.php', @@ -1431,6 +1443,7 @@ phutil_register_library_map(array( 'HarbormasterQueryBuildsConduitAPIMethod' => 'applications/harbormaster/conduit/HarbormasterQueryBuildsConduitAPIMethod.php', 'HarbormasterQueryBuildsSearchEngineAttachment' => 'applications/harbormaster/engineextension/HarbormasterQueryBuildsSearchEngineAttachment.php', 'HarbormasterRemarkupRule' => 'applications/harbormaster/remarkup/HarbormasterRemarkupRule.php', + 'HarbormasterRestartException' => 'applications/harbormaster/exception/HarbormasterRestartException.php', 'HarbormasterRunBuildPlansHeraldAction' => 'applications/harbormaster/herald/HarbormasterRunBuildPlansHeraldAction.php', 'HarbormasterSchemaSpec' => 'applications/harbormaster/storage/HarbormasterSchemaSpec.php', 'HarbormasterScratchTable' => 'applications/harbormaster/storage/HarbormasterScratchTable.php', @@ -1440,6 +1453,7 @@ phutil_register_library_map(array( 'HarbormasterStepDeleteController' => 'applications/harbormaster/controller/HarbormasterStepDeleteController.php', 'HarbormasterStepEditController' => 'applications/harbormaster/controller/HarbormasterStepEditController.php', 'HarbormasterStepViewController' => 'applications/harbormaster/controller/HarbormasterStepViewController.php', + 'HarbormasterString' => 'applications/harbormaster/storage/HarbormasterString.php', 'HarbormasterTargetEngine' => 'applications/harbormaster/engine/HarbormasterTargetEngine.php', 'HarbormasterTargetSearchAPIMethod' => 'applications/harbormaster/conduit/HarbormasterTargetSearchAPIMethod.php', 'HarbormasterTargetWorker' => 'applications/harbormaster/worker/HarbormasterTargetWorker.php', @@ -1518,14 +1532,20 @@ phutil_register_library_map(array( 'HeraldRemarkupFieldValue' => 'applications/herald/value/HeraldRemarkupFieldValue.php', 'HeraldRemarkupRule' => 'applications/herald/remarkup/HeraldRemarkupRule.php', 'HeraldRule' => 'applications/herald/storage/HeraldRule.php', + 'HeraldRuleActionAffectsObjectEdgeType' => 'applications/herald/edge/HeraldRuleActionAffectsObjectEdgeType.php', 'HeraldRuleAdapter' => 'applications/herald/adapter/HeraldRuleAdapter.php', 'HeraldRuleAdapterField' => 'applications/herald/field/rule/HeraldRuleAdapterField.php', 'HeraldRuleController' => 'applications/herald/controller/HeraldRuleController.php', 'HeraldRuleDatasource' => 'applications/herald/typeahead/HeraldRuleDatasource.php', + 'HeraldRuleDisableTransaction' => 'applications/herald/xaction/HeraldRuleDisableTransaction.php', + 'HeraldRuleEditTransaction' => 'applications/herald/xaction/HeraldRuleEditTransaction.php', 'HeraldRuleEditor' => 'applications/herald/editor/HeraldRuleEditor.php', 'HeraldRuleField' => 'applications/herald/field/rule/HeraldRuleField.php', 'HeraldRuleFieldGroup' => 'applications/herald/field/rule/HeraldRuleFieldGroup.php', + 'HeraldRuleIndexEngineExtension' => 'applications/herald/engineextension/HeraldRuleIndexEngineExtension.php', 'HeraldRuleListController' => 'applications/herald/controller/HeraldRuleListController.php', + 'HeraldRuleListView' => 'applications/herald/view/HeraldRuleListView.php', + 'HeraldRuleNameTransaction' => 'applications/herald/xaction/HeraldRuleNameTransaction.php', 'HeraldRulePHIDType' => 'applications/herald/phid/HeraldRulePHIDType.php', 'HeraldRuleQuery' => 'applications/herald/query/HeraldRuleQuery.php', 'HeraldRuleReplyHandler' => 'applications/herald/mail/HeraldRuleReplyHandler.php', @@ -1533,7 +1553,7 @@ phutil_register_library_map(array( 'HeraldRuleSerializer' => 'applications/herald/editor/HeraldRuleSerializer.php', 'HeraldRuleTestCase' => 'applications/herald/storage/__tests__/HeraldRuleTestCase.php', 'HeraldRuleTransaction' => 'applications/herald/storage/HeraldRuleTransaction.php', - 'HeraldRuleTransactionComment' => 'applications/herald/storage/HeraldRuleTransactionComment.php', + 'HeraldRuleTransactionType' => 'applications/herald/xaction/HeraldRuleTransactionType.php', 'HeraldRuleTranscript' => 'applications/herald/storage/transcript/HeraldRuleTranscript.php', 'HeraldRuleTypeConfig' => 'applications/herald/config/HeraldRuleTypeConfig.php', 'HeraldRuleTypeDatasource' => 'applications/herald/typeahead/HeraldRuleTypeDatasource.php', @@ -1690,7 +1710,6 @@ phutil_register_library_map(array( 'ManiphestStatusEmailCommand' => 'applications/maniphest/command/ManiphestStatusEmailCommand.php', 'ManiphestStatusSearchConduitAPIMethod' => 'applications/maniphest/conduit/ManiphestStatusSearchConduitAPIMethod.php', 'ManiphestStatusesConfigType' => 'applications/maniphest/config/ManiphestStatusesConfigType.php', - 'ManiphestSubpriorityController' => 'applications/maniphest/controller/ManiphestSubpriorityController.php', 'ManiphestSubtypesConfigType' => 'applications/maniphest/config/ManiphestSubtypesConfigType.php', 'ManiphestTask' => 'applications/maniphest/storage/ManiphestTask.php', 'ManiphestTaskAssignHeraldAction' => 'applications/maniphest/herald/ManiphestTaskAssignHeraldAction.php', @@ -1715,6 +1734,7 @@ phutil_register_library_map(array( 'ManiphestTaskFerretEngine' => 'applications/maniphest/search/ManiphestTaskFerretEngine.php', 'ManiphestTaskFulltextEngine' => 'applications/maniphest/search/ManiphestTaskFulltextEngine.php', 'ManiphestTaskGraph' => 'infrastructure/graph/ManiphestTaskGraph.php', + 'ManiphestTaskGraphController' => 'applications/maniphest/controller/ManiphestTaskGraphController.php', 'ManiphestTaskHasCommitEdgeType' => 'applications/maniphest/edge/ManiphestTaskHasCommitEdgeType.php', 'ManiphestTaskHasCommitRelationship' => 'applications/maniphest/relationship/ManiphestTaskHasCommitRelationship.php', 'ManiphestTaskHasDuplicateTaskEdgeType' => 'applications/maniphest/edge/ManiphestTaskHasDuplicateTaskEdgeType.php', @@ -1742,6 +1762,7 @@ phutil_register_library_map(array( 'ManiphestTaskParentTransaction' => 'applications/maniphest/xaction/ManiphestTaskParentTransaction.php', 'ManiphestTaskPoints' => 'applications/maniphest/constants/ManiphestTaskPoints.php', 'ManiphestTaskPointsTransaction' => 'applications/maniphest/xaction/ManiphestTaskPointsTransaction.php', + 'ManiphestTaskPolicyCodex' => 'applications/maniphest/policy/ManiphestTaskPolicyCodex.php', 'ManiphestTaskPriority' => 'applications/maniphest/constants/ManiphestTaskPriority.php', 'ManiphestTaskPriorityDatasource' => 'applications/maniphest/typeahead/ManiphestTaskPriorityDatasource.php', 'ManiphestTaskPriorityHeraldAction' => 'applications/maniphest/herald/ManiphestTaskPriorityHeraldAction.php', @@ -1762,11 +1783,11 @@ phutil_register_library_map(array( 'ManiphestTaskSubpriorityTransaction' => 'applications/maniphest/xaction/ManiphestTaskSubpriorityTransaction.php', 'ManiphestTaskSubtaskController' => 'applications/maniphest/controller/ManiphestTaskSubtaskController.php', 'ManiphestTaskSubtypeDatasource' => 'applications/maniphest/typeahead/ManiphestTaskSubtypeDatasource.php', - 'ManiphestTaskTestCase' => 'applications/maniphest/__tests__/ManiphestTaskTestCase.php', 'ManiphestTaskTitleHeraldField' => 'applications/maniphest/herald/ManiphestTaskTitleHeraldField.php', 'ManiphestTaskTitleTransaction' => 'applications/maniphest/xaction/ManiphestTaskTitleTransaction.php', 'ManiphestTaskTransactionType' => 'applications/maniphest/xaction/ManiphestTaskTransactionType.php', 'ManiphestTaskUnblockTransaction' => 'applications/maniphest/xaction/ManiphestTaskUnblockTransaction.php', + 'ManiphestTaskUnlockEngine' => 'applications/maniphest/engine/ManiphestTaskUnlockEngine.php', 'ManiphestTransaction' => 'applications/maniphest/storage/ManiphestTransaction.php', 'ManiphestTransactionComment' => 'applications/maniphest/storage/ManiphestTransactionComment.php', 'ManiphestTransactionEditor' => 'applications/maniphest/editor/ManiphestTransactionEditor.php', @@ -2198,6 +2219,8 @@ phutil_register_library_map(array( 'PhabricatorAuthChallengeGarbageCollector' => 'applications/auth/garbagecollector/PhabricatorAuthChallengeGarbageCollector.php', 'PhabricatorAuthChallengePHIDType' => 'applications/auth/phid/PhabricatorAuthChallengePHIDType.php', 'PhabricatorAuthChallengeQuery' => 'applications/auth/query/PhabricatorAuthChallengeQuery.php', + 'PhabricatorAuthChallengeStatusController' => 'applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php', + 'PhabricatorAuthChallengeUpdate' => 'applications/auth/view/PhabricatorAuthChallengeUpdate.php', 'PhabricatorAuthChangePasswordAction' => 'applications/auth/action/PhabricatorAuthChangePasswordAction.php', 'PhabricatorAuthConduitAPIMethod' => 'applications/auth/conduit/PhabricatorAuthConduitAPIMethod.php', 'PhabricatorAuthConduitTokenRevoker' => 'applications/auth/revoker/PhabricatorAuthConduitTokenRevoker.php', @@ -2273,9 +2296,9 @@ phutil_register_library_map(array( 'PhabricatorAuthInviteVerifyException' => 'applications/auth/exception/PhabricatorAuthInviteVerifyException.php', 'PhabricatorAuthInviteWorker' => 'applications/auth/worker/PhabricatorAuthInviteWorker.php', 'PhabricatorAuthLinkController' => 'applications/auth/controller/PhabricatorAuthLinkController.php', + 'PhabricatorAuthLinkMessageType' => 'applications/auth/message/PhabricatorAuthLinkMessageType.php', 'PhabricatorAuthListController' => 'applications/auth/controller/config/PhabricatorAuthListController.php', 'PhabricatorAuthLoginController' => 'applications/auth/controller/PhabricatorAuthLoginController.php', - 'PhabricatorAuthLoginHandler' => 'applications/auth/handler/PhabricatorAuthLoginHandler.php', 'PhabricatorAuthLoginMessageType' => 'applications/auth/message/PhabricatorAuthLoginMessageType.php', 'PhabricatorAuthLogoutConduitAPIMethod' => 'applications/auth/conduit/PhabricatorAuthLogoutConduitAPIMethod.php', 'PhabricatorAuthMFAEditEngineExtension' => 'applications/auth/engineextension/PhabricatorAuthMFAEditEngineExtension.php', @@ -2338,6 +2361,7 @@ phutil_register_library_map(array( 'PhabricatorAuthProviderConfigTransaction' => 'applications/auth/storage/PhabricatorAuthProviderConfigTransaction.php', 'PhabricatorAuthProviderConfigTransactionQuery' => 'applications/auth/query/PhabricatorAuthProviderConfigTransactionQuery.php', 'PhabricatorAuthProviderController' => 'applications/auth/controller/config/PhabricatorAuthProviderController.php', + 'PhabricatorAuthProviderViewController' => 'applications/auth/controller/config/PhabricatorAuthProviderViewController.php', 'PhabricatorAuthProvidersGuidanceContext' => 'applications/auth/guidance/PhabricatorAuthProvidersGuidanceContext.php', 'PhabricatorAuthProvidersGuidanceEngineExtension' => 'applications/auth/guidance/PhabricatorAuthProvidersGuidanceEngineExtension.php', 'PhabricatorAuthQueryPublicKeysConduitAPIMethod' => 'applications/auth/conduit/PhabricatorAuthQueryPublicKeysConduitAPIMethod.php', @@ -2371,6 +2395,7 @@ phutil_register_library_map(array( 'PhabricatorAuthSessionPHIDType' => 'applications/auth/phid/PhabricatorAuthSessionPHIDType.php', 'PhabricatorAuthSessionQuery' => 'applications/auth/query/PhabricatorAuthSessionQuery.php', 'PhabricatorAuthSessionRevoker' => 'applications/auth/revoker/PhabricatorAuthSessionRevoker.php', + 'PhabricatorAuthSetExternalController' => 'applications/auth/controller/PhabricatorAuthSetExternalController.php', 'PhabricatorAuthSetPasswordController' => 'applications/auth/controller/PhabricatorAuthSetPasswordController.php', 'PhabricatorAuthSetupCheck' => 'applications/config/check/PhabricatorAuthSetupCheck.php', 'PhabricatorAuthStartController' => 'applications/auth/controller/PhabricatorAuthStartController.php', @@ -2856,8 +2881,6 @@ phutil_register_library_map(array( 'PhabricatorDaemonLog' => 'applications/daemon/storage/PhabricatorDaemonLog.php', 'PhabricatorDaemonLogEvent' => 'applications/daemon/storage/PhabricatorDaemonLogEvent.php', 'PhabricatorDaemonLogEventGarbageCollector' => 'applications/daemon/garbagecollector/PhabricatorDaemonLogEventGarbageCollector.php', - 'PhabricatorDaemonLogEventViewController' => 'applications/daemon/controller/PhabricatorDaemonLogEventViewController.php', - 'PhabricatorDaemonLogEventsView' => 'applications/daemon/view/PhabricatorDaemonLogEventsView.php', 'PhabricatorDaemonLogGarbageCollector' => 'applications/daemon/garbagecollector/PhabricatorDaemonLogGarbageCollector.php', 'PhabricatorDaemonLogListController' => 'applications/daemon/controller/PhabricatorDaemonLogListController.php', 'PhabricatorDaemonLogListView' => 'applications/daemon/view/PhabricatorDaemonLogListView.php', @@ -2888,6 +2911,7 @@ phutil_register_library_map(array( 'PhabricatorDashboardApplication' => 'applications/dashboard/application/PhabricatorDashboardApplication.php', 'PhabricatorDashboardArchiveController' => 'applications/dashboard/controller/PhabricatorDashboardArchiveController.php', 'PhabricatorDashboardArrangeController' => 'applications/dashboard/controller/PhabricatorDashboardArrangeController.php', + 'PhabricatorDashboardConsoleController' => 'applications/dashboard/controller/PhabricatorDashboardConsoleController.php', 'PhabricatorDashboardController' => 'applications/dashboard/controller/PhabricatorDashboardController.php', 'PhabricatorDashboardDAO' => 'applications/dashboard/storage/PhabricatorDashboardDAO.php', 'PhabricatorDashboardDashboardHasPanelEdgeType' => 'applications/dashboard/edge/PhabricatorDashboardDashboardHasPanelEdgeType.php', @@ -2927,6 +2951,25 @@ phutil_register_library_map(array( 'PhabricatorDashboardPanelTransactionQuery' => 'applications/dashboard/query/PhabricatorDashboardPanelTransactionQuery.php', 'PhabricatorDashboardPanelType' => 'applications/dashboard/paneltype/PhabricatorDashboardPanelType.php', 'PhabricatorDashboardPanelViewController' => 'applications/dashboard/controller/PhabricatorDashboardPanelViewController.php', + 'PhabricatorDashboardPortal' => 'applications/dashboard/storage/PhabricatorDashboardPortal.php', + 'PhabricatorDashboardPortalController' => 'applications/dashboard/controller/portal/PhabricatorDashboardPortalController.php', + 'PhabricatorDashboardPortalEditConduitAPIMethod' => 'applications/dashboard/conduit/PhabricatorDashboardPortalEditConduitAPIMethod.php', + 'PhabricatorDashboardPortalEditController' => 'applications/dashboard/controller/portal/PhabricatorDashboardPortalEditController.php', + 'PhabricatorDashboardPortalEditEngine' => 'applications/dashboard/editor/PhabricatorDashboardPortalEditEngine.php', + 'PhabricatorDashboardPortalEditor' => 'applications/dashboard/editor/PhabricatorDashboardPortalEditor.php', + 'PhabricatorDashboardPortalListController' => 'applications/dashboard/controller/portal/PhabricatorDashboardPortalListController.php', + 'PhabricatorDashboardPortalMenuItem' => 'applications/dashboard/menuitem/PhabricatorDashboardPortalMenuItem.php', + 'PhabricatorDashboardPortalNameTransaction' => 'applications/dashboard/xaction/portal/PhabricatorDashboardPortalNameTransaction.php', + 'PhabricatorDashboardPortalPHIDType' => 'applications/dashboard/phid/PhabricatorDashboardPortalPHIDType.php', + 'PhabricatorDashboardPortalProfileMenuEngine' => 'applications/dashboard/engine/PhabricatorDashboardPortalProfileMenuEngine.php', + 'PhabricatorDashboardPortalQuery' => 'applications/dashboard/query/PhabricatorDashboardPortalQuery.php', + 'PhabricatorDashboardPortalSearchConduitAPIMethod' => 'applications/dashboard/conduit/PhabricatorDashboardPortalSearchConduitAPIMethod.php', + 'PhabricatorDashboardPortalSearchEngine' => 'applications/dashboard/query/PhabricatorDashboardPortalSearchEngine.php', + 'PhabricatorDashboardPortalStatus' => 'applications/dashboard/constants/PhabricatorDashboardPortalStatus.php', + 'PhabricatorDashboardPortalTransaction' => 'applications/dashboard/storage/PhabricatorDashboardPortalTransaction.php', + 'PhabricatorDashboardPortalTransactionQuery' => 'applications/dashboard/query/PhabricatorDashboardPortalTransactionQuery.php', + 'PhabricatorDashboardPortalTransactionType' => 'applications/dashboard/xaction/portal/PhabricatorDashboardPortalTransactionType.php', + 'PhabricatorDashboardPortalViewController' => 'applications/dashboard/controller/portal/PhabricatorDashboardPortalViewController.php', 'PhabricatorDashboardProfileController' => 'applications/dashboard/controller/PhabricatorDashboardProfileController.php', 'PhabricatorDashboardProfileMenuItem' => 'applications/search/menuitem/PhabricatorDashboardProfileMenuItem.php', 'PhabricatorDashboardQuery' => 'applications/dashboard/query/PhabricatorDashboardQuery.php', @@ -2958,6 +3001,7 @@ phutil_register_library_map(array( 'PhabricatorDebugController' => 'applications/system/controller/PhabricatorDebugController.php', 'PhabricatorDefaultRequestExceptionHandler' => 'aphront/handler/PhabricatorDefaultRequestExceptionHandler.php', 'PhabricatorDefaultSyntaxStyle' => 'infrastructure/syntax/PhabricatorDefaultSyntaxStyle.php', + 'PhabricatorDefaultUnlockEngine' => 'applications/system/engine/PhabricatorDefaultUnlockEngine.php', 'PhabricatorDestructibleCodex' => 'applications/system/codex/PhabricatorDestructibleCodex.php', 'PhabricatorDestructibleCodexInterface' => 'applications/system/interface/PhabricatorDestructibleCodexInterface.php', 'PhabricatorDestructibleInterface' => 'applications/system/interface/PhabricatorDestructibleInterface.php', @@ -2968,6 +3012,8 @@ phutil_register_library_map(array( 'PhabricatorDeveloperPreferencesSettingsPanel' => 'applications/settings/panel/PhabricatorDeveloperPreferencesSettingsPanel.php', 'PhabricatorDiffInlineCommentQuery' => 'infrastructure/diff/query/PhabricatorDiffInlineCommentQuery.php', 'PhabricatorDiffPreferencesSettingsPanel' => 'applications/settings/panel/PhabricatorDiffPreferencesSettingsPanel.php', + 'PhabricatorDiffScopeEngine' => 'infrastructure/diff/PhabricatorDiffScopeEngine.php', + 'PhabricatorDiffScopeEngineTestCase' => 'infrastructure/diff/__tests__/PhabricatorDiffScopeEngineTestCase.php', 'PhabricatorDifferenceEngine' => 'infrastructure/diff/PhabricatorDifferenceEngine.php', 'PhabricatorDifferentialApplication' => 'applications/differential/application/PhabricatorDifferentialApplication.php', 'PhabricatorDifferentialAttachCommitWorkflow' => 'applications/differential/management/PhabricatorDifferentialAttachCommitWorkflow.php', @@ -3093,7 +3139,7 @@ phutil_register_library_map(array( 'PhabricatorEmojiDatasource' => 'applications/macro/typeahead/PhabricatorEmojiDatasource.php', 'PhabricatorEmojiRemarkupRule' => 'applications/macro/markup/PhabricatorEmojiRemarkupRule.php', 'PhabricatorEmojiTranslation' => 'infrastructure/internationalization/translation/PhabricatorEmojiTranslation.php', - 'PhabricatorEmptyQueryException' => 'infrastructure/query/PhabricatorEmptyQueryException.php', + 'PhabricatorEmptyQueryException' => 'infrastructure/query/exception/PhabricatorEmptyQueryException.php', 'PhabricatorEnumConfigType' => 'applications/config/type/PhabricatorEnumConfigType.php', 'PhabricatorEnv' => 'infrastructure/env/PhabricatorEnv.php', 'PhabricatorEnvTestCase' => 'infrastructure/env/__tests__/PhabricatorEnvTestCase.php', @@ -3370,6 +3416,7 @@ phutil_register_library_map(array( 'PhabricatorInternationalizationManagementExtractWorkflow' => 'infrastructure/internationalization/management/PhabricatorInternationalizationManagementExtractWorkflow.php', 'PhabricatorInternationalizationManagementWorkflow' => 'infrastructure/internationalization/management/PhabricatorInternationalizationManagementWorkflow.php', 'PhabricatorInvalidConfigSetupCheck' => 'applications/config/check/PhabricatorInvalidConfigSetupCheck.php', + 'PhabricatorInvalidQueryCursorException' => 'infrastructure/query/exception/PhabricatorInvalidQueryCursorException.php', 'PhabricatorIteratedMD5PasswordHasher' => 'infrastructure/util/password/PhabricatorIteratedMD5PasswordHasher.php', 'PhabricatorIteratedMD5PasswordHasherTestCase' => 'infrastructure/util/password/__tests__/PhabricatorIteratedMD5PasswordHasherTestCase.php', 'PhabricatorIteratorFileUploadSource' => 'applications/files/uploadsource/PhabricatorIteratorFileUploadSource.php', @@ -3442,6 +3489,7 @@ phutil_register_library_map(array( 'PhabricatorMacroTransactionType' => 'applications/macro/xaction/PhabricatorMacroTransactionType.php', 'PhabricatorMacroViewController' => 'applications/macro/controller/PhabricatorMacroViewController.php', 'PhabricatorMailAdapter' => 'applications/metamta/adapter/PhabricatorMailAdapter.php', + 'PhabricatorMailAdapterTestCase' => 'applications/metamta/adapter/__tests__/PhabricatorMailAdapterTestCase.php', 'PhabricatorMailAmazonSESAdapter' => 'applications/metamta/adapter/PhabricatorMailAmazonSESAdapter.php', 'PhabricatorMailAmazonSNSAdapter' => 'applications/metamta/adapter/PhabricatorMailAmazonSNSAdapter.php', 'PhabricatorMailAttachment' => 'applications/metamta/message/PhabricatorMailAttachment.php', @@ -3556,6 +3604,8 @@ phutil_register_library_map(array( 'PhabricatorMetaMTASchemaSpec' => 'applications/metamta/storage/PhabricatorMetaMTASchemaSpec.php', 'PhabricatorMetaMTASendGridReceiveController' => 'applications/metamta/controller/PhabricatorMetaMTASendGridReceiveController.php', 'PhabricatorMetaMTAWorker' => 'applications/metamta/PhabricatorMetaMTAWorker.php', + 'PhabricatorMetronome' => 'infrastructure/util/PhabricatorMetronome.php', + 'PhabricatorMetronomeTestCase' => 'infrastructure/util/__tests__/PhabricatorMetronomeTestCase.php', 'PhabricatorMetronomicTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorMetronomicTriggerClock.php', 'PhabricatorModularTransaction' => 'applications/transactions/storage/PhabricatorModularTransaction.php', 'PhabricatorModularTransactionType' => 'applications/transactions/storage/PhabricatorModularTransactionType.php', @@ -3666,10 +3716,12 @@ phutil_register_library_map(array( 'PhabricatorOlderInlinesSetting' => 'applications/settings/setting/PhabricatorOlderInlinesSetting.php', 'PhabricatorOneTimeTriggerClock' => 'infrastructure/daemon/workers/clock/PhabricatorOneTimeTriggerClock.php', 'PhabricatorOpcodeCacheSpec' => 'applications/cache/spec/PhabricatorOpcodeCacheSpec.php', + 'PhabricatorOptionExportField' => 'infrastructure/export/field/PhabricatorOptionExportField.php', 'PhabricatorOptionGroupSetting' => 'applications/settings/setting/PhabricatorOptionGroupSetting.php', 'PhabricatorOwnerPathQuery' => 'applications/owners/query/PhabricatorOwnerPathQuery.php', 'PhabricatorOwnersApplication' => 'applications/owners/application/PhabricatorOwnersApplication.php', 'PhabricatorOwnersArchiveController' => 'applications/owners/controller/PhabricatorOwnersArchiveController.php', + 'PhabricatorOwnersAuditRule' => 'applications/owners/constants/PhabricatorOwnersAuditRule.php', 'PhabricatorOwnersConfigOptions' => 'applications/owners/config/PhabricatorOwnersConfigOptions.php', 'PhabricatorOwnersConfiguredCustomField' => 'applications/owners/customfield/PhabricatorOwnersConfiguredCustomField.php', 'PhabricatorOwnersController' => 'applications/owners/controller/PhabricatorOwnersController.php', @@ -3868,7 +3920,6 @@ phutil_register_library_map(array( 'PhabricatorPeopleInviteController' => 'applications/people/controller/PhabricatorPeopleInviteController.php', 'PhabricatorPeopleInviteListController' => 'applications/people/controller/PhabricatorPeopleInviteListController.php', 'PhabricatorPeopleInviteSendController' => 'applications/people/controller/PhabricatorPeopleInviteSendController.php', - 'PhabricatorPeopleLdapController' => 'applications/people/controller/PhabricatorPeopleLdapController.php', 'PhabricatorPeopleListController' => 'applications/people/controller/PhabricatorPeopleListController.php', 'PhabricatorPeopleLogQuery' => 'applications/people/query/PhabricatorPeopleLogQuery.php', 'PhabricatorPeopleLogSearchEngine' => 'applications/people/query/PhabricatorPeopleLogSearchEngine.php', @@ -3901,6 +3952,7 @@ phutil_register_library_map(array( 'PhabricatorPeopleTransactionQuery' => 'applications/people/query/PhabricatorPeopleTransactionQuery.php', 'PhabricatorPeopleUserFunctionDatasource' => 'applications/people/typeahead/PhabricatorPeopleUserFunctionDatasource.php', 'PhabricatorPeopleUserPHIDType' => 'applications/people/phid/PhabricatorPeopleUserPHIDType.php', + 'PhabricatorPeopleUsernameMailEngine' => 'applications/people/mail/PhabricatorPeopleUsernameMailEngine.php', 'PhabricatorPeopleWelcomeController' => 'applications/people/controller/PhabricatorPeopleWelcomeController.php', 'PhabricatorPeopleWelcomeMailEngine' => 'applications/people/mail/PhabricatorPeopleWelcomeMailEngine.php', 'PhabricatorPhabricatorAuthProvider' => 'applications/auth/provider/PhabricatorPhabricatorAuthProvider.php', @@ -4009,6 +4061,8 @@ phutil_register_library_map(array( 'PhabricatorProfileMenuItemConfigurationTransactionQuery' => 'applications/search/query/PhabricatorProfileMenuItemConfigurationTransactionQuery.php', 'PhabricatorProfileMenuItemIconSet' => 'applications/search/menuitem/PhabricatorProfileMenuItemIconSet.php', 'PhabricatorProfileMenuItemPHIDType' => 'applications/search/phidtype/PhabricatorProfileMenuItemPHIDType.php', + 'PhabricatorProfileMenuItemView' => 'applications/search/engine/PhabricatorProfileMenuItemView.php', + 'PhabricatorProfileMenuItemViewList' => 'applications/search/engine/PhabricatorProfileMenuItemViewList.php', 'PhabricatorProject' => 'applications/project/storage/PhabricatorProject.php', 'PhabricatorProjectAddHeraldAction' => 'applications/project/herald/PhabricatorProjectAddHeraldAction.php', 'PhabricatorProjectApplication' => 'applications/project/application/PhabricatorProjectApplication.php', @@ -4025,17 +4079,33 @@ phutil_register_library_map(array( 'PhabricatorProjectColorTransaction' => 'applications/project/xaction/PhabricatorProjectColorTransaction.php', 'PhabricatorProjectColorsConfigType' => 'applications/project/config/PhabricatorProjectColorsConfigType.php', 'PhabricatorProjectColumn' => 'applications/project/storage/PhabricatorProjectColumn.php', + 'PhabricatorProjectColumnAuthorOrder' => 'applications/project/order/PhabricatorProjectColumnAuthorOrder.php', + 'PhabricatorProjectColumnCreatedOrder' => 'applications/project/order/PhabricatorProjectColumnCreatedOrder.php', 'PhabricatorProjectColumnDetailController' => 'applications/project/controller/PhabricatorProjectColumnDetailController.php', 'PhabricatorProjectColumnEditController' => 'applications/project/controller/PhabricatorProjectColumnEditController.php', + 'PhabricatorProjectColumnHeader' => 'applications/project/order/PhabricatorProjectColumnHeader.php', 'PhabricatorProjectColumnHideController' => 'applications/project/controller/PhabricatorProjectColumnHideController.php', + 'PhabricatorProjectColumnLimitTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php', + 'PhabricatorProjectColumnNameTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php', + 'PhabricatorProjectColumnNaturalOrder' => 'applications/project/order/PhabricatorProjectColumnNaturalOrder.php', + 'PhabricatorProjectColumnOrder' => 'applications/project/order/PhabricatorProjectColumnOrder.php', + 'PhabricatorProjectColumnOwnerOrder' => 'applications/project/order/PhabricatorProjectColumnOwnerOrder.php', 'PhabricatorProjectColumnPHIDType' => 'applications/project/phid/PhabricatorProjectColumnPHIDType.php', + 'PhabricatorProjectColumnPointsOrder' => 'applications/project/order/PhabricatorProjectColumnPointsOrder.php', 'PhabricatorProjectColumnPosition' => 'applications/project/storage/PhabricatorProjectColumnPosition.php', 'PhabricatorProjectColumnPositionQuery' => 'applications/project/query/PhabricatorProjectColumnPositionQuery.php', + 'PhabricatorProjectColumnPriorityOrder' => 'applications/project/order/PhabricatorProjectColumnPriorityOrder.php', 'PhabricatorProjectColumnQuery' => 'applications/project/query/PhabricatorProjectColumnQuery.php', + 'PhabricatorProjectColumnRemoveTriggerController' => 'applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php', 'PhabricatorProjectColumnSearchEngine' => 'applications/project/query/PhabricatorProjectColumnSearchEngine.php', + 'PhabricatorProjectColumnStatusOrder' => 'applications/project/order/PhabricatorProjectColumnStatusOrder.php', + 'PhabricatorProjectColumnStatusTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php', + 'PhabricatorProjectColumnTitleOrder' => 'applications/project/order/PhabricatorProjectColumnTitleOrder.php', 'PhabricatorProjectColumnTransaction' => 'applications/project/storage/PhabricatorProjectColumnTransaction.php', 'PhabricatorProjectColumnTransactionEditor' => 'applications/project/editor/PhabricatorProjectColumnTransactionEditor.php', 'PhabricatorProjectColumnTransactionQuery' => 'applications/project/query/PhabricatorProjectColumnTransactionQuery.php', + 'PhabricatorProjectColumnTransactionType' => 'applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php', + 'PhabricatorProjectColumnTriggerTransaction' => 'applications/project/xaction/column/PhabricatorProjectColumnTriggerTransaction.php', 'PhabricatorProjectConfigOptions' => 'applications/project/config/PhabricatorProjectConfigOptions.php', 'PhabricatorProjectConfiguredCustomField' => 'applications/project/customfield/PhabricatorProjectConfiguredCustomField.php', 'PhabricatorProjectController' => 'applications/project/controller/PhabricatorProjectController.php', @@ -4050,6 +4120,7 @@ phutil_register_library_map(array( 'PhabricatorProjectDefaultController' => 'applications/project/controller/PhabricatorProjectDefaultController.php', 'PhabricatorProjectDescriptionField' => 'applications/project/customfield/PhabricatorProjectDescriptionField.php', 'PhabricatorProjectDetailsProfileMenuItem' => 'applications/project/menuitem/PhabricatorProjectDetailsProfileMenuItem.php', + 'PhabricatorProjectDropEffect' => 'applications/project/icon/PhabricatorProjectDropEffect.php', 'PhabricatorProjectEditController' => 'applications/project/controller/PhabricatorProjectEditController.php', 'PhabricatorProjectEditEngine' => 'applications/project/engine/PhabricatorProjectEditEngine.php', 'PhabricatorProjectEditPictureController' => 'applications/project/controller/PhabricatorProjectEditPictureController.php', @@ -4128,6 +4199,30 @@ phutil_register_library_map(array( 'PhabricatorProjectTransactionEditor' => 'applications/project/editor/PhabricatorProjectTransactionEditor.php', 'PhabricatorProjectTransactionQuery' => 'applications/project/query/PhabricatorProjectTransactionQuery.php', 'PhabricatorProjectTransactionType' => 'applications/project/xaction/PhabricatorProjectTransactionType.php', + 'PhabricatorProjectTrigger' => 'applications/project/storage/PhabricatorProjectTrigger.php', + 'PhabricatorProjectTriggerController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerController.php', + 'PhabricatorProjectTriggerCorruptionException' => 'applications/project/exception/PhabricatorProjectTriggerCorruptionException.php', + 'PhabricatorProjectTriggerEditController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php', + 'PhabricatorProjectTriggerEditor' => 'applications/project/editor/PhabricatorProjectTriggerEditor.php', + 'PhabricatorProjectTriggerInvalidRule' => 'applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php', + 'PhabricatorProjectTriggerListController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerListController.php', + 'PhabricatorProjectTriggerManiphestPriorityRule' => 'applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php', + 'PhabricatorProjectTriggerManiphestStatusRule' => 'applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php', + 'PhabricatorProjectTriggerNameTransaction' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php', + 'PhabricatorProjectTriggerPHIDType' => 'applications/project/phid/PhabricatorProjectTriggerPHIDType.php', + 'PhabricatorProjectTriggerPlaySoundRule' => 'applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php', + 'PhabricatorProjectTriggerQuery' => 'applications/project/query/PhabricatorProjectTriggerQuery.php', + 'PhabricatorProjectTriggerRule' => 'applications/project/trigger/PhabricatorProjectTriggerRule.php', + 'PhabricatorProjectTriggerRuleRecord' => 'applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php', + 'PhabricatorProjectTriggerRulesetTransaction' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php', + 'PhabricatorProjectTriggerSearchEngine' => 'applications/project/query/PhabricatorProjectTriggerSearchEngine.php', + 'PhabricatorProjectTriggerTransaction' => 'applications/project/storage/PhabricatorProjectTriggerTransaction.php', + 'PhabricatorProjectTriggerTransactionQuery' => 'applications/project/query/PhabricatorProjectTriggerTransactionQuery.php', + 'PhabricatorProjectTriggerTransactionType' => 'applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php', + 'PhabricatorProjectTriggerUnknownRule' => 'applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php', + 'PhabricatorProjectTriggerUsage' => 'applications/project/storage/PhabricatorProjectTriggerUsage.php', + 'PhabricatorProjectTriggerUsageIndexEngineExtension' => 'applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php', + 'PhabricatorProjectTriggerViewController' => 'applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php', 'PhabricatorProjectTypeTransaction' => 'applications/project/xaction/PhabricatorProjectTypeTransaction.php', 'PhabricatorProjectUIEventListener' => 'applications/project/events/PhabricatorProjectUIEventListener.php', 'PhabricatorProjectUpdateController' => 'applications/project/controller/PhabricatorProjectUpdateController.php', @@ -4159,6 +4254,7 @@ phutil_register_library_map(array( 'PhabricatorPygmentSetupCheck' => 'applications/config/check/PhabricatorPygmentSetupCheck.php', 'PhabricatorQuery' => 'infrastructure/query/PhabricatorQuery.php', 'PhabricatorQueryConstraint' => 'infrastructure/query/constraint/PhabricatorQueryConstraint.php', + 'PhabricatorQueryCursor' => 'infrastructure/query/policy/PhabricatorQueryCursor.php', 'PhabricatorQueryIterator' => 'infrastructure/storage/lisk/PhabricatorQueryIterator.php', 'PhabricatorQueryOrderItem' => 'infrastructure/query/order/PhabricatorQueryOrderItem.php', 'PhabricatorQueryOrderTestCase' => 'infrastructure/query/order/__tests__/PhabricatorQueryOrderTestCase.php', @@ -4681,6 +4777,8 @@ phutil_register_library_map(array( 'PhabricatorUnitTestContentSource' => 'infrastructure/contentsource/PhabricatorUnitTestContentSource.php', 'PhabricatorUnitsTestCase' => 'view/__tests__/PhabricatorUnitsTestCase.php', 'PhabricatorUnknownContentSource' => 'infrastructure/contentsource/PhabricatorUnknownContentSource.php', + 'PhabricatorUnlockEngine' => 'applications/system/engine/PhabricatorUnlockEngine.php', + 'PhabricatorUnlockableInterface' => 'applications/system/interface/PhabricatorUnlockableInterface.php', 'PhabricatorUnsubscribedFromObjectEdgeType' => 'applications/transactions/edges/PhabricatorUnsubscribedFromObjectEdgeType.php', 'PhabricatorUser' => 'applications/people/storage/PhabricatorUser.php', 'PhabricatorUserApproveTransaction' => 'applications/people/xaction/PhabricatorUserApproveTransaction.php', @@ -4988,6 +5086,7 @@ phutil_register_library_map(array( 'PhortuneAccountViewController' => 'applications/phortune/controller/account/PhortuneAccountViewController.php', 'PhortuneAdHocCart' => 'applications/phortune/cart/PhortuneAdHocCart.php', 'PhortuneAdHocProduct' => 'applications/phortune/product/PhortuneAdHocProduct.php', + 'PhortuneAddPaymentMethodAction' => 'applications/phortune/action/PhortuneAddPaymentMethodAction.php', 'PhortuneCart' => 'applications/phortune/storage/PhortuneCart.php', 'PhortuneCartAcceptController' => 'applications/phortune/controller/cart/PhortuneCartAcceptController.php', 'PhortuneCartCancelController' => 'applications/phortune/controller/cart/PhortuneCartCancelController.php', @@ -5016,6 +5115,7 @@ phutil_register_library_map(array( 'PhortuneCurrencySerializer' => 'applications/phortune/currency/PhortuneCurrencySerializer.php', 'PhortuneCurrencyTestCase' => 'applications/phortune/currency/__tests__/PhortuneCurrencyTestCase.php', 'PhortuneDAO' => 'applications/phortune/storage/PhortuneDAO.php', + 'PhortuneDisplayException' => 'applications/phortune/exception/PhortuneDisplayException.php', 'PhortuneErrCode' => 'applications/phortune/constants/PhortuneErrCode.php', 'PhortuneInvoiceView' => 'applications/phortune/view/PhortuneInvoiceView.php', 'PhortuneLandingController' => 'applications/phortune/controller/PhortuneLandingController.php', @@ -6165,6 +6265,7 @@ phutil_register_library_map(array( 'DifferentialRevisionUpdateTransaction' => 'DifferentialRevisionTransactionType', 'DifferentialRevisionViewController' => 'DifferentialController', 'DifferentialRevisionVoidTransaction' => 'DifferentialRevisionTransactionType', + 'DifferentialRevisionWrongBuildsTransaction' => 'DifferentialRevisionTransactionType', 'DifferentialRevisionWrongStateTransaction' => 'DifferentialRevisionTransactionType', 'DifferentialSchemaSpec' => 'PhabricatorConfigSchemaSpec', 'DifferentialSetDiffPropertyConduitAPIMethod' => 'DifferentialConduitAPIMethod', @@ -6930,19 +7031,28 @@ phutil_register_library_map(array( 'PhabricatorNgramsInterface', 'PhabricatorConduitResultInterface', 'PhabricatorProjectInterface', + 'PhabricatorPolicyCodexInterface', ), + 'HarbormasterBuildPlanBehavior' => 'Phobject', + 'HarbormasterBuildPlanBehaviorOption' => 'Phobject', + 'HarbormasterBuildPlanBehaviorTransaction' => 'HarbormasterBuildPlanTransactionType', 'HarbormasterBuildPlanDatasource' => 'PhabricatorTypeaheadDatasource', 'HarbormasterBuildPlanDefaultEditCapability' => 'PhabricatorPolicyCapability', 'HarbormasterBuildPlanDefaultViewCapability' => 'PhabricatorPolicyCapability', + 'HarbormasterBuildPlanEditAPIMethod' => 'PhabricatorEditEngineAPIMethod', 'HarbormasterBuildPlanEditEngine' => 'PhabricatorEditEngine', 'HarbormasterBuildPlanEditor' => 'PhabricatorApplicationTransactionEditor', 'HarbormasterBuildPlanNameNgrams' => 'PhabricatorSearchNgrams', + 'HarbormasterBuildPlanNameTransaction' => 'HarbormasterBuildPlanTransactionType', 'HarbormasterBuildPlanPHIDType' => 'PhabricatorPHIDType', + 'HarbormasterBuildPlanPolicyCodex' => 'PhabricatorPolicyCodex', 'HarbormasterBuildPlanQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'HarbormasterBuildPlanSearchAPIMethod' => 'PhabricatorSearchEngineAPIMethod', 'HarbormasterBuildPlanSearchEngine' => 'PhabricatorApplicationSearchEngine', - 'HarbormasterBuildPlanTransaction' => 'PhabricatorApplicationTransaction', + 'HarbormasterBuildPlanStatusTransaction' => 'HarbormasterBuildPlanTransactionType', + 'HarbormasterBuildPlanTransaction' => 'PhabricatorModularTransaction', 'HarbormasterBuildPlanTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'HarbormasterBuildPlanTransactionType' => 'PhabricatorModularTransactionType', 'HarbormasterBuildQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'HarbormasterBuildRequest' => 'Phobject', 'HarbormasterBuildSearchConduitAPIMethod' => 'PhabricatorSearchEngineAPIMethod', @@ -6980,7 +7090,12 @@ phutil_register_library_map(array( 'HarbormasterBuildTransaction' => 'PhabricatorApplicationTransaction', 'HarbormasterBuildTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 'HarbormasterBuildTransactionQuery' => 'PhabricatorApplicationTransactionQuery', - 'HarbormasterBuildUnitMessage' => 'HarbormasterDAO', + 'HarbormasterBuildUnitMessage' => array( + 'HarbormasterDAO', + 'PhabricatorPolicyInterface', + ), + 'HarbormasterBuildUnitMessageQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'HarbormasterBuildView' => 'AphrontView', 'HarbormasterBuildViewController' => 'HarbormasterController', 'HarbormasterBuildWorker' => 'HarbormasterWorker', 'HarbormasterBuildable' => array( @@ -7037,6 +7152,7 @@ phutil_register_library_map(array( 'HarbormasterMessageType' => 'Phobject', 'HarbormasterObject' => 'HarbormasterDAO', 'HarbormasterOtherBuildStepGroup' => 'HarbormasterBuildStepGroup', + 'HarbormasterPlanBehaviorController' => 'HarbormasterPlanController', 'HarbormasterPlanController' => 'HarbormasterController', 'HarbormasterPlanDisableController' => 'HarbormasterPlanController', 'HarbormasterPlanEditController' => 'HarbormasterPlanController', @@ -7050,6 +7166,7 @@ phutil_register_library_map(array( 'HarbormasterQueryBuildsConduitAPIMethod' => 'HarbormasterConduitAPIMethod', 'HarbormasterQueryBuildsSearchEngineAttachment' => 'PhabricatorSearchEngineAttachment', 'HarbormasterRemarkupRule' => 'PhabricatorObjectRemarkupRule', + 'HarbormasterRestartException' => 'Exception', 'HarbormasterRunBuildPlansHeraldAction' => 'HeraldAction', 'HarbormasterSchemaSpec' => 'PhabricatorConfigSchemaSpec', 'HarbormasterScratchTable' => 'HarbormasterDAO', @@ -7059,6 +7176,7 @@ phutil_register_library_map(array( 'HarbormasterStepDeleteController' => 'HarbormasterPlanController', 'HarbormasterStepEditController' => 'HarbormasterPlanController', 'HarbormasterStepViewController' => 'HarbormasterPlanController', + 'HarbormasterString' => 'HarbormasterDAO', 'HarbormasterTargetEngine' => 'Phobject', 'HarbormasterTargetSearchAPIMethod' => 'PhabricatorSearchEngineAPIMethod', 'HarbormasterTargetWorker' => 'HarbormasterWorker', @@ -7148,24 +7266,31 @@ phutil_register_library_map(array( 'PhabricatorFlaggableInterface', 'PhabricatorPolicyInterface', 'PhabricatorDestructibleInterface', + 'PhabricatorIndexableInterface', 'PhabricatorSubscribableInterface', ), + 'HeraldRuleActionAffectsObjectEdgeType' => 'PhabricatorEdgeType', 'HeraldRuleAdapter' => 'HeraldAdapter', 'HeraldRuleAdapterField' => 'HeraldRuleField', 'HeraldRuleController' => 'HeraldController', 'HeraldRuleDatasource' => 'PhabricatorTypeaheadDatasource', + 'HeraldRuleDisableTransaction' => 'HeraldRuleTransactionType', + 'HeraldRuleEditTransaction' => 'HeraldRuleTransactionType', 'HeraldRuleEditor' => 'PhabricatorApplicationTransactionEditor', 'HeraldRuleField' => 'HeraldField', 'HeraldRuleFieldGroup' => 'HeraldFieldGroup', + 'HeraldRuleIndexEngineExtension' => 'PhabricatorIndexEngineExtension', 'HeraldRuleListController' => 'HeraldController', + 'HeraldRuleListView' => 'AphrontView', + 'HeraldRuleNameTransaction' => 'HeraldRuleTransactionType', 'HeraldRulePHIDType' => 'PhabricatorPHIDType', 'HeraldRuleQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'HeraldRuleReplyHandler' => 'PhabricatorApplicationTransactionReplyHandler', 'HeraldRuleSearchEngine' => 'PhabricatorApplicationSearchEngine', 'HeraldRuleSerializer' => 'Phobject', 'HeraldRuleTestCase' => 'PhabricatorTestCase', - 'HeraldRuleTransaction' => 'PhabricatorApplicationTransaction', - 'HeraldRuleTransactionComment' => 'PhabricatorApplicationTransactionComment', + 'HeraldRuleTransaction' => 'PhabricatorModularTransaction', + 'HeraldRuleTransactionType' => 'PhabricatorModularTransactionType', 'HeraldRuleTranscript' => 'Phobject', 'HeraldRuleTypeConfig' => 'Phobject', 'HeraldRuleTypeDatasource' => 'PhabricatorTypeaheadDatasource', @@ -7354,7 +7479,6 @@ phutil_register_library_map(array( 'ManiphestStatusEmailCommand' => 'ManiphestEmailCommand', 'ManiphestStatusSearchConduitAPIMethod' => 'ManiphestConduitAPIMethod', 'ManiphestStatusesConfigType' => 'PhabricatorJSONConfigType', - 'ManiphestSubpriorityController' => 'ManiphestController', 'ManiphestSubtypesConfigType' => 'PhabricatorJSONConfigType', 'ManiphestTask' => array( 'ManiphestDAO', @@ -7377,6 +7501,8 @@ phutil_register_library_map(array( 'PhabricatorEditEngineSubtypeInterface', 'PhabricatorEditEngineLockableInterface', 'PhabricatorEditEngineMFAInterface', + 'PhabricatorPolicyCodexInterface', + 'PhabricatorUnlockableInterface', ), 'ManiphestTaskAssignHeraldAction' => 'HeraldAction', 'ManiphestTaskAssignOtherHeraldAction' => 'ManiphestTaskAssignHeraldAction', @@ -7400,6 +7526,7 @@ phutil_register_library_map(array( 'ManiphestTaskFerretEngine' => 'PhabricatorFerretEngine', 'ManiphestTaskFulltextEngine' => 'PhabricatorFulltextEngine', 'ManiphestTaskGraph' => 'PhabricatorObjectGraph', + 'ManiphestTaskGraphController' => 'ManiphestController', 'ManiphestTaskHasCommitEdgeType' => 'PhabricatorEdgeType', 'ManiphestTaskHasCommitRelationship' => 'ManiphestTaskRelationship', 'ManiphestTaskHasDuplicateTaskEdgeType' => 'PhabricatorEdgeType', @@ -7427,6 +7554,7 @@ phutil_register_library_map(array( 'ManiphestTaskParentTransaction' => 'ManiphestTaskTransactionType', 'ManiphestTaskPoints' => 'Phobject', 'ManiphestTaskPointsTransaction' => 'ManiphestTaskTransactionType', + 'ManiphestTaskPolicyCodex' => 'PhabricatorPolicyCodex', 'ManiphestTaskPriority' => 'ManiphestConstants', 'ManiphestTaskPriorityDatasource' => 'PhabricatorTypeaheadDatasource', 'ManiphestTaskPriorityHeraldAction' => 'HeraldAction', @@ -7447,11 +7575,11 @@ phutil_register_library_map(array( 'ManiphestTaskSubpriorityTransaction' => 'ManiphestTaskTransactionType', 'ManiphestTaskSubtaskController' => 'ManiphestController', 'ManiphestTaskSubtypeDatasource' => 'PhabricatorTypeaheadDatasource', - 'ManiphestTaskTestCase' => 'PhabricatorTestCase', 'ManiphestTaskTitleHeraldField' => 'ManiphestTaskHeraldField', 'ManiphestTaskTitleTransaction' => 'ManiphestTaskTransactionType', 'ManiphestTaskTransactionType' => 'PhabricatorModularTransactionType', 'ManiphestTaskUnblockTransaction' => 'ManiphestTaskTransactionType', + 'ManiphestTaskUnlockEngine' => 'PhabricatorUnlockEngine', 'ManiphestTransaction' => 'PhabricatorModularTransaction', 'ManiphestTransactionComment' => 'PhabricatorApplicationTransactionComment', 'ManiphestTransactionEditor' => 'PhabricatorApplicationTransactionEditor', @@ -7930,6 +8058,8 @@ phutil_register_library_map(array( 'PhabricatorAuthChallengeGarbageCollector' => 'PhabricatorGarbageCollector', 'PhabricatorAuthChallengePHIDType' => 'PhabricatorPHIDType', 'PhabricatorAuthChallengeQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorAuthChallengeStatusController' => 'PhabricatorAuthController', + 'PhabricatorAuthChallengeUpdate' => 'Phobject', 'PhabricatorAuthChangePasswordAction' => 'PhabricatorSystemAction', 'PhabricatorAuthConduitAPIMethod' => 'ConduitAPIMethod', 'PhabricatorAuthConduitTokenRevoker' => 'PhabricatorAuthRevoker', @@ -8024,9 +8154,9 @@ phutil_register_library_map(array( 'PhabricatorAuthInviteVerifyException' => 'PhabricatorAuthInviteDialogException', 'PhabricatorAuthInviteWorker' => 'PhabricatorWorker', 'PhabricatorAuthLinkController' => 'PhabricatorAuthController', + 'PhabricatorAuthLinkMessageType' => 'PhabricatorAuthMessageType', 'PhabricatorAuthListController' => 'PhabricatorAuthProviderConfigController', 'PhabricatorAuthLoginController' => 'PhabricatorAuthController', - 'PhabricatorAuthLoginHandler' => 'Phobject', 'PhabricatorAuthLoginMessageType' => 'PhabricatorAuthMessageType', 'PhabricatorAuthLogoutConduitAPIMethod' => 'PhabricatorAuthConduitAPIMethod', 'PhabricatorAuthMFAEditEngineExtension' => 'PhabricatorEditEngineExtension', @@ -8102,6 +8232,7 @@ phutil_register_library_map(array( 'PhabricatorAuthProviderConfigTransaction' => 'PhabricatorApplicationTransaction', 'PhabricatorAuthProviderConfigTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorAuthProviderController' => 'PhabricatorAuthController', + 'PhabricatorAuthProviderViewController' => 'PhabricatorAuthProviderConfigController', 'PhabricatorAuthProvidersGuidanceContext' => 'PhabricatorGuidanceContext', 'PhabricatorAuthProvidersGuidanceEngineExtension' => 'PhabricatorGuidanceEngineExtension', 'PhabricatorAuthQueryPublicKeysConduitAPIMethod' => 'PhabricatorAuthConduitAPIMethod', @@ -8143,6 +8274,7 @@ phutil_register_library_map(array( 'PhabricatorAuthSessionPHIDType' => 'PhabricatorPHIDType', 'PhabricatorAuthSessionQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorAuthSessionRevoker' => 'PhabricatorAuthRevoker', + 'PhabricatorAuthSetExternalController' => 'PhabricatorAuthController', 'PhabricatorAuthSetPasswordController' => 'PhabricatorAuthController', 'PhabricatorAuthSetupCheck' => 'PhabricatorSetupCheck', 'PhabricatorAuthStartController' => 'PhabricatorAuthController', @@ -8709,8 +8841,6 @@ phutil_register_library_map(array( ), 'PhabricatorDaemonLogEvent' => 'PhabricatorDaemonDAO', 'PhabricatorDaemonLogEventGarbageCollector' => 'PhabricatorGarbageCollector', - 'PhabricatorDaemonLogEventViewController' => 'PhabricatorDaemonController', - 'PhabricatorDaemonLogEventsView' => 'AphrontView', 'PhabricatorDaemonLogGarbageCollector' => 'PhabricatorGarbageCollector', 'PhabricatorDaemonLogListController' => 'PhabricatorDaemonController', 'PhabricatorDaemonLogListView' => 'AphrontView', @@ -8749,6 +8879,7 @@ phutil_register_library_map(array( 'PhabricatorDashboardApplication' => 'PhabricatorApplication', 'PhabricatorDashboardArchiveController' => 'PhabricatorDashboardController', 'PhabricatorDashboardArrangeController' => 'PhabricatorDashboardProfileController', + 'PhabricatorDashboardConsoleController' => 'PhabricatorDashboardController', 'PhabricatorDashboardController' => 'PhabricatorController', 'PhabricatorDashboardDAO' => 'PhabricatorLiskDAO', 'PhabricatorDashboardDashboardHasPanelEdgeType' => 'PhabricatorEdgeType', @@ -8799,6 +8930,30 @@ phutil_register_library_map(array( 'PhabricatorDashboardPanelTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorDashboardPanelType' => 'Phobject', 'PhabricatorDashboardPanelViewController' => 'PhabricatorDashboardController', + 'PhabricatorDashboardPortal' => array( + 'PhabricatorDashboardDAO', + 'PhabricatorApplicationTransactionInterface', + 'PhabricatorPolicyInterface', + 'PhabricatorDestructibleInterface', + ), + 'PhabricatorDashboardPortalController' => 'PhabricatorDashboardController', + 'PhabricatorDashboardPortalEditConduitAPIMethod' => 'PhabricatorEditEngineAPIMethod', + 'PhabricatorDashboardPortalEditController' => 'PhabricatorDashboardPortalController', + 'PhabricatorDashboardPortalEditEngine' => 'PhabricatorEditEngine', + 'PhabricatorDashboardPortalEditor' => 'PhabricatorApplicationTransactionEditor', + 'PhabricatorDashboardPortalListController' => 'PhabricatorDashboardPortalController', + 'PhabricatorDashboardPortalMenuItem' => 'PhabricatorProfileMenuItem', + 'PhabricatorDashboardPortalNameTransaction' => 'PhabricatorDashboardPortalTransactionType', + 'PhabricatorDashboardPortalPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorDashboardPortalProfileMenuEngine' => 'PhabricatorProfileMenuEngine', + 'PhabricatorDashboardPortalQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorDashboardPortalSearchConduitAPIMethod' => 'PhabricatorSearchEngineAPIMethod', + 'PhabricatorDashboardPortalSearchEngine' => 'PhabricatorApplicationSearchEngine', + 'PhabricatorDashboardPortalStatus' => 'Phobject', + 'PhabricatorDashboardPortalTransaction' => 'PhabricatorModularTransaction', + 'PhabricatorDashboardPortalTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'PhabricatorDashboardPortalTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorDashboardPortalViewController' => 'PhabricatorDashboardPortalController', 'PhabricatorDashboardProfileController' => 'PhabricatorController', 'PhabricatorDashboardProfileMenuItem' => 'PhabricatorProfileMenuItem', 'PhabricatorDashboardQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', @@ -8830,6 +8985,7 @@ phutil_register_library_map(array( 'PhabricatorDebugController' => 'PhabricatorController', 'PhabricatorDefaultRequestExceptionHandler' => 'PhabricatorRequestExceptionHandler', 'PhabricatorDefaultSyntaxStyle' => 'PhabricatorSyntaxStyle', + 'PhabricatorDefaultUnlockEngine' => 'PhabricatorUnlockEngine', 'PhabricatorDestructibleCodex' => 'Phobject', 'PhabricatorDestructionEngine' => 'Phobject', 'PhabricatorDestructionEngineExtension' => 'Phobject', @@ -8838,6 +8994,8 @@ phutil_register_library_map(array( 'PhabricatorDeveloperPreferencesSettingsPanel' => 'PhabricatorEditEngineSettingsPanel', 'PhabricatorDiffInlineCommentQuery' => 'PhabricatorApplicationTransactionCommentQuery', 'PhabricatorDiffPreferencesSettingsPanel' => 'PhabricatorEditEngineSettingsPanel', + 'PhabricatorDiffScopeEngine' => 'Phobject', + 'PhabricatorDiffScopeEngineTestCase' => 'PhabricatorTestCase', 'PhabricatorDifferenceEngine' => 'Phobject', 'PhabricatorDifferentialApplication' => 'PhabricatorApplication', 'PhabricatorDifferentialAttachCommitWorkflow' => 'PhabricatorDifferentialManagementWorkflow', @@ -9288,6 +9446,7 @@ phutil_register_library_map(array( 'PhabricatorInternationalizationManagementExtractWorkflow' => 'PhabricatorInternationalizationManagementWorkflow', 'PhabricatorInternationalizationManagementWorkflow' => 'PhabricatorManagementWorkflow', 'PhabricatorInvalidConfigSetupCheck' => 'PhabricatorSetupCheck', + 'PhabricatorInvalidQueryCursorException' => 'Exception', 'PhabricatorIteratedMD5PasswordHasher' => 'PhabricatorPasswordHasher', 'PhabricatorIteratedMD5PasswordHasherTestCase' => 'PhabricatorTestCase', 'PhabricatorIteratorFileUploadSource' => 'PhabricatorFileUploadSource', @@ -9360,6 +9519,7 @@ phutil_register_library_map(array( 'PhabricatorMacroTransactionType' => 'PhabricatorModularTransactionType', 'PhabricatorMacroViewController' => 'PhabricatorMacroController', 'PhabricatorMailAdapter' => 'Phobject', + 'PhabricatorMailAdapterTestCase' => 'PhabricatorTestCase', 'PhabricatorMailAmazonSESAdapter' => 'PhabricatorMailAdapter', 'PhabricatorMailAmazonSNSAdapter' => 'PhabricatorMailAdapter', 'PhabricatorMailAttachment' => 'Phobject', @@ -9488,6 +9648,8 @@ phutil_register_library_map(array( 'PhabricatorMetaMTASchemaSpec' => 'PhabricatorConfigSchemaSpec', 'PhabricatorMetaMTASendGridReceiveController' => 'PhabricatorMetaMTAController', 'PhabricatorMetaMTAWorker' => 'PhabricatorWorker', + 'PhabricatorMetronome' => 'Phobject', + 'PhabricatorMetronomeTestCase' => 'PhabricatorTestCase', 'PhabricatorMetronomicTriggerClock' => 'PhabricatorTriggerClock', 'PhabricatorModularTransaction' => 'PhabricatorApplicationTransaction', 'PhabricatorModularTransactionType' => 'Phobject', @@ -9615,10 +9777,12 @@ phutil_register_library_map(array( 'PhabricatorOlderInlinesSetting' => 'PhabricatorSelectSetting', 'PhabricatorOneTimeTriggerClock' => 'PhabricatorTriggerClock', 'PhabricatorOpcodeCacheSpec' => 'PhabricatorCacheSpec', + 'PhabricatorOptionExportField' => 'PhabricatorExportField', 'PhabricatorOptionGroupSetting' => 'PhabricatorSetting', 'PhabricatorOwnerPathQuery' => 'Phobject', 'PhabricatorOwnersApplication' => 'PhabricatorApplication', 'PhabricatorOwnersArchiveController' => 'PhabricatorOwnersController', + 'PhabricatorOwnersAuditRule' => 'Phobject', 'PhabricatorOwnersConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorOwnersConfiguredCustomField' => array( 'PhabricatorOwnersCustomField', @@ -9869,7 +10033,6 @@ phutil_register_library_map(array( 'PhabricatorPeopleInviteController' => 'PhabricatorPeopleController', 'PhabricatorPeopleInviteListController' => 'PhabricatorPeopleInviteController', 'PhabricatorPeopleInviteSendController' => 'PhabricatorPeopleInviteController', - 'PhabricatorPeopleLdapController' => 'PhabricatorPeopleController', 'PhabricatorPeopleListController' => 'PhabricatorPeopleController', 'PhabricatorPeopleLogQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', 'PhabricatorPeopleLogSearchEngine' => 'PhabricatorApplicationSearchEngine', @@ -9902,6 +10065,7 @@ phutil_register_library_map(array( 'PhabricatorPeopleTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorPeopleUserFunctionDatasource' => 'PhabricatorTypeaheadCompositeDatasource', 'PhabricatorPeopleUserPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorPeopleUsernameMailEngine' => 'PhabricatorPeopleMailEngine', 'PhabricatorPeopleWelcomeController' => 'PhabricatorPeopleController', 'PhabricatorPeopleWelcomeMailEngine' => 'PhabricatorPeopleMailEngine', 'PhabricatorPhabricatorAuthProvider' => 'PhabricatorOAuth2AuthProvider', @@ -10035,6 +10199,8 @@ phutil_register_library_map(array( 'PhabricatorProfileMenuItemConfigurationTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorProfileMenuItemIconSet' => 'PhabricatorIconSet', 'PhabricatorProfileMenuItemPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorProfileMenuItemView' => 'Phobject', + 'PhabricatorProfileMenuItemViewList' => 'Phobject', 'PhabricatorProject' => array( 'PhabricatorProjectDAO', 'PhabricatorApplicationTransactionInterface', @@ -10072,20 +10238,36 @@ phutil_register_library_map(array( 'PhabricatorExtendedPolicyInterface', 'PhabricatorConduitResultInterface', ), + 'PhabricatorProjectColumnAuthorOrder' => 'PhabricatorProjectColumnOrder', + 'PhabricatorProjectColumnCreatedOrder' => 'PhabricatorProjectColumnOrder', 'PhabricatorProjectColumnDetailController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectColumnEditController' => 'PhabricatorProjectBoardController', + 'PhabricatorProjectColumnHeader' => 'Phobject', 'PhabricatorProjectColumnHideController' => 'PhabricatorProjectBoardController', + 'PhabricatorProjectColumnLimitTransaction' => 'PhabricatorProjectColumnTransactionType', + 'PhabricatorProjectColumnNameTransaction' => 'PhabricatorProjectColumnTransactionType', + 'PhabricatorProjectColumnNaturalOrder' => 'PhabricatorProjectColumnOrder', + 'PhabricatorProjectColumnOrder' => 'Phobject', + 'PhabricatorProjectColumnOwnerOrder' => 'PhabricatorProjectColumnOrder', 'PhabricatorProjectColumnPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorProjectColumnPointsOrder' => 'PhabricatorProjectColumnOrder', 'PhabricatorProjectColumnPosition' => array( 'PhabricatorProjectDAO', 'PhabricatorPolicyInterface', ), 'PhabricatorProjectColumnPositionQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorProjectColumnPriorityOrder' => 'PhabricatorProjectColumnOrder', 'PhabricatorProjectColumnQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorProjectColumnRemoveTriggerController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectColumnSearchEngine' => 'PhabricatorApplicationSearchEngine', - 'PhabricatorProjectColumnTransaction' => 'PhabricatorApplicationTransaction', + 'PhabricatorProjectColumnStatusOrder' => 'PhabricatorProjectColumnOrder', + 'PhabricatorProjectColumnStatusTransaction' => 'PhabricatorProjectColumnTransactionType', + 'PhabricatorProjectColumnTitleOrder' => 'PhabricatorProjectColumnOrder', + 'PhabricatorProjectColumnTransaction' => 'PhabricatorModularTransaction', 'PhabricatorProjectColumnTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorProjectColumnTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'PhabricatorProjectColumnTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorProjectColumnTriggerTransaction' => 'PhabricatorProjectColumnTransactionType', 'PhabricatorProjectConfigOptions' => 'PhabricatorApplicationConfigOptions', 'PhabricatorProjectConfiguredCustomField' => array( 'PhabricatorProjectStandardCustomField', @@ -10103,6 +10285,7 @@ phutil_register_library_map(array( 'PhabricatorProjectDefaultController' => 'PhabricatorProjectBoardController', 'PhabricatorProjectDescriptionField' => 'PhabricatorProjectStandardCustomField', 'PhabricatorProjectDetailsProfileMenuItem' => 'PhabricatorProfileMenuItem', + 'PhabricatorProjectDropEffect' => 'Phobject', 'PhabricatorProjectEditController' => 'PhabricatorProjectController', 'PhabricatorProjectEditEngine' => 'PhabricatorEditEngine', 'PhabricatorProjectEditPictureController' => 'PhabricatorProjectController', @@ -10183,6 +10366,36 @@ phutil_register_library_map(array( 'PhabricatorProjectTransactionEditor' => 'PhabricatorApplicationTransactionEditor', 'PhabricatorProjectTransactionQuery' => 'PhabricatorApplicationTransactionQuery', 'PhabricatorProjectTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorProjectTrigger' => array( + 'PhabricatorProjectDAO', + 'PhabricatorApplicationTransactionInterface', + 'PhabricatorPolicyInterface', + 'PhabricatorIndexableInterface', + 'PhabricatorDestructibleInterface', + ), + 'PhabricatorProjectTriggerController' => 'PhabricatorProjectController', + 'PhabricatorProjectTriggerCorruptionException' => 'Exception', + 'PhabricatorProjectTriggerEditController' => 'PhabricatorProjectTriggerController', + 'PhabricatorProjectTriggerEditor' => 'PhabricatorApplicationTransactionEditor', + 'PhabricatorProjectTriggerInvalidRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerListController' => 'PhabricatorProjectTriggerController', + 'PhabricatorProjectTriggerManiphestPriorityRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerManiphestStatusRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerNameTransaction' => 'PhabricatorProjectTriggerTransactionType', + 'PhabricatorProjectTriggerPHIDType' => 'PhabricatorPHIDType', + 'PhabricatorProjectTriggerPlaySoundRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerQuery' => 'PhabricatorCursorPagedPolicyAwareQuery', + 'PhabricatorProjectTriggerRule' => 'Phobject', + 'PhabricatorProjectTriggerRuleRecord' => 'Phobject', + 'PhabricatorProjectTriggerRulesetTransaction' => 'PhabricatorProjectTriggerTransactionType', + 'PhabricatorProjectTriggerSearchEngine' => 'PhabricatorApplicationSearchEngine', + 'PhabricatorProjectTriggerTransaction' => 'PhabricatorModularTransaction', + 'PhabricatorProjectTriggerTransactionQuery' => 'PhabricatorApplicationTransactionQuery', + 'PhabricatorProjectTriggerTransactionType' => 'PhabricatorModularTransactionType', + 'PhabricatorProjectTriggerUnknownRule' => 'PhabricatorProjectTriggerRule', + 'PhabricatorProjectTriggerUsage' => 'PhabricatorProjectDAO', + 'PhabricatorProjectTriggerUsageIndexEngineExtension' => 'PhabricatorIndexEngineExtension', + 'PhabricatorProjectTriggerViewController' => 'PhabricatorProjectTriggerController', 'PhabricatorProjectTypeTransaction' => 'PhabricatorProjectTransactionType', 'PhabricatorProjectUIEventListener' => 'PhabricatorEventListener', 'PhabricatorProjectUpdateController' => 'PhabricatorProjectController', @@ -10214,6 +10427,7 @@ phutil_register_library_map(array( 'PhabricatorPygmentSetupCheck' => 'PhabricatorSetupCheck', 'PhabricatorQuery' => 'Phobject', 'PhabricatorQueryConstraint' => 'Phobject', + 'PhabricatorQueryCursor' => 'Phobject', 'PhabricatorQueryIterator' => 'PhutilBufferedIterator', 'PhabricatorQueryOrderItem' => 'Phobject', 'PhabricatorQueryOrderTestCase' => 'PhabricatorTestCase', @@ -10836,6 +11050,7 @@ phutil_register_library_map(array( 'PhabricatorUnitTestContentSource' => 'PhabricatorContentSource', 'PhabricatorUnitsTestCase' => 'PhabricatorTestCase', 'PhabricatorUnknownContentSource' => 'PhabricatorContentSource', + 'PhabricatorUnlockEngine' => 'Phobject', 'PhabricatorUnsubscribedFromObjectEdgeType' => 'PhabricatorEdgeType', 'PhabricatorUser' => array( 'PhabricatorUserDAO', @@ -11232,6 +11447,7 @@ phutil_register_library_map(array( 'PhortuneAccountViewController' => 'PhortuneAccountProfileController', 'PhortuneAdHocCart' => 'PhortuneCartImplementation', 'PhortuneAdHocProduct' => 'PhortuneProductImplementation', + 'PhortuneAddPaymentMethodAction' => 'PhabricatorSystemAction', 'PhortuneCart' => array( 'PhortuneDAO', 'PhabricatorApplicationTransactionInterface', @@ -11267,6 +11483,7 @@ phutil_register_library_map(array( 'PhortuneCurrencySerializer' => 'PhabricatorLiskSerializer', 'PhortuneCurrencyTestCase' => 'PhabricatorTestCase', 'PhortuneDAO' => 'PhabricatorLiskDAO', + 'PhortuneDisplayException' => 'Exception', 'PhortuneErrCode' => 'PhortuneConstants', 'PhortuneInvoiceView' => 'AphrontTagView', 'PhortuneLandingController' => 'PhortuneController', diff --git a/src/aphront/AphrontRequest.php b/src/aphront/AphrontRequest.php index 78e6dac9d3..48004a521f 100644 --- a/src/aphront/AphrontRequest.php +++ b/src/aphront/AphrontRequest.php @@ -591,10 +591,11 @@ final class AphrontRequest extends Phobject { } public function getRequestURI() { - $get = $_GET; - unset($get['__path__']); - $path = phutil_escape_uri($this->getPath()); - return id(new PhutilURI($path))->setQueryParams($get); + $uri_path = phutil_escape_uri($this->getPath()); + $uri_query = idx($_SERVER, 'QUERY_STRING', ''); + + return id(new PhutilURI($uri_path.'?'.$uri_query)) + ->removeQueryParam('__path__'); } public function getAbsoluteRequestURI() { @@ -824,7 +825,10 @@ final class AphrontRequest extends Phobject { } $uri->setPath($this->getPath()); - $uri->setQueryParams(self::flattenData($_GET)); + $uri->removeAllQueryParams(); + foreach (self::flattenData($_GET) as $query_key => $query_value) { + $uri->appendQueryParam($query_key, $query_value); + } $input = PhabricatorStartup::getRawInput(); diff --git a/src/aphront/configuration/AphrontApplicationConfiguration.php b/src/aphront/configuration/AphrontApplicationConfiguration.php index 8d36bbc880..a479209125 100644 --- a/src/aphront/configuration/AphrontApplicationConfiguration.php +++ b/src/aphront/configuration/AphrontApplicationConfiguration.php @@ -118,6 +118,12 @@ final class AphrontApplicationConfiguration $database_exception = $ex; } + // If we're in developer mode, set a flag so that top-level exception + // handlers can add more information. + if (PhabricatorEnv::getEnvConfig('phabricator.developer-mode')) { + $sink->setShowStackTraces(true); + } + if ($database_exception) { $issue = PhabricatorSetupIssue::newDatabaseConnectionIssue( $database_exception, @@ -282,23 +288,69 @@ final class AphrontApplicationConfiguration } } catch (Exception $ex) { $original_exception = $ex; - $response = $this->handleThrowable($ex); } catch (Throwable $ex) { $original_exception = $ex; - $response = $this->handleThrowable($ex); } + $response_exception = null; try { + if ($original_exception) { + $response = $this->handleThrowable($original_exception); + } + $response = $this->produceResponse($request, $response); $response = $controller->willSendResponse($response); $response->setRequest($request); self::writeResponse($sink, $response); } catch (Exception $ex) { + $response_exception = $ex; + } catch (Throwable $ex) { + $response_exception = $ex; + } + + if ($response_exception) { + // If we encountered an exception while building a normal response, then + // encountered another exception while building a response for the first + // exception, just throw the original exception. It is more likely to be + // useful and point at a root cause than the second exception we ran into + // while telling the user about it. if ($original_exception) { throw $original_exception; } - throw $ex; + + // If we built a response successfully and then ran into an exception + // trying to render it, try to handle and present that exception to the + // user using the standard handler. + + // The problem here might be in rendering (more common) or in the actual + // response mechanism (less common). If it's in rendering, we can likely + // still render a nice exception page: the majority of rendering issues + // are in main page content, not content shared with the exception page. + + $handling_exception = null; + try { + $response = $this->handleThrowable($response_exception); + + $response = $this->produceResponse($request, $response); + $response = $controller->willSendResponse($response); + $response->setRequest($request); + + self::writeResponse($sink, $response); + } catch (Exception $ex) { + $handling_exception = $ex; + } catch (Throwable $ex) { + $handling_exception = $ex; + } + + // If we didn't have any luck with that, raise the original response + // exception. As above, this is the root cause exception and more likely + // to be useful. This will go to the fallback error handler at top + // level. + + if ($handling_exception) { + throw $response_exception; + } } return $response; @@ -724,7 +776,6 @@ final class AphrontApplicationConfiguration 'filler' => str_repeat('Q', 1024 * 16), ); - return id(new AphrontJSONResponse()) ->setAddJSONShield(false) ->setContent($result); diff --git a/src/aphront/response/AphrontAjaxResponse.php b/src/aphront/response/AphrontAjaxResponse.php index 1ccb3fe97e..2187defc8f 100644 --- a/src/aphront/response/AphrontAjaxResponse.php +++ b/src/aphront/response/AphrontAjaxResponse.php @@ -32,22 +32,21 @@ final class AphrontAjaxResponse extends AphrontResponse { } public function buildResponseString() { + $request = $this->getRequest(); $console = $this->getConsole(); if ($console) { // NOTE: We're stripping query parameters here both for readability and // to mitigate BREACH and similar attacks. The parameters are available // in the "Request" tab, so this should not impact usability. See T3684. - $uri = $this->getRequest()->getRequestURI(); - $uri = new PhutilURI($uri); - $uri->setQueryParams(array()); + $path = $request->getPath(); Javelin::initBehavior( 'dark-console', array( - 'uri' => (string)$uri, - 'key' => $console->getKey($this->getRequest()), + 'uri' => $path, + 'key' => $console->getKey($request), 'color' => $console->getColor(), - 'quicksand' => $this->getRequest()->isQuicksand(), + 'quicksand' => $request->isQuicksand(), )); } @@ -60,7 +59,6 @@ final class AphrontAjaxResponse extends AphrontResponse { $response = CelerityAPI::getStaticResourceResponse(); - $request = $this->getRequest(); if ($request) { $viewer = $request->getViewer(); if ($viewer) { diff --git a/src/aphront/response/AphrontResponse.php b/src/aphront/response/AphrontResponse.php index 0eab91b2af..9cfda0f5c1 100644 --- a/src/aphront/response/AphrontResponse.php +++ b/src/aphront/response/AphrontResponse.php @@ -218,7 +218,7 @@ abstract class AphrontResponse extends Phobject { $uri = id(new PhutilURI($uri)) ->setPath(null) ->setFragment(null) - ->setQueryParams(array()); + ->removeAllQueryParams(); $uri = (string)$uri; if (preg_match('/[ ;\']/', $uri)) { diff --git a/src/aphront/response/AphrontUnhandledExceptionResponse.php b/src/aphront/response/AphrontUnhandledExceptionResponse.php index efd9d70ead..32d612ca50 100644 --- a/src/aphront/response/AphrontUnhandledExceptionResponse.php +++ b/src/aphront/response/AphrontUnhandledExceptionResponse.php @@ -4,8 +4,20 @@ final class AphrontUnhandledExceptionResponse extends AphrontStandaloneHTMLResponse { private $exception; + private $showStackTraces; + + public function setShowStackTraces($show_stack_traces) { + $this->showStackTraces = $show_stack_traces; + return $this; + } + + public function getShowStackTraces() { + return $this->showStackTraces; + } + + public function setException($exception) { + // NOTE: We accept an Exception or a Throwable. - public function setException(Exception $exception) { // Log the exception unless it's specifically a silent malformed request // exception. @@ -61,10 +73,36 @@ final class AphrontUnhandledExceptionResponse $body = $ex->getMessage(); $body = phutil_escape_html_newlines($body); + $classes = array(); + $classes[] = 'unhandled-exception-detail'; + + $stack = null; + if ($this->getShowStackTraces()) { + try { + $stack = id(new AphrontStackTraceView()) + ->setTrace($ex->getTrace()); + + $stack = hsprintf('%s', $stack); + + $stack = phutil_tag( + 'div', + array( + 'class' => 'unhandled-exception-stack', + ), + $stack); + + $classes[] = 'unhandled-exception-with-stack'; + } catch (Exception $trace_exception) { + $stack = null; + } catch (Throwable $trace_exception) { + $stack = null; + } + } + return phutil_tag( 'div', array( - 'class' => 'unhandled-exception-detail', + 'class' => implode(' ', $classes), ), array( phutil_tag( @@ -79,6 +117,7 @@ final class AphrontUnhandledExceptionResponse 'class' => 'unhandled-exception-body', ), $body), + $stack, )); } diff --git a/src/aphront/sink/AphrontHTTPSink.php b/src/aphront/sink/AphrontHTTPSink.php index 51c54df520..9e43e4a687 100644 --- a/src/aphront/sink/AphrontHTTPSink.php +++ b/src/aphront/sink/AphrontHTTPSink.php @@ -5,14 +5,22 @@ * Normally this is just @{class:AphrontPHPHTTPSink}, which uses "echo" and * "header()" to emit responses. * - * Mostly, this class allows us to do install security or metrics hooks in the - * output pipeline. - * * @task write Writing Response Components * @task emit Emitting the Response */ abstract class AphrontHTTPSink extends Phobject { + private $showStackTraces = false; + + final public function setShowStackTraces($show_stack_traces) { + $this->showStackTraces = $show_stack_traces; + return $this; + } + + final public function getShowStackTraces() { + return $this->showStackTraces; + } + /* -( Writing Response Components )---------------------------------------- */ @@ -103,6 +111,17 @@ abstract class AphrontHTTPSink extends Phobject { // HTTP headers. $data = $response->getContentIterator(); + // This isn't an exceptionally clean separation of concerns, but we need + // to add CSP headers for all response types (including both web pages + // and dialogs) and can't determine the correct CSP until after we render + // the page (because page elements like Recaptcha may add CSP rules). + $static = CelerityAPI::getStaticResourceResponse(); + foreach ($static->getContentSecurityPolicyURIMap() as $kind => $uris) { + foreach ($uris as $uri) { + $response->addContentSecurityPolicyURI($kind, $uri); + } + } + $all_headers = array_merge( $response->getHeaders(), $response->getCacheHeaders()); diff --git a/src/applications/almanac/controller/AlmanacController.php b/src/applications/almanac/controller/AlmanacController.php index 24a8986766..918b3a4ad9 100644 --- a/src/applications/almanac/controller/AlmanacController.php +++ b/src/applications/almanac/controller/AlmanacController.php @@ -67,19 +67,13 @@ abstract class AlmanacController $is_builtin = isset($builtins[$key]); $is_persistent = (bool)$property->getID(); - $delete_uri = id(new PhutilURI($delete_base)) - ->setQueryParams( - array( - 'key' => $key, - 'objectPHID' => $object->getPHID(), - )); + $params = array( + 'key' => $key, + 'objectPHID' => $object->getPHID(), + ); - $edit_uri = id(new PhutilURI($edit_base)) - ->setQueryParams( - array( - 'key' => $key, - 'objectPHID' => $object->getPHID(), - )); + $delete_uri = new PhutilURI($delete_base, $params); + $edit_uri = new PhutilURI($edit_base, $params); $delete = javelin_tag( 'a', @@ -143,7 +137,7 @@ abstract class AlmanacController $phid = $object->getPHID(); $add_uri = id(new PhutilURI($edit_base)) - ->setQueryParam('objectPHID', $object->getPHID()); + ->replaceQueryParam('objectPHID', $object->getPHID()); $can_edit = PhabricatorPolicyFilter::hasCapability( $viewer, diff --git a/src/applications/almanac/query/AlmanacDeviceQuery.php b/src/applications/almanac/query/AlmanacDeviceQuery.php index 0d38070e0b..21f8c952ef 100644 --- a/src/applications/almanac/query/AlmanacDeviceQuery.php +++ b/src/applications/almanac/query/AlmanacDeviceQuery.php @@ -122,11 +122,10 @@ final class AlmanacDeviceQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $device = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $device->getID(), - 'name' => $device->getName(), + 'id' => (int)$object->getID(), + 'name' => $object->getName(), ); } diff --git a/src/applications/almanac/query/AlmanacInterfaceQuery.php b/src/applications/almanac/query/AlmanacInterfaceQuery.php index d5886761c1..5738108ffc 100644 --- a/src/applications/almanac/query/AlmanacInterfaceQuery.php +++ b/src/applications/almanac/query/AlmanacInterfaceQuery.php @@ -78,6 +78,16 @@ final class AlmanacInterfaceQuery return $interfaces; } + protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { + $select = parent::buildSelectClauseParts($conn); + + if ($this->shouldJoinDeviceTable()) { + $select[] = qsprintf($conn, 'device.name'); + } + + return $select; + } + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); @@ -186,15 +196,16 @@ final class AlmanacInterfaceQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $interface = $this->loadCursorObject($cursor); + protected function newPagingMapFromCursorObject( + PhabricatorQueryCursor $cursor, + array $keys) { - $map = array( - 'id' => $interface->getID(), - 'name' => $interface->getDevice()->getName(), + $interface = $cursor->getObject(); + + return array( + 'id' => (int)$interface->getID(), + 'name' => $cursor->getRawRowProperty('device.name'), ); - - return $map; } } diff --git a/src/applications/almanac/query/AlmanacNamespaceQuery.php b/src/applications/almanac/query/AlmanacNamespaceQuery.php index 81332cf03b..d4378e17c7 100644 --- a/src/applications/almanac/query/AlmanacNamespaceQuery.php +++ b/src/applications/almanac/query/AlmanacNamespaceQuery.php @@ -79,11 +79,10 @@ final class AlmanacNamespaceQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $namespace = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $namespace->getID(), - 'name' => $namespace->getName(), + 'id' => (int)$object->getID(), + 'name' => $object->getName(), ); } diff --git a/src/applications/almanac/query/AlmanacServiceQuery.php b/src/applications/almanac/query/AlmanacServiceQuery.php index 3374413e5b..edc55276a9 100644 --- a/src/applications/almanac/query/AlmanacServiceQuery.php +++ b/src/applications/almanac/query/AlmanacServiceQuery.php @@ -206,11 +206,10 @@ final class AlmanacServiceQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $service = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $service->getID(), - 'name' => $service->getName(), + 'id' => (int)$object->getID(), + 'name' => $object->getName(), ); } diff --git a/src/applications/almanac/storage/AlmanacModularTransaction.php b/src/applications/almanac/storage/AlmanacModularTransaction.php index 6497069241..3e2eb0a3ec 100644 --- a/src/applications/almanac/storage/AlmanacModularTransaction.php +++ b/src/applications/almanac/storage/AlmanacModularTransaction.php @@ -7,8 +7,4 @@ abstract class AlmanacModularTransaction return 'almanac'; } - public function getApplicationTransactionCommentObject() { - return null; - } - } diff --git a/src/applications/audit/management/PhabricatorAuditManagementDeleteWorkflow.php b/src/applications/audit/management/PhabricatorAuditManagementDeleteWorkflow.php index 23583422fb..7d14df7239 100644 --- a/src/applications/audit/management/PhabricatorAuditManagementDeleteWorkflow.php +++ b/src/applications/audit/management/PhabricatorAuditManagementDeleteWorkflow.php @@ -105,10 +105,10 @@ final class PhabricatorAuditManagementDeleteWorkflow $query->withPHIDs(mpull($commits, 'getPHID')); } - $commits = $query->execute(); - $commits = mpull($commits, null, 'getPHID'); + $commit_iterator = new PhabricatorQueryIterator($query); + $audits = array(); - foreach ($commits as $commit) { + foreach ($commit_iterator as $commit) { $commit_audits = $commit->getAudits(); foreach ($commit_audits as $key => $audit) { if ($id_map && empty($id_map[$audit->getID()])) { @@ -131,51 +131,87 @@ final class PhabricatorAuditManagementDeleteWorkflow continue; } } - $audits[] = $commit_audits; - } - $audits = array_mergev($audits); - $console = PhutilConsole::getConsole(); + if (!$commit_audits) { + continue; + } - if (!$audits) { - $console->writeErr("%s\n", pht('No audits match the query.')); - return 0; - } + $handles = id(new PhabricatorHandleQuery()) + ->setViewer($viewer) + ->withPHIDs(mpull($commit_audits, 'getAuditorPHID')) + ->execute(); - $handles = id(new PhabricatorHandleQuery()) - ->setViewer($this->getViewer()) - ->withPHIDs(mpull($audits, 'getAuditorPHID')) - ->execute(); + foreach ($commit_audits as $audit) { + $audit_id = $audit->getID(); - - foreach ($audits as $audit) { - $commit = $commits[$audit->getCommitPHID()]; - - $console->writeOut( - "%s\n", - sprintf( + $description = sprintf( '%10d %-16s %-16s %s: %s', - $audit->getID(), + $audit_id, $handles[$audit->getAuditorPHID()]->getName(), PhabricatorAuditStatusConstants::getStatusName( $audit->getAuditStatus()), $commit->getRepository()->formatCommitName( $commit->getCommitIdentifier()), - trim($commit->getSummary()))); + trim($commit->getSummary())); + + $audits[] = array( + 'auditID' => $audit_id, + 'commitPHID' => $commit->getPHID(), + 'description' => $description, + ); + } } - if (!$is_dry_run) { - $message = pht( - 'Really delete these %d audit(s)? They will be permanently deleted '. - 'and can not be recovered.', - count($audits)); - if ($console->confirm($message)) { - foreach ($audits as $audit) { - $id = $audit->getID(); - $console->writeOut("%s\n", pht('Deleting audit %d...', $id)); - $audit->delete(); - } + if (!$audits) { + echo tsprintf( + "%s\n", + pht('No audits match the query.')); + return 0; + } + + foreach ($audits as $audit_spec) { + echo tsprintf( + "%s\n", + $audit_spec['description']); + } + + if ($is_dry_run) { + echo tsprintf( + "%s\n", + pht('This is a dry run, so no changes will be made.')); + return 0; + } + + $message = pht( + 'Really delete these %s audit(s)? They will be permanently deleted '. + 'and can not be recovered.', + phutil_count($audits)); + if (!phutil_console_confirm($message)) { + echo tsprintf( + "%s\n", + pht('User aborted the workflow.')); + return 1; + } + + $audits_by_commit = igroup($audits, 'commitPHID'); + foreach ($audits_by_commit as $commit_phid => $audit_specs) { + $audit_ids = ipull($audit_specs, 'auditID'); + + $audits = id(new PhabricatorRepositoryAuditRequest())->loadAllWhere( + 'id IN (%Ld)', + $audit_ids); + + foreach ($audits as $audit) { + $id = $audit->getID(); + + echo tsprintf( + "%s\n", + pht('Deleting audit %d...', $id)); + + $audit->delete(); } + + $this->synchronizeCommitAuditState($commit_phid); } return 0; diff --git a/src/applications/audit/management/PhabricatorAuditManagementWorkflow.php b/src/applications/audit/management/PhabricatorAuditManagementWorkflow.php index 6112a38e1d..b9d90bddc8 100644 --- a/src/applications/audit/management/PhabricatorAuditManagementWorkflow.php +++ b/src/applications/audit/management/PhabricatorAuditManagementWorkflow.php @@ -87,4 +87,39 @@ abstract class PhabricatorAuditManagementWorkflow return $commits; } + protected function synchronizeCommitAuditState($commit_phid) { + $viewer = $this->getViewer(); + + $commit = id(new DiffusionCommitQuery()) + ->setViewer($viewer) + ->withPHIDs(array($commit_phid)) + ->needAuditRequests(true) + ->executeOne(); + if (!$commit) { + return; + } + + $old_status = $commit->getAuditStatusObject(); + $commit->updateAuditStatus($commit->getAudits()); + $new_status = $commit->getAuditStatusObject(); + + if ($old_status->getKey() == $new_status->getKey()) { + echo tsprintf( + "%s\n", + pht( + 'No synchronization changes for "%s".', + $commit->getDisplayName())); + } else { + echo tsprintf( + "%s\n", + pht( + 'Synchronizing "%s": "%s" -> "%s".', + $commit->getDisplayName(), + $old_status->getName(), + $new_status->getName())); + + $commit->save(); + } + } + } diff --git a/src/applications/audit/management/PhabricatorAuditSynchronizeManagementWorkflow.php b/src/applications/audit/management/PhabricatorAuditSynchronizeManagementWorkflow.php index 96d06e65c2..abd0a3c637 100644 --- a/src/applications/audit/management/PhabricatorAuditSynchronizeManagementWorkflow.php +++ b/src/applications/audit/management/PhabricatorAuditSynchronizeManagementWorkflow.php @@ -6,8 +6,16 @@ final class PhabricatorAuditSynchronizeManagementWorkflow protected function didConstruct() { $this ->setName('synchronize') - ->setExamples('**synchronize** ...') - ->setSynopsis(pht('Update audit status for commits.')) + ->setExamples( + "**synchronize** __repository__ ...\n". + "**synchronize** __commit__ ...\n". + "**synchronize** --all") + ->setSynopsis( + pht( + 'Update commits to make their summary audit state reflect the '. + 'state of their actual audit requests. This can fix inconsistencies '. + 'in database state if audit requests have been mangled '. + 'accidentally (or on purpose).')) ->setArguments( array_merge( $this->getCommitConstraintArguments(), @@ -21,36 +29,7 @@ final class PhabricatorAuditSynchronizeManagementWorkflow foreach ($objects as $object) { $commits = $this->loadCommitsForConstraintObject($object); foreach ($commits as $commit) { - $commit = id(new DiffusionCommitQuery()) - ->setViewer($viewer) - ->withPHIDs(array($commit->getPHID())) - ->needAuditRequests(true) - ->executeOne(); - if (!$commit) { - continue; - } - - $old_status = $commit->getAuditStatusObject(); - $commit->updateAuditStatus($commit->getAudits()); - $new_status = $commit->getAuditStatusObject(); - - if ($old_status->getKey() == $new_status->getKey()) { - echo tsprintf( - "%s\n", - pht( - 'No changes for "%s".', - $commit->getDisplayName())); - } else { - echo tsprintf( - "%s\n", - pht( - 'Updating "%s": "%s" -> "%s".', - $commit->getDisplayName(), - $old_status->getName(), - $new_status->getName())); - - $commit->save(); - } + $this->synchronizeCommitAuditState($commit->getPHID()); } } } diff --git a/src/applications/auth/application/PhabricatorAuthApplication.php b/src/applications/auth/application/PhabricatorAuthApplication.php index df86595b46..3446ad597d 100644 --- a/src/applications/auth/application/PhabricatorAuthApplication.php +++ b/src/applications/auth/application/PhabricatorAuthApplication.php @@ -48,10 +48,10 @@ final class PhabricatorAuthApplication extends PhabricatorApplication { '' => 'PhabricatorAuthListController', 'config/' => array( 'new/' => 'PhabricatorAuthNewController', - 'new/(?P[^/]+)/' => 'PhabricatorAuthEditController', - 'edit/(?P\d+)/' => 'PhabricatorAuthEditController', + 'edit/(?:(?P\d+)/)?' => 'PhabricatorAuthEditController', '(?Penable|disable)/(?P\d+)/' => 'PhabricatorAuthDisableController', + 'view/(?P\d+)/' => 'PhabricatorAuthProviderViewController', ), 'login/(?P[^/]+)/(?:(?P[^/]+)/)?' => 'PhabricatorAuthLoginController', @@ -61,8 +61,8 @@ final class PhabricatorAuthApplication extends PhabricatorApplication { 'start/' => 'PhabricatorAuthStartController', 'validate/' => 'PhabricatorAuthValidateController', 'finish/' => 'PhabricatorAuthFinishController', - 'unlink/(?P[^/]+)/' => 'PhabricatorAuthUnlinkController', - '(?Plink|refresh)/(?P[^/]+)/' + 'unlink/(?P\d+)/' => 'PhabricatorAuthUnlinkController', + '(?Plink|refresh)/(?P\d+)/' => 'PhabricatorAuthLinkController', 'confirmlink/(?P[^/]+)/' => 'PhabricatorAuthConfirmLinkController', @@ -86,7 +86,9 @@ final class PhabricatorAuthApplication extends PhabricatorApplication { => 'PhabricatorAuthSSHKeyRevokeController', 'view/(?P\d+)/' => 'PhabricatorAuthSSHKeyViewController', ), + 'password/' => 'PhabricatorAuthSetPasswordController', + 'external/' => 'PhabricatorAuthSetExternalController', 'mfa/' => array( $this->getQueryRoutePattern() => @@ -97,6 +99,8 @@ final class PhabricatorAuthApplication extends PhabricatorApplication { 'PhabricatorAuthFactorProviderViewController', 'message/(?P[1-9]\d*)/' => 'PhabricatorAuthFactorProviderMessageController', + 'challenge/status/(?P[1-9]\d*)/' => + 'PhabricatorAuthChallengeStatusController', ), 'message/' => array( diff --git a/src/applications/auth/controller/PhabricatorAuthConfirmLinkController.php b/src/applications/auth/controller/PhabricatorAuthConfirmLinkController.php index 664a97885f..9ceb10df8b 100644 --- a/src/applications/auth/controller/PhabricatorAuthConfirmLinkController.php +++ b/src/applications/auth/controller/PhabricatorAuthConfirmLinkController.php @@ -20,7 +20,15 @@ final class PhabricatorAuthConfirmLinkController $panel_uri = '/settings/panel/external/'; - if ($request->isFormPost()) { + if ($request->isFormOrHisecPost()) { + $workflow_key = sprintf( + 'account.link(%s)', + $account->getPHID()); + + $hisec_token = id(new PhabricatorAuthSessionEngine()) + ->setWorkflowKey($workflow_key) + ->requireHighSecurityToken($viewer, $request, $panel_uri); + $account->setUserPHID($viewer->getPHID()); $account->save(); @@ -31,14 +39,7 @@ final class PhabricatorAuthConfirmLinkController return id(new AphrontRedirectResponse())->setURI($panel_uri); } - // TODO: Provide more information about the external account. Clicking - // through this form blindly is dangerous. - - // TODO: If the user has password authentication, require them to retype - // their password here. - - $dialog = id(new AphrontDialogView()) - ->setUser($viewer) + $dialog = $this->newDialog() ->setTitle(pht('Confirm %s Account Link', $provider->getProviderName())) ->addCancelButton($panel_uri) ->addSubmitButton(pht('Confirm Account Link')); diff --git a/src/applications/auth/controller/PhabricatorAuthController.php b/src/applications/auth/controller/PhabricatorAuthController.php index 9b7267ec96..cda56d34b1 100644 --- a/src/applications/auth/controller/PhabricatorAuthController.php +++ b/src/applications/auth/controller/PhabricatorAuthController.php @@ -95,7 +95,7 @@ abstract class PhabricatorAuthController extends PhabricatorController { private function buildLoginValidateResponse(PhabricatorUser $user) { $validate_uri = new PhutilURI($this->getApplicationURI('validate/')); - $validate_uri->setQueryParam('expect', $user->getUsername()); + $validate_uri->replaceQueryParam('expect', $user->getUsername()); return id(new AphrontRedirectResponse())->setURI((string)$validate_uri); } @@ -213,19 +213,19 @@ abstract class PhabricatorAuthController extends PhabricatorController { return array($account, $provider, $response); } - $provider = PhabricatorAuthProvider::getEnabledProviderByKey( - $account->getProviderKey()); - - if (!$provider) { + $config = $account->getProviderConfig(); + if (!$config->getIsEnabled()) { $response = $this->renderError( pht( - 'The account you are attempting to register with uses a nonexistent '. - 'or disabled authentication provider (with key "%s"). An '. - 'administrator may have recently disabled this provider.', - $account->getProviderKey())); + 'The account you are attempting to register with uses a disabled '. + 'authentication provider ("%s"). An administrator may have '. + 'recently disabled this provider.', + $config->getDisplayName())); return array($account, $provider, $response); } + $provider = $config->getProvider(); + return array($account, $provider, null); } diff --git a/src/applications/auth/controller/PhabricatorAuthLinkController.php b/src/applications/auth/controller/PhabricatorAuthLinkController.php index 44176a278e..4b127b9ad1 100644 --- a/src/applications/auth/controller/PhabricatorAuthLinkController.php +++ b/src/applications/auth/controller/PhabricatorAuthLinkController.php @@ -6,14 +6,20 @@ final class PhabricatorAuthLinkController public function handleRequest(AphrontRequest $request) { $viewer = $this->getViewer(); $action = $request->getURIData('action'); - $provider_key = $request->getURIData('pkey'); - $provider = PhabricatorAuthProvider::getEnabledProviderByKey( - $provider_key); - if (!$provider) { + $id = $request->getURIData('id'); + + $config = id(new PhabricatorAuthProviderConfigQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->withIsEnabled(true) + ->executeOne(); + if (!$config) { return new Aphront404Response(); } + $provider = $config->getProvider(); + switch ($action) { case 'link': if (!$provider->shouldAllowAccountLink()) { @@ -37,15 +43,15 @@ final class PhabricatorAuthLinkController return new Aphront400Response(); } - $account = id(new PhabricatorExternalAccount())->loadOneWhere( - 'accountType = %s AND accountDomain = %s AND userPHID = %s', - $provider->getProviderType(), - $provider->getProviderDomain(), - $viewer->getPHID()); + $accounts = id(new PhabricatorExternalAccountQuery()) + ->setViewer($viewer) + ->withUserPHIDs(array($viewer->getPHID())) + ->withProviderConfigPHIDs(array($config->getPHID())) + ->execute(); switch ($action) { case 'link': - if ($account) { + if ($accounts) { return $this->renderErrorPage( pht('Account Already Linked'), array( @@ -56,7 +62,7 @@ final class PhabricatorAuthLinkController } break; case 'refresh': - if (!$account) { + if (!$accounts) { return $this->renderErrorPage( pht('No Account Linked'), array( @@ -76,11 +82,6 @@ final class PhabricatorAuthLinkController switch ($action) { case 'link': - id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( - $viewer, - $request, - $panel_uri); - $form = $provider->buildLinkForm($this); break; case 'refresh': diff --git a/src/applications/auth/controller/PhabricatorAuthLoginController.php b/src/applications/auth/controller/PhabricatorAuthLoginController.php index 54649a6a69..e7dabd9340 100644 --- a/src/applications/auth/controller/PhabricatorAuthLoginController.php +++ b/src/applications/auth/controller/PhabricatorAuthLoginController.php @@ -35,6 +35,7 @@ final class PhabricatorAuthLoginController return $response; } + $invite = $this->loadInvite(); $provider = $this->provider; try { @@ -103,7 +104,7 @@ final class PhabricatorAuthLoginController // The account is not yet attached to a Phabricator user, so this is // either a registration or an account link request. if (!$viewer->isLoggedIn()) { - if ($provider->shouldAllowRegistration()) { + if ($provider->shouldAllowRegistration() || $invite) { return $this->processRegisterUser($account); } else { return $this->renderError( diff --git a/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php b/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php index 0cac95f53d..d176a67119 100644 --- a/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php +++ b/src/applications/auth/controller/PhabricatorAuthOneTimeLoginController.php @@ -14,11 +14,6 @@ final class PhabricatorAuthOneTimeLoginController $key = $request->getURIData('key'); $email_id = $request->getURIData('emailID'); - if ($request->getUser()->isLoggedIn()) { - return $this->renderError( - pht('You are already logged in.')); - } - $target_user = id(new PhabricatorPeopleQuery()) ->setViewer(PhabricatorUser::getOmnipotentUser()) ->withIDs(array($id)) @@ -27,6 +22,19 @@ final class PhabricatorAuthOneTimeLoginController return new Aphront404Response(); } + // NOTE: We allow you to use a one-time login link for your own current + // login account. This supports the "Set Password" flow. + + $is_logged_in = false; + if ($viewer->isLoggedIn()) { + if ($viewer->getPHID() !== $target_user->getPHID()) { + return $this->renderError( + pht('You are already logged in.')); + } else { + $is_logged_in = true; + } + } + // NOTE: As a convenience to users, these one-time login URIs may also // be associated with an email address which will be verified when the // URI is used. @@ -100,7 +108,7 @@ final class PhabricatorAuthOneTimeLoginController ->addCancelButton('/'); } - if ($request->isFormPost()) { + if ($request->isFormPost() || $is_logged_in) { // If we have an email bound into this URI, verify email so that clicking // the link in the "Welcome" email is good enough, without requiring users // to go through a second round of email verification. @@ -119,38 +127,15 @@ final class PhabricatorAuthOneTimeLoginController } unset($unguarded); - $next = '/'; - if (!PhabricatorPasswordAuthProvider::getPasswordProvider()) { - $next = '/settings/panel/external/'; - } else { + $next_uri = $this->getNextStepURI($target_user); - // We're going to let the user reset their password without knowing - // the old one. Generate a one-time token for that. - $key = Filesystem::readRandomCharacters(16); - $password_type = - PhabricatorAuthPasswordResetTemporaryTokenType::TOKENTYPE; - - $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); - id(new PhabricatorAuthTemporaryToken()) - ->setTokenResource($target_user->getPHID()) - ->setTokenType($password_type) - ->setTokenExpires(time() + phutil_units('1 hour in seconds')) - ->setTokenCode(PhabricatorHash::weakDigest($key)) - ->save(); - unset($unguarded); - - $panel_uri = '/auth/password/'; - - $next = (string)id(new PhutilURI($panel_uri)) - ->setQueryParams( - array( - 'key' => $key, - )); - - $request->setTemporaryCookie(PhabricatorCookies::COOKIE_HISEC, 'yes'); + // If the user is already logged in, we're just doing a "password set" + // flow. Skip directly to the next step. + if ($is_logged_in) { + return id(new AphrontRedirectResponse())->setURI($next_uri); } - PhabricatorCookies::setNextURICookie($request, $next, $force = true); + PhabricatorCookies::setNextURICookie($request, $next_uri, $force = true); $force_full_session = false; if ($link_type === PhabricatorAuthSessionEngine::ONETIME_RECOVER) { @@ -206,4 +191,85 @@ final class PhabricatorAuthOneTimeLoginController return id(new AphrontDialogResponse())->setDialog($dialog); } + + private function getNextStepURI(PhabricatorUser $user) { + $request = $this->getRequest(); + + // If we have password auth, let the user set or reset their password after + // login. + $have_passwords = PhabricatorPasswordAuthProvider::getPasswordProvider(); + if ($have_passwords) { + // We're going to let the user reset their password without knowing + // the old one. Generate a one-time token for that. + $key = Filesystem::readRandomCharacters(16); + $password_type = + PhabricatorAuthPasswordResetTemporaryTokenType::TOKENTYPE; + + $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); + id(new PhabricatorAuthTemporaryToken()) + ->setTokenResource($user->getPHID()) + ->setTokenType($password_type) + ->setTokenExpires(time() + phutil_units('1 hour in seconds')) + ->setTokenCode(PhabricatorHash::weakDigest($key)) + ->save(); + unset($unguarded); + + $panel_uri = '/auth/password/'; + + $request->setTemporaryCookie(PhabricatorCookies::COOKIE_HISEC, 'yes'); + + $params = array( + 'key' => $key, + ); + + return (string)new PhutilURI($panel_uri, $params); + } + + // Check if the user already has external accounts linked. If they do, + // it's not obvious why they aren't using them to log in, but assume they + // know what they're doing. We won't send them to the link workflow. + $accounts = id(new PhabricatorExternalAccountQuery()) + ->setViewer($user) + ->withUserPHIDs(array($user->getPHID())) + ->execute(); + + $configs = id(new PhabricatorAuthProviderConfigQuery()) + ->setViewer($user) + ->withIsEnabled(true) + ->execute(); + + $linkable = array(); + foreach ($configs as $config) { + if (!$config->getShouldAllowLink()) { + continue; + } + + $provider = $config->getProvider(); + if (!$provider->isLoginFormAButton()) { + continue; + } + + $linkable[] = $provider; + } + + // If there's at least one linkable provider, and the user doesn't already + // have accounts, send the user to the link workflow. + if (!$accounts && $linkable) { + return '/auth/external/'; + } + + // If there are no configured providers and the user is an administrator, + // send them to Auth to configure a provider. This is probably where they + // want to go. You can end up in this state by accidentally losing your + // first session during initial setup, or after restoring exported data + // from a hosted instance. + if (!$configs && $user->getIsAdmin()) { + return '/auth/'; + } + + // If we didn't find anywhere better to send them, give up and just send + // them to the home page. + return '/'; + } + } diff --git a/src/applications/auth/controller/PhabricatorAuthRegisterController.php b/src/applications/auth/controller/PhabricatorAuthRegisterController.php index 958864a908..7c34a93db3 100644 --- a/src/applications/auth/controller/PhabricatorAuthRegisterController.php +++ b/src/applications/auth/controller/PhabricatorAuthRegisterController.php @@ -11,21 +11,25 @@ final class PhabricatorAuthRegisterController $viewer = $this->getViewer(); $account_key = $request->getURIData('akey'); - if ($request->getUser()->isLoggedIn()) { + if ($viewer->isLoggedIn()) { return id(new AphrontRedirectResponse())->setURI('/'); } + $invite = $this->loadInvite(); + $is_setup = false; if (strlen($account_key)) { $result = $this->loadAccountForRegistrationOrLinking($account_key); list($account, $provider, $response) = $result; $is_default = false; } else if ($this->isFirstTimeSetup()) { - list($account, $provider, $response) = $this->loadSetupAccount(); + $account = null; + $provider = null; + $response = null; $is_default = true; $is_setup = true; } else { - list($account, $provider, $response) = $this->loadDefaultAccount(); + list($account, $provider, $response) = $this->loadDefaultAccount($invite); $is_default = true; } @@ -33,24 +37,24 @@ final class PhabricatorAuthRegisterController return $response; } - $invite = $this->loadInvite(); + if (!$is_setup) { + if (!$provider->shouldAllowRegistration()) { + if ($invite) { + // If the user has an invite, we allow them to register with any + // provider, even a login-only provider. + } else { + // TODO: This is a routine error if you click "Login" on an external + // auth source which doesn't allow registration. The error should be + // more tailored. - if (!$provider->shouldAllowRegistration()) { - if ($invite) { - // If the user has an invite, we allow them to register with any - // provider, even a login-only provider. - } else { - // TODO: This is a routine error if you click "Login" on an external - // auth source which doesn't allow registration. The error should be - // more tailored. - - return $this->renderError( - pht( - 'The account you are attempting to register with uses an '. - 'authentication provider ("%s") which does not allow '. - 'registration. An administrator may have recently disabled '. - 'registration with this provider.', - $provider->getProviderName())); + return $this->renderError( + pht( + 'The account you are attempting to register with uses an '. + 'authentication provider ("%s") which does not allow '. + 'registration. An administrator may have recently disabled '. + 'registration with this provider.', + $provider->getProviderName())); + } } } @@ -58,14 +62,19 @@ final class PhabricatorAuthRegisterController $user = new PhabricatorUser(); - $default_username = $account->getUsername(); - $default_realname = $account->getRealName(); + if ($is_setup) { + $default_username = null; + $default_realname = null; + $default_email = null; + } else { + $default_username = $account->getUsername(); + $default_realname = $account->getRealName(); + $default_email = $account->getEmail(); + } $account_type = PhabricatorAuthPassword::PASSWORD_TYPE_ACCOUNT; $content_source = PhabricatorContentSource::newFromRequest($request); - $default_email = $account->getEmail(); - if ($invite) { $default_email = $invite->getEmailAddress(); } @@ -212,7 +221,11 @@ final class PhabricatorAuthRegisterController $can_edit_email = $profile->getCanEditEmail(); $can_edit_realname = $profile->getCanEditRealName(); - $must_set_password = $provider->shouldRequireRegistrationPassword(); + if ($is_setup) { + $must_set_password = false; + } else { + $must_set_password = $provider->shouldRequireRegistrationPassword(); + } $can_edit_anything = $profile->getCanEditAnything() || $must_set_password; $force_verify = $profile->getShouldVerifyEmail(); @@ -342,9 +355,11 @@ final class PhabricatorAuthRegisterController } if (!$errors) { - $image = $this->loadProfilePicture($account); - if ($image) { - $user->setProfileImagePHID($image->getPHID()); + if (!$is_setup) { + $image = $this->loadProfilePicture($account); + if ($image) { + $user->setProfileImagePHID($image->getPHID()); + } } try { @@ -354,17 +369,19 @@ final class PhabricatorAuthRegisterController $verify_email = true; } - if ($value_email === $default_email) { - if ($account->getEmailVerified()) { - $verify_email = true; - } + if (!$is_setup) { + if ($value_email === $default_email) { + if ($account->getEmailVerified()) { + $verify_email = true; + } - if ($provider->shouldTrustEmails()) { - $verify_email = true; - } + if ($provider->shouldTrustEmails()) { + $verify_email = true; + } - if ($invite) { - $verify_email = true; + if ($invite) { + $verify_email = true; + } } } @@ -446,9 +463,11 @@ final class PhabricatorAuthRegisterController $transaction_editor->applyTransactions($user, $xactions); } - $account->setUserPHID($user->getPHID()); - $provider->willRegisterAccount($account); - $account->save(); + if (!$is_setup) { + $account->setUserPHID($user->getPHID()); + $provider->willRegisterAccount($account); + $account->save(); + } $user->saveTransaction(); @@ -509,7 +528,6 @@ final class PhabricatorAuthRegisterController ->setAuthProvider($provider))); } - if ($can_edit_username) { $form->appendChild( id(new AphrontFormTextControl()) @@ -603,7 +621,7 @@ final class PhabricatorAuthRegisterController pht( 'Installation is complete. Register your administrator account '. 'below to log in. You will be able to configure options and add '. - 'other authentication mechanisms (like LDAP or OAuth) later on.')); + 'authentication mechanisms later on.')); } $object_box = id(new PHUIObjectBoxView()) @@ -620,11 +638,12 @@ final class PhabricatorAuthRegisterController $view = id(new PHUITwoColumnView()) ->setHeader($header) - ->setFooter(array( - $welcome_view, - $invite_header, - $object_box, - )); + ->setFooter( + array( + $welcome_view, + $invite_header, + $object_box, + )); return $this->newPage() ->setTitle($title) @@ -632,17 +651,20 @@ final class PhabricatorAuthRegisterController ->appendChild($view); } - private function loadDefaultAccount() { + private function loadDefaultAccount($invite) { $providers = PhabricatorAuthProvider::getAllEnabledProviders(); $account = null; $provider = null; $response = null; foreach ($providers as $key => $candidate_provider) { - if (!$candidate_provider->shouldAllowRegistration()) { - unset($providers[$key]); - continue; + if (!$invite) { + if (!$candidate_provider->shouldAllowRegistration()) { + unset($providers[$key]); + continue; + } } + if (!$candidate_provider->isDefaultRegistrationProvider()) { unset($providers[$key]); } @@ -660,24 +682,11 @@ final class PhabricatorAuthRegisterController } $provider = head($providers); - $account = $provider->getDefaultExternalAccount(); + $account = $provider->newDefaultExternalAccount(); return array($account, $provider, $response); } - private function loadSetupAccount() { - $provider = new PhabricatorPasswordAuthProvider(); - $provider->attachProviderConfig( - id(new PhabricatorAuthProviderConfig()) - ->setShouldAllowRegistration(1) - ->setShouldAllowLogin(1) - ->setIsEnabled(true)); - - $account = $provider->getDefaultExternalAccount(); - $response = null; - return array($account, $provider, $response); - } - private function loadProfilePicture(PhabricatorExternalAccount $account) { $phid = $account->getProfileImagePHID(); if (!$phid) { diff --git a/src/applications/auth/controller/PhabricatorAuthSetExternalController.php b/src/applications/auth/controller/PhabricatorAuthSetExternalController.php new file mode 100644 index 0000000000..51dfcab53f --- /dev/null +++ b/src/applications/auth/controller/PhabricatorAuthSetExternalController.php @@ -0,0 +1,110 @@ +getViewer(); + + $configs = id(new PhabricatorAuthProviderConfigQuery()) + ->setViewer($viewer) + ->withIsEnabled(true) + ->execute(); + + $linkable = array(); + foreach ($configs as $config) { + if (!$config->getShouldAllowLink()) { + continue; + } + + // For now, only buttons get to appear here: for example, we can't + // reasonably embed an entire LDAP form into this UI. + $provider = $config->getProvider(); + if (!$provider->isLoginFormAButton()) { + continue; + } + + $linkable[] = $config; + } + + if (!$linkable) { + return $this->newDialog() + ->setTitle(pht('No Linkable External Providers')) + ->appendParagraph( + pht( + 'Currently, there are no configured external auth providers '. + 'which you can link your account to.')) + ->addCancelButton('/'); + } + + $text = PhabricatorAuthMessage::loadMessageText( + $viewer, + PhabricatorAuthLinkMessageType::MESSAGEKEY); + if (!strlen($text)) { + $text = pht( + 'You can link your Phabricator account to an external account to '. + 'allow you to log in more easily in the future. To continue, choose '. + 'an account to link below. If you prefer not to link your account, '. + 'you can skip this step.'); + } + + $remarkup_view = new PHUIRemarkupView($viewer, $text); + $remarkup_view = phutil_tag( + 'div', + array( + 'class' => 'phui-object-box-instructions', + ), + $remarkup_view); + + PhabricatorCookies::setClientIDCookie($request); + + $view = array(); + foreach ($configs as $config) { + $provider = $config->getProvider(); + + $form = $provider->buildLinkForm($this); + + if ($provider->isLoginFormAButton()) { + require_celerity_resource('auth-css'); + $form = phutil_tag( + 'div', + array( + 'class' => 'phabricator-link-button pl', + ), + $form); + } + + $view[] = $form; + } + + $form = id(new AphrontFormView()) + ->setViewer($viewer) + ->appendControl( + id(new AphrontFormSubmitControl()) + ->addCancelButton('/', pht('Skip This Step'))); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Link External Account')); + + $box = id(new PHUIObjectBoxView()) + ->setViewer($viewer) + ->setHeader($header) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->appendChild($remarkup_view) + ->appendChild($view) + ->appendChild($form); + + $main_view = id(new PHUITwoColumnView()) + ->setFooter($box); + + $crumbs = $this->buildApplicationCrumbs() + ->addTextCrumb(pht('Link External Account')) + ->setBorder(true); + + return $this->newPage() + ->setTitle(pht('Link External Account')) + ->setCrumbs($crumbs) + ->appendChild($main_view); + } + +} diff --git a/src/applications/auth/controller/PhabricatorAuthStartController.php b/src/applications/auth/controller/PhabricatorAuthStartController.php index 29fa7e0b9f..72cbbea5a8 100644 --- a/src/applications/auth/controller/PhabricatorAuthStartController.php +++ b/src/applications/auth/controller/PhabricatorAuthStartController.php @@ -54,7 +54,7 @@ final class PhabricatorAuthStartController } $redirect_uri = $request->getRequestURI(); - $redirect_uri->setQueryParam('cleared', 1); + $redirect_uri->replaceQueryParam('cleared', 1); return id(new AphrontRedirectResponse())->setURI($redirect_uri); } } @@ -64,7 +64,7 @@ final class PhabricatorAuthStartController // the workflow will continue normally. if ($did_clear) { $redirect_uri = $request->getRequestURI(); - $redirect_uri->setQueryParam('cleared', null); + $redirect_uri->removeQueryParam('cleared'); return id(new AphrontRedirectResponse())->setURI($redirect_uri); } @@ -75,6 +75,11 @@ final class PhabricatorAuthStartController } } + $configs = array(); + foreach ($providers as $provider) { + $configs[] = $provider->getProviderConfig(); + } + if (!$providers) { if ($this->isFirstTimeSetup()) { // If this is a fresh install, let the user register their admin @@ -172,23 +177,6 @@ final class PhabricatorAuthStartController $button_columns); } - $handlers = PhabricatorAuthLoginHandler::getAllHandlers(); - - $delegating_controller = $this->getDelegatingController(); - - $header = array(); - foreach ($handlers as $handler) { - $handler = clone $handler; - - $handler->setRequest($request); - - if ($delegating_controller) { - $handler->setDelegatingController($delegating_controller); - } - - $header[] = $handler->getAuthLoginHeaderContent(); - } - $invite_message = null; if ($invite) { $invite_message = $this->renderInviteHeader($invite); @@ -196,16 +184,18 @@ final class PhabricatorAuthStartController $custom_message = $this->newCustomStartMessage(); + $email_login = $this->newEmailLoginView($configs); + $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Login')); $crumbs->setBorder(true); $title = pht('Login'); $view = array( - $header, $invite_message, $custom_message, $out, + $email_login, ); return $this->newPage() @@ -329,4 +319,43 @@ final class PhabricatorAuthStartController $remarkup_view); } + private function newEmailLoginView(array $configs) { + assert_instances_of($configs, 'PhabricatorAuthProviderConfig'); + + // Check if password auth is enabled. If it is, the password login form + // renders a "Forgot password?" link, so we don't need to provide a + // supplemental link. + + $has_password = false; + foreach ($configs as $config) { + $provider = $config->getProvider(); + if ($provider instanceof PhabricatorPasswordAuthProvider) { + $has_password = true; + } + } + + if ($has_password) { + return null; + } + + $view = array( + pht('Trouble logging in?'), + ' ', + phutil_tag( + 'a', + array( + 'href' => '/login/email/', + ), + pht('Send a login link to your email address.')), + ); + + return phutil_tag( + 'div', + array( + 'class' => 'auth-custom-message', + ), + $view); + } + + } diff --git a/src/applications/auth/controller/PhabricatorAuthUnlinkController.php b/src/applications/auth/controller/PhabricatorAuthUnlinkController.php index e6e1493e5a..43e7b1b362 100644 --- a/src/applications/auth/controller/PhabricatorAuthUnlinkController.php +++ b/src/applications/auth/controller/PhabricatorAuthUnlinkController.php @@ -3,41 +3,45 @@ final class PhabricatorAuthUnlinkController extends PhabricatorAuthController { - private $providerKey; - public function handleRequest(AphrontRequest $request) { $viewer = $this->getViewer(); - $this->providerKey = $request->getURIData('pkey'); + $id = $request->getURIData('id'); - list($type, $domain) = explode(':', $this->providerKey, 2); - - // Check that this account link actually exists. We don't require the - // provider to exist because we want users to be able to delete links to - // dead accounts if they want. - $account = id(new PhabricatorExternalAccount())->loadOneWhere( - 'accountType = %s AND accountDomain = %s AND userPHID = %s', - $type, - $domain, - $viewer->getPHID()); + $account = id(new PhabricatorExternalAccountQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); if (!$account) { - return $this->renderNoAccountErrorDialog(); + return new Aphront404Response(); } - // Check that the provider (if it exists) allows accounts to be unlinked. - $provider_key = $this->providerKey; - $provider = PhabricatorAuthProvider::getEnabledProviderByKey($provider_key); - if ($provider) { - if (!$provider->shouldAllowAccountUnlink()) { - return $this->renderNotUnlinkableErrorDialog($provider); - } + $done_uri = '/settings/panel/external/'; + + $config = $account->getProviderConfig(); + $provider = $config->getProvider(); + if (!$provider->shouldAllowAccountUnlink()) { + return $this->renderNotUnlinkableErrorDialog($provider, $done_uri); } - // Check that this account isn't the last account which can be used to - // login. We prevent you from removing the last account. + $confirmations = $request->getStrList('confirmations'); + $confirmations = array_fuse($confirmations); + + if (!$request->isFormOrHisecPost() || !isset($confirmations['unlink'])) { + return $this->renderConfirmDialog($confirmations, $config, $done_uri); + } + + // Check that this account isn't the only account which can be used to + // login. We warn you when you remove your only login account. if ($account->isUsableForLogin()) { - $other_accounts = id(new PhabricatorExternalAccount())->loadAllWhere( - 'userPHID = %s', - $viewer->getPHID()); + $other_accounts = id(new PhabricatorExternalAccountQuery()) + ->setViewer($viewer) + ->withUserPHIDs(array($viewer->getPHID())) + ->execute(); $valid_accounts = 0; foreach ($other_accounts as $other_account) { @@ -47,100 +51,91 @@ final class PhabricatorAuthUnlinkController } if ($valid_accounts < 2) { - return $this->renderLastUsableAccountErrorDialog(); + if (!isset($confirmations['only'])) { + return $this->renderOnlyUsableAccountConfirmDialog( + $confirmations, + $done_uri); + } } } - if ($request->isDialogFormPost()) { - $account->delete(); + $workflow_key = sprintf( + 'account.unlink(%s)', + $account->getPHID()); - id(new PhabricatorAuthSessionEngine())->terminateLoginSessions( - $viewer, - new PhutilOpaqueEnvelope( - $request->getCookie(PhabricatorCookies::COOKIE_SESSION))); + $hisec_token = id(new PhabricatorAuthSessionEngine()) + ->setWorkflowKey($workflow_key) + ->requireHighSecurityToken($viewer, $request, $done_uri); - return id(new AphrontRedirectResponse())->setURI($this->getDoneURI()); - } + $account->delete(); - return $this->renderConfirmDialog(); - } + id(new PhabricatorAuthSessionEngine())->terminateLoginSessions( + $viewer, + new PhutilOpaqueEnvelope( + $request->getCookie(PhabricatorCookies::COOKIE_SESSION))); - private function getDoneURI() { - return '/settings/panel/external/'; - } - - private function renderNoAccountErrorDialog() { - $dialog = id(new AphrontDialogView()) - ->setUser($this->getRequest()->getUser()) - ->setTitle(pht('No Such Account')) - ->appendChild( - pht( - 'You can not unlink this account because it is not linked.')) - ->addCancelButton($this->getDoneURI()); - - return id(new AphrontDialogResponse())->setDialog($dialog); + return id(new AphrontRedirectResponse())->setURI($done_uri); } private function renderNotUnlinkableErrorDialog( - PhabricatorAuthProvider $provider) { + PhabricatorAuthProvider $provider, + $done_uri) { - $dialog = id(new AphrontDialogView()) - ->setUser($this->getRequest()->getUser()) + return $this->newDialog() ->setTitle(pht('Permanent Account Link')) ->appendChild( pht( 'You can not unlink this account because the administrator has '. - 'configured Phabricator to make links to %s accounts permanent.', + 'configured Phabricator to make links to "%s" accounts permanent.', $provider->getProviderName())) - ->addCancelButton($this->getDoneURI()); - - return id(new AphrontDialogResponse())->setDialog($dialog); + ->addCancelButton($done_uri); } - private function renderLastUsableAccountErrorDialog() { - $dialog = id(new AphrontDialogView()) - ->setUser($this->getRequest()->getUser()) - ->setTitle(pht('Last Valid Account')) - ->appendChild( + private function renderOnlyUsableAccountConfirmDialog( + array $confirmations, + $done_uri) { + + $confirmations[] = 'only'; + + return $this->newDialog() + ->setTitle(pht('Unlink Your Only Login Account?')) + ->addHiddenInput('confirmations', implode(',', $confirmations)) + ->appendParagraph( pht( - 'You can not unlink this account because you have no other '. - 'valid login accounts. If you removed it, you would be unable '. - 'to log in. Add another authentication method before removing '. - 'this one.')) - ->addCancelButton($this->getDoneURI()); - - return id(new AphrontDialogResponse())->setDialog($dialog); + 'This is the only external login account linked to your Phabicator '. + 'account. If you remove it, you may no longer be able to log in.')) + ->appendParagraph( + pht( + 'If you lose access to your account, you can recover access by '. + 'sending yourself an email login link from the login screen.')) + ->addCancelButton($done_uri) + ->addSubmitButton(pht('Unlink External Account')); } - private function renderConfirmDialog() { - $provider_key = $this->providerKey; - $provider = PhabricatorAuthProvider::getEnabledProviderByKey($provider_key); + private function renderConfirmDialog( + array $confirmations, + PhabricatorAuthProviderConfig $config, + $done_uri) { - if ($provider) { - $title = pht('Unlink "%s" Account?', $provider->getProviderName()); - $body = pht( - 'You will no longer be able to use your %s account to '. - 'log in to Phabricator.', - $provider->getProviderName()); - } else { - $title = pht('Unlink Account?'); - $body = pht( - 'You will no longer be able to use this account to log in '. - 'to Phabricator.'); - } + $confirmations[] = 'unlink'; + $provider = $config->getProvider(); - $dialog = id(new AphrontDialogView()) - ->setUser($this->getRequest()->getUser()) + $title = pht('Unlink "%s" Account?', $provider->getProviderName()); + $body = pht( + 'You will no longer be able to use your %s account to '. + 'log in to Phabricator.', + $provider->getProviderName()); + + return $this->newDialog() ->setTitle($title) + ->addHiddenInput('confirmations', implode(',', $confirmations)) ->appendParagraph($body) ->appendParagraph( pht( 'Note: Unlinking an authentication provider will terminate any '. 'other active login sessions.')) ->addSubmitButton(pht('Unlink Account')) - ->addCancelButton($this->getDoneURI()); - - return id(new AphrontDialogResponse())->setDialog($dialog); + ->addCancelButton($done_uri); } } diff --git a/src/applications/auth/controller/PhabricatorEmailLoginController.php b/src/applications/auth/controller/PhabricatorEmailLoginController.php index f57a29b11a..76b288f059 100644 --- a/src/applications/auth/controller/PhabricatorEmailLoginController.php +++ b/src/applications/auth/controller/PhabricatorEmailLoginController.php @@ -8,29 +8,42 @@ final class PhabricatorEmailLoginController } public function handleRequest(AphrontRequest $request) { - - if (!PhabricatorPasswordAuthProvider::getPasswordProvider()) { - return new Aphront400Response(); - } + $viewer = $this->getViewer(); + $is_logged_in = $viewer->isLoggedIn(); $e_email = true; $e_captcha = true; $errors = array(); - $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); + if ($is_logged_in) { + if (!$this->isPasswordAuthEnabled()) { + return $this->newDialog() + ->setTitle(pht('No Password Auth')) + ->appendParagraph( + pht( + 'Password authentication is not enabled and you are already '. + 'logged in. There is nothing for you here.')) + ->addCancelButton('/', pht('Continue')); + } + + $v_email = $viewer->loadPrimaryEmailAddress(); + } else { + $v_email = $request->getStr('email'); + } if ($request->isFormPost()) { $e_email = null; $e_captcha = pht('Again'); - $captcha_ok = AphrontFormRecaptchaControl::processCaptcha($request); - if (!$captcha_ok) { - $errors[] = pht('Captcha response is incorrect, try again.'); - $e_captcha = pht('Invalid'); + if (!$is_logged_in) { + $captcha_ok = AphrontFormRecaptchaControl::processCaptcha($request); + if (!$captcha_ok) { + $errors[] = pht('Captcha response is incorrect, try again.'); + $e_captcha = pht('Invalid'); + } } - $email = $request->getStr('email'); - if (!strlen($email)) { + if (!strlen($v_email)) { $errors[] = pht('You must provide an email address.'); $e_email = pht('Required'); } @@ -42,7 +55,7 @@ final class PhabricatorEmailLoginController $target_email = id(new PhabricatorUserEmail())->loadOneWhere( 'address = %s', - $email); + $v_email); $target_user = null; if ($target_email) { @@ -81,33 +94,24 @@ final class PhabricatorEmailLoginController } if (!$errors) { - $engine = new PhabricatorAuthSessionEngine(); - $uri = $engine->getOneTimeLoginURI( + $body = $this->newAccountLoginMailBody( $target_user, - null, - PhabricatorAuthSessionEngine::ONETIME_RESET); + $is_logged_in); - if ($is_serious) { - $body = pht( - "You can use this link to reset your Phabricator password:". - "\n\n %s\n", - $uri); + if ($is_logged_in) { + $subject = pht('[Phabricator] Account Password Link'); + $instructions = pht( + 'An email has been sent containing a link you can use to set '. + 'a password for your account.'); } else { - $body = pht( - "Condolences on forgetting your password. You can use this ". - "link to reset it:\n\n". - " %s\n\n". - "After you set a new password, consider writing it down on a ". - "sticky note and attaching it to your monitor so you don't ". - "forget again! Choosing a very short, easy-to-remember password ". - "like \"cat\" or \"1234\" might also help.\n\n". - "Best Wishes,\nPhabricator\n", - $uri); - + $subject = pht('[Phabricator] Account Login Link'); + $instructions = pht( + 'An email has been sent containing a link you can use to log '. + 'in to your account.'); } $mail = id(new PhabricatorMetaMTAMail()) - ->setSubject(pht('[Phabricator] Password Reset')) + ->setSubject($subject) ->setForceDelivery(true) ->addRawTos(array($target_email->getAddress())) ->setBody($body) @@ -116,51 +120,118 @@ final class PhabricatorEmailLoginController return $this->newDialog() ->setTitle(pht('Check Your Email')) ->setShortTitle(pht('Email Sent')) - ->appendParagraph( - pht('An email has been sent with a link you can use to log in.')) + ->appendParagraph($instructions) ->addCancelButton('/', pht('Done')); } } } - $error_view = null; - if ($errors) { - $error_view = new PHUIInfoView(); - $error_view->setErrors($errors); + $form = id(new AphrontFormView()) + ->setViewer($viewer); + + if ($this->isPasswordAuthEnabled()) { + if ($is_logged_in) { + $title = pht('Set Password'); + $form->appendRemarkupInstructions( + pht( + 'A password reset link will be sent to your primary email '. + 'address. Follow the link to set an account password.')); + } else { + $title = pht('Password Reset'); + $form->appendRemarkupInstructions( + pht( + 'To reset your password, provide your email address. An email '. + 'with a login link will be sent to you.')); + } + } else { + $title = pht('Email Login'); + $form->appendRemarkupInstructions( + pht( + 'To access your account, provide your email address. An email '. + 'with a login link will be sent to you.')); } - $email_auth = new PHUIFormLayoutView(); - $email_auth->appendChild($error_view); - $email_auth - ->setUser($request->getUser()) - ->setFullWidth(true) - ->appendChild( - id(new AphrontFormTextControl()) - ->setLabel(pht('Email')) - ->setName('email') - ->setValue($request->getStr('email')) - ->setError($e_email)) - ->appendChild( + if ($is_logged_in) { + $address_control = new AphrontFormStaticControl(); + } else { + $address_control = id(new AphrontFormTextControl()) + ->setName('email') + ->setError($e_email); + } + + $address_control + ->setLabel(pht('Email Address')) + ->setValue($v_email); + + $form + ->appendControl($address_control); + + if (!$is_logged_in) { + $form->appendControl( id(new AphrontFormRecaptchaControl()) ->setLabel(pht('Captcha')) ->setError($e_captcha)); + } - $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Reset Password')); - $crumbs->setBorder(true); - - $dialog = new AphrontDialogView(); - $dialog->setUser($request->getUser()); - $dialog->setTitle(pht('Forgot Password / Email Login')); - $dialog->appendChild($email_auth); - $dialog->addSubmitButton(pht('Send Email')); - $dialog->setSubmitURI('/login/email/'); - - return $this->newPage() - ->setTitle(pht('Forgot Password')) - ->setCrumbs($crumbs) - ->appendChild($dialog); - + return $this->newDialog() + ->setTitle($title) + ->setErrors($errors) + ->setWidth(AphrontDialogView::WIDTH_FORM) + ->appendForm($form) + ->addCancelButton('/auth/start/') + ->addSubmitButton(pht('Send Email')); } + private function newAccountLoginMailBody( + PhabricatorUser $user, + $is_logged_in) { + + $engine = new PhabricatorAuthSessionEngine(); + $uri = $engine->getOneTimeLoginURI( + $user, + null, + PhabricatorAuthSessionEngine::ONETIME_RESET); + + $is_serious = PhabricatorEnv::getEnvConfig('phabricator.serious-business'); + $have_passwords = $this->isPasswordAuthEnabled(); + + if ($have_passwords) { + if ($is_logged_in) { + $body = pht( + 'You can use this link to set a password on your account:'. + "\n\n %s\n", + $uri); + } else if ($is_serious) { + $body = pht( + "You can use this link to reset your Phabricator password:". + "\n\n %s\n", + $uri); + } else { + $body = pht( + "Condolences on forgetting your password. You can use this ". + "link to reset it:\n\n". + " %s\n\n". + "After you set a new password, consider writing it down on a ". + "sticky note and attaching it to your monitor so you don't ". + "forget again! Choosing a very short, easy-to-remember password ". + "like \"cat\" or \"1234\" might also help.\n\n". + "Best Wishes,\nPhabricator\n", + $uri); + + } + } else { + $body = pht( + "You can use this login link to regain access to your Phabricator ". + "account:". + "\n\n". + " %s\n", + $uri); + } + + return $body; + } + + private function isPasswordAuthEnabled() { + return (bool)PhabricatorPasswordAuthProvider::getPasswordProvider(); + } } diff --git a/src/applications/auth/controller/config/PhabricatorAuthDisableController.php b/src/applications/auth/controller/config/PhabricatorAuthDisableController.php index 5863aceca9..252f159ec4 100644 --- a/src/applications/auth/controller/config/PhabricatorAuthDisableController.php +++ b/src/applications/auth/controller/config/PhabricatorAuthDisableController.php @@ -6,7 +6,8 @@ final class PhabricatorAuthDisableController public function handleRequest(AphrontRequest $request) { $this->requireApplicationCapability( AuthManageProvidersCapability::CAPABILITY); - $viewer = $request->getUser(); + + $viewer = $this->getViewer(); $config_id = $request->getURIData('id'); $action = $request->getURIData('action'); @@ -24,6 +25,7 @@ final class PhabricatorAuthDisableController } $is_enable = ($action === 'enable'); + $done_uri = $config->getURI(); if ($request->isDialogFormPost()) { $xactions = array(); @@ -39,8 +41,7 @@ final class PhabricatorAuthDisableController ->setContinueOnNoEffect(true) ->applyTransactions($config, $xactions); - return id(new AphrontRedirectResponse())->setURI( - $this->getApplicationURI()); + return id(new AphrontRedirectResponse())->setURI($done_uri); } if ($is_enable) { @@ -64,8 +65,9 @@ final class PhabricatorAuthDisableController // account and pop a warning like "YOU WILL NO LONGER BE ABLE TO LOGIN // YOU GOOF, YOU PROBABLY DO NOT MEAN TO DO THIS". None of this is // critical and we can wait to see how users manage to shoot themselves - // in the feet. Shortly, `bin/auth` will be able to recover from these - // types of mistakes. + // in the feet. + + // `bin/auth` can recover from these types of mistakes. $title = pht('Disable Provider?'); $body = pht( @@ -77,14 +79,11 @@ final class PhabricatorAuthDisableController $button = pht('Disable Provider'); } - $dialog = id(new AphrontDialogView()) - ->setUser($viewer) + return $this->newDialog() ->setTitle($title) ->appendChild($body) - ->addCancelButton($this->getApplicationURI()) + ->addCancelButton($done_uri) ->addSubmitButton($button); - - return id(new AphrontDialogResponse())->setDialog($dialog); } } diff --git a/src/applications/auth/controller/config/PhabricatorAuthEditController.php b/src/applications/auth/controller/config/PhabricatorAuthEditController.php index 6ff0be4383..d3cd2fef98 100644 --- a/src/applications/auth/controller/config/PhabricatorAuthEditController.php +++ b/src/applications/auth/controller/config/PhabricatorAuthEditController.php @@ -6,8 +6,9 @@ final class PhabricatorAuthEditController public function handleRequest(AphrontRequest $request) { $this->requireApplicationCapability( AuthManageProvidersCapability::CAPABILITY); - $viewer = $request->getUser(); - $provider_class = $request->getURIData('className'); + + $viewer = $this->getViewer(); + $provider_class = $request->getStr('provider'); $config_id = $request->getURIData('id'); if ($config_id) { @@ -155,12 +156,7 @@ final class PhabricatorAuthEditController ->setContinueOnNoEffect(true) ->applyTransactions($config, $xactions); - if ($provider->hasSetupStep() && $is_new) { - $id = $config->getID(); - $next_uri = $this->getApplicationURI('config/edit/'.$id.'/'); - } else { - $next_uri = $this->getApplicationURI(); - } + $next_uri = $config->getURI(); return id(new AphrontRedirectResponse())->setURI($next_uri); } @@ -184,7 +180,7 @@ final class PhabricatorAuthEditController $crumb = pht('Edit Provider'); $title = pht('Edit Auth Provider'); $header_icon = 'fa-pencil'; - $cancel_uri = $this->getApplicationURI(); + $cancel_uri = $config->getURI(); } $header = id(new PHUIHeaderView()) @@ -275,6 +271,7 @@ final class PhabricatorAuthEditController $form = id(new AphrontFormView()) ->setUser($viewer) + ->addHiddenInput('provider', $provider_class) ->appendChild( id(new AphrontFormCheckboxControl()) ->setLabel(pht('Allow')) @@ -346,18 +343,6 @@ final class PhabricatorAuthEditController $crumbs->addTextCrumb($crumb); $crumbs->setBorder(true); - $timeline = null; - if (!$is_new) { - $timeline = $this->buildTransactionTimeline( - $config, - new PhabricatorAuthProviderConfigTransactionQuery()); - $xactions = $timeline->getTransactions(); - foreach ($xactions as $xaction) { - $xaction->setProvider($provider); - } - $timeline->setShouldTerminate(true); - } - $form_box = id(new PHUIObjectBoxView()) ->setHeaderText(pht('Provider')) ->setFormErrors($errors) @@ -369,7 +354,6 @@ final class PhabricatorAuthEditController ->setFooter(array( $form_box, $footer, - $timeline, )); return $this->newPage() diff --git a/src/applications/auth/controller/config/PhabricatorAuthListController.php b/src/applications/auth/controller/config/PhabricatorAuthListController.php index bb118d798e..f4b05e8adf 100644 --- a/src/applications/auth/controller/config/PhabricatorAuthListController.php +++ b/src/applications/auth/controller/config/PhabricatorAuthListController.php @@ -19,31 +19,18 @@ final class PhabricatorAuthListController $id = $config->getID(); - $edit_uri = $this->getApplicationURI('config/edit/'.$id.'/'); - $enable_uri = $this->getApplicationURI('config/enable/'.$id.'/'); - $disable_uri = $this->getApplicationURI('config/disable/'.$id.'/'); + $view_uri = $config->getURI(); $provider = $config->getProvider(); - if ($provider) { - $name = $provider->getProviderName(); - } else { - $name = $config->getProviderType().' ('.$config->getProviderClass().')'; - } + $name = $provider->getProviderName(); - $item->setHeader($name); + $item + ->setHeader($name) + ->setHref($view_uri); - if ($provider) { - $item->setHref($edit_uri); - } else { - $item->addAttribute(pht('Provider Implementation Missing!')); - } - - $domain = null; - if ($provider) { - $domain = $provider->getProviderDomain(); - if ($domain !== 'self') { - $item->addAttribute($domain); - } + $domain = $provider->getProviderDomain(); + if ($domain !== 'self') { + $item->addAttribute($domain); } if ($config->getShouldAllowRegistration()) { @@ -54,21 +41,9 @@ final class PhabricatorAuthListController if ($config->getIsEnabled()) { $item->setStatusIcon('fa-check-circle green'); - $item->addAction( - id(new PHUIListItemView()) - ->setIcon('fa-times') - ->setHref($disable_uri) - ->setDisabled(!$can_manage) - ->addSigil('workflow')); } else { $item->setStatusIcon('fa-ban red'); $item->addIcon('fa-ban grey', pht('Disabled')); - $item->addAction( - id(new PHUIListItemView()) - ->setIcon('fa-plus') - ->setHref($enable_uri) - ->setDisabled(!$can_manage) - ->addSigil('workflow')); } $list->addItem($item); @@ -123,10 +98,11 @@ final class PhabricatorAuthListController $view = id(new PHUITwoColumnView()) ->setHeader($header) - ->setFooter(array( - $guidance, - $list, - )); + ->setFooter( + array( + $guidance, + $list, + )); $nav = $this->newNavigation() ->setCrumbs($crumbs) diff --git a/src/applications/auth/controller/config/PhabricatorAuthNewController.php b/src/applications/auth/controller/config/PhabricatorAuthNewController.php index dbd43f9ea8..770c43208d 100644 --- a/src/applications/auth/controller/config/PhabricatorAuthNewController.php +++ b/src/applications/auth/controller/config/PhabricatorAuthNewController.php @@ -6,44 +6,12 @@ final class PhabricatorAuthNewController public function handleRequest(AphrontRequest $request) { $this->requireApplicationCapability( AuthManageProvidersCapability::CAPABILITY); - $request = $this->getRequest(); - $viewer = $request->getUser(); + + $viewer = $this->getViewer(); + $cancel_uri = $this->getApplicationURI(); $providers = PhabricatorAuthProvider::getAllBaseProviders(); - $e_provider = null; - $errors = array(); - - if ($request->isFormPost()) { - $provider_string = $request->getStr('provider'); - if (!strlen($provider_string)) { - $e_provider = pht('Required'); - $errors[] = pht('You must select an authentication provider.'); - } else { - $found = false; - foreach ($providers as $provider) { - if (get_class($provider) === $provider_string) { - $found = true; - break; - } - } - if (!$found) { - $e_provider = pht('Invalid'); - $errors[] = pht('You must select a valid provider.'); - } - } - - if (!$errors) { - return id(new AphrontRedirectResponse())->setURI( - $this->getApplicationURI('/config/new/'.$provider_string.'/')); - } - } - - $options = id(new AphrontFormRadioButtonControl()) - ->setLabel(pht('Provider')) - ->setName('provider') - ->setError($e_provider); - $configured = PhabricatorAuthProvider::getAllProviders(); $configured_classes = array(); foreach ($configured as $configured_provider) { @@ -55,57 +23,52 @@ final class PhabricatorAuthNewController $providers = msort($providers, 'getLoginOrder'); $providers = array_diff_key($providers, $configured_classes) + $providers; - foreach ($providers as $provider) { - if (isset($configured_classes[get_class($provider)])) { - $disabled = true; - $description = pht('This provider is already configured.'); + $menu = id(new PHUIObjectItemListView()) + ->setViewer($viewer) + ->setBig(true) + ->setFlush(true); + + foreach ($providers as $provider_key => $provider) { + $provider_class = get_class($provider); + + $provider_uri = id(new PhutilURI('/config/edit/')) + ->replaceQueryParam('provider', $provider_class); + $provider_uri = $this->getApplicationURI($provider_uri); + + $already_exists = isset($configured_classes[get_class($provider)]); + + $item = id(new PHUIObjectItemView()) + ->setHeader($provider->getNameForCreate()) + ->setImageIcon($provider->newIconView()) + ->addAttribute($provider->getDescriptionForCreate()); + + if (!$already_exists) { + $item + ->setHref($provider_uri) + ->setClickable(true); } else { - $disabled = false; - $description = $provider->getDescriptionForCreate(); + $item->setDisabled(true); } - $options->addButton( - get_class($provider), - $provider->getNameForCreate(), - $description, - $disabled ? 'disabled' : null, - $disabled); + + if ($already_exists) { + $messages = array(); + $messages[] = pht('You already have a provider of this type.'); + + $info = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->setErrors($messages); + + $item->appendChild($info); + } + + $menu->addItem($item); } - $form = id(new AphrontFormView()) - ->setUser($viewer) - ->appendChild($options) - ->appendChild( - id(new AphrontFormSubmitControl()) - ->addCancelButton($this->getApplicationURI()) - ->setValue(pht('Continue'))); - - $form_box = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Provider')) - ->setFormErrors($errors) - ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->setForm($form); - - $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Add Provider')); - $crumbs->setBorder(true); - - $title = pht('Add Auth Provider'); - - $header = id(new PHUIHeaderView()) - ->setHeader($title) - ->setHeaderIcon('fa-plus-square'); - - $view = id(new PHUITwoColumnView()) - ->setHeader($header) - ->setFooter(array( - $form_box, - )); - - return $this->newPage() - ->setTitle($title) - ->setCrumbs($crumbs) - ->appendChild($view); - + return $this->newDialog() + ->setTitle(pht('Add Auth Provider')) + ->setWidth(AphrontDialogView::WIDTH_FORM) + ->appendChild($menu) + ->addCancelButton($cancel_uri); } } diff --git a/src/applications/auth/controller/config/PhabricatorAuthProviderViewController.php b/src/applications/auth/controller/config/PhabricatorAuthProviderViewController.php new file mode 100644 index 0000000000..532744001c --- /dev/null +++ b/src/applications/auth/controller/config/PhabricatorAuthProviderViewController.php @@ -0,0 +1,119 @@ +requireApplicationCapability( + AuthManageProvidersCapability::CAPABILITY); + + $viewer = $this->getViewer(); + $id = $request->getURIData('id'); + + $config = id(new PhabricatorAuthProviderConfigQuery()) + ->setViewer($viewer) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->withIDs(array($id)) + ->executeOne(); + if (!$config) { + return new Aphront404Response(); + } + + $header = $this->buildHeaderView($config); + $properties = $this->buildPropertiesView($config); + $curtain = $this->buildCurtain($config); + + $timeline = $this->buildTransactionTimeline( + $config, + new PhabricatorAuthProviderConfigTransactionQuery()); + $timeline->setShouldTerminate(true); + + $view = id(new PHUITwoColumnView()) + ->setHeader($header) + ->setCurtain($curtain) + ->addPropertySection(pht('Details'), $properties) + ->setMainColumn($timeline); + + $crumbs = $this->buildApplicationCrumbs() + ->addTextCrumb($config->getObjectName()) + ->setBorder(true); + + return $this->newPage() + ->setTitle(pht('Auth Provider: %s', $config->getDisplayName())) + ->setCrumbs($crumbs) + ->appendChild($view); + } + + private function buildHeaderView(PhabricatorAuthProviderConfig $config) { + $viewer = $this->getViewer(); + + $view = id(new PHUIHeaderView()) + ->setViewer($viewer) + ->setHeader($config->getDisplayName()); + + if ($config->getIsEnabled()) { + $view->setStatus('fa-check', 'bluegrey', pht('Enabled')); + } else { + $view->setStatus('fa-ban', 'red', pht('Disabled')); + } + + return $view; + } + + private function buildCurtain(PhabricatorAuthProviderConfig $config) { + $viewer = $this->getViewer(); + $id = $config->getID(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $config, + PhabricatorPolicyCapability::CAN_EDIT); + + $curtain = $this->newCurtainView($config); + + $curtain->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Edit Auth Provider')) + ->setIcon('fa-pencil') + ->setHref($this->getApplicationURI("config/edit/{$id}/")) + ->setDisabled(!$can_edit) + ->setWorkflow(!$can_edit)); + + if ($config->getIsEnabled()) { + $disable_uri = $this->getApplicationURI('config/disable/'.$id.'/'); + $disable_icon = 'fa-ban'; + $disable_text = pht('Disable Provider'); + } else { + $disable_uri = $this->getApplicationURI('config/enable/'.$id.'/'); + $disable_icon = 'fa-check'; + $disable_text = pht('Enable Provider'); + } + + $curtain->addAction( + id(new PhabricatorActionView()) + ->setName($disable_text) + ->setIcon($disable_icon) + ->setHref($disable_uri) + ->setDisabled(!$can_edit) + ->setWorkflow(true)); + + return $curtain; + } + + private function buildPropertiesView(PhabricatorAuthProviderConfig $config) { + $viewer = $this->getViewer(); + + $view = id(new PHUIPropertyListView()) + ->setViewer($viewer); + + $view->addProperty( + pht('Provider Type'), + $config->getProvider()->getProviderName()); + + return $view; + } +} diff --git a/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php b/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php new file mode 100644 index 0000000000..3fbffabc89 --- /dev/null +++ b/src/applications/auth/controller/mfa/PhabricatorAuthChallengeStatusController.php @@ -0,0 +1,46 @@ +getViewer(); + $id = $request->getURIData('id'); + $now = PhabricatorTime::getNow(); + + $result = new PhabricatorAuthChallengeUpdate(); + + $challenge = id(new PhabricatorAuthChallengeQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->withUserPHIDs(array($viewer->getPHID())) + ->withChallengeTTLBetween($now, null) + ->executeOne(); + if ($challenge) { + $config = id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($viewer) + ->withPHIDs(array($challenge->getFactorPHID())) + ->executeOne(); + if ($config) { + $provider = $config->getFactorProvider(); + $factor = $provider->getFactor(); + + $result = $factor->newChallengeStatusView( + $config, + $provider, + $viewer, + $challenge); + } + } + + return id(new AphrontAjaxResponse()) + ->setContent($result->newContent()); + } + +} diff --git a/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php b/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php index a8d87e2ead..a1636396ac 100644 --- a/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php +++ b/src/applications/auth/controller/mfa/PhabricatorAuthFactorProviderEditController.php @@ -45,7 +45,7 @@ final class PhabricatorAuthFactorProviderEditController foreach ($factors as $factor_key => $factor) { $factor_uri = id(new PhutilURI('/mfa/edit/')) - ->setQueryParam('providerFactorKey', $factor_key); + ->replaceQueryParam('providerFactorKey', $factor_key); $factor_uri = $this->getApplicationURI($factor_uri); $is_enabled = $factor->canCreateNewProvider(); diff --git a/src/applications/auth/engine/PhabricatorAuthInviteEngine.php b/src/applications/auth/engine/PhabricatorAuthInviteEngine.php index f1cb45483e..70fc03345c 100644 --- a/src/applications/auth/engine/PhabricatorAuthInviteEngine.php +++ b/src/applications/auth/engine/PhabricatorAuthInviteEngine.php @@ -147,7 +147,7 @@ final class PhabricatorAuthInviteEngine extends Phobject { // no address. Users can use password recovery to access the other // account if they really control the address. throw id(new PhabricatorAuthInviteAccountException( - pht('Wrong Acount'), + pht('Wrong Account'), pht( 'You are logged in as %s, but the email address you just '. 'clicked a link from is already the primary email address '. diff --git a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php index c052805224..38ae2201b8 100644 --- a/src/applications/auth/engine/PhabricatorAuthSessionEngine.php +++ b/src/applications/auth/engine/PhabricatorAuthSessionEngine.php @@ -714,7 +714,14 @@ final class PhabricatorAuthSessionEngine extends Phobject { if (isset($validation_results[$factor_phid])) { continue; } - $validation_results[$factor_phid] = new PhabricatorAuthFactorResult(); + + $issued_challenges = idx($challenge_map, $factor_phid, array()); + + $validation_results[$factor_phid] = $impl->getResultForPrompt( + $factor, + $viewer, + $request, + $issued_challenges); } throw id(new PhabricatorAuthHighSecurityRequiredException()) diff --git a/src/applications/auth/extension/PhabricatorAuthMainMenuBarExtension.php b/src/applications/auth/extension/PhabricatorAuthMainMenuBarExtension.php index d9fb5d013b..d49a447df6 100644 --- a/src/applications/auth/extension/PhabricatorAuthMainMenuBarExtension.php +++ b/src/applications/auth/extension/PhabricatorAuthMainMenuBarExtension.php @@ -42,7 +42,7 @@ final class PhabricatorAuthMainMenuBarExtension $uri = new PhutilURI('/auth/start/'); if ($controller) { $path = $controller->getRequest()->getPath(); - $uri->setQueryParam('next', $path); + $uri->replaceQueryParam('next', $path); } return id(new PHUIButtonView()) diff --git a/src/applications/auth/factor/PhabricatorAuthFactor.php b/src/applications/auth/factor/PhabricatorAuthFactor.php index ec49f7f748..fefd9b5fd1 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorAuthFactor.php @@ -80,6 +80,14 @@ abstract class PhabricatorAuthFactor extends Phobject { return array(); } + public function newChallengeStatusView( + PhabricatorAuthFactorConfig $config, + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $viewer, + PhabricatorAuthChallenge $challenge) { + return null; + } + /** * Is this a factor which depends on the user's contact number? * @@ -123,6 +131,7 @@ abstract class PhabricatorAuthFactor extends Phobject { ->setUserPHID($viewer->getPHID()) ->setSessionPHID($viewer->getSession()->getPHID()) ->setFactorPHID($config->getPHID()) + ->setIsNewChallenge(true) ->setWorkflowKey($engine->getWorkflowKey()); } @@ -209,11 +218,43 @@ abstract class PhabricatorAuthFactor extends Phobject { get_class($this))); } - $result->setIssuedChallenges($challenges); + return $result; + } + + final public function getResultForPrompt( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + AphrontRequest $request, + array $challenges) { + assert_instances_of($challenges, 'PhabricatorAuthChallenge'); + + $result = $this->newResultForPrompt( + $config, + $viewer, + $request, + $challenges); + + if (!$this->isAuthResult($result)) { + throw new Exception( + pht( + 'Expected "newResultForPrompt()" to return an object of class "%s", '. + 'but it returned something else ("%s"; in "%s").', + 'PhabricatorAuthFactorResult', + phutil_describe_type($result), + get_class($this))); + } return $result; } + protected function newResultForPrompt( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + AphrontRequest $request, + array $challenges) { + return $this->newResult(); + } + abstract protected function newResultFromIssuedChallenges( PhabricatorAuthFactorConfig $config, PhabricatorUser $viewer, @@ -241,8 +282,6 @@ abstract class PhabricatorAuthFactor extends Phobject { get_class($this))); } - $result->setIssuedChallenges($challenges); - return $result; } @@ -283,8 +322,11 @@ abstract class PhabricatorAuthFactor extends Phobject { $error = $result->getErrorMessage(); - $icon = id(new PHUIIconView()) - ->setIcon('fa-clock-o', 'red'); + $icon = $result->getIcon(); + if (!$icon) { + $icon = id(new PHUIIconView()) + ->setIcon('fa-clock-o', 'red'); + } return id(new PHUIFormTimerControl()) ->setIcon($icon) @@ -295,8 +337,11 @@ abstract class PhabricatorAuthFactor extends Phobject { private function newAnsweredControl( PhabricatorAuthFactorResult $result) { - $icon = id(new PHUIIconView()) - ->setIcon('fa-check-circle-o', 'green'); + $icon = $result->getIcon(); + if (!$icon) { + $icon = id(new PHUIIconView()) + ->setIcon('fa-check-circle-o', 'green'); + } return id(new PHUIFormTimerControl()) ->setIcon($icon) @@ -309,8 +354,11 @@ abstract class PhabricatorAuthFactor extends Phobject { $error = $result->getErrorMessage(); - $icon = id(new PHUIIconView()) - ->setIcon('fa-times', 'red'); + $icon = $result->getIcon(); + if (!$icon) { + $icon = id(new PHUIIconView()) + ->setIcon('fa-times', 'red'); + } return id(new PHUIFormTimerControl()) ->setIcon($icon) @@ -323,12 +371,24 @@ abstract class PhabricatorAuthFactor extends Phobject { $error = $result->getErrorMessage(); - $icon = id(new PHUIIconView()) - ->setIcon('fa-commenting', 'green'); + $icon = $result->getIcon(); + if (!$icon) { + $icon = id(new PHUIIconView()) + ->setIcon('fa-commenting', 'green'); + } - return id(new PHUIFormTimerControl()) + $control = id(new PHUIFormTimerControl()) ->setIcon($icon) ->appendChild($error); + + $status_challenge = $result->getStatusChallenge(); + if ($status_challenge) { + $id = $status_challenge->getID(); + $uri = "/auth/mfa/challenge/status/{$id}/"; + $control->setUpdateURI($uri); + } + + return $control; } diff --git a/src/applications/auth/factor/PhabricatorAuthFactorResult.php b/src/applications/auth/factor/PhabricatorAuthFactorResult.php index 2282f162a9..b5da379545 100644 --- a/src/applications/auth/factor/PhabricatorAuthFactorResult.php +++ b/src/applications/auth/factor/PhabricatorAuthFactorResult.php @@ -10,6 +10,8 @@ final class PhabricatorAuthFactorResult private $errorMessage; private $value; private $issuedChallenges = array(); + private $icon; + private $statusChallenge; public function setAnsweredChallenge(PhabricatorAuthChallenge $challenge) { if (!$challenge->getIsAnsweredChallenge()) { @@ -33,6 +35,15 @@ final class PhabricatorAuthFactorResult return $this->answeredChallenge; } + public function setStatusChallenge(PhabricatorAuthChallenge $challenge) { + $this->statusChallenge = $challenge; + return $this; + } + + public function getStatusChallenge() { + return $this->statusChallenge; + } + public function getIsValid() { return (bool)$this->getAnsweredChallenge(); } @@ -82,14 +93,13 @@ final class PhabricatorAuthFactorResult return $this->value; } - public function setIssuedChallenges(array $issued_challenges) { - assert_instances_of($issued_challenges, 'PhabricatorAuthChallenge'); - $this->issuedChallenges = $issued_challenges; + public function setIcon(PHUIIconView $icon) { + $this->icon = $icon; return $this; } - public function getIssuedChallenges() { - return $this->issuedChallenges; + public function getIcon() { + return $this->icon; } } diff --git a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php index 187e011953..a84337a764 100644 --- a/src/applications/auth/factor/PhabricatorDuoAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorDuoAuthFactor.php @@ -585,7 +585,7 @@ final class PhabricatorDuoAuthFactor $result = $this->newDuoFuture($provider) ->setHTTPMethod('GET') ->setMethod('auth_status', $parameters) - ->setTimeout(5) + ->setTimeout(3) ->resolve(); $state = $result['response']['result']; @@ -612,7 +612,22 @@ final class PhabricatorDuoAuthFactor return $this->newResult() ->setAnsweredChallenge($challenge); case 'waiting': - // No result yet, we'll render a default state later on. + // If we didn't just issue this challenge, give the user a stronger + // hint that they need to follow the instructions. + if (!$challenge->getIsNewChallenge()) { + return $this->newResult() + ->setIsContinue(true) + ->setIcon( + id(new PHUIIconView()) + ->setIcon('fa-exclamation-triangle', 'yellow')) + ->setErrorMessage( + pht( + 'You must approve the challenge which was sent to your '. + 'phone. Open the Duo application and confirm the challenge, '. + 'then continue.')); + } + + // Otherwise, we'll construct a default message later on. break; default: case 'deny': @@ -646,15 +661,6 @@ final class PhabricatorDuoAuthFactor PhabricatorAuthFactorResult $result) { $control = $this->newAutomaticControl($result); - if (!$control) { - $result = $this->newResult() - ->setIsContinue(true) - ->setErrorMessage( - pht( - 'A challenge has been sent to your phone. Open the Duo '. - 'application and confirm the challenge, then continue.')); - $control = $this->newAutomaticControl($result); - } $control ->setLabel(pht('Duo')) @@ -666,8 +672,7 @@ final class PhabricatorDuoAuthFactor public function getRequestHasChallengeResponse( PhabricatorAuthFactorConfig $config, AphrontRequest $request) { - $value = $this->getChallengeResponseFromRequest($config, $request); - return (bool)strlen($value); + return false; } protected function newResultFromChallengeResponse( @@ -676,39 +681,38 @@ final class PhabricatorDuoAuthFactor AphrontRequest $request, array $challenges) { + return $this->getResultForPrompt( + $config, + $viewer, + $request, + $challenges); + } + + protected function newResultForPrompt( + PhabricatorAuthFactorConfig $config, + PhabricatorUser $viewer, + AphrontRequest $request, + array $challenges) { + + $result = $this->newResult() + ->setIsContinue(true) + ->setErrorMessage( + pht( + 'A challenge has been sent to your phone. Open the Duo '. + 'application and confirm the challenge, then continue.')); + $challenge = $this->getChallengeForCurrentContext( $config, $viewer, $challenges); - - $code = $this->getChallengeResponseFromRequest( - $config, - $request); - - $result = $this->newResult() - ->setValue($code); - - if ($challenge->getIsAnsweredChallenge()) { - return $result->setAnsweredChallenge($challenge); + if ($challenge) { + $result + ->setStatusChallenge($challenge) + ->setIcon( + id(new PHUIIconView()) + ->setIcon('fa-refresh', 'green ph-spin')); } - if (phutil_hashes_are_identical($code, $challenge->getChallengeKey())) { - $ttl = PhabricatorTime::getNow() + phutil_units('15 minutes in seconds'); - - $challenge - ->markChallengeAsAnswered($ttl); - - return $result->setAnsweredChallenge($challenge); - } - - if (strlen($code)) { - $error_message = pht('Invalid'); - } else { - $error_message = pht('Required'); - } - - $result->setErrorMessage($error_message); - return $result; } @@ -810,4 +814,54 @@ final class PhabricatorDuoAuthFactor $hostname)); } + public function newChallengeStatusView( + PhabricatorAuthFactorConfig $config, + PhabricatorAuthFactorProvider $provider, + PhabricatorUser $viewer, + PhabricatorAuthChallenge $challenge) { + + $duo_xaction = $challenge->getChallengeKey(); + + $parameters = array( + 'txid' => $duo_xaction, + ); + + $default_result = id(new PhabricatorAuthChallengeUpdate()) + ->setRetry(true); + + try { + $result = $this->newDuoFuture($provider) + ->setHTTPMethod('GET') + ->setMethod('auth_status', $parameters) + ->setTimeout(5) + ->resolve(); + + $state = $result['response']['result']; + } catch (HTTPFutureCURLResponseStatus $exception) { + // If we failed or timed out, retry. Usually, this is a timeout. + return id(new PhabricatorAuthChallengeUpdate()) + ->setRetry(true); + } + + // For now, don't update the view for anything but an "Allow". Updates + // here are just about providing more visual feedback for user convenience. + if ($state !== 'allow') { + return id(new PhabricatorAuthChallengeUpdate()) + ->setRetry(false); + } + + $icon = id(new PHUIIconView()) + ->setIcon('fa-check-circle-o', 'green'); + + $view = id(new PHUIFormTimerControl()) + ->setIcon($icon) + ->appendChild(pht('You responded to this challenge correctly.')) + ->newTimerView(); + + return id(new PhabricatorAuthChallengeUpdate()) + ->setState('allow') + ->setRetry(false) + ->setMarkup($view); + } + } diff --git a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php index ba6613c014..7e77dfc11a 100644 --- a/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php +++ b/src/applications/auth/factor/PhabricatorTOTPAuthFactor.php @@ -128,6 +128,7 @@ final class PhabricatorTOTPAuthFactor extends PhabricatorAuthFactor { ->setLabel(pht('TOTP Code')) ->setName('totpcode') ->setValue($code) + ->setAutofocus(true) ->setError($e_code)); } diff --git a/src/applications/auth/future/PhabricatorDuoFuture.php b/src/applications/auth/future/PhabricatorDuoFuture.php index 81a5a2a2b8..1e70ec2a57 100644 --- a/src/applications/auth/future/PhabricatorDuoFuture.php +++ b/src/applications/auth/future/PhabricatorDuoFuture.php @@ -80,11 +80,6 @@ final class PhabricatorDuoFuture $host = $this->apiHostname; $host = phutil_utf8_strtolower($host); - $uri = id(new PhutilURI('')) - ->setProtocol('https') - ->setDomain($host) - ->setPath($path); - $data = $this->parameters; $date = date('r'); @@ -109,11 +104,19 @@ final class PhabricatorDuoFuture $signature = new PhutilOpaqueEnvelope($signature); if ($http_method === 'GET') { - $uri->setQueryParams($data); - $data = array(); + $uri_data = $data; + $body_data = array(); + } else { + $uri_data = array(); + $body_data = $data; } - $future = id(new HTTPSFuture($uri, $data)) + $uri = id(new PhutilURI('', $uri_data)) + ->setProtocol('https') + ->setDomain($host) + ->setPath($path); + + $future = id(new HTTPSFuture($uri, $body_data)) ->setHTTPBasicAuthCredentials($this->integrationKey, $signature) ->setMethod($http_method) ->addHeader('Accept', 'application/json') diff --git a/src/applications/auth/guidance/PhabricatorAuthProvidersGuidanceEngineExtension.php b/src/applications/auth/guidance/PhabricatorAuthProvidersGuidanceEngineExtension.php index ac3fe4d309..d1f67393ca 100644 --- a/src/applications/auth/guidance/PhabricatorAuthProvidersGuidanceEngineExtension.php +++ b/src/applications/auth/guidance/PhabricatorAuthProvidersGuidanceEngineExtension.php @@ -10,6 +10,26 @@ final class PhabricatorAuthProvidersGuidanceEngineExtension } public function generateGuidance(PhabricatorGuidanceContext $context) { + $configs = id(new PhabricatorAuthProviderConfigQuery()) + ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->withIsEnabled(true) + ->execute(); + + $allows_registration = false; + foreach ($configs as $config) { + $provider = $config->getProvider(); + if ($provider->shouldAllowRegistration()) { + $allows_registration = true; + break; + } + } + + // If no provider allows registration, we don't need provide any warnings + // about registration being too open. + if (!$allows_registration) { + return array(); + } + $domains_key = 'auth.email-domains'; $domains_link = $this->renderConfigLink($domains_key); $domains_value = PhabricatorEnv::getEnvConfig($domains_key); diff --git a/src/applications/auth/handler/PhabricatorAuthLoginHandler.php b/src/applications/auth/handler/PhabricatorAuthLoginHandler.php deleted file mode 100644 index eabbf91843..0000000000 --- a/src/applications/auth/handler/PhabricatorAuthLoginHandler.php +++ /dev/null @@ -1,36 +0,0 @@ -delegatingController = $controller; - return $this; - } - - final public function getDelegatingController() { - return $this->delegatingController; - } - - final public function setRequest(AphrontRequest $request) { - $this->request = $request; - return $this; - } - - final public function getRequest() { - return $this->request; - } - - final public static function getAllHandlers() { - return id(new PhutilClassMapQuery()) - ->setAncestorClass(__CLASS__) - ->execute(); - } -} diff --git a/src/applications/auth/message/PhabricatorAuthLinkMessageType.php b/src/applications/auth/message/PhabricatorAuthLinkMessageType.php new file mode 100644 index 0000000000..17991231c1 --- /dev/null +++ b/src/applications/auth/message/PhabricatorAuthLinkMessageType.php @@ -0,0 +1,18 @@ +renderLoginForm($controller->getRequest(), $mode = 'link'); } @@ -220,9 +220,7 @@ abstract class PhabricatorAuthProvider extends Phobject { $adapter->getAdapterDomain(), $account_id); if (!$account) { - $account = id(new PhabricatorExternalAccount()) - ->setAccountType($adapter->getAdapterType()) - ->setAccountDomain($adapter->getAdapterDomain()) + $account = $this->newExternalAccount() ->setAccountID($account_id); } @@ -299,8 +297,18 @@ abstract class PhabricatorAuthProvider extends Phobject { return false; } - public function getDefaultExternalAccount() { - throw new PhutilMethodNotImplementedException(); + public function newDefaultExternalAccount() { + return $this->newExternalAccount(); + } + + protected function newExternalAccount() { + $config = $this->getProviderConfig(); + $adapter = $this->getAdapter(); + + return id(new PhabricatorExternalAccount()) + ->setAccountType($adapter->getAdapterType()) + ->setAccountDomain($adapter->getAdapterDomain()) + ->setProviderConfigPHID($config->getPHID()); } public function getLoginOrder() { @@ -311,6 +319,12 @@ abstract class PhabricatorAuthProvider extends Phobject { return 'Generic'; } + public function newIconView() { + return id(new PHUIIconView()) + ->setSpriteSheet(PHUIIconView::SPRITE_LOGIN) + ->setSpriteIcon($this->getLoginIcon()); + } + public function isLoginFormAButton() { return false; } @@ -432,12 +446,13 @@ abstract class PhabricatorAuthProvider extends Phobject { $uri = $attributes['uri']; $uri = new PhutilURI($uri); - $params = $uri->getQueryParams(); - $uri->setQueryParams(array()); + $params = $uri->getQueryParamsAsPairList(); + $uri->removeAllQueryParams(); $content = array($button); - foreach ($params as $key => $value) { + foreach ($params as $pair) { + list($key, $value) = $pair; $content[] = phutil_tag( 'input', array( diff --git a/src/applications/auth/provider/PhabricatorFacebookAuthProvider.php b/src/applications/auth/provider/PhabricatorFacebookAuthProvider.php index e3e1fb43e5..67840727e8 100644 --- a/src/applications/auth/provider/PhabricatorFacebookAuthProvider.php +++ b/src/applications/auth/provider/PhabricatorFacebookAuthProvider.php @@ -47,6 +47,14 @@ final class PhabricatorFacebookAuthProvider return 'Facebook'; } + protected function getContentSecurityPolicyFormActions() { + return array( + // See T13254. After login with a mobile device, Facebook may redirect + // to the mobile site. + 'https://m.facebook.com/', + ); + } + public function readFormValuesFromProvider() { $require_secure = $this->getProviderConfig()->getProperty( self::KEY_REQUIRE_SECURE); @@ -114,15 +122,4 @@ final class PhabricatorFacebookAuthProvider return parent::renderConfigPropertyTransactionTitle($xaction); } - public static function getFacebookApplicationID() { - $providers = PhabricatorAuthProvider::getAllProviders(); - $fb_provider = idx($providers, 'facebook:facebook.com'); - if (!$fb_provider) { - return null; - } - - return $fb_provider->getProviderConfig()->getProperty( - self::PROPERTY_APP_ID); - } - } diff --git a/src/applications/auth/provider/PhabricatorLDAPAuthProvider.php b/src/applications/auth/provider/PhabricatorLDAPAuthProvider.php index 44b58b85ff..4a4babcc12 100644 --- a/src/applications/auth/provider/PhabricatorLDAPAuthProvider.php +++ b/src/applications/auth/provider/PhabricatorLDAPAuthProvider.php @@ -112,6 +112,7 @@ final class PhabricatorLDAPAuthProvider extends PhabricatorAuthProvider { id(new AphrontFormTextControl()) ->setLabel(pht('LDAP Username')) ->setName('ldap_username') + ->setAutofocus(true) ->setValue($v_user) ->setError($e_user)) ->appendChild( diff --git a/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php b/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php index 8cc5b8bbce..6bf145a393 100644 --- a/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php +++ b/src/applications/auth/provider/PhabricatorPasswordAuthProvider.php @@ -159,8 +159,7 @@ final class PhabricatorPasswordAuthProvider extends PhabricatorAuthProvider { return $dialog; } - public function buildLinkForm( - PhabricatorAuthLinkController $controller) { + public function buildLinkForm($controller) { throw new Exception(pht("Password providers can't be linked.")); } @@ -256,6 +255,7 @@ final class PhabricatorPasswordAuthProvider extends PhabricatorAuthProvider { id(new AphrontFormTextControl()) ->setLabel(pht('Username or Email')) ->setName('username') + ->setAutofocus(true) ->setValue($v_user) ->setError($e_user)) ->appendChild( @@ -385,14 +385,6 @@ final class PhabricatorPasswordAuthProvider extends PhabricatorAuthProvider { return true; } - public function getDefaultExternalAccount() { - $adapter = $this->getAdapter(); - - return id(new PhabricatorExternalAccount()) - ->setAccountType($adapter->getAdapterType()) - ->setAccountDomain($adapter->getAdapterDomain()); - } - protected function willSaveAccount(PhabricatorExternalAccount $account) { parent::willSaveAccount($account); $account->setUserPHID($account->getAccountID()); diff --git a/src/applications/auth/query/PhabricatorAuthProviderConfigQuery.php b/src/applications/auth/query/PhabricatorAuthProviderConfigQuery.php index 626c80348f..ee073e3ac1 100644 --- a/src/applications/auth/query/PhabricatorAuthProviderConfigQuery.php +++ b/src/applications/auth/query/PhabricatorAuthProviderConfigQuery.php @@ -6,11 +6,7 @@ final class PhabricatorAuthProviderConfigQuery private $ids; private $phids; private $providerClasses; - - const STATUS_ALL = 'status:all'; - const STATUS_ENABLED = 'status:enabled'; - - private $status = self::STATUS_ALL; + private $isEnabled; public function withPHIDs(array $phids) { $this->phids = $phids; @@ -22,40 +18,26 @@ final class PhabricatorAuthProviderConfigQuery return $this; } - public function withStatus($status) { - $this->status = $status; - return $this; - } - public function withProviderClasses(array $classes) { $this->providerClasses = $classes; return $this; } - public static function getStatusOptions() { - return array( - self::STATUS_ALL => pht('All Providers'), - self::STATUS_ENABLED => pht('Enabled Providers'), - ); + public function withIsEnabled($is_enabled) { + $this->isEnabled = $is_enabled; + return $this; + } + + public function newResultObject() { + return new PhabricatorAuthProviderConfig(); } protected function loadPage() { - $table = new PhabricatorAuthProviderConfig(); - $conn_r = $table->establishConnection('r'); - - $data = queryfx_all( - $conn_r, - 'SELECT * FROM %T %Q %Q %Q', - $table->getTableName(), - $this->buildWhereClause($conn_r), - $this->buildOrderClause($conn_r), - $this->buildLimitClause($conn_r)); - - return $table->loadAllFromArray($data); + return $this->loadStandardPage($this->newResultObject()); } - protected function buildWhereClause(AphrontDatabaseConnection $conn) { - $where = array(); + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { + $where = parent::buildWhereClauseParts($conn); if ($this->ids !== null) { $where[] = qsprintf( @@ -78,22 +60,27 @@ final class PhabricatorAuthProviderConfigQuery $this->providerClasses); } - $status = $this->status; - switch ($status) { - case self::STATUS_ALL: - break; - case self::STATUS_ENABLED: - $where[] = qsprintf( - $conn, - 'isEnabled = 1'); - break; - default: - throw new Exception(pht("Unknown status '%s'!", $status)); + if ($this->isEnabled !== null) { + $where[] = qsprintf( + $conn, + 'isEnabled = %d', + (int)$this->isEnabled); } - $where[] = $this->buildPagingClause($conn); + return $where; + } - return $this->formatWhereClause($conn, $where); + protected function willFilterPage(array $configs) { + + foreach ($configs as $key => $config) { + $provider = $config->getProvider(); + if (!$provider) { + unset($configs[$key]); + continue; + } + } + + return $configs; } public function getQueryApplicationClass() { diff --git a/src/applications/auth/query/PhabricatorExternalAccountQuery.php b/src/applications/auth/query/PhabricatorExternalAccountQuery.php index b34199ce60..1c5b3fe14f 100644 --- a/src/applications/auth/query/PhabricatorExternalAccountQuery.php +++ b/src/applications/auth/query/PhabricatorExternalAccountQuery.php @@ -21,6 +21,7 @@ final class PhabricatorExternalAccountQuery private $userPHIDs; private $needImages; private $accountSecrets; + private $providerConfigPHIDs; public function withUserPHIDs(array $user_phids) { $this->userPHIDs = $user_phids; @@ -62,6 +63,11 @@ final class PhabricatorExternalAccountQuery return $this; } + public function withProviderConfigPHIDs(array $phids) { + $this->providerConfigPHIDs = $phids; + return $this; + } + public function newResultObject() { return new PhabricatorExternalAccount(); } @@ -71,6 +77,26 @@ final class PhabricatorExternalAccountQuery } protected function willFilterPage(array $accounts) { + $viewer = $this->getViewer(); + + $configs = id(new PhabricatorAuthProviderConfigQuery()) + ->setViewer($viewer) + ->withPHIDs(mpull($accounts, 'getProviderConfigPHID')) + ->execute(); + $configs = mpull($configs, null, 'getPHID'); + + foreach ($accounts as $key => $account) { + $config_phid = $account->getProviderConfigPHID(); + $config = idx($configs, $config_phid); + + if (!$config) { + unset($accounts[$key]); + continue; + } + + $account->attachProviderConfig($config); + } + if ($this->needImages) { $file_phids = mpull($accounts, 'getProfileImagePHID'); $file_phids = array_filter($file_phids); @@ -161,6 +187,13 @@ final class PhabricatorExternalAccountQuery $this->accountSecrets); } + if ($this->providerConfigPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'providerConfigPHID IN (%Ls)', + $this->providerConfigPHIDs); + } + return $where; } @@ -168,35 +201,4 @@ final class PhabricatorExternalAccountQuery return 'PhabricatorPeopleApplication'; } - /** - * Attempts to find an external account and if none exists creates a new - * external account with a shiny new ID and PHID. - * - * NOTE: This function assumes the first item in various query parameters is - * the correct value to use in creating a new external account. - */ - public function loadOneOrCreate() { - $account = $this->executeOne(); - if (!$account) { - $account = new PhabricatorExternalAccount(); - if ($this->accountIDs) { - $account->setAccountID(reset($this->accountIDs)); - } - if ($this->accountTypes) { - $account->setAccountType(reset($this->accountTypes)); - } - if ($this->accountDomains) { - $account->setAccountDomain(reset($this->accountDomains)); - } - if ($this->accountSecrets) { - $account->setAccountSecret(reset($this->accountSecrets)); - } - if ($this->userPHIDs) { - $account->setUserPHID(reset($this->userPHIDs)); - } - $account->save(); - } - return $account; - } - } diff --git a/src/applications/auth/storage/PhabricatorAuthChallenge.php b/src/applications/auth/storage/PhabricatorAuthChallenge.php index 8fa07d712f..0b740e5fa7 100644 --- a/src/applications/auth/storage/PhabricatorAuthChallenge.php +++ b/src/applications/auth/storage/PhabricatorAuthChallenge.php @@ -16,6 +16,7 @@ final class PhabricatorAuthChallenge protected $properties = array(); private $responseToken; + private $isNewChallenge; const HTTPKEY = '__hisec.challenges__'; const TOKEN_DIGEST_KEY = 'auth.challenge.token'; @@ -241,6 +242,15 @@ final class PhabricatorAuthChallenge return $this->properties[$key]; } + public function setIsNewChallenge($is_new_challenge) { + $this->isNewChallenge = $is_new_challenge; + return $this; + } + + public function getIsNewChallenge() { + return $this->isNewChallenge; + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */ diff --git a/src/applications/auth/storage/PhabricatorAuthPasswordTransaction.php b/src/applications/auth/storage/PhabricatorAuthPasswordTransaction.php index 9d02112dff..a8cb6d10a3 100644 --- a/src/applications/auth/storage/PhabricatorAuthPasswordTransaction.php +++ b/src/applications/auth/storage/PhabricatorAuthPasswordTransaction.php @@ -11,10 +11,6 @@ final class PhabricatorAuthPasswordTransaction return PhabricatorAuthPasswordPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'PhabricatorAuthPasswordTransactionType'; } diff --git a/src/applications/auth/storage/PhabricatorAuthProviderConfig.php b/src/applications/auth/storage/PhabricatorAuthProviderConfig.php index 1de34c4077..876a70c2d0 100644 --- a/src/applications/auth/storage/PhabricatorAuthProviderConfig.php +++ b/src/applications/auth/storage/PhabricatorAuthProviderConfig.php @@ -83,6 +83,27 @@ final class PhabricatorAuthProviderConfig return $this->provider; } + public function getURI() { + return '/auth/config/view/'.$this->getID().'/'; + } + + public function getObjectName() { + return pht('Auth Provider %d', $this->getID()); + } + + public function getDisplayName() { + return $this->getProvider()->getProviderName(); + } + + public function getSortVector() { + return id(new PhutilSortVector()) + ->addString($this->getDisplayName()); + } + + public function newIconView() { + return $this->getProvider()->newIconView(); + } + /* -( PhabricatorApplicationTransactionInterface )------------------------- */ diff --git a/src/applications/auth/storage/PhabricatorAuthProviderConfigTransaction.php b/src/applications/auth/storage/PhabricatorAuthProviderConfigTransaction.php index e1453b4383..d5a3588d59 100644 --- a/src/applications/auth/storage/PhabricatorAuthProviderConfigTransaction.php +++ b/src/applications/auth/storage/PhabricatorAuthProviderConfigTransaction.php @@ -33,10 +33,6 @@ final class PhabricatorAuthProviderConfigTransaction return PhabricatorAuthAuthProviderPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getIcon() { $old = $this->getOldValue(); $new = $this->getNewValue(); diff --git a/src/applications/auth/storage/PhabricatorAuthSSHKeyTransaction.php b/src/applications/auth/storage/PhabricatorAuthSSHKeyTransaction.php index bb08310cf3..028be1746d 100644 --- a/src/applications/auth/storage/PhabricatorAuthSSHKeyTransaction.php +++ b/src/applications/auth/storage/PhabricatorAuthSSHKeyTransaction.php @@ -15,10 +15,6 @@ final class PhabricatorAuthSSHKeyTransaction return PhabricatorAuthSSHKeyPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getTitle() { $author_phid = $this->getAuthorPHID(); diff --git a/src/applications/auth/view/PhabricatorAuthChallengeUpdate.php b/src/applications/auth/view/PhabricatorAuthChallengeUpdate.php new file mode 100644 index 0000000000..a8ae5b8825 --- /dev/null +++ b/src/applications/auth/view/PhabricatorAuthChallengeUpdate.php @@ -0,0 +1,44 @@ +retry = $retry; + return $this; + } + + public function getRetry() { + return $this->retry; + } + + public function setState($state) { + $this->state = $state; + return $this; + } + + public function getState() { + return $this->state; + } + + public function setMarkup($markup) { + $this->markup = $markup; + return $this; + } + + public function getMarkup() { + return $this->markup; + } + + public function newContent() { + return array( + 'retry' => $this->getRetry(), + 'state' => $this->getState(), + 'markup' => $this->getMarkup(), + ); + } +} diff --git a/src/applications/badges/query/PhabricatorBadgesQuery.php b/src/applications/badges/query/PhabricatorBadgesQuery.php index c977e3f826..dcadf881fe 100644 --- a/src/applications/badges/query/PhabricatorBadgesQuery.php +++ b/src/applications/badges/query/PhabricatorBadgesQuery.php @@ -108,11 +108,11 @@ final class PhabricatorBadgesQuery ) + parent::getOrderableColumns(); } - protected function getPagingValueMap($cursor, array $keys) { - $badge = $this->loadCursorObject($cursor); + + protected function newPagingMapFromPartialObject($object) { return array( - 'quality' => $badge->getQuality(), - 'id' => $badge->getID(), + 'id' => (int)$object->getID(), + 'quality' => $object->getQuality(), ); } diff --git a/src/applications/base/controller/PhabricatorController.php b/src/applications/base/controller/PhabricatorController.php index 59df22a8aa..cfd0eaee65 100644 --- a/src/applications/base/controller/PhabricatorController.php +++ b/src/applications/base/controller/PhabricatorController.php @@ -608,6 +608,7 @@ abstract class PhabricatorController extends AphrontController { $this->setCurrentApplication($application); $controller = new LegalpadDocumentSignController(); + $controller->setIsSessionGate(true); return $this->delegateToController($controller); } diff --git a/src/applications/calendar/controller/PhabricatorCalendarImportViewController.php b/src/applications/calendar/controller/PhabricatorCalendarImportViewController.php index 9c07d371d0..6d92f00a97 100644 --- a/src/applications/calendar/controller/PhabricatorCalendarImportViewController.php +++ b/src/applications/calendar/controller/PhabricatorCalendarImportViewController.php @@ -234,7 +234,7 @@ final class PhabricatorCalendarImportViewController $all_uri = $this->getApplicationURI('import/log/'); $all_uri = (string)id(new PhutilURI($all_uri)) - ->setQueryParam('importSourcePHID', $import->getPHID()); + ->replaceQueryParam('importSourcePHID', $import->getPHID()); $all_button = id(new PHUIButtonView()) ->setTag('a') @@ -273,8 +273,8 @@ final class PhabricatorCalendarImportViewController $all_uri = $this->getApplicationURI(); $all_uri = (string)id(new PhutilURI($all_uri)) - ->setQueryParam('importSourcePHID', $import->getPHID()) - ->setQueryParam('display', 'list'); + ->replaceQueryParam('importSourcePHID', $import->getPHID()) + ->replaceQueryParam('display', 'list'); $all_button = id(new PHUIButtonView()) ->setTag('a') diff --git a/src/applications/calendar/query/PhabricatorCalendarEventQuery.php b/src/applications/calendar/query/PhabricatorCalendarEventQuery.php index fc1399fdb3..db50bb4d77 100644 --- a/src/applications/calendar/query/PhabricatorCalendarEventQuery.php +++ b/src/applications/calendar/query/PhabricatorCalendarEventQuery.php @@ -140,11 +140,10 @@ final class PhabricatorCalendarEventQuery ) + parent::getOrderableColumns(); } - protected function getPagingValueMap($cursor, array $keys) { - $event = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'start' => $event->getStartDateTimeEpoch(), - 'id' => $event->getID(), + 'id' => (int)$object->getID(), + 'start' => (int)$object->getStartDateTimeEpoch(), ); } diff --git a/src/applications/chatlog/controller/PhabricatorChatLogChannelLogController.php b/src/applications/chatlog/controller/PhabricatorChatLogChannelLogController.php index 2c6e58da50..b9893f6924 100644 --- a/src/applications/chatlog/controller/PhabricatorChatLogChannelLogController.php +++ b/src/applications/chatlog/controller/PhabricatorChatLogChannelLogController.php @@ -11,8 +11,7 @@ final class PhabricatorChatLogChannelLogController $viewer = $request->getViewer(); $id = $request->getURIData('channelID'); - $uri = clone $request->getRequestURI(); - $uri->setQueryParams(array()); + $uri = new PhutilURI($request->getPath()); $pager = new AphrontCursorPagerView(); $pager->setURI($uri); diff --git a/src/applications/conduit/management/PhabricatorConduitCallManagementWorkflow.php b/src/applications/conduit/management/PhabricatorConduitCallManagementWorkflow.php index f9ba48b372..dc241a04b4 100644 --- a/src/applications/conduit/management/PhabricatorConduitCallManagementWorkflow.php +++ b/src/applications/conduit/management/PhabricatorConduitCallManagementWorkflow.php @@ -58,6 +58,10 @@ final class PhabricatorConduitCallManagementWorkflow 'No such user "%s" exists.', $as)); } + + // Allow inline generation of user caches for the user we're acting + // as, since some calls may read user preferences. + $actor->setAllowInlineCacheGeneration(true); } else { $actor = $viewer; } diff --git a/src/applications/conduit/method/ConduitAPIMethod.php b/src/applications/conduit/method/ConduitAPIMethod.php index 05831a782d..0fbfaa2fc3 100644 --- a/src/applications/conduit/method/ConduitAPIMethod.php +++ b/src/applications/conduit/method/ConduitAPIMethod.php @@ -409,4 +409,19 @@ abstract class ConduitAPIMethod $capability); } + final protected function newRemarkupDocumentationView($remarkup) { + $viewer = $this->getViewer(); + + $view = new PHUIRemarkupView($viewer, $remarkup); + + $view->setRemarkupOptions( + array( + PHUIRemarkupView::OPTION_PRESERVE_LINEBREAKS => false, + )); + + return id(new PHUIBoxView()) + ->appendChild($view) + ->addPadding(PHUI::PADDING_LARGE); + } + } diff --git a/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php b/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php index 1c8a593a78..b676063e8c 100644 --- a/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php +++ b/src/applications/config/check/PhabricatorExtraConfigSetupCheck.php @@ -15,6 +15,9 @@ final class PhabricatorExtraConfigSetupCheck extends PhabricatorSetupCheck { $defined_keys = PhabricatorApplicationConfigOptions::loadAllOptions(); + $stack = PhabricatorEnv::getConfigSourceStack(); + $stack = $stack->getStack(); + foreach ($all_keys as $key) { if (isset($defined_keys[$key])) { continue; @@ -48,9 +51,6 @@ final class PhabricatorExtraConfigSetupCheck extends PhabricatorSetupCheck { ->setName($name) ->setSummary($summary); - $stack = PhabricatorEnv::getConfigSourceStack(); - $stack = $stack->getStack(); - $found = array(); $found_local = false; $found_database = false; @@ -84,6 +84,119 @@ final class PhabricatorExtraConfigSetupCheck extends PhabricatorSetupCheck { $issue->addPhabricatorConfig($key); } } + + $options = PhabricatorApplicationConfigOptions::loadAllOptions(); + foreach ($defined_keys as $key => $value) { + $option = idx($options, $key); + if (!$option) { + continue; + } + + if (!$option->getLocked()) { + continue; + } + + $found_database = false; + foreach ($stack as $source_key => $source) { + $value = $source->getKeys(array($key)); + if ($value) { + if ($source instanceof PhabricatorConfigDatabaseSource) { + $found_database = true; + break; + } + } + } + + if (!$found_database) { + continue; + } + + // NOTE: These are values which we don't let you edit directly, but edit + // via other UI workflows. For now, don't raise this warning about them. + // In the future, before we stop reading database configuration for + // locked values, we either need to add a flag which lets these values + // continue reading from the database or move them to some other storage + // mechanism. + $soft_locks = array( + 'phabricator.uninstalled-applications', + 'phabricator.application-settings', + 'config.ignore-issues', + ); + $soft_locks = array_fuse($soft_locks); + if (isset($soft_locks[$key])) { + continue; + } + + $doc_name = 'Configuration Guide: Locked and Hidden Configuration'; + $doc_href = PhabricatorEnv::getDoclink($doc_name); + + $set_command = phutil_tag( + 'tt', + array(), + csprintf( + 'bin/config set %R ', + $key)); + + $summary = pht( + 'Configuration value "%s" is locked, but has a value in the database.', + $key); + $message = pht( + 'The configuration value "%s" is locked (so it can not be edited '. + 'from the web UI), but has a database value. Usually, this means '. + 'that it was previously not locked, you set it using the web UI, '. + 'and it later became locked.'. + "\n\n". + 'You should copy this configuration value in a local configuration '. + 'source (usually by using %s) and then remove it from the database '. + 'with the command below.'. + "\n\n". + 'For more information on locked and hidden configuration, including '. + 'details about this setup issue, see %s.'. + "\n\n". + 'This database value is currently respected, but a future version '. + 'of Phabricator will stop respecting database values for locked '. + 'configuration options.', + $key, + $set_command, + phutil_tag( + 'a', + array( + 'href' => $doc_href, + 'target' => '_blank', + ), + $doc_name)); + $command = csprintf( + 'phabricator/ $ ./bin/config delete --database %R', + $key); + + $this->newIssue('config.locked.'.$key) + ->setShortName(pht('Deprecated Config Source')) + ->setName( + pht( + 'Locked Configuration Option "%s" Has Database Value', + $key)) + ->setSummary($summary) + ->setMessage($message) + ->addCommand($command) + ->addPhabricatorConfig($key); + } + + if (PhabricatorEnv::getEnvConfig('feed.http-hooks')) { + $this->newIssue('config.deprecated.feed.http-hooks') + ->setShortName(pht('Feed Hooks Deprecated')) + ->setName(pht('Migrate From "feed.http-hooks" to Webhooks')) + ->addPhabricatorConfig('feed.http-hooks') + ->setMessage( + pht( + 'The "feed.http-hooks" option is deprecated in favor of '. + 'Webhooks. This option will be removed in a future version '. + 'of Phabricator.'. + "\n\n". + 'You can configure Webhooks in Herald.'. + "\n\n". + 'To resolve this issue, remove all URIs from "feed.http-hooks".')); + } + } /** @@ -420,6 +533,8 @@ final class PhabricatorExtraConfigSetupCheck extends PhabricatorSetupCheck { 'This ancient extension point has been replaced with other '. 'mechanisms, including "AphrontSite".'), + 'differential.whitespace-matters' => pht( + 'Whitespace rendering is now handled automatically.'), ); return $ancient_config; diff --git a/src/applications/config/check/PhabricatorWebServerSetupCheck.php b/src/applications/config/check/PhabricatorWebServerSetupCheck.php index 398ebd6376..284b5e2a5f 100644 --- a/src/applications/config/check/PhabricatorWebServerSetupCheck.php +++ b/src/applications/config/check/PhabricatorWebServerSetupCheck.php @@ -40,7 +40,7 @@ final class PhabricatorWebServerSetupCheck extends PhabricatorSetupCheck { $base_uri = id(new PhutilURI($base_uri)) ->setPath($send_path) - ->setQueryParam($expect_key, $expect_value); + ->replaceQueryParam($expect_key, $expect_value); $self_future = id(new HTTPSFuture($base_uri)) ->addHeader('X-Phabricator-SelfCheck', 1) @@ -129,30 +129,16 @@ final class PhabricatorWebServerSetupCheck extends PhabricatorSetupCheck { } $structure = null; - $caught = null; $extra_whitespace = ($body !== trim($body)); - if (!$extra_whitespace) { - try { - $structure = phutil_json_decode($body); - } catch (Exception $ex) { - $caught = $ex; - } + try { + $structure = phutil_json_decode(trim($body)); + } catch (Exception $ex) { + // Ignore the exception, we only care if the decode worked or not. } - if (!$structure) { - if ($extra_whitespace) { - $message = pht( - 'Phabricator sent itself a test request and expected to get a bare '. - 'JSON response back, but the response had extra whitespace at '. - 'the beginning or end.'. - "\n\n". - 'This usually means you have edited a file and left whitespace '. - 'characters before the opening %s tag, or after a closing %s tag. '. - 'Remove any leading whitespace, and prefer to omit closing tags.', - phutil_tag('tt', array(), '')); - } else { + if (!$structure || $extra_whitespace) { + if (!$structure) { $short = id(new PhutilUTF8StringTruncator()) ->setMaximumGlyphs(1024) ->truncateString($body); @@ -166,6 +152,17 @@ final class PhabricatorWebServerSetupCheck extends PhabricatorSetupCheck { "\n\n". 'Something is misconfigured or otherwise mangling responses.', phutil_tag('pre', array(), $short)); + } else { + $message = pht( + 'Phabricator sent itself a test request and expected to get a bare '. + 'JSON response back. It received a JSON response, but the response '. + 'had extra whitespace at the beginning or end.'. + "\n\n". + 'This usually means you have edited a file and left whitespace '. + 'characters before the opening %s tag, or after a closing %s tag. '. + 'Remove any leading whitespace, and prefer to omit closing tags.', + phutil_tag('tt', array(), '')); } $this->newIssue('webserver.mangle') @@ -174,7 +171,9 @@ final class PhabricatorWebServerSetupCheck extends PhabricatorSetupCheck { ->setMessage($message); // We can't run the other checks if we could not decode the response. - return; + if (!$structure) { + return; + } } $actual_user = idx($structure, 'user'); diff --git a/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php b/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php index ce24d48ead..7e6978dfd8 100644 --- a/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php +++ b/src/applications/config/option/PhabricatorMetaMTAConfigOptions.php @@ -182,6 +182,25 @@ EODOC $mailers_description = $this->deformat(pht(<<deformat(pht(<<setHidden(true) ->setDescription($mailers_description), $this->newOption('metamta.default-address', 'string', null) - ->setDescription(pht('Default "From" address.')), + ->setLocked(true) + ->setSummary(pht('Default address used when generating mail.')) + ->setDescription($default_description), $this->newOption( 'metamta.one-mail-per-recipient', 'bool', diff --git a/src/applications/config/option/PhabricatorPHDConfigOptions.php b/src/applications/config/option/PhabricatorPHDConfigOptions.php index 37fae45dfb..e04353876a 100644 --- a/src/applications/config/option/PhabricatorPHDConfigOptions.php +++ b/src/applications/config/option/PhabricatorPHDConfigOptions.php @@ -41,7 +41,12 @@ final class PhabricatorPHDConfigOptions "If you are running a cluster, this limit applies separately ". "to each instance of `phd`. For example, if this limit is set ". "to `4` and you have three hosts running daemons, the effective ". - "global limit will be 12.")), + "global limit will be 12.". + "\n\n". + "After changing this value, you must restart the daemons. Most ". + "configuration changes are picked up by the daemons ". + "automatically, but pool sizes can not be changed without a ". + "restart.")), $this->newOption('phd.verbose', 'bool', false) ->setLocked(true) ->setBoolOptions( diff --git a/src/applications/config/storage/PhabricatorConfigTransaction.php b/src/applications/config/storage/PhabricatorConfigTransaction.php index b7cfb6f495..94272bfb1a 100644 --- a/src/applications/config/storage/PhabricatorConfigTransaction.php +++ b/src/applications/config/storage/PhabricatorConfigTransaction.php @@ -13,10 +13,6 @@ final class PhabricatorConfigTransaction return PhabricatorConfigConfigPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getTitle() { $author_phid = $this->getAuthorPHID(); diff --git a/src/applications/config/type/PhabricatorSetConfigType.php b/src/applications/config/type/PhabricatorSetConfigType.php index 805ae50468..553ee614b8 100644 --- a/src/applications/config/type/PhabricatorSetConfigType.php +++ b/src/applications/config/type/PhabricatorSetConfigType.php @@ -43,7 +43,7 @@ final class PhabricatorSetConfigType } if ($value) { - if (array_keys($value) !== range(0, count($value) - 1)) { + if (!phutil_is_natural_list($value)) { throw $this->newException( pht( 'Option "%s" is of type "%s", and should be specified on the '. diff --git a/src/applications/conpherence/controller/ConpherenceViewController.php b/src/applications/conpherence/controller/ConpherenceViewController.php index 996417a307..357d07631a 100644 --- a/src/applications/conpherence/controller/ConpherenceViewController.php +++ b/src/applications/conpherence/controller/ConpherenceViewController.php @@ -188,7 +188,7 @@ final class ConpherenceViewController extends } else { // user not logged in so give them a login button. $login_href = id(new PhutilURI('/auth/start/')) - ->setQueryParam('next', '/'.$conpherence->getMonogram()); + ->replaceQueryParam('next', '/'.$conpherence->getMonogram()); return id(new PHUIFormLayoutView()) ->addClass('login-to-participate') ->appendInstructions(pht('Log in to join this room and participate.')) diff --git a/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php b/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php index d45e347729..740a1d81e2 100644 --- a/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php +++ b/src/applications/conpherence/engineextension/ConpherenceThreadIndexEngineExtension.php @@ -51,13 +51,16 @@ final class ConpherenceThreadIndexEngineExtension ConpherenceThread $thread, ConpherenceTransaction $xaction) { - $previous = id(new ConpherenceTransactionQuery()) + $pager = id(new AphrontCursorPagerView()) + ->setPageSize(1) + ->setAfterID($xaction->getID()); + + $previous_xactions = id(new ConpherenceTransactionQuery()) ->setViewer($this->getViewer()) ->withObjectPHIDs(array($thread->getPHID())) ->withTransactionTypes(array(PhabricatorTransactions::TYPE_COMMENT)) - ->setAfterID($xaction->getID()) - ->setLimit(1) - ->executeOne(); + ->executeWithCursorPager($pager); + $previous = head($previous_xactions); $index = id(new ConpherenceIndex()) ->setThreadPHID($thread->getPHID()) diff --git a/src/applications/conpherence/query/ConpherenceThreadQuery.php b/src/applications/conpherence/query/ConpherenceThreadQuery.php index 5cd6489d65..99fd18878e 100644 --- a/src/applications/conpherence/query/ConpherenceThreadQuery.php +++ b/src/applications/conpherence/query/ConpherenceThreadQuery.php @@ -136,7 +136,7 @@ final class ConpherenceThreadQuery protected function buildGroupClause(AphrontDatabaseConnection $conn_r) { if ($this->participantPHIDs !== null || strlen($this->fulltext)) { - return 'GROUP BY thread.id'; + return qsprintf($conn_r, 'GROUP BY thread.id'); } else { return $this->buildApplicationSearchGroupClause($conn_r); } @@ -192,18 +192,24 @@ final class ConpherenceThreadQuery if ($can_optimize) { $members_policy = id(new ConpherenceThreadMembersPolicyRule()) ->getObjectPolicyFullKey(); + $policies = array( + $members_policy, + PhabricatorPolicies::POLICY_USER, + PhabricatorPolicies::POLICY_ADMIN, + PhabricatorPolicies::POLICY_NOONE, + ); if ($viewer->isLoggedIn()) { $where[] = qsprintf( $conn, - 'thread.viewPolicy != %s OR vp.participantPHID = %s', - $members_policy, + 'thread.viewPolicy NOT IN (%Ls) OR vp.participantPHID = %s', + $policies, $viewer->getPHID()); } else { $where[] = qsprintf( $conn, - 'thread.viewPolicy != %s', - $members_policy); + 'thread.viewPolicy NOT IN (%Ls)', + $policies); } } @@ -285,23 +291,35 @@ final class ConpherenceThreadQuery } private function loadTransactionsAndHandles(array $conpherences) { - $query = id(new ConpherenceTransactionQuery()) - ->setViewer($this->getViewer()) - ->withObjectPHIDs(array_keys($conpherences)) - ->needHandles(true); + // NOTE: This is older code which has been modernized to the minimum + // standard required by T13266. It probably isn't the best available + // approach to the problems it solves. + + $limit = $this->getTransactionLimit(); + if ($limit) { + // fetch an extra for "show older" scenarios + $limit = $limit + 1; + } else { + $limit = 0xFFFF; + } + + $pager = id(new AphrontCursorPagerView()) + ->setPageSize($limit); // We have to flip these for the underlying query class. The semantics of // paging are tricky business. if ($this->afterTransactionID) { - $query->setBeforeID($this->afterTransactionID); + $pager->setBeforeID($this->afterTransactionID); } else if ($this->beforeTransactionID) { - $query->setAfterID($this->beforeTransactionID); + $pager->setAfterID($this->beforeTransactionID); } - if ($this->getTransactionLimit()) { - // fetch an extra for "show older" scenarios - $query->setLimit($this->getTransactionLimit() + 1); - } - $transactions = $query->execute(); + + $transactions = id(new ConpherenceTransactionQuery()) + ->setViewer($this->getViewer()) + ->withObjectPHIDs(array_keys($conpherences)) + ->needHandles(true) + ->executeWithCursorPager($pager); + $transactions = mgroup($transactions, 'getObjectPHID'); foreach ($conpherences as $phid => $conpherence) { $current_transactions = idx($transactions, $phid, array()); diff --git a/src/applications/conpherence/view/ConpherenceLayoutView.php b/src/applications/conpherence/view/ConpherenceLayoutView.php index 7dbc00a325..3382a9ba87 100644 --- a/src/applications/conpherence/view/ConpherenceLayoutView.php +++ b/src/applications/conpherence/view/ConpherenceLayoutView.php @@ -224,12 +224,12 @@ final class ConpherenceLayoutView extends AphrontTagView { private function buildNUXView() { $viewer = $this->getViewer(); - $engine = new ConpherenceThreadSearchEngine(); - $engine->setViewer($viewer); + $engine = id(new ConpherenceThreadSearchEngine()) + ->setViewer($viewer); $saved = $engine->buildSavedQueryFromBuiltin('all'); $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(10); + $pager = $engine->newPagerForSavedQuery($saved) + ->setPageSize(10); $results = $engine->executeQuery($query, $pager); $view = $engine->renderResults($results, $saved); diff --git a/src/applications/countdown/query/PhabricatorCountdownQuery.php b/src/applications/countdown/query/PhabricatorCountdownQuery.php index e6c410ee49..67a2f3a9e3 100644 --- a/src/applications/countdown/query/PhabricatorCountdownQuery.php +++ b/src/applications/countdown/query/PhabricatorCountdownQuery.php @@ -97,11 +97,10 @@ final class PhabricatorCountdownQuery ) + parent::getOrderableColumns(); } - protected function getPagingValueMap($cursor, array $keys) { - $countdown = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'epoch' => $countdown->getEpoch(), - 'id' => $countdown->getID(), + 'id' => (int)$object->getID(), + 'epoch' => (int)$object->getEpoch(), ); } diff --git a/src/applications/daemon/application/PhabricatorDaemonsApplication.php b/src/applications/daemon/application/PhabricatorDaemonsApplication.php index a0fb77beb0..08e81d5d7e 100644 --- a/src/applications/daemon/application/PhabricatorDaemonsApplication.php +++ b/src/applications/daemon/application/PhabricatorDaemonsApplication.php @@ -45,7 +45,6 @@ final class PhabricatorDaemonsApplication extends PhabricatorApplication { '' => 'PhabricatorDaemonLogListController', '(?P[1-9]\d*)/' => 'PhabricatorDaemonLogViewController', ), - 'event/(?P[1-9]\d*)/' => 'PhabricatorDaemonLogEventViewController', 'bulk/' => array( '(?:query/(?P[^/]+)/)?' => 'PhabricatorDaemonBulkJobListController', diff --git a/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php b/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php index a70cde04c4..421008082f 100644 --- a/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php +++ b/src/applications/daemon/controller/PhabricatorDaemonConsoleController.php @@ -31,6 +31,7 @@ final class PhabricatorDaemonConsoleController $completed_info[$class] = array( 'n' => 0, 'duration' => 0, + 'queueTime' => 0, ); } $completed_info[$class]['n']++; @@ -41,16 +42,33 @@ final class PhabricatorDaemonConsoleController // compute utilization. $usage_total += $lease_overhead + ($duration / 1000000); $usage_start = min($usage_start, $completed_task->getDateModified()); + + $date_archived = $completed_task->getArchivedEpoch(); + $queue_seconds = $date_archived - $completed_task->getDateCreated(); + + // Don't measure queue time for tasks that completed in the same + // epoch-second they were created in. + if ($queue_seconds > 0) { + $sec_in_us = phutil_units('1 second in microseconds'); + $queue_us = $queue_seconds * $sec_in_us; + $queue_exclusive_us = $queue_us - $duration; + $queue_exclusive_seconds = $queue_exclusive_us / $sec_in_us; + $rounded = floor($queue_exclusive_seconds); + $completed_info[$class]['queueTime'] += $rounded; + } } $completed_info = isort($completed_info, 'n'); $rows = array(); foreach ($completed_info as $class => $info) { + $duration_avg = new PhutilNumber((int)($info['duration'] / $info['n'])); + $queue_avg = new PhutilNumber((int)($info['queueTime'] / $info['n'])); $rows[] = array( $class, number_format($info['n']), - pht('%s us', new PhutilNumber((int)($info['duration'] / $info['n']))), + pht('%s us', $duration_avg), + pht('%s s', $queue_avg), ); } @@ -98,6 +116,7 @@ final class PhabricatorDaemonConsoleController phutil_tag('em', array(), pht('Queue Utilization (Approximate)')), sprintf('%.1f%%', 100 * $used_time), null, + null, ); } @@ -108,13 +127,15 @@ final class PhabricatorDaemonConsoleController array( pht('Class'), pht('Count'), - pht('Avg'), + pht('Average Duration'), + pht('Average Queue Time'), )); $completed_table->setColumnClasses( array( 'wide', 'n', 'n', + 'n', )); $completed_panel = id(new PHUIObjectBoxView()) diff --git a/src/applications/daemon/controller/PhabricatorDaemonLogEventViewController.php b/src/applications/daemon/controller/PhabricatorDaemonLogEventViewController.php deleted file mode 100644 index 208a20b9a0..0000000000 --- a/src/applications/daemon/controller/PhabricatorDaemonLogEventViewController.php +++ /dev/null @@ -1,47 +0,0 @@ -getURIData('id'); - - $event = id(new PhabricatorDaemonLogEvent())->load($id); - if (!$event) { - return new Aphront404Response(); - } - - $event_view = id(new PhabricatorDaemonLogEventsView()) - ->setEvents(array($event)) - ->setUser($request->getUser()) - ->setCombinedLog(true) - ->setShowFullMessage(true); - - $log_panel = id(new PHUIObjectBoxView()) - ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->appendChild($event_view); - - $daemon_id = $event->getLogID(); - - $crumbs = $this->buildApplicationCrumbs() - ->addTextCrumb( - pht('Daemon %s', $daemon_id), - $this->getApplicationURI("log/{$daemon_id}/")) - ->addTextCrumb(pht('Event %s', $event->getID())) - ->setBorder(true); - - $header = id(new PHUIHeaderView()) - ->setHeader(pht('Combined Log')) - ->setHeaderIcon('fa-file-text'); - - $view = id(new PHUITwoColumnView()) - ->setHeader($header) - ->setFooter($log_panel); - - return $this->newPage() - ->setTitle(pht('Combined Daemon Log')) - ->appendChild($view); - - } - -} diff --git a/src/applications/daemon/view/PhabricatorDaemonLogEventsView.php b/src/applications/daemon/view/PhabricatorDaemonLogEventsView.php deleted file mode 100644 index 039906e718..0000000000 --- a/src/applications/daemon/view/PhabricatorDaemonLogEventsView.php +++ /dev/null @@ -1,131 +0,0 @@ -showFullMessage = $show_full_message; - return $this; - } - - public function setEvents(array $events) { - assert_instances_of($events, 'PhabricatorDaemonLogEvent'); - $this->events = $events; - return $this; - } - - public function setCombinedLog($is_combined) { - $this->combinedLog = $is_combined; - return $this; - } - - public function render() { - $viewer = $this->getViewer(); - $rows = array(); - - foreach ($this->events as $event) { - - // Limit display log size. If a daemon gets stuck in an output loop this - // page can be like >100MB if we don't truncate stuff. Try to do cheap - // line-based truncation first, and fall back to expensive UTF-8 character - // truncation if that doesn't get things short enough. - - $message = $event->getMessage(); - $more = null; - - if (!$this->showFullMessage) { - $more_lines = null; - $more_chars = null; - $line_limit = 12; - if (substr_count($message, "\n") > $line_limit) { - $message = explode("\n", $message); - $more_lines = count($message) - $line_limit; - $message = array_slice($message, 0, $line_limit); - $message = implode("\n", $message); - } - - $char_limit = 8192; - if (strlen($message) > $char_limit) { - $message = phutil_utf8v($message); - $more_chars = count($message) - $char_limit; - $message = array_slice($message, 0, $char_limit); - $message = implode('', $message); - } - - if ($more_chars) { - $more = new PhutilNumber($more_chars); - $more = pht('Show %d more character(s)...', $more); - } else if ($more_lines) { - $more = new PhutilNumber($more_lines); - $more = pht('Show %d more line(s)...', $more); - } - - if ($more) { - $id = $event->getID(); - $more = array( - "\n...\n", - phutil_tag( - 'a', - array( - 'href' => "/daemon/event/{$id}/", - ), - $more), - ); - } - } - - $row = array( - $event->getLogType(), - phabricator_date($event->getEpoch(), $viewer), - phabricator_time($event->getEpoch(), $viewer), - array( - $message, - $more, - ), - ); - - if ($this->combinedLog) { - array_unshift( - $row, - phutil_tag( - 'a', - array( - 'href' => '/daemon/log/'.$event->getLogID().'/', - ), - pht('Daemon %s', $event->getLogID()))); - } - - $rows[] = $row; - } - - $classes = array( - '', - '', - 'right', - 'wide prewrap', - ); - - $headers = array( - 'Type', - 'Date', - 'Time', - 'Message', - ); - - if ($this->combinedLog) { - array_unshift($classes, 'pri'); - array_unshift($headers, 'Daemon'); - } - - $log_table = new AphrontTableView($rows); - $log_table->setHeaders($headers); - $log_table->setColumnClasses($classes); - - return $log_table->render(); - } - -} diff --git a/src/applications/dashboard/application/PhabricatorDashboardApplication.php b/src/applications/dashboard/application/PhabricatorDashboardApplication.php index d8e2701727..081367effa 100644 --- a/src/applications/dashboard/application/PhabricatorDashboardApplication.php +++ b/src/applications/dashboard/application/PhabricatorDashboardApplication.php @@ -10,6 +10,10 @@ final class PhabricatorDashboardApplication extends PhabricatorApplication { return '/dashboard/'; } + public function getTypeaheadURI() { + return '/dashboard/console/'; + } + public function getShortDescription() { return pht('Create Custom Pages'); } @@ -27,6 +31,9 @@ final class PhabricatorDashboardApplication extends PhabricatorApplication { } public function getRoutes() { + $menu_rules = $this->getProfileMenuRouting( + 'PhabricatorDashboardPortalViewController'); + return array( '/W(?P\d+)' => 'PhabricatorDashboardPanelViewController', '/dashboard/' => array( @@ -39,6 +46,7 @@ final class PhabricatorDashboardApplication extends PhabricatorApplication { 'create/' => 'PhabricatorDashboardEditController', 'edit/(?:(?P\d+)/)?' => 'PhabricatorDashboardEditController', 'install/(?:(?P\d+)/)?' => 'PhabricatorDashboardInstallController', + 'console/' => 'PhabricatorDashboardConsoleController', 'addpanel/(?P\d+)/' => 'PhabricatorDashboardAddPanelController', 'movepanel/(?P\d+)/' => 'PhabricatorDashboardMovePanelController', 'removepanel/(?P\d+)/' @@ -57,6 +65,16 @@ final class PhabricatorDashboardApplication extends PhabricatorApplication { => 'PhabricatorDashboardPanelArchiveController', ), ), + '/portal/' => array( + $this->getQueryRoutePattern() => + 'PhabricatorDashboardPortalListController', + $this->getEditRoutePattern('edit/') => + 'PhabricatorDashboardPortalEditController', + 'view/(?P\d)/' => array( + '' => 'PhabricatorDashboardPortalViewController', + ) + $menu_rules, + + ), ); } diff --git a/src/applications/dashboard/conduit/PhabricatorDashboardPortalEditConduitAPIMethod.php b/src/applications/dashboard/conduit/PhabricatorDashboardPortalEditConduitAPIMethod.php new file mode 100644 index 0000000000..489bb21cab --- /dev/null +++ b/src/applications/dashboard/conduit/PhabricatorDashboardPortalEditConduitAPIMethod.php @@ -0,0 +1,19 @@ +getViewer(); + + $menu = id(new PHUIObjectItemListView()) + ->setUser($viewer) + ->setBig(true); + + $menu->addItem( + id(new PHUIObjectItemView()) + ->setHeader(pht('Portals')) + ->setImageIcon('fa-compass') + ->setHref('/portal/') + ->setClickable(true) + ->addAttribute( + pht( + 'Portals are collections of dashboards, links, and other '. + 'resources that can provide a high-level overview of a '. + 'project.'))); + + $menu->addItem( + id(new PHUIObjectItemView()) + ->setHeader(pht('Dashboards')) + ->setImageIcon('fa-dashboard') + ->setHref($this->getApplicationURI('/')) + ->setClickable(true) + ->addAttribute( + pht( + 'Dashboards organize panels, creating a cohesive page for '. + 'analysis or action.'))); + + $menu->addItem( + id(new PHUIObjectItemView()) + ->setHeader(pht('Panels')) + ->setImageIcon('fa-line-chart') + ->setHref($this->getApplicationURI('panel/')) + ->setClickable(true) + ->addAttribute( + pht( + 'Panels show queries, charts, and other information to provide '. + 'insight on a particular topic.'))); + + $crumbs = $this->buildApplicationCrumbs(); + $crumbs->addTextCrumb(pht('Console')); + $crumbs->setBorder(true); + + $title = pht('Dashboard Console'); + + $box = id(new PHUIObjectBoxView()) + ->setHeaderText($title) + ->setBackground(PHUIObjectBoxView::WHITE_CONFIG) + ->setObjectList($menu); + + $view = id(new PHUITwoColumnView()) + ->setFixed(true) + ->setFooter($box); + + return $this->newPage() + ->setTitle($title) + ->setCrumbs($crumbs) + ->appendChild($view); + } + +} diff --git a/src/applications/dashboard/controller/PhabricatorDashboardListController.php b/src/applications/dashboard/controller/PhabricatorDashboardListController.php index 60e6d83ab4..93a6af9fbf 100644 --- a/src/applications/dashboard/controller/PhabricatorDashboardListController.php +++ b/src/applications/dashboard/controller/PhabricatorDashboardListController.php @@ -28,9 +28,6 @@ final class PhabricatorDashboardListController ->setViewer($user) ->addNavigationItems($nav->getMenu()); - $nav->addLabel(pht('Panels')); - $nav->addFilter('panel/', pht('Manage Panels')); - $nav->selectFilter(null); return $nav; diff --git a/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalController.php b/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalController.php new file mode 100644 index 0000000000..98ac9a7846 --- /dev/null +++ b/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalController.php @@ -0,0 +1,18 @@ +addCrumb( + id(new PHUICrumbView()) + ->setHref('/portal/') + ->setName(pht('Portals')) + ->setIcon('fa-compass')); + + return $crumbs; + } + +} diff --git a/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalEditController.php b/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalEditController.php new file mode 100644 index 0000000000..327d969f34 --- /dev/null +++ b/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalEditController.php @@ -0,0 +1,12 @@ +setController($this) + ->buildResponse(); + } + +} diff --git a/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalListController.php b/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalListController.php new file mode 100644 index 0000000000..3eba0179b3 --- /dev/null +++ b/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalListController.php @@ -0,0 +1,26 @@ +setController($this) + ->buildResponse(); + } + + protected function buildApplicationCrumbs() { + $crumbs = parent::buildApplicationCrumbs(); + + id(new PhabricatorDashboardPortalEditEngine()) + ->setViewer($this->getViewer()) + ->addActionToCrumbs($crumbs); + + return $crumbs; + } + +} diff --git a/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalViewController.php b/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalViewController.php new file mode 100644 index 0000000000..259a06451b --- /dev/null +++ b/src/applications/dashboard/controller/portal/PhabricatorDashboardPortalViewController.php @@ -0,0 +1,59 @@ +portal = $portal; + return $this; + } + + public function getPortal() { + return $this->portal; + } + + public function shouldAllowPublic() { + return true; + } + + public function handleRequest(AphrontRequest $request) { + $viewer = $this->getViewer(); + $id = $request->getURIData('portalID'); + + $portal = id(new PhabricatorDashboardPortalQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->executeOne(); + if (!$portal) { + return new Aphront404Response(); + } + + $this->setPortal($portal); + + $engine = id(new PhabricatorDashboardPortalProfileMenuEngine()) + ->setProfileObject($portal) + ->setController($this); + + return $engine->buildResponse(); + } + + protected function buildApplicationCrumbs() { + $crumbs = parent::buildApplicationCrumbs(); + + $portal = $this->getPortal(); + if ($portal) { + $crumbs->addTextCrumb($portal->getName(), $portal->getURI()); + } + + return $crumbs; + } + + public function newTimelineView() { + return $this->buildTransactionTimeline( + $this->getPortal(), + new PhabricatorDashboardPortalTransactionQuery()); + } + +} diff --git a/src/applications/dashboard/editor/PhabricatorDashboardPortalEditEngine.php b/src/applications/dashboard/editor/PhabricatorDashboardPortalEditEngine.php new file mode 100644 index 0000000000..9945ea9d28 --- /dev/null +++ b/src/applications/dashboard/editor/PhabricatorDashboardPortalEditEngine.php @@ -0,0 +1,87 @@ +getName()); + } + + protected function getObjectEditShortText($object) { + return pht('Edit Portal'); + } + + protected function getObjectCreateShortText() { + return pht('Create Portal'); + } + + protected function getObjectName() { + return pht('Portal'); + } + + protected function getObjectViewURI($object) { + if ($this->getIsCreate()) { + return $object->getURI(); + } else { + return '/portal/view/'.$object->getID().'/view/manage/'; + } + } + + protected function getEditorURI() { + return '/portal/edit/'; + } + + protected function buildCustomEditFields($object) { + return array( + id(new PhabricatorTextEditField()) + ->setKey('name') + ->setLabel(pht('Name')) + ->setDescription(pht('Name of the portal.')) + ->setConduitDescription(pht('Rename the portal.')) + ->setConduitTypeDescription(pht('New portal name.')) + ->setTransactionType( + PhabricatorDashboardPortalNameTransaction::TRANSACTIONTYPE) + ->setIsRequired(true) + ->setValue($object->getName()), + ); + } + +} diff --git a/src/applications/dashboard/editor/PhabricatorDashboardPortalEditor.php b/src/applications/dashboard/editor/PhabricatorDashboardPortalEditor.php new file mode 100644 index 0000000000..9989d1e7d5 --- /dev/null +++ b/src/applications/dashboard/editor/PhabricatorDashboardPortalEditor.php @@ -0,0 +1,31 @@ +setQueryParam('dashboardID', $dashboard_id); + $edit_uri->replaceQueryParam('dashboardID', $dashboard_id); } $action_edit = id(new PHUIIconView()) @@ -303,7 +303,7 @@ final class PhabricatorDashboardPanelRenderingEngine extends Phobject { $remove_uri = "/dashboard/removepanel/{$dashboard_id}/"; $remove_uri = id(new PhutilURI($remove_uri)) - ->setQueryParam('panelPHID', $panel_phid); + ->replaceQueryParam('panelPHID', $panel_phid); $action_remove = id(new PHUIIconView()) ->setIcon('fa-trash-o') diff --git a/src/applications/dashboard/engine/PhabricatorDashboardPortalProfileMenuEngine.php b/src/applications/dashboard/engine/PhabricatorDashboardPortalProfileMenuEngine.php new file mode 100644 index 0000000000..5da4760cd3 --- /dev/null +++ b/src/applications/dashboard/engine/PhabricatorDashboardPortalProfileMenuEngine.php @@ -0,0 +1,50 @@ +getProfileObject(); + + return $portal->getURI().$path; + } + + protected function getBuiltinProfileItems($object) { + $items = array(); + + $items[] = $this->newManageItem(); + + $items[] = $this->newItem() + ->setMenuItemKey(PhabricatorDashboardPortalMenuItem::MENUITEMKEY) + ->setBuiltinKey('manage') + ->setIsTailItem(true); + + return $items; + } + + protected function newNoMenuItemsView(array $items) { + $object = $this->getProfileObject(); + $builtins = $this->getBuiltinProfileItems($object); + + if (count($items) <= count($builtins)) { + return $this->newEmptyView( + pht('New Portal'), + pht('Use "Edit Menu" to add menu items to this portal.')); + } else { + return $this->newEmptyValue( + pht('No Portal Content'), + pht( + 'None of the visible menu items in this portal can render any '. + 'content.')); + } + } + +} diff --git a/src/applications/dashboard/engine/PhabricatorDashboardRenderingEngine.php b/src/applications/dashboard/engine/PhabricatorDashboardRenderingEngine.php index ba6aace971..9f6481c05b 100644 --- a/src/applications/dashboard/engine/PhabricatorDashboardRenderingEngine.php +++ b/src/applications/dashboard/engine/PhabricatorDashboardRenderingEngine.php @@ -113,11 +113,11 @@ final class PhabricatorDashboardRenderingEngine extends Phobject { $dashboard_id = $this->dashboard->getID(); $create_uri = id(new PhutilURI('/dashboard/panel/create/')) - ->setQueryParam('dashboardID', $dashboard_id) - ->setQueryParam('column', $column); + ->replaceQueryParam('dashboardID', $dashboard_id) + ->replaceQueryParam('column', $column); $add_uri = id(new PhutilURI('/dashboard/addpanel/'.$dashboard_id.'/')) - ->setQueryParam('column', $column); + ->replaceQueryParam('column', $column); $create_button = id(new PHUIButtonView()) ->setTag('a') diff --git a/src/applications/dashboard/menuitem/PhabricatorDashboardPortalMenuItem.php b/src/applications/dashboard/menuitem/PhabricatorDashboardPortalMenuItem.php new file mode 100644 index 0000000000..6d47a4c219 --- /dev/null +++ b/src/applications/dashboard/menuitem/PhabricatorDashboardPortalMenuItem.php @@ -0,0 +1,117 @@ +getMenuItemProperty('name'); + + if (strlen($name)) { + return $name; + } + + return $this->getDefaultName(); + } + + public function buildEditEngineFields( + PhabricatorProfileMenuItemConfiguration $config) { + return array( + id(new PhabricatorTextEditField()) + ->setKey('name') + ->setLabel(pht('Name')) + ->setPlaceholder($this->getDefaultName()) + ->setValue($config->getMenuItemProperty('name')), + ); + } + + protected function newMenuItemViewList( + PhabricatorProfileMenuItemConfiguration $config) { + $viewer = $this->getViewer(); + + if (!$viewer->isLoggedIn()) { + return array(); + } + + $uri = $this->getItemViewURI($config); + $name = $this->getDisplayName($config); + $icon = 'fa-pencil'; + + $item = $this->newItemView() + ->setURI($uri) + ->setName($name) + ->setIcon($icon); + + return array( + $item, + ); + } + + public function newPageContent( + PhabricatorProfileMenuItemConfiguration $config) { + $viewer = $this->getViewer(); + $engine = $this->getEngine(); + $portal = $engine->getProfileObject(); + $controller = $engine->getController(); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Manage Portal')); + + $edit_uri = urisprintf( + '/portal/edit/%d/', + $portal->getID()); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $portal, + PhabricatorPolicyCapability::CAN_EDIT); + + $curtain = $controller->newCurtainView($portal) + ->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Edit Portal')) + ->setIcon('fa-pencil') + ->setDisabled(!$can_edit) + ->setWorkflow(!$can_edit) + ->setHref($edit_uri)); + + $timeline = $controller->newTimelineView() + ->setShouldTerminate(true); + + $view = id(new PHUITwoColumnView()) + ->setHeader($header) + ->setCurtain($curtain) + ->setMainColumn( + array( + $timeline, + )); + + return $view; + } + + +} diff --git a/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php b/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php index 0781d71b16..a71263b27e 100644 --- a/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php +++ b/src/applications/dashboard/paneltype/PhabricatorDashboardQueryPanelType.php @@ -106,9 +106,64 @@ final class PhabricatorDashboardQueryPanelType } } - $results = $engine->executeQuery($query, $pager); + $query->setReturnPartialResultsOnOverheat(true); - return $engine->renderResults($results, $saved); + $results = $engine->executeQuery($query, $pager); + $results_view = $engine->renderResults($results, $saved); + + $is_overheated = $query->getIsOverheated(); + $overheated_view = null; + if ($is_overheated) { + $content = $results_view->getContent(); + + $overheated_message = + PhabricatorApplicationSearchController::newOverheatedError( + (bool)$results); + + $overheated_warning = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_WARNING) + ->setTitle(pht('Query Overheated')) + ->setErrors( + array( + $overheated_message, + )); + + $overheated_box = id(new PHUIBoxView()) + ->addClass('mmt mmb') + ->appendChild($overheated_warning); + + $content = array($content, $overheated_box); + $results_view->setContent($content); + } + + if ($pager->getHasMoreResults()) { + $item_list = $results_view->getObjectList(); + + $more_href = $engine->getQueryResultsPageURI($key); + if ($item_list) { + $item_list->newTailButton() + ->setHref($more_href); + } else { + // For search engines that do not return an object list, add a fake + // one to the end so we can render a "View All Results" button that + // looks like it does in normal applications. At time of writing, + // several major applications like Maniphest (which has group headers) + // and Feed (which uses custom rendering) don't return simple lists. + + $content = $results_view->getContent(); + + $more_list = id(new PHUIObjectItemListView()) + ->setAllowEmptyList(true); + + $more_list->newTailButton() + ->setHref($more_href); + + $content = array($content, $more_list); + $results_view->setContent($content); + } + } + + return $results_view; } public function adjustPanelHeader( @@ -120,10 +175,18 @@ final class PhabricatorDashboardQueryPanelType $search_engine = $this->getSearchEngine($panel); $key = $panel->getProperty('key'); $href = $search_engine->getQueryResultsPageURI($key); + $icon = id(new PHUIIconView()) - ->setIcon('fa-search') - ->setHref($href); - $header->addActionItem($icon); + ->setIcon('fa-search'); + + $button = id(new PHUIButtonView()) + ->setTag('a') + ->setText(pht('View All')) + ->setIcon($icon) + ->setHref($href) + ->setColor(PHUIButtonView::GREY); + + $header->addActionLink($button); return $header; } diff --git a/src/applications/dashboard/phid/PhabricatorDashboardPortalPHIDType.php b/src/applications/dashboard/phid/PhabricatorDashboardPortalPHIDType.php new file mode 100644 index 0000000000..41c8e40f69 --- /dev/null +++ b/src/applications/dashboard/phid/PhabricatorDashboardPortalPHIDType.php @@ -0,0 +1,42 @@ +withPHIDs($phids); + } + + public function loadHandles( + PhabricatorHandleQuery $query, + array $handles, + array $objects) { + + foreach ($handles as $phid => $handle) { + $portal = $objects[$phid]; + + $handle + ->setName($portal->getName()) + ->setURI($portal->getURI()); + } + } + +} diff --git a/src/applications/dashboard/query/PhabricatorDashboardPortalQuery.php b/src/applications/dashboard/query/PhabricatorDashboardPortalQuery.php new file mode 100644 index 0000000000..d352b99c8f --- /dev/null +++ b/src/applications/dashboard/query/PhabricatorDashboardPortalQuery.php @@ -0,0 +1,64 @@ +ids = $ids; + return $this; + } + + public function withPHIDs(array $phids) { + $this->phids = $phids; + return $this; + } + + public function withStatuses(array $statuses) { + $this->statuses = $statuses; + return $this; + } + + public function newResultObject() { + return new PhabricatorDashboardPortal(); + } + + protected function loadPage() { + return $this->loadStandardPage($this->newResultObject()); + } + + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { + $where = parent::buildWhereClauseParts($conn); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn, + 'id IN (%Ld)', + $this->ids); + } + + if ($this->phids !== null) { + $where[] = qsprintf( + $conn, + 'phid IN (%Ls)', + $this->phids); + } + + if ($this->statuses !== null) { + $where[] = qsprintf( + $conn, + 'status IN (%Ls)', + $this->statuses); + } + + return $where; + } + + public function getQueryApplicationClass() { + return 'PhabricatorDashboardApplication'; + } + +} diff --git a/src/applications/dashboard/query/PhabricatorDashboardPortalSearchEngine.php b/src/applications/dashboard/query/PhabricatorDashboardPortalSearchEngine.php new file mode 100644 index 0000000000..c1633f2e97 --- /dev/null +++ b/src/applications/dashboard/query/PhabricatorDashboardPortalSearchEngine.php @@ -0,0 +1,78 @@ +newQuery(); + return $query; + } + + protected function buildCustomSearchFields() { + return array(); + } + + protected function getURI($path) { + return '/portal/'.$path; + } + + protected function getBuiltinQueryNames() { + $names = array(); + + $names['all'] = pht('All Portals'); + + return $names; + } + + public function buildSavedQueryFromBuiltin($query_key) { + $query = $this->newSavedQuery(); + $query->setQueryKey($query_key); + $viewer = $this->requireViewer(); + + switch ($query_key) { + case 'all': + return $query; + } + + return parent::buildSavedQueryFromBuiltin($query_key); + } + + protected function renderResultList( + array $portals, + PhabricatorSavedQuery $query, + array $handles) { + + assert_instances_of($portals, 'PhabricatorDashboardPortal'); + + $viewer = $this->requireViewer(); + + $list = new PHUIObjectItemListView(); + $list->setUser($viewer); + foreach ($portals as $portal) { + $item = id(new PHUIObjectItemView()) + ->setObjectName($portal->getObjectName()) + ->setHeader($portal->getName()) + ->setHref($portal->getURI()) + ->setObject($portal); + + $list->addItem($item); + } + + return id(new PhabricatorApplicationSearchResultView()) + ->setObjectList($list) + ->setNoDataString(pht('No portals found.')); + } + +} diff --git a/src/applications/dashboard/query/PhabricatorDashboardPortalTransactionQuery.php b/src/applications/dashboard/query/PhabricatorDashboardPortalTransactionQuery.php new file mode 100644 index 0000000000..f4dff94088 --- /dev/null +++ b/src/applications/dashboard/query/PhabricatorDashboardPortalTransactionQuery.php @@ -0,0 +1,10 @@ +setName('') + ->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy()) + ->setEditPolicy(PhabricatorPolicies::POLICY_USER) + ->setStatus(PhabricatorDashboardPortalStatus::STATUS_ACTIVE); + } + + protected function getConfiguration() { + return array( + self::CONFIG_AUX_PHID => true, + self::CONFIG_SERIALIZATION => array( + 'properties' => self::SERIALIZATION_JSON, + ), + self::CONFIG_COLUMN_SCHEMA => array( + 'name' => 'text255', + 'status' => 'text32', + ), + ) + parent::getConfiguration(); + } + + public function getPHIDType() { + return PhabricatorDashboardPortalPHIDType::TYPECONST; + } + + public function getPortalProperty($key, $default = null) { + return idx($this->properties, $key, $default); + } + + public function setPortalProperty($key, $value) { + $this->properties[$key] = $value; + return $this; + } + + public function getObjectName() { + return pht('Portal %d', $this->getID()); + } + + public function getURI() { + return '/portal/view/'.$this->getID().'/'; + } + + +/* -( PhabricatorApplicationTransactionInterface )------------------------- */ + + + public function getApplicationTransactionEditor() { + return new PhabricatorDashboardPortalEditor(); + } + + public function getApplicationTransactionTemplate() { + return new PhabricatorDashboardPortalTransaction(); + } + + +/* -( PhabricatorPolicyInterface )----------------------------------------- */ + + + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + ); + } + + public function getPolicy($capability) { + switch ($capability) { + case PhabricatorPolicyCapability::CAN_VIEW: + return $this->getViewPolicy(); + case PhabricatorPolicyCapability::CAN_EDIT: + return $this->getEditPolicy(); + } + } + + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { + return false; + } + + +/* -( PhabricatorDestructibleInterface )----------------------------------- */ + + + public function destroyObjectPermanently( + PhabricatorDestructionEngine $engine) { + $this->delete(); + } + + +} diff --git a/src/applications/dashboard/storage/PhabricatorDashboardPortalTransaction.php b/src/applications/dashboard/storage/PhabricatorDashboardPortalTransaction.php new file mode 100644 index 0000000000..7861394b98 --- /dev/null +++ b/src/applications/dashboard/storage/PhabricatorDashboardPortalTransaction.php @@ -0,0 +1,18 @@ +getName(); + } + + public function applyInternalEffects($object, $value) { + $object->setName($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + return pht( + '%s renamed this portal from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $max_length = $object->getColumnMaximumByteLength('name'); + foreach ($xactions as $xaction) { + $new = $xaction->getNewValue(); + if (!strlen($new)) { + $errors[] = $this->newInvalidError( + pht('Portals must have a title.'), + $xaction); + continue; + } + + if (strlen($new) > $max_length) { + $errors[] = $this->newInvalidError( + pht( + 'Portal names must not be longer than %s characters.', + $max_length)); + continue; + } + } + + if (!$errors) { + if ($this->isEmptyTextTransaction($object->getName(), $xactions)) { + $errors[] = $this->newRequiredError( + pht('Portals must have a title.')); + } + } + + return $errors; + } + + public function getTransactionTypeForConduit($xaction) { + return 'name'; + } + + public function getFieldValuesForConduit($xaction, $data) { + return array( + 'old' => $xaction->getOldValue(), + 'new' => $xaction->getNewValue(), + ); + } + +} diff --git a/src/applications/dashboard/xaction/portal/PhabricatorDashboardPortalTransactionType.php b/src/applications/dashboard/xaction/portal/PhabricatorDashboardPortalTransactionType.php new file mode 100644 index 0000000000..1855312f26 --- /dev/null +++ b/src/applications/dashboard/xaction/portal/PhabricatorDashboardPortalTransactionType.php @@ -0,0 +1,4 @@ +runParser($type, $data, $file, 'expect'); $this->runParser($type, $data, $file, 'unshielded'); - $this->runParser($type, $data, $file, 'whitespace'); } } } @@ -44,25 +47,20 @@ final class DifferentialParseRenderTestCase extends PhabricatorTestCase { } $unshielded = false; - $whitespace = false; switch ($extension) { case 'unshielded': $unshielded = true; break; - case 'whitespace'; - $unshielded = true; - $whitespace = true; - break; } $parsers = $this->buildChangesetParsers($type, $data, $file); - $actual = $this->renderParsers($parsers, $unshielded, $whitespace); + $actual = $this->renderParsers($parsers, $unshielded); $expect = Filesystem::readFile($test_file); $this->assertEqual($expect, $actual, basename($test_file)); } - private function renderParsers(array $parsers, $unshield, $whitespace) { + private function renderParsers(array $parsers, $unshield) { $result = array(); foreach ($parsers as $parser) { if ($unshield) { @@ -73,11 +71,6 @@ final class DifferentialParseRenderTestCase extends PhabricatorTestCase { $e_range = null; } - if ($whitespace) { - $parser->setWhitespaceMode( - DifferentialChangesetParser::WHITESPACE_SHOW_ALL); - } - $result[] = $parser->render($s_range, $e_range, array()); } return implode(str_repeat('~', 80)."\n", $result); diff --git a/src/applications/differential/__tests__/data/generated.diff b/src/applications/differential/__tests__/data/generated.diff index 7846c9a494..c130993cf7 100644 --- a/src/applications/differential/__tests__/data/generated.diff +++ b/src/applications/differential/__tests__/data/generated.diff @@ -4,7 +4,7 @@ index 5dcff7f..eff82ef 100644 +++ b/GENERATED @@ -1,4 +1,4 @@ @generated - + ~ -This is a generated file. +This is a generated file, full of generated code. diff --git a/src/applications/differential/__tests__/data/generated.diff.one.unshielded b/src/applications/differential/__tests__/data/generated.diff.one.unshielded index ca4b1b167e..acfa701c8b 100644 --- a/src/applications/differential/__tests__/data/generated.diff.one.unshielded +++ b/src/applications/differential/__tests__/data/generated.diff.one.unshielded @@ -1,6 +1,5 @@ N 1 . @generated\n~ -O 2 - \n~ +N 2 . \n~ O 3 - This is a generated file.\n~ -N 2 + \n~ N 3 + This is a generated file{(, full of generated code)}.\n~ N 4 . \n~ diff --git a/src/applications/differential/__tests__/data/generated.diff.two.unshielded b/src/applications/differential/__tests__/data/generated.diff.two.unshielded index 967f6220de..183a0b6edc 100644 --- a/src/applications/differential/__tests__/data/generated.diff.two.unshielded +++ b/src/applications/differential/__tests__/data/generated.diff.two.unshielded @@ -1,7 +1,7 @@ O 1 . @generated\n~ N 1 . @generated\n~ -O 2 - \n~ -N 2 + \n~ +O 2 . \n~ +N 2 . \n~ O 3 - This is a generated file.\n~ N 3 + This is a generated file{(, full of generated code)}.\n~ O 4 . \n~ diff --git a/src/applications/differential/__tests__/data/whitespace.diff.one.expect b/src/applications/differential/__tests__/data/whitespace.diff.one.expect index 5b229959d3..87ad1dcdd9 100644 --- a/src/applications/differential/__tests__/data/whitespace.diff.one.expect +++ b/src/applications/differential/__tests__/data/whitespace.diff.one.expect @@ -2,4 +2,5 @@ CTYPE 2 1 (unforced) WHITESPACE WHITESPACE - -SHIELD (whitespace) This file was changed only by adding or removing whitespace. +O 1 - -=[-Rocket-Ship>\n~ +N 1 + {> )}-=[-Rocket-Ship>\n~ diff --git a/src/applications/differential/__tests__/data/whitespace.diff.one.whitespace b/src/applications/differential/__tests__/data/whitespace.diff.one.whitespace index f4a5af6f3e..db43e02158 100644 --- a/src/applications/differential/__tests__/data/whitespace.diff.one.whitespace +++ b/src/applications/differential/__tests__/data/whitespace.diff.one.whitespace @@ -1,2 +1,2 @@ O 1 - -=[-Rocket-Ship>\n~ -N 1 + {( )}-=[-Rocket-Ship>\n~ +N 1 + {> )}-=[-Rocket-Ship>\n~ diff --git a/src/applications/differential/__tests__/data/whitespace.diff.two.expect b/src/applications/differential/__tests__/data/whitespace.diff.two.expect index 5b229959d3..87ad1dcdd9 100644 --- a/src/applications/differential/__tests__/data/whitespace.diff.two.expect +++ b/src/applications/differential/__tests__/data/whitespace.diff.two.expect @@ -2,4 +2,5 @@ CTYPE 2 1 (unforced) WHITESPACE WHITESPACE - -SHIELD (whitespace) This file was changed only by adding or removing whitespace. +O 1 - -=[-Rocket-Ship>\n~ +N 1 + {> )}-=[-Rocket-Ship>\n~ diff --git a/src/applications/differential/__tests__/data/whitespace.diff.two.whitespace b/src/applications/differential/__tests__/data/whitespace.diff.two.whitespace index f4a5af6f3e..db43e02158 100644 --- a/src/applications/differential/__tests__/data/whitespace.diff.two.whitespace +++ b/src/applications/differential/__tests__/data/whitespace.diff.two.whitespace @@ -1,2 +1,2 @@ O 1 - -=[-Rocket-Ship>\n~ -N 1 + {( )}-=[-Rocket-Ship>\n~ +N 1 + {> )}-=[-Rocket-Ship>\n~ diff --git a/src/applications/differential/config/PhabricatorDifferentialConfigOptions.php b/src/applications/differential/config/PhabricatorDifferentialConfigOptions.php index ec2099f8dd..9634756dac 100644 --- a/src/applications/differential/config/PhabricatorDifferentialConfigOptions.php +++ b/src/applications/differential/config/PhabricatorDifferentialConfigOptions.php @@ -80,18 +80,6 @@ EOHELP "Select and reorder revision fields.\n\n". "NOTE: This feature is under active development and subject ". "to change.")), - $this->newOption( - 'differential.whitespace-matters', - 'list', - array( - '/\.py$/', - '/\.l?hs$/', - '/\.ya?ml$/', - )) - ->setDescription( - pht( - "List of file regexps where whitespace is meaningful and should ". - "not use 'ignore-all' by default")), $this->newOption('differential.require-test-plan-field', 'bool', true) ->setBoolOptions( array( diff --git a/src/applications/differential/controller/DifferentialChangesetViewController.php b/src/applications/differential/controller/DifferentialChangesetViewController.php index c41f951394..35793a2108 100644 --- a/src/applications/differential/controller/DifferentialChangesetViewController.php +++ b/src/applications/differential/controller/DifferentialChangesetViewController.php @@ -420,15 +420,17 @@ final class DifferentialChangesetViewController extends DifferentialController { } private function loadCoverage(DifferentialChangeset $changeset) { + $viewer = $this->getViewer(); + $target_phids = $changeset->getDiff()->getBuildTargetPHIDs(); if (!$target_phids) { return null; } - $unit = id(new HarbormasterBuildUnitMessage())->loadAllWhere( - 'buildTargetPHID IN (%Ls)', - $target_phids); - + $unit = id(new HarbormasterBuildUnitMessageQuery()) + ->setViewer($viewer) + ->withBuildTargetPHIDs($target_phids) + ->execute(); if (!$unit) { return null; } diff --git a/src/applications/differential/controller/DifferentialController.php b/src/applications/differential/controller/DifferentialController.php index 8fe5b5caca..334d46c3cb 100644 --- a/src/applications/differential/controller/DifferentialController.php +++ b/src/applications/differential/controller/DifferentialController.php @@ -192,9 +192,10 @@ abstract class DifferentialController extends PhabricatorController { $all_target_phids = array_mergev($target_map); if ($all_target_phids) { - $unit_messages = id(new HarbormasterBuildUnitMessage())->loadAllWhere( - 'buildTargetPHID IN (%Ls)', - $all_target_phids); + $unit_messages = id(new HarbormasterBuildUnitMessageQuery()) + ->setViewer($viewer) + ->withBuildTargetPHIDs($all_target_phids) + ->execute(); $unit_messages = mgroup($unit_messages, 'getBuildTargetPHID'); } else { $unit_messages = array(); diff --git a/src/applications/differential/controller/DifferentialDiffCreateController.php b/src/applications/differential/controller/DifferentialDiffCreateController.php index fb7994f033..197ccace19 100644 --- a/src/applications/differential/controller/DifferentialDiffCreateController.php +++ b/src/applications/differential/controller/DifferentialDiffCreateController.php @@ -71,7 +71,7 @@ final class DifferentialDiffCreateController extends DifferentialController { $uri = $this->getApplicationURI("diff/{$diff_id}/"); $uri = new PhutilURI($uri); if ($revision) { - $uri->setQueryParam('revisionID', $revision->getID()); + $uri->replaceQueryParam('revisionID', $revision->getID()); } return id(new AphrontRedirectResponse())->setURI($uri); @@ -128,7 +128,7 @@ final class DifferentialDiffCreateController extends DifferentialController { $arcanist_link, ), pht( - 'You can also paste a diff below, or upload a file '. + 'You can also paste a diff above, or upload a file '. 'containing a diff (for example, from %s, %s or %s).', phutil_tag('tt', array(), 'svn diff'), phutil_tag('tt', array(), 'git diff'), diff --git a/src/applications/differential/controller/DifferentialInlineCommentEditController.php b/src/applications/differential/controller/DifferentialInlineCommentEditController.php index 9741cc93ee..1de156a9b7 100644 --- a/src/applications/differential/controller/DifferentialInlineCommentEditController.php +++ b/src/applications/differential/controller/DifferentialInlineCommentEditController.php @@ -204,9 +204,9 @@ final class DifferentialInlineCommentEditController queryfx( $conn_w, - 'INSERT IGNORE INTO %T (userPHID, commentID) VALUES %Q', + 'INSERT IGNORE INTO %T (userPHID, commentID) VALUES %LQ', $table->getTableName(), - implode(', ', $sql)); + $sql); } protected function showComments(array $ids) { diff --git a/src/applications/differential/controller/DifferentialRevisionViewController.php b/src/applications/differential/controller/DifferentialRevisionViewController.php index 8216c11557..ba36b1da17 100644 --- a/src/applications/differential/controller/DifferentialRevisionViewController.php +++ b/src/applications/differential/controller/DifferentialRevisionViewController.php @@ -305,10 +305,6 @@ final class DifferentialRevisionViewController $details = $this->buildDetails($revision, $field_list); $curtain = $this->buildCurtain($revision); - $whitespace = $request->getStr( - 'whitespace', - DifferentialChangesetParser::WHITESPACE_IGNORE_MOST); - $repository = $revision->getRepository(); if ($repository) { $symbol_indexes = $this->buildSymbolIndexes( @@ -383,7 +379,6 @@ final class DifferentialRevisionViewController ->setDiff($target) ->setRenderingReferences($rendering_references) ->setVsMap($vs_map) - ->setWhitespace($whitespace) ->setSymbolIndexes($symbol_indexes) ->setTitle(pht('Diff %s', $target->getID())) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY); @@ -412,7 +407,6 @@ final class DifferentialRevisionViewController ->setDiffUnitStatuses($broken_diffs) ->setSelectedVersusDiffID($diff_vs) ->setSelectedDiffID($target->getID()) - ->setSelectedWhitespace($whitespace) ->setCommitsForLinks($commits_for_links); $local_table = id(new DifferentialLocalCommitsView()) @@ -627,8 +621,6 @@ final class DifferentialRevisionViewController ->build($changesets); } - Javelin::initBehavior('differential-user-select'); - $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setSubheader($subheader) @@ -1095,10 +1087,11 @@ final class DifferentialRevisionViewController // this ends up being something like // D123.diff // or the verbose - // D123.vs123.id123.whitespaceignore-all.diff + // D123.vs123.id123.highlightjs.diff // lame but nice to include these options $file_name = ltrim($request_uri->getPath(), '/').'.'; - foreach ($request_uri->getQueryParams() as $key => $value) { + foreach ($request_uri->getQueryParamsAsPairList() as $pair) { + list($key, $value) = $pair; if ($key == 'download') { continue; } diff --git a/src/applications/differential/editor/DifferentialDiffEditor.php b/src/applications/differential/editor/DifferentialDiffEditor.php index 261f6f1598..e78e08d808 100644 --- a/src/applications/differential/editor/DifferentialDiffEditor.php +++ b/src/applications/differential/editor/DifferentialDiffEditor.php @@ -208,15 +208,6 @@ final class DifferentialDiffEditor return $adapter; } - protected function didApplyHeraldRules( - PhabricatorLiskDAO $object, - HeraldAdapter $adapter, - HeraldTranscript $transcript) { - - $xactions = array(); - return $xactions; - } - private function updateDiffFromDict(DifferentialDiff $diff, $dict) { $diff ->setSourcePath(idx($dict, 'sourcePath')) diff --git a/src/applications/differential/editor/DifferentialRevisionEditEngine.php b/src/applications/differential/editor/DifferentialRevisionEditEngine.php index 9c399036d5..32c82629bb 100644 --- a/src/applications/differential/editor/DifferentialRevisionEditEngine.php +++ b/src/applications/differential/editor/DifferentialRevisionEditEngine.php @@ -63,6 +63,10 @@ final class DifferentialRevisionEditEngine return $object->getMonogram(); } + public function getCreateURI($form_key) { + return '/differential/diff/create/'; + } + protected function getObjectCreateShortText() { return pht('Create Revision'); } diff --git a/src/applications/differential/engine/DifferentialChangesetEngine.php b/src/applications/differential/engine/DifferentialChangesetEngine.php index d72db025ad..23382e6a81 100644 --- a/src/applications/differential/engine/DifferentialChangesetEngine.php +++ b/src/applications/differential/engine/DifferentialChangesetEngine.php @@ -54,6 +54,12 @@ final class DifferentialChangesetEngine extends Phobject { if (strpos($new_data, '@'.'generated') !== false) { return true; } + + // See PHI1112. This is the official pattern for marking Go code as + // generated. + if (preg_match('(^// Code generated .* DO NOT EDIT\.$)m', $new_data)) { + return true; + } } return false; diff --git a/src/applications/differential/engine/DifferentialDiffExtractionEngine.php b/src/applications/differential/engine/DifferentialDiffExtractionEngine.php index 861d2ad220..7b94b1958b 100644 --- a/src/applications/differential/engine/DifferentialDiffExtractionEngine.php +++ b/src/applications/differential/engine/DifferentialDiffExtractionEngine.php @@ -285,6 +285,24 @@ final class DifferentialDiffExtractionEngine extends Phobject { ->setNewValue($revision->getModernRevisionStatus()); } + $concerning_builds = $this->loadConcerningBuilds($revision); + if ($concerning_builds) { + $build_list = array(); + foreach ($concerning_builds as $build) { + $build_list[] = array( + 'phid' => $build->getPHID(), + 'status' => $build->getBuildStatus(), + ); + } + + $wrong_builds = + DifferentialRevisionWrongBuildsTransaction::TRANSACTIONTYPE; + + $xactions[] = id(new DifferentialTransaction()) + ->setTransactionType($wrong_builds) + ->setNewValue($build_list); + } + $type_update = DifferentialRevisionUpdateTransaction::TRANSACTIONTYPE; $xactions[] = id(new DifferentialTransaction()) @@ -322,4 +340,81 @@ final class DifferentialDiffExtractionEngine extends Phobject { return $result_data; } + private function loadConcerningBuilds(DifferentialRevision $revision) { + $viewer = $this->getViewer(); + $diff = $revision->getActiveDiff(); + + $buildables = id(new HarbormasterBuildableQuery()) + ->setViewer($viewer) + ->withBuildablePHIDs(array($diff->getPHID())) + ->needBuilds(true) + ->withManualBuildables(false) + ->execute(); + if (!$buildables) { + return array(); + } + + + $land_key = HarbormasterBuildPlanBehavior::BEHAVIOR_LANDWARNING; + $behavior = HarbormasterBuildPlanBehavior::getBehavior($land_key); + + $key_never = HarbormasterBuildPlanBehavior::LANDWARNING_NEVER; + $key_building = HarbormasterBuildPlanBehavior::LANDWARNING_IF_BUILDING; + $key_complete = HarbormasterBuildPlanBehavior::LANDWARNING_IF_COMPLETE; + + $concerning_builds = array(); + foreach ($buildables as $buildable) { + $builds = $buildable->getBuilds(); + foreach ($builds as $build) { + $plan = $build->getBuildPlan(); + $option = $behavior->getPlanOption($plan); + $behavior_value = $option->getKey(); + + $if_never = ($behavior_value === $key_never); + if ($if_never) { + continue; + } + + $if_building = ($behavior_value === $key_building); + if ($if_building && $build->isComplete()) { + continue; + } + + $if_complete = ($behavior_value === $key_complete); + if ($if_complete) { + if (!$build->isComplete()) { + continue; + } + + // TODO: If you "arc land" and a build with "Warn: If Complete" + // is still running, you may not see a warning, and push the revision + // in good faith. The build may then complete before we get here, so + // we now see a completed, failed build. + + // For now, just err on the side of caution and assume these builds + // were in a good state when we prompted the user, even if they're in + // a bad state now. + + // We could refine this with a rule like "if the build finished + // within a couple of minutes before the push happened, assume it was + // in good faith", but we don't currently have an especially + // convenient way to check when the build finished or when the commit + // was pushed or discovered, and this would create some issues in + // cases where the repository is observed and the fetch pipeline + // stalls for a while. + + continue; + } + + if ($build->isPassed()) { + continue; + } + + $concerning_builds[] = $build; + } + } + + return $concerning_builds; + } + } diff --git a/src/applications/differential/harbormaster/DifferentialBuildableEngine.php b/src/applications/differential/harbormaster/DifferentialBuildableEngine.php index 8554f7be25..8565c2dcad 100644 --- a/src/applications/differential/harbormaster/DifferentialBuildableEngine.php +++ b/src/applications/differential/harbormaster/DifferentialBuildableEngine.php @@ -7,7 +7,11 @@ final class DifferentialBuildableEngine $object = $this->getObject(); if ($object instanceof DifferentialDiff) { - return $object->getRevision(); + if ($object->getRevisionID()) { + return $object->getRevision(); + } else { + return null; + } } return $object; diff --git a/src/applications/differential/parser/DifferentialChangesetParser.php b/src/applications/differential/parser/DifferentialChangesetParser.php index e214aa16a4..8ed6d80eed 100644 --- a/src/applications/differential/parser/DifferentialChangesetParser.php +++ b/src/applications/differential/parser/DifferentialChangesetParser.php @@ -8,6 +8,7 @@ final class DifferentialChangesetParser extends Phobject { protected $new = array(); protected $old = array(); protected $intra = array(); + protected $depthOnlyLines = array(); protected $newRender = null; protected $oldRender = null; @@ -18,7 +19,6 @@ final class DifferentialChangesetParser extends Phobject { protected $specialAttributes = array(); protected $changeset; - protected $whitespaceMode = null; protected $renderCacheKey = null; @@ -162,7 +162,6 @@ final class DifferentialChangesetParser extends Phobject { } public function readParametersFromRequest(AphrontRequest $request) { - $this->setWhitespaceMode($request->getStr('whitespace')); $this->setCharacterEncoding($request->getStr('encoding')); $this->setHighlightAs($request->getStr('highlight')); @@ -190,20 +189,14 @@ final class DifferentialChangesetParser extends Phobject { return $this; } - const CACHE_VERSION = 11; + const CACHE_VERSION = 14; const CACHE_MAX_SIZE = 8e6; const ATTR_GENERATED = 'attr:generated'; const ATTR_DELETED = 'attr:deleted'; const ATTR_UNCHANGED = 'attr:unchanged'; - const ATTR_WHITELINES = 'attr:white'; const ATTR_MOVEAWAY = 'attr:moveaway'; - const WHITESPACE_SHOW_ALL = 'show-all'; - const WHITESPACE_IGNORE_TRAILING = 'ignore-trailing'; - const WHITESPACE_IGNORE_MOST = 'ignore-most'; - const WHITESPACE_IGNORE_ALL = 'ignore-all'; - public function setOldLines(array $lines) { $this->old = $lines; return $this; @@ -224,6 +217,15 @@ final class DifferentialChangesetParser extends Phobject { return $this; } + public function setDepthOnlyLines(array $lines) { + $this->depthOnlyLines = $lines; + return $this; + } + + public function getDepthOnlyLines() { + return $this->depthOnlyLines; + } + public function setVisibileLinesMask(array $mask) { $this->visible = $mask; return $this; @@ -326,11 +328,6 @@ final class DifferentialChangesetParser extends Phobject { return $this; } - public function setWhitespaceMode($whitespace_mode) { - $this->whitespaceMode = $whitespace_mode; - return $this; - } - public function setRenderingReference($ref) { $this->renderingReference = $ref; return $this; @@ -450,6 +447,7 @@ final class DifferentialChangesetParser extends Phobject { 'new', 'old', 'intra', + 'depthOnlyLines', 'newRender', 'oldRender', 'specialAttributes', @@ -563,10 +561,6 @@ final class DifferentialChangesetParser extends Phobject { return idx($this->specialAttributes, self::ATTR_UNCHANGED, false); } - public function isWhitespaceOnly() { - return idx($this->specialAttributes, self::ATTR_WHITELINES, false); - } - public function isMoveAway() { return idx($this->specialAttributes, self::ATTR_MOVEAWAY, false); } @@ -574,11 +568,17 @@ final class DifferentialChangesetParser extends Phobject { private function applyIntraline(&$render, $intra, $corpus) { foreach ($render as $key => $text) { + $result = $text; + if (isset($intra[$key])) { - $render[$key] = ArcanistDiffUtils::applyIntralineDiff( - $text, + $result = ArcanistDiffUtils::applyIntralineDiff( + $result, $intra[$key]); } + + $result = $this->adjustRenderedLineForDisplay($result); + + $render[$key] = $result; } } @@ -613,18 +613,8 @@ final class DifferentialChangesetParser extends Phobject { } private function tryCacheStuff() { - $whitespace_mode = $this->whitespaceMode; - switch ($whitespace_mode) { - case self::WHITESPACE_SHOW_ALL: - case self::WHITESPACE_IGNORE_TRAILING: - case self::WHITESPACE_IGNORE_ALL: - break; - default: - $whitespace_mode = self::WHITESPACE_IGNORE_MOST; - break; - } + $skip_cache = false; - $skip_cache = ($whitespace_mode != self::WHITESPACE_IGNORE_MOST); if ($this->disableCache) { $skip_cache = true; } @@ -637,8 +627,6 @@ final class DifferentialChangesetParser extends Phobject { $skip_cache = true; } - $this->whitespaceMode = $whitespace_mode; - $changeset = $this->changeset; if ($changeset->getFileType() != DifferentialChangeType::FILE_TEXT && @@ -657,70 +645,12 @@ final class DifferentialChangesetParser extends Phobject { } private function process() { - $whitespace_mode = $this->whitespaceMode; $changeset = $this->changeset; - $ignore_all = (($whitespace_mode == self::WHITESPACE_IGNORE_MOST) || - ($whitespace_mode == self::WHITESPACE_IGNORE_ALL)); - - $force_ignore = ($whitespace_mode == self::WHITESPACE_IGNORE_ALL); - - if (!$force_ignore) { - if ($ignore_all && $changeset->getWhitespaceMatters()) { - $ignore_all = false; - } - } - - // The "ignore all whitespace" algorithm depends on rediffing the - // files, and we currently need complete representations of both - // files to do anything reasonable. If we only have parts of the files, - // don't use the "ignore all" algorithm. - if ($ignore_all) { - $hunks = $changeset->getHunks(); - if (count($hunks) !== 1) { - $ignore_all = false; - } else { - $first_hunk = reset($hunks); - if ($first_hunk->getOldOffset() != 1 || - $first_hunk->getNewOffset() != 1) { - $ignore_all = false; - } - } - } - - if ($ignore_all) { - $old_file = $changeset->makeOldFile(); - $new_file = $changeset->makeNewFile(); - if ($old_file == $new_file) { - // If the old and new files are exactly identical, the synthetic - // diff below will give us nonsense and whitespace modes are - // irrelevant anyway. This occurs when you, e.g., copy a file onto - // itself in Subversion (see T271). - $ignore_all = false; - } - } - $hunk_parser = new DifferentialHunkParser(); - $hunk_parser->setWhitespaceMode($whitespace_mode); $hunk_parser->parseHunksForLineData($changeset->getHunks()); - // Depending on the whitespace mode, we may need to compute a different - // set of changes than the set of changes in the hunk data (specifically, - // we might want to consider changed lines which have only whitespace - // changes as unchanged). - if ($ignore_all) { - $engine = new PhabricatorDifferenceEngine(); - $engine->setIgnoreWhitespace(true); - $no_whitespace_changeset = $engine->generateChangesetFromFileContent( - $old_file, - $new_file); - - $type_parser = new DifferentialHunkParser(); - $type_parser->parseHunksForLineData($no_whitespace_changeset->getHunks()); - - $hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap()); - $hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap()); - } + $this->realignDiff($changeset, $hunk_parser); $hunk_parser->reparseHunksForSpecialAttributes(); @@ -742,7 +672,6 @@ final class DifferentialChangesetParser extends Phobject { $this->setSpecialAttributes(array( self::ATTR_UNCHANGED => $unchanged, self::ATTR_DELETED => $hunk_parser->getIsDeleted(), - self::ATTR_WHITELINES => !$hunk_parser->getHasTextChanges(), self::ATTR_MOVEAWAY => $moveaway, )); @@ -754,6 +683,7 @@ final class DifferentialChangesetParser extends Phobject { $this->setOldLines($hunk_parser->getOldLines()); $this->setNewLines($hunk_parser->getNewLines()); $this->setIntraLineDiffs($hunk_parser->getIntraLineDiffs()); + $this->setDepthOnlyLines($hunk_parser->getDepthOnlyLines()); $this->setVisibileLinesMask($hunk_parser->getVisibleLinesMask()); $this->hunkStartLines = $hunk_parser->getHunkStartLines( $changeset->getHunks()); @@ -914,7 +844,8 @@ final class DifferentialChangesetParser extends Phobject { ->setShowEditAndReplyLinks($this->getShowEditAndReplyLinks()) ->setCanMarkDone($this->getCanMarkDone()) ->setObjectOwnerPHID($this->getObjectOwnerPHID()) - ->setHighlightingDisabled($this->highlightingDisabled); + ->setHighlightingDisabled($this->highlightingDisabled) + ->setDepthOnlyLines($this->getDepthOnlyLines()); $shield = null; if ($this->isTopLevel && !$this->comments) { @@ -958,10 +889,6 @@ final class DifferentialChangesetParser extends Phobject { pht('The contents of this file were not changed.'), $type); } - } else if ($this->isWhitespaceOnly()) { - $shield = $renderer->renderShield( - pht('This file was changed only by adding or removing whitespace.'), - 'whitespace'); } else if ($this->isDeleted()) { $shield = $renderer->renderShield( pht('This file was completely deleted.')); @@ -1173,7 +1100,7 @@ final class DifferentialChangesetParser extends Phobject { } $range_len = min($range_len, $rows - $range_start); - list($gaps, $mask, $depths) = $this->calculateGapsMaskAndDepths( + list($gaps, $mask) = $this->calculateGapsAndMask( $mask_force, $feedback_mask, $range_start, @@ -1181,8 +1108,7 @@ final class DifferentialChangesetParser extends Phobject { $renderer ->setGaps($gaps) - ->setMask($mask) - ->setDepths($depths); + ->setMask($mask); $html = $renderer->renderTextChange( $range_start, @@ -1208,15 +1134,9 @@ final class DifferentialChangesetParser extends Phobject { * "show more"). The $mask returned is a sparsely populated dictionary * of $visible_line_number => true. * - * Depths - compute how indented any given line is. The $depths returned - * is a sparsely populated dictionary of $visible_line_number => $depth. - * - * This function also has the side effect of modifying member variable - * new such that tabs are normalized to spaces for each line of the diff. - * - * @return array($gaps, $mask, $depths) + * @return array($gaps, $mask) */ - private function calculateGapsMaskAndDepths( + private function calculateGapsAndMask( $mask_force, $feedback_mask, $range_start, @@ -1224,7 +1144,6 @@ final class DifferentialChangesetParser extends Phobject { $lines_context = $this->getLinesOfContext(); - // Calculate gaps and mask first $gaps = array(); $gap_start = 0; $in_gap = false; @@ -1253,38 +1172,7 @@ final class DifferentialChangesetParser extends Phobject { $gaps = array_reverse($gaps); $mask = $base_mask; - // Time to calculate depth. - // We need to go backwards to properly indent whitespace in this code: - // - // 0: class C { - // 1: - // 1: function f() { - // 2: - // 2: return; - // 1: - // 1: } - // 0: - // 0: } - // - $depths = array(); - $last_depth = 0; - $range_end = $range_start + $range_len; - if (!isset($this->new[$range_end])) { - $range_end--; - } - for ($ii = $range_end; $ii >= $range_start; $ii--) { - // We need to expand tabs to process mixed indenting and to round - // correctly later. - $line = str_replace("\t", ' ', $this->new[$ii]['text']); - $trimmed = ltrim($line); - if ($trimmed != '') { - // We round down to flatten "/**" and " *". - $last_depth = floor((strlen($line) - strlen($trimmed)) / 2); - } - $depths[$ii] = $last_depth; - } - - return array($gaps, $mask, $depths); + return array($gaps, $mask); } /** @@ -1487,4 +1375,229 @@ final class DifferentialChangesetParser extends Phobject { return $key; } + private function realignDiff( + DifferentialChangeset $changeset, + DifferentialHunkParser $hunk_parser) { + // Normalizing and realigning the diff depends on rediffing the files, and + // we currently need complete representations of both files to do anything + // reasonable. If we only have parts of the files, skip realignment. + + // We have more than one hunk, so we're definitely missing part of the file. + $hunks = $changeset->getHunks(); + if (count($hunks) !== 1) { + return null; + } + + // The first hunk doesn't start at the beginning of the file, so we're + // missing some context. + $first_hunk = head($hunks); + if ($first_hunk->getOldOffset() != 1 || $first_hunk->getNewOffset() != 1) { + return null; + } + + $old_file = $changeset->makeOldFile(); + $new_file = $changeset->makeNewFile(); + if ($old_file === $new_file) { + // If the old and new files are exactly identical, the synthetic + // diff below will give us nonsense and whitespace modes are + // irrelevant anyway. This occurs when you, e.g., copy a file onto + // itself in Subversion (see T271). + return null; + } + + + $engine = id(new PhabricatorDifferenceEngine()) + ->setNormalize(true); + + $normalized_changeset = $engine->generateChangesetFromFileContent( + $old_file, + $new_file); + + $type_parser = new DifferentialHunkParser(); + $type_parser->parseHunksForLineData($normalized_changeset->getHunks()); + + $hunk_parser->setNormalized(true); + $hunk_parser->setOldLineTypeMap($type_parser->getOldLineTypeMap()); + $hunk_parser->setNewLineTypeMap($type_parser->getNewLineTypeMap()); + } + + private function adjustRenderedLineForDisplay($line) { + // IMPORTANT: We're using "str_replace()" against raw HTML here, which can + // easily become unsafe. The input HTML has already had syntax highlighting + // and intraline diff highlighting applied, so it's full of "" tags. + + static $search; + static $replace; + if ($search === null) { + $rules = $this->newSuspiciousCharacterRules(); + + $map = array(); + foreach ($rules as $key => $spec) { + $tag = phutil_tag( + 'span', + array( + 'data-copy-text' => $key, + 'class' => $spec['class'], + 'title' => $spec['title'], + ), + $spec['replacement']); + $map[$key] = phutil_string_cast($tag); + } + + $search = array_keys($map); + $replace = array_values($map); + } + + $is_html = false; + if ($line instanceof PhutilSafeHTML) { + $is_html = true; + $line = hsprintf('%s', $line); + } + + $line = phutil_string_cast($line); + + if (strpos($line, "\t") !== false) { + $line = $this->replaceTabsWithSpaces($line); + } + $line = str_replace($search, $replace, $line); + + if ($is_html) { + $line = phutil_safe_html($line); + } + + return $line; + } + + private function newSuspiciousCharacterRules() { + // The "title" attributes are cached in the database, so they're + // intentionally not wrapped in "pht(...)". + + $rules = array( + "\xE2\x80\x8B" => array( + 'title' => 'ZWS', + 'class' => 'suspicious-character', + 'replacement' => '!', + ), + "\xC2\xA0" => array( + 'title' => 'NBSP', + 'class' => 'suspicious-character', + 'replacement' => '!', + ), + "\x7F" => array( + 'title' => 'DEL (0x7F)', + 'class' => 'suspicious-character', + 'replacement' => "\xE2\x90\xA1", + ), + ); + + // Unicode defines special pictures for the control characters in the + // range between "0x00" and "0x1F". + + $control = array( + 'NULL', + 'SOH', + 'STX', + 'ETX', + 'EOT', + 'ENQ', + 'ACK', + 'BEL', + 'BS', + null, // "\t" Tab + null, // "\n" New Line + 'VT', + 'FF', + null, // "\r" Carriage Return, + 'SO', + 'SI', + 'DLE', + 'DC1', + 'DC2', + 'DC3', + 'DC4', + 'NAK', + 'SYN', + 'ETB', + 'CAN', + 'EM', + 'SUB', + 'ESC', + 'FS', + 'GS', + 'RS', + 'US', + ); + + foreach ($control as $idx => $label) { + if ($label === null) { + continue; + } + + $rules[chr($idx)] = array( + 'title' => sprintf('%s (0x%02X)', $label, $idx), + 'class' => 'suspicious-character', + 'replacement' => "\xE2\x90".chr(0x80 + $idx), + ); + } + + return $rules; + } + + private function replaceTabsWithSpaces($line) { + // TODO: This should be flexible, eventually. + $tab_width = 2; + + static $tags; + if ($tags === null) { + $tags = array(); + for ($ii = 1; $ii <= $tab_width; $ii++) { + $tag = phutil_tag( + 'span', + array( + 'data-copy-text' => "\t", + ), + str_repeat(' ', $ii)); + $tag = phutil_string_cast($tag); + $tags[$ii] = $tag; + } + } + + // If the line is particularly long, don't try to vectorize it. Use a + // faster approximation of the correct tabstop expansion instead. This + // usually still arrives at the right result. + if (strlen($line) > 256) { + return str_replace("\t", $tags[$tab_width], $line); + } + + $line = phutil_utf8v_combined($line); + $in_tag = false; + $pos = 0; + foreach ($line as $key => $char) { + if ($char === '<') { + $in_tag = true; + continue; + } + + if ($char === '>') { + $in_tag = false; + continue; + } + + if ($in_tag) { + continue; + } + + if ($char === "\t") { + $count = $tab_width - ($pos % $tab_width); + $pos += $count; + $line[$key] = $tags[$count]; + continue; + } + + $pos++; + } + + return implode('', $line); + } + } diff --git a/src/applications/differential/parser/DifferentialHunkParser.php b/src/applications/differential/parser/DifferentialHunkParser.php index 5bd98e9012..8667c032b7 100644 --- a/src/applications/differential/parser/DifferentialHunkParser.php +++ b/src/applications/differential/parser/DifferentialHunkParser.php @@ -5,8 +5,9 @@ final class DifferentialHunkParser extends Phobject { private $oldLines; private $newLines; private $intraLineDiffs; + private $depthOnlyLines; private $visibleLinesMask; - private $whitespaceMode; + private $normalized; /** * Get a map of lines on which hunks start, other than line 1. This @@ -115,20 +116,22 @@ final class DifferentialHunkParser extends Phobject { return $this; } - - public function setWhitespaceMode($white_space_mode) { - $this->whitespaceMode = $white_space_mode; + public function setDepthOnlyLines(array $map) { + $this->depthOnlyLines = $map; return $this; } - private function getWhitespaceMode() { - if ($this->whitespaceMode === null) { - throw new Exception( - pht( - 'You must %s before accessing this data.', - 'setWhitespaceMode')); - } - return $this->whitespaceMode; + public function getDepthOnlyLines() { + return $this->depthOnlyLines; + } + + public function setNormalized($normalized) { + $this->normalized = $normalized; + return $this; + } + + public function getNormalized() { + return $this->normalized; } public function getIsDeleted() { @@ -150,13 +153,6 @@ final class DifferentialHunkParser extends Phobject { return false; } - /** - * Returns true if the hunks change any text, not just whitespace. - */ - public function getHasTextChanges() { - return $this->getHasChanges('text'); - } - /** * Returns true if the hunks change anything, including whitespace. */ @@ -184,9 +180,6 @@ final class DifferentialHunkParser extends Phobject { } if ($o['type'] !== $n['type']) { - // The types are different, so either the underlying text is actually - // different or whatever whitespace rules we're using consider them - // different. return true; } @@ -269,62 +262,7 @@ final class DifferentialHunkParser extends Phobject { $this->setOldLines($rebuild_old); $this->setNewLines($rebuild_new); - $this->updateChangeTypesForWhitespaceMode(); - - return $this; - } - - private function updateChangeTypesForWhitespaceMode() { - $mode = $this->getWhitespaceMode(); - - $mode_show_all = DifferentialChangesetParser::WHITESPACE_SHOW_ALL; - if ($mode === $mode_show_all) { - // If we're showing all whitespace, we don't need to perform any updates. - return; - } - - $mode_trailing = DifferentialChangesetParser::WHITESPACE_IGNORE_TRAILING; - $is_trailing = ($mode === $mode_trailing); - - $new = $this->getNewLines(); - $old = $this->getOldLines(); - foreach ($old as $key => $o) { - $n = $new[$key]; - - if (!$o || !$n) { - continue; - } - - if ($is_trailing) { - // In "trailing" mode, we need to identify lines which are marked - // changed but differ only by trailing whitespace. We mark these lines - // unchanged. - if ($o['type'] != $n['type']) { - if (rtrim($o['text']) === rtrim($n['text'])) { - $old[$key]['type'] = null; - $new[$key]['type'] = null; - } - } - } else { - // In "ignore most" and "ignore all" modes, we need to identify lines - // which are marked unchanged but have internal whitespace changes. - // We want to ignore leading and trailing whitespace changes only, not - // internal whitespace changes (`diff` doesn't have a mode for this, so - // we have to fix it here). If the text is marked unchanged but the - // old and new text differs by internal space, mark the lines changed. - if ($o['type'] === null && $n['type'] === null) { - if ($o['text'] !== $n['text']) { - if (trim($o['text']) !== trim($n['text'])) { - $old[$key]['type'] = '-'; - $new[$key]['type'] = '+'; - } - } - } - } - } - - $this->setOldLines($old); - $this->setNewLines($new); + $this->updateChangeTypesForNormalization(); return $this; } @@ -334,6 +272,7 @@ final class DifferentialHunkParser extends Phobject { $new = $this->getNewLines(); $diffs = array(); + $depth_only = array(); foreach ($old as $key => $o) { $n = $new[$key]; @@ -342,13 +281,75 @@ final class DifferentialHunkParser extends Phobject { } if ($o['type'] != $n['type']) { - $diffs[$key] = ArcanistDiffUtils::generateIntralineDiff( - $o['text'], - $n['text']); + $o_segments = array(); + $n_segments = array(); + $tab_width = 2; + + $o_text = $o['text']; + $n_text = $n['text']; + + if ($o_text !== $n_text && (ltrim($o_text) === ltrim($n_text))) { + $o_depth = $this->getIndentDepth($o_text, $tab_width); + $n_depth = $this->getIndentDepth($n_text, $tab_width); + + if ($o_depth < $n_depth) { + $segment_type = '>'; + $segment_width = $this->getCharacterCountForVisualWhitespace( + $n_text, + ($n_depth - $o_depth), + $tab_width); + if ($segment_width) { + $n_text = substr($n_text, $segment_width); + $n_segments[] = array( + $segment_type, + $segment_width, + ); + } + } else if ($o_depth > $n_depth) { + $segment_type = '<'; + $segment_width = $this->getCharacterCountForVisualWhitespace( + $o_text, + ($o_depth - $n_depth), + $tab_width); + if ($segment_width) { + $o_text = substr($o_text, $segment_width); + $o_segments[] = array( + $segment_type, + $segment_width, + ); + } + } + + // If there are no remaining changes to this line after we've marked + // off the indent depth changes, this line was only modified by + // changing the indent depth. Mark it for later so we can change how + // it is displayed. + if ($o_text === $n_text) { + $depth_only[$key] = $segment_type; + } + } + + $intraline_segments = ArcanistDiffUtils::generateIntralineDiff( + $o_text, + $n_text); + + foreach ($intraline_segments[0] as $o_segment) { + $o_segments[] = $o_segment; + } + + foreach ($intraline_segments[1] as $n_segment) { + $n_segments[] = $n_segment; + } + + $diffs[$key] = array( + $o_segments, + $n_segments, + ); } } $this->setIntraLineDiffs($diffs); + $this->setDepthOnlyLines($depth_only); return $this; } @@ -671,4 +672,148 @@ final class DifferentialHunkParser extends Phobject { return $offsets; } + + private function getIndentDepth($text, $tab_width) { + $len = strlen($text); + + $depth = 0; + for ($ii = 0; $ii < $len; $ii++) { + $c = $text[$ii]; + + // If this is a space, increase the indent depth by 1. + if ($c == ' ') { + $depth++; + continue; + } + + // If this is a tab, increase the indent depth to the next tabstop. + + // For example, if the tab width is 4, these sequences both lead us to + // a visual width of 8, i.e. the cursor will be in the 8th column: + // + // + // + + if ($c == "\t") { + $depth = ($depth + $tab_width); + $depth = $depth - ($depth % $tab_width); + continue; + } + + break; + } + + return $depth; + } + + private function getCharacterCountForVisualWhitespace( + $text, + $depth, + $tab_width) { + + // Here, we know the visual indent depth of a line has been increased by + // some amount (for example, 6 characters). + + // We want to find the largest whitespace prefix of the string we can + // which still fits into that amount of visual space. + + // In most cases, this is very easy. For example, if the string has been + // indented by two characters and the string begins with two spaces, that's + // a perfect match. + + // However, if the string has been indented by 7 characters, the tab width + // is 8, and the string begins with "", we can only + // mark the two spaces as an indent change. These cases are unusual. + + $character_depth = 0; + $visual_depth = 0; + + $len = strlen($text); + for ($ii = 0; $ii < $len; $ii++) { + if ($visual_depth >= $depth) { + break; + } + + $c = $text[$ii]; + + if ($c == ' ') { + $character_depth++; + $visual_depth++; + continue; + } + + if ($c == "\t") { + // Figure out how many visual spaces we have until the next tabstop. + $tab_visual = ($visual_depth + $tab_width); + $tab_visual = $tab_visual - ($tab_visual % $tab_width); + $tab_visual = ($tab_visual - $visual_depth); + + // If this tab would take us over the limit, we're all done. + $remaining_depth = ($depth - $visual_depth); + if ($remaining_depth < $tab_visual) { + break; + } + + $character_depth++; + $visual_depth += $tab_visual; + continue; + } + + break; + } + + return $character_depth; + } + + private function updateChangeTypesForNormalization() { + if (!$this->getNormalized()) { + return; + } + + // If we've parsed based on a normalized diff alignment, we may currently + // believe some lines are unchanged when they have actually changed. This + // happens when: + // + // - a line changes; + // - the change is a kind of change we normalize away when aligning the + // diff, like an indentation change; + // - we normalize the change away to align the diff; and so + // - the old and new copies of the line are now aligned in the new + // normalized diff. + // + // Then we end up with an alignment where the two lines that differ only + // in some some trivial way are aligned. This is great, and exactly what + // we're trying to accomplish by doing all this alignment stuff in the + // first place. + // + // However, in this case the correctly-aligned lines will be incorrectly + // marked as unchanged because the diff alorithm was fed normalized copies + // of the lines, and these copies truly weren't any different. + // + // When lines are aligned and marked identical, but they're not actually + // identcal, we now mark them as changed. The rest of the processing will + // figure out how to render them appropritely. + + $new = $this->getNewLines(); + $old = $this->getOldLines(); + foreach ($old as $key => $o) { + $n = $new[$key]; + + if (!$o || !$n) { + continue; + } + + if ($o['type'] === null && $n['type'] === null) { + if ($o['text'] !== $n['text']) { + $old[$key]['type'] = '-'; + $new[$key]['type'] = '+'; + } + } + } + + $this->setOldLines($old); + $this->setNewLines($new); + } + + } diff --git a/src/applications/differential/parser/DifferentialLineAdjustmentMap.php b/src/applications/differential/parser/DifferentialLineAdjustmentMap.php index fde8f61f7d..e30f2ca866 100644 --- a/src/applications/differential/parser/DifferentialLineAdjustmentMap.php +++ b/src/applications/differential/parser/DifferentialLineAdjustmentMap.php @@ -359,7 +359,6 @@ final class DifferentialLineAdjustmentMap extends Phobject { } $changeset = id(new PhabricatorDifferenceEngine()) - ->setIgnoreWhitespace(true) ->generateChangesetFromFileContent($u_old, $v_old); $results[$u][$v] = self::newFromHunks( diff --git a/src/applications/differential/query/DifferentialRevisionQuery.php b/src/applications/differential/query/DifferentialRevisionQuery.php index fdd4904bee..a385fc5252 100644 --- a/src/applications/differential/query/DifferentialRevisionQuery.php +++ b/src/applications/differential/query/DifferentialRevisionQuery.php @@ -800,11 +800,10 @@ final class DifferentialRevisionQuery ) + parent::getOrderableColumns(); } - protected function getPagingValueMap($cursor, array $keys) { - $revision = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $revision->getID(), - 'updated' => $revision->getDateModified(), + 'id' => (int)$object->getID(), + 'updated' => (int)$object->getDateModified(), ); } diff --git a/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php b/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php index 30acc71c88..df59db9228 100644 --- a/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetHTMLRenderer.php @@ -367,7 +367,6 @@ abstract class DifferentialChangesetHTMLRenderer $reference = $this->getRenderingReference(); if ($force !== 'text' && - $force !== 'whitespace' && $force !== 'none' && $force !== 'default') { throw new Exception( @@ -388,10 +387,6 @@ abstract class DifferentialChangesetHTMLRenderer 'range' => $range, ); - if ($force == 'whitespace') { - $meta['whitespace'] = DifferentialChangesetParser::WHITESPACE_SHOW_ALL; - } - $content = array(); $content[] = $message; if ($force !== 'none') { @@ -437,11 +432,17 @@ abstract class DifferentialChangesetHTMLRenderer $classes[] = 'PhabricatorMonospaced'; $classes[] = $this->getRendererTableClass(); + $sigils = array(); + $sigils[] = 'differential-diff'; + foreach ($this->getTableSigils() as $sigil) { + $sigils[] = $sigil; + } + return javelin_tag( 'table', array( 'class' => implode(' ', $classes), - 'sigil' => 'differential-diff', + 'sigil' => implode(' ', $sigils), ), array( $this->renderColgroup(), @@ -449,6 +450,10 @@ abstract class DifferentialChangesetHTMLRenderer )); } + protected function getTableSigils() { + return array(); + } + protected function buildInlineComment( PhabricatorInlineCommentInterface $comment, $on_right = false) { diff --git a/src/applications/differential/render/DifferentialChangesetOneUpRenderer.php b/src/applications/differential/render/DifferentialChangesetOneUpRenderer.php index 90c3977907..289b802485 100644 --- a/src/applications/differential/render/DifferentialChangesetOneUpRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetOneUpRenderer.php @@ -92,19 +92,23 @@ final class DifferentialChangesetOneUpRenderer $line = $p['line']; $cells[] = phutil_tag( - 'th', + 'td', array( 'id' => $left_id, - 'class' => $class, - ), - $line); + 'class' => $class.' n', + 'data-n' => $line, + )); $render = $p['render']; if ($aural !== null) { $render = array($aural, $render); } - $cells[] = phutil_tag('th', array('class' => $class)); + $cells[] = phutil_tag( + 'td', + array( + 'class' => $class.' n', + )); $cells[] = $no_copy; $cells[] = phutil_tag('td', array('class' => $class), $render); $cells[] = $no_coverage; @@ -115,7 +119,11 @@ final class DifferentialChangesetOneUpRenderer } else { $class = 'right new'; } - $cells[] = phutil_tag('th', array('class' => $class)); + $cells[] = phutil_tag( + 'td', + array( + 'class' => $class.' n', + )); $aural = $aural_plus; } else { $class = 'right'; @@ -127,7 +135,13 @@ final class DifferentialChangesetOneUpRenderer $oline = $p['oline']; - $cells[] = phutil_tag('th', array('id' => $left_id), $oline); + $cells[] = phutil_tag( + 'td', + array( + 'id' => $left_id, + 'class' => 'n', + 'data-n' => $oline, + )); $aural = null; } @@ -144,12 +158,12 @@ final class DifferentialChangesetOneUpRenderer $line = $p['line']; $cells[] = phutil_tag( - 'th', + 'td', array( 'id' => $right_id, - 'class' => $class, - ), - $line); + 'class' => $class.' n', + 'data-n' => $line, + )); $render = $p['render']; if ($aural !== null) { diff --git a/src/applications/differential/render/DifferentialChangesetRenderer.php b/src/applications/differential/render/DifferentialChangesetRenderer.php index 9e7e676c12..4ed77bf041 100644 --- a/src/applications/differential/render/DifferentialChangesetRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetRenderer.php @@ -28,12 +28,13 @@ abstract class DifferentialChangesetRenderer extends Phobject { private $originalNew; private $gaps; private $mask; - private $depths; private $originalCharacterEncoding; private $showEditAndReplyLinks; private $canMarkDone; private $objectOwnerPHID; private $highlightingDisabled; + private $scopeEngine = false; + private $depthOnlyLines; private $oldFile = false; private $newFile = false; @@ -76,14 +77,6 @@ abstract class DifferentialChangesetRenderer extends Phobject { return $this->isUndershield; } - public function setDepths($depths) { - $this->depths = $depths; - return $this; - } - protected function getDepths() { - return $this->depths; - } - public function setMask($mask) { $this->mask = $mask; return $this; @@ -100,6 +93,15 @@ abstract class DifferentialChangesetRenderer extends Phobject { return $this->gaps; } + public function setDepthOnlyLines(array $lines) { + $this->depthOnlyLines = $lines; + return $this; + } + + public function getDepthOnlyLines() { + return $this->depthOnlyLines; + } + public function attachOldFile(PhabricatorFile $old = null) { $this->oldFile = $old; return $this; @@ -361,14 +363,14 @@ abstract class DifferentialChangesetRenderer extends Phobject { $undershield = $this->renderUndershieldHeader(); } - $result = $notice.$props.$undershield.$content; + $result = array( + $notice, + $props, + $undershield, + $content, + ); - // TODO: Let the user customize their tab width / display style. - // TODO: We should possibly post-process "\r" as well. - // TODO: Both these steps should happen earlier. - $result = str_replace("\t", ' ', $result); - - return phutil_safe_html($result); + return hsprintf('%s', $result); } abstract public function isOneUpRenderer(); @@ -404,9 +406,6 @@ abstract class DifferentialChangesetRenderer extends Phobject { * important (e.g., generated code). * - `"text"`: Force the text to be shown. This is probably only relevant * when a file is not changed. - * - `"whitespace"`: Force the text to be shown, and the diff to be - * rendered with all whitespace shown. This is probably only relevant - * when a file is changed only by altering whitespace. * - `"none"`: Don't show the link (e.g., text not available). * * @param string Message explaining why the diff is hidden. @@ -678,4 +677,47 @@ abstract class DifferentialChangesetRenderer extends Phobject { return $views; } + final protected function getScopeEngine() { + if ($this->scopeEngine === false) { + $hunk_starts = $this->getHunkStartLines(); + + // If this change is missing context, don't try to identify scopes, since + // we won't really be able to get anywhere. + $has_multiple_hunks = (count($hunk_starts) > 1); + + $has_offset_hunks = false; + if ($hunk_starts) { + $has_offset_hunks = (head_key($hunk_starts) != 1); + } + + $missing_context = ($has_multiple_hunks || $has_offset_hunks); + + if ($missing_context) { + $scope_engine = null; + } else { + $line_map = $this->getNewLineTextMap(); + $scope_engine = id(new PhabricatorDiffScopeEngine()) + ->setLineTextMap($line_map); + } + + $this->scopeEngine = $scope_engine; + } + + return $this->scopeEngine; + } + + private function getNewLineTextMap() { + $new = $this->getNewLines(); + + $text_map = array(); + foreach ($new as $new_line) { + if (!isset($new_line['line'])) { + continue; + } + $text_map[$new_line['line']] = $new_line['text']; + } + + return $text_map; + } + } diff --git a/src/applications/differential/render/DifferentialChangesetTestRenderer.php b/src/applications/differential/render/DifferentialChangesetTestRenderer.php index a0d1fad0eb..e2bd3f53ed 100644 --- a/src/applications/differential/render/DifferentialChangesetTestRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetTestRenderer.php @@ -96,10 +96,14 @@ abstract class DifferentialChangesetTestRenderer array( '', '', + '', + '', ), array( '{(', ')}', + '{<', + '{>', ), $render); @@ -127,7 +131,7 @@ abstract class DifferentialChangesetTestRenderer } $out = implode("\n", $out)."\n"; - return $out; + return phutil_safe_html($out); } diff --git a/src/applications/differential/render/DifferentialChangesetTwoUpRenderer.php b/src/applications/differential/render/DifferentialChangesetTwoUpRenderer.php index 5d476f5136..d803e92c6c 100644 --- a/src/applications/differential/render/DifferentialChangesetTwoUpRenderer.php +++ b/src/applications/differential/render/DifferentialChangesetTwoUpRenderer.php @@ -3,6 +3,8 @@ final class DifferentialChangesetTwoUpRenderer extends DifferentialChangesetHTMLRenderer { + private $newOffsetMap; + public function isOneUpRenderer() { return false; } @@ -66,9 +68,12 @@ final class DifferentialChangesetTwoUpRenderer $new_render = $this->getNewRender(); $original_left = $this->getOriginalOld(); $original_right = $this->getOriginalNew(); - $depths = $this->getDepths(); $mask = $this->getMask(); + $scope_engine = $this->getScopeEngine(); + $offset_map = null; + $depth_only = $this->getDepthOnlyLines(); + for ($ii = $range_start; $ii < $range_start + $range_len; $ii++) { if (empty($mask[$ii])) { // If we aren't going to show this line, we've just entered a gap. @@ -87,16 +92,19 @@ final class DifferentialChangesetTwoUpRenderer $is_last_block = true; } - $context = null; + $context_text = null; $context_line = null; - if (!$is_last_block && $depths[$ii + $len]) { - for ($l = $ii + $len - 1; $l >= $ii; $l--) { - $line = $new_lines[$l]['text']; - if ($depths[$l] < $depths[$ii + $len] && trim($line) != '') { - $context = $new_render[$l]; - $context_line = $new_lines[$l]['line']; - break; + if (!$is_last_block && $scope_engine) { + $target_line = $new_lines[$ii + $len]['line']; + $context_line = $scope_engine->getScopeStart($target_line); + if ($context_line !== null) { + // The scope engine returns a line number in the file. We need + // to map that back to a display offset in the diff. + if (!$offset_map) { + $offset_map = $this->getNewLineToOffsetMap(); } + $offset = $offset_map[$context_line]; + $context_text = $new_render[$offset]; } } @@ -109,16 +117,20 @@ final class DifferentialChangesetTwoUpRenderer phutil_tag( 'td', array( - 'colspan' => 2, + 'class' => 'show-context-line n left-context', + )), + phutil_tag( + 'td', + array( 'class' => 'show-more', ), $contents), phutil_tag( - 'th', + 'td', array( - 'class' => 'show-context-line', - ), - $context_line ? (int)$context_line : null), + 'class' => 'show-context-line n', + 'data-n' => $context_line, + )), phutil_tag( 'td', array( @@ -126,7 +138,7 @@ final class DifferentialChangesetTwoUpRenderer 'class' => 'show-context', ), // TODO: [HTML] Escaping model here isn't ideal. - phutil_safe_html($context)), + phutil_safe_html($context_text)), )); $html[] = $container; @@ -150,7 +162,20 @@ final class DifferentialChangesetTwoUpRenderer } else if (empty($new_lines[$ii])) { $o_class = 'old old-full'; } else { - $o_class = 'old'; + if (isset($depth_only[$ii])) { + if ($depth_only[$ii] == '>') { + // When a line has depth-only change, we only highlight the + // left side of the diff if the depth is decreasing. When the + // depth is increasing, the ">>" marker on the right hand side + // of the diff generally provides enough visibility on its own. + + $o_class = ''; + } else { + $o_class = 'old'; + } + } else { + $o_class = 'old'; + } } $o_classes = $o_class; } @@ -188,12 +213,27 @@ final class DifferentialChangesetTwoUpRenderer } else if (empty($old_lines[$ii])) { $n_class = 'new new-full'; } else { - $n_class = 'new'; + // When a line has a depth-only change, never highlight it on + // the right side. The ">>" marker generally provides enough + // visibility on its own for indent depth increases, and the left + // side is still highlighted for indent depth decreases. + + if (isset($depth_only[$ii])) { + $n_class = ''; + } else { + $n_class = 'new'; + } } $n_classes = $n_class; - if ($new_lines[$ii]['type'] == '\\' || !isset($copy_lines[$n_num])) { - $n_copy = phutil_tag('td', array('class' => "copy {$n_class}")); + $not_copied = + // If this line only changed depth, copy markers are pointless. + (!isset($copy_lines[$n_num])) || + (isset($depth_only[$ii])) || + ($new_lines[$ii]['type'] == '\\'); + + if ($not_copied) { + $n_copy = phutil_tag('td', array('class' => 'copy')); } else { list($orig_file, $orig_line, $orig_type) = $copy_lines[$n_num]; $title = ($orig_type == '-' ? 'Moved' : 'Copied').' from '; @@ -213,8 +253,7 @@ final class DifferentialChangesetTwoUpRenderer 'msg' => $title, ), 'class' => 'copy '.$class, - ), - ''); + )); } } } @@ -275,23 +314,41 @@ final class DifferentialChangesetTwoUpRenderer } } - // NOTE: This is a unicode zero-width space, which we use as a hint when - // intercepting 'copy' events to make sure sensible text ends up on the - // clipboard. See the 'phabricator-oncopy' behavior. - $zero_space = "\xE2\x80\x8B"; + $old_number = phutil_tag( + 'td', + array( + 'id' => $o_id, + 'class' => $o_classes.' n', + 'data-n' => $o_num, + )); + + $new_number = phutil_tag( + 'td', + array( + 'id' => $n_id, + 'class' => $n_classes.' n', + 'data-n' => $n_num, + )); $html[] = phutil_tag('tr', array(), array( - phutil_tag('th', array('id' => $o_id, 'class' => $o_classes), $o_num), - phutil_tag('td', array('class' => $o_classes), $o_text), - phutil_tag('th', array('id' => $n_id, 'class' => $n_classes), $n_num), + $old_number, + phutil_tag( + 'td', + array( + 'class' => $o_classes, + 'data-copy-mode' => 'copy-l', + ), + $o_text), + $new_number, $n_copy, phutil_tag( 'td', - array('class' => $n_classes, 'colspan' => $n_colspan), array( - phutil_tag('span', array('class' => 'zwsp'), $zero_space), - $n_text, - )), + 'class' => $n_classes, + 'colspan' => $n_colspan, + 'data-copy-mode' => 'copy-r', + ), + $n_text), $n_cov, )); @@ -386,4 +443,28 @@ final class DifferentialChangesetTwoUpRenderer ->addInlineView($view); } + private function getNewLineToOffsetMap() { + if ($this->newOffsetMap === null) { + $new = $this->getNewLines(); + + $map = array(); + foreach ($new as $offset => $new_line) { + if ($new_line['line'] === null) { + continue; + } + $map[$new_line['line']] = $offset; + } + + $this->newOffsetMap = $map; + } + + return $this->newOffsetMap; + } + + protected function getTableSigils() { + return array( + 'intercept-copy', + ); + } + } diff --git a/src/applications/differential/storage/DifferentialChangeset.php b/src/applications/differential/storage/DifferentialChangeset.php index 00af84bc4e..03400d7a16 100644 --- a/src/applications/differential/storage/DifferentialChangeset.php +++ b/src/applications/differential/storage/DifferentialChangeset.php @@ -249,17 +249,6 @@ final class DifferentialChangeset return $path; } - public function getWhitespaceMatters() { - $config = PhabricatorEnv::getEnvConfig('differential.whitespace-matters'); - foreach ($config as $regexp) { - if (preg_match($regexp, $this->getFilename())) { - return true; - } - } - - return false; - } - public function attachDiff(DifferentialDiff $diff) { $this->diff = $diff; return $this; diff --git a/src/applications/differential/storage/DifferentialDiff.php b/src/applications/differential/storage/DifferentialDiff.php index e4c33dc766..a39610c54c 100644 --- a/src/applications/differential/storage/DifferentialDiff.php +++ b/src/applications/differential/storage/DifferentialDiff.php @@ -387,9 +387,10 @@ final class DifferentialDiff return array(); } - $unit = id(new HarbormasterBuildUnitMessage())->loadAllWhere( - 'buildTargetPHID IN (%Ls)', - $target_phids); + $unit = id(new HarbormasterBuildUnitMessageQuery()) + ->setViewer($viewer) + ->withBuildTargetPHIDs($target_phids) + ->execute(); $map = array(); foreach ($unit as $message) { diff --git a/src/applications/differential/storage/DifferentialRevision.php b/src/applications/differential/storage/DifferentialRevision.php index 3397f9cb03..a2a058568b 100644 --- a/src/applications/differential/storage/DifferentialRevision.php +++ b/src/applications/differential/storage/DifferentialRevision.php @@ -877,7 +877,7 @@ final class DifferentialRevision extends DifferentialDAO PhabricatorUser $viewer, array $phids) { - return id(new HarbormasterBuildQuery()) + $builds = id(new HarbormasterBuildQuery()) ->setViewer($viewer) ->withBuildablePHIDs($phids) ->withAutobuilds(false) @@ -893,6 +893,41 @@ final class DifferentialRevision extends DifferentialDAO HarbormasterBuildStatus::STATUS_DEADLOCKED, )) ->execute(); + + // Filter builds based on the "Hold Drafts" behavior of their associated + // build plans. + + $hold_drafts = HarbormasterBuildPlanBehavior::BEHAVIOR_DRAFTS; + $behavior = HarbormasterBuildPlanBehavior::getBehavior($hold_drafts); + + $key_never = HarbormasterBuildPlanBehavior::DRAFTS_NEVER; + $key_building = HarbormasterBuildPlanBehavior::DRAFTS_IF_BUILDING; + + foreach ($builds as $key => $build) { + $plan = $build->getBuildPlan(); + $hold_key = $behavior->getPlanOption($plan)->getKey(); + + $hold_never = ($hold_key === $key_never); + $hold_building = ($hold_key === $key_building); + + // If the build "Never" holds drafts from promoting, we don't care what + // the status is. + if ($hold_never) { + unset($builds[$key]); + continue; + } + + // If the build holds drafts from promoting "While Building", we only + // care about the status until it completes. + if ($hold_building) { + if ($build->isComplete()) { + unset($builds[$key]); + continue; + } + } + } + + return $builds; } diff --git a/src/applications/differential/view/DifferentialChangesetDetailView.php b/src/applications/differential/view/DifferentialChangesetDetailView.php index cb697c2e9d..211403c8bd 100644 --- a/src/applications/differential/view/DifferentialChangesetDetailView.php +++ b/src/applications/differential/view/DifferentialChangesetDetailView.php @@ -9,7 +9,6 @@ final class DifferentialChangesetDetailView extends AphrontView { private $id; private $vsChangesetID; private $renderURI; - private $whitespace; private $renderingRef; private $autoload; private $loaded; @@ -42,15 +41,6 @@ final class DifferentialChangesetDetailView extends AphrontView { return $this->renderingRef; } - public function setWhitespace($whitespace) { - $this->whitespace = $whitespace; - return $this; - } - - public function getWhitespace() { - return $this->whitespace; - } - public function setRenderURI($render_uri) { $this->renderURI = $render_uri; return $this; @@ -196,7 +186,6 @@ final class DifferentialChangesetDetailView extends AphrontView { 'left' => $left_id, 'right' => $right_id, 'renderURI' => $this->getRenderURI(), - 'whitespace' => $this->getWhitespace(), 'highlight' => null, 'renderer' => $this->getRenderer(), 'ref' => $this->getRenderingRef(), diff --git a/src/applications/differential/view/DifferentialChangesetListView.php b/src/applications/differential/view/DifferentialChangesetListView.php index 14de553e59..67568005fa 100644 --- a/src/applications/differential/view/DifferentialChangesetListView.php +++ b/src/applications/differential/view/DifferentialChangesetListView.php @@ -7,7 +7,6 @@ final class DifferentialChangesetListView extends AphrontView { private $references = array(); private $inlineURI; private $renderURI = '/differential/changeset/'; - private $whitespace; private $background; private $header; private $isStandalone; @@ -100,11 +99,6 @@ final class DifferentialChangesetListView extends AphrontView { return $this; } - public function setWhitespace($whitespace) { - $this->whitespace = $whitespace; - return $this; - } - public function setVsMap(array $vs_map) { $this->vsMap = $vs_map; return $this; @@ -180,7 +174,6 @@ final class DifferentialChangesetListView extends AphrontView { $detail->setRenderingRef($ref); $detail->setRenderURI($this->renderURI); - $detail->setWhitespace($this->whitespace); $detail->setRenderer($renderer); if ($this->getParser()) { @@ -352,13 +345,12 @@ final class DifferentialChangesetListView extends AphrontView { $meta = array(); $qparams = array( - 'ref' => $ref, - 'whitespace' => $this->whitespace, + 'ref' => $ref, ); if ($this->standaloneURI) { $uri = new PhutilURI($this->standaloneURI); - $uri->setQueryParams($uri->getQueryParams() + $qparams); + $uri = $this->appendDefaultQueryParams($uri, $qparams); $meta['standaloneURI'] = (string)$uri; } @@ -381,7 +373,7 @@ final class DifferentialChangesetListView extends AphrontView { if ($this->leftRawFileURI) { if ($change != DifferentialChangeType::TYPE_ADD) { $uri = new PhutilURI($this->leftRawFileURI); - $uri->setQueryParams($uri->getQueryParams() + $qparams); + $uri = $this->appendDefaultQueryParams($uri, $qparams); $meta['leftURI'] = (string)$uri; } } @@ -390,7 +382,7 @@ final class DifferentialChangesetListView extends AphrontView { if ($change != DifferentialChangeType::TYPE_DELETE && $change != DifferentialChangeType::TYPE_MULTICOPY) { $uri = new PhutilURI($this->rightRawFileURI); - $uri->setQueryParams($uri->getQueryParams() + $qparams); + $uri = $this->appendDefaultQueryParams($uri, $qparams); $meta['rightURI'] = (string)$uri; } } @@ -421,4 +413,23 @@ final class DifferentialChangesetListView extends AphrontView { } + private function appendDefaultQueryParams(PhutilURI $uri, array $params) { + // Add these default query parameters to the query string if they do not + // already exist. + + $have = array(); + foreach ($uri->getQueryParamsAsPairList() as $pair) { + list($key, $value) = $pair; + $have[$key] = true; + } + + foreach ($params as $key => $value) { + if (!isset($have[$key])) { + $uri->appendQueryParam($key, $value); + } + } + + return $uri; + } + } diff --git a/src/applications/differential/view/DifferentialRevisionUpdateHistoryView.php b/src/applications/differential/view/DifferentialRevisionUpdateHistoryView.php index a77b320d82..07ca983bc0 100644 --- a/src/applications/differential/view/DifferentialRevisionUpdateHistoryView.php +++ b/src/applications/differential/view/DifferentialRevisionUpdateHistoryView.php @@ -5,7 +5,6 @@ final class DifferentialRevisionUpdateHistoryView extends AphrontView { private $diffs = array(); private $selectedVersusDiffID; private $selectedDiffID; - private $selectedWhitespace; private $commitsForLinks = array(); private $unitStatus = array(); @@ -25,11 +24,6 @@ final class DifferentialRevisionUpdateHistoryView extends AphrontView { return $this; } - public function setSelectedWhitespace($whitespace) { - $this->selectedWhitespace = $whitespace; - return $this; - } - public function setCommitsForLinks(array $commits) { assert_instances_of($commits, 'PhabricatorRepositoryCommit'); $this->commitsForLinks = $commits; @@ -224,28 +218,6 @@ final class DifferentialRevisionUpdateHistoryView extends AphrontView { 'radios' => $radios, )); - $options = array( - DifferentialChangesetParser::WHITESPACE_IGNORE_ALL => pht('Ignore All'), - DifferentialChangesetParser::WHITESPACE_IGNORE_MOST => pht('Ignore Most'), - DifferentialChangesetParser::WHITESPACE_IGNORE_TRAILING => - pht('Ignore Trailing'), - DifferentialChangesetParser::WHITESPACE_SHOW_ALL => pht('Show All'), - ); - - foreach ($options as $value => $label) { - $options[$value] = phutil_tag( - 'option', - array( - 'value' => $value, - 'selected' => ($value == $this->selectedWhitespace) - ? 'selected' - : null, - ), - $label); - } - $select = phutil_tag('select', array('name' => 'whitespace'), $options); - - $table = id(new AphrontTableView($rows)); $table->setHeaders( array( @@ -291,13 +263,6 @@ final class DifferentialRevisionUpdateHistoryView extends AphrontView { 'class' => 'differential-update-history-footer', ), array( - phutil_tag( - 'label', - array(), - array( - pht('Whitespace Changes:'), - $select, - )), phutil_tag( 'button', array(), diff --git a/src/applications/differential/xaction/DifferentialRevisionWrongBuildsTransaction.php b/src/applications/differential/xaction/DifferentialRevisionWrongBuildsTransaction.php new file mode 100644 index 0000000000..260813b75b --- /dev/null +++ b/src/applications/differential/xaction/DifferentialRevisionWrongBuildsTransaction.php @@ -0,0 +1,37 @@ +addIcon('fa-code', $name); - if ($package->getAuditingEnabled()) { - $item->addIcon('fa-check', pht('Auditing Enabled')); - } else { - $item->addIcon('fa-ban', pht('No Auditing')); - } + $rule = $package->newAuditingRule(); + $item->addIcon($rule->getIconIcon(), $rule->getDisplayName()); if ($package->isArchived()) { $item->setDisabled(true); @@ -712,10 +709,17 @@ final class DiffusionBrowseController extends DiffusionController { 'path' => $path, )); - $before_uri->setQueryParams($request->getRequestURI()->getQueryParams()); - $before_uri = $before_uri->alter('before', null); - $before_uri = $before_uri->alter('renamed', $renamed); - $before_uri = $before_uri->alter('follow', $follow); + if ($renamed === null) { + $before_uri->removeQueryParam('renamed'); + } else { + $before_uri->replaceQueryParam('renamed', $renamed); + } + + if ($follow === null) { + $before_uri->removeQueryParam('follow'); + } else { + $before_uri->replaceQueryParam('follow', $follow); + } return id(new AphrontRedirectResponse())->setURI($before_uri); } diff --git a/src/applications/diffusion/controller/DiffusionChangeController.php b/src/applications/diffusion/controller/DiffusionChangeController.php index 90258134c4..0120c978d0 100644 --- a/src/applications/diffusion/controller/DiffusionChangeController.php +++ b/src/applications/diffusion/controller/DiffusionChangeController.php @@ -64,9 +64,6 @@ final class DiffusionChangeController extends DiffusionController { $changeset_view->setRawFileURIs($left_uri, $right_uri); $changeset_view->setRenderURI($repository->getPathURI('diff/')); - - $changeset_view->setWhitespace( - DifferentialChangesetParser::WHITESPACE_SHOW_ALL); $changeset_view->setUser($viewer); $changeset_view->setHeader($changeset_header); diff --git a/src/applications/diffusion/controller/DiffusionController.php b/src/applications/diffusion/controller/DiffusionController.php index a220ac05e0..5f4c304ebc 100644 --- a/src/applications/diffusion/controller/DiffusionController.php +++ b/src/applications/diffusion/controller/DiffusionController.php @@ -512,8 +512,7 @@ abstract class DiffusionController extends PhabricatorController { ->setIcon('fa-code') ->setHref($drequest->generateURI( array( - 'action' => 'branch', - 'path' => '/', + 'action' => 'browse', ))) ->setSelected($key == 'code')); diff --git a/src/applications/diffusion/controller/DiffusionDiffController.php b/src/applications/diffusion/controller/DiffusionDiffController.php index 86409c6faa..a0111d001f 100644 --- a/src/applications/diffusion/controller/DiffusionDiffController.php +++ b/src/applications/diffusion/controller/DiffusionDiffController.php @@ -88,9 +88,6 @@ final class DiffusionDiffController extends DiffusionController { ($viewer->getPHID() == $commit->getAuthorPHID())); $parser->setObjectOwnerPHID($commit->getAuthorPHID()); - $parser->setWhitespaceMode( - DifferentialChangesetParser::WHITESPACE_SHOW_ALL); - $inlines = PhabricatorAuditInlineComment::loadDraftAndPublishedComments( $viewer, $commit->getPHID(), diff --git a/src/applications/diffusion/controller/DiffusionServeController.php b/src/applications/diffusion/controller/DiffusionServeController.php index cb4ad0ba95..aea901f100 100644 --- a/src/applications/diffusion/controller/DiffusionServeController.php +++ b/src/applications/diffusion/controller/DiffusionServeController.php @@ -192,7 +192,10 @@ final class DiffusionServeController extends DiffusionController { // Try Git LFS auth first since we can usually reject it without doing // any queries, since the username won't match the one we expect or the // request won't be LFS. - $viewer = $this->authenticateGitLFSUser($username, $password); + $viewer = $this->authenticateGitLFSUser( + $username, + $password, + $identifier); // If that failed, try normal auth. Note that we can use normal auth on // LFS requests, so this isn't strictly an alternative to LFS auth. @@ -655,7 +658,8 @@ final class DiffusionServeController extends DiffusionController { private function authenticateGitLFSUser( $username, - PhutilOpaqueEnvelope $password) { + PhutilOpaqueEnvelope $password, + $identifier) { // Never accept these credentials for requests which aren't LFS requests. if (!$this->getIsGitLFSRequest()) { @@ -668,11 +672,31 @@ final class DiffusionServeController extends DiffusionController { return null; } + // See PHI1123. We need to be able to constrain the token query with + // "withTokenResources(...)" to take advantage of the key on the table. + // In this case, the repository PHID is the "resource" we're after. + + // In normal workflows, we figure out the viewer first, then use the + // viewer to load the repository, but that won't work here. Load the + // repository as the omnipotent viewer, then use the repository PHID to + // look for a token. + + $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); + + $repository = id(new PhabricatorRepositoryQuery()) + ->setViewer($omnipotent_viewer) + ->withIdentifiers(array($identifier)) + ->executeOne(); + if (!$repository) { + return null; + } + $lfs_pass = $password->openEnvelope(); $lfs_hash = PhabricatorHash::weakDigest($lfs_pass); $token = id(new PhabricatorAuthTemporaryTokenQuery()) - ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->setViewer($omnipotent_viewer) + ->withTokenResources(array($repository->getPHID())) ->withTokenTypes(array(DiffusionGitLFSTemporaryTokenType::TOKENTYPE)) ->withTokenCodes(array($lfs_hash)) ->withExpired(false) @@ -682,7 +706,7 @@ final class DiffusionServeController extends DiffusionController { } $user = id(new PhabricatorPeopleQuery()) - ->setViewer(PhabricatorUser::getOmnipotentUser()) + ->setViewer($omnipotent_viewer) ->withPHIDs(array($token->getUserPHID())) ->executeOne(); diff --git a/src/applications/diffusion/management/DiffusionRepositoryBasicsManagementPanel.php b/src/applications/diffusion/management/DiffusionRepositoryBasicsManagementPanel.php index bcf3ad6d74..d585c5774d 100644 --- a/src/applications/diffusion/management/DiffusionRepositoryBasicsManagementPanel.php +++ b/src/applications/diffusion/management/DiffusionRepositoryBasicsManagementPanel.php @@ -444,13 +444,15 @@ final class DiffusionRepositoryBasicsManagementPanel id(new PHUIStatusItemView()) ->setIcon(PHUIStatusItemView::ICON_WARNING, 'red') ->setTarget( - pht('Missing Binary %s', phutil_tag('tt', array(), $binary))) - ->setNote(pht( - 'Unable to find this binary in `%s`. '. - 'You need to configure %s and include %s.', - 'environment.append-paths', - $this->getEnvConfigLink(), - $path))); + pht('Commit Hooks: %s', phutil_tag('tt', array(), $binary))) + ->setNote( + pht( + 'The directory containing the "svnlook" binary is not '. + 'listed in "environment.append-paths", so commit hooks '. + '(which execute with an empty "PATH") will not be able to '. + 'find "svnlook". Add `%s` to %s.', + $path, + $this->getEnvConfigLink()))); } } } diff --git a/src/applications/diffusion/query/DiffusionCommitQuery.php b/src/applications/diffusion/query/DiffusionCommitQuery.php index 05072e07c7..bc555a4fb4 100644 --- a/src/applications/diffusion/query/DiffusionCommitQuery.php +++ b/src/applications/diffusion/query/DiffusionCommitQuery.php @@ -202,6 +202,7 @@ final class DiffusionCommitQuery $table = $this->newResultObject(); $conn = $table->establishConnection('r'); + $empty_exception = null; $subqueries = array(); if ($this->responsiblePHIDs) { $base_authors = $this->authorPHIDs; @@ -222,21 +223,33 @@ final class DiffusionCommitQuery $this->authorPHIDs = $all_authors; $this->auditorPHIDs = $base_auditors; - $subqueries[] = $this->buildStandardPageQuery( - $conn, - $table->getTableName()); + try { + $subqueries[] = $this->buildStandardPageQuery( + $conn, + $table->getTableName()); + } catch (PhabricatorEmptyQueryException $ex) { + $empty_exception = $ex; + } $this->authorPHIDs = $base_authors; $this->auditorPHIDs = $all_auditors; - $subqueries[] = $this->buildStandardPageQuery( - $conn, - $table->getTableName()); + try { + $subqueries[] = $this->buildStandardPageQuery( + $conn, + $table->getTableName()); + } catch (PhabricatorEmptyQueryException $ex) { + $empty_exception = $ex; + } } else { $subqueries[] = $this->buildStandardPageQuery( $conn, $table->getTableName()); } + if (!$subqueries) { + throw $empty_exception; + } + if (count($subqueries) > 1) { $unions = null; foreach ($subqueries as $subquery) { @@ -642,10 +655,19 @@ final class DiffusionCommitQuery } if ($this->authorPHIDs !== null) { + $author_phids = $this->authorPHIDs; + if ($author_phids) { + $author_phids = $this->selectPossibleAuthors($author_phids); + if (!$author_phids) { + throw new PhabricatorEmptyQueryException( + pht('Author PHIDs contain no possible authors.')); + } + } + $where[] = qsprintf( $conn, 'commit.authorPHID IN (%Ls)', - $this->authorPHIDs); + $author_phids); } if ($this->epochMin !== null) { @@ -902,11 +924,10 @@ final class DiffusionCommitQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $commit = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $commit->getID(), - 'epoch' => $commit->getEpoch(), + 'id' => (int)$object->getID(), + 'epoch' => (int)$object->getEpoch(), ); } @@ -934,5 +955,20 @@ final class DiffusionCommitQuery ) + $parent; } + private function selectPossibleAuthors(array $phids) { + // See PHI1057. Select PHIDs which might possibly be commit authors from + // a larger list of PHIDs. This primarily filters out packages and projects + // from "Responsible Users: ..." queries. Our goal in performing this + // filtering is to improve the performance of the final query. + + foreach ($phids as $key => $phid) { + if (phid_get_type($phid) !== PhabricatorPeopleUserPHIDType::TYPECONST) { + unset($phids[$key]); + } + } + + return $phids; + } + } diff --git a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php index b2d1d25f44..57dc83953d 100644 --- a/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php +++ b/src/applications/diffusion/ssh/DiffusionSSHWorkflow.php @@ -127,9 +127,9 @@ abstract class DiffusionSSHWorkflow extends PhabricatorSSHWorkflow { // This is suppressing "added
to the list of known hosts" // messages, which are confusing and irrelevant when they arise from // proxied requests. It might also be suppressing lots of useful errors, - // of course. Ideally, we would enforce host keys eventually. + // of course. Ideally, we would enforce host keys eventually. See T13121. $options[] = '-o'; - $options[] = 'LogLevel=quiet'; + $options[] = 'LogLevel=ERROR'; // NOTE: We prefix the command with "@username", which the far end of the // connection will parse in order to act as the specified user. This diff --git a/src/applications/diffusion/view/DiffusionView.php b/src/applications/diffusion/view/DiffusionView.php index eb7f3eb72f..123d22af5e 100644 --- a/src/applications/diffusion/view/DiffusionView.php +++ b/src/applications/diffusion/view/DiffusionView.php @@ -81,12 +81,12 @@ abstract class DiffusionView extends AphrontView { } if (isset($details['external'])) { - $href = id(new PhutilURI('/diffusion/external/')) - ->setQueryParams( - array( - 'uri' => idx($details, 'external'), - 'id' => idx($details, 'hash'), - )); + $params = array( + 'uri' => idx($details, 'external'), + 'id' => idx($details, 'hash'), + ); + + $href = new PhutilURI('/diffusion/external/', $params); $tip = pht('Browse External'); } else { $href = $this->getDiffusionRequest()->generateURI( diff --git a/src/applications/diviner/markup/DivinerSymbolRemarkupRule.php b/src/applications/diviner/markup/DivinerSymbolRemarkupRule.php index 7fdb4cb37e..db68927625 100644 --- a/src/applications/diviner/markup/DivinerSymbolRemarkupRule.php +++ b/src/applications/diviner/markup/DivinerSymbolRemarkupRule.php @@ -111,15 +111,15 @@ final class DivinerSymbolRemarkupRule extends PhutilRemarkupRule { // Here, we're generating comment text or something like that. Just // link to Diviner and let it sort things out. - $href = id(new PhutilURI('/diviner/find/')) - ->setQueryParams( - array( - 'book' => $ref->getBook(), - 'name' => $ref->getName(), - 'type' => $ref->getType(), - 'context' => $ref->getContext(), - 'jump' => true, - )); + $params = array( + 'book' => $ref->getBook(), + 'name' => $ref->getName(), + 'type' => $ref->getType(), + 'context' => $ref->getContext(), + 'jump' => true, + ); + + $href = new PhutilURI('/diviner/find/', $params); } // TODO: This probably is not the best place to do this. Move it somewhere diff --git a/src/applications/diviner/query/DivinerBookQuery.php b/src/applications/diviner/query/DivinerBookQuery.php index d540d971b0..2d6527ec96 100644 --- a/src/applications/diviner/query/DivinerBookQuery.php +++ b/src/applications/diviner/query/DivinerBookQuery.php @@ -181,11 +181,10 @@ final class DivinerBookQuery extends PhabricatorCursorPagedPolicyAwareQuery { ); } - protected function getPagingValueMap($cursor, array $keys) { - $book = $this->loadCursorObject($cursor); - + protected function newPagingMapFromPartialObject($object) { return array( - 'name' => $book->getName(), + 'id' => (int)$object->getID(), + 'name' => $object->getName(), ); } diff --git a/src/applications/diviner/storage/DivinerLiveBookTransaction.php b/src/applications/diviner/storage/DivinerLiveBookTransaction.php index ae461e751a..f8eb81d1f3 100644 --- a/src/applications/diviner/storage/DivinerLiveBookTransaction.php +++ b/src/applications/diviner/storage/DivinerLiveBookTransaction.php @@ -11,8 +11,4 @@ final class DivinerLiveBookTransaction return DivinerBookPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - } diff --git a/src/applications/drydock/interface/command/DrydockSSHCommandInterface.php b/src/applications/drydock/interface/command/DrydockSSHCommandInterface.php index 1aab14b57b..b1eebd92a1 100644 --- a/src/applications/drydock/interface/command/DrydockSSHCommandInterface.php +++ b/src/applications/drydock/interface/command/DrydockSSHCommandInterface.php @@ -30,8 +30,11 @@ final class DrydockSSHCommandInterface extends DrydockCommandInterface { $full_command = call_user_func_array('csprintf', $argv); $flags = array(); + + // See T13121. Attempt to suppress the "Permanently added X to list of + // known hosts" message without suppressing anything important. $flags[] = '-o'; - $flags[] = 'LogLevel=quiet'; + $flags[] = 'LogLevel=ERROR'; $flags[] = '-o'; $flags[] = 'StrictHostKeyChecking=no'; diff --git a/src/applications/drydock/operation/DrydockLandRepositoryOperation.php b/src/applications/drydock/operation/DrydockLandRepositoryOperation.php index 1ccc82eb7b..acb48f6f0b 100644 --- a/src/applications/drydock/operation/DrydockLandRepositoryOperation.php +++ b/src/applications/drydock/operation/DrydockLandRepositoryOperation.php @@ -401,7 +401,7 @@ final class DrydockLandRepositoryOperation 'body' => pht( 'When this diff was generated, the server was running an older '. 'version of Phabricator which did not support staging areas for '. - 'this version control system, so the chagne was not pushed to '. + 'this version control system, so the change was not pushed to '. 'staging. Changes must be pushed to staging before they can be '. 'landed from the web.'), ); diff --git a/src/applications/fact/controller/PhabricatorFactHomeController.php b/src/applications/fact/controller/PhabricatorFactHomeController.php index 82f6a0905b..56ffe3930b 100644 --- a/src/applications/fact/controller/PhabricatorFactHomeController.php +++ b/src/applications/fact/controller/PhabricatorFactHomeController.php @@ -11,7 +11,7 @@ final class PhabricatorFactHomeController extends PhabricatorFactController { if ($request->isFormPost()) { $uri = new PhutilURI('/fact/chart/'); - $uri->setQueryParam('y1', $request->getStr('y1')); + $uri->replaceQueryParam('y1', $request->getStr('y1')); return id(new AphrontRedirectResponse())->setURI($uri); } diff --git a/src/applications/favorites/engineextension/PhabricatorFavoritesMainMenuBarExtension.php b/src/applications/favorites/engineextension/PhabricatorFavoritesMainMenuBarExtension.php index 7b5d6d0720..adc272a125 100644 --- a/src/applications/favorites/engineextension/PhabricatorFavoritesMainMenuBarExtension.php +++ b/src/applications/favorites/engineextension/PhabricatorFavoritesMainMenuBarExtension.php @@ -20,7 +20,7 @@ final class PhabricatorFavoritesMainMenuBarExtension $dropdown = $this->newDropdown($viewer); if (!$dropdown) { - return null; + return array(); } $favorites_menu = id(new PHUIButtonView()) @@ -54,7 +54,13 @@ final class PhabricatorFavoritesMainMenuBarExtension ->setProfileObject($favorites) ->setCustomPHID($viewer->getPHID()); - $filter_view = $menu_engine->buildNavigation(); + $controller = $this->getController(); + if ($controller) { + $menu_engine->setController($controller); + } + + $filter_view = $menu_engine->newProfileMenuItemViewList() + ->newNavigationView(); $menu_view = $filter_view->getMenu(); $item_views = $menu_view->getItems(); diff --git a/src/applications/feed/config/PhabricatorFeedConfigOptions.php b/src/applications/feed/config/PhabricatorFeedConfigOptions.php index 4b6612f931..29c5a9549b 100644 --- a/src/applications/feed/config/PhabricatorFeedConfigOptions.php +++ b/src/applications/feed/config/PhabricatorFeedConfigOptions.php @@ -20,22 +20,22 @@ final class PhabricatorFeedConfigOptions } public function getOptions() { + $hooks_help = $this->deformat(pht(<<newOption('feed.http-hooks', 'list', array()) ->setLocked(true) - ->setSummary(pht('POST notifications of feed events.')) - ->setDescription( - pht( - "If you set this to a list of HTTP URIs, when a feed story is ". - "published a task will be created for each URI that posts the ". - "story data to the URI. Daemons automagically retry failures 100 ". - "times, waiting `\$fail_count * 60s` between each subsequent ". - "failure. Be sure to keep the daemon console (`%s`) open ". - "while developing and testing your end points. You may need to". - "restart your daemons to start sending HTTP requests.\n\n". - "NOTE: URIs are not validated, the URI must return HTTP status ". - "200 within 30 seconds, and no permission checks are performed.", - '/daemon/')), + ->setSummary(pht('Deprecated.')) + ->setDescription($hooks_help), ); } diff --git a/src/applications/feed/query/PhabricatorFeedQuery.php b/src/applications/feed/query/PhabricatorFeedQuery.php index a35f14da57..8302af20c1 100644 --- a/src/applications/feed/query/PhabricatorFeedQuery.php +++ b/src/applications/feed/query/PhabricatorFeedQuery.php @@ -147,17 +147,21 @@ final class PhabricatorFeedQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - return array( - 'key' => $cursor, - ); + protected function applyExternalCursorConstraintsToQuery( + PhabricatorCursorPagedPolicyAwareQuery $subquery, + $cursor) { + $subquery->withChronologicalKeys(array($cursor)); } - protected function getResultCursor($item) { - if ($item instanceof PhabricatorFeedStory) { - return $item->getChronologicalKey(); - } - return $item['chronologicalKey']; + protected function newExternalCursorStringForResult($object) { + return $object->getChronologicalKey(); + } + + protected function newPagingMapFromPartialObject($object) { + // This query is unusual, and the "object" is a raw result row. + return array( + 'key' => $object['chronologicalKey'], + ); } protected function getPrimaryTableAlias() { diff --git a/src/applications/feed/worker/FeedPublisherHTTPWorker.php b/src/applications/feed/worker/FeedPublisherHTTPWorker.php index 4742e52a29..27a869ddef 100644 --- a/src/applications/feed/worker/FeedPublisherHTTPWorker.php +++ b/src/applications/feed/worker/FeedPublisherHTTPWorker.php @@ -26,6 +26,11 @@ final class FeedPublisherHTTPWorker extends FeedPushWorker { 'epoch' => $data->getEpoch(), ); + // NOTE: We're explicitly using "http_build_query()" here because the + // "storyData" parameter may be a nested object with arbitrary nested + // sub-objects. + $post_data = http_build_query($post_data, '', '&'); + id(new HTTPSFuture($uri, $post_data)) ->setMethod('POST') ->setTimeout(30) diff --git a/src/applications/files/controller/PhabricatorFileDataController.php b/src/applications/files/controller/PhabricatorFileDataController.php index 725bfc9810..1da68d180b 100644 --- a/src/applications/files/controller/PhabricatorFileDataController.php +++ b/src/applications/files/controller/PhabricatorFileDataController.php @@ -139,7 +139,7 @@ final class PhabricatorFileDataController extends PhabricatorFileController { $request_uri = id(clone $request->getAbsoluteRequestURI()) ->setPath(null) ->setFragment(null) - ->setQueryParams(array()); + ->removeAllQueryParams(); $response->addContentSecurityPolicyURI( 'object-src', diff --git a/src/applications/files/controller/PhabricatorFileLightboxController.php b/src/applications/files/controller/PhabricatorFileLightboxController.php index 1f679d621b..59a826dd42 100644 --- a/src/applications/files/controller/PhabricatorFileLightboxController.php +++ b/src/applications/files/controller/PhabricatorFileLightboxController.php @@ -70,7 +70,7 @@ final class PhabricatorFileLightboxController if (!$viewer->isLoggedIn()) { $login_href = id(new PhutilURI('/auth/start/')) - ->setQueryParam('next', '/'.$file->getMonogram()); + ->replaceQueryParam('next', '/'.$file->getMonogram()); return id(new PHUIFormLayoutView()) ->addClass('phui-comment-panel-empty') ->appendChild( diff --git a/src/applications/files/controller/PhabricatorFileTransformListController.php b/src/applications/files/controller/PhabricatorFileTransformListController.php index ab5322fc1a..7b5bc9299d 100644 --- a/src/applications/files/controller/PhabricatorFileTransformListController.php +++ b/src/applications/files/controller/PhabricatorFileTransformListController.php @@ -61,7 +61,7 @@ final class PhabricatorFileTransformListController $view_href = $file->getURIForTransform($xform); $view_href = new PhutilURI($view_href); - $view_href->setQueryParam('regenerate', 'true'); + $view_href->replaceQueryParam('regenerate', 'true'); $view_text = pht('Regenerate'); diff --git a/src/applications/files/markup/PhabricatorImageRemarkupRule.php b/src/applications/files/markup/PhabricatorImageRemarkupRule.php index 5d1979ed3c..57ad75bbc5 100644 --- a/src/applications/files/markup/PhabricatorImageRemarkupRule.php +++ b/src/applications/files/markup/PhabricatorImageRemarkupRule.php @@ -149,7 +149,7 @@ final class PhabricatorImageRemarkupRule extends PhutilRemarkupRule { )); } else { $src_uri = id(new PhutilURI('/file/imageproxy/')) - ->setQueryParam('uri', $uri); + ->replaceQueryParam('uri', $uri); $img = id(new PHUIRemarkupImageView()) ->setURI($src_uri) diff --git a/src/applications/fund/storage/FundBacker.php b/src/applications/fund/storage/FundBacker.php index 87ab342e2a..ebdf39ae17 100644 --- a/src/applications/fund/storage/FundBacker.php +++ b/src/applications/fund/storage/FundBacker.php @@ -76,6 +76,7 @@ final class FundBacker extends FundDAO public function getCapabilities() { return array( PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, ); } @@ -91,6 +92,8 @@ final class FundBacker extends FundDAO return $initiative->getPolicy($capability); } return PhabricatorPolicies::POLICY_NOONE; + case PhabricatorPolicyCapability::CAN_EDIT: + return PhabricatorPolicies::POLICY_NOONE; } } diff --git a/src/applications/fund/storage/FundBackerTransaction.php b/src/applications/fund/storage/FundBackerTransaction.php index c24e769eb6..c08958a29a 100644 --- a/src/applications/fund/storage/FundBackerTransaction.php +++ b/src/applications/fund/storage/FundBackerTransaction.php @@ -11,10 +11,6 @@ final class FundBackerTransaction return FundBackerPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'FundBackerTransactionType'; } diff --git a/src/applications/harbormaster/application/PhabricatorHarbormasterApplication.php b/src/applications/harbormaster/application/PhabricatorHarbormasterApplication.php index 80be90b375..4b369e821e 100644 --- a/src/applications/harbormaster/application/PhabricatorHarbormasterApplication.php +++ b/src/applications/harbormaster/application/PhabricatorHarbormasterApplication.php @@ -83,6 +83,8 @@ final class PhabricatorHarbormasterApplication extends PhabricatorApplication { => 'HarbormasterPlanEditController', 'order/(?:(?P\d+)/)?' => 'HarbormasterPlanOrderController', 'disable/(?P\d+)/' => 'HarbormasterPlanDisableController', + 'behavior/(?P\d+)/(?P[^/]+)/' => + 'HarbormasterPlanBehaviorController', 'run/(?P\d+)/' => 'HarbormasterPlanRunController', '(?P\d+)/' => 'HarbormasterPlanViewController', ), diff --git a/src/applications/harbormaster/codex/HarbormasterBuildPlanPolicyCodex.php b/src/applications/harbormaster/codex/HarbormasterBuildPlanPolicyCodex.php new file mode 100644 index 0000000000..a17f2fb293 --- /dev/null +++ b/src/applications/harbormaster/codex/HarbormasterBuildPlanPolicyCodex.php @@ -0,0 +1,38 @@ +getObject(); + $run_with_view = $object->canRunWithoutEditCapability(); + + $rules = array(); + + $rules[] = $this->newRule() + ->setCapabilities( + array( + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->setIsActive(!$run_with_view) + ->setDescription( + pht( + 'You must have edit permission on this build plan to pause, '. + 'abort, resume, or restart it.')); + + $rules[] = $this->newRule() + ->setCapabilities( + array( + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->setIsActive(!$run_with_view) + ->setDescription( + pht( + 'You must have edit permission on this build plan to run it '. + 'manually.')); + + return $rules; + } + + +} diff --git a/src/applications/harbormaster/conduit/HarbormasterBuildPlanEditAPIMethod.php b/src/applications/harbormaster/conduit/HarbormasterBuildPlanEditAPIMethod.php new file mode 100644 index 0000000000..5509cf189e --- /dev/null +++ b/src/applications/harbormaster/conduit/HarbormasterBuildPlanEditAPIMethod.php @@ -0,0 +1,20 @@ +key === self::STATUS_PASSED); } + public function isFailed() { + return ($this->key === self::STATUS_FAILED); + } + /** * Get a human readable name for a build status constant. diff --git a/src/applications/harbormaster/controller/HarbormasterBuildActionController.php b/src/applications/harbormaster/controller/HarbormasterBuildActionController.php index 843ffd4702..6a4a2b1fee 100644 --- a/src/applications/harbormaster/controller/HarbormasterBuildActionController.php +++ b/src/applications/harbormaster/controller/HarbormasterBuildActionController.php @@ -65,13 +65,12 @@ final class HarbormasterBuildActionController 'restart build?'); $submit = pht('Restart Build'); } else { - $title = pht('Unable to Restart Build'); - if ($build->isRestarting()) { - $body = pht( - 'This build is already restarting. You can not reissue a '. - 'restart command to a restarting build.'); - } else { - $body = pht('You can not restart this build.'); + try { + $build->assertCanRestartBuild(); + throw new Exception(pht('Expected to be unable to restart build.')); + } catch (HarbormasterRestartException $ex) { + $title = $ex->getTitle(); + $body = $ex->getBody(); } } break; @@ -135,8 +134,7 @@ final class HarbormasterBuildActionController break; } - $dialog = id(new AphrontDialogView()) - ->setUser($viewer) + $dialog = $this->newDialog() ->setTitle($title) ->appendChild($body) ->addCancelButton($return_uri); @@ -145,7 +143,7 @@ final class HarbormasterBuildActionController $dialog->addSubmitButton($submit); } - return id(new AphrontDialogResponse())->setDialog($dialog); + return $dialog; } } diff --git a/src/applications/harbormaster/controller/HarbormasterBuildableViewController.php b/src/applications/harbormaster/controller/HarbormasterBuildableViewController.php index 1e79ad2b46..40f6587116 100644 --- a/src/applications/harbormaster/controller/HarbormasterBuildableViewController.php +++ b/src/applications/harbormaster/controller/HarbormasterBuildableViewController.php @@ -312,9 +312,10 @@ final class HarbormasterBuildableViewController 'buildTargetPHID IN (%Ls)', $target_phids); - $unit_data = id(new HarbormasterBuildUnitMessage())->loadAllWhere( - 'buildTargetPHID IN (%Ls)', - $target_phids); + $unit_data = id(new HarbormasterBuildUnitMessageQuery()) + ->setViewer($viewer) + ->withBuildTargetPHIDs($target_phids) + ->execute(); if ($lint_data) { $lint_table = id(new HarbormasterLintPropertyView()) diff --git a/src/applications/harbormaster/controller/HarbormasterPlanBehaviorController.php b/src/applications/harbormaster/controller/HarbormasterPlanBehaviorController.php new file mode 100644 index 0000000000..8f1fece691 --- /dev/null +++ b/src/applications/harbormaster/controller/HarbormasterPlanBehaviorController.php @@ -0,0 +1,92 @@ +getViewer(); + + $plan = id(new HarbormasterBuildPlanQuery()) + ->setViewer($viewer) + ->withIDs(array($request->getURIData('id'))) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$plan) { + return new Aphront404Response(); + } + + $behavior_key = $request->getURIData('behaviorKey'); + $metadata_key = HarbormasterBuildPlanBehavior::getTransactionMetadataKey(); + + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + $behavior = idx($behaviors, $behavior_key); + if (!$behavior) { + return new Aphront404Response(); + } + + $plan_uri = $plan->getURI(); + + $v_option = $behavior->getPlanOption($plan)->getKey(); + if ($request->isFormPost()) { + $v_option = $request->getStr('option'); + + $xactions = array(); + + $xactions[] = id(new HarbormasterBuildPlanTransaction()) + ->setTransactionType( + HarbormasterBuildPlanBehaviorTransaction::TRANSACTIONTYPE) + ->setMetadataValue($metadata_key, $behavior_key) + ->setNewValue($v_option); + + $editor = id(new HarbormasterBuildPlanEditor()) + ->setActor($viewer) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true) + ->setContentSourceFromRequest($request); + + $editor->applyTransactions($plan, $xactions); + + return id(new AphrontRedirectResponse())->setURI($plan_uri); + } + + $select_control = id(new AphrontFormRadioButtonControl()) + ->setName('option') + ->setValue($v_option) + ->setLabel(pht('Option')); + + foreach ($behavior->getOptions() as $option) { + $icon = id(new PHUIIconView()) + ->setIcon($option->getIcon()); + + $select_control->addButton( + $option->getKey(), + array( + $icon, + ' ', + $option->getName(), + ), + $option->getDescription()); + } + + $form = id(new AphrontFormView()) + ->setViewer($viewer) + ->appendInstructions( + pht( + 'Choose a build plan behavior for "%s".', + phutil_tag('strong', array(), $behavior->getName()))) + ->appendRemarkupInstructions($behavior->getEditInstructions()) + ->appendControl($select_control); + + return $this->newDialog() + ->setTitle(pht('Edit Behavior: %s', $behavior->getName())) + ->appendForm($form) + ->setWidth(AphrontDialogView::WIDTH_FORM) + ->addSubmitButton(pht('Save Changes')) + ->addCancelButton($plan_uri); + } + +} diff --git a/src/applications/harbormaster/controller/HarbormasterPlanDisableController.php b/src/applications/harbormaster/controller/HarbormasterPlanDisableController.php index ccf6b8986f..65a993396d 100644 --- a/src/applications/harbormaster/controller/HarbormasterPlanDisableController.php +++ b/src/applications/harbormaster/controller/HarbormasterPlanDisableController.php @@ -19,11 +19,11 @@ final class HarbormasterPlanDisableController return new Aphront404Response(); } - $plan_uri = $this->getApplicationURI('plan/'.$plan->getID().'/'); + $plan_uri = $plan->getURI(); if ($request->isFormPost()) { - $type_status = HarbormasterBuildPlanTransaction::TYPE_STATUS; + $type_status = HarbormasterBuildPlanStatusTransaction::TRANSACTIONTYPE; $v_status = $plan->isDisabled() ? HarbormasterBuildPlan::STATUS_ACTIVE diff --git a/src/applications/harbormaster/controller/HarbormasterPlanRunController.php b/src/applications/harbormaster/controller/HarbormasterPlanRunController.php index fd227ee554..5d80d421aa 100644 --- a/src/applications/harbormaster/controller/HarbormasterPlanRunController.php +++ b/src/applications/harbormaster/controller/HarbormasterPlanRunController.php @@ -9,16 +9,13 @@ final class HarbormasterPlanRunController extends HarbormasterPlanController { $plan = id(new HarbormasterBuildPlanQuery()) ->setViewer($viewer) ->withIDs(array($plan_id)) - ->requireCapabilities( - array( - PhabricatorPolicyCapability::CAN_VIEW, - PhabricatorPolicyCapability::CAN_EDIT, - )) ->executeOne(); if (!$plan) { return new Aphront404Response(); } + $plan->assertHasRunCapability($viewer); + $cancel_uri = $this->getApplicationURI("plan/{$plan_id}/"); if (!$plan->canRunManually()) { diff --git a/src/applications/harbormaster/controller/HarbormasterPlanViewController.php b/src/applications/harbormaster/controller/HarbormasterPlanViewController.php index 6ebadf7a62..4f2b70fcaf 100644 --- a/src/applications/harbormaster/controller/HarbormasterPlanViewController.php +++ b/src/applications/harbormaster/controller/HarbormasterPlanViewController.php @@ -18,11 +18,6 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { return new Aphront404Response(); } - $timeline = $this->buildTransactionTimeline( - $plan, - new HarbormasterBuildPlanTransactionQuery()); - $timeline->setShouldTerminate(true); - $title = $plan->getName(); $header = id(new PHUIHeaderView()) @@ -33,24 +28,30 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { $curtain = $this->buildCurtainView($plan); - $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Plan %d', $id)); - $crumbs->setBorder(true); + $crumbs = $this->buildApplicationCrumbs() + ->addTextCrumb($plan->getObjectName()) + ->setBorder(true); - list($step_list, $has_any_conflicts, $would_deadlock) = + list($step_list, $has_any_conflicts, $would_deadlock, $steps) = $this->buildStepList($plan); $error = null; - if ($would_deadlock) { - $error = pht('This build plan will deadlock when executed, due to '. - 'circular dependencies present in the build plan. '. - 'Examine the step list and resolve the deadlock.'); + if (!$steps) { + $error = pht( + 'This build plan does not have any build steps yet, so it will '. + 'not do anything when run.'); + } else if ($would_deadlock) { + $error = pht( + 'This build plan will deadlock when executed, due to circular '. + 'dependencies present in the build plan. Examine the step list '. + 'and resolve the deadlock.'); } else if ($has_any_conflicts) { // A deadlocking build will also cause all the artifacts to be // invalid, so we just skip showing this message if that's the // case. - $error = pht('This build plan has conflicts in one or more build steps. '. - 'Examine the step list and resolve the listed errors.'); + $error = pht( + 'This build plan has conflicts in one or more build steps. '. + 'Examine the step list and resolve the listed errors.'); } if ($error) { @@ -59,18 +60,32 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { ->appendChild($error); } + $builds_view = $this->newBuildsView($plan); + $options_view = $this->newOptionsView($plan); + $rules_view = $this->newRulesView($plan); + + $timeline = $this->buildTransactionTimeline( + $plan, + new HarbormasterBuildPlanTransactionQuery()); + $timeline->setShouldTerminate(true); + $view = id(new PHUITwoColumnView()) ->setHeader($header) ->setCurtain($curtain) - ->setMainColumn(array( - $error, - $step_list, - $timeline, - )); + ->setMainColumn( + array( + $error, + $step_list, + $options_view, + $rules_view, + $builds_view, + $timeline, + )); return $this->newPage() ->setTitle($title) ->setCrumbs($crumbs) + ->setPageObjectPHIDs(array($plan->getPHID())) ->appendChild($view); } @@ -213,7 +228,7 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->appendChild($step_list); - return array($step_box, $has_any_conflicts, $is_deadlocking); + return array($step_box, $has_any_conflicts, $is_deadlocking, $steps); } private function buildCurtainView(HarbormasterBuildPlan $plan) { @@ -253,7 +268,7 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { ->setIcon('fa-ban')); } - $can_run = ($can_edit && $plan->canRunManually()); + $can_run = ($plan->hasRunCapability($viewer) && $plan->canRunManually()); $curtain->addAction( id(new PhabricatorActionView()) @@ -263,11 +278,6 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { ->setDisabled(!$can_run) ->setIcon('fa-play-circle')); - $curtain->addPanel( - id(new PHUICurtainPanelView()) - ->setHeaderText(pht('Created')) - ->appendChild(phabricator_datetime($plan->getDateCreated(), $viewer))); - return $curtain; } @@ -381,7 +391,7 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { array $steps) { $has_conflicts = false; - if (count($step_phids) === 0) { + if (!$step_phids) { return null; } @@ -441,4 +451,167 @@ final class HarbormasterPlanViewController extends HarbormasterPlanController { return array($ui, $has_conflicts); } + + private function newBuildsView(HarbormasterBuildPlan $plan) { + $viewer = $this->getViewer(); + + $limit = 10; + $builds = id(new HarbormasterBuildQuery()) + ->setViewer($viewer) + ->withBuildPlanPHIDs(array($plan->getPHID())) + ->setLimit($limit + 1) + ->execute(); + + $more_results = (count($builds) > $limit); + $builds = array_slice($builds, 0, $limit); + + $list = id(new HarbormasterBuildView()) + ->setViewer($viewer) + ->setBuilds($builds) + ->newObjectList(); + + $list->setNoDataString(pht('No recent builds.')); + + $more_href = new PhutilURI( + $this->getApplicationURI('/build/'), + array('plan' => $plan->getPHID())); + + if ($more_results) { + $list->newTailButton() + ->setHref($more_href); + } + + $more_link = id(new PHUIButtonView()) + ->setTag('a') + ->setIcon('fa-list-ul') + ->setText(pht('View All Builds')) + ->setHref($more_href); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Recent Builds')) + ->addActionLink($more_link); + + return id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->appendChild($list); + } + + private function newRulesView(HarbormasterBuildPlan $plan) { + $viewer = $this->getViewer(); + + $limit = 10; + $rules = id(new HeraldRuleQuery()) + ->setViewer($viewer) + ->withDisabled(false) + ->withAffectedObjectPHIDs(array($plan->getPHID())) + ->needValidateAuthors(true) + ->setLimit($limit + 1) + ->execute(); + + $more_results = (count($rules) > $limit); + $rules = array_slice($rules, 0, $limit); + + $list = id(new HeraldRuleListView()) + ->setViewer($viewer) + ->setRules($rules) + ->newObjectList(); + + $list->setNoDataString(pht('No active Herald rules trigger this build.')); + + $more_href = new PhutilURI( + '/herald/', + array('affectedPHID' => $plan->getPHID())); + + if ($more_results) { + $list->newTailButton() + ->setHref($more_href); + } + + $more_link = id(new PHUIButtonView()) + ->setTag('a') + ->setIcon('fa-list-ul') + ->setText(pht('View All Rules')) + ->setHref($more_href); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Run By Herald Rules')) + ->addActionLink($more_link); + + return id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->appendChild($list); + } + + private function newOptionsView(HarbormasterBuildPlan $plan) { + $viewer = $this->getViewer(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $plan, + PhabricatorPolicyCapability::CAN_EDIT); + + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + + $rows = array(); + foreach ($behaviors as $behavior) { + $option = $behavior->getPlanOption($plan); + + $icon = $option->getIcon(); + $icon = id(new PHUIIconView())->setIcon($icon); + + $edit_uri = new PhutilURI( + $this->getApplicationURI( + urisprintf( + 'plan/behavior/%d/%s/', + $plan->getID(), + $behavior->getKey()))); + + $edit_button = id(new PHUIButtonView()) + ->setTag('a') + ->setColor(PHUIButtonView::GREY) + ->setSize(PHUIButtonView::SMALL) + ->setDisabled(!$can_edit) + ->setWorkflow(true) + ->setText(pht('Edit')) + ->setHref($edit_uri); + + $rows[] = array( + $icon, + $behavior->getName(), + $option->getName(), + $option->getDescription(), + $edit_button, + ); + } + + $table = id(new AphrontTableView($rows)) + ->setHeaders( + array( + null, + pht('Name'), + pht('Behavior'), + pht('Details'), + null, + )) + ->setColumnClasses( + array( + null, + 'pri', + null, + 'wide', + null, + )); + + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Plan Behaviors')); + + return id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->setTable($table); + } + } diff --git a/src/applications/harbormaster/controller/HarbormasterUnitMessageListController.php b/src/applications/harbormaster/controller/HarbormasterUnitMessageListController.php index d548ceac98..a87d17c4fa 100644 --- a/src/applications/harbormaster/controller/HarbormasterUnitMessageListController.php +++ b/src/applications/harbormaster/controller/HarbormasterUnitMessageListController.php @@ -31,9 +31,10 @@ final class HarbormasterUnitMessageListController $unit_data = array(); if ($target_phids) { - $unit_data = id(new HarbormasterBuildUnitMessage())->loadAllWhere( - 'buildTargetPHID IN (%Ls)', - $target_phids); + $unit_data = id(new HarbormasterBuildUnitMessageQuery()) + ->setViewer($viewer) + ->withBuildTargetPHIDs($target_phids) + ->execute(); } else { $unit_data = array(); } diff --git a/src/applications/harbormaster/controller/HarbormasterUnitMessageViewController.php b/src/applications/harbormaster/controller/HarbormasterUnitMessageViewController.php index 5cb33c0c9a..7111db654f 100644 --- a/src/applications/harbormaster/controller/HarbormasterUnitMessageViewController.php +++ b/src/applications/harbormaster/controller/HarbormasterUnitMessageViewController.php @@ -12,7 +12,10 @@ final class HarbormasterUnitMessageViewController $message_id = $request->getURIData('id'); - $message = id(new HarbormasterBuildUnitMessage())->load($message_id); + $message = id(new HarbormasterBuildUnitMessageQuery()) + ->setViewer($viewer) + ->withIDs(array($message_id)) + ->executeOne(); if (!$message) { return new Aphront404Response(); } diff --git a/src/applications/harbormaster/editor/HarbormasterBuildPlanEditEngine.php b/src/applications/harbormaster/editor/HarbormasterBuildPlanEditEngine.php index 11837051c3..c0fa80d71b 100644 --- a/src/applications/harbormaster/editor/HarbormasterBuildPlanEditEngine.php +++ b/src/applications/harbormaster/editor/HarbormasterBuildPlanEditEngine.php @@ -77,17 +77,48 @@ final class HarbormasterBuildPlanEditEngine } protected function buildCustomEditFields($object) { - return array( + $fields = array( id(new PhabricatorTextEditField()) ->setKey('name') ->setLabel(pht('Name')) ->setIsRequired(true) - ->setTransactionType(HarbormasterBuildPlanTransaction::TYPE_NAME) + ->setTransactionType( + HarbormasterBuildPlanNameTransaction::TRANSACTIONTYPE) ->setDescription(pht('The build plan name.')) ->setConduitDescription(pht('Rename the plan.')) ->setConduitTypeDescription(pht('New plan name.')) ->setValue($object->getName()), ); + + + $metadata_key = HarbormasterBuildPlanBehavior::getTransactionMetadataKey(); + + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + foreach ($behaviors as $behavior) { + $key = $behavior->getKey(); + + // Get the raw key off the object so that we don't reset stuff to + // default values by mistake if a behavior goes missing somehow. + $storage_key = HarbormasterBuildPlanBehavior::getStorageKeyForBehaviorKey( + $key); + $behavior_option = $object->getPlanProperty($storage_key); + + if (!strlen($behavior_option)) { + $behavior_option = $behavior->getPlanOption($object)->getKey(); + } + + $fields[] = id(new PhabricatorSelectEditField()) + ->setIsFormField(false) + ->setKey(sprintf('behavior.%s', $behavior->getKey())) + ->setMetadataValue($metadata_key, $behavior->getKey()) + ->setLabel(pht('Behavior: %s', $behavior->getName())) + ->setTransactionType( + HarbormasterBuildPlanBehaviorTransaction::TRANSACTIONTYPE) + ->setValue($behavior_option) + ->setOptions($behavior->getOptionMap()); + } + + return $fields; } } diff --git a/src/applications/harbormaster/editor/HarbormasterBuildPlanEditor.php b/src/applications/harbormaster/editor/HarbormasterBuildPlanEditor.php index 71c9283ade..1b340b6524 100644 --- a/src/applications/harbormaster/editor/HarbormasterBuildPlanEditor.php +++ b/src/applications/harbormaster/editor/HarbormasterBuildPlanEditor.php @@ -11,100 +11,23 @@ final class HarbormasterBuildPlanEditor return pht('Harbormaster Build Plans'); } + public function getCreateObjectTitle($author, $object) { + return pht('%s created this build plan.', $author); + } + + public function getCreateObjectTitleForFeed($author, $object) { + return pht('%s created %s.', $author, $object); + } + protected function supportsSearch() { return true; } public function getTransactionTypes() { $types = parent::getTransactionTypes(); - $types[] = HarbormasterBuildPlanTransaction::TYPE_NAME; - $types[] = HarbormasterBuildPlanTransaction::TYPE_STATUS; $types[] = PhabricatorTransactions::TYPE_VIEW_POLICY; $types[] = PhabricatorTransactions::TYPE_EDIT_POLICY; return $types; } - protected function getCustomTransactionOldValue( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - switch ($xaction->getTransactionType()) { - case HarbormasterBuildPlanTransaction::TYPE_NAME: - if ($this->getIsNewObject()) { - return null; - } - return $object->getName(); - case HarbormasterBuildPlanTransaction::TYPE_STATUS: - return $object->getPlanStatus(); - } - - return parent::getCustomTransactionOldValue($object, $xaction); - } - - protected function getCustomTransactionNewValue( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - switch ($xaction->getTransactionType()) { - case HarbormasterBuildPlanTransaction::TYPE_NAME: - return $xaction->getNewValue(); - case HarbormasterBuildPlanTransaction::TYPE_STATUS: - return $xaction->getNewValue(); - } - return parent::getCustomTransactionNewValue($object, $xaction); - } - - protected function applyCustomInternalTransaction( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - switch ($xaction->getTransactionType()) { - case HarbormasterBuildPlanTransaction::TYPE_NAME: - $object->setName($xaction->getNewValue()); - return; - case HarbormasterBuildPlanTransaction::TYPE_STATUS: - $object->setPlanStatus($xaction->getNewValue()); - return; - } - return parent::applyCustomInternalTransaction($object, $xaction); - } - - protected function applyCustomExternalTransaction( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - switch ($xaction->getTransactionType()) { - case HarbormasterBuildPlanTransaction::TYPE_NAME: - case HarbormasterBuildPlanTransaction::TYPE_STATUS: - return; - } - return parent::applyCustomExternalTransaction($object, $xaction); - } - - protected function validateTransaction( - PhabricatorLiskDAO $object, - $type, - array $xactions) { - - $errors = parent::validateTransaction($object, $type, $xactions); - - switch ($type) { - case HarbormasterBuildPlanTransaction::TYPE_NAME: - $missing = $this->validateIsEmptyTextField( - $object->getName(), - $xactions); - - if ($missing) { - $error = new PhabricatorApplicationTransactionValidationError( - $type, - pht('Required'), - pht('You must choose a name for your build plan.'), - last($xactions)); - - $error->setIsMissingFieldError(true); - $errors[] = $error; - } - break; - } - - return $errors; - } - - } diff --git a/src/applications/harbormaster/engine/HarbormasterBuildEngine.php b/src/applications/harbormaster/engine/HarbormasterBuildEngine.php index 170e4c8a5c..447bd53704 100644 --- a/src/applications/harbormaster/engine/HarbormasterBuildEngine.php +++ b/src/applications/harbormaster/engine/HarbormasterBuildEngine.php @@ -497,9 +497,33 @@ final class HarbormasterBuildEngine extends Phobject { // passed everything it needs to. if (!$buildable->isPreparing()) { + $behavior_key = HarbormasterBuildPlanBehavior::BEHAVIOR_BUILDABLE; + $behavior = HarbormasterBuildPlanBehavior::getBehavior($behavior_key); + + $key_never = HarbormasterBuildPlanBehavior::BUILDABLE_NEVER; + $key_building = HarbormasterBuildPlanBehavior::BUILDABLE_IF_BUILDING; + $all_pass = true; $any_fail = false; foreach ($buildable->getBuilds() as $build) { + $plan = $build->getBuildPlan(); + $option = $behavior->getPlanOption($plan); + $option_key = $option->getKey(); + + $is_never = ($option_key === $key_never); + $is_building = ($option_key === $key_building); + + // If this build "Never" affects the buildable, ignore it. + if ($is_never) { + continue; + } + + // If this build affects the buildable "If Building", but is already + // complete, ignore it. + if ($is_building && $build->isComplete()) { + continue; + } + if (!$build->isPassed()) { $all_pass = false; } diff --git a/src/applications/harbormaster/exception/HarbormasterRestartException.php b/src/applications/harbormaster/exception/HarbormasterRestartException.php new file mode 100644 index 0000000000..bd0b86184a --- /dev/null +++ b/src/applications/harbormaster/exception/HarbormasterRestartException.php @@ -0,0 +1,33 @@ +setTitle($title); + $this->appendParagraph($body); + + parent::__construct($title); + } + + public function setTitle($title) { + $this->title = $title; + return $this; + } + + public function getTitle() { + return $this->title; + } + + public function appendParagraph($description) { + $this->body[] = $description; + return $this; + } + + public function getBody() { + return $this->body; + } + +} diff --git a/src/applications/harbormaster/herald/HarbormasterRunBuildPlansHeraldAction.php b/src/applications/harbormaster/herald/HarbormasterRunBuildPlansHeraldAction.php index 8c718e5f5d..9fc053e8ae 100644 --- a/src/applications/harbormaster/herald/HarbormasterRunBuildPlansHeraldAction.php +++ b/src/applications/harbormaster/herald/HarbormasterRunBuildPlansHeraldAction.php @@ -91,4 +91,9 @@ final class HarbormasterRunBuildPlansHeraldAction 'Run build plans: %s.', $this->renderHandleList($value)); } + + public function getPHIDsAffectedByAction(HeraldActionRecord $record) { + return $record->getTarget(); + } + } diff --git a/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php new file mode 100644 index 0000000000..112926c47c --- /dev/null +++ b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehavior.php @@ -0,0 +1,398 @@ +key = $key; + return $this; + } + + public function getKey() { + return $this->key; + } + + public function setName($name) { + $this->name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + public function setEditInstructions($edit_instructions) { + $this->editInstructions = $edit_instructions; + return $this; + } + + public function getEditInstructions() { + return $this->editInstructions; + } + + public function getOptionMap() { + return mpull($this->options, 'getName', 'getKey'); + } + + public function setOptions(array $options) { + assert_instances_of($options, 'HarbormasterBuildPlanBehaviorOption'); + + $key_map = array(); + $default = null; + + foreach ($options as $option) { + $key = $option->getKey(); + + if (isset($key_map[$key])) { + throw new Exception( + pht( + 'Multiple behavior options (for behavior "%s") have the same '. + 'key ("%s"). Each option must have a unique key.', + $this->getKey(), + $key)); + } + $key_map[$key] = true; + + if ($option->getIsDefault()) { + if ($default === null) { + $default = $key; + } else { + throw new Exception( + pht( + 'Multiple behavior options (for behavior "%s") are marked as '. + 'default options ("%s" and "%s"). Exactly one option must be '. + 'marked as the default option.', + $this->getKey(), + $default, + $key)); + } + } + } + + if ($default === null) { + throw new Exception( + pht( + 'No behavior option is marked as the default option (for '. + 'behavior "%s"). Exactly one option must be marked as the '. + 'default option.', + $this->getKey())); + } + + $this->options = mpull($options, null, 'getKey'); + $this->defaultKey = $default; + + return $this; + } + + public function getOptions() { + return $this->options; + } + + public function getPlanOption(HarbormasterBuildPlan $plan) { + $behavior_key = $this->getKey(); + $storage_key = self::getStorageKeyForBehaviorKey($behavior_key); + + $plan_value = $plan->getPlanProperty($storage_key); + if (isset($this->options[$plan_value])) { + return $this->options[$plan_value]; + } + + return idx($this->options, $this->defaultKey); + } + + public static function getTransactionMetadataKey() { + return 'behavior-key'; + } + + public static function getStorageKeyForBehaviorKey($behavior_key) { + return sprintf('behavior.%s', $behavior_key); + } + + public static function getBehavior($key) { + $behaviors = self::newPlanBehaviors(); + + if (!isset($behaviors[$key])) { + throw new Exception( + pht( + 'No build plan behavior with key "%s" exists.', + $key)); + } + + return $behaviors[$key]; + } + + public static function newPlanBehaviors() { + $draft_options = array( + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::DRAFTS_ALWAYS) + ->setIcon('fa-check-circle-o green') + ->setName(pht('Always')) + ->setIsDefault(true) + ->setDescription( + pht( + 'Revisions are not sent for review until the build completes, '. + 'and are returned to the author for updates if the build fails.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::DRAFTS_IF_BUILDING) + ->setIcon('fa-pause-circle-o yellow') + ->setName(pht('If Building')) + ->setDescription( + pht( + 'Revisions are not sent for review until the build completes, '. + 'but they will be sent for review even if it fails.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::DRAFTS_NEVER) + ->setIcon('fa-circle-o red') + ->setName(pht('Never')) + ->setDescription( + pht( + 'Revisions are sent for review regardless of the status of the '. + 'build.')), + ); + + $land_options = array( + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::LANDWARNING_ALWAYS) + ->setIcon('fa-check-circle-o green') + ->setName(pht('Always')) + ->setIsDefault(true) + ->setDescription( + pht( + '"arc land" warns if the build is still running or has '. + 'failed.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::LANDWARNING_IF_BUILDING) + ->setIcon('fa-pause-circle-o yellow') + ->setName(pht('If Building')) + ->setDescription( + pht( + '"arc land" warns if the build is still running, but ignores '. + 'the build if it has failed.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::LANDWARNING_IF_COMPLETE) + ->setIcon('fa-dot-circle-o yellow') + ->setName(pht('If Complete')) + ->setDescription( + pht( + '"arc land" warns if the build has failed, but ignores the '. + 'build if it is still running.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::LANDWARNING_NEVER) + ->setIcon('fa-circle-o red') + ->setName(pht('Never')) + ->setDescription( + pht( + '"arc land" never warns that the build is still running or '. + 'has failed.')), + ); + + $aggregate_options = array( + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::BUILDABLE_ALWAYS) + ->setIcon('fa-check-circle-o green') + ->setName(pht('Always')) + ->setIsDefault(true) + ->setDescription( + pht( + 'The buildable waits for the build, and fails if the '. + 'build fails.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::BUILDABLE_IF_BUILDING) + ->setIcon('fa-pause-circle-o yellow') + ->setName(pht('If Building')) + ->setDescription( + pht( + 'The buildable waits for the build, but does not fail '. + 'if the build fails.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::BUILDABLE_NEVER) + ->setIcon('fa-circle-o red') + ->setName(pht('Never')) + ->setDescription( + pht( + 'The buildable does not wait for the build.')), + ); + + $restart_options = array( + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::RESTARTABLE_ALWAYS) + ->setIcon('fa-repeat green') + ->setName(pht('Always')) + ->setIsDefault(true) + ->setDescription( + pht('The build may be restarted.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::RESTARTABLE_IF_FAILED) + ->setIcon('fa-times-circle-o yellow') + ->setName(pht('If Failed')) + ->setDescription( + pht('The build may be restarted if it has failed.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::RESTARTABLE_NEVER) + ->setIcon('fa-times red') + ->setName(pht('Never')) + ->setDescription( + pht('The build may not be restarted.')), + ); + + $run_options = array( + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::RUNNABLE_IF_EDITABLE) + ->setIcon('fa-pencil green') + ->setName(pht('If Editable')) + ->setIsDefault(true) + ->setDescription( + pht('Only users who can edit the plan can run it manually.')), + id(new HarbormasterBuildPlanBehaviorOption()) + ->setKey(self::RUNNABLE_IF_VIEWABLE) + ->setIcon('fa-exclamation-triangle yellow') + ->setName(pht('If Viewable')) + ->setDescription( + pht( + 'Any user who can view the plan can run it manually.')), + ); + + $behaviors = array( + id(new self()) + ->setKey(self::BEHAVIOR_DRAFTS) + ->setName(pht('Hold Drafts')) + ->setEditInstructions( + pht( + 'When users create revisions in Differential, the default '. + 'behavior is to hold them in the "Draft" state until all builds '. + 'pass. Once builds pass, the revisions promote and are sent for '. + 'review, which notifies reviewers.'. + "\n\n". + 'The general intent of this workflow is to make sure reviewers '. + 'are only spending time on review once changes survive automated '. + 'tests. If a change does not pass tests, it usually is not '. + 'really ready for review.'. + "\n\n". + 'If you want to promote revisions out of "Draft" before builds '. + 'pass, or promote revisions even when builds fail, you can '. + 'change the promotion behavior. This may be useful if you have '. + 'very long-running builds, or some builds which are not very '. + 'important.'. + "\n\n". + 'Users may always use "Request Review" to promote a "Draft" '. + 'revision, even if builds have failed or are still in progress.')) + ->setOptions($draft_options), + id(new self()) + ->setKey(self::BEHAVIOR_LANDWARNING) + ->setName(pht('Warn When Landing')) + ->setEditInstructions( + pht( + 'When a user attempts to `arc land` a revision and that revision '. + 'has ongoing or failed builds, the default behavior of `arc` is '. + 'to warn them about those builds and give them a chance to '. + 'reconsider: they may want to wait for ongoing builds to '. + 'complete, or fix failed builds before landing the change.'. + "\n\n". + 'If you do not want to warn users about this build, you can '. + 'change the warning behavior. This may be useful if the build '. + 'takes a long time to run (so you do not expect users to wait '. + 'for it) or the outcome is not important.'. + "\n\n". + 'This warning is only advisory. Users may always elect to ignore '. + 'this warning and continue, even if builds have failed.'. + "\n\n". + 'This setting also affects the warning that is published to '. + 'revisions when commits land with ongoing or failed builds.')) + ->setOptions($land_options), + id(new self()) + ->setKey(self::BEHAVIOR_BUILDABLE) + ->setEditInstructions( + pht( + 'The overall state of a buildable (like a commit or revision) is '. + 'normally the aggregation of the individual states of all builds '. + 'that have run against it.'. + "\n\n". + 'Buildables are "building" until all builds pass (which changes '. + 'them to "pass"), or any build fails (which changes them to '. + '"fail").'. + "\n\n". + 'You can change this behavior if you do not want to wait for this '. + 'build, or do not care if it fails.')) + ->setName(pht('Affects Buildable')) + ->setOptions($aggregate_options), + id(new self()) + ->setKey(self::BEHAVIOR_RESTARTABLE) + ->setEditInstructions( + pht( + 'Usually, builds may be restarted by users who have permission '. + 'to edit the related build plan. (You can change who is allowed '. + 'to restart a build by adjusting the "Runnable" behavior.)'. + "\n\n". + 'Restarting a build may be useful if you suspect it has failed '. + 'for environmental or circumstantial reasons unrelated to the '. + 'actual code, and want to give it another chance at glory.'. + "\n\n". + 'If you want to prevent a build from being restarted, you can '. + 'change when it may be restarted by adjusting this behavior. '. + 'This may be useful to prevent accidents where a build with a '. + 'dangerous side effect (like deployment) is restarted '. + 'improperly.')) + ->setName(pht('Restartable')) + ->setOptions($restart_options), + id(new self()) + ->setKey(self::BEHAVIOR_RUNNABLE) + ->setEditInstructions( + pht( + 'To run a build manually, you normally must have permission to '. + 'edit the related build plan. If you would prefer that anyone who '. + 'can see the build plan be able to run and restart the build, you '. + 'can change the behavior here.'. + "\n\n". + 'Note that this controls access to all build management actions: '. + '"Run Plan Manually", "Restart", "Abort", "Pause", and "Resume".'. + "\n\n". + 'WARNING: This may be unsafe, particularly if the build has '. + 'side effects like deployment.'. + "\n\n". + 'If you weaken this policy, an attacker with control of an '. + 'account that has "Can View" permission but not "Can Edit" '. + 'permission can manually run this build against any old version '. + 'of the code, including versions with known security issues.'. + "\n\n". + 'If running the build has a side effect like deploying code, '. + 'they can force deployment of a vulnerable version and then '. + 'escalate into an attack against the deployed service.')) + ->setName(pht('Runnable')) + ->setOptions($run_options), + ); + + return mpull($behaviors, null, 'getKey'); + } + +} diff --git a/src/applications/harbormaster/plan/HarbormasterBuildPlanBehaviorOption.php b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehaviorOption.php new file mode 100644 index 0000000000..65b9662b9f --- /dev/null +++ b/src/applications/harbormaster/plan/HarbormasterBuildPlanBehaviorOption.php @@ -0,0 +1,57 @@ +name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + public function setKey($key) { + $this->key = $key; + return $this; + } + + public function getKey() { + return $this->key; + } + + public function setDescription($description) { + $this->description = $description; + return $this; + } + + public function getDescription() { + return $this->description; + } + + public function setIsDefault($is_default) { + $this->isDefault = $is_default; + return $this; + } + + public function getIsDefault() { + return $this->isDefault; + } + + public function setIcon($icon) { + $this->icon = $icon; + return $this; + } + + public function getIcon() { + return $this->icon; + } + +} diff --git a/src/applications/harbormaster/query/HarbormasterBuildPlanQuery.php b/src/applications/harbormaster/query/HarbormasterBuildPlanQuery.php index 4058325140..c903fbb37f 100644 --- a/src/applications/harbormaster/query/HarbormasterBuildPlanQuery.php +++ b/src/applications/harbormaster/query/HarbormasterBuildPlanQuery.php @@ -133,11 +133,10 @@ final class HarbormasterBuildPlanQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $plan = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $plan->getID(), - 'name' => $plan->getName(), + 'id' => (int)$object->getID(), + 'name' => $object->getName(), ); } diff --git a/src/applications/harbormaster/query/HarbormasterBuildSearchEngine.php b/src/applications/harbormaster/query/HarbormasterBuildSearchEngine.php index 4cf6a83701..b8140d84f6 100644 --- a/src/applications/harbormaster/query/HarbormasterBuildSearchEngine.php +++ b/src/applications/harbormaster/query/HarbormasterBuildSearchEngine.php @@ -128,49 +128,14 @@ final class HarbormasterBuildSearchEngine $viewer = $this->requireViewer(); - $buildables = mpull($builds, 'getBuildable'); - $object_phids = mpull($buildables, 'getBuildablePHID'); - $initiator_phids = mpull($builds, 'getInitiatorPHID'); - $phids = array_mergev(array($initiator_phids, $object_phids)); - $phids = array_unique(array_filter($phids)); + $list = id(new HarbormasterBuildView()) + ->setViewer($viewer) + ->setBuilds($builds) + ->newObjectList(); - $handles = $viewer->loadHandles($phids); - - $list = new PHUIObjectItemListView(); - foreach ($builds as $build) { - $id = $build->getID(); - $initiator = $handles[$build->getInitiatorPHID()]; - $buildable_object = $handles[$build->getBuildable()->getBuildablePHID()]; - - $item = id(new PHUIObjectItemView()) - ->setViewer($viewer) - ->setObject($build) - ->setObjectName(pht('Build %d', $build->getID())) - ->setHeader($build->getName()) - ->setHref($build->getURI()) - ->setEpoch($build->getDateCreated()) - ->addAttribute($buildable_object->getName()); - - if ($initiator) { - $item->addHandleIcon($initiator, $initiator->getName()); - } - - $status = $build->getBuildStatus(); - - $status_icon = HarbormasterBuildStatus::getBuildStatusIcon($status); - $status_color = HarbormasterBuildStatus::getBuildStatusColor($status); - $status_label = HarbormasterBuildStatus::getBuildStatusName($status); - - $item->setStatusIcon("{$status_icon} {$status_color}", $status_label); - - $list->addItem($item); - } - - $result = new PhabricatorApplicationSearchResultView(); - $result->setObjectList($list); - $result->setNoDataString(pht('No builds found.')); - - return $result; + return id(new PhabricatorApplicationSearchResultView()) + ->setObjectList($list) + ->setNoDataString(pht('No builds found.')); } } diff --git a/src/applications/harbormaster/query/HarbormasterBuildUnitMessageQuery.php b/src/applications/harbormaster/query/HarbormasterBuildUnitMessageQuery.php new file mode 100644 index 0000000000..f73016a29f --- /dev/null +++ b/src/applications/harbormaster/query/HarbormasterBuildUnitMessageQuery.php @@ -0,0 +1,95 @@ +ids = $ids; + return $this; + } + + public function withPHIDs(array $phids) { + $this->phids = $phids; + return $this; + } + + public function withBuildTargetPHIDs(array $target_phids) { + $this->targetPHIDs = $target_phids; + return $this; + } + + public function newResultObject() { + return new HarbormasterBuildUnitMessage(); + } + + protected function loadPage() { + return $this->loadStandardPage($this->newResultObject()); + } + + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { + $where = parent::buildWhereClauseParts($conn); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn, + 'id IN (%Ld)', + $this->ids); + } + + if ($this->phids !== null) { + $where[] = qsprintf( + $conn, + 'phid in (%Ls)', + $this->phids); + } + + if ($this->targetPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'buildTargetPHID in (%Ls)', + $this->targetPHIDs); + } + + return $where; + } + + protected function didFilterPage(array $messages) { + $indexes = array(); + foreach ($messages as $message) { + $index = $message->getNameIndex(); + if (strlen($index)) { + $indexes[$index] = $index; + } + } + + if ($indexes) { + $map = HarbormasterString::newIndexMap($indexes); + + foreach ($messages as $message) { + $index = $message->getNameIndex(); + + if (!strlen($index)) { + continue; + } + + $name = idx($map, $index); + if ($name === null) { + $name = pht('Unknown Unit Message ("%s")', $index); + } + + $message->setName($name); + } + } + + return $messages; + } + + public function getQueryApplicationClass() { + return 'PhabricatorHarbormasterApplication'; + } + +} diff --git a/src/applications/harbormaster/storage/HarbormasterString.php b/src/applications/harbormaster/storage/HarbormasterString.php new file mode 100644 index 0000000000..7493e60e21 --- /dev/null +++ b/src/applications/harbormaster/storage/HarbormasterString.php @@ -0,0 +1,54 @@ + false, + self::CONFIG_COLUMN_SCHEMA => array( + 'stringIndex' => 'bytes12', + 'stringValue' => 'text', + ), + self::CONFIG_KEY_SCHEMA => array( + 'key_string' => array( + 'columns' => array('stringIndex'), + 'unique' => true, + ), + ), + ) + parent::getConfiguration(); + } + + public static function newIndex($string) { + $index = PhabricatorHash::digestForIndex($string); + + $table = new self(); + $conn = $table->establishConnection('w'); + + queryfx( + $conn, + 'INSERT IGNORE INTO %R (stringIndex, stringValue) VALUES (%s, %s)', + $table, + $index, + $string); + + return $index; + } + + public static function newIndexMap(array $indexes) { + $table = new self(); + $conn = $table->establishConnection('r'); + + $rows = queryfx_all( + $conn, + 'SELECT stringIndex, stringValue FROM %R WHERE stringIndex IN (%Ls)', + $table, + $indexes); + + return ipull($rows, 'stringValue', 'stringIndex'); + } + +} diff --git a/src/applications/harbormaster/storage/build/HarbormasterBuild.php b/src/applications/harbormaster/storage/build/HarbormasterBuild.php index 602e388477..70c26827ec 100644 --- a/src/applications/harbormaster/storage/build/HarbormasterBuild.php +++ b/src/applications/harbormaster/storage/build/HarbormasterBuild.php @@ -183,6 +183,10 @@ final class HarbormasterBuild extends HarbormasterDAO return $this->getBuildStatusObject()->isPassed(); } + public function isFailed() { + return $this->getBuildStatusObject()->isFailed(); + } + public function getURI() { $id = $this->getID(); return "/harbormaster/build/{$id}/"; @@ -193,6 +197,10 @@ final class HarbormasterBuild extends HarbormasterDAO return HarbormasterBuildStatus::newBuildStatusObject($status_key); } + public function getObjectName() { + return pht('Build %d', $this->getID()); + } + /* -( Build Commands )----------------------------------------------------- */ @@ -207,11 +215,60 @@ final class HarbormasterBuild extends HarbormasterDAO } public function canRestartBuild() { - if ($this->isAutobuild()) { + try { + $this->assertCanRestartBuild(); + return true; + } catch (HarbormasterRestartException $ex) { return false; } + } - return !$this->isRestarting(); + public function assertCanRestartBuild() { + if ($this->isAutobuild()) { + throw new HarbormasterRestartException( + pht('Can Not Restart Autobuild'), + pht( + 'This build can not be restarted because it is an automatic '. + 'build.')); + } + + $restartable = HarbormasterBuildPlanBehavior::BEHAVIOR_RESTARTABLE; + $plan = $this->getBuildPlan(); + + $option = HarbormasterBuildPlanBehavior::getBehavior($restartable) + ->getPlanOption($plan); + $option_key = $option->getKey(); + + $never_restartable = HarbormasterBuildPlanBehavior::RESTARTABLE_NEVER; + $is_never = ($option_key === $never_restartable); + if ($is_never) { + throw new HarbormasterRestartException( + pht('Build Plan Prevents Restart'), + pht( + 'This build can not be restarted because the build plan is '. + 'configured to prevent the build from restarting.')); + } + + $failed_restartable = HarbormasterBuildPlanBehavior::RESTARTABLE_IF_FAILED; + $is_failed = ($option_key === $failed_restartable); + if ($is_failed) { + if (!$this->isFailed()) { + throw new HarbormasterRestartException( + pht('Only Restartable if Failed'), + pht( + 'This build can not be restarted because the build plan is '. + 'configured to prevent the build from restarting unless it '. + 'has failed, and it has not failed.')); + } + } + + if ($this->isRestarting()) { + throw new HarbormasterRestartException( + pht('Already Restarting'), + pht( + 'This build is already restarting. You can not reissue a restart '. + 'command to a restarting build.')); + } } public function canPauseBuild() { @@ -330,14 +387,17 @@ final class HarbormasterBuild extends HarbormasterDAO } public function assertCanIssueCommand(PhabricatorUser $viewer, $command) { - $need_edit = false; + $plan = $this->getBuildPlan(); + + $need_edit = true; switch ($command) { case HarbormasterBuildCommand::COMMAND_RESTART: - break; case HarbormasterBuildCommand::COMMAND_PAUSE: case HarbormasterBuildCommand::COMMAND_RESUME: case HarbormasterBuildCommand::COMMAND_ABORT: - $need_edit = true; + if ($plan->canRunWithoutEditCapability()) { + $need_edit = false; + } break; default: throw new Exception( @@ -351,7 +411,7 @@ final class HarbormasterBuild extends HarbormasterDAO if ($need_edit) { PhabricatorPolicyFilter::requireCapability( $viewer, - $this->getBuildPlan(), + $plan, PhabricatorPolicyCapability::CAN_EDIT); } } diff --git a/src/applications/harbormaster/storage/build/HarbormasterBuildUnitMessage.php b/src/applications/harbormaster/storage/build/HarbormasterBuildUnitMessage.php index b2f566c3eb..9e437efaba 100644 --- a/src/applications/harbormaster/storage/build/HarbormasterBuildUnitMessage.php +++ b/src/applications/harbormaster/storage/build/HarbormasterBuildUnitMessage.php @@ -1,12 +1,14 @@ 'text255', 'namespace' => 'text255', 'name' => 'text255', + 'nameIndex' => 'bytes12', 'result' => 'text32', 'duration' => 'double?', ), @@ -259,4 +262,52 @@ final class HarbormasterBuildUnitMessage return implode("\0", $parts); } + public function save() { + if ($this->nameIndex === null) { + $this->nameIndex = HarbormasterString::newIndex($this->getName()); + } + + // See T13088. While we're letting installs do online migrations to avoid + // downtime, don't populate the "name" column for new writes. New writes + // use the "HarbormasterString" table instead. + $old_name = $this->name; + $this->name = ''; + + $caught = null; + try { + $result = parent::save(); + } catch (Exception $ex) { + $caught = $ex; + } + + $this->name = $old_name; + + if ($caught) { + throw $caught; + } + + return $result; + } + + +/* -( PhabricatorPolicyInterface )----------------------------------------- */ + + + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + ); + } + + public function getPolicy($capability) { + switch ($capability) { + case PhabricatorPolicyCapability::CAN_VIEW: + return PhabricatorPolicies::getMostOpenPolicy(); + } + } + + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { + return false; + } + } diff --git a/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlan.php b/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlan.php index 2e379aab23..798201f490 100644 --- a/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlan.php +++ b/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlan.php @@ -10,13 +10,15 @@ final class HarbormasterBuildPlan extends HarbormasterDAO PhabricatorSubscribableInterface, PhabricatorNgramsInterface, PhabricatorConduitResultInterface, - PhabricatorProjectInterface { + PhabricatorProjectInterface, + PhabricatorPolicyCodexInterface { protected $name; protected $planStatus; protected $planAutoKey; protected $viewPolicy; protected $editPolicy; + protected $properties = array(); const STATUS_ACTIVE = 'active'; const STATUS_DISABLED = 'disabled'; @@ -45,6 +47,9 @@ final class HarbormasterBuildPlan extends HarbormasterDAO protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, + self::CONFIG_SERIALIZATION => array( + 'properties' => self::SERIALIZATION_JSON, + ), self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'sort128', 'planStatus' => 'text32', @@ -84,6 +89,25 @@ final class HarbormasterBuildPlan extends HarbormasterDAO return ($this->getPlanStatus() == self::STATUS_DISABLED); } + public function getURI() { + return urisprintf( + '/harbormaster/plan/%s/', + $this->getID()); + } + + public function getObjectName() { + return pht('Plan %d', $this->getID()); + } + + public function getPlanProperty($key, $default = null) { + return idx($this->properties, $key, $default); + } + + public function setPlanProperty($key, $value) { + $this->properties[$key] = $value; + return $this; + } + /* -( Autoplans )---------------------------------------------------------- */ @@ -110,7 +134,6 @@ final class HarbormasterBuildPlan extends HarbormasterDAO return true; } - public function getName() { $autoplan = $this->getAutoplan(); if ($autoplan) { @@ -120,6 +143,38 @@ final class HarbormasterBuildPlan extends HarbormasterDAO return parent::getName(); } + public function hasRunCapability(PhabricatorUser $viewer) { + try { + $this->assertHasRunCapability($viewer); + return true; + } catch (PhabricatorPolicyException $ex) { + return false; + } + } + + public function canRunWithoutEditCapability() { + $runnable = HarbormasterBuildPlanBehavior::BEHAVIOR_RUNNABLE; + $if_viewable = HarbormasterBuildPlanBehavior::RUNNABLE_IF_VIEWABLE; + + $option = HarbormasterBuildPlanBehavior::getBehavior($runnable) + ->getPlanOption($this); + + return ($option->getKey() === $if_viewable); + } + + public function assertHasRunCapability(PhabricatorUser $viewer) { + if ($this->canRunWithoutEditCapability()) { + $capability = PhabricatorPolicyCapability::CAN_VIEW; + } else { + $capability = PhabricatorPolicyCapability::CAN_EDIT; + } + + PhabricatorPolicyFilter::requireCapability( + $viewer, + $this, + $capability); + } + /* -( PhabricatorSubscribableInterface )----------------------------------- */ @@ -210,15 +265,31 @@ final class HarbormasterBuildPlan extends HarbormasterDAO ->setKey('status') ->setType('map') ->setDescription(pht('The current status of this build plan.')), + id(new PhabricatorConduitSearchFieldSpecification()) + ->setKey('behaviors') + ->setType('map') + ->setDescription(pht('Behavior configuration for the build plan.')), ); } public function getFieldValuesForConduit() { + $behavior_map = array(); + + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + foreach ($behaviors as $behavior) { + $option = $behavior->getPlanOption($this); + + $behavior_map[$behavior->getKey()] = array( + 'value' => $option->getKey(), + ); + } + return array( 'name' => $this->getName(), 'status' => array( 'value' => $this->getPlanStatus(), ), + 'behaviors' => $behavior_map, ); } @@ -226,4 +297,12 @@ final class HarbormasterBuildPlan extends HarbormasterDAO return array(); } + +/* -( PhabricatorPolicyCodexInterface )------------------------------------ */ + + + public function newPolicyCodex() { + return new HarbormasterBuildPlanPolicyCodex(); + } + } diff --git a/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlanTransaction.php b/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlanTransaction.php index 130471e21b..6cd286343a 100644 --- a/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlanTransaction.php +++ b/src/applications/harbormaster/storage/configuration/HarbormasterBuildPlanTransaction.php @@ -1,10 +1,7 @@ getOldValue(); - $new = $this->getNewValue(); - - switch ($this->getTransactionType()) { - case self::TYPE_NAME: - if ($old === null) { - return 'fa-plus'; - } - break; - } - - return parent::getIcon(); - } - - public function getColor() { - $old = $this->getOldValue(); - $new = $this->getNewValue(); - - switch ($this->getTransactionType()) { - case self::TYPE_NAME: - if ($old === null) { - return 'green'; - } - break; - } - - return parent::getIcon(); - } - - public function getTitle() { - $old = $this->getOldValue(); - $new = $this->getNewValue(); - $author_handle = $this->renderHandleLink($this->getAuthorPHID()); - - switch ($this->getTransactionType()) { - case self::TYPE_NAME: - if ($old === null) { - return pht( - '%s created this build plan.', - $author_handle); - } else { - return pht( - '%s renamed this build plan from "%s" to "%s".', - $author_handle, - $old, - $new); - } - case self::TYPE_STATUS: - if ($new == HarbormasterBuildPlan::STATUS_DISABLED) { - return pht( - '%s disabled this build plan.', - $author_handle); - } else { - return pht( - '%s enabled this build plan.', - $author_handle); - } - } - - return parent::getTitle(); + public function getBaseTransactionClass() { + return 'HarbormasterBuildPlanTransactionType'; } } diff --git a/src/applications/harbormaster/view/HarbormasterBuildView.php b/src/applications/harbormaster/view/HarbormasterBuildView.php new file mode 100644 index 0000000000..54f5abe093 --- /dev/null +++ b/src/applications/harbormaster/view/HarbormasterBuildView.php @@ -0,0 +1,67 @@ +builds = $builds; + return $this; + } + + public function getBuilds() { + return $this->builds; + } + + public function render() { + return $this->newObjectList(); + } + + public function newObjectList() { + $viewer = $this->getViewer(); + $builds = $this->getBuilds(); + + $buildables = mpull($builds, 'getBuildable'); + $object_phids = mpull($buildables, 'getBuildablePHID'); + $initiator_phids = mpull($builds, 'getInitiatorPHID'); + $phids = array_mergev(array($initiator_phids, $object_phids)); + $phids = array_unique(array_filter($phids)); + + $handles = $viewer->loadHandles($phids); + + $list = new PHUIObjectItemListView(); + foreach ($builds as $build) { + $id = $build->getID(); + $initiator = $handles[$build->getInitiatorPHID()]; + $buildable_object = $handles[$build->getBuildable()->getBuildablePHID()]; + + $item = id(new PHUIObjectItemView()) + ->setViewer($viewer) + ->setObject($build) + ->setObjectName($build->getObjectName()) + ->setHeader($build->getName()) + ->setHref($build->getURI()) + ->setEpoch($build->getDateCreated()) + ->addAttribute($buildable_object->getName()); + + if ($initiator) { + $item->addByline($initiator->renderLink()); + } + + $status = $build->getBuildStatus(); + + $status_icon = HarbormasterBuildStatus::getBuildStatusIcon($status); + $status_color = HarbormasterBuildStatus::getBuildStatusColor($status); + $status_label = HarbormasterBuildStatus::getBuildStatusName($status); + + $item->setStatusIcon("{$status_icon} {$status_color}", $status_label); + + $list->addItem($item); + } + + return $list; + } + +} diff --git a/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanBehaviorTransaction.php b/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanBehaviorTransaction.php new file mode 100644 index 0000000000..7a65eefdfa --- /dev/null +++ b/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanBehaviorTransaction.php @@ -0,0 +1,127 @@ +getBehavior(); + return $behavior->getPlanOption($object)->getKey(); + } + + public function applyInternalEffects($object, $value) { + $key = $this->getStorageKey(); + return $object->setPlanProperty($key, $value); + } + + public function getTitle() { + $old_value = $this->getOldValue(); + $new_value = $this->getNewValue(); + + $behavior = $this->getBehavior(); + if ($behavior) { + $behavior_name = $behavior->getName(); + + $options = $behavior->getOptions(); + if (isset($options[$old_value])) { + $old_value = $options[$old_value]->getName(); + } + + if (isset($options[$new_value])) { + $new_value = $options[$new_value]->getName(); + } + } else { + $behavior_name = $this->getBehaviorKey(); + } + + return pht( + '%s changed the %s behavior for this plan from %s to %s.', + $this->renderAuthor(), + $this->renderValue($behavior_name), + $this->renderValue($old_value), + $this->renderValue($new_value)); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + $behaviors = mpull($behaviors, null, 'getKey'); + + foreach ($xactions as $xaction) { + $key = $this->getBehaviorKeyForTransaction($xaction); + + if (!isset($behaviors[$key])) { + $errors[] = $this->newInvalidError( + pht( + 'No behavior with key "%s" exists. Valid keys are: %s.', + $key, + implode(', ', array_keys($behaviors))), + $xaction); + continue; + } + + $behavior = $behaviors[$key]; + $options = $behavior->getOptions(); + + $storage_key = HarbormasterBuildPlanBehavior::getStorageKeyForBehaviorKey( + $key); + $old = $object->getPlanProperty($storage_key); + $new = $xaction->getNewValue(); + + if ($old === $new) { + continue; + } + + if (!isset($options[$new])) { + $errors[] = $this->newInvalidError( + pht( + 'Value "%s" is not a valid option for behavior "%s". Valid '. + 'options are: %s.', + $new, + $key, + implode(', ', array_keys($options))), + $xaction); + continue; + } + } + + return $errors; + } + + public function getTransactionTypeForConduit($xaction) { + return 'behavior'; + } + + public function getFieldValuesForConduit($xaction, $data) { + return array( + 'key' => $this->getBehaviorKeyForTransaction($xaction), + 'old' => $xaction->getOldValue(), + 'new' => $xaction->getNewValue(), + ); + } + + private function getBehaviorKeyForTransaction( + PhabricatorApplicationTransaction $xaction) { + $metadata_key = HarbormasterBuildPlanBehavior::getTransactionMetadataKey(); + return $xaction->getMetadataValue($metadata_key); + } + + private function getBehaviorKey() { + $metadata_key = HarbormasterBuildPlanBehavior::getTransactionMetadataKey(); + return $this->getMetadataValue($metadata_key); + } + + private function getBehavior() { + $behavior_key = $this->getBehaviorKey(); + $behaviors = HarbormasterBuildPlanBehavior::newPlanBehaviors(); + return idx($behaviors, $behavior_key); + } + + private function getStorageKey() { + return HarbormasterBuildPlanBehavior::getStorageKeyForBehaviorKey( + $this->getBehaviorKey()); + } + +} diff --git a/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanNameTransaction.php b/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanNameTransaction.php new file mode 100644 index 0000000000..30fdbe72ca --- /dev/null +++ b/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanNameTransaction.php @@ -0,0 +1,46 @@ +getName(); + } + + public function applyInternalEffects($object, $value) { + $object->setName($value); + } + + public function getTitle() { + return pht( + '%s renamed this build plan from "%s" to "%s".', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + if ($this->isEmptyTextTransaction($object->getName(), $xactions)) { + $errors[] = $this->newRequiredError( + pht('You must choose a name for your build plan.')); + } + + return $errors; + } + + public function getTransactionTypeForConduit($xaction) { + return 'name'; + } + + public function getFieldValuesForConduit($xaction, $data) { + return array( + 'old' => $xaction->getOldValue(), + 'new' => $xaction->getNewValue(), + ); + } + +} diff --git a/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanStatusTransaction.php b/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanStatusTransaction.php new file mode 100644 index 0000000000..e1c72b4183 --- /dev/null +++ b/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanStatusTransaction.php @@ -0,0 +1,67 @@ +getPlanStatus(); + } + + public function applyInternalEffects($object, $value) { + $object->setPlanStatus($value); + } + + public function getTitle() { + $new = $this->getNewValue(); + if ($new === HarbormasterBuildPlan::STATUS_DISABLED) { + return pht( + '%s disabled this build plan.', + $this->renderAuthor()); + } else { + return pht( + '%s enabled this build plan.', + $this->renderAuthor()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $options = array( + HarbormasterBuildPlan::STATUS_DISABLED, + HarbormasterBuildPlan::STATUS_ACTIVE, + ); + $options = array_fuse($options); + + foreach ($xactions as $xaction) { + $new = $xaction->getNewValue(); + + if (!isset($options[$new])) { + $errors[] = $this->newInvalidError( + pht( + 'Status "%s" is not a valid build plan status. Valid '. + 'statuses are: %s.', + $new, + implode(', ', $options))); + continue; + } + + } + + return $errors; + } + + public function getTransactionTypeForConduit($xaction) { + return 'status'; + } + + public function getFieldValuesForConduit($xaction, $data) { + return array( + 'old' => $xaction->getOldValue(), + 'new' => $xaction->getNewValue(), + ); + } + +} diff --git a/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanTransactionType.php b/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanTransactionType.php new file mode 100644 index 0000000000..5545d1de38 --- /dev/null +++ b/src/applications/harbormaster/xaction/plan/HarbormasterBuildPlanTransactionType.php @@ -0,0 +1,4 @@ +getTarget(); + } + } diff --git a/src/applications/herald/action/HeraldCommentAction.php b/src/applications/herald/action/HeraldCommentAction.php index fa52ba1f5f..f8b8fbe813 100644 --- a/src/applications/herald/action/HeraldCommentAction.php +++ b/src/applications/herald/action/HeraldCommentAction.php @@ -19,12 +19,9 @@ final class HeraldCommentAction extends HeraldAction { } $xaction = $object->getApplicationTransactionTemplate(); - try { - $comment = $xaction->getApplicationTransactionCommentObject(); - if (!$comment) { - return false; - } - } catch (PhutilMethodNotImplementedException $ex) { + + $comment = $xaction->getApplicationTransactionCommentObject(); + if (!$comment) { return false; } diff --git a/src/applications/herald/adapter/HeraldAdapter.php b/src/applications/herald/adapter/HeraldAdapter.php index a266e21f39..69f538afcc 100644 --- a/src/applications/herald/adapter/HeraldAdapter.php +++ b/src/applications/herald/adapter/HeraldAdapter.php @@ -186,15 +186,16 @@ abstract class HeraldAdapter extends Phobject { return $this->appliedTransactions; } - public function queueTransaction($transaction) { + final public function queueTransaction( + PhabricatorApplicationTransaction $transaction) { $this->queuedTransactions[] = $transaction; } - public function getQueuedTransactions() { + final public function getQueuedTransactions() { return $this->queuedTransactions; } - public function newTransaction() { + final public function newTransaction() { $object = $this->newObject(); if (!($object instanceof PhabricatorApplicationTransactionInterface)) { @@ -205,7 +206,19 @@ abstract class HeraldAdapter extends Phobject { 'PhabricatorApplicationTransactionInterface')); } - return $object->getApplicationTransactionTemplate(); + $xaction = $object->getApplicationTransactionTemplate(); + + if (!($xaction instanceof PhabricatorApplicationTransaction)) { + throw new Exception( + pht( + 'Expected object (of class "%s") to return a transaction template '. + '(of class "%s"), but it returned something else ("%s").', + get_class($object), + 'PhabricatorApplicationTransaction', + phutil_describe_type($xaction))); + } + + return $xaction; } diff --git a/src/applications/herald/controller/HeraldDisableController.php b/src/applications/herald/controller/HeraldDisableController.php index def87049f7..765237930c 100644 --- a/src/applications/herald/controller/HeraldDisableController.php +++ b/src/applications/herald/controller/HeraldDisableController.php @@ -31,7 +31,7 @@ final class HeraldDisableController extends HeraldController { if ($request->isFormPost()) { $xaction = id(new HeraldRuleTransaction()) - ->setTransactionType(HeraldRuleTransaction::TYPE_DISABLE) + ->setTransactionType(HeraldRuleDisableTransaction::TRANSACTIONTYPE) ->setNewValue($is_disable); id(new HeraldRuleEditor()) diff --git a/src/applications/herald/controller/HeraldNewController.php b/src/applications/herald/controller/HeraldNewController.php index fbaf1aeb9e..f571aeb395 100644 --- a/src/applications/herald/controller/HeraldNewController.php +++ b/src/applications/herald/controller/HeraldNewController.php @@ -81,13 +81,13 @@ final class HeraldNewController extends HeraldController { } if (!$errors && $done) { - $uri = id(new PhutilURI('edit/')) - ->setQueryParams( - array( - 'content_type' => $content_type, - 'rule_type' => $rule_type, - 'targetPHID' => $target_phid, - )); + $params = array( + 'content_type' => $content_type, + 'rule_type' => $rule_type, + 'targetPHID' => $target_phid, + ); + + $uri = new PhutilURI('edit/', $params); $uri = $this->getApplicationURI($uri); return id(new AphrontRedirectResponse())->setURI($uri); } @@ -126,13 +126,13 @@ final class HeraldNewController extends HeraldController { ->addHiddenInput('step', 2) ->appendChild($rule_types); + $params = array( + 'content_type' => $content_type, + 'step' => '0', + ); + $cancel_text = pht('Back'); - $cancel_uri = id(new PhutilURI('new/')) - ->setQueryParams( - array( - 'content_type' => $content_type, - 'step' => 0, - )); + $cancel_uri = new PhutilURI('new/', $params); $cancel_uri = $this->getApplicationURI($cancel_uri); $title = pht('Create Herald Rule: %s', idx($content_type_map, $content_type)); @@ -173,14 +173,14 @@ final class HeraldNewController extends HeraldController { ->setValue($request->getStr('objectName')) ->setLabel(pht('Object'))); + $params = array( + 'content_type' => $content_type, + 'rule_type' => $rule_type, + 'step' => 1, + ); + $cancel_text = pht('Back'); - $cancel_uri = id(new PhutilURI('new/')) - ->setQueryParams( - array( - 'content_type' => $content_type, - 'rule_type' => $rule_type, - 'step' => 1, - )); + $cancel_uri = new PhutilURI('new/', $params); $cancel_uri = $this->getApplicationURI($cancel_uri); $title = pht('Create Herald Rule: %s', idx($content_type_map, $content_type)); diff --git a/src/applications/herald/controller/HeraldRuleController.php b/src/applications/herald/controller/HeraldRuleController.php index d400f8ae90..d05ed2d525 100644 --- a/src/applications/herald/controller/HeraldRuleController.php +++ b/src/applications/herald/controller/HeraldRuleController.php @@ -359,11 +359,21 @@ final class HeraldRuleController extends HeraldController { $repetition_policy); $xactions = array(); + + // Until this moves to EditEngine, manually add a "CREATE" transaction + // if we're creating a new rule. This improves rendering of the initial + // group of transactions. + $is_new = (bool)(!$rule->getID()); + if ($is_new) { + $xactions[] = id(new HeraldRuleTransaction()) + ->setTransactionType(PhabricatorTransactions::TYPE_CREATE); + } + $xactions[] = id(new HeraldRuleTransaction()) - ->setTransactionType(HeraldRuleTransaction::TYPE_EDIT) + ->setTransactionType(HeraldRuleEditTransaction::TRANSACTIONTYPE) ->setNewValue($new_state); $xactions[] = id(new HeraldRuleTransaction()) - ->setTransactionType(HeraldRuleTransaction::TYPE_NAME) + ->setTransactionType(HeraldRuleNameTransaction::TRANSACTIONTYPE) ->setNewValue($new_name); try { diff --git a/src/applications/herald/controller/HeraldWebhookViewController.php b/src/applications/herald/controller/HeraldWebhookViewController.php index d8e5eb3c54..5f6be9816c 100644 --- a/src/applications/herald/controller/HeraldWebhookViewController.php +++ b/src/applications/herald/controller/HeraldWebhookViewController.php @@ -73,12 +73,15 @@ final class HeraldWebhookViewController ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setTable($requests_table); + $rules_view = $this->newRulesView($hook); + $hook_view = id(new PHUITwoColumnView()) ->setHeader($header) ->setMainColumn( array( $warnings, $properties_view, + $rules_view, $requests_view, $timeline, )) @@ -194,4 +197,42 @@ final class HeraldWebhookViewController ->appendChild($properties); } + private function newRulesView(HeraldWebhook $hook) { + $viewer = $this->getViewer(); + + $rules = id(new HeraldRuleQuery()) + ->setViewer($viewer) + ->withDisabled(false) + ->withAffectedObjectPHIDs(array($hook->getPHID())) + ->needValidateAuthors(true) + ->setLimit(10) + ->execute(); + + $list = id(new HeraldRuleListView()) + ->setViewer($viewer) + ->setRules($rules) + ->newObjectList(); + + $list->setNoDataString(pht('No active Herald rules call this webhook.')); + + $more_href = new PhutilURI( + '/herald/', + array('affectedPHID' => $hook->getPHID())); + + $more_link = id(new PHUIButtonView()) + ->setTag('a') + ->setIcon('fa-list-ul') + ->setText(pht('View All Rules')) + ->setHref($more_href); + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Called By Herald Rules')) + ->addActionLink($more_link); + + return id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->appendChild($list); + } + } diff --git a/src/applications/herald/edge/HeraldRuleActionAffectsObjectEdgeType.php b/src/applications/herald/edge/HeraldRuleActionAffectsObjectEdgeType.php new file mode 100644 index 0000000000..35a30773ac --- /dev/null +++ b/src/applications/herald/edge/HeraldRuleActionAffectsObjectEdgeType.php @@ -0,0 +1,8 @@ +getTransactionType()) { - case HeraldRuleTransaction::TYPE_DISABLE: - return (int)$object->getIsDisabled(); - case HeraldRuleTransaction::TYPE_EDIT: - return id(new HeraldRuleSerializer()) - ->serializeRule($object); - case HeraldRuleTransaction::TYPE_NAME: - return $object->getName(); - } - - } - - protected function getCustomTransactionNewValue( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case HeraldRuleTransaction::TYPE_DISABLE: - return (int)$xaction->getNewValue(); - case HeraldRuleTransaction::TYPE_EDIT: - case HeraldRuleTransaction::TYPE_NAME: - return $xaction->getNewValue(); - } - } - - protected function applyCustomInternalTransaction( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case HeraldRuleTransaction::TYPE_DISABLE: - return $object->setIsDisabled($xaction->getNewValue()); - case HeraldRuleTransaction::TYPE_NAME: - return $object->setName($xaction->getNewValue()); - case HeraldRuleTransaction::TYPE_EDIT: - $new_state = id(new HeraldRuleSerializer()) - ->deserializeRuleComponents($xaction->getNewValue()); - $object->setMustMatchAll((int)$new_state['match_all']); - $object->attachConditions($new_state['conditions']); - $object->attachActions($new_state['actions']); - - $new_repetition = $new_state['repetition_policy']; - $object->setRepetitionPolicyStringConstant($new_repetition); - - return $object; - } - - } - - protected function applyCustomExternalTransaction( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - switch ($xaction->getTransactionType()) { - case HeraldRuleTransaction::TYPE_EDIT: - $object->saveConditions($object->getConditions()); - $object->saveActions($object->getActions()); - break; - } - return; - } - protected function shouldApplyHeraldRules( PhabricatorLiskDAO $object, array $xactions) { @@ -137,7 +61,6 @@ final class HeraldRuleEditor return pht('[Herald]'); } - protected function buildMailBody( PhabricatorLiskDAO $object, array $xactions) { @@ -151,4 +74,8 @@ final class HeraldRuleEditor return $body; } + protected function supportsSearch() { + return true; + } + } diff --git a/src/applications/herald/engineextension/HeraldRuleIndexEngineExtension.php b/src/applications/herald/engineextension/HeraldRuleIndexEngineExtension.php new file mode 100644 index 0000000000..7b7b2fb529 --- /dev/null +++ b/src/applications/herald/engineextension/HeraldRuleIndexEngineExtension.php @@ -0,0 +1,92 @@ +getPHID(), + $edge_type); + $old_edges = array_fuse($old_edges); + + $new_edges = $this->getPHIDsAffectedByActions($object); + $new_edges = array_fuse($new_edges); + + $add_edges = array_diff_key($new_edges, $old_edges); + $rem_edges = array_diff_key($old_edges, $new_edges); + + if (!$add_edges && !$rem_edges) { + return; + } + + $editor = new PhabricatorEdgeEditor(); + + foreach ($add_edges as $phid) { + $editor->addEdge($object->getPHID(), $edge_type, $phid); + } + + foreach ($rem_edges as $phid) { + $editor->removeEdge($object->getPHID(), $edge_type, $phid); + } + + $editor->save(); + } + + public function getIndexVersion($object) { + $phids = $this->getPHIDsAffectedByActions($object); + sort($phids); + $phids = implode(':', $phids); + return PhabricatorHash::digestForIndex($phids); + } + + private function getPHIDsAffectedByActions(HeraldRule $rule) { + $viewer = $this->getViewer(); + + $rule = id(new HeraldRuleQuery()) + ->setViewer($viewer) + ->withIDs(array($rule->getID())) + ->needConditionsAndActions(true) + ->executeOne(); + if (!$rule) { + return array(); + } + + $phids = array(); + + $actions = HeraldAction::getAllActions(); + foreach ($rule->getActions() as $action_record) { + $action = idx($actions, $action_record->getAction()); + + if (!$action) { + continue; + } + + foreach ($action->getPHIDsAffectedByAction($action_record) as $phid) { + $phids[] = $phid; + } + } + + $phids = array_fuse($phids); + return array_keys($phids); + } + +} diff --git a/src/applications/herald/query/HeraldRuleQuery.php b/src/applications/herald/query/HeraldRuleQuery.php index e6dba43c7a..e346a998d4 100644 --- a/src/applications/herald/query/HeraldRuleQuery.php +++ b/src/applications/herald/query/HeraldRuleQuery.php @@ -11,6 +11,7 @@ final class HeraldRuleQuery extends PhabricatorCursorPagedPolicyAwareQuery { private $active; private $datasourceQuery; private $triggerObjectPHIDs; + private $affectedObjectPHIDs; private $needConditionsAndActions; private $needAppliedToPHIDs; @@ -61,6 +62,11 @@ final class HeraldRuleQuery extends PhabricatorCursorPagedPolicyAwareQuery { return $this; } + public function withAffectedObjectPHIDs(array $phids) { + $this->affectedObjectPHIDs = $phids; + return $this; + } + public function needConditionsAndActions($need) { $this->needConditionsAndActions = $need; return $this; @@ -261,9 +267,31 @@ final class HeraldRuleQuery extends PhabricatorCursorPagedPolicyAwareQuery { $this->triggerObjectPHIDs); } + if ($this->affectedObjectPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'edge_affects.dst IN (%Ls)', + $this->affectedObjectPHIDs); + } + return $where; } + protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { + $joins = parent::buildJoinClauseParts($conn); + + if ($this->affectedObjectPHIDs !== null) { + $joins[] = qsprintf( + $conn, + 'JOIN %T edge_affects ON rule.phid = edge_affects.src + AND edge_affects.type = %d', + PhabricatorEdgeConfig::TABLE_NAME_EDGE, + HeraldRuleActionAffectsObjectEdgeType::EDGECONST); + } + + return $joins; + } + private function validateRuleAuthors(array $rules) { // "Global" and "Object" rules always have valid authors. foreach ($rules as $key => $rule) { diff --git a/src/applications/herald/query/HeraldRuleSearchEngine.php b/src/applications/herald/query/HeraldRuleSearchEngine.php index 47a6832731..95e3079717 100644 --- a/src/applications/herald/query/HeraldRuleSearchEngine.php +++ b/src/applications/herald/query/HeraldRuleSearchEngine.php @@ -55,6 +55,10 @@ final class HeraldRuleSearchEngine extends PhabricatorApplicationSearchEngine { pht('(Show All)'), pht('Show Only Disabled Rules'), pht('Show Only Enabled Rules')), + id(new PhabricatorPHIDsSearchField()) + ->setLabel(pht('Affected Objects')) + ->setKey('affectedPHIDs') + ->setAliases(array('affectedPHID')), ); } @@ -81,6 +85,10 @@ final class HeraldRuleSearchEngine extends PhabricatorApplicationSearchEngine { $query->withActive($map['active']); } + if ($map['affectedPHIDs']) { + $query->withAffectedObjectPHIDs($map['affectedPHIDs']); + } + return $query; } @@ -127,54 +135,18 @@ final class HeraldRuleSearchEngine extends PhabricatorApplicationSearchEngine { PhabricatorSavedQuery $query, array $handles) { assert_instances_of($rules, 'HeraldRule'); - $viewer = $this->requireViewer(); - $handles = $viewer->loadHandles(mpull($rules, 'getAuthorPHID')); - $content_type_map = HeraldAdapter::getEnabledAdapterMap($viewer); - - $list = id(new PHUIObjectItemListView()) - ->setUser($viewer); - foreach ($rules as $rule) { - $monogram = $rule->getMonogram(); - - $item = id(new PHUIObjectItemView()) - ->setObjectName($monogram) - ->setHeader($rule->getName()) - ->setHref("/{$monogram}"); - - if ($rule->isPersonalRule()) { - $item->addIcon('fa-user', pht('Personal Rule')); - $item->addByline( - pht( - 'Authored by %s', - $handles[$rule->getAuthorPHID()]->renderLink())); - } else if ($rule->isObjectRule()) { - $item->addIcon('fa-briefcase', pht('Object Rule')); - } else { - $item->addIcon('fa-globe', pht('Global Rule')); - } - - if ($rule->getIsDisabled()) { - $item->setDisabled(true); - $item->addIcon('fa-lock grey', pht('Disabled')); - } else if (!$rule->hasValidAuthor()) { - $item->setDisabled(true); - $item->addIcon('fa-user grey', pht('Author Not Active')); - } - - $content_type_name = idx($content_type_map, $rule->getContentType()); - $item->addAttribute(pht('Affects: %s', $content_type_name)); - - $list->addItem($item); - } + $list = id(new HeraldRuleListView()) + ->setViewer($viewer) + ->setRules($rules) + ->newObjectList(); $result = new PhabricatorApplicationSearchResultView(); $result->setObjectList($list); $result->setNoDataString(pht('No rules found.')); return $result; - } protected function getNewUserBody() { diff --git a/src/applications/herald/storage/HeraldRule.php b/src/applications/herald/storage/HeraldRule.php index 1b005898cb..a9c131e717 100644 --- a/src/applications/herald/storage/HeraldRule.php +++ b/src/applications/herald/storage/HeraldRule.php @@ -6,6 +6,7 @@ final class HeraldRule extends HeraldDAO PhabricatorFlaggableInterface, PhabricatorPolicyInterface, PhabricatorDestructibleInterface, + PhabricatorIndexableInterface, PhabricatorSubscribableInterface { const TABLE_RULE_APPLIED = 'herald_ruleapplied'; diff --git a/src/applications/herald/storage/HeraldRuleTransaction.php b/src/applications/herald/storage/HeraldRuleTransaction.php index b1bd563749..7fa7667ec7 100644 --- a/src/applications/herald/storage/HeraldRuleTransaction.php +++ b/src/applications/herald/storage/HeraldRuleTransaction.php @@ -1,11 +1,9 @@ getOldValue(); - $new = $this->getNewValue(); - - switch ($this->getTransactionType()) { - case self::TYPE_DISABLE: - if ($new) { - return 'red'; - } else { - return 'green'; - } - } - - return parent::getColor(); - } - - public function getActionName() { - $old = $this->getOldValue(); - $new = $this->getNewValue(); - - switch ($this->getTransactionType()) { - case self::TYPE_DISABLE: - if ($new) { - return pht('Disabled'); - } else { - return pht('Enabled'); - } - case self::TYPE_NAME: - return pht('Renamed'); - } - - return parent::getActionName(); - } - - public function getIcon() { - $old = $this->getOldValue(); - $new = $this->getNewValue(); - - switch ($this->getTransactionType()) { - case self::TYPE_DISABLE: - if ($new) { - return 'fa-ban'; - } else { - return 'fa-check'; - } - } - - return parent::getIcon(); - } - - - public function getTitle() { - $author_phid = $this->getAuthorPHID(); - - $old = $this->getOldValue(); - $new = $this->getNewValue(); - - switch ($this->getTransactionType()) { - case self::TYPE_DISABLE: - if ($new) { - return pht( - '%s disabled this rule.', - $this->renderHandleLink($author_phid)); - } else { - return pht( - '%s enabled this rule.', - $this->renderHandleLink($author_phid)); - } - case self::TYPE_NAME: - if ($old == null) { - return pht( - '%s created this rule.', - $this->renderHandleLink($author_phid)); - } else { - return pht( - '%s renamed this rule from "%s" to "%s".', - $this->renderHandleLink($author_phid), - $old, - $new); - } - case self::TYPE_EDIT: - return pht( - '%s edited this rule.', - $this->renderHandleLink($author_phid)); - } - - return parent::getTitle(); - } - - public function hasChangeDetails() { - switch ($this->getTransactionType()) { - case self::TYPE_EDIT: - return true; - } - return parent::hasChangeDetails(); - } - - public function renderChangeDetails(PhabricatorUser $viewer) { - $json = new PhutilJSON(); - switch ($this->getTransactionType()) { - case self::TYPE_EDIT: - return $this->renderTextCorpusChangeDetails( - $viewer, - $json->encodeFormatted($this->getOldValue()), - $json->encodeFormatted($this->getNewValue())); - } - - return $this->renderTextCorpusChangeDetails( - $viewer, - $this->getOldValue(), - $this->getNewValue()); + public function getBaseTransactionClass() { + return 'HeraldRuleTransactionType'; } } diff --git a/src/applications/herald/storage/HeraldRuleTransactionComment.php b/src/applications/herald/storage/HeraldRuleTransactionComment.php deleted file mode 100644 index 56022ef863..0000000000 --- a/src/applications/herald/storage/HeraldRuleTransactionComment.php +++ /dev/null @@ -1,10 +0,0 @@ - pht('Hook Error'), - self::ERRORTYPE_HTTP => pht('HTTP Error'), + self::ERRORTYPE_HTTP => pht('HTTP Status Code'), self::ERRORTYPE_TIMEOUT => pht('Request Timeout'), ); diff --git a/src/applications/herald/storage/HeraldWebhookTransaction.php b/src/applications/herald/storage/HeraldWebhookTransaction.php index 03c8cbb776..4f924cd4bb 100644 --- a/src/applications/herald/storage/HeraldWebhookTransaction.php +++ b/src/applications/herald/storage/HeraldWebhookTransaction.php @@ -11,10 +11,6 @@ final class HeraldWebhookTransaction return HeraldWebhookPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'HeraldWebhookTransactionType'; } diff --git a/src/applications/herald/view/HeraldRuleListView.php b/src/applications/herald/view/HeraldRuleListView.php new file mode 100644 index 0000000000..150499ce87 --- /dev/null +++ b/src/applications/herald/view/HeraldRuleListView.php @@ -0,0 +1,65 @@ +rules = $rules; + return $this; + } + + public function render() { + return $this->newObjectList(); + } + + public function newObjectList() { + $viewer = $this->getViewer(); + $rules = $this->rules; + + $handles = $viewer->loadHandles(mpull($rules, 'getAuthorPHID')); + + $content_type_map = HeraldAdapter::getEnabledAdapterMap($viewer); + + $list = id(new PHUIObjectItemListView()) + ->setViewer($viewer); + foreach ($rules as $rule) { + $monogram = $rule->getMonogram(); + + $item = id(new PHUIObjectItemView()) + ->setObjectName($monogram) + ->setHeader($rule->getName()) + ->setHref($rule->getURI()); + + if ($rule->isPersonalRule()) { + $item->addIcon('fa-user', pht('Personal Rule')); + $item->addByline( + pht( + 'Authored by %s', + $handles[$rule->getAuthorPHID()]->renderLink())); + } else if ($rule->isObjectRule()) { + $item->addIcon('fa-briefcase', pht('Object Rule')); + } else { + $item->addIcon('fa-globe', pht('Global Rule')); + } + + if ($rule->getIsDisabled()) { + $item->setDisabled(true); + $item->addIcon('fa-lock grey', pht('Disabled')); + } else if (!$rule->hasValidAuthor()) { + $item->setDisabled(true); + $item->addIcon('fa-user grey', pht('Author Not Active')); + } + + $content_type_name = idx($content_type_map, $rule->getContentType()); + $item->addAttribute(pht('Affects: %s', $content_type_name)); + + $list->addItem($item); + } + + return $list; + } + +} diff --git a/src/applications/herald/xaction/HeraldRuleDisableTransaction.php b/src/applications/herald/xaction/HeraldRuleDisableTransaction.php new file mode 100644 index 0000000000..5debab653b --- /dev/null +++ b/src/applications/herald/xaction/HeraldRuleDisableTransaction.php @@ -0,0 +1,32 @@ +getIsDisabled(); + } + + public function generateNewValue($object, $value) { + return (bool)$value; + } + + public function applyInternalEffects($object, $value) { + $object->setIsDisabled((int)$value); + } + + public function getTitle() { + if ($this->getNewValue()) { + return pht( + '%s disabled this rule.', + $this->renderAuthor()); + } else { + return pht( + '%s enabled this rule.', + $this->renderAuthor()); + } + } + +} diff --git a/src/applications/herald/xaction/HeraldRuleEditTransaction.php b/src/applications/herald/xaction/HeraldRuleEditTransaction.php new file mode 100644 index 0000000000..c4b03983fb --- /dev/null +++ b/src/applications/herald/xaction/HeraldRuleEditTransaction.php @@ -0,0 +1,56 @@ +serializeRule($object); + } + + public function applyInternalEffects($object, $value) { + $new_state = id(new HeraldRuleSerializer()) + ->deserializeRuleComponents($value); + + $object->setMustMatchAll((int)$new_state['match_all']); + $object->attachConditions($new_state['conditions']); + $object->attachActions($new_state['actions']); + + $new_repetition = $new_state['repetition_policy']; + $object->setRepetitionPolicyStringConstant($new_repetition); + } + + public function applyExternalEffects($object, $value) { + $object->saveConditions($object->getConditions()); + $object->saveActions($object->getActions()); + } + + public function getTitle() { + return pht( + '%s edited this rule.', + $this->renderAuthor()); + } + + public function hasChangeDetailView() { + return true; + } + + public function newChangeDetailView() { + $viewer = $this->getViewer(); + + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + $json = new PhutilJSON(); + $old_json = $json->encodeFormatted($old); + $new_json = $json->encodeFormatted($new); + + return id(new PhabricatorApplicationTransactionTextDiffDetailView()) + ->setViewer($viewer) + ->setOldText($old_json) + ->setNewText($new_json); + } + +} diff --git a/src/applications/herald/xaction/HeraldRuleNameTransaction.php b/src/applications/herald/xaction/HeraldRuleNameTransaction.php new file mode 100644 index 0000000000..39ce289d34 --- /dev/null +++ b/src/applications/herald/xaction/HeraldRuleNameTransaction.php @@ -0,0 +1,48 @@ +getName(); + } + + public function applyInternalEffects($object, $value) { + $object->setName($value); + } + + public function getTitle() { + return pht( + '%s renamed this rule from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + if ($this->isEmptyTextTransaction($object->getName(), $xactions)) { + $errors[] = $this->newRequiredError( + pht('Rules must have a name.')); + } + + $max_length = $object->getColumnMaximumByteLength('name'); + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + + $new_length = strlen($new_value); + if ($new_length > $max_length) { + $errors[] = $this->newInvalidError( + pht( + 'Rule names can be no longer than %s characters.', + new PhutilNumber($max_length))); + } + } + + return $errors; + } + +} diff --git a/src/applications/herald/xaction/HeraldRuleTransactionType.php b/src/applications/herald/xaction/HeraldRuleTransactionType.php new file mode 100644 index 0000000000..81c6846b1f --- /dev/null +++ b/src/applications/herald/xaction/HeraldRuleTransactionType.php @@ -0,0 +1,4 @@ +newApplicationMenu(); - - $profile_menu = $this->getProfileMenu(); - if ($profile_menu) { - $menu->setProfileMenu($profile_menu); - } - - return $menu; - } - - protected function getProfileMenu() { - if (!$this->profileMenu) { - $viewer = $this->getViewer(); - $applications = id(new PhabricatorApplicationQuery()) - ->setViewer($viewer) - ->withClasses(array('PhabricatorHomeApplication')) - ->withInstalled(true) - ->execute(); - $home = head($applications); - if (!$home) { - return null; - } - - $engine = id(new PhabricatorHomeProfileMenuEngine()) - ->setViewer($viewer) - ->setProfileObject($home) - ->setCustomPHID($viewer->getPHID()); - - $this->profileMenu = $engine->buildNavigation(); - } - - return $this->profileMenu; - } - -} +abstract class PhabricatorHomeController + extends PhabricatorController {} diff --git a/src/applications/home/menuitem/PhabricatorHomeLauncherProfileMenuItem.php b/src/applications/home/menuitem/PhabricatorHomeLauncherProfileMenuItem.php index a727fbced6..dbf1586366 100644 --- a/src/applications/home/menuitem/PhabricatorHomeLauncherProfileMenuItem.php +++ b/src/applications/home/menuitem/PhabricatorHomeLauncherProfileMenuItem.php @@ -13,6 +13,10 @@ final class PhabricatorHomeLauncherProfileMenuItem return pht('More Applications'); } + public function getMenuItemTypeIcon() { + return 'fa-ellipsis-h'; + } + public function canHideMenuItem( PhabricatorProfileMenuItemConfiguration $config) { return false; @@ -45,16 +49,16 @@ final class PhabricatorHomeLauncherProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $viewer = $this->getViewer(); $name = $this->getDisplayName($config); - $icon = 'fa-globe'; - $href = '/applications/'; + $icon = 'fa-ellipsis-h'; + $uri = '/applications/'; - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon($icon); diff --git a/src/applications/home/menuitem/PhabricatorHomeProfileMenuItem.php b/src/applications/home/menuitem/PhabricatorHomeProfileMenuItem.php index 8b3eb4fe2d..a002b59da5 100644 --- a/src/applications/home/menuitem/PhabricatorHomeProfileMenuItem.php +++ b/src/applications/home/menuitem/PhabricatorHomeProfileMenuItem.php @@ -13,6 +13,10 @@ final class PhabricatorHomeProfileMenuItem return pht('Home'); } + public function getMenuItemTypeIcon() { + return 'fa-home'; + } + public function canMakeDefault( PhabricatorProfileMenuItemConfiguration $config) { return true; @@ -48,16 +52,16 @@ final class PhabricatorHomeProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $viewer = $this->getViewer(); $name = $this->getDisplayName($config); $icon = 'fa-home'; - $href = $this->getItemViewURI($config); + $uri = $this->getItemViewURI($config); - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon($icon); diff --git a/src/applications/home/view/PHUIHomeView.php b/src/applications/home/view/PHUIHomeView.php index d6c3794854..45750f5a93 100644 --- a/src/applications/home/view/PHUIHomeView.php +++ b/src/applications/home/view/PHUIHomeView.php @@ -77,149 +77,76 @@ final class PHUIHomeView return $view; } - private function buildHomepagePanel($title, $href, $view) { - $title = phutil_tag( - 'a', - array( - 'href' => $href, - ), - $title); - - $icon = id(new PHUIIconView()) - ->setIcon('fa-search') - ->setHref($href); - - $header = id(new PHUIHeaderView()) - ->setHeader($title) - ->addActionItem($icon); - - $box = id(new PHUIObjectBoxView()) - ->setHeader($header); - - if ($view->getObjectList()) { - $box->setObjectList($view->getObjectList()); - } - if ($view->getContent()) { - $box->appendChild($view->getContent()); - } - - return $box; - } - private function buildRevisionPanel() { $viewer = $this->getViewer(); if (!$viewer->isLoggedIn()) { return null; } - $engine = new DifferentialRevisionSearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin('active'); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(15); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); + $panel = $this->newQueryPanel() + ->setName(pht('Active Revisions')) + ->setProperty('class', 'DifferentialRevisionSearchEngine') + ->setProperty('key', 'active'); - $title = pht('Active Revisions'); - $href = '/differential/query/active/'; - - return $this->buildHomepagePanel($title, $href, $view); + return $this->renderPanel($panel); } private function buildTasksPanel() { $viewer = $this->getViewer(); - $query = 'assigned'; - $title = pht('Assigned Tasks'); - $href = '/maniphest/query/assigned/'; - if (!$viewer->isLoggedIn()) { + if ($viewer->isLoggedIn()) { + $name = pht('Assigned Tasks'); + $query = 'assigned'; + } else { + $name = pht('Open Tasks'); $query = 'open'; - $title = pht('Open Tasks'); - $href = '/maniphest/query/open/'; } - $engine = new ManiphestTaskSearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin($query); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(15); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); + $panel = $this->newQueryPanel() + ->setName($name) + ->setProperty('class', 'ManiphestTaskSearchEngine') + ->setProperty('key', $query) + ->setProperty('limit', 15); - return $this->buildHomepagePanel($title, $href, $view); + return $this->renderPanel($panel); } public function buildFeedPanel() { - $viewer = $this->getViewer(); + $panel = $this->newQueryPanel() + ->setName(pht('Recent Activity')) + ->setProperty('class', 'PhabricatorFeedSearchEngine') + ->setProperty('key', 'all') + ->setProperty('limit', 40); - $engine = new PhabricatorFeedSearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin('all'); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(40); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); - // Low tech NUX. - if (!$results && ($viewer->getIsAdmin() == 1)) { - $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); - if (!$instance) { - $content = pht(<<setObjectList($list); - } else { - $content = id(new PHUIBoxView()) - ->appendChild(new PHUIRemarkupView($viewer, $content)) - ->addClass('mlt mlb msr msl'); - $view = new PhabricatorApplicationSearchResultView(); - $view->setContent($content); - } - } - - $title = pht('Recent Activity'); - $href = '/feed/'; - - return $this->buildHomepagePanel($title, $href, $view); + return $this->renderPanel($panel); } public function buildRepositoryPanel() { + $panel = $this->newQueryPanel() + ->setName(pht('Active Repositories')) + ->setProperty('class', 'PhabricatorRepositorySearchEngine') + ->setProperty('key', 'active') + ->setProperty('limit', 5); + + return $this->renderPanel($panel); + } + + private function newQueryPanel() { + $panel_type = id(new PhabricatorDashboardQueryPanelType()) + ->getPanelTypeKey(); + + return id(new PhabricatorDashboardPanel()) + ->setPanelType($panel_type); + } + + private function renderPanel(PhabricatorDashboardPanel $panel) { $viewer = $this->getViewer(); - $engine = new PhabricatorRepositorySearchEngine(); - $engine->setViewer($viewer); - $saved = $engine->buildSavedQueryFromBuiltin('active'); - $query = $engine->buildQueryFromSavedQuery($saved); - $pager = $engine->newPagerForSavedQuery($saved); - $pager->setPageSize(5); - $results = $engine->executeQuery($query, $pager); - $view = $engine->renderResults($results, $saved); - - $title = pht('Active Repositories'); - $href = '/diffusion/'; - - return $this->buildHomepagePanel($title, $href, $view); + return id(new PhabricatorDashboardPanelRenderingEngine()) + ->setViewer($viewer) + ->setPanel($panel) + ->setParentPanelPHIDs(array()) + ->renderPanel(); } } diff --git a/src/applications/legalpad/controller/LegalpadDocumentSignController.php b/src/applications/legalpad/controller/LegalpadDocumentSignController.php index ab98c0bb78..fb15e2af8f 100644 --- a/src/applications/legalpad/controller/LegalpadDocumentSignController.php +++ b/src/applications/legalpad/controller/LegalpadDocumentSignController.php @@ -2,6 +2,8 @@ final class LegalpadDocumentSignController extends LegalpadController { + private $isSessionGate; + public function shouldAllowPublic() { return true; } @@ -10,6 +12,15 @@ final class LegalpadDocumentSignController extends LegalpadController { return true; } + public function setIsSessionGate($is_session_gate) { + $this->isSessionGate = $is_session_gate; + return $this; + } + + public function getIsSessionGate() { + return $this->isSessionGate; + } + public function handleRequest(AphrontRequest $request) { $viewer = $request->getUser(); @@ -251,8 +262,14 @@ final class LegalpadDocumentSignController extends LegalpadController { $header = id(new PHUIHeaderView()) ->setHeader($title) ->setUser($viewer) - ->setEpoch($content_updated) - ->addActionLink( + ->setEpoch($content_updated); + + // If we're showing the user this document because it's required to use + // Phabricator and they haven't signed it, don't show the "Manage" button, + // since it won't work. + $is_gate = $this->getIsSessionGate(); + if (!$is_gate) { + $header->addActionLink( id(new PHUIButtonView()) ->setTag('a') ->setIcon('fa-pencil') @@ -260,6 +277,7 @@ final class LegalpadDocumentSignController extends LegalpadController { ->setHref($manage_uri) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); + } $preamble_box = null; if (strlen($document->getPreamble())) { @@ -364,16 +382,6 @@ final class LegalpadDocumentSignController extends LegalpadController { if ($email_obj) { return $this->signInResponse(); } - $external_account = id(new PhabricatorExternalAccountQuery()) - ->setViewer($viewer) - ->withAccountTypes(array('email')) - ->withAccountDomains(array($email->getDomainName())) - ->withAccountIDs(array($email->getAddress())) - ->loadOneOrCreate(); - if ($external_account->getUserPHID()) { - return $this->signInResponse(); - } - $signer_phid = $external_account->getPHID(); } } break; diff --git a/src/applications/legalpad/query/LegalpadDocumentSignatureSearchEngine.php b/src/applications/legalpad/query/LegalpadDocumentSignatureSearchEngine.php index 9df8d2478d..ea14fd4a2f 100644 --- a/src/applications/legalpad/query/LegalpadDocumentSignatureSearchEngine.php +++ b/src/applications/legalpad/query/LegalpadDocumentSignatureSearchEngine.php @@ -226,7 +226,7 @@ final class LegalpadDocumentSignatureSearchEngine $handles[$document->getPHID()]->renderLink(), $signer_phid ? $handles[$signer_phid]->renderLink() - : null, + : phutil_tag('em', array(), pht('None')), $name, phutil_tag( 'a', diff --git a/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php b/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php index 3819f38a70..9932baab80 100644 --- a/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php +++ b/src/applications/legalpad/xaction/LegalpadDocumentRequireSignatureTransaction.php @@ -55,11 +55,22 @@ final class LegalpadDocumentRequireSignatureTransaction public function validateTransactions($object, array $xactions) { $errors = array(); - $is_admin = $this->getActor()->getIsAdmin(); + $old = (bool)$object->getRequireSignature(); + foreach ($xactions as $xaction) { + $new = (bool)$xaction->getNewValue(); - if (!$is_admin) { - $errors[] = $this->newInvalidError( - pht('Only admins may require signature.')); + if ($old === $new) { + continue; + } + + $is_admin = $this->getActor()->getIsAdmin(); + if (!$is_admin) { + $errors[] = $this->newInvalidError( + pht( + 'Only administrators may change whether a document '. + 'requires a signature.'), + $xaction); + } } return $errors; diff --git a/src/applications/macro/engine/PhabricatorMemeEngine.php b/src/applications/macro/engine/PhabricatorMemeEngine.php index 7433a4e8bc..afee0f9b18 100644 --- a/src/applications/macro/engine/PhabricatorMemeEngine.php +++ b/src/applications/macro/engine/PhabricatorMemeEngine.php @@ -47,10 +47,13 @@ final class PhabricatorMemeEngine extends Phobject { } public function getGenerateURI() { - return id(new PhutilURI('/macro/meme/')) - ->alter('macro', $this->getTemplate()) - ->alter('above', $this->getAboveText()) - ->alter('below', $this->getBelowText()); + $params = array( + 'macro' => $this->getTemplate(), + 'above' => $this->getAboveText(), + 'below' => $this->getBelowText(), + ); + + return new PhutilURI('/macro/meme/', $params); } public function newAsset() { diff --git a/src/applications/macro/query/PhabricatorMacroQuery.php b/src/applications/macro/query/PhabricatorMacroQuery.php index 3ba30502d5..7635b68b73 100644 --- a/src/applications/macro/query/PhabricatorMacroQuery.php +++ b/src/applications/macro/query/PhabricatorMacroQuery.php @@ -249,11 +249,10 @@ final class PhabricatorMacroQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $macro = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $macro->getID(), - 'name' => $macro->getName(), + 'id' => (int)$object->getID(), + 'name' => $object->getName(), ); } diff --git a/src/applications/maniphest/__tests__/ManiphestTaskTestCase.php b/src/applications/maniphest/__tests__/ManiphestTaskTestCase.php deleted file mode 100644 index 58190f6a89..0000000000 --- a/src/applications/maniphest/__tests__/ManiphestTaskTestCase.php +++ /dev/null @@ -1,255 +0,0 @@ - true, - ); - } - - public function testTaskReordering() { - $viewer = $this->generateNewTestUser(); - - $t1 = $this->newTask($viewer, pht('Task 1')); - $t2 = $this->newTask($viewer, pht('Task 2')); - $t3 = $this->newTask($viewer, pht('Task 3')); - - $auto_base = min(mpull(array($t1, $t2, $t3), 'getID')); - - - // Default order should be reverse creation. - $tasks = $this->loadTasks($viewer, $auto_base); - $t1 = $tasks[1]; - $t2 = $tasks[2]; - $t3 = $tasks[3]; - $this->assertEqual(array(3, 2, 1), array_keys($tasks)); - - - // Move T3 to the middle. - $this->moveTask($viewer, $t3, $t2, true); - $tasks = $this->loadTasks($viewer, $auto_base); - $t1 = $tasks[1]; - $t2 = $tasks[2]; - $t3 = $tasks[3]; - $this->assertEqual(array(2, 3, 1), array_keys($tasks)); - - - // Move T3 to the end. - $this->moveTask($viewer, $t3, $t1, true); - $tasks = $this->loadTasks($viewer, $auto_base); - $t1 = $tasks[1]; - $t2 = $tasks[2]; - $t3 = $tasks[3]; - $this->assertEqual(array(2, 1, 3), array_keys($tasks)); - - - // Repeat the move above, there should be no overall change in order. - $this->moveTask($viewer, $t3, $t1, true); - $tasks = $this->loadTasks($viewer, $auto_base); - $t1 = $tasks[1]; - $t2 = $tasks[2]; - $t3 = $tasks[3]; - $this->assertEqual(array(2, 1, 3), array_keys($tasks)); - - - // Move T3 to the first slot in the priority. - $this->movePriority($viewer, $t3, $t3->getPriority(), false); - $tasks = $this->loadTasks($viewer, $auto_base); - $t1 = $tasks[1]; - $t2 = $tasks[2]; - $t3 = $tasks[3]; - $this->assertEqual(array(3, 2, 1), array_keys($tasks)); - - - // Move T3 to the last slot in the priority. - $this->movePriority($viewer, $t3, $t3->getPriority(), true); - $tasks = $this->loadTasks($viewer, $auto_base); - $t1 = $tasks[1]; - $t2 = $tasks[2]; - $t3 = $tasks[3]; - $this->assertEqual(array(2, 1, 3), array_keys($tasks)); - - - // Move T3 before T2. - $this->moveTask($viewer, $t3, $t2, false); - $tasks = $this->loadTasks($viewer, $auto_base); - $t1 = $tasks[1]; - $t2 = $tasks[2]; - $t3 = $tasks[3]; - $this->assertEqual(array(3, 2, 1), array_keys($tasks)); - - - // Move T3 before T1. - $this->moveTask($viewer, $t3, $t1, false); - $tasks = $this->loadTasks($viewer, $auto_base); - $t1 = $tasks[1]; - $t2 = $tasks[2]; - $t3 = $tasks[3]; - $this->assertEqual(array(2, 3, 1), array_keys($tasks)); - - } - - public function testTaskAdjacentBlocks() { - $viewer = $this->generateNewTestUser(); - - $t = array(); - for ($ii = 1; $ii < 10; $ii++) { - $t[$ii] = $this->newTask($viewer, pht('Task Block %d', $ii)); - - // This makes sure this test remains meaningful if we begin assigning - // subpriorities when tasks are created. - $t[$ii]->setSubpriority(0)->save(); - } - - $auto_base = min(mpull($t, 'getID')); - - $tasks = $this->loadTasks($viewer, $auto_base); - $this->assertEqual( - array(9, 8, 7, 6, 5, 4, 3, 2, 1), - array_keys($tasks)); - - $this->moveTask($viewer, $t[9], $t[8], true); - $tasks = $this->loadTasks($viewer, $auto_base); - $this->assertEqual( - array(8, 9, 7, 6, 5, 4, 3, 2, 1), - array_keys($tasks)); - - // When there is a large block of tasks which all have the same - // subpriority, they should be assigned distinct subpriorities as a - // side effect of having a task moved into the block. - - $subpri = mpull($tasks, 'getSubpriority'); - $unique_subpri = array_unique($subpri); - $this->assertEqual( - 9, - count($subpri), - pht('Expected subpriorities to be distributed.')); - - // Move task 9 to the end. - $this->moveTask($viewer, $t[9], $t[1], true); - $tasks = $this->loadTasks($viewer, $auto_base); - $this->assertEqual( - array(8, 7, 6, 5, 4, 3, 2, 1, 9), - array_keys($tasks)); - - // Move task 3 to the beginning. - $this->moveTask($viewer, $t[3], $t[8], false); - $tasks = $this->loadTasks($viewer, $auto_base); - $this->assertEqual( - array(3, 8, 7, 6, 5, 4, 2, 1, 9), - array_keys($tasks)); - - // Move task 3 to the end. - $this->moveTask($viewer, $t[3], $t[9], true); - $tasks = $this->loadTasks($viewer, $auto_base); - $this->assertEqual( - array(8, 7, 6, 5, 4, 2, 1, 9, 3), - array_keys($tasks)); - - // Move task 5 to before task 4 (this is its current position). - $this->moveTask($viewer, $t[5], $t[4], false); - $tasks = $this->loadTasks($viewer, $auto_base); - $this->assertEqual( - array(8, 7, 6, 5, 4, 2, 1, 9, 3), - array_keys($tasks)); - } - - private function newTask(PhabricatorUser $viewer, $title) { - $task = ManiphestTask::initializeNewTask($viewer); - - $xactions = array(); - - $xactions[] = id(new ManiphestTransaction()) - ->setTransactionType(ManiphestTaskTitleTransaction::TRANSACTIONTYPE) - ->setNewValue($title); - - - $this->applyTaskTransactions($viewer, $task, $xactions); - - return $task; - } - - private function loadTasks(PhabricatorUser $viewer, $auto_base) { - $tasks = id(new ManiphestTaskQuery()) - ->setViewer($viewer) - ->setOrder(ManiphestTaskQuery::ORDER_PRIORITY) - ->execute(); - - // NOTE: AUTO_INCREMENT changes survive ROLLBACK, and we can't throw them - // away without committing the current transaction, so we adjust the - // apparent task IDs as though the first one had been ID 1. This makes the - // tests a little easier to understand. - - $map = array(); - foreach ($tasks as $task) { - $map[($task->getID() - $auto_base) + 1] = $task; - } - - return $map; - } - - private function moveTask(PhabricatorUser $viewer, $src, $dst, $is_after) { - list($pri, $sub) = ManiphestTransactionEditor::getAdjacentSubpriority( - $dst, - $is_after); - - $keyword_map = ManiphestTaskPriority::getTaskPriorityKeywordsMap(); - $keyword = head($keyword_map[$pri]); - - $xactions = array(); - - $xactions[] = id(new ManiphestTransaction()) - ->setTransactionType(ManiphestTaskPriorityTransaction::TRANSACTIONTYPE) - ->setNewValue($keyword); - - $xactions[] = id(new ManiphestTransaction()) - ->setTransactionType(ManiphestTaskSubpriorityTransaction::TRANSACTIONTYPE) - ->setNewValue($sub); - - return $this->applyTaskTransactions($viewer, $src, $xactions); - } - - private function movePriority( - PhabricatorUser $viewer, - $src, - $target_priority, - $is_end) { - - list($pri, $sub) = ManiphestTransactionEditor::getEdgeSubpriority( - $target_priority, - $is_end); - - $keyword_map = ManiphestTaskPriority::getTaskPriorityKeywordsMap(); - $keyword = head($keyword_map[$pri]); - - $xactions = array(); - - $xactions[] = id(new ManiphestTransaction()) - ->setTransactionType(ManiphestTaskPriorityTransaction::TRANSACTIONTYPE) - ->setNewValue($keyword); - - $xactions[] = id(new ManiphestTransaction()) - ->setTransactionType(ManiphestTaskSubpriorityTransaction::TRANSACTIONTYPE) - ->setNewValue($sub); - - return $this->applyTaskTransactions($viewer, $src, $xactions); - } - - private function applyTaskTransactions( - PhabricatorUser $viewer, - ManiphestTask $task, - array $xactions) { - - $content_source = $this->newContentSource(); - - $editor = id(new ManiphestTransactionEditor()) - ->setActor($viewer) - ->setContentSource($content_source) - ->setContinueOnNoEffect(true) - ->applyTransactions($task, $xactions); - - return $task; - } - -} diff --git a/src/applications/maniphest/application/PhabricatorManiphestApplication.php b/src/applications/maniphest/application/PhabricatorManiphestApplication.php index e3e91fb8db..d74eadf3c0 100644 --- a/src/applications/maniphest/application/PhabricatorManiphestApplication.php +++ b/src/applications/maniphest/application/PhabricatorManiphestApplication.php @@ -55,7 +55,7 @@ final class PhabricatorManiphestApplication extends PhabricatorApplication { => 'ManiphestTaskEditController', 'subtask/(?P[1-9]\d*)/' => 'ManiphestTaskSubtaskController', ), - 'subpriority/' => 'ManiphestSubpriorityController', + 'graph/(?P[1-9]\d*)/' => 'ManiphestTaskGraphController', ), ); } diff --git a/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php b/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php index 8f2830908b..f1916cffec 100644 --- a/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php +++ b/src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php @@ -210,8 +210,9 @@ The keys you can provide in a specification are: - `claim` //Optional bool.// By default, closing an unassigned task claims it. You can set this to `false` to disable this behavior for a particular status. - - `locked` //Optional bool.// Lock tasks in this status, preventing users - from commenting. + - `locked` //Optional string.// Lock tasks in this status. Specify "comments" + to lock comments (users who can edit the task may override this lock). + Specify "edits" to prevent anyone except the task owner from making edits. - `mfa` //Optional bool.// Require all edits to this task to be signed with multi-factor authentication. @@ -342,6 +343,7 @@ dictionary with these keys: - `icon` //Optional string.// Icon for the subtype. - `children` //Optional map.// Configure options shown to the user when they "Create Subtask". See below. + - `fields` //Optional map.// Configure field behaviors. See below. Each subtype must have a unique key, and you must define a subtype with the key "%s", which is used as a default subtype. @@ -397,6 +399,28 @@ be used when presenting options to the user. If only one option would be presented, the user will be taken directly to the appropriate form instead of being prompted to choose a form. + +The `fields` key can configure the behavior of custom fields on specific +task subtypes. For example: + +``` +{ + ... + "fields": { + "custom.some-field": { + "disabled": true + } + } + ... +} +``` + +Each field supports these options: + + - `disabled` //Optional bool.// Allows you to disable fields on certain + subtypes. + - `name` //Optional string.// Custom name of this field for the subtype. + EOTEXT , $subtype_default_key)); diff --git a/src/applications/maniphest/constants/ManiphestTaskStatus.php b/src/applications/maniphest/constants/ManiphestTaskStatus.php index 4d58816e2a..c040befeed 100644 --- a/src/applications/maniphest/constants/ManiphestTaskStatus.php +++ b/src/applications/maniphest/constants/ManiphestTaskStatus.php @@ -16,6 +16,9 @@ final class ManiphestTaskStatus extends ManiphestConstants { const SPECIAL_CLOSED = 'closed'; const SPECIAL_DUPLICATE = 'duplicate'; + const LOCKED_COMMENTS = 'comments'; + const LOCKED_EDITS = 'edits'; + private static function getStatusConfig() { return PhabricatorEnv::getEnvConfig('maniphest.statuses'); } @@ -156,8 +159,13 @@ final class ManiphestTaskStatus extends ManiphestConstants { return !self::isOpenStatus($status); } - public static function isLockedStatus($status) { - return self::getStatusAttribute($status, 'locked', false); + public static function areCommentsLockedInStatus($status) { + return (bool)self::getStatusAttribute($status, 'locked', false); + } + + public static function areEditsLockedInStatus($status) { + $locked = self::getStatusAttribute($status, 'locked'); + return ($locked === self::LOCKED_EDITS); } public static function isMFAStatus($status) { @@ -285,11 +293,35 @@ final class ManiphestTaskStatus extends ManiphestConstants { 'keywords' => 'optional list', 'disabled' => 'optional bool', 'claim' => 'optional bool', - 'locked' => 'optional bool', + 'locked' => 'optional bool|string', 'mfa' => 'optional bool', )); } + // Supported values are "comments" or "edits". For backward compatibility, + // "true" is an alias of "comments". + + foreach ($config as $key => $value) { + $locked = idx($value, 'locked', false); + if ($locked === true || $locked === false) { + continue; + } + + if ($locked === self::LOCKED_EDITS || + $locked === self::LOCKED_COMMENTS) { + continue; + } + + throw new Exception( + pht( + 'Task status ("%s") has unrecognized value for "locked" '. + 'configuration ("%s"). Supported values are: "%s", "%s".', + $key, + $locked, + self::LOCKED_COMMENTS, + self::LOCKED_EDITS)); + } + $special_map = array(); foreach ($config as $key => $value) { $special = idx($value, 'special'); diff --git a/src/applications/maniphest/controller/ManiphestController.php b/src/applications/maniphest/controller/ManiphestController.php index a96b56adcf..fb15f122d6 100644 --- a/src/applications/maniphest/controller/ManiphestController.php +++ b/src/applications/maniphest/controller/ManiphestController.php @@ -238,28 +238,102 @@ abstract class ManiphestController extends PhabricatorController { return $crumbs; } - public function renderSingleTask(ManiphestTask $task) { - $request = $this->getRequest(); - $user = $request->getUser(); + final protected function newTaskGraphDropdownMenu( + ManiphestTask $task, + $has_parents, + $has_subtasks, + $include_standalone) { + $viewer = $this->getViewer(); - $phids = $task->getProjectPHIDs(); - if ($task->getOwnerPHID()) { - $phids[] = $task->getOwnerPHID(); + $parents_uri = urisprintf( + '/?subtaskIDs=%d#R', + $task->getID()); + $parents_uri = $this->getApplicationURI($parents_uri); + + $subtasks_uri = urisprintf( + '/?parentIDs=%d#R', + $task->getID()); + $subtasks_uri = $this->getApplicationURI($subtasks_uri); + + $dropdown_menu = id(new PhabricatorActionListView()) + ->setViewer($viewer) + ->addAction( + id(new PhabricatorActionView()) + ->setHref($parents_uri) + ->setName(pht('Search Parent Tasks')) + ->setDisabled(!$has_parents) + ->setIcon('fa-chevron-circle-up')) + ->addAction( + id(new PhabricatorActionView()) + ->setHref($subtasks_uri) + ->setName(pht('Search Subtasks')) + ->setDisabled(!$has_subtasks) + ->setIcon('fa-chevron-circle-down')); + + if ($include_standalone) { + $standalone_uri = urisprintf('/graph/%d/', $task->getID()); + $standalone_uri = $this->getApplicationURI($standalone_uri); + + $dropdown_menu->addAction( + id(new PhabricatorActionView()) + ->setHref($standalone_uri) + ->setName(pht('View Standalone Graph')) + ->setIcon('fa-code-fork')); } - $handles = id(new PhabricatorHandleQuery()) - ->setViewer($user) - ->withPHIDs($phids) - ->execute(); + $graph_menu = id(new PHUIButtonView()) + ->setTag('a') + ->setIcon('fa-search') + ->setText(pht('Search...')) + ->setDropdownMenu($dropdown_menu); - $view = id(new ManiphestTaskListView()) - ->setUser($user) - ->setShowSubpriorityControls(!$request->getStr('ungrippable')) - ->setShowBatchControls(true) - ->setHandles($handles) - ->setTasks(array($task)); - - return $view; + return $graph_menu; } + final protected function newTaskGraphOverflowView( + ManiphestTask $task, + $overflow_message, + $include_standalone) { + + $id = $task->getID(); + + if ($include_standalone) { + $standalone_uri = $this->getApplicationURI("graph/{$id}/"); + + $standalone_link = id(new PHUIButtonView()) + ->setTag('a') + ->setHref($standalone_uri) + ->setColor(PHUIButtonView::GREY) + ->setIcon('fa-code-fork') + ->setText(pht('View Standalone Graph')); + } else { + $standalone_link = null; + } + + $standalone_icon = id(new PHUIIconView()) + ->setIcon('fa-exclamation-triangle', 'yellow') + ->addClass('object-graph-header-icon'); + + $standalone_view = phutil_tag( + 'div', + array( + 'class' => 'object-graph-header', + ), + array( + $standalone_link, + $standalone_icon, + phutil_tag( + 'div', + array( + 'class' => 'object-graph-header-message', + ), + array( + $overflow_message, + )), + )); + + return $standalone_view; + } + + } diff --git a/src/applications/maniphest/controller/ManiphestReportController.php b/src/applications/maniphest/controller/ManiphestReportController.php index 77bd6c0d59..40498e6e40 100644 --- a/src/applications/maniphest/controller/ManiphestReportController.php +++ b/src/applications/maniphest/controller/ManiphestReportController.php @@ -13,10 +13,19 @@ final class ManiphestReportController extends ManiphestController { $project = head($request->getArr('set_project')); $project = nonempty($project, null); - $uri = $uri->alter('project', $project); + + if ($project !== null) { + $uri->replaceQueryParam('project', $project); + } else { + $uri->removeQueryParam('project'); + } $window = $request->getStr('set_window'); - $uri = $uri->alter('window', $window); + if ($window !== null) { + $uri->replaceQueryParam('window', $window); + } else { + $uri->removeQueryParam('window'); + } return id(new AphrontRedirectResponse())->setURI($uri); } diff --git a/src/applications/maniphest/controller/ManiphestSubpriorityController.php b/src/applications/maniphest/controller/ManiphestSubpriorityController.php deleted file mode 100644 index 8869b6a327..0000000000 --- a/src/applications/maniphest/controller/ManiphestSubpriorityController.php +++ /dev/null @@ -1,70 +0,0 @@ -getViewer(); - - if (!$request->validateCSRF()) { - return new Aphront403Response(); - } - - $task = id(new ManiphestTaskQuery()) - ->setViewer($viewer) - ->withIDs(array($request->getInt('task'))) - ->needProjectPHIDs(true) - ->requireCapabilities( - array( - PhabricatorPolicyCapability::CAN_VIEW, - PhabricatorPolicyCapability::CAN_EDIT, - )) - ->executeOne(); - if (!$task) { - return new Aphront404Response(); - } - - if ($request->getInt('after')) { - $after_task = id(new ManiphestTaskQuery()) - ->setViewer($viewer) - ->withIDs(array($request->getInt('after'))) - ->executeOne(); - if (!$after_task) { - return new Aphront404Response(); - } - list($pri, $sub) = ManiphestTransactionEditor::getAdjacentSubpriority( - $after_task, - $is_after = true); - } else { - list($pri, $sub) = ManiphestTransactionEditor::getEdgeSubpriority( - $request->getInt('priority'), - $is_end = false); - } - - $keyword_map = ManiphestTaskPriority::getTaskPriorityKeywordsMap(); - $keyword = head(idx($keyword_map, $pri)); - - $xactions = array(); - - $xactions[] = id(new ManiphestTransaction()) - ->setTransactionType(ManiphestTaskPriorityTransaction::TRANSACTIONTYPE) - ->setNewValue($keyword); - - $xactions[] = id(new ManiphestTransaction()) - ->setTransactionType(ManiphestTaskSubpriorityTransaction::TRANSACTIONTYPE) - ->setNewValue($sub); - - $editor = id(new ManiphestTransactionEditor()) - ->setActor($viewer) - ->setContinueOnMissingFields(true) - ->setContinueOnNoEffect(true) - ->setContentSourceFromRequest($request); - - $editor->applyTransactions($task, $xactions); - - return id(new AphrontAjaxResponse())->setContent( - array( - 'tasks' => $this->renderSingleTask($task), - )); - } - -} diff --git a/src/applications/maniphest/controller/ManiphestTaskDetailController.php b/src/applications/maniphest/controller/ManiphestTaskDetailController.php index 0f96d76b91..c5dba7d3b5 100644 --- a/src/applications/maniphest/controller/ManiphestTaskDetailController.php +++ b/src/applications/maniphest/controller/ManiphestTaskDetailController.php @@ -80,7 +80,8 @@ final class ManiphestTaskDetailController extends ManiphestController { $related_tabs = array(); $graph_menu = null; - $graph_limit = 100; + $graph_limit = 200; + $overflow_message = null; $task_graph = id(new ManiphestTaskGraph()) ->setViewer($viewer) ->setSeedPHID($task->getPHID()) @@ -96,61 +97,55 @@ final class ManiphestTaskDetailController extends ManiphestController { $has_parents = (bool)$parent_list; $has_subtasks = (bool)$subtask_list; - $search_text = pht('Search...'); - // First, get a count of direct parent tasks and subtasks. If there // are too many of these, we just don't draw anything. You can use // the search button to browse tasks with the search UI instead. $direct_count = count($parent_list) + count($subtask_list); if ($direct_count > $graph_limit) { - $message = pht( - 'Task graph too large to display (this task is directly connected '. - 'to more than %s other tasks). Use %s to explore connected tasks.', - $graph_limit, - phutil_tag('strong', array(), $search_text)); - $message = phutil_tag('em', array(), $message); - $graph_table = id(new PHUIPropertyListView()) - ->addTextContent($message); + $overflow_message = pht( + 'This task is directly connected to more than %s other tasks. '. + 'Use %s to browse parents or subtasks, or %s to show more of the '. + 'graph.', + new PhutilNumber($graph_limit), + phutil_tag('strong', array(), pht('Search...')), + phutil_tag('strong', array(), pht('View Standalone Graph'))); + + $graph_table = null; } else { // If there aren't too many direct tasks, but there are too many total // tasks, we'll only render directly connected tasks. if ($task_graph->isOverLimit()) { $task_graph->setRenderOnlyAdjacentNodes(true); + + $overflow_message = pht( + 'This task is connected to more than %s other tasks. '. + 'Only direct parents and subtasks are shown here. Use '. + '%s to show more of the graph.', + new PhutilNumber($graph_limit), + phutil_tag('strong', array(), pht('View Standalone Graph'))); } + $graph_table = $task_graph->newGraphTable(); } - $parents_uri = urisprintf( - '/?subtaskIDs=%d#R', - $task->getID()); - $parents_uri = $this->getApplicationURI($parents_uri); + if ($overflow_message) { + $overflow_view = $this->newTaskGraphOverflowView( + $task, + $overflow_message, + true); - $subtasks_uri = urisprintf( - '/?parentIDs=%d#R', - $task->getID()); - $subtasks_uri = $this->getApplicationURI($subtasks_uri); + $graph_table = array( + $overflow_view, + $graph_table, + ); + } - $dropdown_menu = id(new PhabricatorActionListView()) - ->setViewer($viewer) - ->addAction( - id(new PhabricatorActionView()) - ->setHref($parents_uri) - ->setName(pht('Search Parent Tasks')) - ->setDisabled(!$has_parents) - ->setIcon('fa-chevron-circle-up')) - ->addAction( - id(new PhabricatorActionView()) - ->setHref($subtasks_uri) - ->setName(pht('Search Subtasks')) - ->setDisabled(!$has_subtasks) - ->setIcon('fa-chevron-circle-down')); - - $graph_menu = id(new PHUIButtonView()) - ->setTag('a') - ->setIcon('fa-search') - ->setText($search_text) - ->setDropdownMenu($dropdown_menu); + $graph_menu = $this->newTaskGraphDropdownMenu( + $task, + $has_parents, + $has_subtasks, + true); $related_tabs[] = id(new PHUITabView()) ->setName(pht('Task Graph')) @@ -300,9 +295,9 @@ final class ManiphestTaskDetailController extends ManiphestController { $subtask_form = head($subtask_options); $form_key = $subtask_form->getIdentifier(); $subtask_uri = id(new PhutilURI("/task/edit/form/{$form_key}/")) - ->setQueryParam('parent', $id) - ->setQueryParam('template', $id) - ->setQueryParam('status', ManiphestTaskStatus::getDefaultStatus()); + ->replaceQueryParam('parent', $id) + ->replaceQueryParam('template', $id) + ->replaceQueryParam('status', ManiphestTaskStatus::getDefaultStatus()); $subtask_workflow = false; } diff --git a/src/applications/maniphest/controller/ManiphestTaskEditController.php b/src/applications/maniphest/controller/ManiphestTaskEditController.php index 9483529138..341997e325 100644 --- a/src/applications/maniphest/controller/ManiphestTaskEditController.php +++ b/src/applications/maniphest/controller/ManiphestTaskEditController.php @@ -5,7 +5,6 @@ final class ManiphestTaskEditController extends ManiphestController { public function handleRequest(AphrontRequest $request) { return id(new ManiphestEditEngine()) ->setController($this) - ->addContextParameter('ungrippable') ->addContextParameter('responseType') ->addContextParameter('columnPHID') ->addContextParameter('order') diff --git a/src/applications/maniphest/controller/ManiphestTaskGraphController.php b/src/applications/maniphest/controller/ManiphestTaskGraphController.php new file mode 100644 index 0000000000..f4655d1835 --- /dev/null +++ b/src/applications/maniphest/controller/ManiphestTaskGraphController.php @@ -0,0 +1,126 @@ +getViewer(); + $id = $request->getURIData('id'); + + $task = id(new ManiphestTaskQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->executeOne(); + if (!$task) { + return new Aphront404Response(); + } + + $crumbs = $this->buildApplicationCrumbs() + ->addTextCrumb($task->getMonogram(), $task->getURI()) + ->addTextCrumb(pht('Graph')) + ->setBorder(true); + + $graph_limit = 2000; + $overflow_message = null; + $task_graph = id(new ManiphestTaskGraph()) + ->setViewer($viewer) + ->setSeedPHID($task->getPHID()) + ->setLimit($graph_limit) + ->setIsStandalone(true) + ->loadGraph(); + if (!$task_graph->isEmpty()) { + $parent_type = ManiphestTaskDependedOnByTaskEdgeType::EDGECONST; + $subtask_type = ManiphestTaskDependsOnTaskEdgeType::EDGECONST; + $parent_map = $task_graph->getEdges($parent_type); + $subtask_map = $task_graph->getEdges($subtask_type); + $parent_list = idx($parent_map, $task->getPHID(), array()); + $subtask_list = idx($subtask_map, $task->getPHID(), array()); + $has_parents = (bool)$parent_list; + $has_subtasks = (bool)$subtask_list; + + // First, get a count of direct parent tasks and subtasks. If there + // are too many of these, we just don't draw anything. You can use + // the search button to browse tasks with the search UI instead. + $direct_count = count($parent_list) + count($subtask_list); + + if ($direct_count > $graph_limit) { + $overflow_message = pht( + 'This task is directly connected to more than %s other tasks, '. + 'which is too many tasks to display. Use %s to browse parents '. + 'or subtasks.', + new PhutilNumber($graph_limit), + phutil_tag('strong', array(), pht('Search...'))); + + $graph_table = null; + } else { + // If there aren't too many direct tasks, but there are too many total + // tasks, we'll only render directly connected tasks. + if ($task_graph->isOverLimit()) { + $task_graph->setRenderOnlyAdjacentNodes(true); + + $overflow_message = pht( + 'This task is connected to more than %s other tasks. '. + 'Only direct parents and subtasks are shown here.', + new PhutilNumber($graph_limit)); + } + + $graph_table = $task_graph->newGraphTable(); + } + + $graph_menu = $this->newTaskGraphDropdownMenu( + $task, + $has_parents, + $has_subtasks, + false); + } else { + $graph_menu = null; + $graph_table = null; + + $overflow_message = pht( + 'This task has no parent tasks and no subtasks, so there is no '. + 'graph to draw.'); + } + + if ($overflow_message) { + $overflow_view = $this->newTaskGraphOverflowView( + $task, + $overflow_message, + false); + + $graph_table = array( + $overflow_view, + $graph_table, + ); + } + + $header = id(new PHUIHeaderView()) + ->setHeader(pht('Task Graph')); + + if ($graph_menu) { + $header->addActionLink($graph_menu); + } + + $tab_view = id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->appendChild($graph_table); + + $view = id(new PHUITwoColumnView()) + ->setFooter($tab_view); + + return $this->newPage() + ->setTitle( + array( + $task->getMonogram(), + pht('Graph'), + )) + ->setCrumbs($crumbs) + ->appendChild($view); + } + + +} diff --git a/src/applications/maniphest/controller/ManiphestTaskSubtaskController.php b/src/applications/maniphest/controller/ManiphestTaskSubtaskController.php index 5256c1bd26..3105cf661d 100644 --- a/src/applications/maniphest/controller/ManiphestTaskSubtaskController.php +++ b/src/applications/maniphest/controller/ManiphestTaskSubtaskController.php @@ -47,9 +47,9 @@ final class ManiphestTaskSubtaskController $subtype = $subtype_map->getSubtype($subtype_key); $subtask_uri = id(new PhutilURI("/task/edit/form/{$form_key}/")) - ->setQueryParam('parent', $id) - ->setQueryParam('template', $id) - ->setQueryParam('status', ManiphestTaskStatus::getDefaultStatus()); + ->replaceQueryParam('parent', $id) + ->replaceQueryParam('template', $id) + ->replaceQueryParam('status', ManiphestTaskStatus::getDefaultStatus()); $subtask_uri = $this->getApplicationURI($subtask_uri); $item = id(new PHUIObjectItemView()) diff --git a/src/applications/maniphest/editor/ManiphestEditEngine.php b/src/applications/maniphest/editor/ManiphestEditEngine.php index 89b8d44fc8..426ac89b04 100644 --- a/src/applications/maniphest/editor/ManiphestEditEngine.php +++ b/src/applications/maniphest/editor/ManiphestEditEngine.php @@ -123,22 +123,23 @@ information about the move, including an optional specific position within the column. The target column should be identified as `columnPHID`, and you may select a -position by passing either `beforePHID` or `afterPHID`, specifying the PHID of -a task currently in the column that you want to move this task before or after: +position by passing either `beforePHIDs` or `afterPHIDs`, specifying the PHIDs +of tasks currently in the column that you want to move this task before or +after: ```lang=json [ { "columnPHID": "PHID-PCOL-4444", - "beforePHID": "PHID-TASK-5555" + "beforePHIDs": ["PHID-TASK-5555"] } ] ``` -Note that this affects only the "natural" position of the task. The task -position when the board is sorted by some other attribute (like priority) -depends on that attribute value: change a task's priority to move it on -priority-sorted boards. +When you specify multiple PHIDs, the task will be moved adjacent to the first +valid PHID found in either of the lists. This allows positional moves to +generally work as users expect even if the client view of the board has fallen +out of date and some of the nearby tasks have moved elsewhere. EODOCS ); @@ -382,7 +383,10 @@ EODOCS $object, array $xactions) { - if ($request->isAjax()) { + $response_type = $request->getStr('responseType'); + $is_card = ($response_type === 'card'); + + if ($is_card) { // Reload the task to make sure we pick up the final task state. $viewer = $this->getViewer(); $task = id(new ManiphestTaskQuery()) @@ -392,29 +396,12 @@ EODOCS ->needProjectPHIDs(true) ->executeOne(); - switch ($request->getStr('responseType')) { - case 'card': - return $this->buildCardResponse($task); - default: - return $this->buildListResponse($task); - } - + return $this->buildCardResponse($task); } return parent::newEditResponse($request, $object, $xactions); } - private function buildListResponse(ManiphestTask $task) { - $controller = $this->getController(); - - $payload = array( - 'tasks' => $controller->renderSingleTask($task), - 'data' => array(), - ); - - return id(new AphrontAjaxResponse())->setContent($payload); - } - private function buildCardResponse(ManiphestTask $task) { $controller = $this->getController(); $request = $controller->getRequest(); @@ -438,12 +425,26 @@ EODOCS $board_phid = $column->getProjectPHID(); $object_phid = $task->getPHID(); - return id(new PhabricatorBoardResponseEngine()) + $order = $request->getStr('order'); + if ($order) { + $ordering = PhabricatorProjectColumnOrder::getOrderByKey($order); + $ordering = id(clone $ordering) + ->setViewer($viewer); + } else { + $ordering = null; + } + + $engine = id(new PhabricatorBoardResponseEngine()) ->setViewer($viewer) ->setBoardPHID($board_phid) ->setObjectPHID($object_phid) - ->setVisiblePHIDs($visible_phids) - ->buildResponse(); + ->setVisiblePHIDs($visible_phids); + + if ($ordering) { + $engine->setOrdering($ordering); + } + + return $engine->buildResponse(); } private function getColumnMap(ManiphestTask $task) { diff --git a/src/applications/maniphest/editor/ManiphestTransactionEditor.php b/src/applications/maniphest/editor/ManiphestTransactionEditor.php index 0722e0e27a..fd5bfe0cdc 100644 --- a/src/applications/maniphest/editor/ManiphestTransactionEditor.php +++ b/src/applications/maniphest/editor/ManiphestTransactionEditor.php @@ -134,10 +134,7 @@ final class ManiphestTransactionEditor $parent_xaction->setMetadataValue('blocker.new', true); } - id(new ManiphestTransactionEditor()) - ->setActor($this->getActor()) - ->setActingAsPHID($this->getActingAsPHID()) - ->setContentSource($this->getContentSource()) + $this->newSubEditor() ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) ->applyTransactions($blocked_task, array($parent_xaction)); @@ -246,8 +243,7 @@ final class ManiphestTransactionEditor foreach ($projects as $project) { $body->addLinkSection( pht('WORKBOARD'), - PhabricatorEnv::getProductionURI( - '/project/board/'.$project->getID().'/')); + PhabricatorEnv::getProductionURI($project->getWorkboardURI())); } } @@ -297,251 +293,6 @@ final class ManiphestTransactionEditor return $copy; } - /** - * Get priorities for moving a task to a new priority. - */ - public static function getEdgeSubpriority( - $priority, - $is_end) { - - $query = id(new ManiphestTaskQuery()) - ->setViewer(PhabricatorUser::getOmnipotentUser()) - ->withPriorities(array($priority)) - ->setLimit(1); - - if ($is_end) { - $query->setOrderVector(array('-priority', '-subpriority', '-id')); - } else { - $query->setOrderVector(array('priority', 'subpriority', 'id')); - } - - $result = $query->executeOne(); - $step = (double)(2 << 32); - - if ($result) { - $base = $result->getSubpriority(); - if ($is_end) { - $sub = ($base - $step); - } else { - $sub = ($base + $step); - } - } else { - $sub = 0; - } - - return array($priority, $sub); - } - - - /** - * Get priorities for moving a task before or after another task. - */ - public static function getAdjacentSubpriority( - ManiphestTask $dst, - $is_after) { - - $query = id(new ManiphestTaskQuery()) - ->setViewer(PhabricatorUser::getOmnipotentUser()) - ->setOrder(ManiphestTaskQuery::ORDER_PRIORITY) - ->withPriorities(array($dst->getPriority())) - ->setLimit(1); - - if ($is_after) { - $query->setAfterID($dst->getID()); - } else { - $query->setBeforeID($dst->getID()); - } - - $adjacent = $query->executeOne(); - - $base = $dst->getSubpriority(); - $step = (double)(2 << 32); - - // If we find an adjacent task, we average the two subpriorities and - // return the result. - if ($adjacent) { - $epsilon = 1.0; - - // If the adjacent task has a subpriority that is identical or very - // close to the task we're looking at, we're going to spread out all - // the nearby tasks. - - $adjacent_sub = $adjacent->getSubpriority(); - if ((abs($adjacent_sub - $base) < $epsilon)) { - $base = self::disperseBlock( - $dst, - $epsilon * 2); - if ($is_after) { - $sub = $base - $epsilon; - } else { - $sub = $base + $epsilon; - } - } else { - $sub = ($adjacent_sub + $base) / 2; - } - } else { - // Otherwise, we take a step away from the target's subpriority and - // use that. - if ($is_after) { - $sub = ($base - $step); - } else { - $sub = ($base + $step); - } - } - - return array($dst->getPriority(), $sub); - } - - /** - * Distribute a cluster of tasks with similar subpriorities. - */ - private static function disperseBlock( - ManiphestTask $task, - $spacing) { - - $conn = $task->establishConnection('w'); - - // Find a block of subpriority space which is, on average, sparse enough - // to hold all the tasks that are inside it with a reasonable level of - // separation between them. - - // We'll start by looking near the target task for a range of numbers - // which has more space available than tasks. For example, if the target - // task has subpriority 33 and we want to separate each task by at least 1, - // we might start by looking in the range [23, 43]. - - // If we find fewer than 20 tasks there, we have room to reassign them - // with the desired level of separation. We space them out, then we're - // done. - - // However: if we find more than 20 tasks, we don't have enough room to - // distribute them. We'll widen our search and look in a bigger range, - // maybe [13, 53]. This range has more space, so if we find fewer than - // 40 tasks in this range we can spread them out. If we still find too - // many tasks, we keep widening the search. - - $base = $task->getSubpriority(); - - $scale = 4.0; - while (true) { - $range = ($spacing * $scale) / 2.0; - $min = ($base - $range); - $max = ($base + $range); - - $result = queryfx_one( - $conn, - 'SELECT COUNT(*) N FROM %T WHERE priority = %d AND - subpriority BETWEEN %f AND %f', - $task->getTableName(), - $task->getPriority(), - $min, - $max); - - $count = $result['N']; - if ($count < $scale) { - // We have found a block which we can make sparse enough, so bail and - // continue below with our selection. - break; - } - - // This block had too many tasks for its size, so try again with a - // bigger block. - $scale *= 2.0; - } - - $rows = queryfx_all( - $conn, - 'SELECT id FROM %T WHERE priority = %d AND - subpriority BETWEEN %f AND %f - ORDER BY priority, subpriority, id', - $task->getTableName(), - $task->getPriority(), - $min, - $max); - - $task_id = $task->getID(); - $result = null; - - // NOTE: In strict mode (which we encourage enabling) we can't structure - // this bulk update as an "INSERT ... ON DUPLICATE KEY UPDATE" unless we - // provide default values for ALL of the columns that don't have defaults. - - // This is gross, but we may be moving enough rows that individual - // queries are unreasonably slow. An alternate construction which might - // be worth evaluating is to use "CASE". Another approach is to disable - // strict mode for this query. - - $default_str = qsprintf($conn, '%s', ''); - $default_int = qsprintf($conn, '%d', 0); - - $extra_columns = array( - 'phid' => $default_str, - 'authorPHID' => $default_str, - 'status' => $default_str, - 'priority' => $default_int, - 'title' => $default_str, - 'description' => $default_str, - 'dateCreated' => $default_int, - 'dateModified' => $default_int, - 'mailKey' => $default_str, - 'viewPolicy' => $default_str, - 'editPolicy' => $default_str, - 'ownerOrdering' => $default_str, - 'spacePHID' => $default_str, - 'bridgedObjectPHID' => $default_str, - 'properties' => $default_str, - 'points' => $default_int, - 'subtype' => $default_str, - ); - - $sql = array(); - $offset = 0; - - // Often, we'll have more room than we need in the range. Distribute the - // tasks evenly over the whole range so that we're less likely to end up - // with tasks spaced exactly the minimum distance apart, which may - // get shifted again later. We have one fewer space to distribute than we - // have tasks. - $divisor = (double)(count($rows) - 1.0); - if ($divisor > 0) { - $available_distance = (($max - $min) / $divisor); - } else { - $available_distance = 0.0; - } - - foreach ($rows as $row) { - $subpriority = $min + ($offset * $available_distance); - - // If this is the task that we're spreading out relative to, keep track - // of where it is ending up so we can return the new subpriority. - $id = $row['id']; - if ($id == $task_id) { - $result = $subpriority; - } - - $sql[] = qsprintf( - $conn, - '(%d, %LQ, %f)', - $id, - $extra_columns, - $subpriority); - - $offset++; - } - - foreach (PhabricatorLiskDAO::chunkSQL($sql) as $chunk) { - queryfx( - $conn, - 'INSERT INTO %T (id, %LC, subpriority) VALUES %LQ - ON DUPLICATE KEY UPDATE subpriority = VALUES(subpriority)', - $task->getTableName(), - array_keys($extra_columns), - $chunk); - } - - return $result; - } - protected function validateAllTransactions( PhabricatorLiskDAO $object, array $xactions) { @@ -552,6 +303,10 @@ final class ManiphestTransactionEditor $errors = array_merge($errors, $this->moreValidationErrors); } + foreach ($this->getLockValidationErrors($object, $xactions) as $error) { + $errors[] = $error; + } + return $errors; } @@ -672,6 +427,7 @@ final class ManiphestTransactionEditor private function buildMoveTransaction( PhabricatorLiskDAO $object, PhabricatorApplicationTransaction $xaction) { + $actor = $this->getActor(); $new = $xaction->getNewValue(); if (!is_array($new)) { @@ -679,7 +435,7 @@ final class ManiphestTransactionEditor $new = array($new); } - $nearby_phids = array(); + $relative_phids = array(); foreach ($new as $key => $value) { if (!is_array($value)) { $this->validateColumnPHID($value); @@ -692,35 +448,83 @@ final class ManiphestTransactionEditor $value, array( 'columnPHID' => 'string', + 'beforePHIDs' => 'optional list', + 'afterPHIDs' => 'optional list', + + // Deprecated older variations of "beforePHIDs" and "afterPHIDs". 'beforePHID' => 'optional string', 'afterPHID' => 'optional string', )); - $new[$key] = $value; - - if (!empty($value['beforePHID'])) { - $nearby_phids[] = $value['beforePHID']; - } + $value = $value + array( + 'beforePHIDs' => array(), + 'afterPHIDs' => array(), + ); + // Normalize the legacy keys "beforePHID" and "afterPHID" keys to the + // modern format. if (!empty($value['afterPHID'])) { - $nearby_phids[] = $value['afterPHID']; + if ($value['afterPHIDs']) { + throw new Exception( + pht( + 'Transaction specifies both "afterPHID" and "afterPHIDs". '. + 'Specify only "afterPHIDs".')); + } + $value['afterPHIDs'] = array($value['afterPHID']); + unset($value['afterPHID']); } + + if (isset($value['beforePHID'])) { + if ($value['beforePHIDs']) { + throw new Exception( + pht( + 'Transaction specifies both "beforePHID" and "beforePHIDs". '. + 'Specify only "beforePHIDs".')); + } + $value['beforePHIDs'] = array($value['beforePHID']); + unset($value['beforePHID']); + } + + foreach ($value['beforePHIDs'] as $phid) { + $relative_phids[] = $phid; + } + + foreach ($value['afterPHIDs'] as $phid) { + $relative_phids[] = $phid; + } + + $new[$key] = $value; } - if ($nearby_phids) { - $nearby_objects = id(new PhabricatorObjectQuery()) - ->setViewer($this->getActor()) - ->withPHIDs($nearby_phids) + // We require that objects you specify in "beforePHIDs" or "afterPHIDs" + // are real objects which exist and which you have permission to view. + // If you provide other objects, we remove them from the specification. + + if ($relative_phids) { + $objects = id(new PhabricatorObjectQuery()) + ->setViewer($actor) + ->withPHIDs($relative_phids) ->execute(); - $nearby_objects = mpull($nearby_objects, null, 'getPHID'); + $objects = mpull($objects, null, 'getPHID'); } else { - $nearby_objects = array(); + $objects = array(); + } + + foreach ($new as $key => $value) { + $value['afterPHIDs'] = $this->filterValidPHIDs( + $value['afterPHIDs'], + $objects); + $value['beforePHIDs'] = $this->filterValidPHIDs( + $value['beforePHIDs'], + $objects); + + $new[$key] = $value; } $column_phids = ipull($new, 'columnPHID'); if ($column_phids) { $columns = id(new PhabricatorProjectColumnQuery()) - ->setViewer($this->getActor()) + ->setViewer($actor) ->withPHIDs($column_phids) ->execute(); $columns = mpull($columns, null, 'getPHID'); @@ -731,10 +535,9 @@ final class ManiphestTransactionEditor $board_phids = mpull($columns, 'getProjectPHID'); $object_phid = $object->getPHID(); - $object_phids = $nearby_phids; - // Note that we may not have an object PHID if we're creating a new // object. + $object_phids = array(); if ($object_phid) { $object_phids[] = $object_phid; } @@ -761,49 +564,6 @@ final class ManiphestTransactionEditor $board_phid = $column->getProjectPHID(); - $nearby = array(); - - if (!empty($spec['beforePHID'])) { - $nearby['beforePHID'] = $spec['beforePHID']; - } - - if (!empty($spec['afterPHID'])) { - $nearby['afterPHID'] = $spec['afterPHID']; - } - - if (count($nearby) > 1) { - throw new Exception( - pht( - 'Column move transaction moves object to multiple positions. '. - 'Specify only "beforePHID" or "afterPHID", not both.')); - } - - foreach ($nearby as $where => $nearby_phid) { - if (empty($nearby_objects[$nearby_phid])) { - throw new Exception( - pht( - 'Column move transaction specifies object "%s" as "%s", but '. - 'there is no corresponding object with this PHID.', - $object_phid, - $where)); - } - - $nearby_columns = $layout_engine->getObjectColumns( - $board_phid, - $nearby_phid); - $nearby_columns = mpull($nearby_columns, null, 'getPHID'); - - if (empty($nearby_columns[$column_phid])) { - throw new Exception( - pht( - 'Column move transaction specifies object "%s" as "%s" in '. - 'column "%s", but this object is not in that column!', - $nearby_phid, - $where, - $column_phid)); - } - } - if ($object_phid) { $old_columns = $layout_engine->getObjectColumns( $board_phid, @@ -822,8 +582,8 @@ final class ManiphestTransactionEditor // We can just drop this column change if it has no effect. $from_map = array_fuse($spec['fromColumnPHIDs']); $already_here = isset($from_map[$column_phid]); - $is_reordering = (bool)$nearby; + $is_reordering = ($spec['afterPHIDs'] || $spec['beforePHIDs']); if ($already_here && !$is_reordering) { unset($new[$key]); } else { @@ -921,8 +681,9 @@ final class ManiphestTransactionEditor private function applyBoardMove($object, array $move) { $board_phid = $move['boardPHID']; $column_phid = $move['columnPHID']; - $before_phid = idx($move, 'beforePHID'); - $after_phid = idx($move, 'afterPHID'); + + $before_phids = $move['beforePHIDs']; + $after_phids = $move['afterPHIDs']; $object_phid = $object->getPHID(); @@ -974,24 +735,12 @@ final class ManiphestTransactionEditor $object_phid); } - if ($before_phid) { - $engine->queueAddPositionBefore( - $board_phid, - $column_phid, - $object_phid, - $before_phid); - } else if ($after_phid) { - $engine->queueAddPositionAfter( - $board_phid, - $column_phid, - $object_phid, - $after_phid); - } else { - $engine->queueAddPosition( - $board_phid, - $column_phid, - $object_phid); - } + $engine->queueAddPosition( + $board_phid, + $column_phid, + $object_phid, + $after_phids, + $before_phids); $engine->applyPositionUpdates(); } @@ -1011,5 +760,98 @@ final class ManiphestTransactionEditor } + private function getLockValidationErrors($object, array $xactions) { + $errors = array(); + + $old_owner = $object->getOwnerPHID(); + $old_status = $object->getStatus(); + + $new_owner = $old_owner; + $new_status = $old_status; + + $owner_xaction = null; + $status_xaction = null; + + foreach ($xactions as $xaction) { + switch ($xaction->getTransactionType()) { + case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE: + $new_owner = $xaction->getNewValue(); + $owner_xaction = $xaction; + break; + case ManiphestTaskStatusTransaction::TRANSACTIONTYPE: + $new_status = $xaction->getNewValue(); + $status_xaction = $xaction; + break; + } + } + + $actor_phid = $this->getActingAsPHID(); + + $was_locked = ManiphestTaskStatus::areEditsLockedInStatus( + $old_status); + $now_locked = ManiphestTaskStatus::areEditsLockedInStatus( + $new_status); + + if (!$now_locked) { + // If we're not ending in an edit-locked status, everything is good. + } else if ($new_owner !== null) { + // If we ending the edit with some valid owner, this is allowed for + // now. We might need to revisit this. + } else { + // The edits end with the task locked and unowned. No one will be able + // to edit it, so we forbid this. We try to be specific about what the + // user did wrong. + + $owner_changed = ($old_owner && !$new_owner); + $status_changed = ($was_locked !== $now_locked); + $message = null; + + if ($status_changed && $owner_changed) { + $message = pht( + 'You can not lock this task and unassign it at the same time '. + 'because no one will be able to edit it anymore. Lock the task '. + 'or remove the owner, but not both.'); + $problem_xaction = $status_xaction; + } else if ($status_changed) { + $message = pht( + 'You can not lock this task because it does not have an owner. '. + 'No one would be able to edit the task. Assign the task to an '. + 'owner before locking it.'); + $problem_xaction = $status_xaction; + } else if ($owner_changed) { + $message = pht( + 'You can not remove the owner of this task because it is locked '. + 'and no one would be able to edit the task. Reassign the task or '. + 'unlock it before removing the owner.'); + $problem_xaction = $owner_xaction; + } else { + // If the task was already broken, we don't have a transaction to + // complain about so just let it through. In theory, this is + // impossible since policy rules should kick in before we get here. + } + + if ($message) { + $errors[] = new PhabricatorApplicationTransactionValidationError( + $problem_xaction->getTransactionType(), + pht('Lock Error'), + $message, + $problem_xaction); + } + } + + return $errors; + } + + private function filterValidPHIDs($phid_list, array $object_map) { + foreach ($phid_list as $key => $phid) { + if (isset($object_map[$phid])) { + continue; + } + + unset($phid_list[$key]); + } + + return array_values($phid_list); + } } diff --git a/src/applications/maniphest/engine/ManiphestTaskUnlockEngine.php b/src/applications/maniphest/engine/ManiphestTaskUnlockEngine.php new file mode 100644 index 0000000000..b223724a77 --- /dev/null +++ b/src/applications/maniphest/engine/ManiphestTaskUnlockEngine.php @@ -0,0 +1,14 @@ +newTransaction($object) + ->setTransactionType(ManiphestTaskOwnerTransaction::TRANSACTIONTYPE) + ->setNewValue($user->getPHID()), + ); + } + +} diff --git a/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php b/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php index 2b2ef3c93d..7474f9cfaf 100644 --- a/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php +++ b/src/applications/maniphest/engineextension/ManiphestHovercardEngineExtension.php @@ -47,8 +47,7 @@ final class ManiphestHovercardEngineExtension $card = id(new ProjectBoardTaskCard()) ->setViewer($viewer) - ->setTask($task) - ->setCanEdit(false); + ->setTask($task); $owner_phid = $task->getOwnerPHID(); if ($owner_phid) { diff --git a/src/applications/maniphest/lipsum/PhabricatorManiphestTaskTestDataGenerator.php b/src/applications/maniphest/lipsum/PhabricatorManiphestTaskTestDataGenerator.php index 3fc1957b4f..ef0644ddd2 100644 --- a/src/applications/maniphest/lipsum/PhabricatorManiphestTaskTestDataGenerator.php +++ b/src/applications/maniphest/lipsum/PhabricatorManiphestTaskTestDataGenerator.php @@ -14,7 +14,6 @@ final class PhabricatorManiphestTaskTestDataGenerator $author = id(new PhabricatorUser()) ->loadOneWhere('phid = %s', $author_phid); $task = ManiphestTask::initializeNewTask($author) - ->setSubPriority($this->generateTaskSubPriority()) ->setTitle($this->generateTitle()); $content_source = $this->getLipsumContentSource(); @@ -106,10 +105,6 @@ final class PhabricatorManiphestTaskTestDataGenerator return $keyword; } - public function generateTaskSubPriority() { - return rand(2 << 16, 2 << 32); - } - public function generateTaskStatus() { $statuses = array_keys(ManiphestTaskStatus::getTaskStatusMap()); // Make sure 4/5th of all generated Tasks are open diff --git a/src/applications/maniphest/policy/ManiphestTaskPolicyCodex.php b/src/applications/maniphest/policy/ManiphestTaskPolicyCodex.php new file mode 100644 index 0000000000..638d9bfa60 --- /dev/null +++ b/src/applications/maniphest/policy/ManiphestTaskPolicyCodex.php @@ -0,0 +1,70 @@ +getObject(); + + if ($object->areEditsLocked()) { + return pht('Edits Locked'); + } + + return null; + } + + public function getPolicyIcon() { + $object = $this->getObject(); + + if ($object->areEditsLocked()) { + return 'fa-lock'; + } + + return null; + } + + public function getPolicyTagClasses() { + $object = $this->getObject(); + $classes = array(); + + if ($object->areEditsLocked()) { + $classes[] = 'policy-adjusted-locked'; + } + + return $classes; + } + + public function getPolicySpecialRuleDescriptions() { + $object = $this->getObject(); + + $rules = array(); + + $rules[] = $this->newRule() + ->setCapabilities( + array( + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->setIsActive($object->areEditsLocked()) + ->setDescription( + pht( + 'Tasks with edits locked may only be edited by their owner.')); + + return $rules; + } + + public function getPolicyForEdit($capability) { + + // When a task has its edits locked, the effective edit policy is locked + // to "No One". However, the task owner may still bypass the lock and edit + // the task. When they do, we want the control in the UI to have the + // correct value. Return the real value stored on the object. + + switch ($capability) { + case PhabricatorPolicyCapability::CAN_EDIT: + return $this->getObject()->getEditPolicy(); + } + + return parent::getPolicyForEdit($capability); + } + +} diff --git a/src/applications/maniphest/query/ManiphestTaskQuery.php b/src/applications/maniphest/query/ManiphestTaskQuery.php index ac1c7380ec..012fc9bc0a 100644 --- a/src/applications/maniphest/query/ManiphestTaskQuery.php +++ b/src/applications/maniphest/query/ManiphestTaskQuery.php @@ -27,6 +27,7 @@ final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery { private $closedEpochMax; private $closerPHIDs; private $columnPHIDs; + private $specificGroupByProjectPHID; private $status = 'status-any'; const STATUS_ANY = 'status-any'; @@ -227,6 +228,11 @@ final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery { return $this; } + public function withSpecificGroupByProjectPHID($project_phid) { + $this->specificGroupByProjectPHID = $project_phid; + return $this; + } + public function newResultObject() { return new ManiphestTask(); } @@ -467,13 +473,6 @@ final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery { $this->priorities); } - if ($this->subpriorities !== null) { - $where[] = qsprintf( - $conn, - 'task.subpriority IN (%Lf)', - $this->subpriorities); - } - if ($this->bridgedObjectPHIDs !== null) { $where[] = qsprintf( $conn, @@ -573,6 +572,13 @@ final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery { $select_phids); } + if ($this->specificGroupByProjectPHID !== null) { + $where[] = qsprintf( + $conn, + 'projectGroupName.indexedObjectPHID = %s', + $this->specificGroupByProjectPHID); + } + return $where; } @@ -650,9 +656,9 @@ final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery { $joins = array(); if ($this->hasOpenParents !== null) { if ($this->hasOpenParents) { - $join_type = 'JOIN'; + $join_type = qsprintf($conn, 'JOIN'); } else { - $join_type = 'LEFT JOIN'; + $join_type = qsprintf($conn, 'LEFT JOIN'); } $joins[] = qsprintf( @@ -863,20 +869,10 @@ final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery { return array_mergev($phids); } - protected function getResultCursor($result) { - $id = $result->getID(); - - if ($this->groupBy == self::GROUP_PROJECT) { - return rtrim($id.'.'.$result->getGroupByProjectPHID(), '.'); - } - - return $id; - } - public function getBuiltinOrders() { $orders = array( 'priority' => array( - 'vector' => array('priority', 'subpriority', 'id'), + 'vector' => array('priority', 'id'), 'name' => pht('Priority'), 'aliases' => array(self::ORDER_PRIORITY), ), @@ -951,11 +947,6 @@ final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery { 'type' => 'string', 'reverse' => true, ), - 'subpriority' => array( - 'table' => 'task', - 'column' => 'subpriority', - 'type' => 'float', - ), 'updated' => array( 'table' => 'task', 'column' => 'dateModified', @@ -970,40 +961,37 @@ final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery { ); } - protected function getPagingValueMap($cursor, array $keys) { - $cursor_parts = explode('.', $cursor, 2); - $task_id = $cursor_parts[0]; - $group_id = idx($cursor_parts, 1); + protected function newPagingMapFromCursorObject( + PhabricatorQueryCursor $cursor, + array $keys) { - $task = $this->loadCursorObject($task_id); + $task = $cursor->getObject(); $map = array( - 'id' => $task->getID(), - 'priority' => $task->getPriority(), - 'subpriority' => $task->getSubpriority(), + 'id' => (int)$task->getID(), + 'priority' => (int)$task->getPriority(), 'owner' => $task->getOwnerOrdering(), 'status' => $task->getStatus(), 'title' => $task->getTitle(), - 'updated' => $task->getDateModified(), + 'updated' => (int)$task->getDateModified(), 'closed' => $task->getClosedEpoch(), ); - foreach ($keys as $key) { - switch ($key) { - case 'project': - $value = null; - if ($group_id) { - $paging_projects = id(new PhabricatorProjectQuery()) - ->setViewer($this->getViewer()) - ->withPHIDs(array($group_id)) - ->execute(); - if ($paging_projects) { - $value = head($paging_projects)->getName(); - } - } - $map[$key] = $value; - break; + if (isset($keys['project'])) { + $value = null; + + $group_phid = $task->getGroupByProjectPHID(); + if ($group_phid) { + $paging_projects = id(new PhabricatorProjectQuery()) + ->setViewer($this->getViewer()) + ->withPHIDs(array($group_phid)) + ->execute(); + if ($paging_projects) { + $value = head($paging_projects)->getName(); + } } + + $map['project'] = $value; } foreach ($keys as $key) { @@ -1016,6 +1004,77 @@ final class ManiphestTaskQuery extends PhabricatorCursorPagedPolicyAwareQuery { return $map; } + protected function newExternalCursorStringForResult($object) { + $id = $object->getID(); + + if ($this->groupBy == self::GROUP_PROJECT) { + return rtrim($id.'.'.$object->getGroupByProjectPHID(), '.'); + } + + return $id; + } + + protected function newInternalCursorFromExternalCursor($cursor) { + list($task_id, $group_phid) = $this->parseCursor($cursor); + + $cursor_object = parent::newInternalCursorFromExternalCursor($cursor); + + if ($group_phid !== null) { + $project = id(new PhabricatorProjectQuery()) + ->setViewer($this->getViewer()) + ->withPHIDs(array($group_phid)) + ->execute(); + + if (!$project) { + $this->throwCursorException( + pht( + 'Group PHID ("%s") component of cursor ("%s") is not valid.', + $group_phid, + $cursor)); + } + + $cursor_object->getObject()->attachGroupByProjectPHID($group_phid); + } + + return $cursor_object; + } + + protected function applyExternalCursorConstraintsToQuery( + PhabricatorCursorPagedPolicyAwareQuery $subquery, + $cursor) { + list($task_id, $group_phid) = $this->parseCursor($cursor); + + $subquery->withIDs(array($task_id)); + + if ($group_phid) { + $subquery->setGroupBy(self::GROUP_PROJECT); + + // The subquery needs to return exactly one result. If a task is in + // several projects, the query may naturally return several results. + // Specify that we want only the particular instance of the task in + // the specified project. + $subquery->withSpecificGroupByProjectPHID($group_phid); + } + } + + + private function parseCursor($cursor) { + // Split a "123.PHID-PROJ-abcd" cursor into a "Task ID" part and a + // "Project PHID" part. + + $parts = explode('.', $cursor, 2); + + if (count($parts) < 2) { + $parts[] = null; + } + + if (!strlen($parts[1])) { + $parts[1] = null; + } + + return $parts; + } + protected function getPrimaryTableAlias() { return 'task'; } diff --git a/src/applications/maniphest/query/ManiphestTaskSearchEngine.php b/src/applications/maniphest/query/ManiphestTaskSearchEngine.php index 30a020e8a7..fac235aec3 100644 --- a/src/applications/maniphest/query/ManiphestTaskSearchEngine.php +++ b/src/applications/maniphest/query/ManiphestTaskSearchEngine.php @@ -419,10 +419,8 @@ final class ManiphestTaskSearchEngine $viewer = $this->requireViewer(); if ($this->isPanelContext()) { - $can_edit_priority = false; $can_bulk_edit = false; } else { - $can_edit_priority = true; $can_bulk_edit = PhabricatorPolicyFilter::hasCapability( $viewer, $this->getApplication(), @@ -433,7 +431,6 @@ final class ManiphestTaskSearchEngine ->setUser($viewer) ->setTasks($tasks) ->setSavedQuery($saved) - ->setCanEditPriority($can_edit_priority) ->setCanBatchEdit($can_bulk_edit) ->setShowBatchControls($this->showBatchControls); diff --git a/src/applications/maniphest/storage/ManiphestTask.php b/src/applications/maniphest/storage/ManiphestTask.php index ada537fa4d..d2700895ce 100644 --- a/src/applications/maniphest/storage/ManiphestTask.php +++ b/src/applications/maniphest/storage/ManiphestTask.php @@ -20,7 +20,9 @@ final class ManiphestTask extends ManiphestDAO DoorkeeperBridgedObjectInterface, PhabricatorEditEngineSubtypeInterface, PhabricatorEditEngineLockableInterface, - PhabricatorEditEngineMFAInterface { + PhabricatorEditEngineMFAInterface, + PhabricatorPolicyCodexInterface, + PhabricatorUnlockableInterface { const MARKUP_FIELD_DESCRIPTION = 'markup:desc'; @@ -217,8 +219,16 @@ final class ManiphestTask extends ManiphestDAO return ManiphestTaskStatus::isClosedStatus($this->getStatus()); } - public function isLocked() { - return ManiphestTaskStatus::isLockedStatus($this->getStatus()); + public function areCommentsLocked() { + if ($this->areEditsLocked()) { + return true; + } + + return ManiphestTaskStatus::areCommentsLockedInStatus($this->getStatus()); + } + + public function areEditsLocked() { + return ManiphestTaskStatus::areEditsLockedInStatus($this->getStatus()); } public function setProperty($key, $value) { @@ -238,16 +248,6 @@ final class ManiphestTask extends ManiphestDAO return idx($this->properties, 'cover.thumbnailPHID'); } - public function getWorkboardOrderVectors() { - return array( - PhabricatorProjectColumn::ORDER_PRIORITY => array( - (int)-$this->getPriority(), - (double)-$this->getSubpriority(), - (int)-$this->getID(), - ), - ); - } - public function getPriorityKeyword() { $priority = $this->getPriority(); @@ -259,46 +259,6 @@ final class ManiphestTask extends ManiphestDAO return ManiphestTaskPriority::UNKNOWN_PRIORITY_KEYWORD; } - private function comparePriorityTo(ManiphestTask $other) { - $upri = $this->getPriority(); - $vpri = $other->getPriority(); - - if ($upri != $vpri) { - return ($upri - $vpri); - } - - $usub = $this->getSubpriority(); - $vsub = $other->getSubpriority(); - - if ($usub != $vsub) { - return ($usub - $vsub); - } - - $uid = $this->getID(); - $vid = $other->getID(); - - if ($uid != $vid) { - return ($uid - $vid); - } - - return 0; - } - - public function isLowerPriorityThan(ManiphestTask $other) { - return ($this->comparePriorityTo($other) < 0); - } - - public function isHigherPriorityThan(ManiphestTask $other) { - return ($this->comparePriorityTo($other) > 0); - } - - public function getWorkboardProperties() { - return array( - 'status' => $this->getStatus(), - 'points' => (double)$this->getPoints(), - ); - } - /* -( PhabricatorSubscribableInterface )----------------------------------- */ @@ -371,13 +331,17 @@ final class ManiphestTask extends ManiphestDAO case PhabricatorPolicyCapability::CAN_VIEW: return $this->getViewPolicy(); case PhabricatorPolicyCapability::CAN_INTERACT: - if ($this->isLocked()) { + if ($this->areCommentsLocked()) { return PhabricatorPolicies::POLICY_NOONE; } else { return $this->getViewPolicy(); } case PhabricatorPolicyCapability::CAN_EDIT: - return $this->getEditPolicy(); + if ($this->areEditsLocked()) { + return PhabricatorPolicies::POLICY_NOONE; + } else { + return $this->getEditPolicy(); + } } } @@ -527,7 +491,6 @@ final class ManiphestTask extends ManiphestDAO $priority_value = (int)$this->getPriority(); $priority_info = array( 'value' => $priority_value, - 'subpriority' => (double)$this->getSubpriority(), 'name' => ManiphestTaskPriority::getTaskPriorityName($priority_value), 'color' => ManiphestTaskPriority::getTaskPriorityColor($priority_value), ); @@ -628,4 +591,20 @@ final class ManiphestTask extends ManiphestDAO return new ManiphestTaskMFAEngine(); } + +/* -( PhabricatorPolicyCodexInterface )------------------------------------ */ + + + public function newPolicyCodex() { + return new ManiphestTaskPolicyCodex(); + } + + +/* -( PhabricatorUnlockableInterface )------------------------------------- */ + + + public function newUnlockEngine() { + return new ManiphestTaskUnlockEngine(); + } + } diff --git a/src/applications/maniphest/view/ManiphestTaskListView.php b/src/applications/maniphest/view/ManiphestTaskListView.php index a56a2d2a52..aee8b3a8b9 100644 --- a/src/applications/maniphest/view/ManiphestTaskListView.php +++ b/src/applications/maniphest/view/ManiphestTaskListView.php @@ -5,7 +5,6 @@ final class ManiphestTaskListView extends ManiphestView { private $tasks; private $handles; private $showBatchControls; - private $showSubpriorityControls; private $noDataString; public function setTasks(array $tasks) { @@ -25,11 +24,6 @@ final class ManiphestTaskListView extends ManiphestView { return $this; } - public function setShowSubpriorityControls($show_subpriority_controls) { - $this->showSubpriorityControls = $show_subpriority_controls; - return $this; - } - public function setNoDataString($text) { $this->noDataString = $text; return $this; @@ -108,10 +102,7 @@ final class ManiphestTaskListView extends ManiphestView { phabricator_datetime($task->getDateModified(), $this->getUser())); } - if ($this->showSubpriorityControls) { - $item->setGrippable(true); - } - if ($this->showSubpriorityControls || $this->showBatchControls) { + if ($this->showBatchControls) { $item->addSigil('maniphest-task'); } @@ -148,9 +139,6 @@ final class ManiphestTaskListView extends ManiphestView { if ($this->showBatchControls) { $href = new PhutilURI('/maniphest/task/edit/'.$task->getID().'/'); - if (!$this->showSubpriorityControls) { - $href->setQueryParam('ungrippable', 'true'); - } $item->addAction( id(new PHUIListItemView()) ->setIcon('fa-pencil') diff --git a/src/applications/maniphest/view/ManiphestTaskResultListView.php b/src/applications/maniphest/view/ManiphestTaskResultListView.php index 6aafcbdccb..cc2a135292 100644 --- a/src/applications/maniphest/view/ManiphestTaskResultListView.php +++ b/src/applications/maniphest/view/ManiphestTaskResultListView.php @@ -4,7 +4,6 @@ final class ManiphestTaskResultListView extends ManiphestView { private $tasks; private $savedQuery; - private $canEditPriority; private $canBatchEdit; private $showBatchControls; @@ -18,11 +17,6 @@ final class ManiphestTaskResultListView extends ManiphestView { return $this; } - public function setCanEditPriority($can_edit_priority) { - $this->canEditPriority = $can_edit_priority; - return $this; - } - public function setCanBatchEdit($can_batch_edit) { $this->canBatchEdit = $can_batch_edit; return $this; @@ -54,28 +48,12 @@ final class ManiphestTaskResultListView extends ManiphestView { $group_parameter, $handles); - $can_edit_priority = $this->canEditPriority; - - $can_drag = ($order_parameter == 'priority') && - ($can_edit_priority) && - ($group_parameter == 'none' || $group_parameter == 'priority'); - - if (!$viewer->isLoggedIn()) { - // TODO: (T7131) Eventually, we conceivably need to make each task - // draggable individually, since the user may be able to edit some but - // not others. - $can_drag = false; - } - $result = array(); $lists = array(); foreach ($groups as $group => $list) { $task_list = new ManiphestTaskListView(); $task_list->setShowBatchControls($this->showBatchControls); - if ($can_drag) { - $task_list->setShowSubpriorityControls(true); - } $task_list->setUser($viewer); $task_list->setTasks($list); $task_list->setHandles($handles); @@ -91,14 +69,6 @@ final class ManiphestTaskResultListView extends ManiphestView { } - if ($can_drag) { - Javelin::initBehavior( - 'maniphest-subpriority-editor', - array( - 'uri' => '/maniphest/subpriority/', - )); - } - return array( $lists, $this->showBatchControls ? $this->renderBatchEditor($query) : null, diff --git a/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php b/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php index eb29c711f8..5e6f63c5c4 100644 --- a/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php +++ b/src/applications/maniphest/xaction/ManiphestTaskCoverImageTransaction.php @@ -82,17 +82,35 @@ final class ManiphestTaskCoverImageTransaction if (!$file) { $errors[] = $this->newInvalidError( - pht('"%s" is not a valid file PHID.', - $file_phid)); - } else { - if (!$file->isViewableImage()) { - $mime_type = $file->getMimeType(); - $errors[] = $this->newInvalidError( - pht('File mime type of "%s" is not a valid viewable image.', - $mime_type)); - } + pht( + 'File PHID ("%s") is invalid, or you do not have permission '. + 'to view it.', + $file_phid), + $xaction); + continue; } + if (!$file->isViewableImage()) { + $errors[] = $this->newInvalidError( + pht( + 'File ("%s", with MIME type "%s") is not a viewable image file.', + $file_phid, + $file->getMimeType()), + $xaction); + continue; + } + + if (!$file->isTransformableImage()) { + $errors[] = $this->newInvalidError( + pht( + 'File ("%s", with MIME type "%s") can not be transformed into '. + 'a thumbnail. You may be missing support for this file type in '. + 'the "GD" extension.', + $file_phid, + $file->getMimeType()), + $xaction); + continue; + } } return $errors; diff --git a/src/applications/maniphest/xaction/ManiphestTaskSubpriorityTransaction.php b/src/applications/maniphest/xaction/ManiphestTaskSubpriorityTransaction.php index 49d227b7f1..c88ee8aa0c 100644 --- a/src/applications/maniphest/xaction/ManiphestTaskSubpriorityTransaction.php +++ b/src/applications/maniphest/xaction/ManiphestTaskSubpriorityTransaction.php @@ -6,16 +6,17 @@ final class ManiphestTaskSubpriorityTransaction const TRANSACTIONTYPE = 'subpriority'; public function generateOldValue($object) { - return $object->getSubpriority(); + return null; } public function applyInternalEffects($object, $value) { - $object->setSubpriority($value); + // This transaction is obsolete, but we're keeping the class around so it + // is hidden from timelines until we destroy the actual transaction data. + throw new PhutilMethodNotImplementedException(); } public function shouldHide() { return true; } - } diff --git a/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php b/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php index e4ec2a132f..7dd9217760 100644 --- a/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php +++ b/src/applications/maniphest/xaction/ManiphestTaskTitleTransaction.php @@ -64,9 +64,27 @@ final class ManiphestTaskTitleTransaction public function validateTransactions($object, array $xactions) { $errors = array(); - if ($this->isEmptyTextTransaction($object->getTitle(), $xactions)) { - $errors[] = $this->newRequiredError( - pht('Tasks must have a title.')); + // If the user is acting via "Bulk Edit" or another workflow which + // continues on missing fields, they may be applying a transaction which + // removes the task title. Mark these transactions as invalid first, + // then flag the missing field error if we don't find any more specific + // problems. + + foreach ($xactions as $xaction) { + $new = $xaction->getNewValue(); + if (!strlen($new)) { + $errors[] = $this->newInvalidError( + pht('Tasks must have a title.'), + $xaction); + continue; + } + } + + if (!$errors) { + if ($this->isEmptyTextTransaction($object->getTitle(), $xactions)) { + $errors[] = $this->newRequiredError( + pht('Tasks must have a title.')); + } } return $errors; diff --git a/src/applications/maniphest/xaction/ManiphestTaskUnblockTransaction.php b/src/applications/maniphest/xaction/ManiphestTaskUnblockTransaction.php index 8833e62b79..cb6c80604d 100644 --- a/src/applications/maniphest/xaction/ManiphestTaskUnblockTransaction.php +++ b/src/applications/maniphest/xaction/ManiphestTaskUnblockTransaction.php @@ -123,4 +123,14 @@ final class ManiphestTaskUnblockTransaction return parent::shouldHideForFeed(); } + public function getRequiredCapabilities( + $object, + PhabricatorApplicationTransaction $xaction) { + + // When you close a task, we want to apply this transaction to its parents + // even if you can not edit (or even see) those parents, so don't require + // any capabilities. See PHI1059. + + return null; + } } diff --git a/src/applications/metamta/adapter/PhabricatorMailAdapter.php b/src/applications/metamta/adapter/PhabricatorMailAdapter.php index 4fb262626d..8c1a6c0ba7 100644 --- a/src/applications/metamta/adapter/PhabricatorMailAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailAdapter.php @@ -137,4 +137,37 @@ abstract class PhabricatorMailAdapter abstract public function newDefaultOptions(); + final protected function guessIfHostSupportsMessageID($config, $host) { + // See T13265. Mailers like "SMTP" and "sendmail" usually allow us to + // set the "Message-ID" header to a value we choose, but we may not be + // able to if the mailer is being used as API glue and the outbound + // pathway ends up routing to a service with an SMTP API that selects + // its own "Message-ID" header, like Amazon SES. + + // If users configured a behavior explicitly, use that behavior. + if ($config !== null) { + return $config; + } + + // If the server we're connecting to is part of a service that we know + // does not support "Message-ID", guess that we don't support "Message-ID". + if ($host !== null) { + $host_blocklist = array( + '/\.amazonaws\.com\z/', + '/\.postmarkapp\.com\z/', + '/\.sendgrid\.net\z/', + ); + + $host = phutil_utf8_strtolower($host); + foreach ($host_blocklist as $regexp) { + if (preg_match($regexp, $host)) { + return false; + } + } + } + + return true; + } + + } diff --git a/src/applications/metamta/adapter/PhabricatorMailAmazonSESAdapter.php b/src/applications/metamta/adapter/PhabricatorMailAmazonSESAdapter.php index a289e5bc73..793cd56091 100644 --- a/src/applications/metamta/adapter/PhabricatorMailAmazonSESAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailAmazonSESAdapter.php @@ -11,10 +11,6 @@ final class PhabricatorMailAmazonSESAdapter ); } - public function supportsMessageIDHeader() { - return false; - } - protected function validateOptions(array $options) { PhutilTypeSpec::checkMap( $options, diff --git a/src/applications/metamta/adapter/PhabricatorMailMailgunAdapter.php b/src/applications/metamta/adapter/PhabricatorMailMailgunAdapter.php index 9eb478efc5..8223ee8102 100644 --- a/src/applications/metamta/adapter/PhabricatorMailMailgunAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailMailgunAdapter.php @@ -24,6 +24,7 @@ final class PhabricatorMailMailgunAdapter array( 'api-key' => 'string', 'domain' => 'string', + 'api-hostname' => 'string', )); } @@ -31,12 +32,14 @@ final class PhabricatorMailMailgunAdapter return array( 'api-key' => null, 'domain' => null, + 'api-hostname' => 'api.mailgun.net', ); } public function sendMessage(PhabricatorMailExternalMessage $message) { $api_key = $this->getOption('api-key'); $domain = $this->getOption('domain'); + $api_hostname = $this->getOption('api-hostname'); $params = array(); $subject = $message->getSubject(); @@ -92,7 +95,8 @@ final class PhabricatorMailMailgunAdapter } $mailgun_uri = urisprintf( - 'https://api.mailgun.net/v2/%s/messages', + 'https://%s/v2/%s/messages', + $api_hostname, $domain); $future = id(new HTTPSFuture($mailgun_uri, $params)) diff --git a/src/applications/metamta/adapter/PhabricatorMailPostmarkAdapter.php b/src/applications/metamta/adapter/PhabricatorMailPostmarkAdapter.php index d84d8f8bfa..2381ff04bf 100644 --- a/src/applications/metamta/adapter/PhabricatorMailPostmarkAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailPostmarkAdapter.php @@ -11,10 +11,6 @@ final class PhabricatorMailPostmarkAdapter ); } - public function supportsMessageIDHeader() { - return true; - } - protected function validateOptions(array $options) { PhutilTypeSpec::checkMap( $options, diff --git a/src/applications/metamta/adapter/PhabricatorMailSMTPAdapter.php b/src/applications/metamta/adapter/PhabricatorMailSMTPAdapter.php index a3c6298279..abbda40146 100644 --- a/src/applications/metamta/adapter/PhabricatorMailSMTPAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailSMTPAdapter.php @@ -12,7 +12,9 @@ final class PhabricatorMailSMTPAdapter } public function supportsMessageIDHeader() { - return true; + return $this->guessIfHostSupportsMessageID( + $this->getOption('message-id'), + $this->getOption('host')); } protected function validateOptions(array $options) { @@ -24,6 +26,7 @@ final class PhabricatorMailSMTPAdapter 'user' => 'string|null', 'password' => 'string|null', 'protocol' => 'string|null', + 'message-id' => 'bool|null', )); } @@ -34,6 +37,7 @@ final class PhabricatorMailSMTPAdapter 'user' => null, 'password' => null, 'protocol' => null, + 'message-id' => null, ); } diff --git a/src/applications/metamta/adapter/PhabricatorMailSendmailAdapter.php b/src/applications/metamta/adapter/PhabricatorMailSendmailAdapter.php index 05f3c909aa..a60c0e5a4e 100644 --- a/src/applications/metamta/adapter/PhabricatorMailSendmailAdapter.php +++ b/src/applications/metamta/adapter/PhabricatorMailSendmailAdapter.php @@ -5,7 +5,6 @@ final class PhabricatorMailSendmailAdapter const ADAPTERTYPE = 'sendmail'; - public function getSupportedMessageTypes() { return array( PhabricatorMailEmailMessage::MESSAGETYPE, @@ -13,20 +12,22 @@ final class PhabricatorMailSendmailAdapter } public function supportsMessageIDHeader() { - return true; + return $this->guessIfHostSupportsMessageID( + $this->getOption('message-id'), + null); } protected function validateOptions(array $options) { PhutilTypeSpec::checkMap( $options, array( - 'encoding' => 'string', + 'message-id' => 'bool|null', )); } public function newDefaultOptions() { return array( - 'encoding' => 'base64', + 'message-id' => null, ); } diff --git a/src/applications/metamta/adapter/__tests__/PhabricatorMailAdapterTestCase.php b/src/applications/metamta/adapter/__tests__/PhabricatorMailAdapterTestCase.php new file mode 100644 index 0000000000..9c194f24c2 --- /dev/null +++ b/src/applications/metamta/adapter/__tests__/PhabricatorMailAdapterTestCase.php @@ -0,0 +1,96 @@ + 'test', + 'secret-key' => 'test', + 'endpoint' => 'test', + ), + ), + + array( + pht('Mailgun'), + true, + new PhabricatorMailMailgunAdapter(), + array( + 'api-key' => 'test', + 'domain' => 'test', + 'api-hostname' => 'test', + ), + ), + + array( + pht('Sendmail'), + true, + new PhabricatorMailSendmailAdapter(), + array(), + ), + + array( + pht('Sendmail (Explicit Config)'), + false, + new PhabricatorMailSendmailAdapter(), + array( + 'message-id' => false, + ), + ), + + array( + pht('SMTP (Local)'), + true, + new PhabricatorMailSMTPAdapter(), + array(), + ), + + array( + pht('SMTP (Local + Explicit)'), + false, + new PhabricatorMailSMTPAdapter(), + array( + 'message-id' => false, + ), + ), + + array( + pht('SMTP (AWS)'), + false, + new PhabricatorMailSMTPAdapter(), + array( + 'host' => 'test.amazonaws.com', + ), + ), + + array( + pht('SMTP (AWS + Explicit)'), + true, + new PhabricatorMailSMTPAdapter(), + array( + 'host' => 'test.amazonaws.com', + 'message-id' => true, + ), + ), + + ); + + foreach ($cases as $case) { + list($label, $expect, $mailer, $options) = $case; + + $defaults = $mailer->newDefaultOptions(); + $mailer->setOptions($options + $defaults); + + $actual = $mailer->supportsMessageIDHeader(); + + $this->assertEqual($expect, $actual, pht('Message-ID: %s', $label)); + } + } + + +} diff --git a/src/applications/metamta/applicationpanel/PhabricatorMetaMTAApplicationEmailPanel.php b/src/applications/metamta/applicationpanel/PhabricatorMetaMTAApplicationEmailPanel.php index c13835e6f4..2f9ddcf22b 100644 --- a/src/applications/metamta/applicationpanel/PhabricatorMetaMTAApplicationEmailPanel.php +++ b/src/applications/metamta/applicationpanel/PhabricatorMetaMTAApplicationEmailPanel.php @@ -54,8 +54,7 @@ final class PhabricatorMetaMTAApplicationEmailPanel return new Aphront404Response(); } - $uri = $request->getRequestURI(); - $uri->setQueryParams(array()); + $uri = new PhutilURI($request->getPath()); $new = $request->getStr('new'); $edit = $request->getInt('edit'); diff --git a/src/applications/metamta/controller/PhabricatorMetaMTAMailListController.php b/src/applications/metamta/controller/PhabricatorMetaMTAMailListController.php index 0651068550..01d6f1e218 100644 --- a/src/applications/metamta/controller/PhabricatorMetaMTAMailListController.php +++ b/src/applications/metamta/controller/PhabricatorMetaMTAMailListController.php @@ -27,4 +27,8 @@ final class PhabricatorMetaMTAMailListController return $nav; } + public function buildApplicationMenu() { + return $this->buildSideNav()->getMenu(); + } + } diff --git a/src/applications/metamta/storage/PhabricatorMetaMTAApplicationEmailTransaction.php b/src/applications/metamta/storage/PhabricatorMetaMTAApplicationEmailTransaction.php index 019adb338d..af6a6fbb88 100644 --- a/src/applications/metamta/storage/PhabricatorMetaMTAApplicationEmailTransaction.php +++ b/src/applications/metamta/storage/PhabricatorMetaMTAApplicationEmailTransaction.php @@ -16,8 +16,4 @@ final class PhabricatorMetaMTAApplicationEmailTransaction return PhabricatorMetaMTAApplicationEmailPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - } diff --git a/src/applications/multimeter/controller/MultimeterSampleController.php b/src/applications/multimeter/controller/MultimeterSampleController.php index da09641d22..190a839f63 100644 --- a/src/applications/multimeter/controller/MultimeterSampleController.php +++ b/src/applications/multimeter/controller/MultimeterSampleController.php @@ -300,13 +300,14 @@ final class MultimeterSampleController extends MultimeterController { $group = implode('.', $group); if (!strlen($group)) { - $group = null; + $uri->removeQueryParam('group'); + } else { + $uri->replaceQueryParam('group', $group); } - $uri->setQueryParam('group', $group); if ($wipe) { foreach ($this->getColumnMap() as $key => $column) { - $uri->setQueryParam($key, null); + $uri->removeQueryParam($key); } } @@ -317,7 +318,7 @@ final class MultimeterSampleController extends MultimeterController { $value = (array)$value; $uri = clone $this->getRequest()->getRequestURI(); - $uri->setQueryParam($key, implode(',', $value)); + $uri->replaceQueryParam($key, implode(',', $value)); return phutil_tag( 'a', diff --git a/src/applications/notification/client/PhabricatorNotificationServerRef.php b/src/applications/notification/client/PhabricatorNotificationServerRef.php index b183221eee..46d03a5c3a 100644 --- a/src/applications/notification/client/PhabricatorNotificationServerRef.php +++ b/src/applications/notification/client/PhabricatorNotificationServerRef.php @@ -153,7 +153,7 @@ final class PhabricatorNotificationServerRef $instance = PhabricatorEnv::getEnvConfig('cluster.instance'); if (strlen($instance)) { - $uri->setQueryParam('instance', $instance); + $uri->replaceQueryParam('instance', $instance); } return $uri; diff --git a/src/applications/notification/controller/PhabricatorNotificationPanelController.php b/src/applications/notification/controller/PhabricatorNotificationPanelController.php index 1e956a60ea..5991e8db7b 100644 --- a/src/applications/notification/controller/PhabricatorNotificationPanelController.php +++ b/src/applications/notification/controller/PhabricatorNotificationPanelController.php @@ -25,7 +25,7 @@ final class PhabricatorNotificationPanelController $notifications_view = $builder->buildView(); $content = $notifications_view->render(); - $clear_uri->setQueryParam( + $clear_uri->replaceQueryParam( 'chronoKey', head($stories)->getChronologicalKey()); } else { diff --git a/src/applications/notification/query/PhabricatorNotificationSearchEngine.php b/src/applications/notification/query/PhabricatorNotificationSearchEngine.php index 0ee7327bfc..c7e1998333 100644 --- a/src/applications/notification/query/PhabricatorNotificationSearchEngine.php +++ b/src/applications/notification/query/PhabricatorNotificationSearchEngine.php @@ -111,7 +111,7 @@ final class PhabricatorNotificationSearchEngine ->setUser($viewer); $view = $builder->buildView(); - $clear_uri->setQueryParam( + $clear_uri->replaceQueryParam( 'chronoKey', head($notifications)->getChronologicalKey()); } else { diff --git a/src/applications/oauthserver/PhabricatorOAuthResponse.php b/src/applications/oauthserver/PhabricatorOAuthResponse.php index 62c0fc9821..e0fca827b4 100644 --- a/src/applications/oauthserver/PhabricatorOAuthResponse.php +++ b/src/applications/oauthserver/PhabricatorOAuthResponse.php @@ -36,7 +36,7 @@ final class PhabricatorOAuthResponse extends AphrontResponse { $base_uri = $this->getClientURI(); $query_params = $this->buildResponseDict(); foreach ($query_params as $key => $value) { - $base_uri->setQueryParam($key, $value); + $base_uri->replaceQueryParam($key, $value); } return $base_uri; } diff --git a/src/applications/oauthserver/PhabricatorOAuthServer.php b/src/applications/oauthserver/PhabricatorOAuthServer.php index f5c074f4eb..889e960213 100644 --- a/src/applications/oauthserver/PhabricatorOAuthServer.php +++ b/src/applications/oauthserver/PhabricatorOAuthServer.php @@ -256,8 +256,8 @@ final class PhabricatorOAuthServer extends Phobject { // Any query parameters present in the first URI must be exactly present // in the second URI. - $need_params = $primary_uri->getQueryParams(); - $have_params = $secondary_uri->getQueryParams(); + $need_params = $primary_uri->getQueryParamsAsMap(); + $have_params = $secondary_uri->getQueryParamsAsMap(); foreach ($need_params as $key => $value) { if (!array_key_exists($key, $have_params)) { diff --git a/src/applications/oauthserver/controller/PhabricatorOAuthServerAuthController.php b/src/applications/oauthserver/controller/PhabricatorOAuthServerAuthController.php index 745be3e820..2b454e00ef 100644 --- a/src/applications/oauthserver/controller/PhabricatorOAuthServerAuthController.php +++ b/src/applications/oauthserver/controller/PhabricatorOAuthServerAuthController.php @@ -306,7 +306,7 @@ final class PhabricatorOAuthServerAuthController foreach ($params as $key => $value) { if (strlen($value)) { - $full_uri->setQueryParam($key, $value); + $full_uri->replaceQueryParam($key, $value); } } diff --git a/src/applications/oauthserver/storage/PhabricatorOAuthServerTransaction.php b/src/applications/oauthserver/storage/PhabricatorOAuthServerTransaction.php index b2624dd9a4..acfb88ef48 100644 --- a/src/applications/oauthserver/storage/PhabricatorOAuthServerTransaction.php +++ b/src/applications/oauthserver/storage/PhabricatorOAuthServerTransaction.php @@ -19,10 +19,6 @@ final class PhabricatorOAuthServerTransaction return PhabricatorOAuthServerClientPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getTitle() { $author_phid = $this->getAuthorPHID(); $old = $this->getOldValue(); diff --git a/src/applications/owners/constants/PhabricatorOwnersAuditRule.php b/src/applications/owners/constants/PhabricatorOwnersAuditRule.php new file mode 100644 index 0000000000..32a9bb804b --- /dev/null +++ b/src/applications/owners/constants/PhabricatorOwnersAuditRule.php @@ -0,0 +1,117 @@ +key = $key; + $rule->spec = $spec; + + return $rule; + } + + public function getKey() { + return $this->key; + } + + public function getDisplayName() { + return idx($this->spec, 'name', $this->key); + } + + public function getIconIcon() { + return idx($this->spec, 'icon.icon'); + } + + public static function newSelectControlMap() { + $specs = self::newSpecifications(); + return ipull($specs, 'name'); + } + + public static function getStorageValueFromAPIValue($value) { + $specs = self::newSpecifications(); + + $map = array(); + foreach ($specs as $key => $spec) { + $deprecated = idx($spec, 'deprecated', array()); + if (isset($deprecated[$value])) { + return $key; + } + } + + return $value; + } + + public static function getModernValueMap() { + $specs = self::newSpecifications(); + + $map = array(); + foreach ($specs as $key => $spec) { + $map[$key] = pht('"%s"', $key); + } + + return $map; + } + + public static function getDeprecatedValueMap() { + $specs = self::newSpecifications(); + + $map = array(); + foreach ($specs as $key => $spec) { + $deprecated_map = idx($spec, 'deprecated', array()); + foreach ($deprecated_map as $deprecated_key => $label) { + $map[$deprecated_key] = $label; + } + } + + return $map; + } + + private static function newSpecifications() { + return array( + self::AUDITING_NONE => array( + 'name' => pht('No Auditing'), + 'icon.icon' => 'fa-ban', + 'deprecated' => array( + '' => pht('"" (empty string)'), + '0' => '"0"', + ), + ), + self::AUDITING_UNREVIEWED => array( + 'name' => pht('Audit Unreviewed Commits'), + 'icon.icon' => 'fa-check', + ), + self::AUDITING_NO_OWNER => array( + 'name' => pht('Audit Commits With No Owner Involvement'), + 'icon.icon' => 'fa-check', + 'deprecated' => array( + '1' => '"1"', + ), + ), + self::AUDITING_NO_OWNER_AND_UNREVIEWED => array( + 'name' => pht( + 'Audit Unreviewed Commits and Commits With No Owner Involvement'), + 'icon.icon' => 'fa-check', + ), + self::AUDITING_ALL => array( + 'name' => pht('Audit All Commits'), + 'icon.icon' => 'fa-check', + ), + ); + } + + + +} diff --git a/src/applications/owners/controller/PhabricatorOwnersDetailController.php b/src/applications/owners/controller/PhabricatorOwnersDetailController.php index f71009cf19..c458e4dbd1 100644 --- a/src/applications/owners/controller/PhabricatorOwnersDetailController.php +++ b/src/applications/owners/controller/PhabricatorOwnersDetailController.php @@ -65,11 +65,11 @@ final class PhabricatorOwnersDetailController $commit_views = array(); - $commit_uri = id(new PhutilURI('/diffusion/commit/')) - ->setQueryParams( - array( - 'package' => $package->getPHID(), - )); + $params = array( + 'package' => $package->getPHID(), + ); + + $commit_uri = new PhutilURI('/diffusion/commit/', $params); $status_concern = DiffusionCommitAuditStatus::CONCERN_RAISED; @@ -194,12 +194,8 @@ final class PhabricatorOwnersDetailController $name = idx($spec, 'name', $auto); $view->addProperty(pht('Auto Review'), $name); - if ($package->getAuditingEnabled()) { - $auditing = pht('Enabled'); - } else { - $auditing = pht('Disabled'); - } - $view->addProperty(pht('Auditing'), $auditing); + $rule = $package->newAuditingRule(); + $view->addProperty(pht('Auditing'), $rule->getDisplayName()); $ignored = $package->getIgnoredPathAttributes(); $ignored = array_keys($ignored); diff --git a/src/applications/owners/editor/PhabricatorOwnersPackageEditEngine.php b/src/applications/owners/editor/PhabricatorOwnersPackageEditEngine.php index 044cb8beda..13f896d3f0 100644 --- a/src/applications/owners/editor/PhabricatorOwnersPackageEditEngine.php +++ b/src/applications/owners/editor/PhabricatorOwnersPackageEditEngine.php @@ -140,12 +140,8 @@ EOTEXT ->setTransactionType( PhabricatorOwnersPackageAuditingTransaction::TRANSACTIONTYPE) ->setIsCopyable(true) - ->setValue($object->getAuditingEnabled()) - ->setOptions( - array( - '' => pht('Disabled'), - '1' => pht('Enabled'), - )), + ->setValue($object->getAuditingState()) + ->setOptions(PhabricatorOwnersAuditRule::newSelectControlMap()), id(new PhabricatorRemarkupEditField()) ->setKey('description') ->setLabel(pht('Description')) diff --git a/src/applications/owners/query/PhabricatorOwnersPackageQuery.php b/src/applications/owners/query/PhabricatorOwnersPackageQuery.php index 6d6ccb2ed2..67b4836a5a 100644 --- a/src/applications/owners/query/PhabricatorOwnersPackageQuery.php +++ b/src/applications/owners/query/PhabricatorOwnersPackageQuery.php @@ -267,11 +267,10 @@ final class PhabricatorOwnersPackageQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $package = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $package->getID(), - 'name' => $package->getName(), + 'id' => (int)$object->getID(), + 'name' => $object->getName(), ); } diff --git a/src/applications/owners/storage/PhabricatorOwnersPackage.php b/src/applications/owners/storage/PhabricatorOwnersPackage.php index 564fc8a28b..b9e91ef958 100644 --- a/src/applications/owners/storage/PhabricatorOwnersPackage.php +++ b/src/applications/owners/storage/PhabricatorOwnersPackage.php @@ -13,7 +13,6 @@ final class PhabricatorOwnersPackage PhabricatorNgramsInterface { protected $name; - protected $auditingEnabled; protected $autoReview; protected $description; protected $status; @@ -21,6 +20,7 @@ final class PhabricatorOwnersPackage protected $editPolicy; protected $dominion; protected $properties = array(); + protected $auditingState; private $paths = self::ATTACHABLE; private $owners = self::ATTACHABLE; @@ -55,7 +55,7 @@ final class PhabricatorOwnersPackage PhabricatorOwnersDefaultEditCapability::CAPABILITY); return id(new PhabricatorOwnersPackage()) - ->setAuditingEnabled(0) + ->setAuditingState(PhabricatorOwnersAuditRule::AUDITING_NONE) ->setAutoReview(self::AUTOREVIEW_NONE) ->setDominion(self::DOMINION_STRONG) ->setViewPolicy($view_policy) @@ -126,7 +126,7 @@ final class PhabricatorOwnersPackage self::CONFIG_COLUMN_SCHEMA => array( 'name' => 'sort', 'description' => 'text', - 'auditingEnabled' => 'bool', + 'auditingState' => 'text32', 'status' => 'text32', 'autoReview' => 'text32', 'dominion' => 'text32', @@ -564,6 +564,10 @@ final class PhabricatorOwnersPackage return '/owners/package/'.$this->getID().'/'; } + public function newAuditingRule() { + return PhabricatorOwnersAuditRule::newFromState($this->getAuditingState()); + } + /* -( PhabricatorPolicyInterface )----------------------------------------- */ @@ -720,17 +724,11 @@ final class PhabricatorOwnersPackage 'label' => $review_label, ); - if ($this->getAuditingEnabled()) { - $audit_value = 'audit'; - $audit_label = pht('Auditing Enabled'); - } else { - $audit_value = 'none'; - $audit_label = pht('No Auditing'); - } + $audit_rule = $this->newAuditingRule(); $audit = array( - 'value' => $audit_value, - 'label' => $audit_label, + 'value' => $audit_rule->getKey(), + 'label' => $audit_rule->getDisplayName(), ); $dominion_value = $this->getDominion(); diff --git a/src/applications/owners/storage/PhabricatorOwnersPackageTransaction.php b/src/applications/owners/storage/PhabricatorOwnersPackageTransaction.php index 1dfc944f63..66e15b634a 100644 --- a/src/applications/owners/storage/PhabricatorOwnersPackageTransaction.php +++ b/src/applications/owners/storage/PhabricatorOwnersPackageTransaction.php @@ -15,8 +15,4 @@ final class PhabricatorOwnersPackageTransaction return 'PhabricatorOwnersPackageTransactionType'; } - public function getApplicationTransactionCommentObject() { - return null; - } - } diff --git a/src/applications/owners/xaction/PhabricatorOwnersPackageAuditingTransaction.php b/src/applications/owners/xaction/PhabricatorOwnersPackageAuditingTransaction.php index df4f0feb01..7c16c850fd 100644 --- a/src/applications/owners/xaction/PhabricatorOwnersPackageAuditingTransaction.php +++ b/src/applications/owners/xaction/PhabricatorOwnersPackageAuditingTransaction.php @@ -6,27 +6,62 @@ final class PhabricatorOwnersPackageAuditingTransaction const TRANSACTIONTYPE = 'owners.auditing'; public function generateOldValue($object) { - return (int)$object->getAuditingEnabled(); + return $object->getAuditingState(); } public function generateNewValue($object, $value) { - return (int)$value; + return PhabricatorOwnersAuditRule::getStorageValueFromAPIValue($value); } public function applyInternalEffects($object, $value) { - $object->setAuditingEnabled($value); + $object->setAuditingState($value); } public function getTitle() { - if ($this->getNewValue()) { - return pht( - '%s enabled auditing for this package.', - $this->renderAuthor()); - } else { - return pht( - '%s disabled auditing for this package.', - $this->renderAuthor()); + $old_value = $this->getOldValue(); + $new_value = $this->getNewValue(); + + $old_rule = PhabricatorOwnersAuditRule::newFromState($old_value); + $new_rule = PhabricatorOwnersAuditRule::newFromState($new_value); + + return pht( + '%s changed the audit rule for this package from %s to %s.', + $this->renderAuthor(), + $this->renderValue($old_rule->getDisplayName()), + $this->renderValue($new_rule->getDisplayName())); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + // See PHI1047. This transaction type accepted some weird stuff. Continue + // supporting it for now, but move toward sensible consistency. + + $modern_options = PhabricatorOwnersAuditRule::getModernValueMap(); + $deprecated_options = PhabricatorOwnersAuditRule::getDeprecatedValueMap(); + + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + + if (isset($modern_options[$new_value])) { + continue; + } + + if (isset($deprecated_options[$new_value])) { + continue; + } + + $errors[] = $this->newInvalidError( + pht( + 'Package auditing value "%s" is not supported. Supported options '. + 'are: %s. Deprecated options are: %s.', + $new_value, + implode(', ', $modern_options), + implode(', ', $deprecated_options)), + $xaction); } + + return $errors; } } diff --git a/src/applications/passphrase/storage/PassphraseCredentialTransaction.php b/src/applications/passphrase/storage/PassphraseCredentialTransaction.php index b7e4f904ef..bbc3b09668 100644 --- a/src/applications/passphrase/storage/PassphraseCredentialTransaction.php +++ b/src/applications/passphrase/storage/PassphraseCredentialTransaction.php @@ -11,10 +11,6 @@ final class PassphraseCredentialTransaction return PassphraseCredentialPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'PassphraseCredentialTransactionType'; } diff --git a/src/applications/people/application/PhabricatorPeopleApplication.php b/src/applications/people/application/PhabricatorPeopleApplication.php index 9238d8da3b..9cc3607930 100644 --- a/src/applications/people/application/PhabricatorPeopleApplication.php +++ b/src/applications/people/application/PhabricatorPeopleApplication.php @@ -51,7 +51,8 @@ final class PhabricatorPeopleApplication extends PhabricatorApplication { 'send/' => 'PhabricatorPeopleInviteSendController', ), - 'approve/(?P[1-9]\d*)/' => 'PhabricatorPeopleApproveController', + 'approve/(?P[1-9]\d*)/(?:via/(?P[^/]+)/)?' + => 'PhabricatorPeopleApproveController', '(?Pdisapprove)/(?P[1-9]\d*)/' => 'PhabricatorPeopleDisableController', '(?Pdisable)/(?P[1-9]\d*)/' @@ -62,7 +63,6 @@ final class PhabricatorPeopleApplication extends PhabricatorApplication { 'welcome/(?P[1-9]\d*)/' => 'PhabricatorPeopleWelcomeController', 'create/' => 'PhabricatorPeopleCreateController', 'new/(?P[^/]+)/' => 'PhabricatorPeopleNewController', - 'ldap/' => 'PhabricatorPeopleLdapController', 'editprofile/(?P[1-9]\d*)/' => 'PhabricatorPeopleProfileEditController', 'badges/(?P[1-9]\d*)/' => diff --git a/src/applications/people/controller/PhabricatorPeopleApproveController.php b/src/applications/people/controller/PhabricatorPeopleApproveController.php index 013f4371f6..af08a6fbdc 100644 --- a/src/applications/people/controller/PhabricatorPeopleApproveController.php +++ b/src/applications/people/controller/PhabricatorPeopleApproveController.php @@ -14,7 +14,15 @@ final class PhabricatorPeopleApproveController return new Aphront404Response(); } - $done_uri = $this->getApplicationURI('query/approval/'); + $via = $request->getURIData('via'); + switch ($via) { + case 'profile': + $done_uri = urisprintf('/people/manage/%d/', $user->getID()); + break; + default: + $done_uri = $this->getApplicationURI('query/approval/'); + break; + } if ($user->getIsApproved()) { return $this->newDialog() diff --git a/src/applications/people/controller/PhabricatorPeopleController.php b/src/applications/people/controller/PhabricatorPeopleController.php index e3b60eff2b..c2c262f9f4 100644 --- a/src/applications/people/controller/PhabricatorPeopleController.php +++ b/src/applications/people/controller/PhabricatorPeopleController.php @@ -28,10 +28,6 @@ abstract class PhabricatorPeopleController extends PhabricatorController { if ($viewer->getIsAdmin()) { $nav->addLabel(pht('User Administration')); - if (PhabricatorLDAPAuthProvider::getLDAPProvider()) { - $nav->addFilter('ldap', pht('Import from LDAP')); - } - $nav->addFilter('logs', pht('Activity Logs')); $nav->addFilter('invite', pht('Email Invitations')); } diff --git a/src/applications/people/controller/PhabricatorPeopleLdapController.php b/src/applications/people/controller/PhabricatorPeopleLdapController.php deleted file mode 100644 index 876bf986ad..0000000000 --- a/src/applications/people/controller/PhabricatorPeopleLdapController.php +++ /dev/null @@ -1,214 +0,0 @@ -requireApplicationCapability( - PeopleCreateUsersCapability::CAPABILITY); - $admin = $request->getUser(); - - $content = array(); - - $form = id(new AphrontFormView()) - ->setAction($request->getRequestURI() - ->alter('search', 'true')->alter('import', null)) - ->setUser($admin) - ->appendChild( - id(new AphrontFormTextControl()) - ->setLabel(pht('LDAP username')) - ->setName('username')) - ->appendChild( - id(new AphrontFormPasswordControl()) - ->setDisableAutocomplete(true) - ->setLabel(pht('Password')) - ->setName('password')) - ->appendChild( - id(new AphrontFormTextControl()) - ->setLabel(pht('LDAP query')) - ->setCaption(pht('A filter such as %s.', '(objectClass=*)')) - ->setName('query')) - ->appendChild( - id(new AphrontFormSubmitControl()) - ->setValue(pht('Search'))); - - $panel = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Import LDAP Users')) - ->setForm($form); - - $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb( - pht('Import LDAP Users'), - $this->getApplicationURI('/ldap/')); - - $nav = $this->buildSideNavView(); - $nav->selectFilter('ldap'); - $nav->appendChild($content); - - if ($request->getStr('import')) { - $nav->appendChild($this->processImportRequest($request)); - } - - $nav->appendChild($panel); - - if ($request->getStr('search')) { - $nav->appendChild($this->processSearchRequest($request)); - } - - return $this->newPage() - ->setTitle(pht('Import LDAP Users')) - ->setCrumbs($crumbs) - ->setNavigation($nav); - } - - private function processImportRequest($request) { - $admin = $request->getUser(); - $usernames = $request->getArr('usernames'); - $emails = $request->getArr('email'); - $names = $request->getArr('name'); - - $notice_view = new PHUIInfoView(); - $notice_view->setSeverity(PHUIInfoView::SEVERITY_NOTICE); - $notice_view->setTitle(pht('Import Successful')); - $notice_view->setErrors(array( - pht('Successfully imported users from LDAP'), - )); - - $list = new PHUIObjectItemListView(); - $list->setNoDataString(pht('No users imported?')); - - foreach ($usernames as $username) { - $user = new PhabricatorUser(); - $user->setUsername($username); - $user->setRealname($names[$username]); - - $email_obj = id(new PhabricatorUserEmail()) - ->setAddress($emails[$username]) - ->setIsVerified(1); - try { - id(new PhabricatorUserEditor()) - ->setActor($admin) - ->createNewUser($user, $email_obj); - - id(new PhabricatorExternalAccount()) - ->setUserPHID($user->getPHID()) - ->setAccountType('ldap') - ->setAccountDomain('self') - ->setAccountID($username) - ->save(); - - $header = pht('Successfully added %s', $username); - $attribute = null; - $color = 'fa-check green'; - } catch (Exception $ex) { - $header = pht('Failed to add %s', $username); - $attribute = $ex->getMessage(); - $color = 'fa-times red'; - } - - $item = id(new PHUIObjectItemView()) - ->setHeader($header) - ->addAttribute($attribute) - ->setStatusIcon($color); - - $list->addItem($item); - } - - return array( - $notice_view, - $list, - ); - - } - - private function processSearchRequest($request) { - $panel = new PHUIBoxView(); - $admin = $request->getUser(); - - $search = $request->getStr('query'); - - $ldap_provider = PhabricatorLDAPAuthProvider::getLDAPProvider(); - if (!$ldap_provider) { - throw new Exception(pht('No LDAP provider enabled!')); - } - - $ldap_adapter = $ldap_provider->getAdapter(); - $ldap_adapter->setLoginUsername($request->getStr('username')); - $ldap_adapter->setLoginPassword( - new PhutilOpaqueEnvelope($request->getStr('password'))); - - // This causes us to connect and bind. - // TODO: Clean up this discard mode stuff. - DarkConsoleErrorLogPluginAPI::enableDiscardMode(); - $ldap_adapter->getAccountID(); - DarkConsoleErrorLogPluginAPI::disableDiscardMode(); - - $results = $ldap_adapter->searchLDAP('%Q', $search); - - foreach ($results as $key => $record) { - $account_id = $ldap_adapter->readLDAPRecordAccountID($record); - if (!$account_id) { - unset($results[$key]); - continue; - } - - $info = array( - $account_id, - $ldap_adapter->readLDAPRecordEmail($record), - $ldap_adapter->readLDAPRecordRealName($record), - ); - $results[$key] = $info; - $results[$key][] = $this->renderUserInputs($info); - } - - $form = id(new AphrontFormView()) - ->setUser($admin); - - $table = new AphrontTableView($results); - $table->setHeaders( - array( - pht('Username'), - pht('Email'), - pht('Real Name'), - pht('Import?'), - )); - $form->appendChild($table); - $form->setAction($request->getRequestURI() - ->alter('import', 'true')->alter('search', null)) - ->appendChild( - id(new AphrontFormSubmitControl()) - ->setValue(pht('Import'))); - - $panel->appendChild($form); - - return $panel; - } - - private function renderUserInputs($user) { - $username = $user[0]; - return hsprintf( - '%s%s%s', - phutil_tag( - 'input', - array( - 'type' => 'checkbox', - 'name' => 'usernames[]', - 'value' => $username, - )), - phutil_tag( - 'input', - array( - 'type' => 'hidden', - 'name' => "email[$username]", - 'value' => $user[1], - )), - phutil_tag( - 'input', - array( - 'type' => 'hidden', - 'name' => "name[$username]", - 'value' => $user[2], - ))); - } - -} diff --git a/src/applications/people/controller/PhabricatorPeopleProfileBadgesController.php b/src/applications/people/controller/PhabricatorPeopleProfileBadgesController.php index f3e95eeb66..f98970ef73 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfileBadgesController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfileBadgesController.php @@ -30,8 +30,9 @@ final class PhabricatorPeopleProfileBadgesController $crumbs->addTextCrumb(pht('Badges')); $crumbs->setBorder(true); - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorPeopleProfileMenuEngine::ITEM_BADGES); + $nav = $this->newNavigation( + $user, + PhabricatorPeopleProfileMenuEngine::ITEM_BADGES); // Best option? $badges = id(new PhabricatorBadgesQuery()) diff --git a/src/applications/people/controller/PhabricatorPeopleProfileCommitsController.php b/src/applications/people/controller/PhabricatorPeopleProfileCommitsController.php index c18c5f4d96..430e11311e 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfileCommitsController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfileCommitsController.php @@ -32,8 +32,9 @@ final class PhabricatorPeopleProfileCommitsController $crumbs->addTextCrumb(pht('Recent Commits')); $crumbs->setBorder(true); - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorPeopleProfileMenuEngine::ITEM_COMMITS); + $nav = $this->newNavigation( + $user, + PhabricatorPeopleProfileMenuEngine::ITEM_COMMITS); $view = id(new PHUITwoColumnView()) ->setHeader($header) diff --git a/src/applications/people/controller/PhabricatorPeopleProfileController.php b/src/applications/people/controller/PhabricatorPeopleProfileController.php index 902b21efcc..1d6f0fc74c 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfileController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfileController.php @@ -4,7 +4,6 @@ abstract class PhabricatorPeopleProfileController extends PhabricatorPeopleController { private $user; - private $profileMenu; public function shouldRequireAdmin() { return false; @@ -19,34 +18,6 @@ abstract class PhabricatorPeopleProfileController return $this->user; } - public function buildApplicationMenu() { - $menu = $this->newApplicationMenu(); - - $profile_menu = $this->getProfileMenu(); - if ($profile_menu) { - $menu->setProfileMenu($profile_menu); - } - - return $menu; - } - - protected function getProfileMenu() { - if (!$this->profileMenu) { - $user = $this->getUser(); - if ($user) { - $viewer = $this->getViewer(); - - $engine = id(new PhabricatorPeopleProfileMenuEngine()) - ->setViewer($viewer) - ->setProfileObject($user); - - $this->profileMenu = $engine->buildNavigation(); - } - } - - return $this->profileMenu; - } - protected function buildApplicationCrumbs() { $crumbs = parent::buildApplicationCrumbs(); @@ -70,40 +41,53 @@ abstract class PhabricatorPeopleProfileController $profile_icon = PhabricatorPeopleIconSet::getIconIcon($profile->getIcon()); $profile_title = $profile->getDisplayTitle(); - $roles = array(); + + $tag = id(new PHUITagView()) + ->setType(PHUITagView::TYPE_SHADE); + + $tags = array(); if ($user->getIsAdmin()) { - $roles[] = pht('Administrator'); - } - if ($user->getIsDisabled()) { - $roles[] = pht('Disabled'); - } - if (!$user->getIsApproved()) { - $roles[] = pht('Not Approved'); - } - if ($user->getIsSystemAgent()) { - $roles[] = pht('Bot'); - } - if ($user->getIsMailingList()) { - $roles[] = pht('Mailing List'); - } - if (!$user->getIsEmailVerified()) { - $roles[] = pht('Email Not Verified'); + $tags[] = id(clone $tag) + ->setName(pht('Administrator')) + ->setColor('blue'); } - $tag = null; - if ($roles) { - $tag = id(new PHUITagView()) - ->setName(implode(', ', $roles)) - ->addClass('project-view-header-tag') - ->setType(PHUITagView::TYPE_SHADE); + // "Disabled" gets a stronger status tag below. + + if (!$user->getIsApproved()) { + $tags[] = id(clone $tag) + ->setName('Not Approved') + ->setColor('yellow'); + } + + if ($user->getIsSystemAgent()) { + $tags[] = id(clone $tag) + ->setName(pht('Bot')) + ->setColor('orange'); + } + + if ($user->getIsMailingList()) { + $tags[] = id(clone $tag) + ->setName(pht('Mailing List')) + ->setColor('orange'); + } + + if (!$user->getIsEmailVerified()) { + $tags[] = id(clone $tag) + ->setName(pht('Email Not Verified')) + ->setColor('violet'); } $header = id(new PHUIHeaderView()) - ->setHeader(array($user->getFullName(), $tag)) + ->setHeader($user->getFullName()) ->setImage($picture) ->setProfileHeader(true) ->addClass('people-profile-header'); + foreach ($tags as $tag) { + $header->addTag($tag); + } + require_celerity_resource('project-view-css'); if ($user->getIsDisabled()) { @@ -125,4 +109,24 @@ abstract class PhabricatorPeopleProfileController return $header; } + final protected function newNavigation( + PhabricatorUser $user, + $item_identifier) { + + $viewer = $this->getViewer(); + + $engine = id(new PhabricatorPeopleProfileMenuEngine()) + ->setViewer($viewer) + ->setController($this) + ->setProfileObject($user); + + $view_list = $engine->newProfileMenuItemViewList(); + + $view_list->setSelectedViewWithItemIdentifier($item_identifier); + + $navigation = $view_list->newNavigationView(); + + return $navigation; + } + } diff --git a/src/applications/people/controller/PhabricatorPeopleProfileManageController.php b/src/applications/people/controller/PhabricatorPeopleProfileManageController.php index e9faae3d62..5db38adafb 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfileManageController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfileManageController.php @@ -29,8 +29,9 @@ final class PhabricatorPeopleProfileManageController $properties = $this->buildPropertyView($user); $name = $user->getUsername(); - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorPeopleProfileMenuEngine::ITEM_MANAGE); + $nav = $this->newNavigation( + $user, + PhabricatorPeopleProfileMenuEngine::ITEM_MANAGE); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Manage')); @@ -92,6 +93,8 @@ final class PhabricatorPeopleProfileManageController PeopleDisableUsersCapability::CAPABILITY); $can_disable = ($has_disable && !$is_self); + $id = $user->getID(); + $welcome_engine = id(new PhabricatorPeopleWelcomeMailEngine()) ->setSender($viewer) ->setRecipient($user); @@ -103,7 +106,7 @@ final class PhabricatorPeopleProfileManageController id(new PhabricatorActionView()) ->setIcon('fa-pencil') ->setName(pht('Edit Profile')) - ->setHref($this->getApplicationURI('editprofile/'.$user->getID().'/')) + ->setHref($this->getApplicationURI('editprofile/'.$id.'/')) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); @@ -111,7 +114,7 @@ final class PhabricatorPeopleProfileManageController id(new PhabricatorActionView()) ->setIcon('fa-picture-o') ->setName(pht('Edit Profile Picture')) - ->setHref($this->getApplicationURI('picture/'.$user->getID().'/')) + ->setHref($this->getApplicationURI('picture/'.$id.'/')) ->setDisabled(!$can_edit) ->setWorkflow(!$can_edit)); @@ -137,7 +140,7 @@ final class PhabricatorPeopleProfileManageController ->setName($empower_name) ->setDisabled(!$can_admin) ->setWorkflow(true) - ->setHref($this->getApplicationURI('empower/'.$user->getID().'/'))); + ->setHref($this->getApplicationURI('empower/'.$id.'/'))); $curtain->addAction( id(new PhabricatorActionView()) @@ -145,7 +148,7 @@ final class PhabricatorPeopleProfileManageController ->setName(pht('Change Username')) ->setDisabled(!$is_admin) ->setWorkflow(true) - ->setHref($this->getApplicationURI('rename/'.$user->getID().'/'))); + ->setHref($this->getApplicationURI('rename/'.$id.'/'))); if ($user->getIsDisabled()) { $disable_icon = 'fa-check-circle-o'; @@ -161,19 +164,34 @@ final class PhabricatorPeopleProfileManageController ->setName(pht('Send Welcome Email')) ->setWorkflow(true) ->setDisabled(!$can_welcome) - ->setHref($this->getApplicationURI('welcome/'.$user->getID().'/'))); + ->setHref($this->getApplicationURI('welcome/'.$id.'/'))); $curtain->addAction( id(new PhabricatorActionView()) ->setType(PhabricatorActionView::TYPE_DIVIDER)); + if (!$user->getIsApproved()) { + $approve_action = id(new PhabricatorActionView()) + ->setIcon('fa-thumbs-up') + ->setName(pht('Approve User')) + ->setWorkflow(true) + ->setDisabled(!$is_admin) + ->setHref("/people/approve/{$id}/via/profile/"); + + if ($is_admin) { + $approve_action->setColor(PhabricatorActionView::GREEN); + } + + $curtain->addAction($approve_action); + } + $curtain->addAction( id(new PhabricatorActionView()) ->setIcon($disable_icon) ->setName($disable_name) ->setDisabled(!$can_disable) ->setWorkflow(true) - ->setHref($this->getApplicationURI('disable/'.$user->getID().'/'))); + ->setHref($this->getApplicationURI('disable/'.$id.'/'))); $curtain->addAction( id(new PhabricatorActionView()) @@ -181,7 +199,7 @@ final class PhabricatorPeopleProfileManageController ->setName(pht('Delete User')) ->setDisabled(!$can_admin) ->setWorkflow(true) - ->setHref($this->getApplicationURI('delete/'.$user->getID().'/'))); + ->setHref($this->getApplicationURI('delete/'.$id.'/'))); $curtain->addAction( id(new PhabricatorActionView()) diff --git a/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php b/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php index 5cab924255..92bb2e0b86 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfilePictureController.php @@ -157,13 +157,10 @@ final class PhabricatorPeopleProfilePictureController continue; } - $provider = PhabricatorAuthProvider::getEnabledProviderByKey( - $account->getProviderKey()); - if ($provider) { - $tip = pht('Picture From %s', $provider->getProviderName()); - } else { - $tip = pht('Picture From External Account'); - } + $config = $account->getProviderConfig(); + $provider = $config->getProvider(); + + $tip = pht('Picture From %s', $provider->getProviderName()); if ($file->isTransformableImage()) { $images[$file->getPHID()] = array( diff --git a/src/applications/people/controller/PhabricatorPeopleProfileRevisionsController.php b/src/applications/people/controller/PhabricatorPeopleProfileRevisionsController.php index 55baf0140f..0c7b6f6a1f 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfileRevisionsController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfileRevisionsController.php @@ -32,8 +32,9 @@ final class PhabricatorPeopleProfileRevisionsController $crumbs->addTextCrumb(pht('Recent Revisions')); $crumbs->setBorder(true); - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorPeopleProfileMenuEngine::ITEM_REVISIONS); + $nav = $this->newNavigation( + $user, + PhabricatorPeopleProfileMenuEngine::ITEM_REVISIONS); $view = id(new PHUITwoColumnView()) ->setHeader($header) diff --git a/src/applications/people/controller/PhabricatorPeopleProfileTasksController.php b/src/applications/people/controller/PhabricatorPeopleProfileTasksController.php index b843af8fc7..bc4e1432f1 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfileTasksController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfileTasksController.php @@ -32,8 +32,9 @@ final class PhabricatorPeopleProfileTasksController $crumbs->addTextCrumb(pht('Assigned Tasks')); $crumbs->setBorder(true); - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorPeopleProfileMenuEngine::ITEM_TASKS); + $nav = $this->newNavigation( + $user, + PhabricatorPeopleProfileMenuEngine::ITEM_TASKS); $view = id(new PHUITwoColumnView()) ->setHeader($header) diff --git a/src/applications/people/controller/PhabricatorPeopleProfileViewController.php b/src/applications/people/controller/PhabricatorPeopleProfileViewController.php index 0c32075932..b5c0e2b816 100644 --- a/src/applications/people/controller/PhabricatorPeopleProfileViewController.php +++ b/src/applications/people/controller/PhabricatorPeopleProfileViewController.php @@ -64,15 +64,16 @@ final class PhabricatorPeopleProfileViewController $calendar, )); - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorPeopleProfileMenuEngine::ITEM_PROFILE); + $navigation = $this->newNavigation( + $user, + PhabricatorPeopleProfileMenuEngine::ITEM_PROFILE); $crumbs = $this->buildApplicationCrumbs(); $crumbs->setBorder(true); return $this->newPage() ->setTitle($user->getUsername()) - ->setNavigation($nav) + ->setNavigation($navigation) ->setCrumbs($crumbs) ->setPageObjectPHIDs( array( @@ -175,6 +176,12 @@ final class PhabricatorPeopleProfileViewController return null; } + // Don't show calendar information for disabled users, since it's probably + // not useful or accurate and may be misleading. + if ($user->getIsDisabled()) { + return null; + } + $midnight = PhabricatorTime::getTodayMidnightDateTime($viewer); $week_end = clone $midnight; $week_end = $week_end->modify('+3 days'); diff --git a/src/applications/people/customfield/PhabricatorUserStatusField.php b/src/applications/people/customfield/PhabricatorUserStatusField.php index 2ae9158566..1716e8e198 100644 --- a/src/applications/people/customfield/PhabricatorUserStatusField.php +++ b/src/applications/people/customfield/PhabricatorUserStatusField.php @@ -30,6 +30,12 @@ final class PhabricatorUserStatusField $user = $this->getObject(); $viewer = $this->requireViewer(); + // Don't show availability for disabled users, since this is vaguely + // misleading to say "Availability: Available" and probably not useful. + if ($user->getIsDisabled()) { + return null; + } + return id(new PHUIUserAvailabilityView()) ->setViewer($viewer) ->setAvailableUser($user); diff --git a/src/applications/people/mail/PhabricatorPeopleUsernameMailEngine.php b/src/applications/people/mail/PhabricatorPeopleUsernameMailEngine.php new file mode 100644 index 0000000000..c954b7c38e --- /dev/null +++ b/src/applications/people/mail/PhabricatorPeopleUsernameMailEngine.php @@ -0,0 +1,60 @@ +newUsername = $new_username; + return $this; + } + + public function getNewUsername() { + return $this->newUsername; + } + + public function setOldUsername($old_username) { + $this->oldUsername = $old_username; + return $this; + } + + public function getOldUsername() { + return $this->oldUsername; + } + + public function validateMail() { + return; + } + + protected function newMail() { + $sender = $this->getSender(); + $recipient = $this->getRecipient(); + + $sender_username = $sender->getUsername(); + $sender_realname = $sender->getRealName(); + + $old_username = $this->getOldUsername(); + $new_username = $this->getNewUsername(); + + $body = sprintf( + "%s\n\n %s\n %s\n", + pht( + '%s (%s) has changed your Phabricator username.', + $sender_username, + $sender_realname), + pht( + 'Old Username: %s', + $old_username), + pht( + 'New Username: %s', + $new_username)); + + return id(new PhabricatorMetaMTAMail()) + ->addTos(array($recipient->getPHID())) + ->setSubject(pht('[Phabricator] Username Changed')) + ->setBody($body); + } + +} diff --git a/src/applications/people/menuitem/PhabricatorPeopleBadgesProfileMenuItem.php b/src/applications/people/menuitem/PhabricatorPeopleBadgesProfileMenuItem.php index 0e4da29b61..71f3aa1392 100644 --- a/src/applications/people/menuitem/PhabricatorPeopleBadgesProfileMenuItem.php +++ b/src/applications/people/menuitem/PhabricatorPeopleBadgesProfileMenuItem.php @@ -40,14 +40,14 @@ final class PhabricatorPeopleBadgesProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $user = $config->getProfileObject(); $id = $user->getID(); - $item = $this->newItem() - ->setHref("/people/badges/{$id}/") + $item = $this->newItemView() + ->setURI("/people/badges/{$id}/") ->setName($this->getDisplayName($config)) ->setIcon('fa-trophy'); diff --git a/src/applications/people/menuitem/PhabricatorPeopleCommitsProfileMenuItem.php b/src/applications/people/menuitem/PhabricatorPeopleCommitsProfileMenuItem.php index f1d8be1828..b6c1c446cc 100644 --- a/src/applications/people/menuitem/PhabricatorPeopleCommitsProfileMenuItem.php +++ b/src/applications/people/menuitem/PhabricatorPeopleCommitsProfileMenuItem.php @@ -40,14 +40,14 @@ final class PhabricatorPeopleCommitsProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $user = $config->getProfileObject(); $id = $user->getID(); - $item = $this->newItem() - ->setHref("/people/commits/{$id}/") + $item = $this->newItemView() + ->setURI("/people/commits/{$id}/") ->setName($this->getDisplayName($config)) ->setIcon('fa-code'); diff --git a/src/applications/people/menuitem/PhabricatorPeopleDetailsProfileMenuItem.php b/src/applications/people/menuitem/PhabricatorPeopleDetailsProfileMenuItem.php index d7d36b4ed5..61508ff515 100644 --- a/src/applications/people/menuitem/PhabricatorPeopleDetailsProfileMenuItem.php +++ b/src/applications/people/menuitem/PhabricatorPeopleDetailsProfileMenuItem.php @@ -35,16 +35,16 @@ final class PhabricatorPeopleDetailsProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $user = $config->getProfileObject(); - $href = urisprintf( + $uri = urisprintf( '/p/%s/', $user->getUsername()); - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName(pht('Profile')) ->setIcon('fa-user'); diff --git a/src/applications/people/menuitem/PhabricatorPeopleManageProfileMenuItem.php b/src/applications/people/menuitem/PhabricatorPeopleManageProfileMenuItem.php index 78d3dca49d..43d2271a79 100644 --- a/src/applications/people/menuitem/PhabricatorPeopleManageProfileMenuItem.php +++ b/src/applications/people/menuitem/PhabricatorPeopleManageProfileMenuItem.php @@ -40,14 +40,14 @@ final class PhabricatorPeopleManageProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $user = $config->getProfileObject(); $id = $user->getID(); - $item = $this->newItem() - ->setHref("/people/manage/{$id}/") + $item = $this->newItemView() + ->setURI("/people/manage/{$id}/") ->setName($this->getDisplayName($config)) ->setIcon('fa-gears'); diff --git a/src/applications/people/menuitem/PhabricatorPeoplePictureProfileMenuItem.php b/src/applications/people/menuitem/PhabricatorPeoplePictureProfileMenuItem.php index 938b7cf60a..3e3fc62bf0 100644 --- a/src/applications/people/menuitem/PhabricatorPeoplePictureProfileMenuItem.php +++ b/src/applications/people/menuitem/PhabricatorPeoplePictureProfileMenuItem.php @@ -28,52 +28,18 @@ final class PhabricatorPeoplePictureProfileMenuItem return array(); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $user = $config->getProfileObject(); - require_celerity_resource('people-picture-menu-item-css'); $picture = $user->getProfileImageURI(); $name = $user->getUsername(); - $classes = array(); - $classes[] = 'people-menu-image'; - if ($user->getIsDisabled()) { - $classes[] = 'phui-image-disabled'; - } + $item = $this->newItemView() + ->setDisabled($user->getIsDisabled()); - $href = urisprintf( - '/p/%s/', - $user->getUsername()); - - $photo = phutil_tag( - 'img', - array( - 'src' => $picture, - 'class' => implode(' ', $classes), - )); - - $can_edit = PhabricatorPolicyFilter::hasCapability( - $this->getViewer(), - $user, - PhabricatorPolicyCapability::CAN_EDIT); - - if ($can_edit) { - $id = $user->getID(); - $href = "/people/picture/{$id}/"; - } - - $view = phutil_tag_div('people-menu-image-container', $photo); - $view = phutil_tag( - 'a', - array( - 'href' => $href, - ), - $view); - - $item = $this->newItem() - ->appendChild($view); + $item->newProfileImage($picture); return array( $item, diff --git a/src/applications/people/menuitem/PhabricatorPeopleRevisionsProfileMenuItem.php b/src/applications/people/menuitem/PhabricatorPeopleRevisionsProfileMenuItem.php index 499fc1d7f4..cfa760fcd6 100644 --- a/src/applications/people/menuitem/PhabricatorPeopleRevisionsProfileMenuItem.php +++ b/src/applications/people/menuitem/PhabricatorPeopleRevisionsProfileMenuItem.php @@ -40,14 +40,14 @@ final class PhabricatorPeopleRevisionsProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $user = $config->getProfileObject(); $id = $user->getID(); - $item = $this->newItem() - ->setHref("/people/revisions/{$id}/") + $item = $this->newItemView() + ->setURI("/people/revisions/{$id}/") ->setName($this->getDisplayName($config)) ->setIcon('fa-gear'); diff --git a/src/applications/people/menuitem/PhabricatorPeopleTasksProfileMenuItem.php b/src/applications/people/menuitem/PhabricatorPeopleTasksProfileMenuItem.php index c2a5036521..5dea58cb29 100644 --- a/src/applications/people/menuitem/PhabricatorPeopleTasksProfileMenuItem.php +++ b/src/applications/people/menuitem/PhabricatorPeopleTasksProfileMenuItem.php @@ -40,14 +40,14 @@ final class PhabricatorPeopleTasksProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $user = $config->getProfileObject(); $id = $user->getID(); - $item = $this->newItem() - ->setHref("/people/tasks/{$id}/") + $item = $this->newItemView() + ->setURI("/people/tasks/{$id}/") ->setName($this->getDisplayName($config)) ->setIcon('fa-anchor'); diff --git a/src/applications/people/query/PhabricatorPeopleQuery.php b/src/applications/people/query/PhabricatorPeopleQuery.php index 542b685e29..5e737aaf90 100644 --- a/src/applications/people/query/PhabricatorPeopleQuery.php +++ b/src/applications/people/query/PhabricatorPeopleQuery.php @@ -379,11 +379,10 @@ final class PhabricatorPeopleQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $user = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $user->getID(), - 'username' => $user->getUsername(), + 'id' => (int)$object->getID(), + 'username' => $object->getUsername(), ); } diff --git a/src/applications/people/storage/PhabricatorExternalAccount.php b/src/applications/people/storage/PhabricatorExternalAccount.php index 4bc0fcae98..bde9588339 100644 --- a/src/applications/people/storage/PhabricatorExternalAccount.php +++ b/src/applications/people/storage/PhabricatorExternalAccount.php @@ -16,8 +16,10 @@ final class PhabricatorExternalAccount extends PhabricatorUserDAO protected $accountURI; protected $profileImagePHID; protected $properties = array(); + protected $providerConfigPHID; private $profileImageFile = self::ATTACHABLE; + private $providerConfig = self::ATTACHABLE; public function getProfileImageFile() { return $this->assertAttached($this->profileImageFile); @@ -65,13 +67,6 @@ final class PhabricatorExternalAccount extends PhabricatorUserDAO ) + parent::getConfiguration(); } - public function getPhabricatorUser() { - $tmp_usr = id(new PhabricatorUser()) - ->makeEphemeral() - ->setPHID($this->getPHID()); - return $tmp_usr; - } - public function getProviderKey() { return $this->getAccountType().':'.$this->getAccountDomain(); } @@ -93,13 +88,12 @@ final class PhabricatorExternalAccount extends PhabricatorUserDAO } public function isUsableForLogin() { - $key = $this->getProviderKey(); - $provider = PhabricatorAuthProvider::getEnabledProviderByKey($key); - - if (!$provider) { + $config = $this->getProviderConfig(); + if (!$config->getIsEnabled()) { return false; } + $provider = $config->getProvider(); if (!$provider->shouldAllowLogin()) { return false; } @@ -125,6 +119,14 @@ final class PhabricatorExternalAccount extends PhabricatorUserDAO return idx($map, $type, pht('"%s" User', $type)); } + public function attachProviderConfig(PhabricatorAuthProviderConfig $config) { + $this->providerConfig = $config; + return $this; + } + + public function getProviderConfig() { + return $this->assertAttached($this->providerConfig); + } /* -( PhabricatorPolicyInterface )----------------------------------------- */ diff --git a/src/applications/people/storage/PhabricatorUser.php b/src/applications/people/storage/PhabricatorUser.php index 429d4f7a7c..63e5a24db9 100644 --- a/src/applications/people/storage/PhabricatorUser.php +++ b/src/applications/people/storage/PhabricatorUser.php @@ -625,58 +625,9 @@ final class PhabricatorUser } } - public function sendUsernameChangeEmail( - PhabricatorUser $admin, - $old_username) { - - $admin_username = $admin->getUserName(); - $admin_realname = $admin->getRealName(); - $new_username = $this->getUserName(); - - $password_instructions = null; - if (PhabricatorPasswordAuthProvider::getPasswordProvider()) { - $engine = new PhabricatorAuthSessionEngine(); - $uri = $engine->getOneTimeLoginURI( - $this, - null, - PhabricatorAuthSessionEngine::ONETIME_USERNAME); - $password_instructions = sprintf( - "%s\n\n %s\n\n%s\n", - pht( - "If you use a password to login, you'll need to reset it ". - "before you can login again. You can reset your password by ". - "following this link:"), - $uri, - pht( - "And, of course, you'll need to use your new username to login ". - "from now on. If you use OAuth to login, nothing should change.")); - } - - $body = sprintf( - "%s\n\n %s\n %s\n\n%s", - pht( - '%s (%s) has changed your Phabricator username.', - $admin_username, - $admin_realname), - pht( - 'Old Username: %s', - $old_username), - pht( - 'New Username: %s', - $new_username), - $password_instructions); - - $mail = id(new PhabricatorMetaMTAMail()) - ->addTos(array($this->getPHID())) - ->setForceDelivery(true) - ->setSubject(pht('[Phabricator] Username Changed')) - ->setBody($body) - ->saveAndSend(); - } - public static function describeValidUsername() { return pht( - 'Usernames must contain only numbers, letters, period, underscore and '. + 'Usernames must contain only numbers, letters, period, underscore, and '. 'hyphen, and can not end with a period. They must have no more than %d '. 'characters.', new PhutilNumber(self::MAXIMUM_USERNAME_LENGTH)); @@ -1250,9 +1201,10 @@ final class PhabricatorUser $this->openTransaction(); $this->delete(); - $externals = id(new PhabricatorExternalAccount())->loadAllWhere( - 'userPHID = %s', - $this->getPHID()); + $externals = id(new PhabricatorExternalAccountQuery()) + ->setViewer($engine->getViewer()) + ->withUserPHIDs(array($this->getPHID())) + ->execute(); foreach ($externals as $external) { $external->delete(); } diff --git a/src/applications/people/storage/PhabricatorUserEmail.php b/src/applications/people/storage/PhabricatorUserEmail.php index 42946015de..572c7d6e8b 100644 --- a/src/applications/people/storage/PhabricatorUserEmail.php +++ b/src/applications/people/storage/PhabricatorUserEmail.php @@ -83,9 +83,8 @@ final class PhabricatorUserEmail extends PhabricatorUserDAO { */ public static function describeValidAddresses() { return pht( - "Email addresses should be in the form '%s'. The maximum ". - "length of an email address is %s character(s).", - 'user@domain.com', + 'Email addresses should be in the form "user@domain.com". The maximum '. + 'length of an email address is %s characters.', new PhutilNumber(self::MAX_ADDRESS_LENGTH)); } diff --git a/src/applications/people/storage/PhabricatorUserTransaction.php b/src/applications/people/storage/PhabricatorUserTransaction.php index 24edb2f5b5..81ca52a132 100644 --- a/src/applications/people/storage/PhabricatorUserTransaction.php +++ b/src/applications/people/storage/PhabricatorUserTransaction.php @@ -11,10 +11,6 @@ final class PhabricatorUserTransaction return PhabricatorPeopleUserPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'PhabricatorUserTransactionType'; } diff --git a/src/applications/people/typeahead/PhabricatorPeopleDatasource.php b/src/applications/people/typeahead/PhabricatorPeopleDatasource.php index df146808bb..d4a5ad96c7 100644 --- a/src/applications/people/typeahead/PhabricatorPeopleDatasource.php +++ b/src/applications/people/typeahead/PhabricatorPeopleDatasource.php @@ -19,7 +19,8 @@ final class PhabricatorPeopleDatasource $viewer = $this->getViewer(); $query = id(new PhabricatorPeopleQuery()) - ->setOrderVector(array('username')); + ->setOrderVector(array('username')) + ->needAvailability(true); if ($this->getPhase() == self::PHASE_PREFIX) { $prefix = $this->getPrefixQuery(); @@ -96,6 +97,14 @@ final class PhabricatorPeopleDatasource $result->setDisplayType($display_type); } + $until = $user->getAwayUntil(); + if ($until) { + $availability = $user->getDisplayAvailability(); + $color = PhabricatorCalendarEventInvitee::getAvailabilityColor( + $availability); + $result->setAvailabilityColor($color); + } + $results[] = $result; } diff --git a/src/applications/people/view/PhabricatorUserCardView.php b/src/applications/people/view/PhabricatorUserCardView.php index f1fc515f88..21cb468ba8 100644 --- a/src/applications/people/view/PhabricatorUserCardView.php +++ b/src/applications/people/view/PhabricatorUserCardView.php @@ -95,14 +95,17 @@ final class PhabricatorUserCardView extends AphrontTagView { 'fa-user-plus', phabricator_date($user->getDateCreated(), $viewer)); - if (PhabricatorApplication::isClassInstalledForViewer( - 'PhabricatorCalendarApplication', - $viewer)) { - $body[] = $this->addItem( - 'fa-calendar-o', - id(new PHUIUserAvailabilityView()) - ->setViewer($viewer) - ->setAvailableUser($user)); + $has_calendar = PhabricatorApplication::isClassInstalledForViewer( + 'PhabricatorCalendarApplication', + $viewer); + if ($has_calendar) { + if (!$user->getIsDisabled()) { + $body[] = $this->addItem( + 'fa-calendar-o', + id(new PHUIUserAvailabilityView()) + ->setViewer($viewer) + ->setAvailableUser($user)); + } } $classes[] = 'project-card-image'; @@ -150,8 +153,8 @@ final class PhabricatorUserCardView extends AphrontTagView { 'class' => 'project-card-inner', ), array( - $image, $header, + $image, )); return $card; diff --git a/src/applications/people/xaction/PhabricatorUserUsernameTransaction.php b/src/applications/people/xaction/PhabricatorUserUsernameTransaction.php index b6d23b3511..b436b76716 100644 --- a/src/applications/people/xaction/PhabricatorUserUsernameTransaction.php +++ b/src/applications/people/xaction/PhabricatorUserUsernameTransaction.php @@ -18,18 +18,27 @@ final class PhabricatorUserUsernameTransaction } public function applyExternalEffects($object, $value) { + $actor = $this->getActor(); $user = $object; + $old_username = $this->getOldValue(); + $new_username = $this->getNewValue(); + $this->newUserLog(PhabricatorUserLog::ACTION_CHANGE_USERNAME) - ->setOldValue($this->getOldValue()) - ->setNewValue($value) + ->setOldValue($old_username) + ->setNewValue($new_username) ->save(); // The SSH key cache currently includes usernames, so dirty it. See T12554 // for discussion. PhabricatorAuthSSHKeyQuery::deleteSSHKeyCache(); - $user->sendUsernameChangeEmail($this->getActor(), $this->getOldValue()); + id(new PhabricatorPeopleUsernameMailEngine()) + ->setSender($actor) + ->setRecipient($object) + ->setOldUsername($old_username) + ->setNewUsername($new_username) + ->sendMail(); } public function getTitle() { @@ -40,6 +49,15 @@ final class PhabricatorUserUsernameTransaction $this->renderNewValue()); } + public function getTitleForFeed() { + return pht( + '%s renamed %s from %s to %s.', + $this->renderAuthor(), + $this->renderObject(), + $this->renderOldValue(), + $this->renderNewValue()); + } + public function validateTransactions($object, array $xactions) { $actor = $this->getActor(); $errors = array(); diff --git a/src/applications/phame/controller/post/PhamePostViewController.php b/src/applications/phame/controller/post/PhamePostViewController.php index 11d94d2f94..4fb01c4def 100644 --- a/src/applications/phame/controller/post/PhamePostViewController.php +++ b/src/applications/phame/controller/post/PhamePostViewController.php @@ -304,6 +304,15 @@ final class PhamePostViewController private function loadAdjacentPosts(PhamePost $post) { $viewer = $this->getViewer(); + $pager = id(new AphrontCursorPagerView()) + ->setPageSize(1); + + $prev_pager = id(clone $pager) + ->setAfterID($post->getID()); + + $next_pager = id(clone $pager) + ->setBeforeID($post->getID()); + $query = id(new PhamePostQuery()) ->setViewer($viewer) ->withVisibility(array(PhameConstants::VISIBILITY_PUBLISHED)) @@ -311,12 +320,10 @@ final class PhamePostViewController ->setLimit(1); $prev = id(clone $query) - ->setAfterID($post->getID()) - ->execute(); + ->executeWithCursorPager($prev_pager); $next = id(clone $query) - ->setBeforeID($post->getID()) - ->execute(); + ->executeWithCursorPager($next_pager); return array(head($prev), head($next)); } diff --git a/src/applications/phame/query/PhamePostQuery.php b/src/applications/phame/query/PhamePostQuery.php index 85ef470cea..d7396e553f 100644 --- a/src/applications/phame/query/PhamePostQuery.php +++ b/src/applications/phame/query/PhamePostQuery.php @@ -171,15 +171,11 @@ final class PhamePostQuery extends PhabricatorCursorPagedPolicyAwareQuery { ); } - protected function getPagingValueMap($cursor, array $keys) { - $post = $this->loadCursorObject($cursor); - - $map = array( - 'datePublished' => $post->getDatePublished(), - 'id' => $post->getID(), + protected function newPagingMapFromPartialObject($object) { + return array( + 'id' => (int)$object->getID(), + 'datePublished' => (int)$object->getDatePublished(), ); - - return $map; } public function getQueryApplicationClass() { diff --git a/src/applications/phame/storage/PhameBlogTransaction.php b/src/applications/phame/storage/PhameBlogTransaction.php index d3d6a79d0a..c605510d7d 100644 --- a/src/applications/phame/storage/PhameBlogTransaction.php +++ b/src/applications/phame/storage/PhameBlogTransaction.php @@ -15,10 +15,6 @@ final class PhameBlogTransaction return PhabricatorPhameBlogPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'PhameBlogTransactionType'; } diff --git a/src/applications/phlux/query/PhluxVariableQuery.php b/src/applications/phlux/query/PhluxVariableQuery.php index 75abd044d0..8ec4bc9334 100644 --- a/src/applications/phlux/query/PhluxVariableQuery.php +++ b/src/applications/phlux/query/PhluxVariableQuery.php @@ -81,9 +81,9 @@ final class PhluxVariableQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $object = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( + 'id' => (int)$object->getID(), 'key' => $object->getVariableKey(), ); } diff --git a/src/applications/phlux/storage/PhluxTransaction.php b/src/applications/phlux/storage/PhluxTransaction.php index 1224caf201..b1624d581a 100644 --- a/src/applications/phlux/storage/PhluxTransaction.php +++ b/src/applications/phlux/storage/PhluxTransaction.php @@ -13,10 +13,6 @@ final class PhluxTransaction extends PhabricatorApplicationTransaction { return PhluxVariablePHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getTitle() { $author_phid = $this->getAuthorPHID(); diff --git a/src/applications/pholio/view/PholioMockImagesView.php b/src/applications/pholio/view/PholioMockImagesView.php index 99645c4a91..786de07cfd 100644 --- a/src/applications/pholio/view/PholioMockImagesView.php +++ b/src/applications/pholio/view/PholioMockImagesView.php @@ -133,7 +133,7 @@ final class PholioMockImagesView extends AphrontView { ); $login_uri = id(new PhutilURI('/login/')) - ->setQueryParam('next', (string)$this->getRequestURI()); + ->replaceQueryParam('next', (string)$this->getRequestURI()); $config = array( 'mockID' => $mock->getID(), diff --git a/src/applications/phortune/action/PhortuneAddPaymentMethodAction.php b/src/applications/phortune/action/PhortuneAddPaymentMethodAction.php new file mode 100644 index 0000000000..09a8cd2f5d --- /dev/null +++ b/src/applications/phortune/action/PhortuneAddPaymentMethodAction.php @@ -0,0 +1,22 @@ +getID(); + $params = array( + 'merchantID' => $merchant->getID(), + 'cartID' => $cart->getID(), + ); + $payment_method_uri = $this->getApplicationURI("{$account_id}/card/new/"); - $payment_method_uri = new PhutilURI($payment_method_uri); - $payment_method_uri->setQueryParams( - array( - 'merchantID' => $merchant->getID(), - 'cartID' => $cart->getID(), - )); + $payment_method_uri = new PhutilURI($payment_method_uri, $params); $form = id(new AphrontFormView()) ->setUser($viewer) diff --git a/src/applications/phortune/controller/payment/PhortunePaymentMethodCreateController.php b/src/applications/phortune/controller/payment/PhortunePaymentMethodCreateController.php index 87bddd4d33..c068862631 100644 --- a/src/applications/phortune/controller/payment/PhortunePaymentMethodCreateController.php +++ b/src/applications/phortune/controller/payment/PhortunePaymentMethodCreateController.php @@ -73,6 +73,7 @@ final class PhortunePaymentMethodCreateController $provider = $providers[$provider_id]; $errors = array(); + $display_exception = null; if ($request->isFormPost() && $request->getBool('isProviderForm')) { $method = id(new PhortunePaymentMethod()) ->setAccountPHID($account->getPHID()) @@ -81,6 +82,15 @@ final class PhortunePaymentMethodCreateController ->setProviderPHID($provider->getProviderConfig()->getPHID()) ->setStatus(PhortunePaymentMethod::STATUS_ACTIVE); + // Limit the rate at which you can attempt to add payment methods. This + // is intended as a line of defense against using Phortune to validate a + // large list of stolen credit card numbers. + + PhabricatorSystemActionEngine::willTakeAction( + array($viewer->getPHID()), + new PhortuneAddPaymentMethodAction(), + 1); + if (!$errors) { $errors = $this->processClientErrors( $provider, @@ -107,14 +117,23 @@ final class PhortunePaymentMethodCreateController } if (!$errors) { - $errors = $provider->createPaymentMethodFromRequest( - $request, - $method, - $client_token); + try { + $provider->createPaymentMethodFromRequest( + $request, + $method, + $client_token); + } catch (PhortuneDisplayException $exception) { + $display_exception = $exception; + } catch (Exception $ex) { + $errors = array( + pht('There was an error adding this payment method:'), + $ex->getMessage(), + ); + } } } - if (!$errors) { + if (!$errors && !$display_exception) { $method->save(); // If we added this method on a cart flow, return to the cart to @@ -124,7 +143,7 @@ final class PhortunePaymentMethodCreateController "cart/{$cart_id}/checkout/?paymentMethodID=".$method->getID()); } else if ($subscription_id) { $next_uri = new PhutilURI($cancel_uri); - $next_uri->setQueryParam('added', true); + $next_uri->replaceQueryParam('added', true); } else { $account_uri = $this->getApplicationURI($account->getID().'/'); $next_uri = new PhutilURI($account_uri); @@ -133,13 +152,17 @@ final class PhortunePaymentMethodCreateController return id(new AphrontRedirectResponse())->setURI($next_uri); } else { - $dialog = id(new AphrontDialogView()) - ->setUser($viewer) - ->setTitle(pht('Error Adding Payment Method')) - ->appendChild(id(new PHUIInfoView())->setErrors($errors)) - ->addCancelButton($request->getRequestURI()); + if ($display_exception) { + $dialog_body = $display_exception->getView(); + } else { + $dialog_body = id(new PHUIInfoView()) + ->setErrors($errors); + } - return id(new AphrontDialogResponse())->setDialog($dialog); + return $this->newDialog() + ->setTitle(pht('Error Adding Payment Method')) + ->appendChild($dialog_body) + ->addCancelButton($request->getRequestURI()); } } diff --git a/src/applications/phortune/controller/subscription/PhortuneSubscriptionEditController.php b/src/applications/phortune/controller/subscription/PhortuneSubscriptionEditController.php index e7287f3d29..04367a88a0 100644 --- a/src/applications/phortune/controller/subscription/PhortuneSubscriptionEditController.php +++ b/src/applications/phortune/controller/subscription/PhortuneSubscriptionEditController.php @@ -118,8 +118,8 @@ final class PhortuneSubscriptionEditController extends PhortuneController { $uri = $this->getApplicationURI($account->getID().'/card/new/'); $uri = new PhutilURI($uri); - $uri->setQueryParam('merchantID', $merchant->getID()); - $uri->setQueryParam('subscriptionID', $subscription->getID()); + $uri->replaceQueryParam('merchantID', $merchant->getID()); + $uri->replaceQueryParam('subscriptionID', $subscription->getID()); $add_method_button = phutil_tag( 'a', diff --git a/src/applications/phortune/exception/PhortuneDisplayException.php b/src/applications/phortune/exception/PhortuneDisplayException.php new file mode 100644 index 0000000000..7b2bbf6875 --- /dev/null +++ b/src/applications/phortune/exception/PhortuneDisplayException.php @@ -0,0 +1,15 @@ +view = $view; + return $this; + } + + public function getView() { + return $this->view; + } + +} diff --git a/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php b/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php index 078141f8a1..262606ca62 100644 --- a/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php +++ b/src/applications/phortune/provider/PhortunePayPalPaymentProvider.php @@ -348,12 +348,14 @@ final class PhortunePayPalPaymentProvider extends PhortunePaymentProvider { ->setRawPayPalQuery('SetExpressCheckout', $params) ->resolve(); - $uri = new PhutilURI('https://www.sandbox.paypal.com/cgi-bin/webscr'); - $uri->setQueryParams( - array( - 'cmd' => '_express-checkout', - 'token' => $result['TOKEN'], - )); + $params = array( + 'cmd' => '_express-checkout', + 'token' => $result['TOKEN'], + ); + + $uri = new PhutilURI( + 'https://www.sandbox.paypal.com/cgi-bin/webscr', + $params); $cart->setMetadataValue('provider.checkoutURI', (string)$uri); $cart->save(); diff --git a/src/applications/phortune/provider/PhortunePaymentProvider.php b/src/applications/phortune/provider/PhortunePaymentProvider.php index 90e354c5dc..57b2956ecb 100644 --- a/src/applications/phortune/provider/PhortunePaymentProvider.php +++ b/src/applications/phortune/provider/PhortunePaymentProvider.php @@ -273,8 +273,7 @@ abstract class PhortunePaymentProvider extends Phobject { $app = PhabricatorApplication::getByClass('PhabricatorPhortuneApplication'); $path = $app->getBaseURI().'provider/'.$id.'/'.$action.'/'; - $uri = new PhutilURI($path); - $uri->setQueryParams($params); + $uri = new PhutilURI($path, $params); if ($local) { return $uri; diff --git a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php index bdaa4294b2..0463881016 100644 --- a/src/applications/phortune/provider/PhortuneStripePaymentProvider.php +++ b/src/applications/phortune/provider/PhortuneStripePaymentProvider.php @@ -233,8 +233,6 @@ final class PhortuneStripePaymentProvider extends PhortunePaymentProvider { array $token) { $this->loadStripeAPILibraries(); - $errors = array(); - $secret_key = $this->getSecretKey(); $stripe_token = $token['stripeCardToken']; @@ -253,7 +251,15 @@ final class PhortuneStripePaymentProvider extends PhortunePaymentProvider { // the card more than once. We create one Customer for each card; // they do not map to PhortuneAccounts because we allow an account to // have more than one active card. - $customer = Stripe_Customer::create($params, $secret_key); + try { + $customer = Stripe_Customer::create($params, $secret_key); + } catch (Stripe_CardError $ex) { + $display_exception = $this->newDisplayExceptionFromCardError($ex); + if ($display_exception) { + throw $display_exception; + } + throw $ex; + } $card = $info->card; @@ -267,8 +273,6 @@ final class PhortuneStripePaymentProvider extends PhortunePaymentProvider { 'stripe.customerID' => $customer->id, 'stripe.cardToken' => $stripe_token, )); - - return $errors; } public function renderCreatePaymentMethodForm( @@ -383,4 +387,84 @@ final class PhortuneStripePaymentProvider extends PhortunePaymentProvider { require_once $root.'/externals/stripe-php/lib/Stripe.php'; } + + private function newDisplayExceptionFromCardError(Stripe_CardError $ex) { + $body = $ex->getJSONBody(); + if (!$body) { + return null; + } + + $map = idx($body, 'error'); + if (!$map) { + return null; + } + + $view = array(); + + $message = idx($map, 'message'); + + $view[] = id(new PHUIInfoView()) + ->setErrors(array($message)); + + $view[] = phutil_tag( + 'div', + array( + 'class' => 'mlt mlb', + ), + pht('Additional details about this error:')); + + $rows = array(); + + $rows[] = array( + pht('Error Code'), + idx($map, 'code'), + ); + + $rows[] = array( + pht('Error Type'), + idx($map, 'type'), + ); + + $param = idx($map, 'param'); + if (strlen($param)) { + $rows[] = array( + pht('Error Param'), + $param, + ); + } + + $decline_code = idx($map, 'decline_code'); + if (strlen($decline_code)) { + $rows[] = array( + pht('Decline Code'), + $decline_code, + ); + } + + $doc_url = idx($map, 'doc_url'); + if ($doc_url) { + $rows[] = array( + pht('Learn More'), + phutil_tag( + 'a', + array( + 'href' => $doc_url, + 'target' => '_blank', + ), + $doc_url), + ); + } + + $view[] = id(new AphrontTableView($rows)) + ->setColumnClasses( + array( + 'header', + 'wide', + )); + + return id(new PhortuneDisplayException(get_class($ex))) + ->setView($view); + } + + } diff --git a/src/applications/phortune/storage/PhortuneAccountTransaction.php b/src/applications/phortune/storage/PhortuneAccountTransaction.php index 6733cbe879..e333ef4a26 100644 --- a/src/applications/phortune/storage/PhortuneAccountTransaction.php +++ b/src/applications/phortune/storage/PhortuneAccountTransaction.php @@ -11,10 +11,6 @@ final class PhortuneAccountTransaction return PhortuneAccountPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'PhortuneAccountTransactionType'; } diff --git a/src/applications/phortune/storage/PhortuneCartTransaction.php b/src/applications/phortune/storage/PhortuneCartTransaction.php index 41790011a2..c7a1e36e73 100644 --- a/src/applications/phortune/storage/PhortuneCartTransaction.php +++ b/src/applications/phortune/storage/PhortuneCartTransaction.php @@ -19,10 +19,6 @@ final class PhortuneCartTransaction return PhortuneCartPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function shouldHideForMail(array $xactions) { switch ($this->getTransactionType()) { case self::TYPE_CREATED: diff --git a/src/applications/phortune/storage/PhortuneMerchantTransaction.php b/src/applications/phortune/storage/PhortuneMerchantTransaction.php index 3befb12212..976259c534 100644 --- a/src/applications/phortune/storage/PhortuneMerchantTransaction.php +++ b/src/applications/phortune/storage/PhortuneMerchantTransaction.php @@ -11,10 +11,6 @@ final class PhortuneMerchantTransaction return PhortuneMerchantPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'PhortuneMerchantTransactionType'; } diff --git a/src/applications/phortune/storage/PhortunePaymentProviderConfigTransaction.php b/src/applications/phortune/storage/PhortunePaymentProviderConfigTransaction.php index 08872d48fd..9241c7ae04 100644 --- a/src/applications/phortune/storage/PhortunePaymentProviderConfigTransaction.php +++ b/src/applications/phortune/storage/PhortunePaymentProviderConfigTransaction.php @@ -17,10 +17,6 @@ final class PhortunePaymentProviderConfigTransaction return PhortunePaymentProviderPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getTitle() { $author_phid = $this->getAuthorPHID(); diff --git a/src/applications/phrequent/query/PhrequentUserTimeQuery.php b/src/applications/phrequent/query/PhrequentUserTimeQuery.php index cf5122c020..6400771a00 100644 --- a/src/applications/phrequent/query/PhrequentUserTimeQuery.php +++ b/src/applications/phrequent/query/PhrequentUserTimeQuery.php @@ -133,12 +133,11 @@ final class PhrequentUserTimeQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $usertime = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $usertime->getID(), - 'start' => $usertime->getDateStarted(), - 'end' => $usertime->getDateEnded(), + 'id' => (int)$object->getID(), + 'start' => (int)$object->getDateStarted(), + 'end' => (int)$object->getDateEnded(), ); } diff --git a/src/applications/phriction/editor/PhrictionTransactionEditor.php b/src/applications/phriction/editor/PhrictionTransactionEditor.php index d09ff5d556..1476b24c46 100644 --- a/src/applications/phriction/editor/PhrictionTransactionEditor.php +++ b/src/applications/phriction/editor/PhrictionTransactionEditor.php @@ -229,9 +229,14 @@ final class PhrictionTransactionEditor foreach ($xactions as $xaction) { switch ($xaction->getTransactionType()) { case PhrictionDocumentContentTransaction::TRANSACTIONTYPE: - $uri = id(new PhutilURI('/phriction/diff/'.$object->getID().'/')) - ->alter('l', $this->getOldContent()->getVersion()) - ->alter('r', $this->getNewContent()->getVersion()); + $params = array( + 'l' => $this->getOldContent()->getVersion(), + 'r' => $this->getNewContent()->getVersion(), + ); + + $path = '/phriction/diff/'.$object->getID().'/'; + $uri = new PhutilURI($path, $params); + $this->contentDiffURI = (string)$uri; break 2; default: diff --git a/src/applications/phriction/query/PhrictionDocumentQuery.php b/src/applications/phriction/query/PhrictionDocumentQuery.php index 5f508ad804..e7b5a0529e 100644 --- a/src/applications/phriction/query/PhrictionDocumentQuery.php +++ b/src/applications/phriction/query/PhrictionDocumentQuery.php @@ -168,10 +168,20 @@ final class PhrictionDocumentQuery return $documents; } + protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { + $select = parent::buildSelectClauseParts($conn); + + if ($this->shouldJoinContentTable()) { + $select[] = qsprintf($conn, 'c.title'); + } + + return $select; + } + protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { $joins = parent::buildJoinClauseParts($conn); - if ($this->getOrderVector()->containsKey('updated')) { + if ($this->shouldJoinContentTable()) { $content_dao = new PhrictionContent(); $joins[] = qsprintf( $conn, @@ -182,6 +192,14 @@ final class PhrictionDocumentQuery return $joins; } + private function shouldJoinContentTable() { + if ($this->getOrderVector()->containsKey('title')) { + return true; + } + + return false; + } + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); @@ -354,35 +372,25 @@ final class PhrictionDocumentQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $document = $this->loadCursorObject($cursor); + protected function newPagingMapFromCursorObject( + PhabricatorQueryCursor $cursor, + array $keys) { + + $document = $cursor->getObject(); $map = array( - 'id' => $document->getID(), + 'id' => (int)$document->getID(), 'depth' => $document->getDepth(), - 'updated' => $document->getEditedEpoch(), + 'updated' => (int)$document->getEditedEpoch(), ); - foreach ($keys as $key) { - switch ($key) { - case 'title': - $map[$key] = $document->getContent()->getTitle(); - break; - } + if (isset($keys['title'])) { + $map['title'] = $cursor->getRawRowProperty('title'); } return $map; } - protected function willExecuteCursorQuery( - PhabricatorCursorPagedPolicyAwareQuery $query) { - $vector = $this->getOrderVector(); - - if ($vector->containsKey('title')) { - $query->needContent(true); - } - } - protected function getPrimaryTableAlias() { return 'd'; } diff --git a/src/applications/phurl/query/PhabricatorPhurlURLQuery.php b/src/applications/phurl/query/PhabricatorPhurlURLQuery.php index 74cced0771..6efbbd5b4c 100644 --- a/src/applications/phurl/query/PhabricatorPhurlURLQuery.php +++ b/src/applications/phurl/query/PhabricatorPhurlURLQuery.php @@ -50,13 +50,6 @@ final class PhabricatorPhurlURLQuery return $this; } - protected function getPagingValueMap($cursor, array $keys) { - $url = $this->loadCursorObject($cursor); - return array( - 'id' => $url->getID(), - ); - } - protected function loadPage() { return $this->loadStandardPage($this->newResultObject()); } diff --git a/src/applications/policy/codex/PhabricatorPolicyCodex.php b/src/applications/policy/codex/PhabricatorPolicyCodex.php index 060a798e0a..48e6d2f557 100644 --- a/src/applications/policy/codex/PhabricatorPolicyCodex.php +++ b/src/applications/policy/codex/PhabricatorPolicyCodex.php @@ -29,6 +29,10 @@ abstract class PhabricatorPolicyCodex return array(); } + public function getPolicyForEdit($capability) { + return $this->getObject()->getPolicy($capability); + } + public function getDefaultPolicy() { return PhabricatorPolicyQuery::getDefaultPolicyForObject( $this->viewer, diff --git a/src/applications/policy/editor/PhabricatorPolicyEditEngineExtension.php b/src/applications/policy/editor/PhabricatorPolicyEditEngineExtension.php index 568b7bc399..14a4768f21 100644 --- a/src/applications/policy/editor/PhabricatorPolicyEditEngineExtension.php +++ b/src/applications/policy/editor/PhabricatorPolicyEditEngineExtension.php @@ -68,6 +68,14 @@ final class PhabricatorPolicyEditEngineExtension ), ); + if ($object instanceof PhabricatorPolicyCodexInterface) { + $codex = PhabricatorPolicyCodex::newFromObject( + $object, + $viewer); + } else { + $codex = null; + } + $fields = array(); foreach ($map as $type => $spec) { if (empty($types[$type])) { @@ -82,6 +90,18 @@ final class PhabricatorPolicyEditEngineExtension $conduit_description = $spec['description.conduit']; $edit = $spec['edit']; + // Objects may present a policy value to the edit workflow that is + // different from their nominal policy value: for example, when tasks + // are locked, they appear as "Editable By: No One" to other applications + // but we still want to edit the actual policy stored in the database + // when we show the user a form with a policy control in it. + + if ($codex) { + $policy_value = $codex->getPolicyForEdit($capability); + } else { + $policy_value = $object->getPolicy($capability); + } + $policy_field = id(new PhabricatorPolicyEditField()) ->setKey($key) ->setLabel($label) @@ -94,7 +114,7 @@ final class PhabricatorPolicyEditEngineExtension ->setDescription($description) ->setConduitDescription($conduit_description) ->setConduitTypeDescription(pht('New policy PHID or constant.')) - ->setValue($object->getPolicy($capability)); + ->setValue($policy_value); $fields[] = $policy_field; if ($object instanceof PhabricatorSpacesInterface) { diff --git a/src/applications/policy/filter/PhabricatorPolicyFilter.php b/src/applications/policy/filter/PhabricatorPolicyFilter.php index fb03936ec2..a5c9f356f4 100644 --- a/src/applications/policy/filter/PhabricatorPolicyFilter.php +++ b/src/applications/policy/filter/PhabricatorPolicyFilter.php @@ -175,9 +175,10 @@ final class PhabricatorPolicyFilter extends Phobject { if (!in_array($capability, $object_capabilities)) { throw new Exception( pht( - "Testing for capability '%s' on an object which does ". - "not have that capability!", - $capability)); + 'Testing for capability "%s" on an object ("%s") which does '. + 'not support that capability.', + $capability, + get_class($object))); } $policy = $this->getObjectPolicy($object, $capability); diff --git a/src/applications/policy/management/PhabricatorPolicyManagementUnlockWorkflow.php b/src/applications/policy/management/PhabricatorPolicyManagementUnlockWorkflow.php index 33f7e209c2..64a32b7186 100644 --- a/src/applications/policy/management/PhabricatorPolicyManagementUnlockWorkflow.php +++ b/src/applications/policy/management/PhabricatorPolicyManagementUnlockWorkflow.php @@ -8,40 +8,72 @@ final class PhabricatorPolicyManagementUnlockWorkflow ->setName('unlock') ->setSynopsis( pht( - 'Unlock an object by setting its policies to allow anyone to view '. - 'and edit it.')) - ->setExamples('**unlock** D123') + 'Unlock an object which has policies that prevent it from being '. + 'viewed or edited.')) + ->setExamples('**unlock** --view __user__ __object__') ->setArguments( array( array( - 'name' => 'objects', - 'wildcard' => true, + 'name' => 'view', + 'param' => 'username', + 'help' => pht( + 'Change the view policy of an object so that the specified '. + 'user may view it.'), + ), + array( + 'name' => 'edit', + 'param' => 'username', + 'help' => pht( + 'Change the edit policy of an object so that the specified '. + 'user may edit it.'), + ), + array( + 'name' => 'owner', + 'param' => 'username', + 'help' => pht( + 'Change the owner of an object to the specified user.'), + ), + array( + 'name' => 'objects', + 'wildcard' => true, ), )); } public function execute(PhutilArgumentParser $args) { - $console = PhutilConsole::getConsole(); $viewer = $this->getViewer(); - $obj_names = $args->getArg('objects'); - if (!$obj_names) { + $object_names = $args->getArg('objects'); + if (!$object_names) { throw new PhutilArgumentUsageException( pht('Specify the name of an object to unlock.')); - } else if (count($obj_names) > 1) { + } else if (count($object_names) > 1) { throw new PhutilArgumentUsageException( pht('Specify the name of exactly one object to unlock.')); } + $object_name = head($object_names); + $object = id(new PhabricatorObjectQuery()) ->setViewer($viewer) - ->withNames($obj_names) + ->withNames(array($object_name)) ->executeOne(); - if (!$object) { - $name = head($obj_names); throw new PhutilArgumentUsageException( - pht("No such object '%s'!", $name)); + pht( + 'Unable to find any object with the specified name ("%s").', + $object_name)); + } + + $view_user = $this->loadUser($args->getArg('view')); + $edit_user = $this->loadUser($args->getArg('edit')); + $owner_user = $this->loadUser($args->getArg('owner')); + + if (!$view_user && !$edit_user && !$owner_user) { + throw new PhutilArgumentUsageException( + pht( + 'Choose which capabilities to unlock with "--view", "--edit", '. + 'or "--owner".')); } $handle = id(new PhabricatorHandleQuery()) @@ -49,84 +81,73 @@ final class PhabricatorPolicyManagementUnlockWorkflow ->withPHIDs(array($object->getPHID())) ->executeOne(); - if ($object instanceof PhabricatorApplication) { - $application = $object; + echo tsprintf( + "** %s ** %s\n", + pht('UNLOCKING'), + pht('Unlocking: %s', $handle->getFullName())); - $console->writeOut( - "%s\n", - pht('Unlocking Application: %s', $handle->getFullName())); + $engine = PhabricatorUnlockEngine::newUnlockEngineForObject($object); - // For applications, we can't unlock them in a normal way and don't want - // to unlock every capability, just view and edit. - $capabilities = array( - PhabricatorPolicyCapability::CAN_VIEW, - PhabricatorPolicyCapability::CAN_EDIT, - ); + $xactions = array(); + if ($view_user) { + $xactions[] = $engine->newUnlockViewTransactions($object, $view_user); + } + if ($edit_user) { + $xactions[] = $engine->newUnlockEditTransactions($object, $edit_user); + } + if ($owner_user) { + $xactions[] = $engine->newUnlockOwnerTransactions($object, $owner_user); + } + $xactions = array_mergev($xactions); - $key = 'phabricator.application-settings'; - $config_entry = PhabricatorConfigEntry::loadConfigEntry($key); - $value = $config_entry->getValue(); + $policy_application = new PhabricatorPolicyApplication(); + $content_source = $this->newContentSource(); - foreach ($capabilities as $capability) { - if ($application->isCapabilityEditable($capability)) { - unset($value[$application->getPHID()]['policy'][$capability]); - } - } + $editor = $object->getApplicationTransactionEditor() + ->setActor($viewer) + ->setActingAsPHID($policy_application->getPHID()) + ->setContinueOnMissingFields(true) + ->setContinueOnNoEffect(true) + ->setContentSource($content_source); - $config_entry->setValue($value); - $config_entry->save(); + $editor->applyTransactions($object, $xactions); - $console->writeOut("%s\n", pht('Saved application.')); + echo tsprintf( + "** %s ** %s\n", + pht('UNLOCKED'), + pht('Modified object policies.')); - return 0; + $uri = $handle->getURI(); + if (strlen($uri)) { + echo tsprintf( + "\n **%s**: __%s__\n\n", + pht('Object URI'), + PhabricatorEnv::getURI($uri)); } - $console->writeOut("%s\n", pht('Unlocking: %s', $handle->getFullName())); + return 0; + } - $updated = false; - foreach ($object->getCapabilities() as $capability) { - switch ($capability) { - case PhabricatorPolicyCapability::CAN_VIEW: - try { - $object->setViewPolicy(PhabricatorPolicies::POLICY_USER); - $console->writeOut("%s\n", pht('Unlocked view policy.')); - $updated = true; - } catch (Exception $ex) { - $console->writeOut("%s\n", pht('View policy is not mutable.')); - } - break; - case PhabricatorPolicyCapability::CAN_EDIT: - try { - $object->setEditPolicy(PhabricatorPolicies::POLICY_USER); - $console->writeOut("%s\n", pht('Unlocked edit policy.')); - $updated = true; - } catch (Exception $ex) { - $console->writeOut("%s\n", pht('Edit policy is not mutable.')); - } - break; - case PhabricatorPolicyCapability::CAN_JOIN: - try { - $object->setJoinPolicy(PhabricatorPolicies::POLICY_USER); - $console->writeOut("%s\n", pht('Unlocked join policy.')); - $updated = true; - } catch (Exception $ex) { - $console->writeOut("%s\n", pht('Join policy is not mutable.')); - } - break; - } + private function loadUser($username) { + $viewer = $this->getViewer(); + + if ($username === null) { + return null; } - if ($updated) { - $object->save(); - $console->writeOut("%s\n", pht('Saved object.')); - } else { - $console->writeOut( - "%s\n", + $user = id(new PhabricatorPeopleQuery()) + ->setViewer($viewer) + ->withUsernames(array($username)) + ->executeOne(); + + if (!$user) { + throw new PhutilArgumentUsageException( pht( - 'Object has no mutable policies. Try unlocking parent/container '. - 'object instead. For example, to gain access to a commit, unlock '. - 'the repository it belongs to.')); + 'No user with username "%s" exists.', + $username)); } + + return $user; } } diff --git a/src/applications/ponder/view/PonderAddAnswerView.php b/src/applications/ponder/view/PonderAddAnswerView.php index 43bfd0d6ba..20c52dac8e 100644 --- a/src/applications/ponder/view/PonderAddAnswerView.php +++ b/src/applications/ponder/view/PonderAddAnswerView.php @@ -66,7 +66,7 @@ final class PonderAddAnswerView extends AphrontView { if (!$viewer->isLoggedIn()) { $login_href = id(new PhutilURI('/auth/start/')) - ->setQueryParam('next', '/Q'.$question->getID()); + ->replaceQueryParam('next', '/Q'.$question->getID()); $form = id(new PHUIFormLayoutView()) ->addClass('login-to-participate') ->appendChild( diff --git a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php index e50c83ab5a..186ac7dea4 100644 --- a/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php +++ b/src/applications/project/__tests__/PhabricatorProjectCoreTestCase.php @@ -1008,29 +1008,32 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { $task2->getPHID(), $task1->getPHID(), ); - $this->assertTasksInColumn($expect, $user, $board, $column); + $label = pht('Simple move'); + $this->assertTasksInColumn($expect, $user, $board, $column, $label); // Move the second task after the first task. $options = array( - 'afterPHID' => $task1->getPHID(), + 'afterPHIDs' => array($task1->getPHID()), ); $this->moveToColumn($user, $board, $task2, $column, $column, $options); $expect = array( $task1->getPHID(), $task2->getPHID(), ); - $this->assertTasksInColumn($expect, $user, $board, $column); + $label = pht('With afterPHIDs'); + $this->assertTasksInColumn($expect, $user, $board, $column, $label); // Move the second task before the first task. $options = array( - 'beforePHID' => $task1->getPHID(), + 'beforePHIDs' => array($task1->getPHID()), ); $this->moveToColumn($user, $board, $task2, $column, $column, $options); $expect = array( $task2->getPHID(), $task1->getPHID(), ); - $this->assertTasksInColumn($expect, $user, $board, $column); + $label = pht('With beforePHIDs'); + $this->assertTasksInColumn($expect, $user, $board, $column, $label); } public function testMilestoneMoves() { @@ -1333,7 +1336,8 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { array $expect, PhabricatorUser $viewer, PhabricatorProject $board, - PhabricatorProjectColumn $column) { + PhabricatorProjectColumn $column, + $label = null) { $engine = id(new PhabricatorBoardLayoutEngine()) ->setViewer($viewer) @@ -1346,7 +1350,7 @@ final class PhabricatorProjectCoreTestCase extends PhabricatorTestCase { $column->getPHID()); $object_phids = array_values($object_phids); - $this->assertEqual($expect, $object_phids); + $this->assertEqual($expect, $object_phids, $label); } private function addColumn( diff --git a/src/applications/project/application/PhabricatorProjectApplication.php b/src/applications/project/application/PhabricatorProjectApplication.php index 31791416fd..eaf39d3b37 100644 --- a/src/applications/project/application/PhabricatorProjectApplication.php +++ b/src/applications/project/application/PhabricatorProjectApplication.php @@ -89,6 +89,18 @@ final class PhabricatorProjectApplication extends PhabricatorApplication { 'background/' => 'PhabricatorProjectBoardBackgroundController', ), + 'column/' => array( + 'remove/(?P\d+)/' => + 'PhabricatorProjectColumnRemoveTriggerController', + ), + 'trigger/' => array( + $this->getQueryRoutePattern() => + 'PhabricatorProjectTriggerListController', + '(?P[1-9]\d*)/' => + 'PhabricatorProjectTriggerViewController', + $this->getEditRoutePattern('edit/') => + 'PhabricatorProjectTriggerEditController', + ), 'update/(?P[1-9]\d*)/(?P[^/]+)/' => 'PhabricatorProjectUpdateController', 'manage/(?P[1-9]\d*)/' => 'PhabricatorProjectManageController', diff --git a/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php b/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php index b229f59ecb..c70c211398 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardBackgroundController.php @@ -55,7 +55,7 @@ final class PhabricatorProjectBoardBackgroundController $nav = $this->getProfileMenu(); $crumbs = id($this->buildApplicationCrumbs()) - ->addTextCrumb(pht('Workboard'), "/project/board/{$board_id}/") + ->addTextCrumb(pht('Workboard'), $board->getWorkboardURI()) ->addTextCrumb(pht('Manage'), $manage_uri) ->addTextCrumb(pht('Background Color')); diff --git a/src/applications/project/controller/PhabricatorProjectBoardController.php b/src/applications/project/controller/PhabricatorProjectBoardController.php index d0c6abf882..b889bc75da 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardController.php @@ -1,14 +1,4 @@ selectFilter(PhabricatorProject::ITEM_WORKBOARD); - $menu->addClass('project-board-nav'); - - return $menu; - } -} + extends PhabricatorProjectController {} diff --git a/src/applications/project/controller/PhabricatorProjectBoardManageController.php b/src/applications/project/controller/PhabricatorProjectBoardManageController.php index 5c71dcfb61..21daf2e654 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardManageController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardManageController.php @@ -34,7 +34,7 @@ final class PhabricatorProjectBoardManageController $curtain = $this->buildCurtainView($board); $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Workboard'), "/project/board/{$board_id}/"); + $crumbs->addTextCrumb(pht('Workboard'), $board->getWorkboardURI()); $crumbs->addTextCrumb(pht('Manage')); $crumbs->setBorder(true); diff --git a/src/applications/project/controller/PhabricatorProjectBoardViewController.php b/src/applications/project/controller/PhabricatorProjectBoardViewController.php index 15e0c5d075..3ee0213daa 100644 --- a/src/applications/project/controller/PhabricatorProjectBoardViewController.php +++ b/src/applications/project/controller/PhabricatorProjectBoardViewController.php @@ -172,8 +172,9 @@ final class PhabricatorProjectBoardViewController return $content; } - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorProject::ITEM_WORKBOARD); + $nav = $this->newNavigation( + $project, + PhabricatorProject::ITEM_WORKBOARD); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Workboard')); @@ -284,7 +285,7 @@ final class PhabricatorProjectBoardViewController $query_key = $saved_query->getQueryKey(); $bulk_uri = new PhutilURI("/maniphest/bulk/query/{$query_key}/"); - $bulk_uri->setQueryParam('board', $this->id); + $bulk_uri->replaceQueryParam('board', $this->id); return id(new AphrontRedirectResponse()) ->setURI($bulk_uri); @@ -328,7 +329,7 @@ final class PhabricatorProjectBoardViewController $columns = null; $errors = array(); - if ($request->isFormPost()) { + if ($request->isFormOrHiSecPost()) { $move_project_phid = head($request->getArr('moveProjectPHID')); if (!$move_project_phid) { $move_project_phid = $request->getStr('moveProjectPHID'); @@ -425,7 +426,8 @@ final class PhabricatorProjectBoardViewController ->setActor($viewer) ->setContinueOnMissingFields(true) ->setContinueOnNoEffect(true) - ->setContentSourceFromRequest($request); + ->setContentSourceFromRequest($request) + ->setCancelURI($cancel_uri); $editor->applyTransactions($move_task, $xactions); } @@ -522,13 +524,6 @@ final class PhabricatorProjectBoardViewController $column->getPHID()); $column_tasks = array_select_keys($tasks, $task_phids); - - // If we aren't using "natural" order, reorder the column by the original - // query order. - if ($this->sortKey != PhabricatorProjectColumn::ORDER_NATURAL) { - $column_tasks = array_select_keys($column_tasks, array_keys($tasks)); - } - $column_phid = $column->getPHID(); $visible_columns[$column_phid] = $column; @@ -546,8 +541,9 @@ final class PhabricatorProjectBoardViewController ->setExcludedProjectPHIDs($select_phids); $templates = array(); - $column_maps = array(); $all_tasks = array(); + $column_templates = array(); + $sounds = array(); foreach ($visible_columns as $column_phid => $column) { $column_tasks = $column_phids[$column_phid]; @@ -580,6 +576,11 @@ final class PhabricatorProjectBoardViewController $column_menu = $this->buildColumnMenu($project, $column); $panel->addHeaderAction($column_menu); + if ($column->canHaveTrigger()) { + $trigger_menu = $this->buildTriggerMenu($column); + $panel->addHeaderAction($trigger_menu); + } + $count_tag = id(new PHUITagView()) ->setType(PHUITagView::TYPE_SHADE) ->setColor(PHUITagView::COLOR_BLUE) @@ -607,18 +608,68 @@ final class PhabricatorProjectBoardViewController 'pointLimit' => $column->getPointLimit(), )); + $card_phids = array(); foreach ($column_tasks as $task) { $object_phid = $task->getPHID(); $card = $rendering_engine->renderCard($object_phid); $templates[$object_phid] = hsprintf('%s', $card->getItem()); - $column_maps[$column_phid][] = $object_phid; + $card_phids[] = $object_phid; $all_tasks[$object_phid] = $task; } $panel->setCards($cards); $board->addPanel($panel); + + $drop_effects = $column->getDropEffects(); + $drop_effects = mpull($drop_effects, 'toDictionary'); + + $preview_effect = null; + if ($column->canHaveTrigger()) { + $trigger = $column->getTrigger(); + if ($trigger) { + $preview_effect = $trigger->getPreviewEffect() + ->toDictionary(); + + foreach ($trigger->getSoundEffects() as $sound) { + $sounds[] = $sound; + } + } + } + + $column_templates[] = array( + 'columnPHID' => $column_phid, + 'effects' => $drop_effects, + 'cardPHIDs' => $card_phids, + 'triggerPreviewEffect' => $preview_effect, + ); + } + + $order_key = $this->sortKey; + + $ordering_map = PhabricatorProjectColumnOrder::getEnabledOrders(); + $ordering = id(clone $ordering_map[$order_key]) + ->setViewer($viewer); + + $headers = $ordering->getHeadersForObjects($all_tasks); + $headers = mpull($headers, 'toDictionary'); + + $vectors = $ordering->getSortVectorsForObjects($all_tasks); + $vector_map = array(); + foreach ($vectors as $task_phid => $vector) { + $vector_map[$task_phid][$order_key] = $vector; + } + + $header_keys = $ordering->getHeaderKeysForObjects($all_tasks); + + $order_maps = array(); + $order_maps[] = $ordering->toDictionary(); + + $properties = array(); + foreach ($all_tasks as $task) { + $properties[$task->getPHID()] = + PhabricatorBoardResponseEngine::newTaskProperties($task); } $behavior_config = array( @@ -630,21 +681,25 @@ final class PhabricatorProjectBoardViewController 'boardPHID' => $project->getPHID(), 'order' => $this->sortKey, + 'orders' => $order_maps, + 'headers' => $headers, + 'headerKeys' => $header_keys, 'templateMap' => $templates, - 'columnMaps' => $column_maps, - 'orderMaps' => mpull($all_tasks, 'getWorkboardOrderVectors'), - 'propertyMaps' => mpull($all_tasks, 'getWorkboardProperties'), + 'orderMaps' => $vector_map, + 'propertyMaps' => $properties, + 'columnTemplates' => $column_templates, 'boardID' => $board_id, 'projectPHID' => $project->getPHID(), + 'preloadSounds' => $sounds, ); $this->initBehavior('project-boards', $behavior_config); - $sort_menu = $this->buildSortMenu( $viewer, $project, - $this->sortKey); + $this->sortKey, + $ordering_map); $filter_menu = $this->buildFilterMenu( $viewer, @@ -666,13 +721,15 @@ final class PhabricatorProjectBoardViewController ->appendChild($board) ->addClass('project-board-wrapper'); - $nav = $this->getProfileMenu(); + $nav = $this->newNavigation( + $project, + PhabricatorProject::ITEM_WORKBOARD); $divider = id(new PHUIListItemView()) ->setType(PHUIListItemView::TYPE_DIVIDER); $fullscreen = $this->buildFullscreenMenu(); - $crumbs = $this->buildApplicationCrumbs(); + $crumbs = $this->newWorkboardCrumbs(); $crumbs->addTextCrumb(pht('Workboard')); $crumbs->setBorder(true); @@ -739,7 +796,7 @@ final class PhabricatorProjectBoardViewController return $default_sort; } - return PhabricatorProjectColumn::DEFAULT_ORDER; + return PhabricatorProjectColumnNaturalOrder::ORDERKEY; } private function getDefaultFilter(PhabricatorProject $project) { @@ -753,41 +810,37 @@ final class PhabricatorProjectBoardViewController } private function isValidSort($sort) { - switch ($sort) { - case PhabricatorProjectColumn::ORDER_NATURAL: - case PhabricatorProjectColumn::ORDER_PRIORITY: - return true; - } - - return false; + $map = PhabricatorProjectColumnOrder::getEnabledOrders(); + return isset($map[$sort]); } private function buildSortMenu( PhabricatorUser $viewer, PhabricatorProject $project, - $sort_key) { - - $sort_icon = id(new PHUIIconView()) - ->setIcon('fa-sort-amount-asc bluegrey'); - - $named = array( - PhabricatorProjectColumn::ORDER_NATURAL => pht('Natural'), - PhabricatorProjectColumn::ORDER_PRIORITY => pht('Sort by Priority'), - ); + $sort_key, + array $ordering_map) { $base_uri = $this->getURIWithState(); $items = array(); - foreach ($named as $key => $name) { - $is_selected = ($key == $sort_key); + foreach ($ordering_map as $key => $ordering) { + // TODO: It would be desirable to build a real "PHUIIconView" here, but + // the pathway for threading that through all the view classes ends up + // being fairly complex, since some callers read the icon out of other + // views. For now, just stick with a string. + $ordering_icon = $ordering->getMenuIconIcon(); + $ordering_name = $ordering->getDisplayName(); + + $is_selected = ($key === $sort_key); if ($is_selected) { - $active_order = $name; + $active_name = $ordering_name; + $active_icon = $ordering_icon; } $item = id(new PhabricatorActionView()) - ->setIcon('fa-sort-amount-asc') + ->setIcon($ordering_icon) ->setSelected($is_selected) - ->setName($name); + ->setName($ordering_name); $uri = $base_uri->alter('order', $key); $item->setHref($uri); @@ -806,6 +859,9 @@ final class PhabricatorProjectBoardViewController $project, PhabricatorPolicyCapability::CAN_EDIT); + $items[] = id(new PhabricatorActionView()) + ->setType(PhabricatorActionView::TYPE_DIVIDER); + $items[] = id(new PhabricatorActionView()) ->setIcon('fa-floppy-o') ->setName(pht('Save as Default')) @@ -820,8 +876,8 @@ final class PhabricatorProjectBoardViewController } $sort_button = id(new PHUIListItemView()) - ->setName($active_order) - ->setIcon('fa-sort-amount-asc') + ->setName($active_name) + ->setIcon($active_icon) ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( @@ -878,7 +934,7 @@ final class PhabricatorProjectBoardViewController } $uri = $this->getURIWithState($uri) - ->setQueryParam('filter', null); + ->removeQueryParam('filter'); $item->setHref($uri); $items[] = $item; @@ -904,6 +960,9 @@ final class PhabricatorProjectBoardViewController $project, PhabricatorPolicyCapability::CAN_EDIT); + $items[] = id(new PhabricatorActionView()) + ->setType(PhabricatorActionView::TYPE_DIVIDER); + $items[] = id(new PhabricatorActionView()) ->setIcon('fa-floppy-o') ->setName(pht('Save as Default')) @@ -966,12 +1025,12 @@ final class PhabricatorProjectBoardViewController if ($show_hidden) { $hidden_uri = $this->getURIWithState() - ->setQueryParam('hidden', null); + ->removeQueryParam('hidden'); $hidden_icon = 'fa-eye-slash'; $hidden_text = pht('Hide Hidden Columns'); } else { $hidden_uri = $this->getURIWithState() - ->setQueryParam('hidden', 'true'); + ->replaceQueryParam('hidden', 'true'); $hidden_icon = 'fa-eye'; $hidden_text = pht('Show Hidden Columns'); } @@ -999,7 +1058,7 @@ final class PhabricatorProjectBoardViewController ->setHref($manage_uri); $batch_edit_uri = $request->getRequestURI(); - $batch_edit_uri->setQueryParam('batch', self::BATCH_EDIT_ALL); + $batch_edit_uri->replaceQueryParam('batch', self::BATCH_EDIT_ALL); $can_batch_edit = PhabricatorPolicyFilter::hasCapability( $viewer, PhabricatorApplication::getByClass('PhabricatorManiphestApplication'), @@ -1084,13 +1143,11 @@ final class PhabricatorProjectBoardViewController )); } - if (count($specs) > 1) { - $column_items[] = id(new PhabricatorActionView()) - ->setType(PhabricatorActionView::TYPE_DIVIDER); - } + $column_items[] = id(new PhabricatorActionView()) + ->setType(PhabricatorActionView::TYPE_DIVIDER); $batch_edit_uri = $request->getRequestURI(); - $batch_edit_uri->setQueryParam('batch', $column->getID()); + $batch_edit_uri->replaceQueryParam('batch', $column->getID()); $can_batch_edit = PhabricatorPolicyFilter::hasCapability( $viewer, PhabricatorApplication::getByClass('PhabricatorManiphestApplication'), @@ -1103,7 +1160,7 @@ final class PhabricatorProjectBoardViewController ->setDisabled(!$can_batch_edit); $batch_move_uri = $request->getRequestURI(); - $batch_move_uri->setQueryParam('move', $column->getID()); + $batch_move_uri->replaceQueryParam('move', $column->getID()); $column_items[] = id(new PhabricatorActionView()) ->setIcon('fa-arrow-right') ->setName(pht('Move Tasks to Column...')) @@ -1111,7 +1168,7 @@ final class PhabricatorProjectBoardViewController ->setWorkflow(true); $query_uri = $request->getRequestURI(); - $query_uri->setQueryParam('queryColumnID', $column->getID()); + $query_uri->replaceQueryParam('queryColumnID', $column->getID()); $column_items[] = id(new PhabricatorActionView()) ->setName(pht('View as Query')) @@ -1154,7 +1211,7 @@ final class PhabricatorProjectBoardViewController } $column_button = id(new PHUIIconView()) - ->setIcon('fa-caret-down') + ->setIcon('fa-pencil') ->setHref('#') ->addSigil('boards-dropdown-menu') ->setMetadata( @@ -1165,6 +1222,75 @@ final class PhabricatorProjectBoardViewController return $column_button; } + private function buildTriggerMenu(PhabricatorProjectColumn $column) { + $viewer = $this->getViewer(); + $trigger = $column->getTrigger(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $column, + PhabricatorPolicyCapability::CAN_EDIT); + + $trigger_items = array(); + if (!$trigger) { + $set_uri = $this->getApplicationURI( + new PhutilURI( + 'trigger/edit/', + array( + 'columnPHID' => $column->getPHID(), + ))); + + $trigger_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-cogs') + ->setName(pht('New Trigger...')) + ->setHref($set_uri) + ->setDisabled(!$can_edit); + } else { + $trigger_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-cogs') + ->setName(pht('View Trigger')) + ->setHref($trigger->getURI()) + ->setDisabled(!$can_edit); + } + + $remove_uri = $this->getApplicationURI( + new PhutilURI( + urisprintf( + 'column/remove/%d/', + $column->getID()))); + + $trigger_items[] = id(new PhabricatorActionView()) + ->setIcon('fa-times') + ->setName(pht('Remove Trigger')) + ->setHref($remove_uri) + ->setWorkflow(true) + ->setDisabled(!$can_edit || !$trigger); + + $trigger_menu = id(new PhabricatorActionListView()) + ->setUser($viewer); + foreach ($trigger_items as $item) { + $trigger_menu->addAction($item); + } + + if ($trigger) { + $trigger_icon = 'fa-cogs'; + } else { + $trigger_icon = 'fa-cogs grey'; + } + + $trigger_button = id(new PHUIIconView()) + ->setIcon($trigger_icon) + ->setHref('#') + ->addSigil('boards-dropdown-menu') + ->addSigil('trigger-preview') + ->setMetadata( + array( + 'items' => hsprintf('%s', $trigger_menu), + 'columnPHID' => $column->getPHID(), + )); + + return $trigger_button; + } /** * Add current state parameters (like order and the visibility of hidden @@ -1182,24 +1308,36 @@ final class PhabricatorProjectBoardViewController $project = $this->getProject(); if ($base === null) { - $base = $this->getRequest()->getRequestURI(); + $base = $this->getRequest()->getPath(); } $base = new PhutilURI($base); if ($force || ($this->sortKey != $this->getDefaultSort($project))) { - $base->setQueryParam('order', $this->sortKey); + if ($this->sortKey !== null) { + $base->replaceQueryParam('order', $this->sortKey); + } else { + $base->removeQueryParam('order'); + } } else { - $base->setQueryParam('order', null); + $base->removeQueryParam('order'); } if ($force || ($this->queryKey != $this->getDefaultFilter($project))) { - $base->setQueryParam('filter', $this->queryKey); + if ($this->queryKey !== null) { + $base->replaceQueryParam('filter', $this->queryKey); + } else { + $base->removeQueryParam('filter'); + } } else { - $base->setQueryParam('filter', null); + $base->removeQueryParam('filter'); } - $base->setQueryParam('hidden', $this->showHidden ? 'true' : null); + if ($this->showHidden) { + $base->replaceQueryParam('hidden', 'true'); + } else { + $base->removeQueryParam('hidden'); + } return $base; } diff --git a/src/applications/project/controller/PhabricatorProjectColumnDetailController.php b/src/applications/project/controller/PhabricatorProjectColumnDetailController.php index 24efec5ebb..781461a812 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnDetailController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnDetailController.php @@ -47,7 +47,7 @@ final class PhabricatorProjectColumnDetailController $properties = $this->buildPropertyView($column); $crumbs = $this->buildApplicationCrumbs(); - $crumbs->addTextCrumb(pht('Workboard'), "/project/board/{$project_id}/"); + $crumbs->addTextCrumb(pht('Workboard'), $project->getWorkboardURI()); $crumbs->addTextCrumb(pht('Column: %s', $title)); $crumbs->setBorder(true); diff --git a/src/applications/project/controller/PhabricatorProjectColumnEditController.php b/src/applications/project/controller/PhabricatorProjectColumnEditController.php index 94277c92e5..9ddb2b7d8a 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnEditController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnEditController.php @@ -50,8 +50,7 @@ final class PhabricatorProjectColumnEditController $v_name = $column->getName(); $validation_exception = null; - $base_uri = '/board/'.$project_id.'/'; - $view_uri = $this->getApplicationURI($base_uri); + $view_uri = $project->getWorkboardURI(); if ($request->isFormPost()) { $v_name = $request->getStr('name'); @@ -76,8 +75,8 @@ final class PhabricatorProjectColumnEditController $xactions = array(); - $type_name = PhabricatorProjectColumnTransaction::TYPE_NAME; - $type_limit = PhabricatorProjectColumnTransaction::TYPE_LIMIT; + $type_name = PhabricatorProjectColumnNameTransaction::TRANSACTIONTYPE; + $type_limit = PhabricatorProjectColumnLimitTransaction::TRANSACTIONTYPE; if (!$column->getProxy()) { $xactions[] = id(new PhabricatorProjectColumnTransaction()) @@ -93,7 +92,6 @@ final class PhabricatorProjectColumnEditController $editor = id(new PhabricatorProjectColumnTransactionEditor()) ->setActor($viewer) ->setContinueOnNoEffect(true) - ->setContinueOnMissingFields(true) ->setContentSourceFromRequest($request) ->applyTransactions($column, $xactions); return id(new AphrontRedirectResponse())->setURI($view_uri); diff --git a/src/applications/project/controller/PhabricatorProjectColumnHideController.php b/src/applications/project/controller/PhabricatorProjectColumnHideController.php index 1dd5e47ecb..254beab78c 100644 --- a/src/applications/project/controller/PhabricatorProjectColumnHideController.php +++ b/src/applications/project/controller/PhabricatorProjectColumnHideController.php @@ -38,10 +38,10 @@ final class PhabricatorProjectColumnHideController $column_phid = $column->getPHID(); - $view_uri = $this->getApplicationURI('/board/'.$project_id.'/'); + $view_uri = $project->getWorkboardURI(); $view_uri = new PhutilURI($view_uri); foreach ($request->getPassthroughRequestData() as $key => $value) { - $view_uri->setQueryParam($key, $value); + $view_uri->replaceQueryParam($key, $value); } if ($column->isDefaultColumn()) { @@ -82,7 +82,9 @@ final class PhabricatorProjectColumnHideController $new_status = PhabricatorProjectColumn::STATUS_HIDDEN; } - $type_status = PhabricatorProjectColumnTransaction::TYPE_STATUS; + $type_status = + PhabricatorProjectColumnStatusTransaction::TRANSACTIONTYPE; + $xactions = array( id(new PhabricatorProjectColumnTransaction()) ->setTransactionType($type_status) diff --git a/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php b/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php new file mode 100644 index 0000000000..9bb92e5a3a --- /dev/null +++ b/src/applications/project/controller/PhabricatorProjectColumnRemoveTriggerController.php @@ -0,0 +1,60 @@ +getViewer(); + $id = $request->getURIData('id'); + + $column = id(new PhabricatorProjectColumnQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$column) { + return new Aphront404Response(); + } + + $done_uri = $column->getWorkboardURI(); + + if (!$column->getTriggerPHID()) { + return $this->newDialog() + ->setTitle(pht('No Trigger')) + ->appendParagraph( + pht('This column does not have a trigger.')) + ->addCancelButton($done_uri); + } + + if ($request->isFormPost()) { + $column_xactions = array(); + + $column_xactions[] = $column->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectColumnTriggerTransaction::TRANSACTIONTYPE) + ->setNewValue(null); + + $column_editor = $column->getApplicationTransactionEditor() + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true); + + $column_editor->applyTransactions($column, $column_xactions); + + return id(new AphrontRedirectResponse())->setURI($done_uri); + } + + $body = pht('Really remove the trigger from this column?'); + + return $this->newDialog() + ->setTitle(pht('Remove Trigger')) + ->appendParagraph($body) + ->addSubmitButton(pht('Remove Trigger')) + ->addCancelButton($done_uri); + } +} diff --git a/src/applications/project/controller/PhabricatorProjectController.php b/src/applications/project/controller/PhabricatorProjectController.php index 7c344b0b8e..7781f4c73b 100644 --- a/src/applications/project/controller/PhabricatorProjectController.php +++ b/src/applications/project/controller/PhabricatorProjectController.php @@ -84,31 +84,15 @@ abstract class PhabricatorProjectController extends PhabricatorController { return null; } - public function buildApplicationMenu() { - $menu = $this->newApplicationMenu(); - - $profile_menu = $this->getProfileMenu(); - if ($profile_menu) { - $menu->setProfileMenu($profile_menu); - } - - $menu->setSearchEngine(new PhabricatorProjectSearchEngine()); - - return $menu; - } - - protected function getProfileMenu() { - if (!$this->profileMenu) { - $engine = $this->getProfileMenuEngine(); - if ($engine) { - $this->profileMenu = $engine->buildNavigation(); - } - } - - return $this->profileMenu; - } - protected function buildApplicationCrumbs() { + return $this->newApplicationCrumbs('profile'); + } + + protected function newWorkboardCrumbs() { + return $this->newApplicationCrumbs('workboard'); + } + + private function newApplicationCrumbs($mode) { $crumbs = parent::buildApplicationCrumbs(); $project = $this->getProject(); @@ -117,10 +101,24 @@ abstract class PhabricatorProjectController extends PhabricatorController { $ancestors = array_reverse($ancestors); $ancestors[] = $project; foreach ($ancestors as $ancestor) { - $crumbs->addTextCrumb( - $ancestor->getName(), - $ancestor->getProfileURI() - ); + if ($ancestor->getPHID() === $project->getPHID()) { + // Link the current project's crumb to its profile no matter what, + // since we're already on the right context page for it and linking + // to the current page isn't helpful. + $crumb_uri = $ancestor->getProfileURI(); + } else { + switch ($mode) { + case 'workboard': + $crumb_uri = $ancestor->getWorkboardURI(); + break; + case 'profile': + default: + $crumb_uri = $ancestor->getProfileURI(); + break; + } + } + + $crumbs->addTextCrumb($ancestor->getName(), $crumb_uri); } } @@ -149,7 +147,12 @@ abstract class PhabricatorProjectController extends PhabricatorController { return $this; } - protected function newCardResponse($board_phid, $object_phid) { + protected function newCardResponse( + $board_phid, + $object_phid, + PhabricatorProjectColumnOrder $ordering = null, + $sounds = array()) { + $viewer = $this->getViewer(); $request = $this->getRequest(); @@ -158,12 +161,18 @@ abstract class PhabricatorProjectController extends PhabricatorController { $visible_phids = array(); } - return id(new PhabricatorBoardResponseEngine()) + $engine = id(new PhabricatorBoardResponseEngine()) ->setViewer($viewer) ->setBoardPHID($board_phid) ->setObjectPHID($object_phid) ->setVisiblePHIDs($visible_phids) - ->buildResponse(); + ->setSounds($sounds); + + if ($ordering) { + $engine->setOrdering($ordering); + } + + return $engine->buildResponse(); } public function renderHashtags(array $tags) { @@ -174,4 +183,23 @@ abstract class PhabricatorProjectController extends PhabricatorController { return implode(', ', $result); } + final protected function newNavigation( + PhabricatorProject $project, + $item_identifier) { + + $engine = $this->getProfileMenuEngine(); + + $view_list = $engine->newProfileMenuItemViewList(); + + $view_list->setSelectedViewWithItemIdentifier($item_identifier); + + $navigation = $view_list->newNavigationView(); + + if ($item_identifier === PhabricatorProject::ITEM_WORKBOARD) { + $navigation->addClass('project-board-nav'); + } + + return $navigation; + } + } diff --git a/src/applications/project/controller/PhabricatorProjectDefaultController.php b/src/applications/project/controller/PhabricatorProjectDefaultController.php index 8f42ff9736..2c7a47b2df 100644 --- a/src/applications/project/controller/PhabricatorProjectDefaultController.php +++ b/src/applications/project/controller/PhabricatorProjectDefaultController.php @@ -54,7 +54,7 @@ final class PhabricatorProjectDefaultController $view_uri = $this->getApplicationURI("board/{$id}/"); $view_uri = new PhutilURI($view_uri); foreach ($request->getPassthroughRequestData() as $key => $value) { - $view_uri->setQueryParam($key, $value); + $view_uri->replaceQueryParam($key, $value); } if ($request->isFormPost()) { diff --git a/src/applications/project/controller/PhabricatorProjectManageController.php b/src/applications/project/controller/PhabricatorProjectManageController.php index 2c76c63606..eadda5bf86 100644 --- a/src/applications/project/controller/PhabricatorProjectManageController.php +++ b/src/applications/project/controller/PhabricatorProjectManageController.php @@ -37,8 +37,9 @@ final class PhabricatorProjectManageController new PhabricatorProjectTransactionQuery()); $timeline->setShouldTerminate(true); - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorProject::ITEM_MANAGE); + $nav = $this->newNavigation( + $project, + PhabricatorProject::ITEM_MANAGE); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Manage')); diff --git a/src/applications/project/controller/PhabricatorProjectMembersViewController.php b/src/applications/project/controller/PhabricatorProjectMembersViewController.php index ad553eb664..ee0b71cefa 100644 --- a/src/applications/project/controller/PhabricatorProjectMembersViewController.php +++ b/src/applications/project/controller/PhabricatorProjectMembersViewController.php @@ -36,8 +36,9 @@ final class PhabricatorProjectMembersViewController ->setUserPHIDs($project->getWatcherPHIDs()) ->setShowNote(true); - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorProject::ITEM_MEMBERS); + $nav = $this->newNavigation( + $project, + PhabricatorProject::ITEM_MEMBERS); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Members')); diff --git a/src/applications/project/controller/PhabricatorProjectMoveController.php b/src/applications/project/controller/PhabricatorProjectMoveController.php index 29b70cfafc..1fd8b3c677 100644 --- a/src/applications/project/controller/PhabricatorProjectMoveController.php +++ b/src/applications/project/controller/PhabricatorProjectMoveController.php @@ -11,9 +11,26 @@ final class PhabricatorProjectMoveController $column_phid = $request->getStr('columnPHID'); $object_phid = $request->getStr('objectPHID'); - $after_phid = $request->getStr('afterPHID'); - $before_phid = $request->getStr('beforePHID'); - $order = $request->getStr('order', PhabricatorProjectColumn::DEFAULT_ORDER); + + $after_phids = $request->getStrList('afterPHIDs'); + $before_phids = $request->getStrList('beforePHIDs'); + + $order = $request->getStr('order'); + if (!strlen($order)) { + $order = PhabricatorProjectColumnNaturalOrder::ORDERKEY; + } + + $ordering = PhabricatorProjectColumnOrder::getOrderByKey($order); + $ordering = id(clone $ordering) + ->setViewer($viewer); + + $edit_header = null; + $raw_header = $request->getStr('header'); + if (strlen($raw_header)) { + $edit_header = phutil_json_decode($raw_header); + } else { + $edit_header = array(); + } $project = id(new PhabricatorProjectQuery()) ->setViewer($viewer) @@ -27,6 +44,13 @@ final class PhabricatorProjectMoveController return new Aphront404Response(); } + $cancel_uri = $this->getApplicationURI( + new PhutilURI( + urisprintf('board/%d/', $project->getID()), + array( + 'order' => $order, + ))); + $board_phid = $project->getPHID(); $object = id(new ManiphestTaskQuery()) @@ -47,6 +71,7 @@ final class PhabricatorProjectMoveController $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withProjectPHIDs(array($project->getPHID())) + ->needTriggers(true) ->execute(); $columns = mpull($columns, null, 'getPHID'); @@ -63,20 +88,12 @@ final class PhabricatorProjectMoveController ->setObjectPHIDs(array($object_phid)) ->executeLayout(); - $columns = $engine->getObjectColumns($board_phid, $object_phid); - $old_column_phids = mpull($columns, 'getPHID'); + $order_params = array( + 'afterPHIDs' => $after_phids, + 'beforePHIDs' => $before_phids, + ); $xactions = array(); - - $order_params = array(); - if ($order == PhabricatorProjectColumn::ORDER_NATURAL) { - if ($after_phid) { - $order_params['afterPHID'] = $after_phid; - } else if ($before_phid) { - $order_params['beforePHID'] = $before_phid; - } - } - $xactions[] = id(new ManiphestTransaction()) ->setTransactionType(PhabricatorTransactions::TYPE_COLUMNS) ->setNewValue( @@ -86,13 +103,28 @@ final class PhabricatorProjectMoveController ) + $order_params, )); - if ($order == PhabricatorProjectColumn::ORDER_PRIORITY) { - $priority_xactions = $this->getPriorityTransactions( - $object, - $after_phid, - $before_phid); - foreach ($priority_xactions as $xaction) { - $xactions[] = $xaction; + $header_xactions = $ordering->getColumnTransactions( + $object, + $edit_header); + foreach ($header_xactions as $header_xaction) { + $xactions[] = $header_xaction; + } + + $sounds = array(); + if ($column->canHaveTrigger()) { + $trigger = $column->getTrigger(); + if ($trigger) { + $trigger_xactions = $trigger->newDropTransactions( + $viewer, + $column, + $object); + foreach ($trigger_xactions as $trigger_xaction) { + $xactions[] = $trigger_xaction; + } + + foreach ($trigger->getSoundEffects() as $effect) { + $sounds[] = $effect; + } } } @@ -100,111 +132,16 @@ final class PhabricatorProjectMoveController ->setActor($viewer) ->setContinueOnMissingFields(true) ->setContinueOnNoEffect(true) - ->setContentSourceFromRequest($request); + ->setContentSourceFromRequest($request) + ->setCancelURI($cancel_uri); $editor->applyTransactions($object, $xactions); - return $this->newCardResponse($board_phid, $object_phid); - } - - private function getPriorityTransactions( - ManiphestTask $task, - $after_phid, - $before_phid) { - - list($after_task, $before_task) = $this->loadPriorityTasks( - $after_phid, - $before_phid); - - $must_move = false; - if ($after_task && !$task->isLowerPriorityThan($after_task)) { - $must_move = true; - } - - if ($before_task && !$task->isHigherPriorityThan($before_task)) { - $must_move = true; - } - - // The move doesn't require a priority change to be valid, so don't - // change the priority since we are not being forced to. - if (!$must_move) { - return array(); - } - - $try = array( - array($after_task, true), - array($before_task, false), - ); - - $pri = null; - $sub = null; - foreach ($try as $spec) { - list($task, $is_after) = $spec; - - if (!$task) { - continue; - } - - list($pri, $sub) = ManiphestTransactionEditor::getAdjacentSubpriority( - $task, - $is_after); - - // If we find a priority on the first try, don't keep going. - break; - } - - $keyword_map = ManiphestTaskPriority::getTaskPriorityKeywordsMap(); - $keyword = head(idx($keyword_map, $pri)); - - $xactions = array(); - if ($pri !== null) { - $xactions[] = id(new ManiphestTransaction()) - ->setTransactionType(ManiphestTaskPriorityTransaction::TRANSACTIONTYPE) - ->setNewValue($keyword); - $xactions[] = id(new ManiphestTransaction()) - ->setTransactionType( - ManiphestTaskSubpriorityTransaction::TRANSACTIONTYPE) - ->setNewValue($sub); - } - - return $xactions; - } - - private function loadPriorityTasks($after_phid, $before_phid) { - $viewer = $this->getViewer(); - - $task_phids = array(); - - if ($after_phid) { - $task_phids[] = $after_phid; - } - if ($before_phid) { - $task_phids[] = $before_phid; - } - - if (!$task_phids) { - return array(null, null); - } - - $tasks = id(new ManiphestTaskQuery()) - ->setViewer($viewer) - ->withPHIDs($task_phids) - ->execute(); - $tasks = mpull($tasks, null, 'getPHID'); - - if ($after_phid) { - $after_task = idx($tasks, $after_phid); - } else { - $after_task = null; - } - - if ($before_phid) { - $before_task = idx($tasks, $before_phid); - } else { - $before_task = null; - } - - return array($after_task, $before_task); + return $this->newCardResponse( + $board_phid, + $object_phid, + $ordering, + $sounds); } } diff --git a/src/applications/project/controller/PhabricatorProjectProfileController.php b/src/applications/project/controller/PhabricatorProjectProfileController.php index 6e1e7677e6..67b94f7fa9 100644 --- a/src/applications/project/controller/PhabricatorProjectProfileController.php +++ b/src/applications/project/controller/PhabricatorProjectProfileController.php @@ -74,8 +74,9 @@ final class PhabricatorProjectProfileController ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setUserPHIDs($project->getWatcherPHIDs()); - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorProject::ITEM_PROFILE); + $nav = $this->newNavigation( + $project, + PhabricatorProject::ITEM_PROFILE); $stories = id(new PhabricatorFeedQuery()) ->setViewer($viewer) diff --git a/src/applications/project/controller/PhabricatorProjectSubprojectsController.php b/src/applications/project/controller/PhabricatorProjectSubprojectsController.php index 36736c78ba..be787e7a4f 100644 --- a/src/applications/project/controller/PhabricatorProjectSubprojectsController.php +++ b/src/applications/project/controller/PhabricatorProjectSubprojectsController.php @@ -77,8 +77,9 @@ final class PhabricatorProjectSubprojectsController $milestones, $subprojects); - $nav = $this->getProfileMenu(); - $nav->selectFilter(PhabricatorProject::ITEM_SUBPROJECTS); + $nav = $this->newNavigation( + $project, + PhabricatorProject::ITEM_SUBPROJECTS); $crumbs = $this->buildApplicationCrumbs(); $crumbs->addTextCrumb(pht('Subprojects')); diff --git a/src/applications/project/controller/PhabricatorProjectViewController.php b/src/applications/project/controller/PhabricatorProjectViewController.php index 2e53fe7276..beb622ea50 100644 --- a/src/applications/project/controller/PhabricatorProjectViewController.php +++ b/src/applications/project/controller/PhabricatorProjectViewController.php @@ -18,7 +18,7 @@ final class PhabricatorProjectViewController $project = $this->getProject(); $engine = $this->getProfileMenuEngine(); - $default = $engine->getDefaultItem(); + $default = $engine->getDefaultMenuItemConfiguration(); // If defaults are broken somehow, serve the manage page. See T13033 for // discussion. @@ -28,7 +28,7 @@ final class PhabricatorProjectViewController $default_key = PhabricatorProject::ITEM_MANAGE; } - switch ($default->getBuiltinKey()) { + switch ($default_key) { case PhabricatorProject::ITEM_WORKBOARD: $controller_object = new PhabricatorProjectBoardViewController(); break; diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerController.php new file mode 100644 index 0000000000..ea729e82a4 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerController.php @@ -0,0 +1,16 @@ +addTextCrumb( + pht('Triggers'), + $this->getApplicationURI('trigger/')); + + return $crumbs; + } + +} diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php new file mode 100644 index 0000000000..df362efb61 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerEditController.php @@ -0,0 +1,293 @@ +getRequest(); + $viewer = $request->getViewer(); + + $id = $request->getURIData('id'); + if ($id) { + $trigger = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$trigger) { + return new Aphront404Response(); + } + } else { + $trigger = PhabricatorProjectTrigger::initializeNewTrigger(); + } + + $column_phid = $request->getStr('columnPHID'); + if ($column_phid) { + $column = id(new PhabricatorProjectColumnQuery()) + ->setViewer($viewer) + ->withPHIDs(array($column_phid)) + ->requireCapabilities( + array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + )) + ->executeOne(); + if (!$column) { + return new Aphront404Response(); + } + $board_uri = $column->getWorkboardURI(); + } else { + $column = null; + $board_uri = null; + } + + if ($board_uri) { + $cancel_uri = $board_uri; + } else if ($trigger->getID()) { + $cancel_uri = $trigger->getURI(); + } else { + $cancel_uri = $this->getApplicationURI('trigger/'); + } + + $v_name = $trigger->getName(); + $v_edit = $trigger->getEditPolicy(); + $v_rules = $trigger->getTriggerRules(); + + $e_name = null; + $e_edit = null; + + $validation_exception = null; + if ($request->isFormPost()) { + try { + $v_name = $request->getStr('name'); + $v_edit = $request->getStr('editPolicy'); + + // Read the JSON rules from the request and convert them back into + // "TriggerRule" objects so we can render the correct form state + // if the user is modifying the rules + $raw_rules = $request->getStr('rules'); + $raw_rules = phutil_json_decode($raw_rules); + + $copy = clone $trigger; + $copy->setRuleset($raw_rules); + $v_rules = $copy->getTriggerRules(); + + $xactions = array(); + if (!$trigger->getID()) { + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType(PhabricatorTransactions::TYPE_CREATE) + ->setNewValue(true); + } + + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectTriggerNameTransaction::TRANSACTIONTYPE) + ->setNewValue($v_name); + + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType(PhabricatorTransactions::TYPE_EDIT_POLICY) + ->setNewValue($v_edit); + + $xactions[] = $trigger->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectTriggerRulesetTransaction::TRANSACTIONTYPE) + ->setNewValue($raw_rules); + + $editor = $trigger->getApplicationTransactionEditor() + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true); + + $editor->applyTransactions($trigger, $xactions); + + $next_uri = $trigger->getURI(); + + if ($column) { + $column_xactions = array(); + + $column_xactions[] = $column->getApplicationTransactionTemplate() + ->setTransactionType( + PhabricatorProjectColumnTriggerTransaction::TRANSACTIONTYPE) + ->setNewValue($trigger->getPHID()); + + $column_editor = $column->getApplicationTransactionEditor() + ->setActor($viewer) + ->setContentSourceFromRequest($request) + ->setContinueOnNoEffect(true) + ->setContinueOnMissingFields(true); + + $column_editor->applyTransactions($column, $column_xactions); + + $next_uri = $column->getWorkboardURI(); + } + + return id(new AphrontRedirectResponse())->setURI($next_uri); + } catch (PhabricatorApplicationTransactionValidationException $ex) { + $validation_exception = $ex; + + $e_name = $ex->getShortMessage( + PhabricatorProjectTriggerNameTransaction::TRANSACTIONTYPE); + + $e_edit = $ex->getShortMessage( + PhabricatorTransactions::TYPE_EDIT_POLICY); + + $trigger->setEditPolicy($v_edit); + } + } + + if ($trigger->getID()) { + $title = $trigger->getObjectName(); + $submit = pht('Save Trigger'); + $header = pht('Edit Trigger: %s', $trigger->getObjectName()); + } else { + $title = pht('New Trigger'); + $submit = pht('Create Trigger'); + $header = pht('New Trigger'); + } + + $form_id = celerity_generate_unique_node_id(); + $table_id = celerity_generate_unique_node_id(); + $create_id = celerity_generate_unique_node_id(); + $input_id = celerity_generate_unique_node_id(); + + $form = id(new AphrontFormView()) + ->setViewer($viewer) + ->setID($form_id); + + if ($column) { + $form->addHiddenInput('columnPHID', $column->getPHID()); + } + + $form->appendControl( + id(new AphrontFormTextControl()) + ->setLabel(pht('Name')) + ->setName('name') + ->setValue($v_name) + ->setError($e_name) + ->setPlaceholder($trigger->getDefaultName())); + + $policies = id(new PhabricatorPolicyQuery()) + ->setViewer($viewer) + ->setObject($trigger) + ->execute(); + + $form->appendControl( + id(new AphrontFormPolicyControl()) + ->setName('editPolicy') + ->setPolicyObject($trigger) + ->setCapability(PhabricatorPolicyCapability::CAN_EDIT) + ->setPolicies($policies) + ->setError($e_edit)); + + $form->appendChild( + phutil_tag( + 'input', + array( + 'type' => 'hidden', + 'name' => 'rules', + 'id' => $input_id, + ))); + + $form->appendChild( + id(new PHUIFormInsetView()) + ->setTitle(pht('Rules')) + ->setDescription( + pht( + 'When a card is dropped into a column which uses this trigger:')) + ->setRightButton( + javelin_tag( + 'a', + array( + 'href' => '#', + 'class' => 'button button-green', + 'id' => $create_id, + 'mustcapture' => true, + ), + pht('New Rule'))) + ->setContent( + javelin_tag( + 'table', + array( + 'id' => $table_id, + 'class' => 'trigger-rules-table', + )))); + + $this->setupEditorBehavior( + $trigger, + $v_rules, + $form_id, + $table_id, + $create_id, + $input_id); + + $form->appendControl( + id(new AphrontFormSubmitControl()) + ->setValue($submit) + ->addCancelButton($cancel_uri)); + + $header = id(new PHUIHeaderView()) + ->setHeader($header); + + $box_view = id(new PHUIObjectBoxView()) + ->setHeader($header) + ->setValidationException($validation_exception) + ->appendChild($form); + + $column_view = id(new PHUITwoColumnView()) + ->setFooter($box_view); + + $crumbs = $this->buildApplicationCrumbs() + ->setBorder(true); + + if ($column) { + $crumbs->addTextCrumb( + pht( + '%s: %s', + $column->getProject()->getDisplayName(), + $column->getName()), + $board_uri); + } + + $crumbs->addTextCrumb($title); + + return $this->newPage() + ->setTitle($title) + ->setCrumbs($crumbs) + ->appendChild($column_view); + } + + private function setupEditorBehavior( + PhabricatorProjectTrigger $trigger, + array $rule_list, + $form_id, + $table_id, + $create_id, + $input_id) { + + $rule_list = mpull($rule_list, 'toDictionary'); + $rule_list = array_values($rule_list); + + $type_list = PhabricatorProjectTriggerRule::getAllTriggerRules(); + $type_list = mpull($type_list, 'newTemplate'); + $type_list = array_values($type_list); + + require_celerity_resource('project-triggers-css'); + + Javelin::initBehavior( + 'trigger-rule-editor', + array( + 'formNodeID' => $form_id, + 'tableNodeID' => $table_id, + 'createNodeID' => $create_id, + 'inputNodeID' => $input_id, + + 'rules' => $rule_list, + 'types' => $type_list, + )); + } + +} diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerListController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerListController.php new file mode 100644 index 0000000000..62e5430f26 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerListController.php @@ -0,0 +1,16 @@ +setController($this) + ->buildResponse(); + } + +} diff --git a/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php new file mode 100644 index 0000000000..d148c0a421 --- /dev/null +++ b/src/applications/project/controller/trigger/PhabricatorProjectTriggerViewController.php @@ -0,0 +1,231 @@ +getRequest(); + $viewer = $request->getViewer(); + + $id = $request->getURIData('id'); + + $trigger = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($viewer) + ->withIDs(array($id)) + ->executeOne(); + if (!$trigger) { + return new Aphront404Response(); + } + + $rules_view = $this->newRulesView($trigger); + $columns_view = $this->newColumnsView($trigger); + + $title = $trigger->getObjectName(); + + $header = id(new PHUIHeaderView()) + ->setHeader($trigger->getDisplayName()); + + $timeline = $this->buildTransactionTimeline( + $trigger, + new PhabricatorProjectTriggerTransactionQuery()); + $timeline->setShouldTerminate(true); + + $curtain = $this->newCurtain($trigger); + + $column_view = id(new PHUITwoColumnView()) + ->setHeader($header) + ->setCurtain($curtain) + ->setMainColumn( + array( + $rules_view, + $columns_view, + $timeline, + )); + + $crumbs = $this->buildApplicationCrumbs() + ->addTextCrumb($trigger->getObjectName()) + ->setBorder(true); + + return $this->newPage() + ->setTitle($title) + ->setCrumbs($crumbs) + ->appendChild($column_view); + } + + private function newColumnsView(PhabricatorProjectTrigger $trigger) { + $viewer = $this->getViewer(); + + // NOTE: When showing columns which use this trigger, we want to represent + // all columns the trigger is used by: even columns the user can't see. + + // If we hide columns the viewer can't see, they might think that the + // trigger isn't widely used and is safe to edit, when it may actually + // be in use on workboards they don't have access to. + + // Query the columns with the omnipotent viewer first, then pull out their + // PHIDs and throw the actual objects away. Re-query with the real viewer + // so we load only the columns they can actually see, but have a list of + // all the impacted column PHIDs. + + // (We're also exposing the status of columns the user might not be able + // to see. This technically violates policy, but the trigger usage table + // hints at it anyway and it seems unlikely to ever have any security + // impact, but is useful in assessing whether a trigger is really in use + // or not.) + + $omnipotent_viewer = PhabricatorUser::getOmnipotentUser(); + $all_columns = id(new PhabricatorProjectColumnQuery()) + ->setViewer($omnipotent_viewer) + ->withTriggerPHIDs(array($trigger->getPHID())) + ->execute(); + $column_map = mpull($all_columns, 'getStatus', 'getPHID'); + + if ($column_map) { + $visible_columns = id(new PhabricatorProjectColumnQuery()) + ->setViewer($viewer) + ->withPHIDs(array_keys($column_map)) + ->execute(); + $visible_columns = mpull($visible_columns, null, 'getPHID'); + } else { + $visible_columns = array(); + } + + $rows = array(); + foreach ($column_map as $column_phid => $column_status) { + $column = idx($visible_columns, $column_phid); + + if ($column) { + $project = $column->getProject(); + + $project_name = phutil_tag( + 'a', + array( + 'href' => $project->getURI(), + ), + $project->getDisplayName()); + + $column_name = phutil_tag( + 'a', + array( + 'href' => $column->getWorkboardURI(), + ), + $column->getDisplayName()); + } else { + $project_name = null; + $column_name = phutil_tag('em', array(), pht('Restricted Column')); + } + + if ($column_status == PhabricatorProjectColumn::STATUS_ACTIVE) { + $status_icon = id(new PHUIIconView()) + ->setIcon('fa-columns', 'blue') + ->setTooltip(pht('Active Column')); + } else { + $status_icon = id(new PHUIIconView()) + ->setIcon('fa-eye-slash', 'grey') + ->setTooltip(pht('Hidden Column')); + } + + $rows[] = array( + $status_icon, + $project_name, + $column_name, + ); + } + + $table_view = id(new AphrontTableView($rows)) + ->setNoDataString(pht('This trigger is not used by any columns.')) + ->setHeaders( + array( + null, + pht('Project'), + pht('Column'), + )) + ->setColumnClasses( + array( + null, + null, + 'wide pri', + )); + + $header_view = id(new PHUIHeaderView()) + ->setHeader(pht('Used by Columns')); + + return id(new PHUIObjectBoxView()) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->setHeader($header_view) + ->setTable($table_view); + } + + private function newRulesView(PhabricatorProjectTrigger $trigger) { + $viewer = $this->getViewer(); + $rules = $trigger->getTriggerRules(); + + $rows = array(); + foreach ($rules as $rule) { + $value = $rule->getRecord()->getValue(); + + $rows[] = array( + $rule->getRuleViewIcon($value), + $rule->getRuleViewLabel(), + $rule->getRuleViewDescription($value), + ); + } + + $table_view = id(new AphrontTableView($rows)) + ->setNoDataString(pht('This trigger has no rules.')) + ->setHeaders( + array( + null, + pht('Rule'), + pht('Action'), + )) + ->setColumnClasses( + array( + null, + 'pri', + 'wide', + )); + + $header_view = id(new PHUIHeaderView()) + ->setHeader(pht('Trigger Rules')) + ->setSubheader( + pht( + 'When a card is dropped into a column that uses this trigger, '. + 'these actions will be taken.')); + + return id(new PHUIObjectBoxView()) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->setHeader($header_view) + ->setTable($table_view); + } + private function newCurtain(PhabricatorProjectTrigger $trigger) { + $viewer = $this->getViewer(); + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $viewer, + $trigger, + PhabricatorPolicyCapability::CAN_EDIT); + + $curtain = $this->newCurtainView($trigger); + + $edit_uri = $this->getApplicationURI( + urisprintf( + 'trigger/edit/%d/', + $trigger->getID())); + + $curtain->addAction( + id(new PhabricatorActionView()) + ->setName(pht('Edit Trigger')) + ->setIcon('fa-pencil') + ->setHref($edit_uri) + ->setDisabled(!$can_edit) + ->setWorkflow(!$can_edit)); + + return $curtain; + } + +} diff --git a/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php b/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php index d494767085..e0becc3470 100644 --- a/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php +++ b/src/applications/project/editor/PhabricatorProjectColumnTransactionEditor.php @@ -11,130 +11,12 @@ final class PhabricatorProjectColumnTransactionEditor return pht('Workboard Columns'); } - public function getTransactionTypes() { - $types = parent::getTransactionTypes(); - - $types[] = PhabricatorProjectColumnTransaction::TYPE_NAME; - $types[] = PhabricatorProjectColumnTransaction::TYPE_STATUS; - $types[] = PhabricatorProjectColumnTransaction::TYPE_LIMIT; - - return $types; + public function getCreateObjectTitle($author, $object) { + return pht('%s created this column.', $author); } - protected function getCustomTransactionOldValue( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - return $object->getName(); - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - return $object->getStatus(); - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - return $object->getPointLimit(); - - } - - return parent::getCustomTransactionOldValue($object, $xaction); - } - - protected function getCustomTransactionNewValue( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - return $xaction->getNewValue(); - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - $value = $xaction->getNewValue(); - if (strlen($value)) { - return (int)$xaction->getNewValue(); - } else { - return null; - } - } - - return parent::getCustomTransactionNewValue($object, $xaction); - } - - protected function applyCustomInternalTransaction( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - $object->setName($xaction->getNewValue()); - return; - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - $object->setStatus($xaction->getNewValue()); - return; - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - $object->setPointLimit($xaction->getNewValue()); - return; - } - - return parent::applyCustomInternalTransaction($object, $xaction); - } - - protected function applyCustomExternalTransaction( - PhabricatorLiskDAO $object, - PhabricatorApplicationTransaction $xaction) { - - switch ($xaction->getTransactionType()) { - case PhabricatorProjectColumnTransaction::TYPE_NAME: - case PhabricatorProjectColumnTransaction::TYPE_STATUS: - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - return; - } - - return parent::applyCustomExternalTransaction($object, $xaction); - } - - protected function validateTransaction( - PhabricatorLiskDAO $object, - $type, - array $xactions) { - - $errors = parent::validateTransaction($object, $type, $xactions); - - switch ($type) { - case PhabricatorProjectColumnTransaction::TYPE_LIMIT: - foreach ($xactions as $xaction) { - $value = $xaction->getNewValue(); - if (strlen($value) && !preg_match('/^\d+\z/', $value)) { - $errors[] = new PhabricatorApplicationTransactionValidationError( - $type, - pht('Invalid'), - pht( - 'Column point limit must either be empty or a nonnegative '. - 'integer.'), - $xaction); - } - } - break; - case PhabricatorProjectColumnTransaction::TYPE_NAME: - $missing = $this->validateIsEmptyTextField( - $object->getName(), - $xactions); - - // The default "Backlog" column is allowed to be unnamed, which - // means we use the default name. - - if ($missing && !$object->isDefaultColumn()) { - $error = new PhabricatorApplicationTransactionValidationError( - $type, - pht('Required'), - pht('Column name is required.'), - nonempty(last($xactions), null)); - - $error->setIsMissingFieldError(true); - $errors[] = $error; - } - break; - } - - return $errors; + public function getCreateObjectTitleForFeed($author, $object) { + return pht('%s created %s.', $author, $object); } } diff --git a/src/applications/project/editor/PhabricatorProjectTriggerEditor.php b/src/applications/project/editor/PhabricatorProjectTriggerEditor.php new file mode 100644 index 0000000000..9014fd6f16 --- /dev/null +++ b/src/applications/project/editor/PhabricatorProjectTriggerEditor.php @@ -0,0 +1,34 @@ +queueAddPositionRelative( - $board_phid, - $column_phid, - $object_phid, - $before_phid, - true); - } - - public function queueAddPositionAfter( - $board_phid, - $column_phid, - $object_phid, - $after_phid) { - - return $this->queueAddPositionRelative( - $board_phid, - $column_phid, - $object_phid, - $after_phid, - false); - } - public function queueAddPosition( - $board_phid, - $column_phid, - $object_phid) { - return $this->queueAddPositionRelative( - $board_phid, - $column_phid, - $object_phid, - null, - true); - } - - private function queueAddPositionRelative( $board_phid, $column_phid, $object_phid, - $relative_phid, - $is_before) { + array $after_phids, + array $before_phids) { $board_layout = idx($this->boardLayout, $board_phid, array()); $positions = idx($board_layout, $column_phid, array()); @@ -196,54 +156,76 @@ final class PhabricatorBoardLayoutEngine extends Phobject { ->setObjectPHID($object_phid); } - $found = false; if (!$positions) { $object_position->setSequence(0); } else { - foreach ($positions as $position) { - if (!$found) { - if ($relative_phid === null) { - $is_match = true; - } else { - $position_phid = $position->getObjectPHID(); - $is_match = ($relative_phid == $position_phid); + // The user's view of the board may fall out of date, so they might + // try to drop a card under a different card which is no longer where + // they thought it was. + + // When this happens, we perform the move anyway, since this is almost + // certainly what users want when interacting with the UI. We'l try to + // fall back to another nearby card if the client provided us one. If + // we don't find any of the cards the client specified in the column, + // we'll just move the card to the default position. + + $search_phids = array(); + foreach ($after_phids as $after_phid) { + $search_phids[] = array($after_phid, false); + } + + foreach ($before_phids as $before_phid) { + $search_phids[] = array($before_phid, true); + } + + // This makes us fall back to the default position if we fail every + // candidate position. The default position counts as a "before" position + // because we want to put the new card at the top of the column. + $search_phids[] = array(null, true); + + $found = false; + foreach ($search_phids as $search_position) { + list($relative_phid, $is_before) = $search_position; + foreach ($positions as $position) { + if (!$found) { + if ($relative_phid === null) { + $is_match = true; + } else { + $position_phid = $position->getObjectPHID(); + $is_match = ($relative_phid === $position_phid); + } + + if ($is_match) { + $found = true; + + $sequence = $position->getSequence(); + + if (!$is_before) { + $sequence++; + } + + $object_position->setSequence($sequence++); + + if (!$is_before) { + // If we're inserting after this position, continue the loop so + // we don't update it. + continue; + } + } } - if ($is_match) { - $found = true; - - $sequence = $position->getSequence(); - - if (!$is_before) { - $sequence++; - } - - $object_position->setSequence($sequence++); - - if (!$is_before) { - // If we're inserting after this position, continue the loop so - // we don't update it. - continue; - } + if ($found) { + $position->setSequence($sequence++); + $this->addQueue[] = $position; } } if ($found) { - $position->setSequence($sequence++); - $this->addQueue[] = $position; + break; } } } - if ($relative_phid && !$found) { - throw new Exception( - pht( - 'Unable to find object "%s" in column "%s" on board "%s".', - $relative_phid, - $column_phid, - $board_phid)); - } - $this->addQueue[] = $object_position; $positions[$object_phid] = $object_position; @@ -336,6 +318,7 @@ final class PhabricatorBoardLayoutEngine extends Phobject { $columns = id(new PhabricatorProjectColumnQuery()) ->setViewer($viewer) ->withProjectPHIDs(array_keys($boards)) + ->needTriggers(true) ->execute(); $columns = msort($columns, 'getOrderingKey'); $columns = mpull($columns, null, 'getPHID'); diff --git a/src/applications/project/engine/PhabricatorBoardRenderingEngine.php b/src/applications/project/engine/PhabricatorBoardRenderingEngine.php index d76497bc21..f5a81eb9b0 100644 --- a/src/applications/project/engine/PhabricatorBoardRenderingEngine.php +++ b/src/applications/project/engine/PhabricatorBoardRenderingEngine.php @@ -56,6 +56,7 @@ final class PhabricatorBoardRenderingEngine extends Phobject { $card = id(new ProjectBoardTaskCard()) ->setViewer($viewer) ->setTask($object) + ->setShowEditControls(true) ->setCanEdit($this->getCanEdit($phid)); $owner_phid = $object->getOwnerPHID(); diff --git a/src/applications/project/engine/PhabricatorBoardResponseEngine.php b/src/applications/project/engine/PhabricatorBoardResponseEngine.php index 969dfa3bc8..f22254e43a 100644 --- a/src/applications/project/engine/PhabricatorBoardResponseEngine.php +++ b/src/applications/project/engine/PhabricatorBoardResponseEngine.php @@ -6,6 +6,8 @@ final class PhabricatorBoardResponseEngine extends Phobject { private $boardPHID; private $objectPHID; private $visiblePHIDs; + private $ordering; + private $sounds; public function setViewer(PhabricatorUser $viewer) { $this->viewer = $viewer; @@ -43,10 +45,29 @@ final class PhabricatorBoardResponseEngine extends Phobject { return $this->visiblePHIDs; } + public function setOrdering(PhabricatorProjectColumnOrder $ordering) { + $this->ordering = $ordering; + return $this; + } + + public function getOrdering() { + return $this->ordering; + } + + public function setSounds(array $sounds) { + $this->sounds = $sounds; + return $this; + } + + public function getSounds() { + return $this->sounds; + } + public function buildResponse() { $viewer = $this->getViewer(); $object_phid = $this->getObjectPHID(); $board_phid = $this->getBoardPHID(); + $ordering = $this->getOrdering(); // Load all the other tasks that are visible in the affected columns and // perform layout for them. @@ -74,10 +95,17 @@ final class PhabricatorBoardResponseEngine extends Phobject { ->setViewer($viewer) ->withPHIDs($visible_phids) ->execute(); + $all_visible = mpull($all_visible, null, 'getPHID'); - $order_maps = array(); - foreach ($all_visible as $visible) { - $order_maps[$visible->getPHID()] = $visible->getWorkboardOrderVectors(); + if ($ordering) { + $vectors = $ordering->getSortVectorsForObjects($all_visible); + $header_keys = $ordering->getHeaderKeysForObjects($all_visible); + $headers = $ordering->getHeadersForObjects($all_visible); + $headers = mpull($headers, 'toDictionary'); + } else { + $vectors = array(); + $header_keys = array(); + $headers = array(); } $object = id(new ManiphestTaskQuery()) @@ -91,20 +119,63 @@ final class PhabricatorBoardResponseEngine extends Phobject { $template = $this->buildTemplate($object); + $cards = array(); + foreach ($all_visible as $card_phid => $object) { + $card = array( + 'vectors' => array(), + 'headers' => array(), + 'properties' => array(), + 'nodeHTMLTemplate' => null, + ); + + if ($ordering) { + $order_key = $ordering->getColumnOrderKey(); + + $vector = idx($vectors, $card_phid); + if ($vector !== null) { + $card['vectors'][$order_key] = $vector; + } + + $header = idx($header_keys, $card_phid); + if ($header !== null) { + $card['headers'][$order_key] = $header; + } + + $card['properties'] = self::newTaskProperties($object); + } + + if ($card_phid === $object_phid) { + $card['nodeHTMLTemplate'] = hsprintf('%s', $template); + } + + $card['vectors'] = (object)$card['vectors']; + $card['headers'] = (object)$card['headers']; + $card['properties'] = (object)$card['properties']; + + $cards[$card_phid] = $card; + } + $payload = array( 'objectPHID' => $object_phid, - 'cardHTML' => $template, 'columnMaps' => $natural, - 'orderMaps' => $order_maps, - 'propertyMaps' => array( - $object_phid => $object->getWorkboardProperties(), - ), + 'cards' => $cards, + 'headers' => $headers, + 'sounds' => $this->getSounds(), ); return id(new AphrontAjaxResponse()) ->setContent($payload); } + public static function newTaskProperties($task) { + return array( + 'points' => (double)$task->getPoints(), + 'status' => $task->getStatus(), + 'priority' => (int)$task->getPriority(), + 'owner' => $task->getOwnerPHID(), + ); + } + private function buildTemplate($object) { $viewer = $this->getViewer(); $object_phid = $this->getObjectPHID(); diff --git a/src/applications/project/engine/PhabricatorProjectProfileMenuEngine.php b/src/applications/project/engine/PhabricatorProjectProfileMenuEngine.php index 77b69443da..813cd01781 100644 --- a/src/applications/project/engine/PhabricatorProjectProfileMenuEngine.php +++ b/src/applications/project/engine/PhabricatorProjectProfileMenuEngine.php @@ -22,7 +22,8 @@ final class PhabricatorProjectProfileMenuEngine $items[] = $this->newItem() ->setBuiltinKey(PhabricatorProject::ITEM_PICTURE) - ->setMenuItemKey(PhabricatorProjectPictureProfileMenuItem::MENUITEMKEY); + ->setMenuItemKey(PhabricatorProjectPictureProfileMenuItem::MENUITEMKEY) + ->setIsHeadItem(true); $items[] = $this->newItem() ->setBuiltinKey(PhabricatorProject::ITEM_PROFILE) @@ -47,7 +48,8 @@ final class PhabricatorProjectProfileMenuEngine $items[] = $this->newItem() ->setBuiltinKey(PhabricatorProject::ITEM_MANAGE) - ->setMenuItemKey(PhabricatorProjectManageProfileMenuItem::MENUITEMKEY); + ->setMenuItemKey(PhabricatorProjectManageProfileMenuItem::MENUITEMKEY) + ->setIsTailItem(true); return $items; } diff --git a/src/applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php b/src/applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php new file mode 100644 index 0000000000..b50c51fba6 --- /dev/null +++ b/src/applications/project/engineextension/PhabricatorProjectTriggerUsageIndexEngineExtension.php @@ -0,0 +1,69 @@ +establishConnection('w'); + + $active_statuses = array( + PhabricatorProjectColumn::STATUS_ACTIVE, + ); + + // Select summary information to populate the usage index. When picking + // an "examplePHID", we try to pick an active column. + $row = queryfx_one( + $conn_w, + 'SELECT phid, COUNT(*) N, SUM(IF(status IN (%Ls), 1, 0)) M FROM %R + WHERE triggerPHID = %s + ORDER BY IF(status IN (%Ls), 1, 0) DESC, id ASC', + $active_statuses, + $column_table, + $object->getPHID(), + $active_statuses); + if ($row) { + $example_phid = $row['phid']; + $column_count = $row['N']; + $active_count = $row['M']; + } else { + $example_phid = null; + $column_count = 0; + $active_count = 0; + } + + queryfx( + $conn_w, + 'INSERT INTO %R (triggerPHID, examplePHID, columnCount, activeColumnCount) + VALUES (%s, %ns, %d, %d) + ON DUPLICATE KEY UPDATE + examplePHID = VALUES(examplePHID), + columnCount = VALUES(columnCount), + activeColumnCount = VALUES(activeColumnCount)', + $usage_table, + $object->getPHID(), + $example_phid, + $column_count, + $active_count); + } + +} diff --git a/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php b/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php index c69e130275..7251323415 100644 --- a/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php +++ b/src/applications/project/engineextension/PhabricatorProjectsCurtainExtension.php @@ -55,7 +55,7 @@ final class PhabricatorProjectsCurtainExtension $column_link = phutil_tag( 'a', array( - 'href' => "/project/board/{$project_id}/", + 'href' => $column->getWorkboardURI(), 'class' => 'maniphest-board-link', ), $column_name); diff --git a/src/applications/project/events/PhabricatorProjectUIEventListener.php b/src/applications/project/events/PhabricatorProjectUIEventListener.php index 104084bbf7..25d1ba9f74 100644 --- a/src/applications/project/events/PhabricatorProjectUIEventListener.php +++ b/src/applications/project/events/PhabricatorProjectUIEventListener.php @@ -81,7 +81,7 @@ final class PhabricatorProjectUIEventListener $column_link = phutil_tag( 'a', array( - 'href' => "/project/board/{$project_id}/", + 'href' => $column->getWorkboardURI(), 'class' => 'maniphest-board-link', ), $column_name); diff --git a/src/applications/project/exception/PhabricatorProjectTriggerCorruptionException.php b/src/applications/project/exception/PhabricatorProjectTriggerCorruptionException.php new file mode 100644 index 0000000000..c235fe7357 --- /dev/null +++ b/src/applications/project/exception/PhabricatorProjectTriggerCorruptionException.php @@ -0,0 +1,4 @@ +icon = $icon; + return $this; + } + + public function getIcon() { + return $this->icon; + } + + public function setColor($color) { + $this->color = $color; + return $this; + } + + public function getColor() { + return $this->color; + } + + public function setContent($content) { + $this->content = $content; + return $this; + } + + public function getContent() { + return $this->content; + } + + public function toDictionary() { + return array( + 'icon' => $this->getIcon(), + 'color' => $this->getColor(), + 'content' => hsprintf('%s', $this->getContent()), + 'isTriggerEffect' => $this->getIsTriggerEffect(), + 'isHeader' => $this->getIsHeader(), + 'conditions' => $this->getConditions(), + ); + } + + public function addCondition($field, $operator, $value) { + $this->conditions[] = array( + 'field' => $field, + 'operator' => $operator, + 'value' => $value, + ); + + return $this; + } + + public function getConditions() { + return $this->conditions; + } + + public function setIsTriggerEffect($is_trigger_effect) { + $this->isTriggerEffect = $is_trigger_effect; + return $this; + } + + public function getIsTriggerEffect() { + return $this->isTriggerEffect; + } + + public function setIsHeader($is_header) { + $this->isHeader = $is_header; + return $this; + } + + public function getIsHeader() { + return $this->isHeader; + } + +} diff --git a/src/applications/project/menuitem/PhabricatorProjectDetailsProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectDetailsProfileMenuItem.php index b779f4be90..a3021e0239 100644 --- a/src/applications/project/menuitem/PhabricatorProjectDetailsProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectDetailsProfileMenuItem.php @@ -13,6 +13,10 @@ final class PhabricatorProjectDetailsProfileMenuItem return pht('Project Details'); } + public function getMenuItemTypeIcon() { + return 'fa-file-text-o'; + } + public function canHideMenuItem( PhabricatorProfileMenuItemConfiguration $config) { return false; @@ -45,7 +49,7 @@ final class PhabricatorProjectDetailsProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $project = $config->getProfileObject(); @@ -54,10 +58,10 @@ final class PhabricatorProjectDetailsProfileMenuItem $name = $project->getName(); $icon = $project->getDisplayIconIcon(); - $href = "/project/profile/{$id}/"; + $uri = "/project/profile/{$id}/"; - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon($icon); diff --git a/src/applications/project/menuitem/PhabricatorProjectManageProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectManageProfileMenuItem.php index 1bd7e796dc..9b8a769318 100644 --- a/src/applications/project/menuitem/PhabricatorProjectManageProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectManageProfileMenuItem.php @@ -13,6 +13,10 @@ final class PhabricatorProjectManageProfileMenuItem return pht('Manage'); } + public function getMenuItemTypeIcon() { + return 'fa-cog'; + } + public function canHideMenuItem( PhabricatorProfileMenuItemConfiguration $config) { return false; @@ -45,7 +49,7 @@ final class PhabricatorProjectManageProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $project = $config->getProfileObject(); @@ -54,10 +58,10 @@ final class PhabricatorProjectManageProfileMenuItem $name = $this->getDisplayName($config); $icon = 'fa-gears'; - $href = "/project/manage/{$id}/"; + $uri = "/project/manage/{$id}/"; - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon($icon); diff --git a/src/applications/project/menuitem/PhabricatorProjectMembersProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectMembersProfileMenuItem.php index 580aacb635..11a57d3a5b 100644 --- a/src/applications/project/menuitem/PhabricatorProjectMembersProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectMembersProfileMenuItem.php @@ -13,6 +13,10 @@ final class PhabricatorProjectMembersProfileMenuItem return pht('Members'); } + public function getMenuItemTypeIcon() { + return 'fa-users'; + } + public function getDisplayName( PhabricatorProfileMenuItemConfiguration $config) { $name = $config->getMenuItemProperty('name'); @@ -35,7 +39,7 @@ final class PhabricatorProjectMembersProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $project = $config->getProfileObject(); @@ -44,10 +48,10 @@ final class PhabricatorProjectMembersProfileMenuItem $name = $this->getDisplayName($config); $icon = 'fa-group'; - $href = "/project/members/{$id}/"; + $uri = "/project/members/{$id}/"; - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon($icon); diff --git a/src/applications/project/menuitem/PhabricatorProjectPictureProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectPictureProfileMenuItem.php index 2201687c81..5a58b3af41 100644 --- a/src/applications/project/menuitem/PhabricatorProjectPictureProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectPictureProfileMenuItem.php @@ -13,6 +13,10 @@ final class PhabricatorProjectPictureProfileMenuItem return pht('Project Picture'); } + public function getMenuItemTypeIcon() { + return 'fa-image'; + } + public function canHideMenuItem( PhabricatorProfileMenuItemConfiguration $config) { return false; @@ -28,38 +32,16 @@ final class PhabricatorProjectPictureProfileMenuItem return array(); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $project = $config->getProfileObject(); - require_celerity_resource('people-picture-menu-item-css'); - $picture = $project->getProfileImageURI(); - $href = $project->getProfileURI(); - $classes = array(); - $classes[] = 'people-menu-image'; - if ($project->isArchived()) { - $classes[] = 'phui-image-disabled'; - } + $item = $this->newItemView() + ->setDisabled($project->isArchived()); - $photo = phutil_tag( - 'img', - array( - 'src' => $picture, - 'class' => implode(' ', $classes), - )); - - $view = phutil_tag_div('people-menu-image-container', $photo); - $view = phutil_tag( - 'a', - array( - 'href' => $href, - ), - $view); - - $item = $this->newItem() - ->appendChild($view); + $item->newProfileImage($picture); return array( $item, diff --git a/src/applications/project/menuitem/PhabricatorProjectPointsProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectPointsProfileMenuItem.php index b64b4bb7a0..d8c7ee82b1 100644 --- a/src/applications/project/menuitem/PhabricatorProjectPointsProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectPointsProfileMenuItem.php @@ -52,7 +52,7 @@ final class PhabricatorProjectPointsProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $viewer = $this->getViewer(); $project = $config->getProfileObject(); @@ -165,8 +165,8 @@ final class PhabricatorProjectPointsProfileMenuItem ), $bar); - $item = $this->newItem() - ->appendChild($bar); + $item = $this->newItemView() + ->newProgressBar($bar); return array( $item, diff --git a/src/applications/project/menuitem/PhabricatorProjectSubprojectsProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectSubprojectsProfileMenuItem.php index 6b43210274..b1782e8f1c 100644 --- a/src/applications/project/menuitem/PhabricatorProjectSubprojectsProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectSubprojectsProfileMenuItem.php @@ -13,6 +13,10 @@ final class PhabricatorProjectSubprojectsProfileMenuItem return pht('Subprojects'); } + public function getMenuItemTypeIcon() { + return 'fa-sitemap'; + } + public function shouldEnableForObject($object) { if ($object->isMilestone()) { return false; @@ -43,7 +47,7 @@ final class PhabricatorProjectSubprojectsProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $project = $config->getProfileObject(); @@ -51,10 +55,10 @@ final class PhabricatorProjectSubprojectsProfileMenuItem $name = $this->getDisplayName($config); $icon = 'fa-sitemap'; - $href = "/project/subprojects/{$id}/"; + $uri = "/project/subprojects/{$id}/"; - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon($icon); diff --git a/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php b/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php index 80ec0d835a..34152f85e7 100644 --- a/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php +++ b/src/applications/project/menuitem/PhabricatorProjectWorkboardProfileMenuItem.php @@ -13,6 +13,10 @@ final class PhabricatorProjectWorkboardProfileMenuItem return pht('Workboard'); } + public function getMenuItemTypeIcon() { + return 'fa-columns'; + } + public function canMakeDefault( PhabricatorProfileMenuItemConfiguration $config) { return true; @@ -52,16 +56,16 @@ final class PhabricatorProjectWorkboardProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $project = $config->getProfileObject(); $id = $project->getID(); - $href = "/project/board/{$id}/"; + $uri = $project->getWorkboardURI(); $name = $this->getDisplayName($config); - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon('fa-columns'); diff --git a/src/applications/project/order/PhabricatorProjectColumnAuthorOrder.php b/src/applications/project/order/PhabricatorProjectColumnAuthorOrder.php new file mode 100644 index 0000000000..9d6bac2aff --- /dev/null +++ b/src/applications/project/order/PhabricatorProjectColumnAuthorOrder.php @@ -0,0 +1,139 @@ +newHeaderKeyForAuthorPHID($object->getAuthorPHID()); + } + + private function newHeaderKeyForAuthorPHID($author_phid) { + return sprintf('author(%s)', $author_phid); + } + + protected function newSortVectorsForObjects(array $objects) { + $author_phids = mpull($objects, null, 'getAuthorPHID'); + $author_phids = array_keys($author_phids); + $author_phids = array_filter($author_phids); + + if ($author_phids) { + $author_users = id(new PhabricatorPeopleQuery()) + ->setViewer($this->getViewer()) + ->withPHIDs($author_phids) + ->execute(); + $author_users = mpull($author_users, null, 'getPHID'); + } else { + $author_users = array(); + } + + $vectors = array(); + foreach ($objects as $vector_key => $object) { + $author_phid = $object->getAuthorPHID(); + $author = idx($author_users, $author_phid); + if ($author) { + $vector = $this->newSortVectorForAuthor($author); + } else { + $vector = $this->newSortVectorForAuthorPHID($author_phid); + } + + $vectors[$vector_key] = $vector; + } + + return $vectors; + } + + private function newSortVectorForAuthor(PhabricatorUser $user) { + return array( + 1, + $user->getUsername(), + ); + } + + private function newSortVectorForAuthorPHID($author_phid) { + return array( + 2, + $author_phid, + ); + } + + protected function newHeadersForObjects(array $objects) { + $author_phids = mpull($objects, null, 'getAuthorPHID'); + $author_phids = array_keys($author_phids); + $author_phids = array_filter($author_phids); + + if ($author_phids) { + $author_users = id(new PhabricatorPeopleQuery()) + ->setViewer($this->getViewer()) + ->withPHIDs($author_phids) + ->needProfileImage(true) + ->execute(); + $author_users = mpull($author_users, null, 'getPHID'); + } else { + $author_users = array(); + } + + $headers = array(); + foreach ($author_phids as $author_phid) { + $header_key = $this->newHeaderKeyForAuthorPHID($author_phid); + + $author = idx($author_users, $author_phid); + if ($author) { + $sort_vector = $this->newSortVectorForAuthor($author); + $author_name = $author->getUsername(); + $author_image = $author->getProfileImageURI(); + } else { + $sort_vector = $this->newSortVectorForAuthorPHID($author_phid); + $author_name = pht('Unknown User ("%s")', $author_phid); + $author_image = null; + } + + $author_icon = 'fa-user'; + $author_color = 'bluegrey'; + + $icon_view = id(new PHUIIconView()); + + if ($author_image) { + $icon_view->setImage($author_image); + } else { + $icon_view->setIcon($author_icon, $author_color); + } + + $header = $this->newHeader() + ->setHeaderKey($header_key) + ->setSortVector($sort_vector) + ->setName($author_name) + ->setIcon($icon_view) + ->setEditProperties( + array( + 'value' => $author_phid, + )); + + $headers[] = $header; + } + + return $headers; + } + +} diff --git a/src/applications/project/order/PhabricatorProjectColumnCreatedOrder.php b/src/applications/project/order/PhabricatorProjectColumnCreatedOrder.php new file mode 100644 index 0000000000..05f25a3d6a --- /dev/null +++ b/src/applications/project/order/PhabricatorProjectColumnCreatedOrder.php @@ -0,0 +1,35 @@ +getDateCreated(), + -1 * (int)$object->getID(), + ); + } + +} diff --git a/src/applications/project/order/PhabricatorProjectColumnHeader.php b/src/applications/project/order/PhabricatorProjectColumnHeader.php new file mode 100644 index 0000000000..898d9b0222 --- /dev/null +++ b/src/applications/project/order/PhabricatorProjectColumnHeader.php @@ -0,0 +1,110 @@ +orderKey = $order_key; + return $this; + } + + public function getOrderKey() { + return $this->orderKey; + } + + public function setHeaderKey($header_key) { + $this->headerKey = $header_key; + return $this; + } + + public function getHeaderKey() { + return $this->headerKey; + } + + public function setSortVector(array $sort_vector) { + $this->sortVector = $sort_vector; + return $this; + } + + public function getSortVector() { + return $this->sortVector; + } + + public function setName($name) { + $this->name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + public function setIcon(PHUIIconView$icon) { + $this->icon = $icon; + return $this; + } + + public function getIcon() { + return $this->icon; + } + + public function setEditProperties(array $edit_properties) { + $this->editProperties = $edit_properties; + return $this; + } + + public function getEditProperties() { + return $this->editProperties; + } + + public function addDropEffect(PhabricatorProjectDropEffect $effect) { + $this->dropEffects[] = $effect; + return $this; + } + + public function getDropEffects() { + return $this->dropEffects; + } + + public function toDictionary() { + return array( + 'order' => $this->getOrderKey(), + 'key' => $this->getHeaderKey(), + 'template' => hsprintf('%s', $this->newView()), + 'vector' => $this->getSortVector(), + 'editProperties' => $this->getEditProperties(), + 'effects' => mpull($this->getDropEffects(), 'toDictionary'), + ); + } + + private function newView() { + $icon_view = $this->getIcon(); + $name = $this->getName(); + + $template = phutil_tag( + 'li', + array( + 'class' => 'workboard-group-header', + ), + array( + $icon_view, + phutil_tag( + 'span', + array( + 'class' => 'workboard-group-header-name', + ), + $name), + )); + + return $template; + } + +} diff --git a/src/applications/project/order/PhabricatorProjectColumnNaturalOrder.php b/src/applications/project/order/PhabricatorProjectColumnNaturalOrder.php new file mode 100644 index 0000000000..be67d28bcc --- /dev/null +++ b/src/applications/project/order/PhabricatorProjectColumnNaturalOrder.php @@ -0,0 +1,24 @@ +viewer = $viewer; + return $this; + } + + final public function getViewer() { + return $this->viewer; + } + + final public function getColumnOrderKey() { + return $this->getPhobjectClassConstant('ORDERKEY'); + } + + final public static function getAllOrders() { + return id(new PhutilClassMapQuery()) + ->setAncestorClass(__CLASS__) + ->setUniqueMethod('getColumnOrderKey') + ->setSortMethod('getMenuOrder') + ->execute(); + } + + final public static function getEnabledOrders() { + $map = self::getAllOrders(); + + foreach ($map as $key => $order) { + if (!$order->isEnabled()) { + unset($map[$key]); + } + } + + return $map; + } + + final public static function getOrderByKey($key) { + $map = self::getAllOrders(); + + if (!isset($map[$key])) { + throw new Exception( + pht( + 'No column ordering exists with key "%s".', + $key)); + } + + return $map[$key]; + } + + final public function getColumnTransactions($object, array $header) { + $result = $this->newColumnTransactions($object, $header); + + if (!is_array($result) && !is_null($result)) { + throw new Exception( + pht( + 'Expected "newColumnTransactions()" on "%s" to return "null" or a '. + 'list of transactions, but got "%s".', + get_class($this), + phutil_describe_type($result))); + } + + if ($result === null) { + $result = array(); + } + + assert_instances_of($result, 'PhabricatorApplicationTransaction'); + + return $result; + } + + final public function getMenuIconIcon() { + return $this->newMenuIconIcon(); + } + + protected function newMenuIconIcon() { + return 'fa-sort-amount-asc'; + } + + abstract public function getDisplayName(); + abstract public function getHasHeaders(); + abstract public function getCanReorder(); + + public function getMenuOrder() { + return 9000; + } + + public function isEnabled() { + return true; + } + + protected function newColumnTransactions($object, array $header) { + return array(); + } + + final public function getHeadersForObjects(array $objects) { + $headers = $this->newHeadersForObjects($objects); + + if (!is_array($headers)) { + throw new Exception( + pht( + 'Expected "newHeadersForObjects()" on "%s" to return a list '. + 'of headers, but got "%s".', + get_class($this), + phutil_describe_type($headers))); + } + + assert_instances_of($headers, 'PhabricatorProjectColumnHeader'); + + // Add a "0" to the end of each header. This makes them sort above object + // cards in the same group. + foreach ($headers as $header) { + $vector = $header->getSortVector(); + $vector[] = 0; + $header->setSortVector($vector); + } + + return $headers; + } + + protected function newHeadersForObjects(array $objects) { + return array(); + } + + final public function getSortVectorsForObjects(array $objects) { + $vectors = $this->newSortVectorsForObjects($objects); + + if (!is_array($vectors)) { + throw new Exception( + pht( + 'Expected "newSortVectorsForObjects()" on "%s" to return a '. + 'map of vectors, but got "%s".', + get_class($this), + phutil_describe_type($vectors))); + } + + assert_same_keys($objects, $vectors); + + return $vectors; + } + + protected function newSortVectorsForObjects(array $objects) { + $vectors = array(); + + foreach ($objects as $key => $object) { + $vectors[$key] = $this->newSortVectorForObject($object); + } + + return $vectors; + } + + protected function newSortVectorForObject($object) { + return array(); + } + + final public function getHeaderKeysForObjects(array $objects) { + $header_keys = $this->newHeaderKeysForObjects($objects); + + if (!is_array($header_keys)) { + throw new Exception( + pht( + 'Expected "newHeaderKeysForObject()" on "%s" to return a '. + 'map of header keys, but got "%s".', + get_class($this), + phutil_describe_type($header_keys))); + } + + assert_same_keys($objects, $header_keys); + + return $header_keys; + } + + protected function newHeaderKeysForObjects(array $objects) { + $header_keys = array(); + + foreach ($objects as $key => $object) { + $header_keys[$key] = $this->newHeaderKeyForObject($object); + } + + return $header_keys; + } + + protected function newHeaderKeyForObject($object) { + return null; + } + + final protected function newTransaction($object) { + return $object->getApplicationTransactionTemplate(); + } + + final protected function newHeader() { + return id(new PhabricatorProjectColumnHeader()) + ->setOrderKey($this->getColumnOrderKey()); + } + + final protected function newEffect() { + return new PhabricatorProjectDropEffect(); + } + + final public function toDictionary() { + return array( + 'orderKey' => $this->getColumnOrderKey(), + 'hasHeaders' => $this->getHasHeaders(), + 'canReorder' => $this->getCanReorder(), + ); + } + +} diff --git a/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php b/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php new file mode 100644 index 0000000000..48a6c394db --- /dev/null +++ b/src/applications/project/order/PhabricatorProjectColumnOwnerOrder.php @@ -0,0 +1,199 @@ +newHeaderKeyForOwnerPHID($object->getOwnerPHID()); + } + + private function newHeaderKeyForOwnerPHID($owner_phid) { + if ($owner_phid === null) { + $owner_phid = ''; + } + + return sprintf('owner(%s)', $owner_phid); + } + + protected function newSortVectorsForObjects(array $objects) { + $owner_phids = mpull($objects, null, 'getOwnerPHID'); + $owner_phids = array_keys($owner_phids); + $owner_phids = array_filter($owner_phids); + + if ($owner_phids) { + $owner_users = id(new PhabricatorPeopleQuery()) + ->setViewer($this->getViewer()) + ->withPHIDs($owner_phids) + ->execute(); + $owner_users = mpull($owner_users, null, 'getPHID'); + } else { + $owner_users = array(); + } + + $vectors = array(); + foreach ($objects as $vector_key => $object) { + $owner_phid = $object->getOwnerPHID(); + if (!$owner_phid) { + $vector = $this->newSortVectorForUnowned(); + } else { + $owner = idx($owner_users, $owner_phid); + if ($owner) { + $vector = $this->newSortVectorForOwner($owner); + } else { + $vector = $this->newSortVectorForOwnerPHID($owner_phid); + } + } + + $vectors[$vector_key] = $vector; + } + + return $vectors; + } + + private function newSortVectorForUnowned() { + // Always put unasssigned tasks at the top. + return array( + 0, + ); + } + + private function newSortVectorForOwner(PhabricatorUser $user) { + // Put assigned tasks with a valid owner after "Unassigned", but above + // assigned tasks with an invalid owner. Sort these tasks by the owner's + // username. + return array( + 1, + $user->getUsername(), + ); + } + + private function newSortVectorForOwnerPHID($owner_phid) { + // If we have tasks with a nonempty owner but can't load the associated + // "User" object, move them to the bottom. We can only sort these by the + // PHID. + return array( + 2, + $owner_phid, + ); + } + + protected function newHeadersForObjects(array $objects) { + $owner_phids = mpull($objects, null, 'getOwnerPHID'); + $owner_phids = array_keys($owner_phids); + $owner_phids = array_filter($owner_phids); + + if ($owner_phids) { + $owner_users = id(new PhabricatorPeopleQuery()) + ->setViewer($this->getViewer()) + ->withPHIDs($owner_phids) + ->needProfileImage(true) + ->execute(); + $owner_users = mpull($owner_users, null, 'getPHID'); + } else { + $owner_users = array(); + } + + array_unshift($owner_phids, null); + + $headers = array(); + foreach ($owner_phids as $owner_phid) { + $header_key = $this->newHeaderKeyForOwnerPHID($owner_phid); + + $owner_image = null; + $effect_content = null; + if ($owner_phid === null) { + $owner = null; + $sort_vector = $this->newSortVectorForUnowned(); + $owner_name = pht('Not Assigned'); + + $effect_content = pht('Remove task assignee.'); + } else { + $owner = idx($owner_users, $owner_phid); + if ($owner) { + $sort_vector = $this->newSortVectorForOwner($owner); + $owner_name = $owner->getUsername(); + $owner_image = $owner->getProfileImageURI(); + + $effect_content = pht( + 'Assign task to %s.', + phutil_tag('strong', array(), $owner_name)); + } else { + $sort_vector = $this->newSortVectorForOwnerPHID($owner_phid); + $owner_name = pht('Unknown User ("%s")', $owner_phid); + } + } + + $owner_icon = 'fa-user'; + $owner_color = 'bluegrey'; + + $icon_view = id(new PHUIIconView()); + + if ($owner_image) { + $icon_view->setImage($owner_image); + } else { + $icon_view->setIcon($owner_icon, $owner_color); + } + + $header = $this->newHeader() + ->setHeaderKey($header_key) + ->setSortVector($sort_vector) + ->setName($owner_name) + ->setIcon($icon_view) + ->setEditProperties( + array( + 'value' => $owner_phid, + )); + + if ($effect_content !== null) { + $header->addDropEffect( + $this->newEffect() + ->setIcon($owner_icon) + ->setColor($owner_color) + ->addCondition('owner', '!=', $owner_phid) + ->setContent($effect_content)); + } + + $headers[] = $header; + } + + return $headers; + } + + protected function newColumnTransactions($object, array $header) { + $new_owner = idx($header, 'value'); + + if ($object->getOwnerPHID() === $new_owner) { + return null; + } + + $xactions = array(); + $xactions[] = $this->newTransaction($object) + ->setTransactionType(ManiphestTaskOwnerTransaction::TRANSACTIONTYPE) + ->setNewValue($new_owner); + + return $xactions; + } + +} diff --git a/src/applications/project/order/PhabricatorProjectColumnPointsOrder.php b/src/applications/project/order/PhabricatorProjectColumnPointsOrder.php new file mode 100644 index 0000000000..2e9be8e4bb --- /dev/null +++ b/src/applications/project/order/PhabricatorProjectColumnPointsOrder.php @@ -0,0 +1,50 @@ +getPoints(); + + // Put cards with no points on top. + $has_points = ($points !== null); + if (!$has_points) { + $overall_order = 0; + } else { + $overall_order = 1; + } + + return array( + $overall_order, + -1.0 * (double)$points, + -1 * (int)$object->getID(), + ); + } + +} diff --git a/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php b/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php new file mode 100644 index 0000000000..42ccf96553 --- /dev/null +++ b/src/applications/project/order/PhabricatorProjectColumnPriorityOrder.php @@ -0,0 +1,113 @@ +newHeaderKeyForPriority($object->getPriority()); + } + + private function newHeaderKeyForPriority($priority) { + return sprintf('priority(%d)', $priority); + } + + protected function newSortVectorForObject($object) { + return $this->newSortVectorForPriority($object->getPriority()); + } + + private function newSortVectorForPriority($priority) { + return array( + -1 * (int)$priority, + ); + } + + protected function newHeadersForObjects(array $objects) { + $priorities = ManiphestTaskPriority::getTaskPriorityMap(); + + // It's possible for tasks to have an invalid/unknown priority in the + // database. We still want to generate a header for these tasks so we + // don't break the workboard. + $priorities = $priorities + mpull($objects, null, 'getPriority'); + + $priorities = array_keys($priorities); + + $headers = array(); + foreach ($priorities as $priority) { + $header_key = $this->newHeaderKeyForPriority($priority); + $sort_vector = $this->newSortVectorForPriority($priority); + + $priority_name = ManiphestTaskPriority::getTaskPriorityName($priority); + $priority_color = ManiphestTaskPriority::getTaskPriorityColor($priority); + $priority_icon = ManiphestTaskPriority::getTaskPriorityIcon($priority); + + $icon_view = id(new PHUIIconView()) + ->setIcon($priority_icon, $priority_color); + + $drop_effect = $this->newEffect() + ->setIcon($priority_icon) + ->setColor($priority_color) + ->addCondition('priority', '!=', $priority) + ->setContent( + pht( + 'Change priority to %s.', + phutil_tag('strong', array(), $priority_name))); + + $header = $this->newHeader() + ->setHeaderKey($header_key) + ->setSortVector($sort_vector) + ->setName($priority_name) + ->setIcon($icon_view) + ->setEditProperties( + array( + 'value' => (int)$priority, + )) + ->addDropEffect($drop_effect); + + $headers[] = $header; + } + + return $headers; + } + + protected function newColumnTransactions($object, array $header) { + $new_priority = idx($header, 'value'); + + if ($object->getPriority() === $new_priority) { + return null; + } + + $keyword_map = ManiphestTaskPriority::getTaskPriorityKeywordsMap(); + $keyword = head(idx($keyword_map, $new_priority)); + + $xactions = array(); + $xactions[] = $this->newTransaction($object) + ->setTransactionType(ManiphestTaskPriorityTransaction::TRANSACTIONTYPE) + ->setNewValue($keyword); + + return $xactions; + } + + +} diff --git a/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php b/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php new file mode 100644 index 0000000000..2cb156aa92 --- /dev/null +++ b/src/applications/project/order/PhabricatorProjectColumnStatusOrder.php @@ -0,0 +1,116 @@ +newHeaderKeyForStatus($object->getStatus()); + } + + private function newHeaderKeyForStatus($status) { + return sprintf('status(%s)', $status); + } + + protected function newSortVectorsForObjects(array $objects) { + $status_sequence = $this->newStatusSequence(); + + $vectors = array(); + foreach ($objects as $object_key => $object) { + $vectors[$object_key] = array( + (int)idx($status_sequence, $object->getStatus(), 0), + ); + } + + return $vectors; + } + + private function newStatusSequence() { + $statuses = ManiphestTaskStatus::getTaskStatusMap(); + return array_combine( + array_keys($statuses), + range(1, count($statuses))); + } + + protected function newHeadersForObjects(array $objects) { + $headers = array(); + + $statuses = ManiphestTaskStatus::getTaskStatusMap(); + $sequence = $this->newStatusSequence(); + + foreach ($statuses as $status_key => $status_name) { + $header_key = $this->newHeaderKeyForStatus($status_key); + + $sort_vector = array( + (int)idx($sequence, $status_key, 0), + ); + + $status_icon = ManiphestTaskStatus::getStatusIcon($status_key); + $status_color = ManiphestTaskStatus::getStatusColor($status_key); + + $icon_view = id(new PHUIIconView()) + ->setIcon($status_icon, $status_color); + + $drop_effect = $this->newEffect() + ->setIcon($status_icon) + ->setColor($status_color) + ->addCondition('status', '!=', $status_key) + ->setContent( + pht( + 'Change status to %s.', + phutil_tag('strong', array(), $status_name))); + + $header = $this->newHeader() + ->setHeaderKey($header_key) + ->setSortVector($sort_vector) + ->setName($status_name) + ->setIcon($icon_view) + ->setEditProperties( + array( + 'value' => $status_key, + )) + ->addDropEffect($drop_effect); + + $headers[] = $header; + } + + return $headers; + } + + protected function newColumnTransactions($object, array $header) { + $new_status = idx($header, 'value'); + + if ($object->getStatus() === $new_status) { + return null; + } + + $xactions = array(); + $xactions[] = $this->newTransaction($object) + ->setTransactionType(ManiphestTaskStatusTransaction::TRANSACTIONTYPE) + ->setNewValue($new_status); + + return $xactions; + } + +} diff --git a/src/applications/project/order/PhabricatorProjectColumnTitleOrder.php b/src/applications/project/order/PhabricatorProjectColumnTitleOrder.php new file mode 100644 index 0000000000..a281c75437 --- /dev/null +++ b/src/applications/project/order/PhabricatorProjectColumnTitleOrder.php @@ -0,0 +1,34 @@ +getTitle(), + ); + } + +} diff --git a/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php b/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php index 07c7f7a0ee..c58bb44671 100644 --- a/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php +++ b/src/applications/project/phid/PhabricatorProjectColumnPHIDType.php @@ -37,7 +37,7 @@ final class PhabricatorProjectColumnPHIDType extends PhabricatorPHIDType { $column = $objects[$phid]; $handle->setName($column->getDisplayName()); - $handle->setURI('/project/board/'.$column->getProject()->getID().'/'); + $handle->setURI($column->getWorkboardURI()); if ($column->isHidden()) { $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); diff --git a/src/applications/project/phid/PhabricatorProjectTriggerPHIDType.php b/src/applications/project/phid/PhabricatorProjectTriggerPHIDType.php new file mode 100644 index 0000000000..346b0e69fa --- /dev/null +++ b/src/applications/project/phid/PhabricatorProjectTriggerPHIDType.php @@ -0,0 +1,45 @@ +withPHIDs($phids); + } + + public function loadHandles( + PhabricatorHandleQuery $query, + array $handles, + array $objects) { + + foreach ($handles as $phid => $handle) { + $trigger = $objects[$phid]; + + $handle->setName($trigger->getDisplayName()); + $handle->setURI($trigger->getURI()); + } + } + +} diff --git a/src/applications/project/query/PhabricatorProjectColumnQuery.php b/src/applications/project/query/PhabricatorProjectColumnQuery.php index 441c33e8cb..380dab5208 100644 --- a/src/applications/project/query/PhabricatorProjectColumnQuery.php +++ b/src/applications/project/query/PhabricatorProjectColumnQuery.php @@ -9,6 +9,8 @@ final class PhabricatorProjectColumnQuery private $proxyPHIDs; private $statuses; private $isProxyColumn; + private $triggerPHIDs; + private $needTriggers; public function withIDs(array $ids) { $this->ids = $ids; @@ -40,6 +42,16 @@ final class PhabricatorProjectColumnQuery return $this; } + public function withTriggerPHIDs(array $trigger_phids) { + $this->triggerPHIDs = $trigger_phids; + return $this; + } + + public function needTriggers($need_triggers) { + $this->needTriggers = true; + return $this; + } + public function newResultObject() { return new PhabricatorProjectColumn(); } @@ -121,6 +133,42 @@ final class PhabricatorProjectColumnQuery $column->attachProxy($proxy); } + if ($this->needTriggers) { + $trigger_phids = array(); + foreach ($page as $column) { + if ($column->canHaveTrigger()) { + $trigger_phid = $column->getTriggerPHID(); + if ($trigger_phid) { + $trigger_phids[] = $trigger_phid; + } + } + } + + if ($trigger_phids) { + $triggers = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($this->getViewer()) + ->setParentQuery($this) + ->withPHIDs($trigger_phids) + ->execute(); + $triggers = mpull($triggers, null, 'getPHID'); + } else { + $triggers = array(); + } + + foreach ($page as $column) { + $trigger = null; + + if ($column->canHaveTrigger()) { + $trigger_phid = $column->getTriggerPHID(); + if ($trigger_phid) { + $trigger = idx($triggers, $trigger_phid); + } + } + + $column->attachTrigger($trigger); + } + } + return $page; } @@ -162,6 +210,13 @@ final class PhabricatorProjectColumnQuery $this->statuses); } + if ($this->triggerPHIDs !== null) { + $where[] = qsprintf( + $conn, + 'triggerPHID IN (%Ls)', + $this->triggerPHIDs); + } + if ($this->isProxyColumn !== null) { if ($this->isProxyColumn) { $where[] = qsprintf($conn, 'proxyPHID IS NOT NULL'); diff --git a/src/applications/project/query/PhabricatorProjectQuery.php b/src/applications/project/query/PhabricatorProjectQuery.php index f6087f7d2a..b08a58501f 100644 --- a/src/applications/project/query/PhabricatorProjectQuery.php +++ b/src/applications/project/query/PhabricatorProjectQuery.php @@ -201,12 +201,11 @@ final class PhabricatorProjectQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $project = $this->loadCursorObject($cursor); + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $project->getID(), - 'name' => $project->getName(), - 'status' => $project->getStatus(), + 'id' => (int)$object->getID(), + 'name' => $object->getName(), + 'status' => $object->getStatus(), ); } diff --git a/src/applications/project/query/PhabricatorProjectTriggerQuery.php b/src/applications/project/query/PhabricatorProjectTriggerQuery.php new file mode 100644 index 0000000000..452e3e53f1 --- /dev/null +++ b/src/applications/project/query/PhabricatorProjectTriggerQuery.php @@ -0,0 +1,135 @@ +ids = $ids; + return $this; + } + + public function withPHIDs(array $phids) { + $this->phids = $phids; + return $this; + } + + public function needUsage($need_usage) { + $this->needUsage = $need_usage; + return $this; + } + + public function withActiveColumnCountBetween($min, $max) { + $this->activeColumnMin = $min; + $this->activeColumnMax = $max; + return $this; + } + + public function newResultObject() { + return new PhabricatorProjectTrigger(); + } + + protected function loadPage() { + return $this->loadStandardPage($this->newResultObject()); + } + + protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { + $where = parent::buildWhereClauseParts($conn); + + if ($this->ids !== null) { + $where[] = qsprintf( + $conn, + 'trigger.id IN (%Ld)', + $this->ids); + } + + if ($this->phids !== null) { + $where[] = qsprintf( + $conn, + 'trigger.phid IN (%Ls)', + $this->phids); + } + + if ($this->activeColumnMin !== null) { + $where[] = qsprintf( + $conn, + 'trigger_usage.activeColumnCount >= %d', + $this->activeColumnMin); + } + + if ($this->activeColumnMax !== null) { + $where[] = qsprintf( + $conn, + 'trigger_usage.activeColumnCount <= %d', + $this->activeColumnMax); + } + + return $where; + } + + protected function buildJoinClauseParts(AphrontDatabaseConnection $conn) { + $joins = parent::buildJoinClauseParts($conn); + + if ($this->shouldJoinUsageTable()) { + $joins[] = qsprintf( + $conn, + 'JOIN %R trigger_usage ON trigger.phid = trigger_usage.triggerPHID', + new PhabricatorProjectTriggerUsage()); + } + + return $joins; + } + + private function shouldJoinUsageTable() { + if ($this->activeColumnMin !== null) { + return true; + } + + if ($this->activeColumnMax !== null) { + return true; + } + + return false; + } + + protected function didFilterPage(array $triggers) { + if ($this->needUsage) { + $usage_map = id(new PhabricatorProjectTriggerUsage())->loadAllWhere( + 'triggerPHID IN (%Ls)', + mpull($triggers, 'getPHID')); + $usage_map = mpull($usage_map, null, 'getTriggerPHID'); + + foreach ($triggers as $trigger) { + $trigger_phid = $trigger->getPHID(); + + $usage = idx($usage_map, $trigger_phid); + if (!$usage) { + $usage = id(new PhabricatorProjectTriggerUsage()) + ->setTriggerPHID($trigger_phid) + ->setExamplePHID(null) + ->setColumnCount(0) + ->setActiveColumnCount(0); + } + + $trigger->attachUsage($usage); + } + } + + return $triggers; + } + + public function getQueryApplicationClass() { + return 'PhabricatorProjectApplication'; + } + + protected function getPrimaryTableAlias() { + return 'trigger'; + } + +} diff --git a/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php b/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php new file mode 100644 index 0000000000..a178ed3e6c --- /dev/null +++ b/src/applications/project/query/PhabricatorProjectTriggerSearchEngine.php @@ -0,0 +1,155 @@ +needUsage(true); + } + + protected function buildCustomSearchFields() { + return array( + id(new PhabricatorSearchThreeStateField()) + ->setLabel(pht('Active')) + ->setKey('isActive') + ->setOptions( + pht('(Show All)'), + pht('Show Only Active Triggers'), + pht('Show Only Inactive Triggers')), + ); + } + + protected function buildQueryFromParameters(array $map) { + $query = $this->newQuery(); + + if ($map['isActive'] !== null) { + if ($map['isActive']) { + $query->withActiveColumnCountBetween(1, null); + } else { + $query->withActiveColumnCountBetween(null, 0); + } + } + + return $query; + } + + protected function getURI($path) { + return '/project/trigger/'.$path; + } + + protected function getBuiltinQueryNames() { + $names = array(); + + $names['active'] = pht('Active Triggers'); + $names['all'] = pht('All Triggers'); + + return $names; + } + + public function buildSavedQueryFromBuiltin($query_key) { + $query = $this->newSavedQuery(); + $query->setQueryKey($query_key); + + switch ($query_key) { + case 'active': + return $query->setParameter('isActive', true); + case 'all': + return $query; + } + + return parent::buildSavedQueryFromBuiltin($query_key); + } + + protected function renderResultList( + array $triggers, + PhabricatorSavedQuery $query, + array $handles) { + assert_instances_of($triggers, 'PhabricatorProjectTrigger'); + $viewer = $this->requireViewer(); + + $example_phids = array(); + foreach ($triggers as $trigger) { + $example_phid = $trigger->getUsage()->getExamplePHID(); + if ($example_phid) { + $example_phids[] = $example_phid; + } + } + + $handles = $viewer->loadHandles($example_phids); + + $list = id(new PHUIObjectItemListView()) + ->setViewer($viewer); + foreach ($triggers as $trigger) { + $usage = $trigger->getUsage(); + + $column_handle = null; + $have_column = false; + $example_phid = $usage->getExamplePHID(); + if ($example_phid) { + $column_handle = $handles[$example_phid]; + if ($column_handle->isComplete()) { + if (!$column_handle->getPolicyFiltered()) { + $have_column = true; + } + } + } + + $column_count = $usage->getColumnCount(); + $active_count = $usage->getActiveColumnCount(); + + if ($have_column) { + if ($active_count > 1) { + $usage_description = pht( + 'Used on %s and %s other active column(s).', + $column_handle->renderLink(), + new PhutilNumber($active_count - 1)); + } else if ($column_count > 1) { + $usage_description = pht( + 'Used on %s and %s other column(s).', + $column_handle->renderLink(), + new PhutilNumber($column_count - 1)); + } else { + $usage_description = pht( + 'Used on %s.', + $column_handle->renderLink()); + } + } else { + if ($active_count) { + $usage_description = pht( + 'Used on %s active column(s).', + new PhutilNumber($active_count)); + } else if ($column_count) { + $usage_description = pht( + 'Used on %s column(s).', + new PhutilNumber($column_count)); + } else { + $usage_description = pht( + 'Unused trigger.'); + } + } + + $item = id(new PHUIObjectItemView()) + ->setObjectName($trigger->getObjectName()) + ->setHeader($trigger->getDisplayName()) + ->setHref($trigger->getURI()) + ->addAttribute($usage_description) + ->setDisabled(!$active_count); + + $list->addItem($item); + } + + return id(new PhabricatorApplicationSearchResultView()) + ->setObjectList($list) + ->setNoDataString(pht('No triggers found.')); + } + +} diff --git a/src/applications/project/query/PhabricatorProjectTriggerTransactionQuery.php b/src/applications/project/query/PhabricatorProjectTriggerTransactionQuery.php new file mode 100644 index 0000000000..9ec4d4a53b --- /dev/null +++ b/src/applications/project/query/PhabricatorProjectTriggerTransactionQuery.php @@ -0,0 +1,10 @@ +getID()); + } + public function save() { if (!$this->getMailKey()) { $this->setMailKey(Filesystem::readRandomCharacters(20)); diff --git a/src/applications/project/storage/PhabricatorProjectColumn.php b/src/applications/project/storage/PhabricatorProjectColumn.php index 756c356ee1..49d7f28a9f 100644 --- a/src/applications/project/storage/PhabricatorProjectColumn.php +++ b/src/applications/project/storage/PhabricatorProjectColumn.php @@ -12,19 +12,17 @@ final class PhabricatorProjectColumn const STATUS_ACTIVE = 0; const STATUS_HIDDEN = 1; - const DEFAULT_ORDER = 'natural'; - const ORDER_NATURAL = 'natural'; - const ORDER_PRIORITY = 'priority'; - protected $name; protected $status; protected $projectPHID; protected $proxyPHID; protected $sequence; protected $properties = array(); + protected $triggerPHID; private $project = self::ATTACHABLE; private $proxy = self::ATTACHABLE; + private $trigger = self::ATTACHABLE; public static function initializeNewColumn(PhabricatorUser $user) { return id(new PhabricatorProjectColumn()) @@ -44,6 +42,7 @@ final class PhabricatorProjectColumn 'status' => 'uint32', 'sequence' => 'uint32', 'proxyPHID' => 'phid?', + 'triggerPHID' => 'phid?', ), self::CONFIG_KEY_SCHEMA => array( 'key_status' => array( @@ -56,6 +55,9 @@ final class PhabricatorProjectColumn 'columns' => array('projectPHID', 'proxyPHID'), 'unique' => true, ), + 'key_trigger' => array( + 'columns' => array('triggerPHID'), + ), ), ) + parent::getConfiguration(); } @@ -184,6 +186,72 @@ final class PhabricatorProjectColumn return sprintf('%s%012d', $group, $sequence); } + public function attachTrigger(PhabricatorProjectTrigger $trigger = null) { + $this->trigger = $trigger; + return $this; + } + + public function getTrigger() { + return $this->assertAttached($this->trigger); + } + + public function canHaveTrigger() { + // Backlog columns and proxy (subproject / milestone) columns can't have + // triggers because cards routinely end up in these columns through tag + // edits rather than drag-and-drop and it would likely be confusing to + // have these triggers act only a small fraction of the time. + + if ($this->isDefaultColumn()) { + return false; + } + + if ($this->getProxy()) { + return false; + } + + return true; + } + + public function getWorkboardURI() { + return $this->getProject()->getWorkboardURI(); + } + + public function getDropEffects() { + $effects = array(); + + $proxy = $this->getProxy(); + if ($proxy && $proxy->isMilestone()) { + $effects[] = id(new PhabricatorProjectDropEffect()) + ->setIcon($proxy->getProxyColumnIcon()) + ->setColor('violet') + ->setContent( + pht( + 'Move to milestone %s.', + phutil_tag('strong', array(), $this->getDisplayName()))); + } else { + $effects[] = id(new PhabricatorProjectDropEffect()) + ->setIcon('fa-columns') + ->setColor('blue') + ->setContent( + pht( + 'Move to column %s.', + phutil_tag('strong', array(), $this->getDisplayName()))); + } + + + if ($this->canHaveTrigger()) { + $trigger = $this->getTrigger(); + if ($trigger) { + foreach ($trigger->getDropEffects() as $trigger_effect) { + $effects[] = $trigger_effect; + } + } + } + + return $effects; + } + + /* -( PhabricatorConduitResultInterface )---------------------------------- */ public function getFieldSpecificationsForConduit() { diff --git a/src/applications/project/storage/PhabricatorProjectColumnTransaction.php b/src/applications/project/storage/PhabricatorProjectColumnTransaction.php index ed4bfed8a6..35a7461ca2 100644 --- a/src/applications/project/storage/PhabricatorProjectColumnTransaction.php +++ b/src/applications/project/storage/PhabricatorProjectColumnTransaction.php @@ -1,11 +1,7 @@ getOldValue(); - $new = $this->getNewValue(); - $author_handle = $this->renderHandleLink($this->getAuthorPHID()); - - switch ($this->getTransactionType()) { - case self::TYPE_NAME: - if ($old === null) { - return pht( - '%s created this column.', - $author_handle); - } else { - if (!strlen($old)) { - return pht( - '%s named this column "%s".', - $author_handle, - $new); - } else if (strlen($new)) { - return pht( - '%s renamed this column from "%s" to "%s".', - $author_handle, - $old, - $new); - } else { - return pht( - '%s removed the custom name of this column.', - $author_handle); - } - } - case self::TYPE_LIMIT: - if (!$old) { - return pht( - '%s set the point limit for this column to %s.', - $author_handle, - $new); - } else if (!$new) { - return pht( - '%s removed the point limit for this column.', - $author_handle); - } else { - return pht( - '%s changed point limit for this column from %s to %s.', - $author_handle, - $old, - $new); - } - - case self::TYPE_STATUS: - switch ($new) { - case PhabricatorProjectColumn::STATUS_ACTIVE: - return pht( - '%s marked this column visible.', - $author_handle); - case PhabricatorProjectColumn::STATUS_HIDDEN: - return pht( - '%s marked this column hidden.', - $author_handle); - } - break; - } - - return parent::getTitle(); + public function getBaseTransactionClass() { + return 'PhabricatorProjectColumnTransactionType'; } } diff --git a/src/applications/project/storage/PhabricatorProjectTransaction.php b/src/applications/project/storage/PhabricatorProjectTransaction.php index a8b2bb0d4a..158c2480c0 100644 --- a/src/applications/project/storage/PhabricatorProjectTransaction.php +++ b/src/applications/project/storage/PhabricatorProjectTransaction.php @@ -19,10 +19,6 @@ final class PhabricatorProjectTransaction return PhabricatorProjectProjectPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'PhabricatorProjectTransactionType'; } diff --git a/src/applications/project/storage/PhabricatorProjectTrigger.php b/src/applications/project/storage/PhabricatorProjectTrigger.php new file mode 100644 index 0000000000..625dc7ffd8 --- /dev/null +++ b/src/applications/project/storage/PhabricatorProjectTrigger.php @@ -0,0 +1,336 @@ +setName('') + ->setEditPolicy($default_edit); + } + + protected function getConfiguration() { + return array( + self::CONFIG_AUX_PHID => true, + self::CONFIG_SERIALIZATION => array( + 'ruleset' => self::SERIALIZATION_JSON, + ), + self::CONFIG_COLUMN_SCHEMA => array( + 'name' => 'text255', + ), + self::CONFIG_KEY_SCHEMA => array( + ), + ) + parent::getConfiguration(); + } + + public function getPHIDType() { + return PhabricatorProjectTriggerPHIDType::TYPECONST; + } + + public function getDisplayName() { + $name = $this->getName(); + if (strlen($name)) { + return $name; + } + + return $this->getDefaultName(); + } + + public function getDefaultName() { + return pht('Custom Trigger'); + } + + public function getURI() { + return urisprintf( + '/project/trigger/%d/', + $this->getID()); + } + + public function getObjectName() { + return pht('Trigger %d', $this->getID()); + } + + public function setRuleset(array $ruleset) { + // Clear any cached trigger rules, since we're changing the ruleset + // for the trigger. + $this->triggerRules = null; + + parent::setRuleset($ruleset); + } + + public function getTriggerRules() { + if ($this->triggerRules === null) { + $trigger_rules = self::newTriggerRulesFromRuleSpecifications( + $this->getRuleset(), + $allow_invalid = true); + + $this->triggerRules = $trigger_rules; + } + + return $this->triggerRules; + } + + public static function newTriggerRulesFromRuleSpecifications( + array $list, + $allow_invalid) { + + // NOTE: With "$allow_invalid" set, we're trying to preserve the database + // state in the rule structure, even if it includes rule types we don't + // ha ve implementations for, or rules with invalid rule values. + + // If an administrator adds or removes extensions which add rules, or + // an upgrade affects rule validity, existing rules may become invalid. + // When they do, we still want the UI to reflect the ruleset state + // accurately and "Edit" + "Save" shouldn't destroy data unless the + // user explicitly modifies the ruleset. + + // In this mode, when we run into rules which are structured correctly but + // which have types we don't know about, we replace them with "Unknown + // Rules". If we know about the type of a rule but the value doesn't + // validate, we replace it with "Invalid Rules". These two rule types don't + // take any actions when a card is dropped into the column, but they show + // the user what's wrong with the ruleset and can be saved without causing + // any collateral damage. + + $rule_map = PhabricatorProjectTriggerRule::getAllTriggerRules(); + + // If the stored rule data isn't a list of rules (or we encounter other + // fundamental structural problems, below), there isn't much we can do + // to try to represent the state. + if (!is_array($list)) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ruleset is corrupt: expected a list of rule '. + 'specifications, found "%s".', + phutil_describe_type($list))); + } + + $trigger_rules = array(); + foreach ($list as $key => $rule) { + if (!is_array($rule)) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ruleset is corrupt: rule (with key "%s") should be a '. + 'rule specification, but is actually "%s".', + $key, + phutil_describe_type($rule))); + } + + try { + PhutilTypeSpec::checkMap( + $rule, + array( + 'type' => 'string', + 'value' => 'wild', + )); + } catch (PhutilTypeCheckException $ex) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ruleset is corrupt: rule (with key "%s") is not a '. + 'valid rule specification: %s', + $key, + $ex->getMessage())); + } + + $record = id(new PhabricatorProjectTriggerRuleRecord()) + ->setType(idx($rule, 'type')) + ->setValue(idx($rule, 'value')); + + if (!isset($rule_map[$record->getType()])) { + if (!$allow_invalid) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ruleset is corrupt: rule type "%s" is unknown.', + $record->getType())); + } + + $rule = new PhabricatorProjectTriggerUnknownRule(); + } else { + $rule = clone $rule_map[$record->getType()]; + } + + try { + $rule->setRecord($record); + } catch (Exception $ex) { + if (!$allow_invalid) { + throw new PhabricatorProjectTriggerCorruptionException( + pht( + 'Trigger ruleset is corrupt, rule (of type "%s") does not '. + 'validate: %s', + $record->getType(), + $ex->getMessage())); + } + + $rule = id(new PhabricatorProjectTriggerInvalidRule()) + ->setRecord($record) + ->setException($ex); + } + + $trigger_rules[] = $rule; + } + + return $trigger_rules; + } + + + public function getDropEffects() { + $effects = array(); + + $rules = $this->getTriggerRules(); + foreach ($rules as $rule) { + foreach ($rule->getDropEffects() as $effect) { + $effects[] = $effect; + } + } + + return $effects; + } + + public function newDropTransactions( + PhabricatorUser $viewer, + PhabricatorProjectColumn $column, + $object) { + + $trigger_xactions = array(); + foreach ($this->getTriggerRules() as $rule) { + $rule + ->setViewer($viewer) + ->setTrigger($this) + ->setColumn($column) + ->setObject($object); + + $xactions = $rule->getDropTransactions( + $object, + $rule->getRecord()->getValue()); + + if (!is_array($xactions)) { + throw new Exception( + pht( + 'Expected trigger rule (of class "%s") to return a list of '. + 'transactions from "newDropTransactions()", but got "%s".', + get_class($rule), + phutil_describe_type($xactions))); + } + + $expect_type = get_class($object->getApplicationTransactionTemplate()); + assert_instances_of($xactions, $expect_type); + + foreach ($xactions as $xaction) { + $trigger_xactions[] = $xaction; + } + } + + return $trigger_xactions; + } + + public function getPreviewEffect() { + $header = pht('Trigger: %s', $this->getDisplayName()); + + return id(new PhabricatorProjectDropEffect()) + ->setIcon('fa-cogs') + ->setColor('blue') + ->setIsHeader(true) + ->setContent($header); + } + + public function getSoundEffects() { + $sounds = array(); + + foreach ($this->getTriggerRules() as $rule) { + foreach ($rule->getSoundEffects() as $effect) { + $sounds[] = $effect; + } + } + + return $sounds; + } + + public function getUsage() { + return $this->assertAttached($this->usage); + } + + public function attachUsage(PhabricatorProjectTriggerUsage $usage) { + $this->usage = $usage; + return $this; + } + + +/* -( PhabricatorApplicationTransactionInterface )------------------------- */ + + + public function getApplicationTransactionEditor() { + return new PhabricatorProjectTriggerEditor(); + } + + public function getApplicationTransactionTemplate() { + return new PhabricatorProjectTriggerTransaction(); + } + + +/* -( PhabricatorPolicyInterface )----------------------------------------- */ + + + public function getCapabilities() { + return array( + PhabricatorPolicyCapability::CAN_VIEW, + PhabricatorPolicyCapability::CAN_EDIT, + ); + } + + public function getPolicy($capability) { + switch ($capability) { + case PhabricatorPolicyCapability::CAN_VIEW: + return PhabricatorPolicies::getMostOpenPolicy(); + case PhabricatorPolicyCapability::CAN_EDIT: + return $this->getEditPolicy(); + } + } + + public function hasAutomaticCapability($capability, PhabricatorUser $viewer) { + return false; + } + + +/* -( PhabricatorDestructibleInterface )----------------------------------- */ + + + public function destroyObjectPermanently( + PhabricatorDestructionEngine $engine) { + + $this->openTransaction(); + $conn = $this->establishConnection('w'); + + // Remove the reference to this trigger from any columns which use it. + queryfx( + $conn, + 'UPDATE %R SET triggerPHID = null WHERE triggerPHID = %s', + new PhabricatorProjectColumn(), + $this->getPHID()); + + // Remove the usage index row for this trigger, if one exists. + queryfx( + $conn, + 'DELETE FROM %R WHERE triggerPHID = %s', + new PhabricatorProjectTriggerUsage(), + $this->getPHID()); + + $this->delete(); + + $this->saveTransaction(); + } + +} diff --git a/src/applications/project/storage/PhabricatorProjectTriggerTransaction.php b/src/applications/project/storage/PhabricatorProjectTriggerTransaction.php new file mode 100644 index 0000000000..fb94bdc364 --- /dev/null +++ b/src/applications/project/storage/PhabricatorProjectTriggerTransaction.php @@ -0,0 +1,18 @@ + false, + self::CONFIG_COLUMN_SCHEMA => array( + 'examplePHID' => 'phid?', + 'columnCount' => 'uint32', + 'activeColumnCount' => 'uint32', + ), + self::CONFIG_KEY_SCHEMA => array( + 'key_trigger' => array( + 'columns' => array('triggerPHID'), + 'unique' => true, + ), + ), + ) + parent::getConfiguration(); + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php new file mode 100644 index 0000000000..ba53b77e75 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerInvalidRule.php @@ -0,0 +1,93 @@ +exception = $exception; + return $this; + } + + public function getException() { + return $this->exception; + } + + public function getSelectControlName() { + return pht('(Invalid Rule)'); + } + + protected function isSelectableRule() { + return false; + } + + protected function assertValidRuleValue($value) { + return; + } + + protected function newDropTransactions($object, $value) { + return array(); + } + + protected function newDropEffects($value) { + return array(); + } + + protected function isValidRule() { + return false; + } + + protected function newInvalidView() { + return array( + id(new PHUIIconView()) + ->setIcon('fa-exclamation-triangle red'), + ' ', + pht( + 'This is a trigger rule with a valid type ("%s") but an invalid '. + 'value.', + $this->getRecord()->getType()), + ); + } + + protected function getDefaultValue() { + return null; + } + + protected function getPHUIXControlType() { + return null; + } + + protected function getPHUIXControlSpecification() { + return null; + } + + public function getRuleViewLabel() { + return pht('Invalid Rule'); + } + + public function getRuleViewDescription($value) { + $record = $this->getRecord(); + $type = $record->getType(); + + $exception = $this->getException(); + if ($exception) { + return pht( + 'This rule (of type "%s") is invalid: %s', + $type, + $exception->getMessage()); + } else { + return pht( + 'This rule (of type "%s") is invalid.', + $type); + } + } + + public function getRuleViewIcon($value) { + return id(new PHUIIconView()) + ->setIcon('fa-exclamation-triangle', 'red'); + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php new file mode 100644 index 0000000000..98a03a1393 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestPriorityRule.php @@ -0,0 +1,94 @@ +newTransaction() + ->setTransactionType(ManiphestTaskPriorityTransaction::TRANSACTIONTYPE) + ->setNewValue($value), + ); + } + + protected function newDropEffects($value) { + $priority_name = ManiphestTaskPriority::getTaskPriorityName($value); + $priority_icon = ManiphestTaskPriority::getTaskPriorityIcon($value); + $priority_color = ManiphestTaskPriority::getTaskPriorityColor($value); + + $content = pht( + 'Change priority to %s.', + phutil_tag('strong', array(), $priority_name)); + + return array( + $this->newEffect() + ->setIcon($priority_icon) + ->setColor($priority_color) + ->addCondition('priority', '!=', $value) + ->setContent($content), + ); + } + + protected function getDefaultValue() { + return head_key(ManiphestTaskPriority::getTaskPriorityMap()); + } + + protected function getPHUIXControlType() { + return 'select'; + } + + protected function getPHUIXControlSpecification() { + $map = ManiphestTaskPriority::getTaskPriorityMap(); + + return array( + 'options' => $map, + 'order' => array_keys($map), + ); + } + + public function getRuleViewLabel() { + return pht('Change Priority'); + } + + public function getRuleViewDescription($value) { + $priority_name = ManiphestTaskPriority::getTaskPriorityName($value); + + return pht( + 'Change task priority to %s.', + phutil_tag('strong', array(), $priority_name)); + } + + public function getRuleViewIcon($value) { + $priority_icon = ManiphestTaskPriority::getTaskPriorityIcon($value); + $priority_color = ManiphestTaskPriority::getTaskPriorityColor($value); + + return id(new PHUIIconView()) + ->setIcon($priority_icon, $priority_color); + } + + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php new file mode 100644 index 0000000000..b11d7567de --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerManiphestStatusRule.php @@ -0,0 +1,93 @@ +newTransaction() + ->setTransactionType(ManiphestTaskStatusTransaction::TRANSACTIONTYPE) + ->setNewValue($value), + ); + } + + protected function newDropEffects($value) { + $status_name = ManiphestTaskStatus::getTaskStatusName($value); + $status_icon = ManiphestTaskStatus::getStatusIcon($value); + $status_color = ManiphestTaskStatus::getStatusColor($value); + + $content = pht( + 'Change status to %s.', + phutil_tag('strong', array(), $status_name)); + + return array( + $this->newEffect() + ->setIcon($status_icon) + ->setColor($status_color) + ->addCondition('status', '!=', $value) + ->setContent($content), + ); + } + + protected function getDefaultValue() { + return head_key(ManiphestTaskStatus::getTaskStatusMap()); + } + + protected function getPHUIXControlType() { + return 'select'; + } + + protected function getPHUIXControlSpecification() { + $map = ManiphestTaskStatus::getTaskStatusMap(); + + return array( + 'options' => $map, + 'order' => array_keys($map), + ); + } + + public function getRuleViewLabel() { + return pht('Change Status'); + } + + public function getRuleViewDescription($value) { + $status_name = ManiphestTaskStatus::getTaskStatusName($value); + + return pht( + 'Change task status to %s.', + phutil_tag('strong', array(), $status_name)); + } + + public function getRuleViewIcon($value) { + $status_icon = ManiphestTaskStatus::getStatusIcon($value); + $status_color = ManiphestTaskStatus::getStatusColor($value); + + return id(new PHUIIconView()) + ->setIcon($status_icon, $status_color); + } + + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php new file mode 100644 index 0000000000..ef19b504ef --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerPlaySoundRule.php @@ -0,0 +1,122 @@ +newEffect() + ->setIcon($sound_icon) + ->setColor($sound_color) + ->setContent($content), + ); + } + + protected function getDefaultValue() { + return head_key(self::getSoundMap()); + } + + protected function getPHUIXControlType() { + return 'select'; + } + + protected function getPHUIXControlSpecification() { + $map = self::getSoundMap(); + $map = ipull($map, 'name'); + + return array( + 'options' => $map, + 'order' => array_keys($map), + ); + } + + public function getRuleViewLabel() { + return pht('Play Sound'); + } + + public function getRuleViewDescription($value) { + $sound_name = self::getSoundName($value); + + return pht( + 'Play sound %s.', + phutil_tag('strong', array(), $sound_name)); + } + + public function getRuleViewIcon($value) { + $sound_icon = 'fa-volume-up'; + $sound_color = 'blue'; + + return id(new PHUIIconView()) + ->setIcon($sound_icon, $sound_color); + } + + private static function getSoundName($value) { + $map = self::getSoundMap(); + $spec = idx($map, $value, array()); + return idx($spec, 'name', $value); + } + + private static function getSoundMap() { + return array( + 'bing' => array( + 'name' => pht('Bing'), + 'uri' => celerity_get_resource_uri('/rsrc/audio/basic/bing.mp3'), + ), + 'glass' => array( + 'name' => pht('Glass'), + 'uri' => celerity_get_resource_uri('/rsrc/audio/basic/ting.mp3'), + ), + ); + } + + public function getSoundEffects() { + $value = $this->getValue(); + + $map = self::getSoundMap(); + $spec = idx($map, $value, array()); + + $uris = array(); + if (isset($spec['uri'])) { + $uris[] = $spec['uri']; + } + + return $uris; + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php new file mode 100644 index 0000000000..ae2b3ee092 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRule.php @@ -0,0 +1,153 @@ +getPhobjectClassConstant('TRIGGERTYPE', 64); + } + + final public static function getAllTriggerRules() { + return id(new PhutilClassMapQuery()) + ->setAncestorClass(__CLASS__) + ->setUniqueMethod('getTriggerType') + ->execute(); + } + + final public function setRecord(PhabricatorProjectTriggerRuleRecord $record) { + $value = $record->getValue(); + + $this->assertValidRuleValue($value); + + $this->record = $record; + return $this; + } + + final public function getRecord() { + return $this->record; + } + + final protected function getValue() { + return $this->getRecord()->getValue(); + } + + abstract public function getSelectControlName(); + abstract public function getRuleViewLabel(); + abstract public function getRuleViewDescription($value); + abstract public function getRuleViewIcon($value); + abstract protected function assertValidRuleValue($value); + abstract protected function newDropTransactions($object, $value); + abstract protected function newDropEffects($value); + abstract protected function getDefaultValue(); + abstract protected function getPHUIXControlType(); + abstract protected function getPHUIXControlSpecification(); + + protected function isSelectableRule() { + return true; + } + + protected function isValidRule() { + return true; + } + + protected function newInvalidView() { + return null; + } + + public function getSoundEffects() { + return array(); + } + + final public function getDropTransactions($object, $value) { + return $this->newDropTransactions($object, $value); + } + + final public function setViewer(PhabricatorUser $viewer) { + $this->viewer = $viewer; + return $this; + } + + final public function getViewer() { + return $this->viewer; + } + + final public function setColumn(PhabricatorProjectColumn $column) { + $this->column = $column; + return $this; + } + + final public function getColumn() { + return $this->column; + } + + final public function setTrigger(PhabricatorProjectTrigger $trigger) { + $this->trigger = $trigger; + return $this; + } + + final public function getTrigger() { + return $this->trigger; + } + + final public function setObject( + PhabricatorApplicationTransactionInterface $object) { + $this->object = $object; + return $this; + } + + final public function getObject() { + return $this->object; + } + + final protected function newTransaction() { + return $this->getObject()->getApplicationTransactionTemplate(); + } + + final public function getDropEffects() { + return $this->newDropEffects($this->getValue()); + } + + final protected function newEffect() { + return id(new PhabricatorProjectDropEffect()) + ->setIsTriggerEffect(true); + } + + final public function toDictionary() { + $record = $this->getRecord(); + + $is_valid = $this->isValidRule(); + if (!$is_valid) { + $invalid_view = hsprintf('%s', $this->newInvalidView()); + } else { + $invalid_view = null; + } + + return array( + 'type' => $record->getType(), + 'value' => $record->getValue(), + 'isValidRule' => $is_valid, + 'invalidView' => $invalid_view, + ); + } + + final public function newTemplate() { + return array( + 'type' => $this->getTriggerType(), + 'name' => $this->getSelectControlName(), + 'selectable' => $this->isSelectableRule(), + 'defaultValue' => $this->getDefaultValue(), + 'control' => array( + 'type' => $this->getPHUIXControlType(), + 'specification' => $this->getPHUIXControlSpecification(), + ), + ); + } + + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php b/src/applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php new file mode 100644 index 0000000000..da36d9a4d8 --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerRuleRecord.php @@ -0,0 +1,27 @@ +type = $type; + return $this; + } + + public function getType() { + return $this->type; + } + + public function setValue($value) { + $this->value = $value; + return $this; + } + + public function getValue() { + return $this->value; + } + +} diff --git a/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php new file mode 100644 index 0000000000..925a369bae --- /dev/null +++ b/src/applications/project/trigger/PhabricatorProjectTriggerUnknownRule.php @@ -0,0 +1,71 @@ +setIcon('fa-exclamation-triangle yellow'), + ' ', + pht( + 'This is a trigger rule with a unknown type ("%s").', + $this->getRecord()->getType()), + ); + } + + protected function getDefaultValue() { + return null; + } + + protected function getPHUIXControlType() { + return null; + } + + protected function getPHUIXControlSpecification() { + return null; + } + + public function getRuleViewLabel() { + return pht('Unknown Rule'); + } + + public function getRuleViewDescription($value) { + return pht( + 'This is an unknown rule of type "%s". An administrator may have '. + 'edited or removed an extension which implements this rule type.', + $this->getRecord()->getType()); + } + + public function getRuleViewIcon($value) { + return id(new PHUIIconView()) + ->setIcon('fa-question-circle', 'yellow'); + } + +} diff --git a/src/applications/project/view/ProjectBoardTaskCard.php b/src/applications/project/view/ProjectBoardTaskCard.php index 3a7016ca74..d102ac1b11 100644 --- a/src/applications/project/view/ProjectBoardTaskCard.php +++ b/src/applications/project/view/ProjectBoardTaskCard.php @@ -6,6 +6,7 @@ final class ProjectBoardTaskCard extends Phobject { private $projectHandles; private $task; private $owner; + private $showEditControls; private $canEdit; private $coverImageFile; private $hideArchivedProjects; @@ -70,6 +71,15 @@ final class ProjectBoardTaskCard extends Phobject { return $this->canEdit; } + public function setShowEditControls($show_edit_controls) { + $this->showEditControls = $show_edit_controls; + return $this; + } + + public function getShowEditControls() { + return $this->showEditControls; + } + public function getItem() { $task = $this->getTask(); $owner = $this->getOwner(); @@ -82,20 +92,34 @@ final class ProjectBoardTaskCard extends Phobject { $card = id(new PHUIObjectItemView()) ->setObject($task) ->setUser($viewer) - ->setObjectName('T'.$task->getID()) + ->setObjectName($task->getMonogram()) ->setHeader($task->getTitle()) - ->setGrippable($can_edit) - ->setHref('/T'.$task->getID()) + ->setHref($task->getURI()) ->addSigil('project-card') ->setDisabled($task->isClosed()) - ->addAction( - id(new PHUIListItemView()) - ->setName(pht('Edit')) - ->setIcon('fa-pencil') - ->addSigil('edit-project-card') - ->setHref('/maniphest/task/edit/'.$task->getID().'/')) ->setBarColor($bar_color); + if ($this->getShowEditControls()) { + if ($can_edit) { + $card + ->addSigil('draggable-card') + ->addClass('draggable-card'); + $edit_icon = 'fa-pencil'; + } else { + $card + ->addClass('not-editable') + ->addClass('undraggable-card'); + $edit_icon = 'fa-lock red'; + } + + $card->addAction( + id(new PHUIListItemView()) + ->setName(pht('Edit')) + ->setIcon($edit_icon) + ->addSigil('edit-project-card') + ->setHref('/maniphest/task/edit/'.$task->getID().'/')); + } + if ($owner) { $card->addHandleIcon($owner, $owner->getName()); } diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php new file mode 100644 index 0000000000..8e91ccbe5d --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnLimitTransaction.php @@ -0,0 +1,63 @@ +getPointLimit(); + } + + public function generateNewValue($object, $value) { + if (strlen($value)) { + return (int)$value; + } else { + return null; + } + } + + public function applyInternalEffects($object, $value) { + $object->setPointLimit($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (!$old) { + return pht( + '%s set the point limit for this column to %s.', + $this->renderAuthor(), + $this->renderNewValue()); + } else if (!$new) { + return pht( + '%s removed the point limit for this column.', + $this->renderAuthor()); + } else { + return pht( + '%s changed the point limit for this column from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + foreach ($xactions as $xaction) { + $value = $xaction->getNewValue(); + if (strlen($value) && !preg_match('/^\d+\z/', $value)) { + $errors[] = $this->newInvalidError( + pht( + 'Column point limit must either be empty or a nonnegative '. + 'integer.'), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php new file mode 100644 index 0000000000..bff54277de --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnNameTransaction.php @@ -0,0 +1,66 @@ +getName(); + } + + public function applyInternalEffects($object, $value) { + $object->setName($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (!strlen($old)) { + return pht( + '%s named this column %s.', + $this->renderAuthor(), + $this->renderNewValue()); + } else if (strlen($new)) { + return pht( + '%s renamed this column from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } else { + return pht( + '%s removed the custom name of this column.', + $this->renderAuthor()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + if ($this->isEmptyTextTransaction($object->getName(), $xactions)) { + // The default "Backlog" column is allowed to be unnamed, which + // means we use the default name. + if (!$object->isDefaultColumn()) { + $errors[] = $this->newRequiredError( + pht('Columns must have a name.')); + } + } + + $max_length = $object->getColumnMaximumByteLength('name'); + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + $new_length = strlen($new_value); + if ($new_length > $max_length) { + $errors[] = $this->newInvalidError( + pht( + 'Column names must not be longer than %s characters.', + new PhutilNumber($max_length)), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php b/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php new file mode 100644 index 0000000000..7aab57c8e6 --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnStatusTransaction.php @@ -0,0 +1,64 @@ +getStatus(); + } + + public function applyInternalEffects($object, $value) { + $object->setStatus($value); + } + + public function applyExternalEffects($object, $value) { + // Update the trigger usage index, which cares about whether columns are + // active or not. + $trigger_phid = $object->getTriggerPHID(); + if ($trigger_phid) { + PhabricatorSearchWorker::queueDocumentForIndexing($trigger_phid); + } + } + + public function getTitle() { + $new = $this->getNewValue(); + + switch ($new) { + case PhabricatorProjectColumn::STATUS_ACTIVE: + return pht( + '%s unhid this column.', + $this->renderAuthor()); + case PhabricatorProjectColumn::STATUS_HIDDEN: + return pht( + '%s hid this column.', + $this->renderAuthor()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $map = array( + PhabricatorProjectColumn::STATUS_ACTIVE, + PhabricatorProjectColumn::STATUS_HIDDEN, + ); + $map = array_fuse($map); + + foreach ($xactions as $xaction) { + $value = $xaction->getNewValue(); + if (!isset($map[$value])) { + $errors[] = $this->newInvalidError( + pht( + 'Column status "%s" is unrecognized, valid statuses are: %s.', + $value, + implode(', ', array_keys($map))), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php b/src/applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php new file mode 100644 index 0000000000..1473d3cabb --- /dev/null +++ b/src/applications/project/xaction/column/PhabricatorProjectColumnTransactionType.php @@ -0,0 +1,4 @@ +getTriggerPHID(); + } + + public function applyInternalEffects($object, $value) { + $object->setTriggerPHID($value); + } + + public function applyExternalEffects($object, $value) { + // After we change the trigger attached to a column, update the search + // indexes for the old and new triggers so we update the usage index. + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + $column_phids = array(); + if ($old) { + $column_phids[] = $old; + } + if ($new) { + $column_phids[] = $new; + } + + foreach ($column_phids as $phid) { + PhabricatorSearchWorker::queueDocumentForIndexing($phid); + } + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (!$old) { + return pht( + '%s set the column trigger to %s.', + $this->renderAuthor(), + $this->renderNewHandle()); + } else if (!$new) { + return pht( + '%s removed the trigger for this column (was %s).', + $this->renderAuthor(), + $this->renderOldHandle()); + } else { + return pht( + '%s changed the trigger for this column from %s to %s.', + $this->renderAuthor(), + $this->renderOldHandle(), + $this->renderNewHandle()); + } + } + + public function validateTransactions($object, array $xactions) { + $actor = $this->getActor(); + $errors = array(); + + foreach ($xactions as $xaction) { + $trigger_phid = $xaction->getNewValue(); + + // You can always remove a trigger. + if (!$trigger_phid) { + continue; + } + + // You can't put a trigger on a column that can't have triggers, like + // a backlog column or a proxy column. + if (!$object->canHaveTrigger()) { + $errors[] = $this->newInvalidError( + pht('This column can not have a trigger.'), + $xaction); + continue; + } + + $trigger = id(new PhabricatorProjectTriggerQuery()) + ->setViewer($actor) + ->withPHIDs(array($trigger_phid)) + ->execute(); + if (!$trigger) { + $errors[] = $this->newInvalidError( + pht( + 'Trigger "%s" is not a valid trigger, or you do not have '. + 'permission to view it.', + $trigger_phid), + $xaction); + continue; + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php new file mode 100644 index 0000000000..91a1be6bd9 --- /dev/null +++ b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerNameTransaction.php @@ -0,0 +1,58 @@ +getName(); + } + + public function applyInternalEffects($object, $value) { + $object->setName($value); + } + + public function getTitle() { + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + if (strlen($old) && strlen($new)) { + return pht( + '%s renamed this trigger from %s to %s.', + $this->renderAuthor(), + $this->renderOldValue(), + $this->renderNewValue()); + } else if (strlen($new)) { + return pht( + '%s named this trigger %s.', + $this->renderAuthor(), + $this->renderNewValue()); + } else { + return pht( + '%s stripped the name %s from this trigger.', + $this->renderAuthor(), + $this->renderOldValue()); + } + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + $max_length = $object->getColumnMaximumByteLength('name'); + foreach ($xactions as $xaction) { + $new_value = $xaction->getNewValue(); + $new_length = strlen($new_value); + if ($new_length > $max_length) { + $errors[] = $this->newInvalidError( + pht( + 'Trigger names must not be longer than %s characters.', + new PhutilNumber($max_length)), + $xaction); + } + } + + return $errors; + } + +} diff --git a/src/applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php new file mode 100644 index 0000000000..59c846becf --- /dev/null +++ b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerRulesetTransaction.php @@ -0,0 +1,65 @@ +getRuleset(); + } + + public function applyInternalEffects($object, $value) { + $object->setRuleset($value); + } + + public function getTitle() { + return pht( + '%s updated the ruleset for this trigger.', + $this->renderAuthor()); + } + + public function validateTransactions($object, array $xactions) { + $errors = array(); + + foreach ($xactions as $xaction) { + $ruleset = $xaction->getNewValue(); + + try { + PhabricatorProjectTrigger::newTriggerRulesFromRuleSpecifications( + $ruleset, + $allow_invalid = false); + } catch (PhabricatorProjectTriggerCorruptionException $ex) { + $errors[] = $this->newInvalidError( + pht( + 'Ruleset specification is not valid. %s', + $ex->getMessage()), + $xaction); + continue; + } + } + + return $errors; + } + + public function hasChangeDetailView() { + return true; + } + + public function newChangeDetailView() { + $viewer = $this->getViewer(); + + $old = $this->getOldValue(); + $new = $this->getNewValue(); + + $json = new PhutilJSON(); + $old_json = $json->encodeAsList($old); + $new_json = $json->encodeAsList($new); + + return id(new PhabricatorApplicationTransactionTextDiffDetailView()) + ->setViewer($viewer) + ->setOldText($old_json) + ->setNewText($new_json); + } + +} diff --git a/src/applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php new file mode 100644 index 0000000000..30222e1e2c --- /dev/null +++ b/src/applications/project/xaction/trigger/PhabricatorProjectTriggerTransactionType.php @@ -0,0 +1,4 @@ +getURI('request/'); return id(new PhutilURI($uri)) - ->setQueryParam('D', $this->revision->getID()); + ->replaceQueryParam('D', $this->revision->getID()); } } diff --git a/src/applications/releeph/query/ReleephProductQuery.php b/src/applications/releeph/query/ReleephProductQuery.php index c039950379..118b9919a8 100644 --- a/src/applications/releeph/query/ReleephProductQuery.php +++ b/src/applications/releeph/query/ReleephProductQuery.php @@ -130,12 +130,10 @@ final class ReleephProductQuery ); } - protected function getPagingValueMap($cursor, array $keys) { - $product = $this->loadCursorObject($cursor); - + protected function newPagingMapFromPartialObject($object) { return array( - 'id' => $product->getID(), - 'name' => $product->getName(), + 'id' => (int)$object->getID(), + 'name' => $object->getName(), ); } diff --git a/src/applications/repository/phid/PhabricatorRepositoryRepositoryPHIDType.php b/src/applications/repository/phid/PhabricatorRepositoryRepositoryPHIDType.php index ba78b0fe7a..6ca67257cf 100644 --- a/src/applications/repository/phid/PhabricatorRepositoryRepositoryPHIDType.php +++ b/src/applications/repository/phid/PhabricatorRepositoryRepositoryPHIDType.php @@ -46,6 +46,10 @@ final class PhabricatorRepositoryRepositoryPHIDType ->setFullName("{$monogram} {$name}") ->setURI($uri) ->setMailStampName($monogram); + + if ($repository->getStatus() !== PhabricatorRepository::STATUS_ACTIVE) { + $handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED); + } } } diff --git a/src/applications/repository/query/PhabricatorRepositoryIdentityQuery.php b/src/applications/repository/query/PhabricatorRepositoryIdentityQuery.php index ef038f045f..c64b1a296b 100644 --- a/src/applications/repository/query/PhabricatorRepositoryIdentityQuery.php +++ b/src/applications/repository/query/PhabricatorRepositoryIdentityQuery.php @@ -124,29 +124,6 @@ final class PhabricatorRepositoryIdentityQuery return $where; } - protected function didFilterPage(array $identities) { - $user_ids = array_filter( - mpull($identities, 'getCurrentEffectiveUserPHID', 'getID')); - if (!$user_ids) { - return $identities; - } - - $users = id(new PhabricatorPeopleQuery()) - ->withPHIDs($user_ids) - ->setViewer($this->getViewer()) - ->execute(); - $users = mpull($users, null, 'getPHID'); - - foreach ($identities as $identity) { - if ($identity->hasEffectiveUser()) { - $user = idx($users, $identity->getCurrentEffectiveUserPHID()); - $identity->attachEffectiveUser($user); - } - } - - return $identities; - } - public function getQueryApplicationClass() { return 'PhabricatorDiffusionApplication'; } diff --git a/src/applications/repository/query/PhabricatorRepositoryQuery.php b/src/applications/repository/query/PhabricatorRepositoryQuery.php index 5271a040ef..9ae4115146 100644 --- a/src/applications/repository/query/PhabricatorRepositoryQuery.php +++ b/src/applications/repository/query/PhabricatorRepositoryQuery.php @@ -442,47 +442,24 @@ final class PhabricatorRepositoryQuery ); } - protected function willExecuteCursorQuery( - PhabricatorCursorPagedPolicyAwareQuery $query) { - $vector = $this->getOrderVector(); + protected function newPagingMapFromCursorObject( + PhabricatorQueryCursor $cursor, + array $keys) { - if ($vector->containsKey('committed')) { - $query->needMostRecentCommits(true); - } - - if ($vector->containsKey('size')) { - $query->needCommitCounts(true); - } - } - - protected function getPagingValueMap($cursor, array $keys) { - $repository = $this->loadCursorObject($cursor); + $repository = $cursor->getObject(); $map = array( - 'id' => $repository->getID(), + 'id' => (int)$repository->getID(), 'callsign' => $repository->getCallsign(), 'name' => $repository->getName(), ); - foreach ($keys as $key) { - switch ($key) { - case 'committed': - $commit = $repository->getMostRecentCommit(); - if ($commit) { - $map[$key] = $commit->getEpoch(); - } else { - $map[$key] = null; - } - break; - case 'size': - $count = $repository->getCommitCount(); - if ($count) { - $map[$key] = $count; - } else { - $map[$key] = null; - } - break; - } + if (isset($keys['committed'])) { + $map['committed'] = $cursor->getRawRowProperty('epoch'); + } + + if (isset($keys['size'])) { + $map['size'] = $cursor->getRawRowProperty('size'); } return $map; @@ -491,8 +468,6 @@ final class PhabricatorRepositoryQuery protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { $parts = parent::buildSelectClauseParts($conn); - $parts[] = qsprintf($conn, 'r.*'); - if ($this->shouldJoinSummaryTable()) { $parts[] = qsprintf($conn, 's.*'); } diff --git a/src/applications/repository/storage/PhabricatorRepository.php b/src/applications/repository/storage/PhabricatorRepository.php index e66fb78afa..d5ad83b6d6 100644 --- a/src/applications/repository/storage/PhabricatorRepository.php +++ b/src/applications/repository/storage/PhabricatorRepository.php @@ -820,8 +820,6 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO return $uri; } - $uri = new PhutilURI($uri); - if (isset($params['lint'])) { $params['params'] = idx($params, 'params', array()) + array( 'lint' => $params['lint'], @@ -830,11 +828,7 @@ final class PhabricatorRepository extends PhabricatorRepositoryDAO $query = idx($params, 'params', array()) + $query; - if ($query) { - $uri->setQueryParams($query); - } - - return $uri; + return new PhutilURI($uri, $query); } public function updateURIIndex() { diff --git a/src/applications/repository/storage/PhabricatorRepositoryIdentity.php b/src/applications/repository/storage/PhabricatorRepositoryIdentity.php index 76c6aed9e0..e3833bd10e 100644 --- a/src/applications/repository/storage/PhabricatorRepositoryIdentity.php +++ b/src/applications/repository/storage/PhabricatorRepositoryIdentity.php @@ -14,17 +14,6 @@ final class PhabricatorRepositoryIdentity protected $manuallySetUserPHID; protected $currentEffectiveUserPHID; - private $effectiveUser = self::ATTACHABLE; - - public function attachEffectiveUser(PhabricatorUser $user) { - $this->effectiveUser = $user; - return $this; - } - - public function getEffectiveUser() { - return $this->assertAttached($this->effectiveUser); - } - protected function getConfiguration() { return array( self::CONFIG_AUX_PHID => true, diff --git a/src/applications/repository/storage/PhabricatorRepositoryTransaction.php b/src/applications/repository/storage/PhabricatorRepositoryTransaction.php index 85c354ba67..8729e462a3 100644 --- a/src/applications/repository/storage/PhabricatorRepositoryTransaction.php +++ b/src/applications/repository/storage/PhabricatorRepositoryTransaction.php @@ -11,10 +11,6 @@ final class PhabricatorRepositoryTransaction return PhabricatorRepositoryRepositoryPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'PhabricatorRepositoryTransactionType'; } diff --git a/src/applications/repository/worker/PhabricatorRepositoryCommitOwnersWorker.php b/src/applications/repository/worker/PhabricatorRepositoryCommitOwnersWorker.php index 75ae0c9c14..d5054a7f18 100644 --- a/src/applications/repository/worker/PhabricatorRepositoryCommitOwnersWorker.php +++ b/src/applications/repository/worker/PhabricatorRepositoryCommitOwnersWorker.php @@ -132,28 +132,91 @@ final class PhabricatorRepositoryCommitOwnersWorker $author_phid, $revision) { - // Don't trigger an audit if auditing isn't enabled for the package. - if (!$package->getAuditingEnabled()) { - return false; + $audit_uninvolved = false; + $audit_unreviewed = false; + + $rule = $package->newAuditingRule(); + switch ($rule->getKey()) { + case PhabricatorOwnersAuditRule::AUDITING_NONE: + return false; + case PhabricatorOwnersAuditRule::AUDITING_ALL: + return true; + case PhabricatorOwnersAuditRule::AUDITING_NO_OWNER: + $audit_uninvolved = true; + break; + case PhabricatorOwnersAuditRule::AUDITING_UNREVIEWED: + $audit_unreviewed = true; + break; + case PhabricatorOwnersAuditRule::AUDITING_NO_OWNER_AND_UNREVIEWED: + $audit_uninvolved = true; + $audit_unreviewed = true; + break; } - // Trigger an audit if we don't recognize the commit's author. - if (!$author_phid) { - return true; + // If auditing is configured to trigger on unreviewed changes, check if + // the revision was "Accepted" when it landed. If not, trigger an audit. + if ($audit_unreviewed) { + $commit_unreviewed = true; + if ($revision) { + $was_accepted = DifferentialRevision::PROPERTY_CLOSED_FROM_ACCEPTED; + if ($revision->isPublished()) { + if ($revision->getProperty($was_accepted)) { + $commit_unreviewed = false; + } + } + } + + if ($commit_unreviewed) { + return true; + } } + // If auditing is configured to trigger on changes with no involved owner, + // check for an owner. If we don't find one, trigger an audit. + if ($audit_uninvolved) { + $owner_involved = $this->isOwnerInvolved( + $commit, + $package, + $author_phid, + $revision); + if (!$owner_involved) { + return true; + } + } + + // We can't find any reason to trigger an audit for this commit. + return false; + } + + private function isOwnerInvolved( + PhabricatorRepositoryCommit $commit, + PhabricatorOwnersPackage $package, + $author_phid, + $revision) { + $owner_phids = PhabricatorOwnersOwner::loadAffiliatedUserPHIDs( array( $package->getID(), )); $owner_phids = array_fuse($owner_phids); - // Don't trigger an audit if the author is a package owner. - if (isset($owner_phids[$author_phid])) { - return false; + // For the purposes of deciding whether the owners were involved in the + // revision or not, consider a review by the package itself to count as + // involvement. This can happen when human reviewers force-accept on + // behalf of packages they don't own but have authority over. + $owner_phids[$package->getPHID()] = $package->getPHID(); + + // If the commit author is identifiable and a package owner, they're + // involved. + if ($author_phid) { + if (isset($owner_phids[$author_phid])) { + return true; + } } - // Trigger an audit of there is no corresponding revision. + // Otherwise, we need to find an owner as a reviewer. + + // If we don't have a revision, this is hopeless: no owners are involved. if (!$revision) { return true; } @@ -168,26 +231,25 @@ final class PhabricatorRepositoryCommitOwnersWorker foreach ($revision->getReviewers() as $reviewer) { $reviewer_phid = $reviewer->getReviewerPHID(); - // If this reviewer isn't a package owner, just ignore them. + // If this reviewer isn't a package owner or the package itself, + // just ignore them. if (empty($owner_phids[$reviewer_phid])) { continue; } - // If this reviewer accepted the revision and owns the package, we're - // all clear and do not need to trigger an audit. + // If this reviewer accepted the revision and owns the package (or is + // the package), we've found an involved owner. if (isset($accepted_statuses[$reviewer->getReviewerStatus()])) { $found_accept = true; break; } } - // Don't trigger an audit if a package owner already reviewed the - // revision. if ($found_accept) { - return false; + return true; } - return true; + return false; } } diff --git a/src/applications/search/controller/PhabricatorApplicationSearchController.php b/src/applications/search/controller/PhabricatorApplicationSearchController.php index b593cbc7a1..c08c2abd88 100644 --- a/src/applications/search/controller/PhabricatorApplicationSearchController.php +++ b/src/applications/search/controller/PhabricatorApplicationSearchController.php @@ -254,6 +254,8 @@ $pager = $engine->newPagerForSavedQuery($saved_query); $pager->readFromRequest($request); + $query->setReturnPartialResultsOnOverheat(true); + $objects = $engine->executeQuery($query, $pager); $description = $this->getDescriptionForQuery($named_query, $query); @@ -284,9 +286,10 @@ throw new Exception( pht( 'SearchEngines must render a "%s" object, but this engine '. - '(of class "%s") rendered something else.', + '(of class "%s") rendered something else ("%s").', 'PhabricatorApplicationSearchResultView', - get_class($engine))); + get_class($engine), + phutil_describe_type($list))); } if ($list->getObjectList()) { @@ -359,6 +362,8 @@ $exec_errors[] = $ex->getMessage(); } catch (PhabricatorSearchConstraintException $ex) { $exec_errors[] = $ex->getMessage(); + } catch (PhabricatorInvalidQueryCursorException $ex) { + $exec_errors[] = $ex->getMessage(); } // The engine may have encountered additional errors during rendering; @@ -392,7 +397,6 @@ require_celerity_resource('application-search-view-css'); return $this->newPage() - ->setApplicationMenu($this->buildApplicationMenu()) ->setTitle(pht('Query: %s', $title)) ->setCrumbs($crumbs) ->setNavigation($nav) @@ -616,7 +620,6 @@ ->setFooter($lists); return $this->newPage() - ->setApplicationMenu($this->buildApplicationMenu()) ->setTitle(pht('Saved Queries')) ->setCrumbs($crumbs) ->setNavigation($nav) @@ -808,6 +811,7 @@ $object = $query ->setViewer(PhabricatorUser::getOmnipotentUser()) ->setLimit(1) + ->setReturnPartialResultsOnOverheat(true) ->execute(); if ($object) { return null; @@ -854,19 +858,31 @@ )); } - private function newOverheatedView(array $results) { - if ($results) { + public static function newOverheatedError($has_results) { + $overheated_link = phutil_tag( + 'a', + array( + 'href' => 'https://phurl.io/u/overheated', + 'target' => '_blank', + ), + pht('Learn More')); + + if ($has_results) { $message = pht( - 'Most objects matching your query are not visible to you, so '. - 'filtering results is taking a long time. Only some results are '. - 'shown. Refine your query to find results more quickly.'); + 'This query took too long, so only some results are shown. %s', + $overheated_link); } else { $message = pht( - 'Most objects matching your query are not visible to you, so '. - 'filtering results is taking a long time. Refine your query to '. - 'find results more quickly.'); + 'This query took too long. %s', + $overheated_link); } + return $message; + } + + private function newOverheatedView(array $results) { + $message = self::newOverheatedError((bool)$results); + return id(new PHUIInfoView()) ->setSeverity(PHUIInfoView::SEVERITY_WARNING) ->setFlush(true) @@ -915,7 +931,7 @@ $engine = $this->getSearchEngine(); $nux_uri = $engine->getQueryBaseURI(); $nux_uri = id(new PhutilURI($nux_uri)) - ->setQueryParam('nux', true); + ->replaceQueryParam('nux', true); $actions[] = id(new PhabricatorActionView()) ->setIcon('fa-user-plus') @@ -925,7 +941,7 @@ if ($is_dev) { $overheated_uri = $this->getRequest()->getRequestURI() - ->setQueryParam('overheated', true); + ->replaceQueryParam('overheated', true); $actions[] = id(new PhabricatorActionView()) ->setIcon('fa-fire') diff --git a/src/applications/search/editor/PhabricatorProfileMenuEditEngine.php b/src/applications/search/editor/PhabricatorProfileMenuEditEngine.php index b21d03cbd1..b55f43255c 100644 --- a/src/applications/search/editor/PhabricatorProfileMenuEditEngine.php +++ b/src/applications/search/editor/PhabricatorProfileMenuEditEngine.php @@ -109,7 +109,7 @@ final class PhabricatorProfileMenuEditEngine } protected function getObjectEditTitleText($object) { - $object->willBuildNavigationItems(array($object)); + $object->willGetMenuItemViewList(array($object)); return pht('Edit Menu Item: %s', $object->getDisplayName()); } diff --git a/src/applications/search/engine/PhabricatorProfileMenuEngine.php b/src/applications/search/engine/PhabricatorProfileMenuEngine.php index 172ad51f71..fadb929594 100644 --- a/src/applications/search/engine/PhabricatorProfileMenuEngine.php +++ b/src/applications/search/engine/PhabricatorProfileMenuEngine.php @@ -6,7 +6,6 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { private $profileObject; private $customPHID; private $items; - private $defaultItem; private $controller; private $navigation; private $showNavigation = true; @@ -72,17 +71,6 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { return $this->controller; } - private function setDefaultItem( - PhabricatorProfileMenuItemConfiguration $default_item) { - $this->defaultItem = $default_item; - return $this; - } - - public function getDefaultItem() { - $this->getItems(); - return $this->defaultItem; - } - public function setShowNavigation($show) { $this->showNavigation = $show; return $this; @@ -152,35 +140,23 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { $item_id = $request->getURIData('id'); } - $item_list = $this->getItems(); + $view_list = $this->newProfileMenuItemViewList(); - $selected_item = null; - if (strlen($item_id)) { - $item_id_int = (int)$item_id; - foreach ($item_list as $item) { - if ($item_id_int) { - if ((int)$item->getID() === $item_id_int) { - $selected_item = $item; - break; - } - } - - $builtin_key = $item->getBuiltinKey(); - if ($builtin_key === (string)$item_id) { - $selected_item = $item; - break; - } - } - } - - if (!$selected_item) { - if ($is_view) { - $selected_item = $this->getDefaultItem(); + if ($is_view) { + $selected_item = $this->selectViewItem($view_list, $item_id); + } else { + if (!strlen($item_id)) { + $item_id = self::ITEM_MANAGE; } + $selected_item = $this->selectEditItem($view_list, $item_id); } switch ($item_action) { case 'view': + // If we were not able to select an item, we're still going to render + // a page state. For example, this happens when you create a new + // portal for the first time. + break; case 'info': case 'hide': case 'default': @@ -201,13 +177,10 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { break; } - $navigation = $this->buildNavigation(); - + $navigation = $view_list->newNavigationView(); $crumbs = $controller->buildApplicationCrumbsForEditEngine(); if (!$is_view) { - $navigation->selectFilter(self::ITEM_MANAGE); - if ($selected_item) { if ($selected_item->getCustomPHID()) { $edit_mode = 'custom'; @@ -231,27 +204,33 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { } $page_title = pht('Configure Menu'); } else { - $page_title = $selected_item->getDisplayName(); - if ($page_title == 'Home') { - $page_title = 'Blender Foundation: Welcome'; + if ($selected_item) { + $page_title = $selected_item->getDisplayName(); + } else { + $page_title = pht('Empty'); } } switch ($item_action) { case 'view': - $navigation->selectFilter($selected_item->getDefaultMenuItemKey()); + if ($selected_item) { + try { + $content = $this->buildItemViewContent($selected_item); + } catch (Exception $ex) { + $content = id(new PHUIInfoView()) + ->setTitle(pht('Unable to Render Dashboard')) + ->setErrors(array($ex->getMessage())); + } - try { - $content = $this->buildItemViewContent($selected_item); - } catch (Exception $ex) { - $content = id(new PHUIInfoView()) - ->setTitle(pht('Unable to Render Dashboard')) - ->setErrors(array($ex->getMessage())); + $crumbs->addTextCrumb($selected_item->getDisplayName()); + } else { + $content = $this->newNoContentView($this->getItems()); } - $crumbs->addTextCrumb($selected_item->getDisplayName()); if (!$content) { - return new Aphront404Response(); + $content = $this->newEmptyView( + pht('Empty'), + pht('There is nothing here.')); } break; case 'configure': @@ -300,9 +279,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { if (!$this->isMenuEnginePinnable()) { return new Aphront404Response(); } - $content = $this->buildItemDefaultContent( - $selected_item, - $item_list); + $content = $this->buildItemDefaultContent($selected_item); break; case 'edit': $content = $this->buildItemEditContent(); @@ -345,62 +322,6 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { return $page; } - public function buildNavigation() { - if ($this->navigation) { - return $this->navigation; - } - $nav = id(new AphrontSideNavFilterView()) - ->setIsProfileMenu(true) - ->setBaseURI(new PhutilURI($this->getItemURI(''))); - - $menu_items = $this->getItems(); - - $filtered_items = array(); - foreach ($menu_items as $menu_item) { - if ($menu_item->isDisabled()) { - continue; - } - $filtered_items[] = $menu_item; - } - $filtered_groups = mgroup($filtered_items, 'getMenuItemKey'); - foreach ($filtered_groups as $group) { - $first_item = head($group); - $first_item->willBuildNavigationItems($group); - } - - foreach ($menu_items as $menu_item) { - if ($menu_item->isDisabled()) { - continue; - } - - $items = $menu_item->buildNavigationMenuItems(); - foreach ($items as $item) { - $this->validateNavigationMenuItem($item); - } - - // If the item produced only a single item which does not otherwise - // have a key, try to automatically assign it a reasonable key. This - // makes selecting the correct item simpler. - - if (count($items) == 1) { - $item = head($items); - if ($item->getKey() === null) { - $default_key = $menu_item->getDefaultMenuItemKey(); - $item->setKey($default_key); - } - } - - foreach ($items as $item) { - $nav->addMenuItem($item); - } - } - - $nav->selectFilter(null); - - $this->navigation = $nav; - return $this->navigation; - } - private function getItems() { if ($this->items === null) { $this->items = $this->loadItems(self::MODE_COMBINED); @@ -453,6 +374,12 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { // stored config: it corresponds to an out-of-date or uninstalled // item. if (isset($items[$builtin_key])) { + $builtin_item = $items[$builtin_key]; + + // Copy runtime properties from the builtin item to the stored item. + $stored_item->setIsHeadItem($builtin_item->getIsHeadItem()); + $stored_item->setIsTailItem($builtin_item->getIsTailItem()); + $items[$builtin_key] = $stored_item; } else { continue; @@ -462,39 +389,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { } } - $items = $this->arrangeItems($items, $mode); - - // Make sure exactly one valid item is marked as default. - $default = null; - $first = null; - foreach ($items as $item) { - if (!$item->canMakeDefault() || $item->isDisabled()) { - continue; - } - - // If this engine doesn't support pinning items, don't respect any - // setting which might be present in the database. - if ($this->isMenuEnginePinnable()) { - if ($item->isDefault()) { - $default = $item; - break; - } - } - - if ($first === null) { - $first = $item; - } - } - - if (!$default) { - $default = $first; - } - - if ($default) { - $this->setDefaultItem($default); - } - - return $items; + return $this->arrangeItems($items, $mode); } private function loadBuiltinProfileItems($mode) { @@ -734,7 +629,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { * * @return bool True if items may be pinned as default items. */ - protected function isMenuEnginePinnable() { + public function isMenuEnginePinnable() { return !$this->isMenuEnginePersonalizable(); } @@ -798,7 +693,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { $filtered_groups = mgroup($items, 'getMenuItemKey'); foreach ($filtered_groups as $group) { $first_item = head($group); - $first_item->willBuildNavigationItems($group); + $first_item->willGetMenuItemViewList($group); } // Users only need to be able to edit the object which this menu appears @@ -827,6 +722,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { ->setID($list_id) ->setNoDataString(pht('This menu currently has no items.')); + $any_draggable = false; foreach ($items as $item) { $id = $item->getID(); $builtin_key = $item->getBuiltinKey(); @@ -847,14 +743,25 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { $view->setHeader($name); $view->addAttribute($type); + $icon = $item->getMenuItem()->getMenuItemTypeIcon(); + if ($icon !== null) { + $view->setStatusIcon($icon); + } + if ($can_edit) { - $view - ->setGrippable(true) - ->addSigil('profile-menu-item') - ->setMetadata( - array( - 'key' => nonempty($id, $builtin_key), - )); + $can_move = (!$item->getIsHeadItem() && !$item->getIsTailItem()); + if ($can_move) { + $view + ->setGrippable(true) + ->addSigil('profile-menu-item') + ->setMetadata( + array( + 'key' => nonempty($id, $builtin_key), + )); + $any_draggable = true; + } else { + $view->setGrippable(false); + } if ($id) { $default_uri = $this->getItemURI("default/{$id}/"); @@ -920,15 +827,16 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { $list->addItem($view); } - $action_view = id(new PhabricatorActionListView()) - ->setUser($viewer); - $item_types = PhabricatorProfileMenuItem::getAllMenuItems(); $object = $this->getProfileObject(); $action_list = id(new PhabricatorActionListView()) ->setViewer($viewer); + // See T12167. This makes the "Actions" dropdown button show up in the + // page header. + $action_list->setID(celerity_generate_unique_node_id()); + $action_list->addAction( id(new PhabricatorActionView()) ->setLabel(true) @@ -968,14 +876,19 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { ->setHeader(pht('Menu Items')) ->setHeaderIcon('fa-list'); + $list_header = id(new PHUIHeaderView()) + ->setHeader(pht('Current Menu Items')); + + if ($any_draggable) { + $list_header->setSubheader( + pht('Drag items in this list to reorder them.')); + } + $box = id(new PHUIObjectBoxView()) - ->setHeaderText(pht('Current Menu Items')) + ->setHeader($list_header) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) ->setObjectList($list); - $panel = id(new PHUICurtainPanelView()) - ->appendChild($action_view); - $curtain = id(new PHUICurtainView()) ->setViewer($viewer) ->setActionList($action_list); @@ -1154,8 +1067,7 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { } private function buildItemDefaultContent( - PhabricatorProfileMenuItemConfiguration $configuration, - array $items) { + PhabricatorProfileMenuItemConfiguration $configuration) { $controller = $this->getController(); $request = $controller->getRequest(); @@ -1217,7 +1129,19 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { protected function newManageItem() { return $this->newItem() ->setBuiltinKey(self::ITEM_MANAGE) - ->setMenuItemKey(PhabricatorManageProfileMenuItem::MENUITEMKEY); + ->setMenuItemKey(PhabricatorManageProfileMenuItem::MENUITEMKEY) + ->setIsTailItem(true); + } + + public function getDefaultMenuItemConfiguration() { + $configs = $this->getItems(); + foreach ($configs as $config) { + if ($config->isDefault()) { + return $config; + } + } + + return null; } public function adjustDefault($key) { @@ -1324,5 +1248,113 @@ abstract class PhabricatorProfileMenuEngine extends Phobject { return $items; } + final protected function newEmptyView($title, $message) { + return id(new PHUIInfoView()) + ->setTitle($title) + ->setSeverity(PHUIInfoView::SEVERITY_NODATA) + ->setErrors( + array( + $message, + )); + } + + protected function newNoContentView(array $items) { + return $this->newEmptyView( + pht('No Content'), + pht('No visible menu items can render content.')); + } + + + final public function newProfileMenuItemViewList() { + $items = $this->getItems(); + + // Throw away disabled items: they are not allowed to build any views for + // the menu. + foreach ($items as $key => $item) { + if ($item->isDisabled()) { + unset($items[$key]); + continue; + } + } + + // Give each item group a callback so it can load data it needs to render + // views. + $groups = mgroup($items, 'getMenuItemKey'); + foreach ($groups as $group) { + $item = head($group); + $item->willGetMenuItemViewList($group); + } + + $view_list = id(new PhabricatorProfileMenuItemViewList()) + ->setProfileMenuEngine($this); + + foreach ($items as $item) { + $views = $item->getMenuItemViewList(); + foreach ($views as $view) { + $view_list->addItemView($view); + } + } + + return $view_list; + } + + private function selectViewItem( + PhabricatorProfileMenuItemViewList $view_list, + $item_id) { + + // Figure out which view's content we're going to render. In most cases, + // the URI tells us. If we don't have an identifier in the URI, we'll + // render the default view instead. + + $selected_view = null; + if (strlen($item_id)) { + $item_views = $view_list->getViewsWithItemIdentifier($item_id); + if ($item_views) { + $selected_view = head($item_views); + } + } else { + $default_views = $view_list->getDefaultViews(); + if ($default_views) { + $selected_view = head($default_views); + } + } + + if ($selected_view) { + $view_list->setSelectedView($selected_view); + $selected_item = $selected_view->getMenuItemConfiguration(); + } else { + $selected_item = null; + } + + return $selected_item; + } + + private function selectEditItem( + PhabricatorProfileMenuItemViewList $view_list, + $item_id) { + + // First, try to select a visible item using the normal view selection + // pathway. If this works, it also highlights the menu properly. + + if ($item_id) { + $selected_item = $this->selectViewItem($view_list, $item_id); + if ($selected_item) { + return $selected_item; + } + } + + // If we didn't find an item in the view list, we may be enabling an item + // which is currently disabled or editing an item which is not generating + // any actual items in the menu. + + foreach ($this->getItems() as $item) { + if ($item->matchesIdentifier($item_id)) { + return $item; + } + } + + return null; + } + } diff --git a/src/applications/search/engine/PhabricatorProfileMenuItemView.php b/src/applications/search/engine/PhabricatorProfileMenuItemView.php new file mode 100644 index 0000000000..d947afcba6 --- /dev/null +++ b/src/applications/search/engine/PhabricatorProfileMenuItemView.php @@ -0,0 +1,232 @@ +config = $config; + return $this; + } + + public function getMenuItemConfiguration() { + return $this->config; + } + + public function setURI($uri) { + $this->uri = $uri; + return $this; + } + + public function getURI() { + return $this->uri; + } + + public function setName($name) { + $this->name = $name; + return $this; + } + + public function getName() { + return $this->name; + } + + public function setIcon($icon) { + $this->icon = $icon; + return $this; + } + + public function getIcon() { + return $this->icon; + } + + public function setIconImage($icon_image) { + $this->iconImage = $icon_image; + return $this; + } + + public function getIconImage() { + return $this->iconImage; + } + + public function setDisabled($disabled) { + $this->disabled = $disabled; + return $this; + } + + public function getDisabled() { + return $this->disabled; + } + + public function setTooltip($tooltip) { + $this->tooltip = $tooltip; + return $this; + } + + public function getTooltip() { + return $this->tooltip; + } + + public function newAction($uri) { + $this->actions[] = $uri; + return null; + } + + public function newCount($count) { + $this->counts[] = $count; + return null; + } + + public function newProfileImage($src) { + $this->images[] = $src; + return null; + } + + public function newProgressBar($bar) { + $this->progressBars[] = $bar; + return null; + } + + public function setIsExternalLink($is_external) { + $this->isExternalLink = $is_external; + return $this; + } + + public function getIsExternalLink() { + return $this->isExternalLink; + } + + public function setIsLabel($is_label) { + return $this->setSpecialType('label'); + } + + public function getIsLabel() { + return $this->isSpecialType('label'); + } + + public function setIsDivider($is_divider) { + return $this->setSpecialType('divider'); + } + + public function getIsDivider() { + return $this->isSpecialType('divider'); + } + + private function setSpecialType($type) { + $this->specialType = $type; + return $this; + } + + private function isSpecialType($type) { + return ($this->specialType === $type); + } + + public function newListItemView() { + $view = id(new PHUIListItemView()) + ->setName($this->getName()); + + $uri = $this->getURI(); + if (strlen($uri)) { + if ($this->getIsExternalLink()) { + if (!PhabricatorEnv::isValidURIForLink($uri)) { + $uri = '#'; + } + $view->setRel('noreferrer'); + } + + $view->setHref($uri); + } + + $icon = $this->getIcon(); + if ($icon) { + $view->setIcon($icon); + } + + $icon_image = $this->getIconImage(); + if ($icon_image) { + $view->setProfileImage($icon_image); + } + + if ($this->getDisabled()) { + $view->setDisabled(true); + } + + if ($this->getIsLabel()) { + $view->setType(PHUIListItemView::TYPE_LABEL); + } + + if ($this->getIsDivider()) { + $view + ->setType(PHUIListItemView::TYPE_DIVIDER) + ->addClass('phui-divider'); + } + + $tooltip = $this->getTooltip(); + if (strlen($tooltip)) { + $view->setTooltip($tooltip); + } + + if ($this->images) { + require_celerity_resource('people-picture-menu-item-css'); + foreach ($this->images as $image_src) { + $classes = array(); + $classes[] = 'people-menu-image'; + + if ($this->getDisabled()) { + $classes[] = 'phui-image-disabled'; + } + + $image = phutil_tag( + 'img', + array( + 'src' => $image_src, + 'class' => implode(' ', $classes), + )); + + $image = phutil_tag( + 'div', + array( + 'class' => 'people-menu-image-container', + ), + $image); + + $view->appendChild($image); + } + } + + foreach ($this->counts as $count) { + $view->appendChild( + phutil_tag( + 'span', + array( + 'class' => 'phui-list-item-count', + ), + $count)); + } + + foreach ($this->actions as $action) { + $view->setActionIcon('fa-pencil', $action); + } + + foreach ($this->progressBars as $bar) { + $view->appendChild($bar); + } + + return $view; + } + +} diff --git a/src/applications/search/engine/PhabricatorProfileMenuItemViewList.php b/src/applications/search/engine/PhabricatorProfileMenuItemViewList.php new file mode 100644 index 0000000000..e5a3fd3d38 --- /dev/null +++ b/src/applications/search/engine/PhabricatorProfileMenuItemViewList.php @@ -0,0 +1,266 @@ +engine = $engine; + return $this; + } + + public function getProfileMenuEngine() { + return $this->engine; + } + + public function addItemView(PhabricatorProfileMenuItemView $view) { + $this->views[] = $view; + return $this; + } + + public function getItemViews() { + return $this->views; + } + + public function setSelectedView(PhabricatorProfileMenuItemView $view) { + $found = false; + foreach ($this->getItemViews() as $item_view) { + if ($view === $item_view) { + $found = true; + break; + } + } + + if (!$found) { + throw new Exception( + pht( + 'Provided view is not one of the views in the list: you can only '. + 'select a view which appears in the list.')); + } + + $this->selectedView = $view; + + return $this; + } + + public function setSelectedViewWithItemIdentifier($identifier) { + $views = $this->getViewsWithItemIdentifier($identifier); + + if (!$views) { + throw new Exception( + pht( + 'No views match identifier "%s"!', + $identifier)); + } + + return $this->setSelectedView(head($views)); + } + + public function getViewsWithItemIdentifier($identifier) { + $views = $this->getItemViews(); + + $results = array(); + foreach ($views as $view) { + $config = $view->getMenuItemConfiguration(); + + if (!$config->matchesIdentifier($identifier)) { + continue; + } + + $results[] = $view; + } + + return $results; + } + + public function getDefaultViews() { + $engine = $this->getProfileMenuEngine(); + $can_pin = $engine->isMenuEnginePinnable(); + + $views = $this->getItemViews(); + + // Remove all the views which were built by an item that can not be the + // default item. + foreach ($views as $key => $view) { + $config = $view->getMenuItemConfiguration(); + + if (!$config->canMakeDefault()) { + unset($views[$key]); + continue; + } + } + + // Remove disabled views. + foreach ($views as $key => $view) { + if ($view->getDisabled()) { + unset($views[$key]); + } + } + + // If this engine supports pinning items and we have candidate views from a + // valid pinned item, they are the default views. + if ($can_pin) { + $pinned = array(); + + foreach ($views as $key => $view) { + $config = $view->getMenuItemConfiguration(); + + if ($config->isDefault()) { + $pinned[] = $view; + continue; + } + } + + if ($pinned) { + return $pinned; + } + } + + // Return whatever remains that's still valid. + return $views; + } + + public function newNavigationView() { + $engine = $this->getProfileMenuEngine(); + + $base_uri = $engine->getItemURI(''); + $base_uri = new PhutilURI($base_uri); + + $navigation = id(new AphrontSideNavFilterView()) + ->setIsProfileMenu(true) + ->setBaseURI($base_uri); + + $views = $this->getItemViews(); + $selected_item = null; + $item_key = 0; + $items = array(); + foreach ($views as $view) { + $list_item = $view->newListItemView(); + + // Assign unique keys to the list items. These keys are purely internal. + $list_item->setKey(sprintf('item(%d)', $item_key++)); + + if ($this->selectedView) { + if ($this->selectedView === $view) { + $selected_item = $list_item; + } + } + + $navigation->addMenuItem($list_item); + $items[] = $list_item; + } + + if (!$views) { + // If the navigation menu has no items, add an empty label item to + // force it to render something. + $empty_item = id(new PHUIListItemView()) + ->setType(PHUIListItemView::TYPE_LABEL); + $navigation->addMenuItem($empty_item); + } + + $highlight_key = $this->getHighlightedItemKey( + $items, + $selected_item); + $navigation->selectFilter($highlight_key); + + return $navigation; + } + + private function getHighlightedItemKey( + array $items, + PHUIListItemView $selected_item = null) { + + assert_instances_of($items, 'PHUIListItemView'); + + $default_key = null; + if ($selected_item) { + $default_key = $selected_item->getKey(); + } + + $engine = $this->getProfileMenuEngine(); + $controller = $engine->getController(); + + // In some rare cases, when like building the "Favorites" menu on a + // 404 page, we may not have a controller. Just accept whatever default + // behavior we'd otherwise end up with. + if (!$controller) { + return $default_key; + } + + $request = $controller->getRequest(); + + // See T12949. If one of the menu items is a link to the same URI that + // the page was accessed with, we want to highlight that item. For example, + // this allows you to add links to a menu that apply filters to a + // workboard. + + $matches = array(); + foreach ($items as $item) { + $href = $item->getHref(); + if ($this->isMatchForRequestURI($request, $href)) { + $matches[] = $item; + } + } + + foreach ($matches as $match) { + if ($match->getKey() === $default_key) { + return $default_key; + } + } + + if ($matches) { + return head($matches)->getKey(); + } + + return $default_key; + } + + private function isMatchForRequestURI(AphrontRequest $request, $item_uri) { + $request_uri = $request->getAbsoluteRequestURI(); + $item_uri = new PhutilURI($item_uri); + + // If the request URI and item URI don't have matching paths, they + // do not match. + if ($request_uri->getPath() !== $item_uri->getPath()) { + return false; + } + + // If the request URI and item URI don't have matching parameters, they + // also do not match. We're specifically trying to let "?filter=X" work + // on Workboards, among other use cases, so this is important. + $request_params = $request_uri->getQueryParamsAsPairList(); + $item_params = $item_uri->getQueryParamsAsPairList(); + if ($request_params !== $item_params) { + return false; + } + + // If the paths and parameters match, the item domain must be: empty; or + // match the request domain; or match the production domain. + + $request_domain = $request_uri->getDomain(); + + $production_uri = PhabricatorEnv::getProductionURI('/'); + $production_domain = id(new PhutilURI($production_uri)) + ->getDomain(); + + $allowed_domains = array( + '', + $request_domain, + $production_domain, + ); + $allowed_domains = array_fuse($allowed_domains); + + $item_domain = $item_uri->getDomain(); + $item_domain = (string)$item_domain; + + if (isset($allowed_domains[$item_domain])) { + return true; + } + + return false; + } + +} diff --git a/src/applications/search/engine/PhabricatorSearchEngineAPIMethod.php b/src/applications/search/engine/PhabricatorSearchEngineAPIMethod.php index 235d74d6f3..f4e2dc918f 100644 --- a/src/applications/search/engine/PhabricatorSearchEngineAPIMethod.php +++ b/src/applications/search/engine/PhabricatorSearchEngineAPIMethod.php @@ -144,7 +144,7 @@ EOTEXT ->setHeaderText(pht('Builtin and Saved Queries')) ->setCollapsed(true) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->appendChild($this->buildRemarkup($info)) + ->appendChild($this->newRemarkupDocumentationView($info)) ->appendChild($table); } @@ -223,7 +223,7 @@ EOTEXT ); if ($constants) { - $constant_lists[] = $this->buildRemarkup( + $constant_lists[] = $this->newRemarkupDocumentationView( pht( 'Constants supported by the `%s` constraint:', 'statuses')); @@ -283,7 +283,7 @@ EOTEXT ->setHeaderText(pht('Custom Query Constraints')) ->setCollapsed(true) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->appendChild($this->buildRemarkup($info)) + ->appendChild($this->newRemarkupDocumentationView($info)) ->appendChild($table) ->appendChild($constant_lists); } @@ -391,9 +391,9 @@ EOTEXT ->setHeaderText(pht('Result Ordering')) ->setCollapsed(true) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->appendChild($this->buildRemarkup($orders_info)) + ->appendChild($this->newRemarkupDocumentationView($orders_info)) ->appendChild($orders_table) - ->appendChild($this->buildRemarkup($columns_info)) + ->appendChild($this->newRemarkupDocumentationView($columns_info)) ->appendChild($columns_table); } @@ -403,7 +403,7 @@ EOTEXT $info = pht(<<setHeaderText(pht('Object Fields')) ->setCollapsed(true) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->appendChild($this->buildRemarkup($info)) + ->appendChild($this->newRemarkupDocumentationView($info)) ->appendChild($table); } @@ -562,7 +562,7 @@ EOTEXT ->setHeaderText(pht('Attachments')) ->setCollapsed(true) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->appendChild($this->buildRemarkup($info)) + ->appendChild($this->newRemarkupDocumentationView($info)) ->appendChild($table); } @@ -633,21 +633,7 @@ EOTEXT ->setHeaderText(pht('Paging and Limits')) ->setCollapsed(true) ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) - ->appendChild($this->buildRemarkup($info)); + ->appendChild($this->newRemarkupDocumentationView($info)); } - private function buildRemarkup($remarkup) { - $viewer = $this->getViewer(); - - $view = new PHUIRemarkupView($viewer, $remarkup); - - $view->setRemarkupOptions( - array( - PHUIRemarkupView::OPTION_PRESERVE_LINEBREAKS => false, - )); - - return id(new PHUIBoxView()) - ->appendChild($view) - ->addPadding(PHUI::PADDING_LARGE); - } } diff --git a/src/applications/search/engineextension/PhabricatorFulltextIndexEngineExtension.php b/src/applications/search/engineextension/PhabricatorFulltextIndexEngineExtension.php index ab4da88420..126f298f1f 100644 --- a/src/applications/search/engineextension/PhabricatorFulltextIndexEngineExtension.php +++ b/src/applications/search/engineextension/PhabricatorFulltextIndexEngineExtension.php @@ -70,12 +70,8 @@ final class PhabricatorFulltextIndexEngineExtension private function getCommentVersion($object) { $xaction = $object->getApplicationTransactionTemplate(); - try { - $comment = $xaction->getApplicationTransactionCommentObject(); - if (!$comment) { - return 'none'; - } - } catch (Exception $ex) { + $comment = $xaction->getApplicationTransactionCommentObject(); + if (!$comment) { return 'none'; } diff --git a/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php b/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php index 6b6d25cb6c..984eeae5fb 100644 --- a/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php +++ b/src/applications/search/management/PhabricatorSearchManagementIndexWorkflow.php @@ -116,6 +116,10 @@ final class PhabricatorSearchManagementIndexWorkflow // them a hint that they might want to use "--force". $track_skips = (!$is_background && !$is_force); + // Activate "strict" error reporting if we're running in the foreground + // so we'll report a wider range of conditions as errors. + $is_strict = !$is_background; + $count_updated = 0; $count_skipped = 0; @@ -125,11 +129,20 @@ final class PhabricatorSearchManagementIndexWorkflow $old_versions = $this->loadIndexVersions($phid); } - PhabricatorSearchWorker::queueDocumentForIndexing($phid, $parameters); + PhabricatorSearchWorker::queueDocumentForIndexing( + $phid, + $parameters, + $is_strict); if ($track_skips) { $new_versions = $this->loadIndexVersions($phid); - if ($old_versions !== $new_versions) { + + if (!$old_versions && !$new_versions) { + // If the document doesn't use an index version, both the lists + // of versions will be empty. We still rebuild the index in this + // case. + $count_updated++; + } else if ($old_versions !== $new_versions) { $count_updated++; } else { $count_skipped++; diff --git a/src/applications/search/menuitem/PhabricatorApplicationProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorApplicationProfileMenuItem.php index aa42d56cfb..040b877368 100644 --- a/src/applications/search/menuitem/PhabricatorApplicationProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorApplicationProfileMenuItem.php @@ -68,7 +68,7 @@ final class PhabricatorApplicationProfileMenuItem return head($apps); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $viewer = $this->getViewer(); $app = $this->getApplication($config); @@ -83,8 +83,8 @@ final class PhabricatorApplicationProfileMenuItem return array(); } - $item = $this->newItem() - ->setHref($app->getApplicationURI()) + $item = $this->newItemView() + ->setURI($app->getApplicationURI()) ->setName($this->getDisplayName($config)) ->setIcon($app->getIcon()); diff --git a/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php index 6a91188c8f..591dee8604 100644 --- a/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorConpherenceProfileMenuItem.php @@ -17,6 +17,12 @@ final class PhabricatorConpherenceProfileMenuItem } public function canAddToObject($object) { + $application = new PhabricatorConpherenceApplication(); + + if (!$application->isInstalled()) { + return false; + } + return true; } @@ -35,7 +41,7 @@ final class PhabricatorConpherenceProfileMenuItem return $conpherence; } - public function willBuildNavigationItems(array $items) { + public function willGetMenuItemViewList(array $items) { $viewer = $this->getViewer(); $room_phids = array(); foreach ($items as $item) { @@ -92,7 +98,7 @@ final class PhabricatorConpherenceProfileMenuItem return $config->getMenuItemProperty('name'); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $viewer = $this->getViewer(); $room = $this->getConpherence($config); @@ -108,21 +114,14 @@ final class PhabricatorConpherenceProfileMenuItem $unread_count = $data['unread_count']; } - $count = null; - if ($unread_count) { - $count = phutil_tag( - 'span', - array( - 'class' => 'phui-list-item-count', - ), - $unread_count); - } - - $item = $this->newItem() - ->setHref('/'.$room->getMonogram()) + $item = $this->newItemView() + ->setURI('/'.$room->getMonogram()) ->setName($this->getDisplayName($config)) - ->setIcon('fa-comments') - ->appendChild($count); + ->setIcon('fa-comments'); + + if ($unread_count) { + $item->newCount($unread_count); + } return array( $item, diff --git a/src/applications/search/menuitem/PhabricatorDashboardProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorDashboardProfileMenuItem.php index 7d4f319b61..af913778ee 100644 --- a/src/applications/search/menuitem/PhabricatorDashboardProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorDashboardProfileMenuItem.php @@ -8,6 +8,7 @@ final class PhabricatorDashboardProfileMenuItem const FIELD_DASHBOARD = 'dashboardPHID'; private $dashboard; + private $dashboardHandle; public function getMenuItemTypeIcon() { return 'fa-dashboard'; @@ -26,21 +27,13 @@ final class PhabricatorDashboardProfileMenuItem return true; } - public function attachDashboard($dashboard) { + private function attachDashboard(PhabricatorDashboard $dashboard = null) { $this->dashboard = $dashboard; return $this; } - public function getDashboard() { - $dashboard = $this->dashboard; - - if (!$dashboard) { - return null; - } else if ($dashboard->isArchived()) { - return null; - } - - return $dashboard; + private function getDashboard() { + return $this->dashboard; } public function newPageContent( @@ -56,7 +49,15 @@ final class PhabricatorDashboardProfileMenuItem ->needPanels(true) ->executeOne(); if (!$dashboard) { - return null; + return $this->newEmptyView( + pht('Invalid Dashboard'), + pht('This dashboard is invalid and could not be loaded.')); + } + + if ($dashboard->isArchived()) { + return $this->newEmptyView( + pht('Archived Dashboard'), + pht('This dashboard has been archived.')); } $engine = id(new PhabricatorDashboardRenderingEngine()) @@ -66,7 +67,7 @@ final class PhabricatorDashboardProfileMenuItem return $engine->renderDashboard(); } - public function willBuildNavigationItems(array $items) { + public function willGetMenuItemViewList(array $items) { $viewer = $this->getViewer(); $dashboard_phids = array(); foreach ($items as $item) { @@ -78,11 +79,18 @@ final class PhabricatorDashboardProfileMenuItem ->withPHIDs($dashboard_phids) ->execute(); + $handles = $viewer->loadHandles($dashboard_phids); + $dashboards = mpull($dashboards, null, 'getPHID'); foreach ($items as $item) { $dashboard_phid = $item->getMenuItemProperty('dashboardPHID'); $dashboard = idx($dashboards, $dashboard_phid, null); - $item->getMenuItem()->attachDashboard($dashboard); + + $menu_item = $item->getMenuItem(); + + $menu_item + ->attachDashboard($dashboard) + ->setDashboardHandle($handles[$dashboard_phid]); } } @@ -91,7 +99,15 @@ final class PhabricatorDashboardProfileMenuItem $dashboard = $this->getDashboard(); if (!$dashboard) { - return pht('(Restricted/Invalid Dashboard)'); + if ($this->getDashboardHandle()->getPolicyFiltered()) { + return pht('Restricted Dashboard'); + } else { + return pht('Invalid Dashboard'); + } + } + + if ($dashboard->isArchived()) { + return pht('Archived Dashboard'); } if (strlen($this->getName($config))) { @@ -122,24 +138,43 @@ final class PhabricatorDashboardProfileMenuItem return $config->getMenuItemProperty('name'); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { + $is_disabled = true; + $action_uri = null; + $dashboard = $this->getDashboard(); - if (!$dashboard) { - return array(); + if ($dashboard) { + if ($dashboard->isArchived()) { + $icon = 'fa-ban'; + $name = $this->getDisplayName($config); + } else { + $icon = $dashboard->getIcon(); + $name = $this->getDisplayName($config); + $is_disabled = false; + $action_uri = '/dashboard/arrange/'.$dashboard->getID().'/'; + } + } else { + $icon = 'fa-ban'; + if ($this->getDashboardHandle()->getPolicyFiltered()) { + $name = pht('Restricted Dashboard'); + } else { + $name = pht('Invalid Dashboard'); + } } - $icon = $dashboard->getIcon(); - $name = $this->getDisplayName($config); - $href = $this->getItemViewURI($config); - $action_href = '/dashboard/arrange/'.$dashboard->getID().'/'; + $uri = $this->getItemViewURI($config); - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon($icon) - ->setActionIcon('fa-pencil', $action_href); + ->setDisabled($is_disabled); + + if ($action_uri) { + $item->newAction($action_uri); + } return array( $item, @@ -191,4 +226,13 @@ final class PhabricatorDashboardProfileMenuItem return $errors; } + private function getDashboardHandle() { + return $this->dashboardHandle; + } + + private function setDashboardHandle(PhabricatorObjectHandle $handle) { + $this->dashboardHandle = $handle; + return $this; + } + } diff --git a/src/applications/search/menuitem/PhabricatorDividerProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorDividerProfileMenuItem.php index e6a6e608e6..8510418fab 100644 --- a/src/applications/search/menuitem/PhabricatorDividerProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorDividerProfileMenuItem.php @@ -34,12 +34,11 @@ final class PhabricatorDividerProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { - $item = $this->newItem() - ->setType(PHUIListItemView::TYPE_DIVIDER) - ->addClass('phui-divider'); + $item = $this->newItemView() + ->setIsDivider(true); return array( $item, diff --git a/src/applications/search/menuitem/PhabricatorEditEngineProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorEditEngineProfileMenuItem.php index 88749d247f..71e3d7e8a5 100644 --- a/src/applications/search/menuitem/PhabricatorEditEngineProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorEditEngineProfileMenuItem.php @@ -34,7 +34,7 @@ final class PhabricatorEditEngineProfileMenuItem return $form; } - public function willBuildNavigationItems(array $items) { + public function willGetMenuItemViewList(array $items) { $viewer = $this->getViewer(); $engines = PhabricatorEditEngine::getAllEditEngines(); $engine_keys = array_keys($engines); @@ -99,7 +99,7 @@ final class PhabricatorEditEngineProfileMenuItem return $config->getMenuItemProperty('name'); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $form = $this->getForm(); @@ -110,13 +110,13 @@ final class PhabricatorEditEngineProfileMenuItem $icon = $form->getIcon(); $name = $this->getDisplayName($config); - $href = $form->getCreateURI(); - if ($href === null) { + $uri = $form->getCreateURI(); + if ($uri === null) { return array(); } - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon($icon); diff --git a/src/applications/search/menuitem/PhabricatorLabelProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorLabelProfileMenuItem.php index 1f769905d7..a152da5898 100644 --- a/src/applications/search/menuitem/PhabricatorLabelProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorLabelProfileMenuItem.php @@ -7,7 +7,7 @@ final class PhabricatorLabelProfileMenuItem const FIELD_NAME = 'name'; public function getMenuItemTypeIcon() { - return 'fa-map-signs'; + return 'fa-tag'; } public function getMenuItemTypeName() { @@ -39,14 +39,14 @@ final class PhabricatorLabelProfileMenuItem return $config->getMenuItemProperty('name'); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $name = $this->getLabelName($config); - $item = $this->newItem() + $item = $this->newItemView() ->setName($name) - ->setType(PHUIListItemView::TYPE_LABEL); + ->setIsLabel(true); return array( $item, diff --git a/src/applications/search/menuitem/PhabricatorLinkProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorLinkProfileMenuItem.php index 0b6a2f330e..bba3b01060 100644 --- a/src/applications/search/menuitem/PhabricatorLinkProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorLinkProfileMenuItem.php @@ -71,22 +71,14 @@ final class PhabricatorLinkProfileMenuItem return $config->getMenuItemProperty('tooltip'); } - private function isValidLinkURI($uri) { - return PhabricatorEnv::isValidURIForLink($uri); - } - - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $icon = $this->getLinkIcon($config); $name = $this->getLinkName($config); - $href = $this->getLinkURI($config); + $uri = $this->getLinkURI($config); $tooltip = $this->getLinkTooltip($config); - if (!$this->isValidLinkURI($href)) { - $href = '#'; - } - $icon_object = id(new PhabricatorProfileMenuItemIconSet()) ->getIcon($icon); if ($icon_object) { @@ -95,12 +87,12 @@ final class PhabricatorLinkProfileMenuItem $icon_class = 'fa-link'; } - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon($icon_class) ->setTooltip($tooltip) - ->setRel('noreferrer'); + ->setIsExternalLink(true); return array( $item, @@ -142,7 +134,7 @@ final class PhabricatorLinkProfileMenuItem continue; } - if (!$this->isValidLinkURI($new)) { + if (!PhabricatorEnv::isValidURIForLink($new)) { $errors[] = $this->newInvalidError( pht( 'URI "%s" is not a valid link URI. It should be a full, valid '. diff --git a/src/applications/search/menuitem/PhabricatorManageProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorManageProfileMenuItem.php index d5de555975..89ac4a5633 100644 --- a/src/applications/search/menuitem/PhabricatorManageProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorManageProfileMenuItem.php @@ -13,6 +13,10 @@ final class PhabricatorManageProfileMenuItem return pht('Edit Menu'); } + public function getMenuItemTypeIcon() { + return 'fa-pencil'; + } + public function canHideMenuItem( PhabricatorProfileMenuItemConfiguration $config) { return false; @@ -45,7 +49,7 @@ final class PhabricatorManageProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $viewer = $this->getViewer(); @@ -54,13 +58,13 @@ final class PhabricatorManageProfileMenuItem } $engine = $this->getEngine(); - $href = $engine->getItemURI('configure/'); + $uri = $engine->getItemURI('configure/'); $name = $this->getDisplayName($config); $icon = 'fa-pencil'; - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) ->setIcon($icon); diff --git a/src/applications/search/menuitem/PhabricatorMotivatorProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorMotivatorProfileMenuItem.php index 071979d391..d85b02b3a2 100644 --- a/src/applications/search/menuitem/PhabricatorMotivatorProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorMotivatorProfileMenuItem.php @@ -50,7 +50,7 @@ final class PhabricatorMotivatorProfileMenuItem ); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $source = $config->getMenuItemProperty('source'); @@ -66,11 +66,11 @@ final class PhabricatorMotivatorProfileMenuItem $fact_text = $this->selectFact($facts); - $item = $this->newItem() + $item = $this->newItemView() ->setName($fact_name) ->setIcon($fact_icon) ->setTooltip($fact_text) - ->setHref('#'); + ->setURI('#'); return array( $item, diff --git a/src/applications/search/menuitem/PhabricatorProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorProfileMenuItem.php index 061afc7fad..118815393d 100644 --- a/src/applications/search/menuitem/PhabricatorProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorProfileMenuItem.php @@ -5,15 +5,6 @@ abstract class PhabricatorProfileMenuItem extends Phobject { private $viewer; private $engine; - final public function buildNavigationMenuItems( - PhabricatorProfileMenuItemConfiguration $config) { - return $this->newNavigationMenuItems($config); - } - - abstract protected function newNavigationMenuItems( - PhabricatorProfileMenuItemConfiguration $config); - - public function willBuildNavigationItems(array $items) {} public function getMenuItemTypeIcon() { return null; @@ -76,10 +67,38 @@ abstract class PhabricatorProfileMenuItem extends Phobject { ->execute(); } - protected function newItem() { - return new PHUIListItemView(); + final protected function newItemView() { + return new PhabricatorProfileMenuItemView(); } + public function willGetMenuItemViewList(array $items) {} + + final public function getMenuItemViewList( + PhabricatorProfileMenuItemConfiguration $config) { + $list = $this->newMenuItemViewList($config); + + if (!is_array($list)) { + throw new Exception( + pht( + 'Expected "newMenuItemViewList()" to return a list (in class "%s"), '. + 'but it returned something else ("%s").', + get_class($this), + phutil_describe_type($list))); + } + + assert_instances_of($list, 'PhabricatorProfileMenuItemView'); + + foreach ($list as $view) { + $view->setMenuItemConfiguration($config); + } + + return $list; + } + + abstract protected function newMenuItemViewList( + PhabricatorProfileMenuItemConfiguration $config); + + public function newPageContent( PhabricatorProfileMenuItemConfiguration $config) { return null; @@ -131,4 +150,14 @@ abstract class PhabricatorProfileMenuItem extends Phobject { return $this->newError(pht('Invalid'), $message, $xaction); } + final protected function newEmptyView($title, $message) { + return id(new PHUIInfoView()) + ->setTitle($title) + ->setSeverity(PHUIInfoView::SEVERITY_NODATA) + ->setErrors( + array( + $message, + )); + } + } diff --git a/src/applications/search/menuitem/PhabricatorProjectProfileMenuItem.php b/src/applications/search/menuitem/PhabricatorProjectProfileMenuItem.php index aadabd179a..efb61f06a1 100644 --- a/src/applications/search/menuitem/PhabricatorProjectProfileMenuItem.php +++ b/src/applications/search/menuitem/PhabricatorProjectProfileMenuItem.php @@ -35,7 +35,7 @@ final class PhabricatorProjectProfileMenuItem return $project; } - public function willBuildNavigationItems(array $items) { + public function willGetMenuItemViewList(array $items) { $viewer = $this->getViewer(); $project_phids = array(); foreach ($items as $item) { @@ -90,7 +90,7 @@ final class PhabricatorProjectProfileMenuItem return $config->getMenuItemProperty('name'); } - protected function newNavigationMenuItems( + protected function newMenuItemViewList( PhabricatorProfileMenuItemConfiguration $config) { $project = $this->getProject(); @@ -100,12 +100,12 @@ final class PhabricatorProjectProfileMenuItem $picture = $project->getProfileImageURI(); $name = $this->getDisplayName($config); - $href = $project->getURI(); + $uri = $project->getURI(); - $item = $this->newItem() - ->setHref($href) + $item = $this->newItemView() + ->setURI($uri) ->setName($name) - ->setProfileImage($picture); + ->setIconImage($picture); return array( $item, diff --git a/src/applications/search/storage/PhabricatorProfileMenuItemConfiguration.php b/src/applications/search/storage/PhabricatorProfileMenuItemConfiguration.php index 8520cac1bd..8ca948cb07 100644 --- a/src/applications/search/storage/PhabricatorProfileMenuItemConfiguration.php +++ b/src/applications/search/storage/PhabricatorProfileMenuItemConfiguration.php @@ -17,6 +17,8 @@ final class PhabricatorProfileMenuItemConfiguration private $profileObject = self::ATTACHABLE; private $menuItem = self::ATTACHABLE; + private $isHeadItem = false; + private $isTailItem = false; const VISIBILITY_DEFAULT = 'default'; const VISIBILITY_VISIBLE = 'visible'; @@ -98,10 +100,6 @@ final class PhabricatorProfileMenuItemConfiguration return idx($this->menuItemProperties, $key, $default); } - public function buildNavigationMenuItems() { - return $this->getMenuItem()->buildNavigationMenuItems($this); - } - public function getMenuItemTypeName() { return $this->getMenuItem()->getMenuItemTypeName(); } @@ -122,8 +120,12 @@ final class PhabricatorProfileMenuItemConfiguration return $this->getMenuItem()->shouldEnableForObject($object); } - public function willBuildNavigationItems(array $items) { - return $this->getMenuItem()->willBuildNavigationItems($items); + public function willGetMenuItemViewList(array $items) { + return $this->getMenuItem()->willGetMenuItemViewList($items); + } + + public function getMenuItemViewList() { + return $this->getMenuItem()->getMenuItemViewList($this); } public function validateTransactions(array $map) { @@ -158,6 +160,15 @@ final class PhabricatorProfileMenuItemConfiguration $is_global = 1; } + // Sort "head" items above other items and "tail" items after other items. + if ($this->getIsHeadItem()) { + $force_position = 0; + } else if ($this->getIsTailItem()) { + $force_position = 2; + } else { + $force_position = 1; + } + // Sort items with an explicit order above items without an explicit order, // so any newly created builtins go to the bottom. $order = $this->getMenuItemOrder(); @@ -169,6 +180,7 @@ final class PhabricatorProfileMenuItemConfiguration return id(new PhutilSortVector()) ->addInt($is_global) + ->addInt($force_position) ->addInt($has_order) ->addInt((int)$order) ->addInt((int)$this->getID()); @@ -207,6 +219,43 @@ final class PhabricatorProfileMenuItemConfiguration return $this->getMenuItem()->newPageContent($this); } + public function setIsHeadItem($is_head_item) { + $this->isHeadItem = $is_head_item; + return $this; + } + + public function getIsHeadItem() { + return $this->isHeadItem; + } + + public function setIsTailItem($is_tail_item) { + $this->isTailItem = $is_tail_item; + return $this; + } + + public function getIsTailItem() { + return $this->isTailItem; + } + + public function matchesIdentifier($identifier) { + if (!strlen($identifier)) { + return false; + } + + if (ctype_digit($identifier)) { + if ((int)$this->getID() === (int)$identifier) { + return true; + } + } + + if ((string)$this->getBuiltinKey() === (string)$identifier) { + return true; + } + + return false; + } + + /* -( PhabricatorPolicyInterface )----------------------------------------- */ diff --git a/src/applications/search/storage/PhabricatorProfileMenuItemConfigurationTransaction.php b/src/applications/search/storage/PhabricatorProfileMenuItemConfigurationTransaction.php index b1d30a5b9d..4624d6f9af 100644 --- a/src/applications/search/storage/PhabricatorProfileMenuItemConfigurationTransaction.php +++ b/src/applications/search/storage/PhabricatorProfileMenuItemConfigurationTransaction.php @@ -20,8 +20,4 @@ final class PhabricatorProfileMenuItemConfigurationTransaction return PhabricatorProfileMenuItemPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - } diff --git a/src/applications/search/worker/PhabricatorSearchWorker.php b/src/applications/search/worker/PhabricatorSearchWorker.php index f93df63981..31c68d45c8 100644 --- a/src/applications/search/worker/PhabricatorSearchWorker.php +++ b/src/applications/search/worker/PhabricatorSearchWorker.php @@ -2,7 +2,11 @@ final class PhabricatorSearchWorker extends PhabricatorWorker { - public static function queueDocumentForIndexing($phid, $parameters = null) { + public static function queueDocumentForIndexing( + $phid, + $parameters = null, + $is_strict = false) { + if ($parameters === null) { $parameters = array(); } @@ -12,9 +16,10 @@ final class PhabricatorSearchWorker extends PhabricatorWorker { array( 'documentPHID' => $phid, 'parameters' => $parameters, + 'strict' => $is_strict, ), array( - 'priority' => parent::PRIORITY_IMPORT, + 'priority' => parent::PRIORITY_INDEX, 'objectPHID' => $phid, )); } @@ -23,7 +28,25 @@ final class PhabricatorSearchWorker extends PhabricatorWorker { $data = $this->getTaskData(); $object_phid = idx($data, 'documentPHID'); - $object = $this->loadObjectForIndexing($object_phid); + // See T12425. By the time we run an indexing task, the object it indexes + // may have been deleted. This is unusual, but not concerning, and failing + // to index these objects is correct. + + // To avoid showing these non-actionable errors to users, don't report + // indexing exceptions unless we're in "strict" mode. This mode is set by + // the "bin/search index" tool. + + $is_strict = idx($data, 'strict', false); + + try { + $object = $this->loadObjectForIndexing($object_phid); + } catch (PhabricatorWorkerPermanentFailureException $ex) { + if ($is_strict) { + throw $ex; + } else { + return; + } + } $engine = id(new PhabricatorIndexEngine()) ->setObject($object); @@ -35,8 +58,11 @@ final class PhabricatorSearchWorker extends PhabricatorWorker { return; } - $key = "index.{$object_phid}"; - $lock = PhabricatorGlobalLock::newLock($key); + $lock = PhabricatorGlobalLock::newLock( + 'index', + array( + 'objectPHID' => $object_phid, + )); try { $lock->lock(1); @@ -48,29 +74,34 @@ final class PhabricatorSearchWorker extends PhabricatorWorker { throw new PhabricatorWorkerYieldException(15); } + $caught = null; try { // Reload the object now that we have a lock, to make sure we have the // most current version. $object = $this->loadObjectForIndexing($object->getPHID()); $engine->setObject($object); - $engine->indexObject(); } catch (Exception $ex) { - $lock->unlock(); + $caught = $ex; + } - if (!($ex instanceof PhabricatorWorkerPermanentFailureException)) { - $ex = new PhabricatorWorkerPermanentFailureException( + // Release the lock before we deal with the exception. + $lock->unlock(); + + if ($caught) { + if (!($caught instanceof PhabricatorWorkerPermanentFailureException)) { + $caught = new PhabricatorWorkerPermanentFailureException( pht( 'Failed to update search index for document "%s": %s', $object_phid, - $ex->getMessage())); + $caught->getMessage())); } - throw $ex; + if ($is_strict) { + throw $caught; + } } - - $lock->unlock(); } private function loadObjectForIndexing($phid) { diff --git a/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php b/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php index 51f1747b9f..6a0ba19d03 100644 --- a/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php +++ b/src/applications/settings/controller/PhabricatorSettingsTimezoneController.php @@ -113,6 +113,11 @@ final class PhabricatorSettingsTimezoneController } private function formatOffset($offset) { + // This controller works with client-side (Javascript) offsets, which have + // the opposite sign we might expect -- for example "UTC-3" is a positive + // offset. Invert the sign before rendering the offset. + $offset = -1 * $offset; + $hours = $offset / 60; // Non-integer number of hours off UTC? if ($offset % 60) { diff --git a/src/applications/settings/panel/PhabricatorEmailAddressesSettingsPanel.php b/src/applications/settings/panel/PhabricatorEmailAddressesSettingsPanel.php index 1b69adcd62..8f7f633e7e 100644 --- a/src/applications/settings/panel/PhabricatorEmailAddressesSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorEmailAddressesSettingsPanel.php @@ -31,8 +31,7 @@ final class PhabricatorEmailAddressesSettingsPanel $user = $this->getUser(); $editable = PhabricatorEnv::getEnvConfig('account.editable'); - $uri = $request->getRequestURI(); - $uri->setQueryParams(array()); + $uri = new PhutilURI($request->getPath()); if ($editable) { $new = $request->getStr('new'); diff --git a/src/applications/settings/panel/PhabricatorExternalAccountsSettingsPanel.php b/src/applications/settings/panel/PhabricatorExternalAccountsSettingsPanel.php index 1215487208..60389e1590 100644 --- a/src/applications/settings/panel/PhabricatorExternalAccountsSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorExternalAccountsSettingsPanel.php @@ -41,27 +41,16 @@ final class PhabricatorExternalAccountsSettingsPanel ->setUser($viewer) ->setNoDataString(pht('You have no linked accounts.')); - $login_accounts = 0; - foreach ($accounts as $account) { - if ($account->isUsableForLogin()) { - $login_accounts++; - } - } - foreach ($accounts as $account) { $item = new PHUIObjectItemView(); - $provider = idx($providers, $account->getProviderKey()); - if ($provider) { - $item->setHeader($provider->getProviderName()); - $can_unlink = $provider->shouldAllowAccountUnlink(); - if (!$can_unlink) { - $item->addAttribute(pht('Permanently Linked')); - } - } else { - $item->setHeader( - pht('Unknown Account ("%s")', $account->getProviderKey())); - $can_unlink = true; + $config = $account->getProviderConfig(); + $provider = $config->getProvider(); + + $item->setHeader($provider->getProviderName()); + $can_unlink = $provider->shouldAllowAccountUnlink(); + if (!$can_unlink) { + $item->addAttribute(pht('Permanently Linked')); } $can_login = $account->isUsableForLogin(); @@ -72,14 +61,12 @@ final class PhabricatorExternalAccountsSettingsPanel 'account provider).')); } - $can_unlink = $can_unlink && (!$can_login || ($login_accounts > 1)); - - $can_refresh = $provider && $provider->shouldAllowAccountRefresh(); + $can_refresh = $provider->shouldAllowAccountRefresh(); if ($can_refresh) { $item->addAction( id(new PHUIListItemView()) ->setIcon('fa-refresh') - ->setHref('/auth/refresh/'.$account->getProviderKey().'/')); + ->setHref('/auth/refresh/'.$config->getID().'/')); } $item->addAction( @@ -87,7 +74,7 @@ final class PhabricatorExternalAccountsSettingsPanel ->setIcon('fa-times') ->setWorkflow(true) ->setDisabled(!$can_unlink) - ->setHref('/auth/unlink/'.$account->getProviderKey().'/')); + ->setHref('/auth/unlink/'.$account->getID().'/')); if ($provider) { $provider->willRenderLinkedAccount($viewer, $item, $account); @@ -103,28 +90,41 @@ final class PhabricatorExternalAccountsSettingsPanel ->setNoDataString( pht('Your account is linked with all available providers.')); - $accounts = mpull($accounts, null, 'getProviderKey'); + $configs = id(new PhabricatorAuthProviderConfigQuery()) + ->setViewer($viewer) + ->withIsEnabled(true) + ->execute(); + $configs = msort($configs, 'getSortVector'); - $providers = PhabricatorAuthProvider::getAllEnabledProviders(); - $providers = msort($providers, 'getProviderName'); - foreach ($providers as $key => $provider) { - if (isset($accounts[$key])) { - continue; - } + $account_map = mgroup($accounts, 'getProviderConfigPHID'); + + + foreach ($configs as $config) { + $provider = $config->getProvider(); if (!$provider->shouldAllowAccountLink()) { continue; } - $link_uri = '/auth/link/'.$provider->getProviderKey().'/'; + // Don't show the user providers they already have linked. + if (isset($account_map[$config->getPHID()])) { + continue; + } + + $link_uri = '/auth/link/'.$config->getID().'/'; + + $link_button = id(new PHUIButtonView()) + ->setTag('a') + ->setIcon('fa-link') + ->setHref($link_uri) + ->setColor(PHUIButtonView::GREY) + ->setText(pht('Link External Account')); $item = id(new PHUIObjectItemView()) - ->setHeader($provider->getProviderName()) + ->setHeader($config->getDisplayName()) ->setHref($link_uri) - ->addAction( - id(new PHUIListItemView()) - ->setIcon('fa-link') - ->setHref($link_uri)); + ->setImageIcon($config->newIconView()) + ->setSideColumn($link_button); $linkable->addItem($item); } diff --git a/src/applications/settings/panel/PhabricatorMultiFactorSettingsPanel.php b/src/applications/settings/panel/PhabricatorMultiFactorSettingsPanel.php index 6809b51334..09193f3c96 100644 --- a/src/applications/settings/panel/PhabricatorMultiFactorSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorMultiFactorSettingsPanel.php @@ -219,7 +219,7 @@ final class PhabricatorMultiFactorSettingsPanel foreach ($providers as $provider_phid => $provider) { $provider_uri = id(new PhutilURI($this->getPanelURI())) - ->setQueryParam('providerPHID', $provider_phid); + ->replaceQueryParam('providerPHID', $provider_phid); $is_enabled = $provider->canCreateNewConfiguration($viewer); diff --git a/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php b/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php index 37393d5d4f..77f32f977d 100644 --- a/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php +++ b/src/applications/settings/panel/PhabricatorPasswordSettingsPanel.php @@ -34,11 +34,6 @@ final class PhabricatorPasswordSettingsPanel extends PhabricatorSettingsPanel { $content_source = PhabricatorContentSource::newFromRequest($request); - $token = id(new PhabricatorAuthSessionEngine())->requireHighSecuritySession( - $viewer, - $request, - '/settings/'); - $min_len = PhabricatorEnv::getEnvConfig('account.minimum-password-length'); $min_len = (int)$min_len; @@ -55,20 +50,25 @@ final class PhabricatorPasswordSettingsPanel extends PhabricatorSettingsPanel { ->withPasswordTypes(array($account_type)) ->withIsRevoked(false) ->execute(); - if ($password_objects) { - $password_object = head($password_objects); - } else { - $password_object = PhabricatorAuthPassword::initializeNewPassword( - $user, - $account_type); + if (!$password_objects) { + return $this->newSetPasswordView($request); } + $password_object = head($password_objects); $e_old = true; $e_new = true; $e_conf = true; $errors = array(); - if ($request->isFormPost()) { + if ($request->isFormOrHisecPost()) { + $workflow_key = sprintf( + 'password.change(%s)', + $user->getPHID()); + + $hisec_token = id(new PhabricatorAuthSessionEngine()) + ->setWorkflowKey($workflow_key) + ->requireHighSecurityToken($viewer, $request, '/settings/'); + // Rate limit guesses about the old password. This page requires MFA and // session compromise already, so this is mostly just to stop researchers // from reporting this as a vulnerability. @@ -218,5 +218,27 @@ final class PhabricatorPasswordSettingsPanel extends PhabricatorSettingsPanel { ); } + private function newSetPasswordView(AphrontRequest $request) { + $viewer = $request->getUser(); + $user = $this->getUser(); + + $form = id(new AphrontFormView()) + ->setViewer($viewer) + ->appendRemarkupInstructions( + pht( + 'Your account does not currently have a password set. You can '. + 'choose a password by performing a password reset.')) + ->appendControl( + id(new AphrontFormSubmitControl()) + ->addCancelButton('/login/email/', pht('Reset Password'))); + + $form_box = id(new PHUIObjectBoxView()) + ->setHeaderText(pht('Set Password')) + ->setBackground(PHUIObjectBoxView::WHITE_CONFIG) + ->setForm($form); + + return $form_box; + } + } diff --git a/src/applications/settings/setting/PhabricatorTimezoneSetting.php b/src/applications/settings/setting/PhabricatorTimezoneSetting.php index 887e08129b..52fce77428 100644 --- a/src/applications/settings/setting/PhabricatorTimezoneSetting.php +++ b/src/applications/settings/setting/PhabricatorTimezoneSetting.php @@ -57,11 +57,11 @@ final class PhabricatorTimezoneSetting $groups = array(); foreach ($timezones as $timezone) { $zone = new DateTimeZone($timezone); - $offset = -($zone->getOffset($now) / (60 * 60)); + $offset = ($zone->getOffset($now) / 60); $groups[$offset][] = $timezone; } - krsort($groups); + ksort($groups); $option_groups = array( array( @@ -71,10 +71,13 @@ final class PhabricatorTimezoneSetting ); foreach ($groups as $offset => $group) { - if ($offset >= 0) { - $label = pht('UTC-%d', $offset); + $hours = $offset / 60; + $minutes = abs($offset % 60); + + if ($offset % 60) { + $label = pht('UTC%+d:%02d', $hours, $minutes); } else { - $label = pht('UTC+%d', -$offset); + $label = pht('UTC%+d', $hours); } sort($group); diff --git a/src/applications/settings/storage/PhabricatorUserPreferencesTransaction.php b/src/applications/settings/storage/PhabricatorUserPreferencesTransaction.php index 6378ee29d3..3ef48c01e0 100644 --- a/src/applications/settings/storage/PhabricatorUserPreferencesTransaction.php +++ b/src/applications/settings/storage/PhabricatorUserPreferencesTransaction.php @@ -11,10 +11,6 @@ final class PhabricatorUserPreferencesTransaction return 'user'; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getApplicationTransactionType() { return PhabricatorUserPreferencesPHIDType::TYPECONST; } diff --git a/src/applications/slowvote/controller/PhabricatorSlowvoteVoteController.php b/src/applications/slowvote/controller/PhabricatorSlowvoteVoteController.php index 62913b09e3..e1a1b9df34 100644 --- a/src/applications/slowvote/controller/PhabricatorSlowvoteVoteController.php +++ b/src/applications/slowvote/controller/PhabricatorSlowvoteVoteController.php @@ -37,6 +37,19 @@ final class PhabricatorSlowvoteVoteController $method = $poll->getMethod(); $is_plurality = ($method == PhabricatorSlowvotePoll::METHOD_PLURALITY); + if (!$votes) { + if ($is_plurality) { + $message = pht('You must vote for something.'); + } else { + $message = pht('You must vote for at least one option.'); + } + + return $this->newDialog() + ->setTitle(pht('Stand For Something')) + ->appendParagraph($message) + ->addCancelButton($poll->getURI()); + } + if ($is_plurality && count($votes) > 1) { throw new Exception( pht('In this poll, you may only vote for one option.')); @@ -52,23 +65,39 @@ final class PhabricatorSlowvoteVoteController } } - foreach ($old_votes as $old_vote) { - if (!idx($votes, $old_vote->getOptionID(), false)) { + $poll->openTransaction(); + $poll->beginReadLocking(); + + $poll->reload(); + + $old_votes = id(new PhabricatorSlowvoteChoice())->loadAllWhere( + 'pollID = %d AND authorPHID = %s', + $poll->getID(), + $viewer->getPHID()); + $old_votes = mpull($old_votes, null, 'getOptionID'); + + foreach ($old_votes as $old_vote) { + if (idx($votes, $old_vote->getOptionID())) { + continue; + } + $old_vote->delete(); } - } - foreach ($votes as $vote) { - if (idx($old_votes, $vote, false)) { - continue; + foreach ($votes as $vote) { + if (idx($old_votes, $vote)) { + continue; + } + + id(new PhabricatorSlowvoteChoice()) + ->setAuthorPHID($viewer->getPHID()) + ->setPollID($poll->getID()) + ->setOptionID($vote) + ->save(); } - id(new PhabricatorSlowvoteChoice()) - ->setAuthorPHID($viewer->getPHID()) - ->setPollID($poll->getID()) - ->setOptionID($vote) - ->save(); - } + $poll->endReadLocking(); + $poll->saveTransaction(); return id(new AphrontRedirectResponse()) ->setURI($poll->getURI()); diff --git a/src/applications/spaces/storage/PhabricatorSpacesNamespaceTransaction.php b/src/applications/spaces/storage/PhabricatorSpacesNamespaceTransaction.php index 0f50a870f6..bac0ea636f 100644 --- a/src/applications/spaces/storage/PhabricatorSpacesNamespaceTransaction.php +++ b/src/applications/spaces/storage/PhabricatorSpacesNamespaceTransaction.php @@ -11,10 +11,6 @@ final class PhabricatorSpacesNamespaceTransaction return PhabricatorSpacesNamespacePHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getBaseTransactionClass() { return 'PhabricatorSpacesNamespaceTransactionType'; } diff --git a/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php b/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php index 941c0c5811..747e4e98f8 100644 --- a/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php +++ b/src/applications/subscriptions/controller/PhabricatorSubscriptionsEditController.php @@ -47,15 +47,6 @@ final class PhabricatorSubscriptionsEditController $handle->getURI()); } - if (!PhabricatorPolicyFilter::canInteract($viewer, $object)) { - $lock = PhabricatorEditEngineLock::newForObject($viewer, $object); - - $dialog = $this->newDialog() - ->addCancelButton($handle->getURI()); - - return $lock->willBlockUserInteractionWithDialog($dialog); - } - if ($object instanceof PhabricatorApplicationTransactionInterface) { if ($is_add) { $xaction_value = array( diff --git a/src/applications/subscriptions/events/PhabricatorSubscriptionsUIEventListener.php b/src/applications/subscriptions/events/PhabricatorSubscriptionsUIEventListener.php index caf860117e..2077160b7c 100644 --- a/src/applications/subscriptions/events/PhabricatorSubscriptionsUIEventListener.php +++ b/src/applications/subscriptions/events/PhabricatorSubscriptionsUIEventListener.php @@ -73,24 +73,20 @@ final class PhabricatorSubscriptionsUIEventListener ->setName(pht('Automatically Subscribed')) ->setIcon('fa-check-circle lightgreytext'); } else { - $can_interact = PhabricatorPolicyFilter::canInteract($user, $object); - if ($is_subscribed) { $sub_action = id(new PhabricatorActionView()) ->setWorkflow(true) ->setRenderAsForm(true) ->setHref('/subscriptions/delete/'.$object->getPHID().'/') ->setName(pht('Unsubscribe')) - ->setIcon('fa-minus-circle') - ->setDisabled(!$can_interact); + ->setIcon('fa-minus-circle'); } else { $sub_action = id(new PhabricatorActionView()) ->setWorkflow(true) ->setRenderAsForm(true) ->setHref('/subscriptions/add/'.$object->getPHID().'/') ->setName(pht('Subscribe')) - ->setIcon('fa-plus-circle') - ->setDisabled(!$can_interact); + ->setIcon('fa-plus-circle'); } if (!$user->isLoggedIn()) { diff --git a/src/applications/system/engine/PhabricatorDefaultUnlockEngine.php b/src/applications/system/engine/PhabricatorDefaultUnlockEngine.php new file mode 100644 index 0000000000..624191ad21 --- /dev/null +++ b/src/applications/system/engine/PhabricatorDefaultUnlockEngine.php @@ -0,0 +1,4 @@ +newUnlockEngine(); + } else { + $engine = new PhabricatorDefaultUnlockEngine(); + } + + return $engine; + } + + public function newUnlockViewTransactions($object, $user) { + $type_view = PhabricatorTransactions::TYPE_VIEW_POLICY; + + if (!$this->canApplyTransactionType($object, $type_view)) { + throw new Exception( + pht( + 'Object view policy can not be unlocked because this object '. + 'does not have a mutable view policy.')); + } + + return array( + $this->newTransaction($object) + ->setTransactionType($type_view) + ->setNewValue($user->getPHID()), + ); + } + + public function newUnlockEditTransactions($object, $user) { + $type_edit = PhabricatorTransactions::TYPE_EDIT_POLICY; + + if (!$this->canApplyTransactionType($object, $type_edit)) { + throw new Exception( + pht( + 'Object edit policy can not be unlocked because this object '. + 'does not have a mutable edit policy.')); + } + + return array( + $this->newTransaction($object) + ->setTransactionType($type_edit) + ->setNewValue($user->getPHID()), + ); + } + + public function newUnlockOwnerTransactions($object, $user) { + throw new Exception( + pht( + 'Object owner can not be unlocked: the unlocking engine ("%s") for '. + 'this object does not implement an owner unlocking mechanism.', + get_class($this))); + } + + final protected function canApplyTransactionType($object, $type) { + $xaction_types = $object->getApplicationTransactionEditor() + ->getTransactionTypesForObject($object); + + $xaction_types = array_fuse($xaction_types); + + return isset($xaction_types[$type]); + } + + final protected function newTransaction($object) { + return $object->getApplicationTransactionTemplate(); + } + + +} diff --git a/src/applications/system/interface/PhabricatorUnlockableInterface.php b/src/applications/system/interface/PhabricatorUnlockableInterface.php new file mode 100644 index 0000000000..1a95215e8c --- /dev/null +++ b/src/applications/system/interface/PhabricatorUnlockableInterface.php @@ -0,0 +1,18 @@ +>>UnlockEngine(); + } + +*/ diff --git a/src/applications/transactions/conduit/TransactionSearchConduitAPIMethod.php b/src/applications/transactions/conduit/TransactionSearchConduitAPIMethod.php index 0edc0b3f5a..4ab5de519e 100644 --- a/src/applications/transactions/conduit/TransactionSearchConduitAPIMethod.php +++ b/src/applications/transactions/conduit/TransactionSearchConduitAPIMethod.php @@ -8,15 +8,58 @@ final class TransactionSearchConduitAPIMethod } public function getMethodDescription() { - return pht('Read transactions for an object.'); + return pht('Read transactions and comments for an object.'); } - public function getMethodStatus() { - return self::METHOD_STATUS_UNSTABLE; - } + public function getMethodDocumentation() { + $markup = pht(<<.// Find specific transactions by PHID. This + is most likely to be useful if you're responding to a webhook notification + and want to inspect only the related events. + - `authorPHIDs` //Optional list.// Find transactions with particular + authors. + +Transaction Format +================== + +Each transaction has custom data describing what the transaction did. The +format varies from transaction to transaction. The easiest way to figure out +exactly what a particular transaction looks like is to make the associated kind +of edit to a test object, then query that object. + +Not all transactions have data: by default, transactions have a `null` "type" +and no additional data. This API does not expose raw transaction data because +some of it is internal, oddly named, misspelled, confusing, not useful, or +could create security or policy problems to expose directly. + +New transactions are exposed (with correctly spelled, comprehensible types and +useful, reasonable fields) as we become aware of use cases for them. + +EOREMARKUP + ); + + $markup = $this->newRemarkupDocumentationView($markup); + + return id(new PHUIObjectBoxView()) + ->setCollapsed(true) + ->setBackground(PHUIObjectBoxView::BLUE_PROPERTY) + ->setHeaderText(pht('Method Details')) + ->appendChild($markup); } protected function defineParamTypes() { @@ -73,24 +116,8 @@ final class TransactionSearchConduitAPIMethod ->setViewer($viewer); $constraints = $request->getValue('constraints', array()); - PhutilTypeSpec::checkMap( - $constraints, - array( - 'phids' => 'optional list', - )); - $with_phids = idx($constraints, 'phids'); - - if ($with_phids === array()) { - throw new Exception( - pht( - 'Constraint "phids" to "transaction.search" requires nonempty list, '. - 'empty list provided.')); - } - - if ($with_phids) { - $xaction_query->withPHIDs($with_phids); - } + $xaction_query = $this->applyConstraints($constraints, $xaction_query); $xactions = $xaction_query->executeWithCursorPager($pager); @@ -218,6 +245,14 @@ final class TransactionSearchConduitAPIMethod case PhabricatorTransactions::TYPE_CREATE: $type = 'create'; break; + case PhabricatorTransactions::TYPE_EDGE: + switch ($xaction->getMetadataValue('edge:type')) { + case PhabricatorProjectObjectHasProjectEdgeType::EDGECONST: + $type = 'projects'; + $fields = $this->newEdgeTransactionFields($xaction); + break; + } + break; } } @@ -240,4 +275,69 @@ final class TransactionSearchConduitAPIMethod return $this->addPagerResults($results, $pager); } + + private function applyConstraints( + array $constraints, + PhabricatorApplicationTransactionQuery $query) { + + PhutilTypeSpec::checkMap( + $constraints, + array( + 'phids' => 'optional list', + 'authorPHIDs' => 'optional list', + )); + + $with_phids = idx($constraints, 'phids'); + + if ($with_phids === array()) { + throw new Exception( + pht( + 'Constraint "phids" to "transaction.search" requires nonempty list, '. + 'empty list provided.')); + } + + if ($with_phids) { + $query->withPHIDs($with_phids); + } + + $with_authors = idx($constraints, 'authorPHIDs'); + if ($with_authors === array()) { + throw new Exception( + pht( + 'Constraint "authorPHIDs" to "transaction.search" requires '. + 'nonempty list, empty list provided.')); + } + + if ($with_authors) { + $query->withAuthorPHIDs($with_authors); + } + + return $query; + } + + private function newEdgeTransactionFields( + PhabricatorApplicationTransaction $xaction) { + + $record = PhabricatorEdgeChangeRecord::newFromTransaction($xaction); + + $operations = array(); + foreach ($record->getAddedPHIDs() as $phid) { + $operations[] = array( + 'operation' => 'add', + 'phid' => $phid, + ); + } + + foreach ($record->getRemovedPHIDs() as $phid) { + $operations[] = array( + 'operation' => 'remove', + 'phid' => $phid, + ); + } + + return array( + 'operations' => $operations, + ); + } + } diff --git a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php index a93f16a688..1682a7d136 100644 --- a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php +++ b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentEditController.php @@ -29,7 +29,9 @@ final class PhabricatorApplicationTransactionCommentEditController $handles = $viewer->loadHandles(array($phid)); $obj_handle = $handles[$phid]; - if ($request->isDialogFormPost()) { + $done_uri = $obj_handle->getURI(); + + if ($request->isFormOrHisecPost()) { $text = $request->getStr('text'); $comment = $xaction->getApplicationTransactionCommentObject(); @@ -41,29 +43,42 @@ final class PhabricatorApplicationTransactionCommentEditController $editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($viewer) ->setContentSource(PhabricatorContentSource::newFromRequest($request)) + ->setRequest($request) + ->setCancelURI($done_uri) ->applyEdit($xaction, $comment); if ($request->isAjax()) { return id(new AphrontAjaxResponse())->setContent(array()); } else { - return id(new AphrontReloadResponse())->setURI($obj_handle->getURI()); + return id(new AphrontReloadResponse())->setURI($done_uri); } } + $errors = array(); + if ($xaction->getIsMFATransaction()) { + $message = pht( + 'This comment was signed with MFA, so you will be required to '. + 'provide MFA credentials to make changes.'); + + $errors[] = id(new PHUIInfoView()) + ->setSeverity(PHUIInfoView::SEVERITY_MFA) + ->setErrors(array($message)); + } + $form = id(new AphrontFormView()) ->setUser($viewer) ->setFullWidth(true) ->appendControl( id(new PhabricatorRemarkupControl()) - ->setName('text') - ->setValue($xaction->getComment()->getContent())); + ->setName('text') + ->setValue($xaction->getComment()->getContent())); return $this->newDialog() ->setTitle(pht('Edit Comment')) - ->addHiddenInput('anchor', $request->getStr('anchor')) + ->appendChild($errors) ->appendForm($form) ->addSubmitButton(pht('Save Changes')) - ->addCancelButton($obj_handle->getURI()); + ->addCancelButton($done_uri); } } diff --git a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php index c52b087273..381dfe1176 100644 --- a/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php +++ b/src/applications/transactions/controller/PhabricatorApplicationTransactionCommentRemoveController.php @@ -30,20 +30,24 @@ final class PhabricatorApplicationTransactionCommentRemoveController ->withPHIDs(array($obj_phid)) ->executeOne(); - if ($request->isDialogFormPost()) { + $done_uri = $obj_handle->getURI(); + + if ($request->isFormOrHisecPost()) { $comment = $xaction->getApplicationTransactionCommentObject() ->setContent('') ->setIsRemoved(true); $editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($viewer) + ->setRequest($request) + ->setCancelURI($done_uri) ->setContentSource(PhabricatorContentSource::newFromRequest($request)) ->applyEdit($xaction, $comment); if ($request->isAjax()) { return id(new AphrontAjaxResponse())->setContent(array()); } else { - return id(new AphrontReloadResponse())->setURI($obj_handle->getURI()); + return id(new AphrontReloadResponse())->setURI($done_uri); } } @@ -54,7 +58,6 @@ final class PhabricatorApplicationTransactionCommentRemoveController ->setTitle(pht('Remove Comment')); $dialog - ->addHiddenInput('anchor', $request->getStr('anchor')) ->appendParagraph( pht( "Removing a comment prevents anyone (including you) from reading ". @@ -65,7 +68,7 @@ final class PhabricatorApplicationTransactionCommentRemoveController $dialog ->addSubmitButton(pht('Remove Comment')) - ->addCancelButton($obj_handle->getURI()); + ->addCancelButton($done_uri); return $dialog; } diff --git a/src/applications/transactions/editengine/PhabricatorEditEngine.php b/src/applications/transactions/editengine/PhabricatorEditEngine.php index feb783e724..0986247454 100644 --- a/src/applications/transactions/editengine/PhabricatorEditEngine.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngine.php @@ -165,14 +165,29 @@ abstract class PhabricatorEditEngine $extensions = array(); } + // See T13248. Create a template object to provide to extensions. We + // adjust the template to have the intended subtype, so that extensions + // may change behavior based on the form subtype. + + $template_object = clone $object; + if ($this->getIsCreate()) { + if ($this->supportsSubtypes()) { + $config = $this->getEditEngineConfiguration(); + $subtype = $config->getSubtype(); + $template_object->setSubtype($subtype); + } + } + foreach ($extensions as $extension) { $extension->setViewer($viewer); - if (!$extension->supportsObject($this, $object)) { + if (!$extension->supportsObject($this, $template_object)) { continue; } - $extension_fields = $extension->buildCustomEditFields($this, $object); + $extension_fields = $extension->buildCustomEditFields( + $this, + $template_object); // TODO: Validate this in more detail with a more tailored error. assert_instances_of($extension_fields, 'PhabricatorEditField'); @@ -551,6 +566,18 @@ abstract class PhabricatorEditEngine return $this->getObjectViewURI($object); } + /** + * @task uri + */ + public function getCreateURI($form_key) { + try { + $create_uri = $this->getEditURI(null, "form/{$form_key}/"); + } catch (Exception $ex) { + $create_uri = null; + } + + return $create_uri; + } /** * @task uri @@ -1279,8 +1306,7 @@ abstract class PhabricatorEditEngine $fields = $this->willBuildEditForm($object, $fields); - $request_path = $request->getRequestURI() - ->setQueryParams(array()); + $request_path = $request->getPath(); $form = id(new AphrontFormView()) ->setUser($viewer) @@ -1542,8 +1568,7 @@ abstract class PhabricatorEditEngine $config_uri = $config->getCreateURI(); if ($parameters) { - $config_uri = (string)id(new PhutilURI($config_uri)) - ->setQueryParams($parameters); + $config_uri = (string)new PhutilURI($config_uri, $parameters); } $specs[] = array( diff --git a/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php b/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php index 9e754a3ca8..6e1d1de115 100644 --- a/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php +++ b/src/applications/transactions/editengine/PhabricatorEditEngineSubtype.php @@ -13,6 +13,7 @@ final class PhabricatorEditEngineSubtype private $color; private $childSubtypes = array(); private $childIdentifiers = array(); + private $fieldConfiguration = array(); public function setKey($key) { $this->key = $key; @@ -94,6 +95,17 @@ final class PhabricatorEditEngineSubtype return $view; } + public function setSubtypeFieldConfiguration( + $subtype_key, + array $configuration) { + $this->fieldConfiguration[$subtype_key] = $configuration; + return $this; + } + + public function getSubtypeFieldConfiguration($subtype_key) { + return idx($this->fieldConfiguration, $subtype_key); + } + public static function validateSubtypeKey($subtype) { if (strlen($subtype) > 64) { throw new Exception( @@ -139,6 +151,7 @@ final class PhabricatorEditEngineSubtype 'color' => 'optional string', 'icon' => 'optional string', 'children' => 'optional map', + 'fields' => 'optional map', )); $key = $value['key']; @@ -183,6 +196,18 @@ final class PhabricatorEditEngineSubtype 'or the other, but not both.')); } } + + $fields = idx($value, 'fields'); + if ($fields) { + foreach ($fields as $field_key => $configuration) { + PhutilTypeSpec::checkMap( + $configuration, + array( + 'disabled' => 'optional bool', + 'name' => 'optional string', + )); + } + } } if (!isset($map[self::SUBTYPE_DEFAULT])) { @@ -233,6 +258,15 @@ final class PhabricatorEditEngineSubtype $subtype->setChildFormIdentifiers($child_forms); } + $field_configurations = idx($entry, 'fields'); + if ($field_configurations) { + foreach ($field_configurations as $field_key => $field_configuration) { + $subtype->setSubtypeFieldConfiguration( + $field_key, + $field_configuration); + } + } + $map[$key] = $subtype; } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php index f9db0e238e..d963ea2ecb 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionCommentEditor.php @@ -5,6 +5,9 @@ final class PhabricatorApplicationTransactionCommentEditor private $contentSource; private $actingAsPHID; + private $request; + private $cancelURI; + private $isNewComment; public function setActingAsPHID($acting_as_phid) { $this->actingAsPHID = $acting_as_phid; @@ -27,6 +30,33 @@ final class PhabricatorApplicationTransactionCommentEditor return $this->contentSource; } + public function setRequest(AphrontRequest $request) { + $this->request = $request; + return $this; + } + + public function getRequest() { + return $this->request; + } + + public function setCancelURI($cancel_uri) { + $this->cancelURI = $cancel_uri; + return $this; + } + + public function getCancelURI() { + return $this->cancelURI; + } + + public function setIsNewComment($is_new) { + $this->isNewComment = $is_new; + return $this; + } + + public function getIsNewComment() { + return $this->isNewComment; + } + /** * Edit a transaction's comment. This method effects the required create, * update or delete to set the transaction's comment to the provided comment. @@ -39,6 +69,8 @@ final class PhabricatorApplicationTransactionCommentEditor $actor = $this->requireActor(); + $this->applyMFAChecks($xaction, $comment); + $comment->setContentSource($this->getContentSource()); $comment->setAuthorPHID($this->getActingAsPHID()); @@ -160,5 +192,94 @@ final class PhabricatorApplicationTransactionCommentEditor } } + private function applyMFAChecks( + PhabricatorApplicationTransaction $xaction, + PhabricatorApplicationTransactionComment $comment) { + $actor = $this->requireActor(); + + // We don't do any MFA checks here when you're creating a comment for the + // first time (the parent editor handles them for us), so we can just bail + // out if this is the creation flow. + if ($this->getIsNewComment()) { + return; + } + + $request = $this->getRequest(); + if (!$request) { + throw new PhutilInvalidStateException('setRequest'); + } + + $cancel_uri = $this->getCancelURI(); + if (!strlen($cancel_uri)) { + throw new PhutilInvalidStateException('setCancelURI'); + } + + // If you're deleting a comment, we try to prompt you for MFA if you have + // it configured, but do not require that you have it configured. In most + // cases, this is administrators removing content. + + // See PHI1173. If you're editing a comment you authored and the original + // comment was signed with MFA, you MUST have MFA on your account and you + // MUST sign the edit with MFA. Otherwise, we can end up with an MFA badge + // on different content than what was signed. + + $want_mfa = false; + $need_mfa = false; + + if ($comment->getIsRemoved()) { + // Try to prompt on removal. + $want_mfa = true; + } + + if ($xaction->getIsMFATransaction()) { + if ($actor->getPHID() === $xaction->getAuthorPHID()) { + // Strictly require MFA if the original transaction was signed and + // you're the author. + $want_mfa = true; + $need_mfa = true; + } + } + + if (!$want_mfa) { + return; + } + + if ($need_mfa) { + $factors = id(new PhabricatorAuthFactorConfigQuery()) + ->setViewer($actor) + ->withUserPHIDs(array($this->getActingAsPHID())) + ->withFactorProviderStatuses( + array( + PhabricatorAuthFactorProviderStatus::STATUS_ACTIVE, + PhabricatorAuthFactorProviderStatus::STATUS_DEPRECATED, + )) + ->execute(); + if (!$factors) { + $error = new PhabricatorApplicationTransactionValidationError( + $xaction->getTransactionType(), + pht('No MFA'), + pht( + 'This comment was signed with MFA, so edits to it must also be '. + 'signed with MFA. You do not have any MFA factors attached to '. + 'your account, so you can not sign this edit. Add MFA to your '. + 'account in Settings.'), + $xaction); + + throw new PhabricatorApplicationTransactionValidationException( + array( + $error, + )); + } + } + + $workflow_key = sprintf( + 'comment.edit(%s, %d)', + $xaction->getPHID(), + $xaction->getComment()->getID()); + + $hisec_token = id(new PhabricatorAuthSessionEngine()) + ->setWorkflowKey($workflow_key) + ->requireHighSecurityToken($actor, $request, $cancel_uri); + } } diff --git a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php index 37ed1547fb..3b54655f57 100644 --- a/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php +++ b/src/applications/transactions/editor/PhabricatorApplicationTransactionEditor.php @@ -72,7 +72,7 @@ abstract class PhabricatorApplicationTransactionEditor private $mailShouldSend = false; private $modularTypes; private $silent; - private $mustEncrypt; + private $mustEncrypt = array(); private $stampTemplates = array(); private $mailStamps = array(); private $oldTo = array(); @@ -90,6 +90,11 @@ abstract class PhabricatorApplicationTransactionEditor private $cancelURI; private $extensions; + private $parentEditor; + private $subEditors = array(); + private $publishableObject; + private $publishableTransactions; + const STORAGE_ENCODING_BINARY = 'binary'; /** @@ -360,12 +365,7 @@ abstract class PhabricatorApplicationTransactionEditor } if ($template) { - try { - $comment = $template->getApplicationTransactionCommentObject(); - } catch (PhutilMethodNotImplementedException $ex) { - $comment = null; - } - + $comment = $template->getApplicationTransactionCommentObject(); if ($comment) { $types[] = PhabricatorTransactions::TYPE_COMMENT; } @@ -1113,13 +1113,24 @@ abstract class PhabricatorApplicationTransactionEditor $comment_editor = id(new PhabricatorApplicationTransactionCommentEditor()) ->setActor($actor) ->setActingAsPHID($this->getActingAsPHID()) - ->setContentSource($this->getContentSource()); + ->setContentSource($this->getContentSource()) + ->setIsNewComment(true); if (!$transaction_open) { $object->openTransaction(); $transaction_open = true; } + // We can technically test any object for CAN_INTERACT, but we can + // run into some issues in doing so (for example, in project unit tests). + // For now, only test for CAN_INTERACT if the object is explicitly a + // lockable object. + + $was_locked = false; + if ($object instanceof PhabricatorEditEngineLockableInterface) { + $was_locked = !PhabricatorPolicyFilter::canInteract($actor, $object); + } + foreach ($xactions as $xaction) { $this->applyInternalEffects($object, $xaction); } @@ -1137,6 +1148,10 @@ abstract class PhabricatorApplicationTransactionEditor } foreach ($xactions as $xaction) { + if ($was_locked) { + $xaction->setIsLockOverrideTransaction(true); + } + $xaction->setObjectPHID($object->getPHID()); if ($xaction->getComment()) { $xaction->setPHID($xaction->generatePHID()); @@ -1263,10 +1278,9 @@ abstract class PhabricatorApplicationTransactionEditor $herald_source = PhabricatorContentSource::newForSource( PhabricatorHeraldContentSource::SOURCECONST); - $herald_editor = newv(get_class($this), array()) + $herald_editor = $this->newEditorCopy() ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) - ->setParentMessageID($this->getParentMessageID()) ->setIsHeraldEditor(true) ->setActor($herald_actor) ->setActingAsPHID($herald_phid) @@ -1321,6 +1335,38 @@ abstract class PhabricatorApplicationTransactionEditor } $this->heraldHeader = $herald_header; + // See PHI1134. If we're a subeditor, we don't publish information about + // the edit yet. Our parent editor still needs to finish applying + // transactions and execute Herald, which may change the information we + // publish. + + // For example, Herald actions may change the parent object's title or + // visibility, or Herald may apply rules like "Must Encrypt" that affect + // email. + + // Once the parent finishes work, it will queue its own publish step and + // then queue publish steps for its children. + + $this->publishableObject = $object; + $this->publishableTransactions = $xactions; + if (!$this->parentEditor) { + $this->queuePublishing(); + } + + return $xactions; + } + + final private function queuePublishing() { + $object = $this->publishableObject; + $xactions = $this->publishableTransactions; + + if (!$object) { + throw new Exception( + pht( + 'Editor method "queuePublishing()" was called, but no publishable '. + 'object is present. This Editor is not ready to publish.')); + } + // We're going to compute some of the data we'll use to publish these // transactions here, before queueing a worker. // @@ -1383,9 +1429,11 @@ abstract class PhabricatorApplicationTransactionEditor 'priority' => PhabricatorWorker::PRIORITY_ALERTS, )); - $this->flushTransactionQueue($object); + foreach ($this->subEditors as $sub_editor) { + $sub_editor->queuePublishing(); + } - return $xactions; + $this->flushTransactionQueue($object); } protected function didCatchDuplicateKeyException( @@ -1639,9 +1687,48 @@ abstract class PhabricatorApplicationTransactionEditor // don't enforce it here. return null; case PhabricatorTransactions::TYPE_SUBSCRIBERS: - // TODO: Removing subscribers other than yourself should probably - // require CAN_EDIT permission. You can do this via the API but - // generally can not via the web interface. + // Anyone can subscribe to or unsubscribe from anything they can view, + // with no other permissions. + + $old = array_fuse($xaction->getOldValue()); + $new = array_fuse($xaction->getNewValue()); + + // To remove users other than yourself, you must be able to edit the + // object. + $rem = array_diff_key($old, $new); + foreach ($rem as $phid) { + if ($phid !== $this->getActingAsPHID()) { + return PhabricatorPolicyCapability::CAN_EDIT; + } + } + + // To add users other than yourself, you must be able to interact. + // This allows "@mentioning" users to work as long as you can comment + // on objects. + + // If you can edit, we return that policy instead so that you can + // override a soft lock and still make edits. + + // TODO: This is a little bit hacky. We really want to be able to say + // "this requires either interact or edit", but there's currently no + // way to specify this kind of requirement. + + $can_edit = PhabricatorPolicyFilter::hasCapability( + $this->getActor(), + $this->object, + PhabricatorPolicyCapability::CAN_EDIT); + + $add = array_diff_key($new, $old); + foreach ($add as $phid) { + if ($phid !== $this->getActingAsPHID()) { + if ($can_edit) { + return PhabricatorPolicyCapability::CAN_EDIT; + } else { + return PhabricatorPolicyCapability::CAN_INTERACT; + } + } + } + return null; case PhabricatorTransactions::TYPE_TOKEN: // TODO: This technically requires CAN_INTERACT, like comments. @@ -1768,31 +1855,52 @@ abstract class PhabricatorApplicationTransactionEditor $users = mpull($users, null, 'getPHID'); foreach ($phids as $key => $phid) { - // Do not subscribe mentioned users - // who do not have VIEW Permissions - if ($object instanceof PhabricatorPolicyInterface - && !PhabricatorPolicyFilter::hasCapability( - $users[$phid], - $object, - PhabricatorPolicyCapability::CAN_VIEW) - ) { + $user = idx($users, $phid); + + // Don't subscribe invalid users. + if (!$user) { unset($phids[$key]); - } else { - if ($object->isAutomaticallySubscribed($phid)) { + continue; + } + + // Don't subscribe bots that get mentioned. If users truly intend + // to subscribe them, they can add them explicitly, but it's generally + // not useful to subscribe bots to objects. + if ($user->getIsSystemAgent()) { + unset($phids[$key]); + continue; + } + + // Do not subscribe mentioned users who do not have permission to see + // the object. + if ($object instanceof PhabricatorPolicyInterface) { + $can_view = PhabricatorPolicyFilter::hasCapability( + $user, + $object, + PhabricatorPolicyCapability::CAN_VIEW); + if (!$can_view) { unset($phids[$key]); + continue; } } + + // Don't subscribe users who are already automatically subscribed. + if ($object->isAutomaticallySubscribed($phid)) { + unset($phids[$key]); + continue; + } } + $phids = array_values($phids); } - // No else here to properly return null should we unset all subscriber + if (!$phids) { return null; } - $xaction = newv(get_class(head($xactions)), array()); - $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS); - $xaction->setNewValue(array('+' => $phids)); + $xaction = $object->getApplicationTransactionTemplate() + ->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS) + ->setNewValue(array('+' => $phids)); return $xaction; } @@ -2831,6 +2939,24 @@ abstract class PhabricatorApplicationTransactionEditor } } + $actor = $this->getActor(); + + $user = id(new PhabricatorPeopleQuery()) + ->setViewer($actor) + ->withPHIDs(array($actor_phid)) + ->executeOne(); + if (!$user) { + return $xactions; + } + + // When a bot acts (usually via the API), don't automatically subscribe + // them as a side effect. They can always subscribe explicitly if they + // want, and bot subscriptions normally just clutter things up since bots + // usually do not read email. + if ($user->getIsSystemAgent()) { + return $xactions; + } + $xaction = newv(get_class(head($xactions)), array()); $xaction->setTransactionType(PhabricatorTransactions::TYPE_SUBSCRIBERS); $xaction->setNewValue(array('+' => array($actor_phid))); @@ -3734,9 +3860,19 @@ abstract class PhabricatorApplicationTransactionEditor $this->mustEncrypt = $adapter->getMustEncryptReasons(); + // See PHI1134. Propagate "Must Encrypt" state to sub-editors. + foreach ($this->subEditors as $sub_editor) { + $sub_editor->mustEncrypt = $this->mustEncrypt; + } + + $apply_xactions = $this->didApplyHeraldRules($object, $adapter, $xscript); + assert_instances_of($apply_xactions, 'PhabricatorApplicationTransaction'); + + $queue_xactions = $adapter->getQueuedTransactions(); + return array_merge( - $this->didApplyHeraldRules($object, $adapter, $xscript), - $adapter->getQueuedTransactions()); + array_values($apply_xactions), + array_values($queue_xactions)); } protected function didApplyHeraldRules( @@ -3945,15 +4081,10 @@ abstract class PhabricatorApplicationTransactionEditor ->setOldValue($old_phids) ->setNewValue($new_phids); - $editor + $editor = $this->newSubEditor($editor) ->setContinueOnNoEffect(true) ->setContinueOnMissingFields(true) - ->setParentMessageID($this->getParentMessageID()) - ->setIsInverseEdgeEditor(true) - ->setIsSilent($this->getIsSilent()) - ->setActor($this->requireActor()) - ->setActingAsPHID($this->getActingAsPHID()) - ->setContentSource($this->getContentSource()); + ->setIsInverseEdgeEditor(true); $editor->applyTransactions($node, array($template)); } @@ -4462,23 +4593,41 @@ abstract class PhabricatorApplicationTransactionEditor $xactions = $this->transactionQueue; $this->transactionQueue = array(); - $editor = $this->newQueueEditor(); + $editor = $this->newEditorCopy(); return $editor->applyTransactions($object, $xactions); } - private function newQueueEditor() { - $editor = id(newv(get_class($this), array())) + final protected function newSubEditor( + PhabricatorApplicationTransactionEditor $template = null) { + $editor = $this->newEditorCopy($template); + + $editor->parentEditor = $this; + $this->subEditors[] = $editor; + + return $editor; + } + + private function newEditorCopy( + PhabricatorApplicationTransactionEditor $template = null) { + if ($template === null) { + $template = newv(get_class($this), array()); + } + + $editor = id(clone $template) ->setActor($this->getActor()) ->setContentSource($this->getContentSource()) ->setContinueOnNoEffect($this->getContinueOnNoEffect()) ->setContinueOnMissingFields($this->getContinueOnMissingFields()) + ->setParentMessageID($this->getParentMessageID()) ->setIsSilent($this->getIsSilent()); if ($this->actingAsPHID !== null) { $editor->setActingAsPHID($this->actingAsPHID); } + $editor->mustEncrypt = $this->mustEncrypt; + return $editor; } diff --git a/src/applications/transactions/engineextension/PhabricatorCommentEditEngineExtension.php b/src/applications/transactions/engineextension/PhabricatorCommentEditEngineExtension.php index 0d20533798..7d80082cb2 100644 --- a/src/applications/transactions/engineextension/PhabricatorCommentEditEngineExtension.php +++ b/src/applications/transactions/engineextension/PhabricatorCommentEditEngineExtension.php @@ -23,12 +23,7 @@ final class PhabricatorCommentEditEngineExtension PhabricatorApplicationTransactionInterface $object) { $xaction = $object->getApplicationTransactionTemplate(); - - try { - $comment = $xaction->getApplicationTransactionCommentObject(); - } catch (PhutilMethodNotImplementedException $ex) { - $comment = null; - } + $comment = $xaction->getApplicationTransactionCommentObject(); return (bool)$comment; } diff --git a/src/applications/transactions/query/PhabricatorApplicationTransactionQuery.php b/src/applications/transactions/query/PhabricatorApplicationTransactionQuery.php index f15522a087..1db622163d 100644 --- a/src/applications/transactions/query/PhabricatorApplicationTransactionQuery.php +++ b/src/applications/transactions/query/PhabricatorApplicationTransactionQuery.php @@ -3,6 +3,7 @@ abstract class PhabricatorApplicationTransactionQuery extends PhabricatorCursorPagedPolicyAwareQuery { + private $ids; private $phids; private $objectPHIDs; private $authorPHIDs; @@ -35,6 +36,11 @@ abstract class PhabricatorApplicationTransactionQuery abstract public function getTemplateApplicationTransaction(); + public function withIDs(array $ids) { + $this->ids = $ids; + return $this; + } + public function withPHIDs(array $phids) { $this->phids = $phids; return $this; @@ -157,6 +163,13 @@ abstract class PhabricatorApplicationTransactionQuery protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = parent::buildWhereClauseParts($conn); + if ($this->ids !== null) { + $where[] = qsprintf( + $conn, + 'x.id IN (%Ld)', + $this->ids); + } + if ($this->phids !== null) { $where[] = qsprintf( $conn, diff --git a/src/applications/transactions/storage/PhabricatorApplicationTransaction.php b/src/applications/transactions/storage/PhabricatorApplicationTransaction.php index 6d047fc823..d71728a01f 100644 --- a/src/applications/transactions/storage/PhabricatorApplicationTransaction.php +++ b/src/applications/transactions/storage/PhabricatorApplicationTransaction.php @@ -76,7 +76,7 @@ abstract class PhabricatorApplicationTransaction } public function getApplicationTransactionCommentObject() { - throw new PhutilMethodNotImplementedException(); + return null; } public function getMetadataValue($key, $default = null) { @@ -169,6 +169,14 @@ abstract class PhabricatorApplicationTransaction return (bool)$this->getMetadataValue('core.mfa', false); } + public function setIsLockOverrideTransaction($override) { + return $this->setMetadataValue('core.lock-override', $override); + } + + public function getIsLockOverrideTransaction() { + return (bool)$this->getMetadataValue('core.lock-override', false); + } + public function attachComment( PhabricatorApplicationTransactionComment $comment) { $this->comment = $comment; @@ -1529,6 +1537,12 @@ abstract class PhabricatorApplicationTransaction return false; } } + + // Don't group lock override and non-override transactions together. + $is_override = $this->getIsLockOverrideTransaction(); + if ($is_override != $xaction->getIsLockOverrideTransaction()) { + return false; + } } return true; @@ -1731,12 +1745,7 @@ abstract class PhabricatorApplicationTransaction PhabricatorDestructionEngine $engine) { $this->openTransaction(); - $comment_template = null; - try { - $comment_template = $this->getApplicationTransactionCommentObject(); - } catch (Exception $ex) { - // Continue; no comments for these transactions. - } + $comment_template = $this->getApplicationTransactionCommentObject(); if ($comment_template) { $comments = $comment_template->loadAllWhere( diff --git a/src/applications/transactions/storage/PhabricatorEditEngineConfiguration.php b/src/applications/transactions/storage/PhabricatorEditEngineConfiguration.php index 6c9f3a50a6..b1919a0ee0 100644 --- a/src/applications/transactions/storage/PhabricatorEditEngineConfiguration.php +++ b/src/applications/transactions/storage/PhabricatorEditEngineConfiguration.php @@ -227,14 +227,7 @@ final class PhabricatorEditEngineConfiguration public function getCreateURI() { $form_key = $this->getIdentifier(); $engine = $this->getEngine(); - - try { - $create_uri = $engine->getEditURI(null, "form/{$form_key}/"); - } catch (Exception $ex) { - $create_uri = null; - } - - return $create_uri; + return $engine->getCreateURI($form_key); } public function getIdentifier() { diff --git a/src/applications/transactions/storage/PhabricatorEditEngineConfigurationTransaction.php b/src/applications/transactions/storage/PhabricatorEditEngineConfigurationTransaction.php index a1c44cc003..8cf7fe5b48 100644 --- a/src/applications/transactions/storage/PhabricatorEditEngineConfigurationTransaction.php +++ b/src/applications/transactions/storage/PhabricatorEditEngineConfigurationTransaction.php @@ -23,10 +23,6 @@ final class PhabricatorEditEngineConfigurationTransaction return PhabricatorEditEngineConfigurationPHIDType::TYPECONST; } - public function getApplicationTransactionCommentObject() { - return null; - } - public function getTitle() { $author_phid = $this->getAuthorPHID(); diff --git a/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php b/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php index f6a27d4bcd..115c7b950e 100644 --- a/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php +++ b/src/applications/transactions/view/PhabricatorApplicationTransactionCommentView.php @@ -198,7 +198,7 @@ final class PhabricatorApplicationTransactionCommentView $viewer = $this->getViewer(); if (!$viewer->isLoggedIn()) { $uri = id(new PhutilURI('/login/')) - ->setQueryParam('next', (string)$this->getRequestURI()); + ->replaceQueryParam('next', (string)$this->getRequestURI()); return id(new PHUIObjectBoxView()) ->setFlush(true) ->appendChild( @@ -254,11 +254,12 @@ final class PhabricatorApplicationTransactionCommentView require_celerity_resource('phui-comment-form-css'); $image_uri = $viewer->getProfileImageURI(); - $image = phutil_tag( + $image = javelin_tag( 'div', array( 'style' => 'background-image: url('.$image_uri.')', - 'class' => 'phui-comment-image visual-only', + 'class' => 'phui-comment-image', + 'aural' => false, )); $wedge = phutil_tag( 'div', diff --git a/src/applications/transactions/view/PhabricatorApplicationTransactionView.php b/src/applications/transactions/view/PhabricatorApplicationTransactionView.php index c007d31fef..837945de27 100644 --- a/src/applications/transactions/view/PhabricatorApplicationTransactionView.php +++ b/src/applications/transactions/view/PhabricatorApplicationTransactionView.php @@ -424,7 +424,8 @@ class PhabricatorApplicationTransactionView extends AphrontView { ->setColor($xaction->getColor()) ->setHideCommentOptions($this->getHideCommentOptions()) ->setIsSilent($xaction->getIsSilentTransaction()) - ->setIsMFA($xaction->getIsMFATransaction()); + ->setIsMFA($xaction->getIsMFATransaction()) + ->setIsLockOverride($xaction->getIsLockOverrideTransaction()); list($token, $token_removed) = $xaction->getToken(); if ($token) { diff --git a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php index 9e905f8cce..2d55c5f663 100644 --- a/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php +++ b/src/applications/typeahead/controller/PhabricatorTypeaheadModularDatasourceController.php @@ -35,7 +35,26 @@ final class PhabricatorTypeaheadModularDatasourceController if (isset($sources[$class])) { $source = $sources[$class]; - $source->setParameters($request->getRequestData()); + + $parameters = array(); + + $raw_parameters = $request->getStr('parameters'); + if (strlen($raw_parameters)) { + try { + $parameters = phutil_json_decode($raw_parameters); + } catch (PhutilJSONParserException $ex) { + return $this->newDialog() + ->setTitle(pht('Invalid Parameters')) + ->appendParagraph( + pht( + 'The HTTP parameter named "parameters" for this request is '. + 'not a valid JSON parameter. JSON is required. Exception: %s', + $ex->getMessage())) + ->addCancelButton('/'); + } + } + + $source->setParameters($parameters); $source->setViewer($viewer); // NOTE: Wrapping the source in a Composite datasource ensures we perform @@ -107,10 +126,20 @@ final class PhabricatorTypeaheadModularDatasourceController $results = array_slice($results, 0, $limit, $preserve_keys = true); if (($offset + (2 * $limit)) < $hard_limit) { $next_uri = id(new PhutilURI($request->getRequestURI())) - ->setQueryParam('offset', $offset + $limit) - ->setQueryParam('q', $query) - ->setQueryParam('raw', $raw_query) - ->setQueryParam('format', 'html'); + ->replaceQueryParam('offset', $offset + $limit) + ->replaceQueryParam('format', 'html'); + + if ($query !== null) { + $next_uri->replaceQueryParam('q', $query); + } else { + $next_uri->removeQueryParam('q'); + } + + if ($raw_query !== null) { + $next_uri->replaceQueryParam('raw', $raw_query); + } else { + $next_uri->removeQueryParam('raw'); + } $next_link = javelin_tag( 'a', @@ -229,7 +258,9 @@ final class PhabricatorTypeaheadModularDatasourceController $parameters = $source->getParameters(); if ($parameters) { $reference_uri = (string)id(new PhutilURI($reference_uri)) - ->setQueryParam('parameters', phutil_json_encode($parameters)); + ->replaceQueryParam( + 'parameters', + phutil_json_encode($parameters)); } $reference_link = phutil_tag( diff --git a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php index 2e369a3f67..e077d7a7ec 100644 --- a/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php +++ b/src/applications/typeahead/datasource/PhabricatorTypeaheadDatasource.php @@ -99,9 +99,9 @@ abstract class PhabricatorTypeaheadDatasource extends Phobject { } public function getDatasourceURI() { - $uri = new PhutilURI('/typeahead/class/'.get_class($this).'/'); - $uri->setQueryParams($this->parameters); - return (string)$uri; + $params = $this->newURIParameters(); + $uri = new PhutilURI('/typeahead/class/'.get_class($this).'/', $params); + return phutil_string_cast($uri); } public function getBrowseURI() { @@ -109,9 +109,21 @@ abstract class PhabricatorTypeaheadDatasource extends Phobject { return null; } - $uri = new PhutilURI('/typeahead/browse/'.get_class($this).'/'); - $uri->setQueryParams($this->parameters); - return (string)$uri; + $params = $this->newURIParameters(); + $uri = new PhutilURI('/typeahead/browse/'.get_class($this).'/', $params); + return phutil_string_cast($uri); + } + + private function newURIParameters() { + if (!$this->parameters) { + return array(); + } + + $map = array( + 'parameters' => phutil_json_encode($this->parameters), + ); + + return $map; } abstract public function getPlaceholderText(); diff --git a/src/applications/typeahead/storage/PhabricatorTypeaheadResult.php b/src/applications/typeahead/storage/PhabricatorTypeaheadResult.php index 14cbe726dc..b13cf351b1 100644 --- a/src/applications/typeahead/storage/PhabricatorTypeaheadResult.php +++ b/src/applications/typeahead/storage/PhabricatorTypeaheadResult.php @@ -19,6 +19,7 @@ final class PhabricatorTypeaheadResult extends Phobject { private $autocomplete; private $attributes = array(); private $phase; + private $availabilityColor; public function setIcon($icon) { $this->icon = $icon; @@ -156,6 +157,7 @@ final class PhabricatorTypeaheadResult extends Phobject { $this->unique ? 1 : null, $this->autocomplete, $this->phase, + $this->availabilityColor, ); while (end($data) === null) { array_pop($data); @@ -222,4 +224,13 @@ final class PhabricatorTypeaheadResult extends Phobject { return $this->phase; } + public function setAvailabilityColor($availability_color) { + $this->availabilityColor = $availability_color; + return $this; + } + + public function getAvailabilityColor() { + return $this->availabilityColor; + } + } diff --git a/src/applications/typeahead/view/PhabricatorTypeaheadTokenView.php b/src/applications/typeahead/view/PhabricatorTypeaheadTokenView.php index 56867d8278..e0a5270e84 100644 --- a/src/applications/typeahead/view/PhabricatorTypeaheadTokenView.php +++ b/src/applications/typeahead/view/PhabricatorTypeaheadTokenView.php @@ -14,6 +14,7 @@ final class PhabricatorTypeaheadTokenView private $inputName; private $value; private $tokenType = self::TYPE_OBJECT; + private $availabilityColor; public static function newFromTypeaheadResult( PhabricatorTypeaheadResult $result) { @@ -41,6 +42,21 @@ final class PhabricatorTypeaheadTokenView $token->setColor($handle->getTagColor()); } + $availability = $handle->getAvailability(); + $color = null; + switch ($availability) { + case PhabricatorObjectHandle::AVAILABILITY_PARTIAL: + $color = PHUITagView::COLOR_ORANGE; + break; + case PhabricatorObjectHandle::AVAILABILITY_NONE: + $color = PHUITagView::COLOR_RED; + break; + } + + if ($color !== null) { + $token->setAvailabilityColor($color); + } + return $token; } @@ -106,6 +122,15 @@ final class PhabricatorTypeaheadTokenView return 'a'; } + public function setAvailabilityColor($availability_color) { + $this->availabilityColor = $availability_color; + return $this; + } + + public function getAvailabilityColor() { + return $this->availabilityColor; + } + protected function getTagAttributes() { $classes = array(); $classes[] = 'jx-tokenizer-token'; @@ -139,20 +164,32 @@ final class PhabricatorTypeaheadTokenView $value = $this->getValue(); + $availability = null; + $availability_color = $this->getAvailabilityColor(); + if ($availability_color) { + $availability = phutil_tag( + 'span', + array( + 'class' => 'phui-tag-dot phui-tag-color-'.$availability_color, + )); + } + + $icon_view = null; $icon = $this->getIcon(); if ($icon) { - $value = array( - phutil_tag( - 'span', - array( - 'class' => 'phui-icon-view phui-font-fa '.$icon, - )), - $value, - ); + $icon_view = phutil_tag( + 'span', + array( + 'class' => 'phui-icon-view phui-font-fa '.$icon, + )); } return array( - $value, + array( + $icon_view, + $availability, + $value, + ), phutil_tag( 'input', array( diff --git a/src/docs/user/configuration/configuration_locked.diviner b/src/docs/user/configuration/configuration_locked.diviner index 958124c381..f96adc2d82 100644 --- a/src/docs/user/configuration/configuration_locked.diviner +++ b/src/docs/user/configuration/configuration_locked.diviner @@ -111,6 +111,55 @@ phabricator/ $ ./bin/config set ``` +Locked Configuration With Database Values +========================================= + +You may receive a setup issue warning you that a locked configuration key has a +value set in the database. Most commonly, this is because: + + - In some earlier version of Phabricator, this configuration was not locked. + - In the past, you or some other administrator used the web UI to set a + value. This value was written to the database. + - In a later version of the software, the value became locked. + +When Phabricator was originally released, locked configuration did not yet +exist. Locked configuration was introduced later, and then configuration options +were gradually locked for a long time after that. + +In some cases the meaning of a value changed and it became possible to use it +to break an install or the configuration became a security risk. In other +cases, we identified an existing security risk or arrived at some other reason +to lock the value. + +Locking values was more common in the past, and it is now relatively rare for +an unlocked value to become locked: when new values are introduced, they are +generally locked or hidden appropriately. In most cases, this setup issue only +affects installs that have used Phabricator for a long time. + +At time of writing (February 2019), Phabricator currently respects these old +database values. However, some future version of Phabricator will refuse to +read locked configuration from the database, because this improves security if +an attacker manages to find a way to bypass restrictions on editing locked +configuration from the web UI. + +To clear this setup warning and avoid surprise behavioral changes in the future, +you should move these configuration values from the database to a local config +file. Usually, you'll do this by first copying the value from the database: + +``` +phabricator/ $ ./bin/config set +``` + +...and then removing the database value: + +``` +phabricator/ $ ./bin/config delete --database +``` + +See @{Configuration User Guide: Advanced Configuration} for some more detailed +discussion of different configuration sources. + + Next Steps ========== diff --git a/src/docs/user/configuration/configuring_accounts_and_registration.diviner b/src/docs/user/configuration/configuring_accounts_and_registration.diviner index 05d11b11f3..a56d7377cb 100644 --- a/src/docs/user/configuration/configuring_accounts_and_registration.diviner +++ b/src/docs/user/configuration/configuring_accounts_and_registration.diviner @@ -3,7 +3,8 @@ Describes how to configure user access to Phabricator. -= Overview = +Overview +======== Phabricator supports a number of login systems. You can enable or disable these systems to configure who can register for and access your install, and how users @@ -28,24 +29,37 @@ After you add a provider, you can link it to existing accounts (for example, associate an existing Phabricator account with a GitHub OAuth account) or users can use it to register new accounts (assuming you enable these options). -= Recovering Inaccessible Accounts = + +Recovering Inaccessible Accounts +================================ If you accidentally lock yourself out of Phabricator (for example, by disabling -all authentication providers), you can use the `bin/auth` -script to recover access to an account. To recover access, run: +all authentication providers), you can normally use the "send a login link" +action from the login screen to email yourself a login link and regain access +to your account. - phabricator/ $ ./bin/auth recover +If that isn't working (perhaps because you haven't configured email yet), you +can use the `bin/auth` script to recover access to an account. To recover +access, run: + +``` +phabricator/ $ ./bin/auth recover +``` ...where `` is the account username you want to recover access to. This will generate a link which will log you in as the specified user. -= Managing Accounts with the Web Console = + +Managing Accounts with the Web Console +====================================== To manage accounts from the web, login as an administrator account and go to `/people/` or click "People" on the homepage. Provided you're an admin, you'll see options to create or edit accounts. -= Manually Creating New Accounts = + +Manually Creating New Accounts +============================== There are two ways to manually create new accounts: via the web UI using the "People" application (this is easiest), or via the CLI using the @@ -60,7 +74,9 @@ the CLI. You can also use this script to make a user an administrator (if you accidentally remove your admin flag) or to create an administrative account. -= Next Steps = + +Next Steps +========== Continue by: diff --git a/src/docs/user/configuration/configuring_outbound_email.diviner b/src/docs/user/configuration/configuring_outbound_email.diviner index 4d18ba0eb2..884e4e7fdb 100644 --- a/src/docs/user/configuration/configuring_outbound_email.diviner +++ b/src/docs/user/configuration/configuring_outbound_email.diviner @@ -47,18 +47,31 @@ not. For more information on using daemons, see @{article:Managing Daemons with phd}. -Basics -====== +Outbound "From" and "To" Addresses +================================== -Before configuring outbound mail, you should first set up -`metamta.default-address` in Configuration. This determines where mail is sent -"From" by default. +When Phabricator sends outbound mail, it must select some "From" address to +send mail from, since mailers require this. -If your domain is `example.org`, set this to something -like `noreply@example.org`. +When mail only has "CC" recipients, Phabricator generates a dummy "To" address, +since some mailers require this and some users write mail rules that depend +on whether they appear in the "To" or "CC" line. -Ideally, this should be a valid, deliverable address that doesn't bounce if -users accidentally send mail to it. +In both cases, the address should ideally correspond to a valid, deliverable +mailbox that accepts the mail and then simply discards it. If the address is +not valid, some outbound mail will bounce, and users will receive bounces when +they "Reply All" even if the other recipients for the message are valid. In +contrast, if the address is a real user address, that user will receive a lot +of mail they probably don't want. + +If you plan to configure //inbound// mail later, you usually don't need to do +anything. Phabricator will automatically create a `noreply@` mailbox which +works the right way (accepts and discards all mail it receives) and +automatically use it when generating addresses. + +If you don't plan to configure inbound mail, you may need to configure an +address for Phabricator to use. You can do this by setting +`metamta.default-address`. Configuring Mailers @@ -214,6 +227,9 @@ To use this mailer, set `type` to `mailgun`, then configure these `options`: - `api-key`: Required string. Your Mailgun API key. - `domain`: Required string. Your Mailgun domain. + - `api-hostname`: Optional string. Defaults to "api.mailgun.net". If your + account is in another region (like the EU), you may need to specify a + different hostname. Consult the Mailgun documentation. Mailer: Amazon SES @@ -323,9 +339,11 @@ document. If you can already send outbound email from the command line or know how to configure it, this option is straightforward. If you have no idea how to do any of this, strongly consider using Postmark or Mailgun instead. -To use this mailer, set `type` to `sendmail`. There are no `options` to -configure. +To use this mailer, set `type` to `sendmail`, then configure these `options`: + - `message-id`: Optional bool. Set to `false` if Phabricator will not be + able to select a custom "Message-ID" header when sending mail via this + mailer. See "Message-ID Headers" below. Mailer: SMTP ============ @@ -345,6 +363,9 @@ To use this mailer, set `type` to `smtp`, then configure these `options`: - `password`: Optional string. Password for authentication. - `protocol`: Optional string. Set to `tls` or `ssl` if necessary. Use `ssl` for Gmail. + - `message-id`: Optional bool. Set to `false` if Phabricator will not be + able to select a custom "Message-ID" header when sending mail via this + mailer. See "Message-ID Headers" below. Disable Mail @@ -430,6 +451,54 @@ in any priority group, in the configured order. In this example there is only one such server, so it will try to send via Mailgun. +Message-ID Headers +================== + +Email has a "Message-ID" header which is important for threading messages +correctly in mail clients. Normally, Phabricator is free to select its own +"Message-ID" header values for mail it sends. + +However, some mailers (including Amazon SES) do not allow selection of custom +"Message-ID" values and will ignore or replace the "Message-ID" in mail that +is submitted through them. + +When Phabricator adds other mail headers which affect threading, like +"In-Reply-To", it needs to know if its "Message-ID" headers will be respected +or not to select header values which will produce good threading behavior. If +we guess wrong and think we can set a "Message-ID" header when we can't, you +may get poor threading behavior in mail clients. + +For most mailers (like Postmark, Mailgun, and Amazon SES), the correct setting +will be selected for you automatically, because the behavior of the mailer +is knowable ahead of time. For example, we know Amazon SES will never respect +our "Message-ID" headers. + +However, if you're sending mail indirectly through a mailer like SMTP or +Sendmail, the mail might or might not be routing through some mail service +which will ignore or replace the "Message-ID" header. + +For example, your local mailer might submit mail to Mailgun (so "Message-ID" +will work), or to Amazon SES (so "Message-ID" will not work), or to some other +mail service (which we may not know anything about). We can't make a reliable +guess about whether "Message-ID" will be respected or not based only on +the local mailer configuration. + +By default, we check if the mailer has a hostname we recognize as belonging +to a service which does not allow us to set a "Message-ID" header. If we don't +recognize the hostname (which is very common, since these services are most +often configured against the localhost or some other local machine), we assume +we can set a "Message-ID" header. + +If the outbound pathway does not actually allow selection of a "Message-ID" +header, you can set the `message-id` option on the mailer to `false` to tell +Phabricator that it should not assume it can select a value for this header. + +For example, if you are sending mail via a local Postfix server which then +forwards the mail to Amazon SES (a service which does not allow selection of +a "Message-ID" header), your `smtp` configuration in Phabricator should +specify `"message-id": false`. + + Next Steps ========== diff --git a/src/docs/user/userguide/audit.diviner b/src/docs/user/userguide/audit.diviner index 0d10867906..5223f6c969 100644 --- a/src/docs/user/userguide/audit.diviner +++ b/src/docs/user/userguide/audit.diviner @@ -175,16 +175,13 @@ You can use this command to forcibly delete requests which may have triggered incorrectly (for example, because a package or Herald rule was configured in an overbroad way). -After deleting audits, you may want to run `bin/audit synchronize` to -synchronize audit state. - **Synchronize Audit State**: Synchronize the audit state of commits to the current open audit requests with `bin/audit synchronize`. Normally, overall audit state is automatically kept up to date as changes are -made to an audit. However, if you delete audits or manually update the database -to make changes to audit request state, the state of corresponding commits may -no longer be correct. +made to an audit. However, if you manually update the database to make changes +to audit request state, the state of corresponding commits may no longer be +consistent. This command will update commits so their overall audit state reflects the cumulative state of their actual audit requests. diff --git a/src/docs/user/userguide/differential_faq.diviner b/src/docs/user/userguide/differential_faq.diviner index aea49c4ce6..0df1f7b1c9 100644 --- a/src/docs/user/userguide/differential_faq.diviner +++ b/src/docs/user/userguide/differential_faq.diviner @@ -51,22 +51,6 @@ You need to install and configure **Pygments** to highlight anything else than PHP. See the `pygments.enabled` configuration setting. -= What do the whitespace options mean? = - -Most of these are pretty straightforward, but "Ignore Most" is not: - - - **Show All**: Show all whitespace. - - **Ignore Trailing**: Ignore changes which only affect trailing whitespace. - - **Ignore Most**: Ignore changes which only affect leading or trailing - whitespace (but not whitespace changes between non-whitespace characters) - in files which are not marked as having significant whitespace. - In those files, show whitespace changes. By default, Python (.py) and - Haskell (.lhs, .hs) are marked as having significant whitespace, but this - can be changed in the `differential.whitespace-matters` configuration - setting. - - **Ignore All**: Ignore all whitespace changes in all files. - - = What do the very light green and red backgrounds mean? = Differential uses these colors to mark changes coming from rebase: they are diff --git a/src/docs/user/userguide/diviner.diviner b/src/docs/user/userguide/diviner.diviner index e94c33d275..01484be14c 100644 --- a/src/docs/user/userguide/diviner.diviner +++ b/src/docs/user/userguide/diviner.diviner @@ -3,17 +3,28 @@ Using Diviner, a documentation generator. -= Overview = +Overview +======== -NOTE: Diviner is new and not yet generally useful. +Diviner is an application for creating technical documentation. -= Generating Documentation = +This article is maintained in a text file in the Phabricator repository and +generated into the display document you are currently reading using Diviner. + +Beyond generating articles, Diviner can also analyze source code and generate +documentation about classes, methods, and other primitives. + + +Generating Documentation +======================== To generate documentation, run: phabricator/ $ ./bin/diviner generate --book -= .book Files = + +Diviner ".book" Files +===================== Diviner documentation books are configured using JSON `.book` files, which look like this: diff --git a/src/docs/user/userguide/owners.diviner b/src/docs/user/userguide/owners.diviner index 95a3882552..11dee8941a 100644 --- a/src/docs/user/userguide/owners.diviner +++ b/src/docs/user/userguide/owners.diviner @@ -114,16 +114,23 @@ Auditing ======== You can automatically trigger audits on unreviewed code by configuring -**Auditing**. The available settings are: +**Auditing**. The available settings allow you to select behavior based on +these conditions: - - **Disabled**: Do not trigger audits. - - **Enabled**: Trigger audits. + - **No Owner Involvement**: Triggers an audit when the commit author is not + a package owner, and no package owner reviewed an associated revision in + Differential. + - **Unreviewed Commits**: Triggers an audit when a commit has no associated + revision in Differential, or the associated revision in Differential landed + without being "Accepted". -When enabled, audits are triggered for commits which: +For example, the **Audit Commits With No Owner Involvement** option triggers +audits for commits which: - affect code owned by the package; - were not authored by a package owner; and - - were not accepted by a package owner. + - were not accepted (in Differential) by a package owner or the package + itself. Audits do not trigger if the package has been archived. diff --git a/src/infrastructure/customfield/field/PhabricatorCustomField.php b/src/infrastructure/customfield/field/PhabricatorCustomField.php index d7df3c5b78..c6c70a9614 100644 --- a/src/infrastructure/customfield/field/PhabricatorCustomField.php +++ b/src/infrastructure/customfield/field/PhabricatorCustomField.php @@ -74,9 +74,22 @@ abstract class PhabricatorCustomField extends Phobject { $spec, $object); + $fields = self::adjustCustomFieldsForObjectSubtype( + $object, + $role, + $fields); + foreach ($fields as $key => $field) { + // NOTE: We perform this filtering in "buildFieldList()", but may need + // to filter again after subtype adjustment. + if (!$field->isFieldEnabled()) { + unset($fields[$key]); + continue; + } + if (!$field->shouldEnableForRole($role)) { unset($fields[$key]); + continue; } } @@ -1622,4 +1635,78 @@ abstract class PhabricatorCustomField extends Phobject { return null; } + private static function adjustCustomFieldsForObjectSubtype( + PhabricatorCustomFieldInterface $object, + $role, + array $fields) { + assert_instances_of($fields, __CLASS__); + + // We only apply subtype adjustment for some roles. For example, when + // writing Herald rules or building a Search interface, we always want to + // show all the fields in their default state, so we do not apply any + // adjustments. + $subtype_roles = array( + self::ROLE_EDITENGINE, + self::ROLE_VIEW, + ); + + $subtype_roles = array_fuse($subtype_roles); + if (!isset($subtype_roles[$role])) { + return $fields; + } + + // If the object doesn't support subtypes, we can't possibly make + // any adjustments based on subtype. + if (!($object instanceof PhabricatorEditEngineSubtypeInterface)) { + return $fields; + } + + $subtype_map = $object->newEditEngineSubtypeMap(); + $subtype_key = $object->getEditEngineSubtype(); + $subtype_object = $subtype_map->getSubtype($subtype_key); + + $map = array(); + foreach ($fields as $field) { + $modern_key = $field->getModernFieldKey(); + if (!strlen($modern_key)) { + continue; + } + + $map[$modern_key] = $field; + } + + foreach ($map as $field_key => $field) { + // For now, only support overriding standard custom fields. In the + // future there's no technical or product reason we couldn't let you + // override (some properites of) other fields like "Title", but they + // don't usually support appropriate "setX()" methods today. + if (!($field instanceof PhabricatorStandardCustomField)) { + // For fields that are proxies on top of StandardCustomField, which + // is how most application custom fields work today, we can reconfigure + // the proxied field instead. + $field = $field->getProxy(); + if (!$field || !($field instanceof PhabricatorStandardCustomField)) { + continue; + } + } + + $subtype_config = $subtype_object->getSubtypeFieldConfiguration( + $field_key); + + if (!$subtype_config) { + continue; + } + + if (isset($subtype_config['disabled'])) { + $field->setIsEnabled(!$subtype_config['disabled']); + } + + if (isset($subtype_config['name'])) { + $field->setFieldName($subtype_config['name']); + } + } + + return $fields; + } + } diff --git a/src/infrastructure/customfield/standard/PhabricatorStandardCustomField.php b/src/infrastructure/customfield/standard/PhabricatorStandardCustomField.php index f0f9d4115f..6edbfb495c 100644 --- a/src/infrastructure/customfield/standard/PhabricatorStandardCustomField.php +++ b/src/infrastructure/customfield/standard/PhabricatorStandardCustomField.php @@ -19,6 +19,7 @@ abstract class PhabricatorStandardCustomField private $hasStorageValue; private $isBuiltin; private $hidden; + private $isEnabled = true; abstract public function getFieldType(); @@ -183,6 +184,19 @@ abstract class PhabricatorStandardCustomField return $this->rawKey; } + public function setIsEnabled($is_enabled) { + $this->isEnabled = $is_enabled; + return $this; + } + + public function getIsEnabled() { + return $this->isEnabled; + } + + public function isFieldEnabled() { + return $this->getIsEnabled(); + } + /* -( PhabricatorCustomField )--------------------------------------------- */ diff --git a/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php b/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php index 036b7301a1..5957afe56a 100644 --- a/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php +++ b/src/infrastructure/customfield/standard/PhabricatorStandardCustomFieldSelect.php @@ -153,4 +153,9 @@ final class PhabricatorStandardCustomFieldSelect ->setOptions($this->getOptions()); } + protected function newExportFieldType() { + return id(new PhabricatorOptionExportField()) + ->setOptions($this->getOptions()); + } + } diff --git a/src/infrastructure/daemon/workers/PhabricatorWorker.php b/src/infrastructure/daemon/workers/PhabricatorWorker.php index 1b9821b68d..f055544b7b 100644 --- a/src/infrastructure/daemon/workers/PhabricatorWorker.php +++ b/src/infrastructure/daemon/workers/PhabricatorWorker.php @@ -18,6 +18,7 @@ abstract class PhabricatorWorker extends Phobject { const PRIORITY_DEFAULT = 2000; const PRIORITY_COMMIT = 2500; const PRIORITY_BULK = 3000; + const PRIORITY_INDEX = 3500; const PRIORITY_IMPORT = 4000; /** diff --git a/src/infrastructure/daemon/workers/editor/PhabricatorWorkerBulkJobEditor.php b/src/infrastructure/daemon/workers/editor/PhabricatorWorkerBulkJobEditor.php index b23c987d6d..e94ca6dc49 100644 --- a/src/infrastructure/daemon/workers/editor/PhabricatorWorkerBulkJobEditor.php +++ b/src/infrastructure/daemon/workers/editor/PhabricatorWorkerBulkJobEditor.php @@ -15,6 +15,7 @@ final class PhabricatorWorkerBulkJobEditor $types = parent::getTransactionTypes(); $types[] = PhabricatorWorkerBulkJobTransaction::TYPE_STATUS; + $types[] = PhabricatorTransactions::TYPE_EDGE; return $types; } diff --git a/src/infrastructure/daemon/workers/management/PhabricatorWorkerManagementExecuteWorkflow.php b/src/infrastructure/daemon/workers/management/PhabricatorWorkerManagementExecuteWorkflow.php index 2acc8452ea..f3c6be520a 100644 --- a/src/infrastructure/daemon/workers/management/PhabricatorWorkerManagementExecuteWorkflow.php +++ b/src/infrastructure/daemon/workers/management/PhabricatorWorkerManagementExecuteWorkflow.php @@ -11,23 +11,64 @@ final class PhabricatorWorkerManagementExecuteWorkflow pht( 'Execute a task explicitly. This command ignores leases, is '. 'dangerous, and may cause work to be performed twice.')) - ->setArguments($this->getTaskSelectionArguments()); + ->setArguments( + array_merge( + array( + array( + 'name' => 'retry', + 'help' => pht('Retry archived tasks.'), + ), + array( + 'name' => 'repeat', + 'help' => pht('Repeat archived, successful tasks.'), + ), + ), + $this->getTaskSelectionArguments())); } public function execute(PhutilArgumentParser $args) { $console = PhutilConsole::getConsole(); $tasks = $this->loadTasks($args); + $is_retry = $args->getArg('retry'); + $is_repeat = $args->getArg('repeat'); + foreach ($tasks as $task) { $can_execute = !$task->isArchived(); if (!$can_execute) { - $console->writeOut( + if (!$is_retry) { + $console->writeOut( + "** %s ** %s\n", + pht('ARCHIVED'), + pht( + '%s is already archived, and will not be executed. '. + 'Use "--retry" to execute archived tasks.', + $this->describeTask($task))); + continue; + } + + $result_success = PhabricatorWorkerArchiveTask::RESULT_SUCCESS; + if ($task->getResult() == $result_success) { + if (!$is_repeat) { + $console->writeOut( + "** %s ** %s\n", + pht('SUCCEEDED'), + pht( + '%s has already succeeded, and will not be retried. '. + 'Use "--repeat" to repeat successful tasks.', + $this->describeTask($task))); + continue; + } + } + + echo tsprintf( "** %s ** %s\n", pht('ARCHIVED'), pht( - '%s is already archived, and can not be executed.', + 'Unarchiving %s.', $this->describeTask($task))); - continue; + + $task = $task->unarchiveTask(); } // NOTE: This ignores leases, maybe it should respect them without diff --git a/src/infrastructure/daemon/workers/management/PhabricatorWorkerManagementRetryWorkflow.php b/src/infrastructure/daemon/workers/management/PhabricatorWorkerManagementRetryWorkflow.php index 6dbebd168d..538a70add8 100644 --- a/src/infrastructure/daemon/workers/management/PhabricatorWorkerManagementRetryWorkflow.php +++ b/src/infrastructure/daemon/workers/management/PhabricatorWorkerManagementRetryWorkflow.php @@ -10,15 +10,24 @@ final class PhabricatorWorkerManagementRetryWorkflow ->setSynopsis( pht( 'Retry selected tasks which previously failed permanently or '. - 'were cancelled. Only archived, unsuccessful tasks can be '. - 'retried.')) - ->setArguments($this->getTaskSelectionArguments()); + 'were cancelled. Only archived tasks can be retried.')) + ->setArguments( + array_merge( + array( + array( + 'name' => 'repeat', + 'help' => pht( + 'Repeat tasks which already completed successfully.'), + ), + ), + $this->getTaskSelectionArguments())); } public function execute(PhutilArgumentParser $args) { $console = PhutilConsole::getConsole(); $tasks = $this->loadTasks($args); + $is_repeat = $args->getArg('repeat'); foreach ($tasks as $task) { if (!$task->isArchived()) { $console->writeOut( @@ -32,13 +41,16 @@ final class PhabricatorWorkerManagementRetryWorkflow $result_success = PhabricatorWorkerArchiveTask::RESULT_SUCCESS; if ($task->getResult() == $result_success) { - $console->writeOut( - "** %s ** %s\n", - pht('SUCCEEDED'), - pht( - '%s has already succeeded, and can not be retried.', - $this->describeTask($task))); - continue; + if (!$is_repeat) { + $console->writeOut( + "** %s ** %s\n", + pht('SUCCEEDED'), + pht( + '%s has already succeeded, and will not be repeated. '. + 'Use "--repeat" to repeat successful tasks.', + $this->describeTask($task))); + continue; + } } $task->unarchiveTask(); diff --git a/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php b/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php index 87c16a48c5..8ca12d60e4 100644 --- a/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php +++ b/src/infrastructure/daemon/workers/query/PhabricatorWorkerTriggerQuery.php @@ -70,7 +70,11 @@ final class PhabricatorWorkerTriggerQuery protected function nextPage(array $page) { // NOTE: We don't implement paging because we don't currently ever need // it and paging ORDER_EXECUTION is a hassle. - throw new PhutilMethodNotImplementedException(); + + // (Before T13266, we raised an exception here, but since "nextPage()" is + // now called even if we don't page we can't do that anymore. Just do + // nothing instead.) + return null; } protected function loadPage() { diff --git a/src/infrastructure/daemon/workers/storage/PhabricatorWorkerActiveTask.php b/src/infrastructure/daemon/workers/storage/PhabricatorWorkerActiveTask.php index ed1eeaea63..7139e39ac3 100644 --- a/src/infrastructure/daemon/workers/storage/PhabricatorWorkerActiveTask.php +++ b/src/infrastructure/daemon/workers/storage/PhabricatorWorkerActiveTask.php @@ -12,37 +12,22 @@ final class PhabricatorWorkerActiveTask extends PhabricatorWorkerTask { $config = array( self::CONFIG_IDS => self::IDS_COUNTER, - self::CONFIG_TIMESTAMPS => false, self::CONFIG_KEY_SCHEMA => array( - 'dataID' => array( - 'columns' => array('dataID'), - 'unique' => true, - ), 'taskClass' => array( 'columns' => array('taskClass'), ), 'leaseExpires' => array( 'columns' => array('leaseExpires'), ), - 'leaseOwner' => array( - 'columns' => array('leaseOwner(16)'), - ), 'key_failuretime' => array( 'columns' => array('failureTime'), ), - 'leaseOwner_2' => array( + 'key_owner' => array( 'columns' => array('leaseOwner', 'priority', 'id'), ), ) + $parent[self::CONFIG_KEY_SCHEMA], ); - $config[self::CONFIG_COLUMN_SCHEMA] = array( - // T6203/NULLABILITY - // This isn't nullable in the archive table, so at a minimum these - // should be the same. - 'dataID' => 'uint32?', - ) + $parent[self::CONFIG_COLUMN_SCHEMA]; - return $config + $parent; } @@ -74,7 +59,7 @@ final class PhabricatorWorkerActiveTask extends PhabricatorWorkerTask { $this->failureCount = 0; } - if ($is_new && ($this->getData() !== null)) { + if ($is_new) { $data = new PhabricatorWorkerTaskData(); $data->setData($this->getData()); $data->save(); @@ -132,7 +117,9 @@ final class PhabricatorWorkerActiveTask extends PhabricatorWorkerTask { ->setPriority($this->getPriority()) ->setObjectPHID($this->getObjectPHID()) ->setResult($result) - ->setDuration($duration); + ->setDuration($duration) + ->setDateCreated($this->getDateCreated()) + ->setArchivedEpoch(PhabricatorTime::getNow()); // NOTE: This deletes the active task (this object)! $archive->save(); diff --git a/src/infrastructure/daemon/workers/storage/PhabricatorWorkerArchiveTask.php b/src/infrastructure/daemon/workers/storage/PhabricatorWorkerArchiveTask.php index fe1164e532..25a453b47b 100644 --- a/src/infrastructure/daemon/workers/storage/PhabricatorWorkerArchiveTask.php +++ b/src/infrastructure/daemon/workers/storage/PhabricatorWorkerArchiveTask.php @@ -8,6 +8,7 @@ final class PhabricatorWorkerArchiveTask extends PhabricatorWorkerTask { protected $duration; protected $result; + protected $archivedEpoch; protected function getConfiguration() { $parent = parent::getConfiguration(); @@ -22,15 +23,13 @@ final class PhabricatorWorkerArchiveTask extends PhabricatorWorkerTask { $config[self::CONFIG_COLUMN_SCHEMA] = array( 'result' => 'uint32', 'duration' => 'uint64', + 'archivedEpoch' => 'epoch?', ) + $config[self::CONFIG_COLUMN_SCHEMA]; $config[self::CONFIG_KEY_SCHEMA] = array( 'dateCreated' => array( 'columns' => array('dateCreated'), ), - 'leaseOwner' => array( - 'columns' => array('leaseOwner', 'priority', 'id'), - ), 'key_modified' => array( 'columns' => array('dateModified'), ), @@ -88,6 +87,7 @@ final class PhabricatorWorkerArchiveTask extends PhabricatorWorkerTask { ->setDataID($this->getDataID()) ->setPriority($this->getPriority()) ->setObjectPHID($this->getObjectPHID()) + ->setDateCreated($this->getDateCreated()) ->insert(); $this->setDataID(null); diff --git a/src/infrastructure/diff/PhabricatorDiffScopeEngine.php b/src/infrastructure/diff/PhabricatorDiffScopeEngine.php new file mode 100644 index 0000000000..5ea1ec5021 --- /dev/null +++ b/src/infrastructure/diff/PhabricatorDiffScopeEngine.php @@ -0,0 +1,156 @@ + $value) { + if ($key === $expect) { + $expect++; + continue; + } + + throw new Exception( + pht( + 'ScopeEngine text map must be a contiguous map of '. + 'lines, but is not: found key "%s" where key "%s" was expected.', + $key, + $expect)); + } + + $this->lineTextMap = $map; + + return $this; + } + + public function getLineTextMap() { + if ($this->lineTextMap === null) { + throw new PhutilInvalidStateException('setLineTextMap'); + } + return $this->lineTextMap; + } + + public function getScopeStart($line) { + $text_map = $this->getLineTextMap(); + $depth_map = $this->getLineDepthMap(); + $length = count($text_map); + + // Figure out the effective depth of the line we're getting scope for. + // If the line is just whitespace, it may have no depth on its own. In + // this case, we look for the next line. + $line_depth = null; + for ($ii = $line; $ii <= $length; $ii++) { + if ($depth_map[$ii] !== null) { + $line_depth = $depth_map[$ii]; + break; + } + } + + // If we can't find a line depth for the target line, just bail. + if ($line_depth === null) { + return null; + } + + // Limit the maximum number of lines we'll examine. If a user has a + // million-line diff of nonsense, scanning the whole thing is a waste + // of time. + $search_range = 1000; + $search_until = max(0, $ii - $search_range); + + for ($ii = $line - 1; $ii > $search_until; $ii--) { + $line_text = $text_map[$ii]; + + // This line is in missing context: the diff was diffed with partial + // context, and we ran out of context before finding a good scope line. + // Bail out, we don't want to jump across missing context blocks. + if ($line_text === null) { + return null; + } + + $depth = $depth_map[$ii]; + + // This line is all whitespace. This isn't a possible match. + if ($depth === null) { + continue; + } + + // The depth is the same as (or greater than) the depth we started with, + // so this isn't a possible match. + if ($depth >= $line_depth) { + continue; + } + + // Reject lines which begin with "}" or "{". These lines are probably + // never good matches. + if (preg_match('/^\s*[{}]/i', $line_text)) { + continue; + } + + return $ii; + } + + return null; + } + + private function getLineDepthMap() { + if (!$this->lineDepthMap) { + $this->lineDepthMap = $this->newLineDepthMap(); + } + + return $this->lineDepthMap; + } + + private function newLineDepthMap() { + $text_map = $this->getLineTextMap(); + + // TODO: This should be configurable once we handle tab widths better. + $tab_width = 2; + + $depth_map = array(); + foreach ($text_map as $line_number => $line_text) { + if ($line_text === null) { + $depth_map[$line_number] = null; + continue; + } + + $len = strlen($line_text); + + // If the line has no actual text, don't assign it a depth. + if (!$len || !strlen(trim($line_text))) { + $depth_map[$line_number] = null; + continue; + } + + $count = 0; + for ($ii = 0; $ii < $len; $ii++) { + $c = $line_text[$ii]; + if ($c == ' ') { + $count++; + } else if ($c == "\t") { + $count += $tab_width; + } else { + break; + } + } + + // Round down to cheat our way through the " *" parts of docblock + // comments. This is generally a reasonble heuristic because odd tab + // widths are exceptionally rare. + $depth = ($count >> 1); + + $depth_map[$line_number] = $depth; + } + + return $depth_map; + } + +} diff --git a/src/infrastructure/diff/PhabricatorDifferenceEngine.php b/src/infrastructure/diff/PhabricatorDifferenceEngine.php index 3b4eb55473..84e88ceaaa 100644 --- a/src/infrastructure/diff/PhabricatorDifferenceEngine.php +++ b/src/infrastructure/diff/PhabricatorDifferenceEngine.php @@ -10,27 +10,14 @@ final class PhabricatorDifferenceEngine extends Phobject { - private $ignoreWhitespace; private $oldName; private $newName; + private $normalize; /* -( Configuring the Engine )--------------------------------------------- */ - /** - * If true, ignore whitespace when computing differences. - * - * @param bool Ignore whitespace? - * @return this - * @task config - */ - public function setIgnoreWhitespace($ignore_whitespace) { - $this->ignoreWhitespace = $ignore_whitespace; - return $this; - } - - /** * Set the name to identify the old file with. Primarily cosmetic. * @@ -57,6 +44,16 @@ final class PhabricatorDifferenceEngine extends Phobject { } + public function setNormalize($normalize) { + $this->normalize = $normalize; + return $this; + } + + public function getNormalize() { + return $this->normalize; + } + + /* -( Generating Diffs )--------------------------------------------------- */ @@ -73,9 +70,6 @@ final class PhabricatorDifferenceEngine extends Phobject { public function generateRawDiffFromFileContent($old, $new) { $options = array(); - if ($this->ignoreWhitespace) { - $options[] = '-bw'; - } // Generate diffs with full context. $options[] = '-U65535'; @@ -88,6 +82,12 @@ final class PhabricatorDifferenceEngine extends Phobject { $options[] = '-L'; $options[] = $new_name; + $normalize = $this->getNormalize(); + if ($normalize) { + $old = $this->normalizeFile($old); + $new = $this->normalizeFile($new); + } + $old_tmp = new TempFile(); $new_tmp = new TempFile(); @@ -100,12 +100,10 @@ final class PhabricatorDifferenceEngine extends Phobject { $new_tmp); if (!$err) { - // This indicates that the two files are the same (or, possibly, the - // same modulo whitespace differences, which is why we can't do this - // check trivially before running `diff`). Build a synthetic, changeless - // diff so that we can still render the raw, unchanged file instead of - // being forced to just say "this file didn't change" since we don't have - // the content. + // This indicates that the two files are the same. Build a synthetic, + // changeless diff so that we can still render the raw, unchanged file + // instead of being forced to just say "this file didn't change" since we + // don't have the content. $entire_file = explode("\n", $old); foreach ($entire_file as $k => $line) { @@ -123,26 +121,6 @@ final class PhabricatorDifferenceEngine extends Phobject { "+++ {$new_name}\n". "@@ -1,{$len} +1,{$len} @@\n". $entire_file."\n"; - } else { - if ($this->ignoreWhitespace) { - - // Under "-bw", `diff` is inconsistent about emitting "\ No newline - // at end of file". For instance, a long file with a change in the - // middle will emit a contextless "\ No newline..." at the end if a - // newline is removed, but not if one is added. A file with a change - // at the end will emit the "old" "\ No newline..." block only, even - // if the newline was not removed. Since we're ostensibly ignoring - // whitespace changes, just drop these lines if they appear anywhere - // in the diff. - - $lines = explode("\n", $diff); - foreach ($lines as $key => $line) { - if (isset($line[0]) && $line[0] == '\\') { - unset($lines[$key]); - } - } - $diff = implode("\n", $lines); - } } return $diff; @@ -168,4 +146,27 @@ final class PhabricatorDifferenceEngine extends Phobject { return head($diff->getChangesets()); } + private function normalizeFile($corpus) { + // We can freely apply any other transformations we want to here: we have + // no constraints on what we need to preserve. If we normalize every line + // to "cat", the diff will still work, the alignment of the "-" / "+" + // lines will just be very hard to read. + + // In general, we'll make the diff better if we normalize two lines that + // humans think are the same. + + // We'll make the diff worse if we normalize two lines that humans think + // are different. + + + // Strip all whitespace present anywhere in the diff, since humans never + // consider whitespace changes to alter the line into a "different line" + // even when they're semantic (e.g., in a string constant). This covers + // indentation changes, trailing whitepspace, and formatting changes + // like "+/-". + $corpus = preg_replace('/[ \t]/', '', $corpus); + + return $corpus; + } + } diff --git a/src/infrastructure/diff/__tests__/PhabricatorDiffScopeEngineTestCase.php b/src/infrastructure/diff/__tests__/PhabricatorDiffScopeEngineTestCase.php new file mode 100644 index 0000000000..50e23ac31c --- /dev/null +++ b/src/infrastructure/diff/__tests__/PhabricatorDiffScopeEngineTestCase.php @@ -0,0 +1,51 @@ +assertScopeStart('zebra.c', 4, 2); + } + + private function assertScopeStart($file, $line, $expect) { + $engine = $this->getScopeTestEngine($file); + + $actual = $engine->getScopeStart($line); + $this->assertEqual( + $expect, + $actual, + pht( + 'Expect scope for line %s to start on line %s (actual: %s) in "%s".', + $line, + $expect, + $actual, + $file)); + } + + private function getScopeTestEngine($file) { + if (!isset($this->engines[$file])) { + $this->engines[$file] = $this->newScopeTestEngine($file); + } + + return $this->engines[$file]; + } + + private function newScopeTestEngine($file) { + $path = dirname(__FILE__).'/data/'.$file; + $data = Filesystem::readFile($path); + + $lines = phutil_split_lines($data); + $map = array(); + foreach ($lines as $key => $line) { + $map[$key + 1] = $line; + } + + $engine = id(new PhabricatorDiffScopeEngine()) + ->setLineTextMap($map); + + return $engine; + } + +} diff --git a/src/infrastructure/diff/__tests__/data/zebra.c b/src/infrastructure/diff/__tests__/data/zebra.c new file mode 100644 index 0000000000..d587b018a9 --- /dev/null +++ b/src/infrastructure/diff/__tests__/data/zebra.c @@ -0,0 +1,5 @@ +void +ZebraTamer::TameAZebra(nsPoint where, const nsRect& zone, nsAtom* material) +{ + zebra.tame = true; +} diff --git a/src/infrastructure/diff/view/PHUIDiffOneUpInlineCommentRowScaffold.php b/src/infrastructure/diff/view/PHUIDiffOneUpInlineCommentRowScaffold.php index 53c2255dc8..fe5cab8622 100644 --- a/src/infrastructure/diff/view/PHUIDiffOneUpInlineCommentRowScaffold.php +++ b/src/infrastructure/diff/view/PHUIDiffOneUpInlineCommentRowScaffold.php @@ -18,7 +18,6 @@ final class PHUIDiffOneUpInlineCommentRowScaffold $attrs = array( 'colspan' => 3, - 'class' => 'right3', 'id' => $inline->getScaffoldCellID(), ); @@ -31,8 +30,8 @@ final class PHUIDiffOneUpInlineCommentRowScaffold } $cells = array( - phutil_tag('th', array(), $left_hidden), - phutil_tag('th', array(), $right_hidden), + phutil_tag('td', array('class' => 'n'), $left_hidden), + phutil_tag('td', array('class' => 'n'), $right_hidden), phutil_tag('td', $attrs, $inline), ); diff --git a/src/infrastructure/diff/view/PHUIDiffTwoUpInlineCommentRowScaffold.php b/src/infrastructure/diff/view/PHUIDiffTwoUpInlineCommentRowScaffold.php index 81b0edaf49..f9bde17bf3 100644 --- a/src/infrastructure/diff/view/PHUIDiffTwoUpInlineCommentRowScaffold.php +++ b/src/infrastructure/diff/view/PHUIDiffTwoUpInlineCommentRowScaffold.php @@ -65,15 +65,15 @@ final class PHUIDiffTwoUpInlineCommentRowScaffold ); $right_attrs = array( - 'colspan' => 3, - 'class' => 'right3', + 'colspan' => 2, 'id' => ($right_side ? $right_side->getScaffoldCellID() : null), ); $cells = array( - phutil_tag('th', array(), $left_hidden), + phutil_tag('td', array('class' => 'n'), $left_hidden), phutil_tag('td', $left_attrs, $left_side), - phutil_tag('th', array(), $right_hidden), + phutil_tag('td', array('class' => 'n'), $right_hidden), + phutil_tag('td', array('class' => 'copy')), phutil_tag('td', $right_attrs, $right_side), ); diff --git a/src/infrastructure/edges/conduit/PhabricatorEdgeObject.php b/src/infrastructure/edges/conduit/PhabricatorEdgeObject.php index ec5f84e59f..3183185631 100644 --- a/src/infrastructure/edges/conduit/PhabricatorEdgeObject.php +++ b/src/infrastructure/edges/conduit/PhabricatorEdgeObject.php @@ -8,14 +8,18 @@ final class PhabricatorEdgeObject private $src; private $dst; private $type; + private $dateCreated; + private $sequence; public static function newFromRow(array $row) { $edge = new self(); - $edge->id = $row['id']; - $edge->src = $row['src']; - $edge->dst = $row['dst']; - $edge->type = $row['type']; + $edge->id = idx($row, 'id'); + $edge->src = idx($row, 'src'); + $edge->dst = idx($row, 'dst'); + $edge->type = idx($row, 'type'); + $edge->dateCreated = idx($row, 'dateCreated'); + $edge->sequence = idx($row, 'seq'); return $edge; } @@ -40,6 +44,15 @@ final class PhabricatorEdgeObject return null; } + public function getDateCreated() { + return $this->dateCreated; + } + + public function getSequence() { + return $this->sequence; + } + + /* -( PhabricatorPolicyInterface )----------------------------------------- */ diff --git a/src/infrastructure/edges/query/PhabricatorEdgeObjectQuery.php b/src/infrastructure/edges/query/PhabricatorEdgeObjectQuery.php index 1385215569..048a2a9fb4 100644 --- a/src/infrastructure/edges/query/PhabricatorEdgeObjectQuery.php +++ b/src/infrastructure/edges/query/PhabricatorEdgeObjectQuery.php @@ -12,7 +12,6 @@ final class PhabricatorEdgeObjectQuery private $edgeTypes; private $destinationPHIDs; - public function withSourcePHIDs(array $source_phids) { $this->sourcePHIDs = $source_phids; return $this; @@ -85,18 +84,6 @@ final class PhabricatorEdgeObjectQuery return $result; } - protected function buildSelectClauseParts(AphrontDatabaseConnection $conn) { - $parts = parent::buildSelectClauseParts($conn); - - // TODO: This is hacky, because we don't have real IDs on this table. - $parts[] = qsprintf( - $conn, - 'CONCAT(dateCreated, %s, seq) AS id', - '_'); - - return $parts; - } - protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $parts = parent::buildWhereClauseParts($conn); @@ -151,13 +138,45 @@ final class PhabricatorEdgeObjectQuery return array('dateCreated', 'sequence'); } - protected function getPagingValueMap($cursor, array $keys) { - $parts = explode('_', $cursor); + protected function newInternalCursorFromExternalCursor($cursor) { + list($epoch, $sequence) = $this->parseCursor($cursor); + // Instead of actually loading an edge, we're just making a fake edge + // with the properties the cursor describes. + + $edge_object = PhabricatorEdgeObject::newFromRow( + array( + 'dateCreated' => $epoch, + 'seq' => $sequence, + )); + + return id(new PhabricatorQueryCursor()) + ->setObject($edge_object); + } + + protected function newPagingMapFromPartialObject($object) { return array( - 'dateCreated' => $parts[0], - 'sequence' => $parts[1], + 'dateCreated' => $object->getDateCreated(), + 'sequence' => $object->getSequence(), ); } + protected function newExternalCursorStringForResult($object) { + return sprintf( + '%d_%d', + $object->getDateCreated(), + $object->getSequence()); + } + + private function parseCursor($cursor) { + if (!preg_match('/^\d+_\d+\z/', $cursor)) { + $this->throwCursorException( + pht( + 'Expected edge cursor in the form "0123_6789", got "%s".', + $cursor)); + } + + return explode('_', $cursor); + } + } diff --git a/src/infrastructure/env/PhabricatorEnv.php b/src/infrastructure/env/PhabricatorEnv.php index 8b2af38d3d..24fb940c9a 100644 --- a/src/infrastructure/env/PhabricatorEnv.php +++ b/src/infrastructure/env/PhabricatorEnv.php @@ -475,11 +475,17 @@ final class PhabricatorEnv extends Phobject { * @task read */ public static function getDoclink($resource, $type = 'article') { - $uri = new PhutilURI('https://secure.phabricator.com/diviner/find/'); - $uri->setQueryParam('name', $resource); - $uri->setQueryParam('type', $type); - $uri->setQueryParam('jump', true); - return (string)$uri; + $params = array( + 'name' => $resource, + 'type' => $type, + 'jump' => true, + ); + + $uri = new PhutilURI( + 'https://secure.phabricator.com/diviner/find/', + $params); + + return phutil_string_cast($uri); } diff --git a/src/infrastructure/export/field/PhabricatorOptionExportField.php b/src/infrastructure/export/field/PhabricatorOptionExportField.php new file mode 100644 index 0000000000..e6d3e9b45b --- /dev/null +++ b/src/infrastructure/export/field/PhabricatorOptionExportField.php @@ -0,0 +1,47 @@ +options = $options; + return $this; + } + + public function getOptions() { + return $this->options; + } + + public function getNaturalValue($value) { + if ($value === null) { + return $value; + } + + if (!strlen($value)) { + return null; + } + + $options = $this->getOptions(); + + return array( + 'value' => (string)$value, + 'name' => (string)idx($options, $value, $value), + ); + } + + public function getTextValue($value) { + $natural_value = $this->getNaturalValue($value); + if ($natural_value === null) { + return null; + } + + return $natural_value['name']; + } + + public function getPHPExcelValue($value) { + return $this->getTextValue($value); + } + +} diff --git a/src/infrastructure/graph/ManiphestTaskGraph.php b/src/infrastructure/graph/ManiphestTaskGraph.php index 99191760dd..74a1fe8701 100644 --- a/src/infrastructure/graph/ManiphestTaskGraph.php +++ b/src/infrastructure/graph/ManiphestTaskGraph.php @@ -4,6 +4,7 @@ final class ManiphestTaskGraph extends PhabricatorObjectGraph { private $seedMaps = array(); + private $isStandalone; protected function getEdgeTypes() { return array( @@ -24,6 +25,15 @@ final class ManiphestTaskGraph return $object->isClosed(); } + public function setIsStandalone($is_standalone) { + $this->isStandalone = $is_standalone; + return $this; + } + + public function getIsStandalone() { + return $this->isStandalone; + } + protected function newTableRow($phid, $object, $trace) { $viewer = $this->getViewer(); @@ -132,6 +142,14 @@ final class ManiphestTaskGraph array( true, !$this->getRenderOnlyAdjacentNodes(), + )) + ->setDeviceVisibility( + array( + true, + + // On mobile, we only show the actual graph drawing if we're on the + // standalone page, since it can take over the screen otherwise. + $this->getIsStandalone(), )); } diff --git a/src/infrastructure/markup/PhabricatorMarkupEngine.php b/src/infrastructure/markup/PhabricatorMarkupEngine.php index 868bbc5676..3a63cb97a6 100644 --- a/src/infrastructure/markup/PhabricatorMarkupEngine.php +++ b/src/infrastructure/markup/PhabricatorMarkupEngine.php @@ -42,7 +42,7 @@ final class PhabricatorMarkupEngine extends Phobject { private $objects = array(); private $viewer; private $contextObject; - private $version = 17; + private $version = 18; private $engineCaches = array(); private $auxiliaryConfig = array(); diff --git a/src/infrastructure/markup/rule/PhabricatorYoutubeRemarkupRule.php b/src/infrastructure/markup/rule/PhabricatorYoutubeRemarkupRule.php index cbf322b2d9..9d79d223e0 100644 --- a/src/infrastructure/markup/rule/PhabricatorYoutubeRemarkupRule.php +++ b/src/infrastructure/markup/rule/PhabricatorYoutubeRemarkupRule.php @@ -18,12 +18,22 @@ final class PhabricatorYoutubeRemarkupRule extends PhutilRemarkupRule { return $text; } - $params = $uri->getQueryParams(); - $v_param = idx($params, 'v'); - if (!strlen($v_param)) { + $v_params = array(); + + $params = $uri->getQueryParamsAsPairList(); + foreach ($params as $pair) { + list($k, $v) = $pair; + if ($k === 'v') { + $v_params[] = $v; + } + } + + if (count($v_params) !== 1) { return $text; } + $v_param = head($v_params); + $text_mode = $this->getEngine()->isTextMode(); $mail_mode = $this->getEngine()->isHTMLMailMode(); diff --git a/src/infrastructure/query/PhabricatorEmptyQueryException.php b/src/infrastructure/query/exception/PhabricatorEmptyQueryException.php similarity index 100% rename from src/infrastructure/query/PhabricatorEmptyQueryException.php rename to src/infrastructure/query/exception/PhabricatorEmptyQueryException.php diff --git a/src/infrastructure/query/exception/PhabricatorInvalidQueryCursorException.php b/src/infrastructure/query/exception/PhabricatorInvalidQueryCursorException.php new file mode 100644 index 0000000000..8a87745f9a --- /dev/null +++ b/src/infrastructure/query/exception/PhabricatorInvalidQueryCursorException.php @@ -0,0 +1,4 @@ +getID(); + } + + protected function newInternalCursorFromExternalCursor($cursor) { + $viewer = $this->getViewer(); + + $query = newv(get_class($this), array()); + + $query + ->setParentQuery($this) + ->setViewer($viewer); + + // We're copying our order vector to the subquery so that the subquery + // knows it should generate any supplemental information required by the + // ordering. + + // For example, Phriction documents may be ordered by title, but the title + // isn't a column in the "document" table: the query must JOIN the + // "content" table to perform the ordering. Passing the ordering to the + // subquery tells it that we need it to do that JOIN and attach relevant + // paging information to the internal cursor object. + + // We only expect to load a single result, so the actual result order does + // not matter. We only want the internal cursor for that result to look + // like a cursor this parent query would generate. + $query->setOrderVector($this->getOrderVector()); + + $this->applyExternalCursorConstraintsToQuery($query, $cursor); + + // If we have a Ferret fulltext query, copy it to the subquery so that we + // generate ranking columns appropriately, and compute the correct object + // ranking score for the current query. + if ($this->ferretEngine) { + $query->withFerretConstraint($this->ferretEngine, $this->ferretTokens); + } + + // We're executing the subquery normally to make sure the viewer can + // actually see the object, and that it's a completely valid object which + // passes all filtering and policy checks. You aren't allowed to use an + // object you can't see as a cursor, since this can leak information. + $result = $query->executeOne(); + if (!$result) { + $this->throwCursorException( + pht( + 'Cursor "%s" does not identify a valid object in query "%s".', + $cursor, + get_class($this))); + } + + // Now that we made sure the viewer can actually see the object the + // external cursor identifies, return the internal cursor the query + // generated as a side effect while loading the object. + return $query->getInternalCursorObject(); + } + + final protected function throwCursorException($message) { + throw new PhabricatorInvalidQueryCursorException($message); + } + + protected function applyExternalCursorConstraintsToQuery( + PhabricatorCursorPagedPolicyAwareQuery $subquery, + $cursor) { + $subquery->withIDs(array($cursor)); + } + + protected function newPagingMapFromCursorObject( + PhabricatorQueryCursor $cursor, + array $keys) { + + $object = $cursor->getObject(); + + return $this->newPagingMapFromPartialObject($object); + } + + protected function newPagingMapFromPartialObject($object) { return array( - $this->getResultCursor(head($page)), - $this->getResultCursor(last($page)), + 'id' => (int)$object->getID(), ); } - protected function getResultCursor($object) { - if (!is_object($object)) { + + final private function getExternalCursorStringForResult($object) { + $cursor = $this->newExternalCursorStringForResult($object); + + if (!is_string($cursor)) { throw new Exception( pht( - 'Expected object, got "%s".', - gettype($object))); + 'Expected "newExternalCursorStringForResult()" in class "%s" to '. + 'return a string, but got "%s".', + get_class($this), + phutil_describe_type($cursor))); } - return $object->getID(); + return $cursor; } - protected function nextPage(array $page) { - // See getPagingViewer() for a description of this flag. - $this->internalPaging = true; - - if ($this->beforeID !== null) { - $page = array_reverse($page, $preserve_keys = true); - list($before, $after) = $this->getPageCursors($page); - $this->beforeID = $before; - } else { - list($before, $after) = $this->getPageCursors($page); - $this->afterID = $after; - } + final private function getExternalCursorString() { + return $this->externalCursorString; } - final public function setAfterID($object_id) { - $this->afterID = $object_id; + final private function setExternalCursorString($external_cursor) { + $this->externalCursorString = $external_cursor; return $this; } - final protected function getAfterID() { - return $this->afterID; + final private function getIsQueryOrderReversed() { + return $this->isQueryOrderReversed; } - final public function setBeforeID($object_id) { - $this->beforeID = $object_id; + final private function setIsQueryOrderReversed($is_reversed) { + $this->isQueryOrderReversed = $is_reversed; return $this; } - final protected function getBeforeID() { - return $this->beforeID; + final private function getInternalCursorObject() { + return $this->internalCursorObject; + } + + final private function setInternalCursorObject( + PhabricatorQueryCursor $cursor) { + $this->internalCursorObject = $cursor; + return $this; + } + + final private function getInternalCursorFromExternalCursor( + $cursor_string) { + + $cursor_object = $this->newInternalCursorFromExternalCursor($cursor_string); + + if (!($cursor_object instanceof PhabricatorQueryCursor)) { + throw new Exception( + pht( + 'Expected "newInternalCursorFromExternalCursor()" to return an '. + 'object of class "PhabricatorQueryCursor", but got "%s" (in '. + 'class "%s").', + phutil_describe_type($cursor_object), + get_class($this))); + } + + return $cursor_object; + } + + final private function getPagingMapFromCursorObject( + PhabricatorQueryCursor $cursor, + array $keys) { + + $map = $this->newPagingMapFromCursorObject($cursor, $keys); + + if (!is_array($map)) { + throw new Exception( + pht( + 'Expected "newPagingMapFromCursorObject()" to return a map of '. + 'paging values, but got "%s" (in class "%s").', + phutil_describe_type($map), + get_class($this))); + } + + if ($this->supportsFerretEngine()) { + if ($this->hasFerretOrder()) { + $map += array( + 'rank' => + $cursor->getRawRowProperty(self::FULLTEXT_RANK), + 'fulltext-modified' => + $cursor->getRawRowProperty(self::FULLTEXT_MODIFIED), + 'fulltext-created' => + $cursor->getRawRowProperty(self::FULLTEXT_CREATED), + ); + } + } + + foreach ($keys as $key) { + if (!array_key_exists($key, $map)) { + throw new Exception( + pht( + 'Map returned by "newPagingMapFromCursorObject()" in class "%s" '. + 'omits required key "%s".', + get_class($this), + $key)); + } + } + + return $map; + } + + final protected function nextPage(array $page) { + if (!$page) { + return; + } + + $cursor = id(new PhabricatorQueryCursor()) + ->setObject(last($page)); + + if ($this->rawCursorRow) { + $cursor->setRawRow($this->rawCursorRow); + } + + $this->setInternalCursorObject($cursor); } final public function getFerretMetadata() { @@ -145,6 +315,8 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery } protected function didLoadRawRows(array $rows) { + $this->rawCursorRow = last($rows); + if ($this->ferretEngine) { foreach ($rows as $row) { $phid = $row['phid']; @@ -152,56 +324,19 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery $metadata = id(new PhabricatorFerretMetadata()) ->setPHID($phid) ->setEngine($this->ferretEngine) - ->setRelevance(idx($row, '_ft_rank')); + ->setRelevance(idx($row, self::FULLTEXT_RANK)); $this->ferretMetadata[$phid] = $metadata; - unset($row['_ft_rank']); + unset($row[self::FULLTEXT_RANK]); + unset($row[self::FULLTEXT_MODIFIED]); + unset($row[self::FULLTEXT_CREATED]); } } return $rows; } - /** - * Get the viewer for making cursor paging queries. - * - * NOTE: You should ONLY use this viewer to load cursor objects while - * building paging queries. - * - * Cursor paging can happen in two ways. First, the user can request a page - * like `/stuff/?after=33`, which explicitly causes paging. Otherwise, we - * can fall back to implicit paging if we filter some results out of a - * result list because the user can't see them and need to go fetch some more - * results to generate a large enough result list. - * - * In the first case, want to use the viewer's policies to load the object. - * This prevents an attacker from figuring out information about an object - * they can't see by executing queries like `/stuff/?after=33&order=name`, - * which would otherwise give them a hint about the name of the object. - * Generally, if a user can't see an object, they can't use it to page. - * - * In the second case, we need to load the object whether the user can see - * it or not, because we need to examine new results. For example, if a user - * loads `/stuff/` and we run a query for the first 100 items that they can - * see, but the first 100 rows in the database aren't visible, we need to - * be able to issue a query for the next 100 results. If we can't load the - * cursor object, we'll fail or issue the same query over and over again. - * So, generally, internal paging must bypass policy controls. - * - * This method returns the appropriate viewer, based on the context in which - * the paging is occurring. - * - * @return PhabricatorUser Viewer for executing paging queries. - */ - final protected function getPagingViewer() { - if ($this->internalPaging) { - return PhabricatorUser::getOmnipotentUser(); - } else { - return $this->getViewer(); - } - } - final protected function buildLimitClause(AphrontDatabaseConnection $conn) { if ($this->shouldLimitResults()) { $limit = $this->getRawResultLimit(); @@ -218,7 +353,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery } final protected function didLoadResults(array $results) { - if ($this->beforeID) { + if ($this->getIsQueryOrderReversed()) { $results = array_reverse($results, $preserve_keys = true); } @@ -230,10 +365,11 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery $this->setLimit($limit + 1); - if ($pager->getAfterID()) { - $this->setAfterID($pager->getAfterID()); + if (strlen($pager->getAfterID())) { + $this->setExternalCursorString($pager->getAfterID()); } else if ($pager->getBeforeID()) { - $this->setBeforeID($pager->getBeforeID()); + $this->setExternalCursorString($pager->getBeforeID()); + $this->setIsQueryOrderReversed(true); } $results = $this->execute(); @@ -241,15 +377,22 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery $sliced_results = $pager->sliceResults($results); if ($sliced_results) { - list($before, $after) = $this->getPageCursors($sliced_results); + + // If we have results, generate external-facing cursors from the visible + // results. This stops us from leaking any internal details about objects + // which we loaded but which were not visible to the viewer. if ($pager->getBeforeID() || ($count > $limit)) { - $pager->setNextPageID($after); + $last_object = last($sliced_results); + $cursor = $this->getExternalCursorStringForResult($last_object); + $pager->setNextPageID($cursor); } if ($pager->getAfterID() || ($pager->getBeforeID() && ($count > $limit))) { - $pager->setPrevPageID($before); + $head_object = head($sliced_results); + $cursor = $this->getExternalCursorStringForResult($head_object); + $pager->setPrevPageID($cursor); } } @@ -344,7 +487,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery */ protected function buildWhereClauseParts(AphrontDatabaseConnection $conn) { $where = array(); - $where[] = $this->buildPagingClause($conn); + $where[] = $this->buildPagingWhereClause($conn); $where[] = $this->buildEdgeLogicWhereClause($conn); $where[] = $this->buildSpacesWhereClause($conn); $where[] = $this->buildNgramsWhereClause($conn); @@ -359,6 +502,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery */ protected function buildHavingClause(AphrontDatabaseConnection $conn) { $having = $this->buildHavingClauseParts($conn); + $having[] = $this->buildPagingHavingClause($conn); return $this->formatHavingClause($conn, $having); } @@ -416,45 +560,86 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery /* -( Paging )------------------------------------------------------------- */ + private function buildPagingWhereClause(AphrontDatabaseConnection $conn) { + if ($this->shouldPageWithHavingClause()) { + return null; + } + + return $this->buildPagingClause($conn); + } + + private function buildPagingHavingClause(AphrontDatabaseConnection $conn) { + if (!$this->shouldPageWithHavingClause()) { + return null; + } + + return $this->buildPagingClause($conn); + } + + private function shouldPageWithHavingClause() { + // If any of the paging conditions reference dynamic columns, we need to + // put the paging conditions in a "HAVING" clause instead of a "WHERE" + // clause. + + // For example, this happens when paging on the Ferret "rank" column, + // since the "rank" value is computed dynamically in the SELECT statement. + + $orderable = $this->getOrderableColumns(); + $vector = $this->getOrderVector(); + + foreach ($vector as $order) { + $key = $order->getOrderKey(); + $column = $orderable[$key]; + + if (!empty($column['having'])) { + return true; + } + } + + return false; + } + /** * @task paging */ protected function buildPagingClause(AphrontDatabaseConnection $conn) { $orderable = $this->getOrderableColumns(); - $vector = $this->getOrderVector(); + $vector = $this->getQueryableOrderVector(); - if ($this->beforeID !== null) { - $cursor = $this->beforeID; - $reversed = true; - } else if ($this->afterID !== null) { - $cursor = $this->afterID; - $reversed = false; - } else { - // No paging is being applied to this query so we do not need to - // construct a paging clause. + // If we don't have a cursor object yet, it means we're trying to load + // the first result page. We may need to build a cursor object from the + // external string, or we may not need a paging clause yet. + $cursor_object = $this->getInternalCursorObject(); + if (!$cursor_object) { + $external_cursor = $this->getExternalCursorString(); + if ($external_cursor !== null) { + $cursor_object = $this->getInternalCursorFromExternalCursor( + $external_cursor); + } + } + + // If we still don't have a cursor object, this is the first result page + // and we aren't paging it. We don't need to build a paging clause. + if (!$cursor_object) { return qsprintf($conn, ''); } + $reversed = $this->getIsQueryOrderReversed(); + $keys = array(); foreach ($vector as $order) { $keys[] = $order->getOrderKey(); } + $keys = array_fuse($keys); - $value_map = $this->getPagingValueMap($cursor, $keys); + $value_map = $this->getPagingMapFromCursorObject( + $cursor_object, + $keys); $columns = array(); foreach ($vector as $order) { $key = $order->getOrderKey(); - if (!array_key_exists($key, $value_map)) { - throw new Exception( - pht( - 'Query "%s" failed to return a value from getPagingValueMap() '. - 'for column "%s".', - get_class($this), - $key)); - } - $column = $orderable[$key]; $column['value'] = $value_map[$key]; @@ -476,48 +661,6 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery } - /** - * @task paging - */ - protected function getPagingValueMap($cursor, array $keys) { - return array( - 'id' => $cursor, - ); - } - - - /** - * @task paging - */ - protected function loadCursorObject($cursor) { - $query = newv(get_class($this), array()) - ->setViewer($this->getPagingViewer()) - ->withIDs(array((int)$cursor)); - - $this->willExecuteCursorQuery($query); - - $object = $query->executeOne(); - if (!$object) { - throw new Exception( - pht( - 'Cursor "%s" does not identify a valid object in query "%s".', - $cursor, - get_class($this))); - } - - return $object; - } - - - /** - * @task paging - */ - protected function willExecuteCursorQuery( - PhabricatorCursorPagedPolicyAwareQuery $query) { - return; - } - - /** * Simplifies the task of constructing a paging clause across multiple * columns. In the general case, this looks like: @@ -572,6 +715,8 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery 'reverse' => 'optional bool', 'unique' => 'optional bool', 'null' => 'optional string|null', + 'requires-ferret' => 'optional bool', + 'having' => 'optional bool', )); } @@ -1020,18 +1165,22 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery if ($this->supportsFerretEngine()) { $columns['rank'] = array( 'table' => null, - 'column' => '_ft_rank', + 'column' => self::FULLTEXT_RANK, 'type' => 'int', + 'requires-ferret' => true, + 'having' => true, ); $columns['fulltext-created'] = array( - 'table' => 'ft_doc', - 'column' => 'epochCreated', + 'table' => null, + 'column' => self::FULLTEXT_CREATED, 'type' => 'int', + 'requires-ferret' => true, ); $columns['fulltext-modified'] = array( - 'table' => 'ft_doc', - 'column' => 'epochModified', + 'table' => null, + 'column' => self::FULLTEXT_MODIFIED, 'type' => 'int', + 'requires-ferret' => true, ); } @@ -1049,11 +1198,12 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery $for_union = false) { $orderable = $this->getOrderableColumns(); - $vector = $this->getOrderVector(); + $vector = $this->getQueryableOrderVector(); $parts = array(); foreach ($vector as $order) { $part = $orderable[$order->getOrderKey()]; + if ($order->getIsReversed()) { $part['reverse'] = !idx($part, 'reverse', false); } @@ -1063,6 +1213,31 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery return $this->formatOrderClause($conn, $parts, $for_union); } + /** + * @task order + */ + private function getQueryableOrderVector() { + $vector = $this->getOrderVector(); + $orderable = $this->getOrderableColumns(); + + $keep = array(); + foreach ($vector as $order) { + $column = $orderable[$order->getOrderKey()]; + + // If this is a Ferret fulltext column but the query doesn't actually + // have a fulltext query, we'll skip most of the Ferret stuff and won't + // actually have the columns in the result set. Just skip them. + if (!empty($column['requires-ferret'])) { + if (!$this->getFerretTokens()) { + continue; + } + } + + $keep[] = $order->getAsScalar(); + } + + return PhabricatorQueryOrderVector::newFromVector($keep); + } /** * @task order @@ -1072,10 +1247,7 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery array $parts, $for_union = false) { - $is_query_reversed = false; - if ($this->getBeforeID()) { - $is_query_reversed = !$is_query_reversed; - } + $is_query_reversed = $this->getIsQueryOrderReversed(); $sql = array(); foreach ($parts as $key => $part) { @@ -1233,6 +1405,8 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery 'index' => $index->getIndexKey(), 'alias' => $alias, 'value' => array($min, $max), + 'data' => null, + 'constraints' => null, ); return $this; @@ -1666,15 +1840,16 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery return $select; } - $vector = $this->getOrderVector(); - if (!$vector->containsKey('rank')) { - // We only need to SELECT the virtual "_ft_rank" column if we're + if (!$this->hasFerretOrder()) { + // We only need to SELECT the virtual rank/relevance columns if we're // actually sorting the results by rank. return $select; } if (!$this->ferretEngine) { - $select[] = qsprintf($conn, '0 _ft_rank'); + $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_RANK); + $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_CREATED); + $select[] = qsprintf($conn, '0 AS %T', self::FULLTEXT_MODIFIED); return $select; } @@ -1753,8 +1928,27 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery $select[] = qsprintf( $conn, - '%Q _ft_rank', - $sum); + '%Q AS %T', + $sum, + self::FULLTEXT_RANK); + + // See D20297. We select these as real columns in the result set so that + // constructions like this will work: + // + // ((SELECT ...) UNION (SELECT ...)) ORDER BY ... + // + // If the columns aren't part of the result set, the final "ORDER BY" can + // not act on them. + + $select[] = qsprintf( + $conn, + 'ft_doc.epochCreated AS %T', + self::FULLTEXT_CREATED); + + $select[] = qsprintf( + $conn, + 'ft_doc.epochModified AS %T', + self::FULLTEXT_MODIFIED); return $select; } @@ -2959,4 +3153,22 @@ abstract class PhabricatorCursorPagedPolicyAwareQuery } } + private function hasFerretOrder() { + $vector = $this->getOrderVector(); + + if ($vector->containsKey('rank')) { + return true; + } + + if ($vector->containsKey('fulltext-created')) { + return true; + } + + if ($vector->containsKey('fulltext-modified')) { + return true; + } + + return false; + } + } diff --git a/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php b/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php index 7aa0f28dfe..8780584f94 100644 --- a/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php +++ b/src/infrastructure/query/policy/PhabricatorPolicyAwareQuery.php @@ -45,6 +45,8 @@ abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery { */ private $raisePolicyExceptions; private $isOverheated; + private $returnPartialResultsOnOverheat; + private $disableOverheating; /* -( Query Configuration )------------------------------------------------ */ @@ -130,6 +132,16 @@ abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery { return $this; } + final public function setReturnPartialResultsOnOverheat($bool) { + $this->returnPartialResultsOnOverheat = $bool; + return $this; + } + + final public function setDisableOverheating($disable_overheating) { + $this->disableOverheating = $disable_overheating; + return $this; + } + /* -( Query Execution )---------------------------------------------------- */ @@ -282,6 +294,13 @@ abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery { $this->didFilterResults($removed); + // NOTE: We call "nextPage()" before checking if we've found enough + // results because we want to build the internal cursor object even + // if we don't need to execute another query: the internal cursor may + // be used by a parent query that is using this query to translate an + // external cursor into an internal cursor. + $this->nextPage($page); + foreach ($visible as $key => $result) { ++$count; @@ -312,11 +331,22 @@ abstract class PhabricatorPolicyAwareQuery extends PhabricatorOffsetPagedQuery { break; } - $this->nextPage($page); + if (!$this->disableOverheating) { + if ($overheat_limit && ($total_seen >= $overheat_limit)) { + $this->isOverheated = true; - if ($overheat_limit && ($total_seen >= $overheat_limit)) { - $this->isOverheated = true; - break; + if (!$this->returnPartialResultsOnOverheat) { + throw new Exception( + pht( + 'Query (of class "%s") overheated: examined more than %s '. + 'raw rows without finding %s visible objects.', + get_class($this), + new PhutilNumber($overheat_limit), + new PhutilNumber($need))); + } + + break; + } } } while (true); diff --git a/src/infrastructure/query/policy/PhabricatorQueryCursor.php b/src/infrastructure/query/policy/PhabricatorQueryCursor.php new file mode 100644 index 0000000000..4ec1263130 --- /dev/null +++ b/src/infrastructure/query/policy/PhabricatorQueryCursor.php @@ -0,0 +1,47 @@ +object = $object; + return $this; + } + + public function getObject() { + return $this->object; + } + + public function setRawRow(array $raw_row) { + $this->rawRow = $raw_row; + return $this; + } + + public function getRawRow() { + return $this->rawRow; + } + + public function getRawRowProperty($key) { + if (!is_array($this->rawRow)) { + throw new Exception( + pht( + 'Caller is trying to "getRawRowProperty()" with key "%s", but this '. + 'cursor has no raw row.', + $key)); + } + + if (!array_key_exists($key, $this->rawRow)) { + throw new Exception( + pht( + 'Caller is trying to access raw row property "%s", but the row '. + 'does not have this property.', + $key)); + } + + return $this->rawRow[$key]; + } + +} diff --git a/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php b/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php index e47d701df9..85adb4fc29 100644 --- a/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php +++ b/src/infrastructure/storage/lisk/PhabricatorLiskDAO.php @@ -6,6 +6,7 @@ abstract class PhabricatorLiskDAO extends LiskDAO { private static $namespaceStack = array(); + private $forcedNamespace; const ATTACHABLE = ''; const CONFIG_APPLICATION_SERIALIZERS = 'phabricator/serializers'; @@ -47,6 +48,11 @@ abstract class PhabricatorLiskDAO extends LiskDAO { return $namespace; } + public function setForcedStorageNamespace($namespace) { + $this->forcedNamespace = $namespace; + return $this; + } + /** * @task config */ @@ -104,6 +110,19 @@ abstract class PhabricatorLiskDAO extends LiskDAO { $connection = $replica->newApplicationConnection($database); $connection->setReadOnly(true); if ($replica->isReachable($connection)) { + if ($master_exception) { + // If we ended up here as the result of a failover, log the + // exception. This is seriously bad news even if we are able + // to recover from it. + $proxy_exception = new PhutilProxyException( + pht( + 'Failed to connect to master database ("%s"), failing over '. + 'into read-only mode.', + $database), + $master_exception); + phlog($proxy_exception); + } + return $connection; } } @@ -188,7 +207,13 @@ abstract class PhabricatorLiskDAO extends LiskDAO { abstract public function getApplicationName(); protected function getDatabaseName() { - return self::getStorageNamespace().'_'.$this->getApplicationName(); + if ($this->forcedNamespace) { + $namespace = $this->forcedNamespace; + } else { + $namespace = self::getStorageNamespace(); + } + + return $namespace.'_'.$this->getApplicationName(); } /** diff --git a/src/infrastructure/storage/lisk/PhabricatorQueryIterator.php b/src/infrastructure/storage/lisk/PhabricatorQueryIterator.php index 03aaf5707e..648b83863a 100644 --- a/src/infrastructure/storage/lisk/PhabricatorQueryIterator.php +++ b/src/infrastructure/storage/lisk/PhabricatorQueryIterator.php @@ -25,6 +25,8 @@ final class PhabricatorQueryIterator extends PhutilBufferedIterator { $pager = clone $this->pager; $query = clone $this->query; + $query->setDisableOverheating(true); + $results = $query->executeWithCursorPager($pager); // If we got less than a full page of results, this was the last set of diff --git a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php index 5bc83972dd..acbbb4fbda 100644 --- a/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php +++ b/src/infrastructure/storage/management/workflow/PhabricatorStorageManagementWorkflow.php @@ -432,7 +432,7 @@ abstract class PhabricatorStorageManagementWorkflow case 'key': if (($phase == 'drop_keys') && $adjust['exists']) { if ($adjust['name'] == 'PRIMARY') { - $key_name = 'PRIMARY KEY'; + $key_name = qsprintf($conn, 'PRIMARY KEY'); } else { $key_name = qsprintf($conn, 'KEY %T', $adjust['name']); } diff --git a/src/infrastructure/util/PhabricatorMetronome.php b/src/infrastructure/util/PhabricatorMetronome.php new file mode 100644 index 0000000000..24f58127f6 --- /dev/null +++ b/src/infrastructure/util/PhabricatorMetronome.php @@ -0,0 +1,92 @@ +offset = $offset; + + return $this; + } + + public function setFrequency($frequency) { + if (!is_int($frequency)) { + throw new Exception(pht('Metronome frequency must be an integer.')); + } + + if ($frequency < 1) { + throw new Exception(pht('Metronome frequency must be 1 or more.')); + } + + $this->frequency = $frequency; + + return $this; + } + + public function setOffsetFromSeed($seed) { + $offset = PhabricatorHash::digestToRange($seed, 0, PHP_INT_MAX); + return $this->setOffset($offset); + } + + public function getFrequency() { + if ($this->frequency === null) { + throw new PhutilInvalidStateException('setFrequency'); + } + return $this->frequency; + } + + public function getOffset() { + $frequency = $this->getFrequency(); + return ($this->offset % $frequency); + } + + public function getNextTickAfter($epoch) { + $frequency = $this->getFrequency(); + $offset = $this->getOffset(); + + $remainder = ($epoch % $frequency); + + if ($remainder < $offset) { + return ($epoch - $remainder) + $offset; + } else { + return ($epoch - $remainder) + $frequency + $offset; + } + } + + public function didTickBetween($min, $max) { + if ($max < $min) { + throw new Exception( + pht( + 'Maximum tick window must not be smaller than minimum tick window.')); + } + + $next = $this->getNextTickAfter($min); + return ($next <= $max); + } + +} diff --git a/src/infrastructure/util/__tests__/PhabricatorMetronomeTestCase.php b/src/infrastructure/util/__tests__/PhabricatorMetronomeTestCase.php new file mode 100644 index 0000000000..9ad74e2b90 --- /dev/null +++ b/src/infrastructure/util/__tests__/PhabricatorMetronomeTestCase.php @@ -0,0 +1,61 @@ + 44, + 'web002.example.net' => 36, + 'web003.example.net' => 25, + 'web004.example.net' => 25, + 'web005.example.net' => 16, + 'web006.example.net' => 26, + 'web007.example.net' => 35, + 'web008.example.net' => 14, + ); + + $metronome = id(new PhabricatorMetronome()) + ->setFrequency(60); + + foreach ($cases as $input => $expect) { + $metronome->setOffsetFromSeed($input); + + $this->assertEqual( + $expect, + $metronome->getOffset(), + pht('Offset for: %s', $input)); + } + } + + public function testMetronomeTicks() { + $metronome = id(new PhabricatorMetronome()) + ->setFrequency(60) + ->setOffset(13); + + $tick_epoch = strtotime('2000-01-01 11:11:13 AM UTC'); + + // Since the epoch is at "0:13" on the clock, the metronome should tick + // then. + $this->assertEqual( + $tick_epoch, + $metronome->getNextTickAfter($tick_epoch - 1), + pht('Tick at 11:11:13 AM.')); + + // The next tick should be a minute later. + $this->assertEqual( + $tick_epoch + 60, + $metronome->getNextTickAfter($tick_epoch), + pht('Tick at 11:12:13 AM.')); + + + // There's no tick in the next 59 seconds. + $this->assertFalse( + $metronome->didTickBetween($tick_epoch, $tick_epoch + 59)); + + $this->assertTrue( + $metronome->didTickBetween($tick_epoch, $tick_epoch + 60)); + } + + +} diff --git a/src/view/control/AphrontCursorPagerView.php b/src/view/control/AphrontCursorPagerView.php index 4608158481..cdb9562624 100644 --- a/src/view/control/AphrontCursorPagerView.php +++ b/src/view/control/AphrontCursorPagerView.php @@ -99,9 +99,9 @@ final class AphrontCursorPagerView extends AphrontView { return null; } - return $this->uri - ->alter('before', null) - ->alter('after', null); + return id(clone $this->uri) + ->removeQueryParam('after') + ->removeQueryParam('before'); } public function getPrevPageURI() { @@ -113,9 +113,9 @@ final class AphrontCursorPagerView extends AphrontView { return null; } - return $this->uri - ->alter('after', null) - ->alter('before', $this->prevPageID); + return id(clone $this->uri) + ->removeQueryParam('after') + ->replaceQueryParam('before', $this->prevPageID); } public function getNextPageURI() { @@ -127,9 +127,9 @@ final class AphrontCursorPagerView extends AphrontView { return null; } - return $this->uri - ->alter('after', $this->nextPageID) - ->alter('before', null); + return id(clone $this->uri) + ->replaceQueryParam('after', $this->nextPageID) + ->removeQueryParam('before'); } public function render() { diff --git a/src/view/form/control/AphrontFormTextControl.php b/src/view/form/control/AphrontFormTextControl.php index 581f22682d..f7fd117cfd 100644 --- a/src/view/form/control/AphrontFormTextControl.php +++ b/src/view/form/control/AphrontFormTextControl.php @@ -5,6 +5,7 @@ final class AphrontFormTextControl extends AphrontFormControl { private $disableAutocomplete; private $sigil; private $placeholder; + private $autofocus; public function setDisableAutocomplete($disable) { $this->disableAutocomplete = $disable; @@ -24,6 +25,15 @@ final class AphrontFormTextControl extends AphrontFormControl { return $this; } + public function setAutofocus($autofocus) { + $this->autofocus = $autofocus; + return $this; + } + + public function getAutofocus() { + return $this->autofocus; + } + public function getSigil() { return $this->sigil; } @@ -49,6 +59,7 @@ final class AphrontFormTextControl extends AphrontFormControl { 'id' => $this->getID(), 'sigil' => $this->getSigil(), 'placeholder' => $this->getPlaceholder(), + 'autofocus' => ($this->getAutofocus() ? 'autofocus' : null), )); } diff --git a/src/view/form/control/AphrontFormTokenizerControl.php b/src/view/form/control/AphrontFormTokenizerControl.php index 3d65c4e525..fe80c86f81 100644 --- a/src/view/form/control/AphrontFormTokenizerControl.php +++ b/src/view/form/control/AphrontFormTokenizerControl.php @@ -108,6 +108,10 @@ final class AphrontFormTokenizerControl extends AphrontFormControl { 'icons' => mpull($tokens, 'getIcon', 'getKey'), 'types' => mpull($tokens, 'getTokenType', 'getKey'), 'colors' => mpull($tokens, 'getColor', 'getKey'), + 'availabilityColors' => mpull( + $tokens, + 'getAvailabilityColor', + 'getKey'), 'limit' => $this->limit, 'username' => $username, 'placeholder' => $placeholder, diff --git a/src/view/form/control/PHUIFormNumberControl.php b/src/view/form/control/PHUIFormNumberControl.php index 26e7e03955..c577bebbd0 100644 --- a/src/view/form/control/PHUIFormNumberControl.php +++ b/src/view/form/control/PHUIFormNumberControl.php @@ -3,6 +3,7 @@ final class PHUIFormNumberControl extends AphrontFormControl { private $disableAutocomplete; + private $autofocus; public function setDisableAutocomplete($disable_autocomplete) { $this->disableAutocomplete = $disable_autocomplete; @@ -13,6 +14,15 @@ final class PHUIFormNumberControl extends AphrontFormControl { return $this->disableAutocomplete; } + public function setAutofocus($autofocus) { + $this->autofocus = $autofocus; + return $this; + } + + public function getAutofocus() { + return $this->autofocus; + } + protected function getCustomControlClass() { return 'phui-form-number'; } @@ -34,6 +44,7 @@ final class PHUIFormNumberControl extends AphrontFormControl { 'disabled' => $this->getDisabled() ? 'disabled' : null, 'autocomplete' => $autocomplete, 'id' => $this->getID(), + 'autofocus' => ($this->getAutofocus() ? 'autofocus' : null), )); } diff --git a/src/view/form/control/PHUIFormTimerControl.php b/src/view/form/control/PHUIFormTimerControl.php index 7229d649e9..090de2c8e4 100644 --- a/src/view/form/control/PHUIFormTimerControl.php +++ b/src/view/form/control/PHUIFormTimerControl.php @@ -3,6 +3,7 @@ final class PHUIFormTimerControl extends AphrontFormControl { private $icon; + private $updateURI; public function setIcon(PHUIIconView $icon) { $this->icon = $icon; @@ -13,11 +14,24 @@ final class PHUIFormTimerControl extends AphrontFormControl { return $this->icon; } + public function setUpdateURI($update_uri) { + $this->updateURI = $update_uri; + return $this; + } + + public function getUpdateURI() { + return $this->updateURI; + } + protected function getCustomControlClass() { return 'phui-form-timer'; } protected function renderInput() { + return $this->newTimerView(); + } + + public function newTimerView() { $icon_cell = phutil_tag( 'td', array( @@ -34,7 +48,21 @@ final class PHUIFormTimerControl extends AphrontFormControl { $row = phutil_tag('tr', array(), array($icon_cell, $content_cell)); - return phutil_tag('table', array(), $row); + $node_id = null; + + $update_uri = $this->getUpdateURI(); + if ($update_uri) { + $node_id = celerity_generate_unique_node_id(); + + Javelin::initBehavior( + 'phui-timer-control', + array( + 'nodeID' => $node_id, + 'uri' => $update_uri, + )); + } + + return phutil_tag('table', array('id' => $node_id), $row); } } diff --git a/src/view/layout/PhabricatorActionView.php b/src/view/layout/PhabricatorActionView.php index a1d8fe2664..3de60a2374 100644 --- a/src/view/layout/PhabricatorActionView.php +++ b/src/view/layout/PhabricatorActionView.php @@ -25,6 +25,7 @@ final class PhabricatorActionView extends AphrontView { const TYPE_DIVIDER = 'type-divider'; const TYPE_LABEL = 'label'; const RED = 'action-item-red'; + const GREEN = 'action-item-green'; public function setSelected($selected) { $this->selected = $selected; diff --git a/src/view/page/PhabricatorStandardPageView.php b/src/view/page/PhabricatorStandardPageView.php index 99143add5f..1933f223e5 100644 --- a/src/view/page/PhabricatorStandardPageView.php +++ b/src/view/page/PhabricatorStandardPageView.php @@ -32,18 +32,6 @@ final class PhabricatorStandardPageView extends PhabricatorBarePageView return $this->showFooter; } - public function setApplicationMenu($application_menu) { - // NOTE: For now, this can either be a PHUIListView or a - // PHUIApplicationMenuView. - - $this->applicationMenu = $application_menu; - return $this; - } - - public function getApplicationMenu() { - return $this->applicationMenu; - } - public function setApplicationName($application_name) { $this->applicationName = $application_name; return $this; @@ -345,7 +333,7 @@ final class PhabricatorStandardPageView extends PhabricatorBarePageView $menu->setController($this->getController()); } - $application_menu = $this->getApplicationMenu(); + $application_menu = $this->applicationMenu; if ($application_menu) { if ($application_menu instanceof PHUIApplicationMenuView) { $crumbs = $this->getCrumbs(); @@ -865,13 +853,6 @@ final class PhabricatorStandardPageView extends PhabricatorBarePageView public function produceAphrontResponse() { $controller = $this->getController(); - if (!$this->getApplicationMenu()) { - $application_menu = $controller->buildApplicationMenu(); - if ($application_menu) { - $this->setApplicationMenu($application_menu); - } - } - $viewer = $this->getUser(); if ($viewer && $viewer->getPHID()) { $object_phids = $this->pageObjects; @@ -887,18 +868,22 @@ final class PhabricatorStandardPageView extends PhabricatorBarePageView $response = id(new AphrontAjaxResponse()) ->setContent($content); } else { + // See T13247. Try to find some navigational menu items to create a + // mobile navigation menu from. + $application_menu = $controller->buildApplicationMenu(); + if (!$application_menu) { + $navigation = $this->getNavigation(); + if ($navigation) { + $application_menu = $navigation->getMenu(); + } + } + $this->applicationMenu = $application_menu; + $content = $this->render(); $response = id(new AphrontWebpageResponse()) ->setContent($content) ->setFrameable($this->getFrameable()); - - $static = CelerityAPI::getStaticResourceResponse(); - foreach ($static->getContentSecurityPolicyURIMap() as $kind => $uris) { - foreach ($uris as $uri) { - $response->addContentSecurityPolicyURI($kind, $uri); - } - } } return $response; diff --git a/src/view/phui/PHUIHeadThingView.php b/src/view/phui/PHUIHeadThingView.php index ab2feee984..219ed28be0 100644 --- a/src/view/phui/PHUIHeadThingView.php +++ b/src/view/phui/PHUIHeadThingView.php @@ -52,12 +52,13 @@ final class PHUIHeadThingView extends AphrontTagView { protected function getTagContent() { - $image = phutil_tag( + $image = javelin_tag( 'a', array( - 'class' => 'phui-head-thing-image visual-only', + 'class' => 'phui-head-thing-image', 'style' => 'background-image: url('.$this->image.');', 'href' => $this->imageHref, + 'aural' => false, )); if ($this->image) { diff --git a/src/view/phui/PHUIIconView.php b/src/view/phui/PHUIIconView.php index 8cc61ba2eb..d907cb3343 100644 --- a/src/view/phui/PHUIIconView.php +++ b/src/view/phui/PHUIIconView.php @@ -19,6 +19,7 @@ final class PHUIIconView extends AphrontTagView { private $iconColor; private $iconBackground; private $tooltip; + private $emblemColor; public function setHref($href) { $this->href = $href; @@ -66,6 +67,15 @@ final class PHUIIconView extends AphrontTagView { return $this; } + public function setEmblemColor($emblem_color) { + $this->emblemColor = $emblem_color; + return $this; + } + + public function getEmblemColor() { + return $this->emblemColor; + } + protected function getTagName() { $tag = 'span'; if ($this->href) { @@ -106,6 +116,10 @@ final class PHUIIconView extends AphrontTagView { $this->appendChild($this->text); } + if ($this->emblemColor) { + $classes[] = 'phui-icon-emblem phui-icon-emblem-'.$this->emblemColor; + } + $sigil = null; $meta = array(); if ($this->tooltip) { diff --git a/src/view/phui/PHUIObjectItemListView.php b/src/view/phui/PHUIObjectItemListView.php index 53e86382c2..fbc3904586 100644 --- a/src/view/phui/PHUIObjectItemListView.php +++ b/src/view/phui/PHUIObjectItemListView.php @@ -12,6 +12,7 @@ final class PHUIObjectItemListView extends AphrontTagView { private $drag; private $allowEmptyList; private $itemClass = 'phui-oi-standard'; + private $tail = array(); public function setAllowEmptyList($allow_empty_list) { $this->allowEmptyList = $allow_empty_list; @@ -72,6 +73,18 @@ final class PHUIObjectItemListView extends AphrontTagView { return 'ul'; } + public function newTailButton() { + $button = id(new PHUIButtonView()) + ->setTag('a') + ->setColor(PHUIButtonView::GREY) + ->setIcon('fa-chevron-down') + ->setText(pht('View All Results')); + + $this->tail[] = $button; + + return $button; + } + protected function getTagAttributes() { $classes = array(); $classes[] = 'phui-oi-list-view'; @@ -149,9 +162,20 @@ final class PHUIObjectItemListView extends AphrontTagView { $pager = $this->pager; } + $tail = array(); + foreach ($this->tail as $tail_item) { + $tail[] = phutil_tag( + 'li', + array( + 'class' => 'phui-oi-tail', + ), + $tail_item); + } + return array( $header, $items, + $tail, $pager, $this->renderChildren(), ); diff --git a/src/view/phui/PHUIObjectItemView.php b/src/view/phui/PHUIObjectItemView.php index e1c67c7f32..40dd8f0a4b 100644 --- a/src/view/phui/PHUIObjectItemView.php +++ b/src/view/phui/PHUIObjectItemView.php @@ -330,8 +330,14 @@ final class PHUIObjectItemView extends AphrontTagView { Javelin::initBehavior('phui-selectable-list'); } - if ($this->getGrippable()) { - $item_classes[] = 'phui-oi-grippable'; + $is_grippable = $this->getGrippable(); + if ($is_grippable !== null) { + $item_classes[] = 'phui-oi-has-grip'; + if ($is_grippable) { + $item_classes[] = 'phui-oi-grippable'; + } else { + $item_classes[] = 'phui-oi-ungrippable'; + } } if ($this->getImageURI()) { @@ -414,25 +420,17 @@ final class PHUIObjectItemView extends AphrontTagView { )); } - // Wrap the header content in a with the "slippery" sigil. This - // prevents us from beginning a drag if you click the text (like "T123"), - // but not if you click the white space after the header. $header = phutil_tag( 'div', array( 'class' => 'phui-oi-name', ), - javelin_tag( - 'span', - array( - 'sigil' => 'slippery', - ), - array( - $this->headIcons, - $header_name, - $header_link, - $description_tag, - ))); + array( + $this->headIcons, + $header_name, + $header_link, + $description_tag, + )); $icons = array(); if ($this->icons) { @@ -588,7 +586,7 @@ final class PHUIObjectItemView extends AphrontTagView { } $grippable = null; - if ($this->getGrippable()) { + if ($this->getGrippable() !== null) { $grippable = phutil_tag( 'div', array( diff --git a/src/view/phui/PHUIPagerView.php b/src/view/phui/PHUIPagerView.php index b78efcda96..2bb3a8276e 100644 --- a/src/view/phui/PHUIPagerView.php +++ b/src/view/phui/PHUIPagerView.php @@ -187,9 +187,15 @@ final class PHUIPagerView extends AphrontView { foreach ($pager_index as $key => $index) { if ($index !== null) { $display_index = $this->getDisplayIndex($index); - $pager_links[$key] = (string)$base_uri->alter( - $parameter, - $display_index); + + $uri = id(clone $base_uri); + if ($display_index === null) { + $uri->removeQueryParam($parameter); + } else { + $uri->replaceQueryParam($parameter, $display_index); + } + + $pager_links[$key] = phutil_string_cast($uri); } } Javelin::initBehavior('phabricator-keyboard-pager', $pager_links); @@ -200,10 +206,17 @@ final class PHUIPagerView extends AphrontView { foreach ($links as $link) { list($index, $label, $class) = $link; $display_index = $this->getDisplayIndex($index); - $link = $base_uri->alter($parameter, $display_index); + + $uri = id(clone $base_uri); + if ($display_index === null) { + $uri->removeQueryParam($parameter); + } else { + $uri->replaceQueryParam($parameter, $display_index); + } + $rendered_links[] = id(new PHUIButtonView()) ->setTag('a') - ->setHref($link) + ->setHref($uri) ->setColor(PHUIButtonView::GREY) ->addClass('mml') ->addClass($class) diff --git a/src/view/phui/PHUITimelineEventView.php b/src/view/phui/PHUITimelineEventView.php index 86628058fe..5013611084 100644 --- a/src/view/phui/PHUITimelineEventView.php +++ b/src/view/phui/PHUITimelineEventView.php @@ -31,6 +31,7 @@ final class PHUITimelineEventView extends AphrontView { private $pinboardItems = array(); private $isSilent; private $isMFA; + private $isLockOverride; public function setAuthorPHID($author_phid) { $this->authorPHID = $author_phid; @@ -197,6 +198,15 @@ final class PHUITimelineEventView extends AphrontView { return $this->isMFA; } + public function setIsLockOverride($is_override) { + $this->isLockOverride = $is_override; + return $this; + } + + public function getIsLockOverride() { + return $this->isLockOverride; + } + public function setReallyMajorEvent($me) { $this->reallyMajorEvent = $me; return $this; @@ -410,12 +420,13 @@ final class PHUITimelineEventView extends AphrontView { $image = null; $badges = null; if ($image_uri) { - $image = phutil_tag( + $image = javelin_tag( ($this->userHandle->getURI()) ? 'a' : 'div', array( 'style' => 'background-image: url('.$image_uri.')', - 'class' => 'phui-timeline-image visual-only', + 'class' => 'phui-timeline-image', 'href' => $this->userHandle->getURI(), + 'aural' => false, ), ''); if ($this->badges && $show_badges) { @@ -597,7 +608,8 @@ final class PHUITimelineEventView extends AphrontView { // not expect to have received any mail or notifications. if ($this->getIsSilent()) { $extra[] = id(new PHUIIconView()) - ->setIcon('fa-bell-slash', 'red') + ->setIcon('fa-bell-slash', 'white') + ->setEmblemColor('red') ->setTooltip(pht('Silent Edit')); } @@ -605,9 +617,17 @@ final class PHUITimelineEventView extends AphrontView { // provide a hint that it was extra authentic. if ($this->getIsMFA()) { $extra[] = id(new PHUIIconView()) - ->setIcon('fa-vcard', 'pink') + ->setIcon('fa-vcard', 'white') + ->setEmblemColor('pink') ->setTooltip(pht('MFA Authenticated')); } + + if ($this->getIsLockOverride()) { + $extra[] = id(new PHUIIconView()) + ->setIcon('fa-chain-broken', 'white') + ->setEmblemColor('violet') + ->setTooltip(pht('Lock Overridden')); + } } $extra = javelin_tag( diff --git a/src/view/phui/PHUITimelineView.php b/src/view/phui/PHUITimelineView.php index d0e942f461..2e6d8298c8 100644 --- a/src/view/phui/PHUITimelineView.php +++ b/src/view/phui/PHUITimelineView.php @@ -154,8 +154,21 @@ final class PHUITimelineView extends AphrontView { } $uri = $this->getPager()->getNextPageURI(); - $uri->setQueryParam('quoteTargetID', $this->getQuoteTargetID()); - $uri->setQueryParam('quoteRef', $this->getQuoteRef()); + + $target_id = $this->getQuoteTargetID(); + if ($target_id === null) { + $uri->removeQueryParam('quoteTargetID'); + } else { + $uri->replaceQueryParam('quoteTargetID', $target_id); + } + + $quote_ref = $this->getQuoteRef(); + if ($quote_ref === null) { + $uri->removeQueryParam('quoteRef'); + } else { + $uri->replaceQueryParam('quoteRef', $quote_ref); + } + $events[] = javelin_tag( 'div', array( diff --git a/src/view/widget/AphrontStackTraceView.php b/src/view/widget/AphrontStackTraceView.php index 1d0616df3d..edb805af8f 100644 --- a/src/view/widget/AphrontStackTraceView.php +++ b/src/view/widget/AphrontStackTraceView.php @@ -10,7 +10,6 @@ final class AphrontStackTraceView extends AphrontView { } public function render() { - $user = $this->getUser(); $trace = $this->trace; $libraries = PhutilBootloader::getInstance()->getAllLibraries(); diff --git a/support/lint/browser.jshintrc b/support/lint/browser.jshintrc index b88c931eee..2a9c65bdd2 100644 --- a/support/lint/browser.jshintrc +++ b/support/lint/browser.jshintrc @@ -4,12 +4,12 @@ "freeze": true, "immed": true, "indent": 2, - "latedef": true, + "latedef": "nofunc", "newcap": true, "noarg": true, "quotmark": "single", "undef": true, - "unused": true, + "unused": "vars", "expr": true, "loopfunc": true, diff --git a/support/startup/PhabricatorStartup.php b/support/startup/PhabricatorStartup.php index 1bfb74d886..4c577ca20c 100644 --- a/support/startup/PhabricatorStartup.php +++ b/support/startup/PhabricatorStartup.php @@ -315,7 +315,7 @@ final class PhabricatorStartup { * * @param string Brief description of the exception context, like * `"Rendering Exception"`. - * @param Exception The exception itself. + * @param Throwable The exception itself. * @param bool True if it's okay to show the exception's stack trace * to the user. The trace will always be logged. * @return exit This method **does not return**. @@ -324,7 +324,7 @@ final class PhabricatorStartup { */ public static function didEncounterFatalException( $note, - Exception $ex, + $ex, $show_trace) { $message = '['.$note.'/'.get_class($ex).'] '.$ex->getMessage(); diff --git a/webroot/index.php b/webroot/index.php index 5c7d79bfa1..0014edfa2c 100644 --- a/webroot/index.php +++ b/webroot/index.php @@ -2,6 +2,7 @@ phabricator_startup(); +$fatal_exception = null; try { PhabricatorStartup::beginStartupPhase('libraries'); PhabricatorStartup::loadCoreLibraries(); @@ -12,25 +13,66 @@ try { PhabricatorStartup::beginStartupPhase('sink'); $sink = new AphrontPHPHTTPSink(); + // PHP introduced a "Throwable" interface in PHP 7 and began making more + // runtime errors throw as "Throwable" errors. This is generally good, but + // makes top-level exception handling that is compatible with both PHP 5 + // and PHP 7 a bit tricky. + + // In PHP 5, "Throwable" does not exist, so "catch (Throwable $ex)" catches + // nothing. + + // In PHP 7, various runtime conditions raise an Error which is a Throwable + // but NOT an Exception, so "catch (Exception $ex)" will not catch them. + + // To cover both cases, we "catch (Exception $ex)" to catch everything in + // PHP 5, and most things in PHP 7. Then, we "catch (Throwable $ex)" to catch + // everything else in PHP 7. For the most part, we only need to do this at + // the top level. + + $main_exception = null; try { PhabricatorStartup::beginStartupPhase('run'); AphrontApplicationConfiguration::runHTTPRequest($sink); } catch (Exception $ex) { + $main_exception = $ex; + } catch (Throwable $ex) { + $main_exception = $ex; + } + + if ($main_exception) { + $response_exception = null; try { $response = new AphrontUnhandledExceptionResponse(); - $response->setException($ex); + $response->setException($main_exception); + $response->setShowStackTraces($sink->getShowStackTraces()); PhabricatorStartup::endOutputCapture(); $sink->writeResponse($response); - } catch (Exception $response_exception) { - // If we hit a rendering exception, ignore it and throw the original - // exception. It is generally more interesting and more likely to be - // the root cause. - throw $ex; + } catch (Exception $ex) { + $response_exception = $ex; + } catch (Throwable $ex) { + $response_exception = $ex; + } + + // If we hit a rendering exception, ignore it and throw the original + // exception. It is generally more interesting and more likely to be + // the root cause. + + if ($response_exception) { + throw $main_exception; } } } catch (Exception $ex) { - PhabricatorStartup::didEncounterFatalException('Core Exception', $ex, false); + $fatal_exception = $ex; +} catch (Throwable $ex) { + $fatal_exception = $ex; +} + +if ($fatal_exception) { + PhabricatorStartup::didEncounterFatalException( + 'Core Exception', + $fatal_exception, + false); } function phabricator_startup() { diff --git a/webroot/rsrc/css/aphront/table-view.css b/webroot/rsrc/css/aphront/table-view.css index 1a7d2eb215..fd1a918148 100644 --- a/webroot/rsrc/css/aphront/table-view.css +++ b/webroot/rsrc/css/aphront/table-view.css @@ -45,16 +45,20 @@ background: inherit; } -.aphront-table-view th { +.aphront-table-view th, +.aphront-table-view td.header { font-weight: bold; white-space: nowrap; color: {$bluetext}; - text-shadow: 0 1px 0 white; font-weight: bold; - border-bottom: 1px solid {$thinblueborder}; + text-shadow: 0 1px 0 white; background-color: {$lightbluebackground}; } +.aphront-table-view th { + border-bottom: 1px solid {$thinblueborder}; +} + th.aphront-table-view-sortable-selected { background-color: {$greybackground}; } @@ -74,12 +78,8 @@ th.aphront-table-view-sortable-selected { } .aphront-table-view td.header { - padding: 4px 8px; - white-space: nowrap; text-align: right; - color: {$bluetext}; - font-weight: bold; - vertical-align: top; + border-right: 1px solid {$thinblueborder}; } .aphront-table-view td { @@ -229,7 +229,8 @@ span.single-display-line-content { word-wrap: break-word; overflow: hidden; text-overflow: ellipsis; - max-width: 0; + min-width: 320px; + max-width: 320px; } .aphront-table-view tr.closed td.object-link .object-name, @@ -327,3 +328,40 @@ span.single-display-line-content { .phui-object-box .aphront-table-view { border: none; } + +.object-graph-header { + padding: 8px 12px; + overflow: hidden; + background: {$lightyellow}; + border-bottom: 1px solid {$lightblueborder}; + vertical-align: middle; +} + +.object-graph-header .object-graph-header-icon { + float: left; + margin-top: 10px; +} + +.object-graph-header a.button { + float: right; +} + +.object-graph-header-message { + margin: 8px 200px 8px 20px; +} + +.device .object-graph-header .object-graph-header-icon { + display: none; +} + +.device .object-graph-header-message { + clear: both; + margin: 0; +} + +.device .object-graph-header a.button { + margin: 0 auto 12px; + display: block; + width: 180px; + float: none; +} diff --git a/webroot/rsrc/css/application/base/notification-menu.css b/webroot/rsrc/css/application/base/notification-menu.css index 8db2436891..5886798600 100644 --- a/webroot/rsrc/css/application/base/notification-menu.css +++ b/webroot/rsrc/css/application/base/notification-menu.css @@ -15,6 +15,7 @@ .phabricator-notification { padding: 8px 12px; + color: {$darkgreytext}; } .phabricator-notification-menu-loading { @@ -114,7 +115,7 @@ } .phabricator-notification-header a { - color: {$darkgreytext}; + color: {$anchor}; } .phabricator-notification-header a:hover { @@ -162,3 +163,8 @@ .aphlict-connection-status .connection-status-text { margin-left: 12px; } + +.phabricator-notification .phui-timeline-value { + font-style: italic; + color: #000; +} diff --git a/webroot/rsrc/css/application/config/unhandled-exception.css b/webroot/rsrc/css/application/config/unhandled-exception.css index 831148cdad..cd8ad313dc 100644 --- a/webroot/rsrc/css/application/config/unhandled-exception.css +++ b/webroot/rsrc/css/application/config/unhandled-exception.css @@ -8,12 +8,12 @@ background: #fff; border: 1px solid #c0392b; border-radius: 3px; - padding: 0 8px; + padding: 8px; } .unhandled-exception-detail .unhandled-exception-title { color: #c0392b; - padding: 12px 8px; + padding: 4px 8px 12px; border-bottom: 1px solid #f4dddb; font-size: 16px; font-weight: 500; @@ -23,3 +23,31 @@ .unhandled-exception-detail .unhandled-exception-body { padding: 16px 12px; } + +.unhandled-exception-with-stack { + max-width: 95%; +} + +.unhandled-exception-stack { + background: #fcfcfc; + overflow-x: auto; + overflow-y: hidden; +} + +.unhandled-exception-stack table { + border-spacing: 0; + border-collapse: collapse; + width: 100%; + border: 1px solid #d7d7d7; +} + +.unhandled-exception-stack th { + background: #e7e7e7; + border-bottom: 1px solid #d7d7d7; + padding: 8px; +} + +.unhandled-exception-stack td { + padding: 4px 8px; + white-space: nowrap; +} diff --git a/webroot/rsrc/css/application/differential/changeset-view.css b/webroot/rsrc/css/application/differential/changeset-view.css index b9683dc7c6..233ac4cca5 100644 --- a/webroot/rsrc/css/application/differential/changeset-view.css +++ b/webroot/rsrc/css/application/differential/changeset-view.css @@ -67,28 +67,6 @@ padding: 1px 4px; } -.differential-diff td .zwsp { - position: absolute; - width: 0; -} - -.differential-diff th { - text-align: right; - padding: 1px 6px 1px 0; - vertical-align: top; - background: {$lightbluebackground}; - color: {$bluetext}; - cursor: pointer; - border-right: 1px solid {$thinblueborder}; - overflow: hidden; - - -moz-user-select: -moz-none; - -khtml-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; -} - .prose-diff { padding: 12px 0; white-space: pre-wrap; @@ -109,7 +87,7 @@ color: {$darkgreytext}; } -.differential-changeset-immutable .differential-diff th { +.differential-changeset-immutable .differential-diff td { cursor: auto; } @@ -135,16 +113,41 @@ background: {$old-bright}; } + .differential-diff td.new span.bright, .differential-diff td.new-full, .prose-diff span.new { background: {$new-bright}; } +.differential-diff td span.depth-out, +.differential-diff td span.depth-in { + padding: 2px 0; + background-size: 12px 12px; + background-repeat: no-repeat; + background-position: left center; + position: relative; + left: -8px; + opacity: 0.5; +} + +.differential-diff td span.depth-out { + background-image: url(/rsrc/image/chevron-out.png); + background-color: {$old-bright}; +} + +.differential-diff td span.depth-in { + background-position: 1px center; + background-image: url(/rsrc/image/chevron-in.png); + background-color: {$new-bright}; +} + + .differential-diff td.copy { min-width: 0.5%; width: 0.5%; padding: 0; + background: {$lightbluebackground}; } .differential-diff td.new-copy, @@ -161,6 +164,36 @@ background: #dddddd; } +.differential-diff .inline > td { + padding: 0; +} + +/* Specify line number behaviors after other behaviors because line numbers +should always have a boring grey background. */ + +.differential-diff td.n { + text-align: right; + padding: 1px 6px 1px 0; + vertical-align: top; + background: {$lightbluebackground}; + color: {$bluetext}; + cursor: pointer; + border-right: 1px solid {$thinblueborder}; + overflow: hidden; +} + +.differential-diff td + td.n { + border-left: 1px solid {$thinblueborder}; +} + +.differential-diff td.n::before { + content: attr(data-n); +} + +.differential-diff td.show-context-line.n { + cursor: auto; +} + .differential-diff td.cov { padding: 0; } @@ -201,7 +234,7 @@ td.cov-I { } .differential-diff td.show-more, -.differential-diff th.show-context-line, +.differential-diff td.show-context-line, .differential-diff td.show-context, .differential-diff td.differential-shield { background: {$lightbluebackground}; @@ -211,7 +244,7 @@ td.cov-I { } .device .differential-diff td.show-more, -.device .differential-diff th.show-context-line, +.device .differential-diff td.show-context-line, .device .differential-diff td.show-context, .device .differential-diff td.differential-shield { padding: 6px 0; @@ -229,10 +262,14 @@ td.cov-I { color: {$bluetext}; } -.differential-diff th.show-context-line { +.differential-diff td.show-context-line { padding-right: 6px; } +.differential-diff td.show-context-line.left-context { + border-right: none; +} + .differential-diff td.show-context { padding-left: 14px; } @@ -295,10 +332,6 @@ td.cov-I { pointer-events: none; } -.differential-diff .inline > td { - padding: 0; -} - .differential-loading { border-top: 1px solid {$gentle.highlight.border}; border-bottom: 1px solid {$gentle.highlight.border}; @@ -407,3 +440,44 @@ tr.differential-inline-loading { .diff-banner-buttons { float: right; } + +/* In Firefox, making the table unselectable and then making cells selectable +does not work: the cells remain unselectable. Narrowly mark the cells as +unselectable. */ + +.differential-diff.copy-l > tbody > tr > td, +.differential-diff.copy-r > tbody > tr > td { + -moz-user-select: none; + -ms-user-select: none; + -webkit-user-select: none; + user-select: none; +} + +.differential-diff.copy-l > tbody > tr > td:nth-child(2) { + -moz-user-select: auto; + -ms-user-select: auto; + -webkit-user-select: auto; + user-select: auto; +} + +.differential-diff.copy-l > tbody > tr > td.show-more:nth-child(2) { + -moz-user-select: none; + -ms-user-select: none; + -webkit-user-select: none; + user-select: none; +} + +.differential-diff.copy-r > tbody > tr > td:nth-child(5) { + -moz-user-select: auto; + -ms-user-select: auto; + -webkit-user-select: auto; + user-select: auto; +} + +.differential-diff.copy-l > tbody > tr.inline > td, +.differential-diff.copy-r > tbody > tr.inline > td { + -moz-user-select: none; + -ms-user-select: none; + -webkit-user-select: none; + user-select: none; +} diff --git a/webroot/rsrc/css/application/differential/core.css b/webroot/rsrc/css/application/differential/core.css index 2dcc02bb18..893cfb34a8 100644 --- a/webroot/rsrc/css/application/differential/core.css +++ b/webroot/rsrc/css/application/differential/core.css @@ -16,14 +16,6 @@ margin-bottom: 8px; } -.differential-unselectable tr td:nth-of-type(1) { - -moz-user-select: -moz-none; - -khtml-user-select: none; - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; -} - .differential-content-hidden { margin: 0 0 24px 0; } diff --git a/webroot/rsrc/css/application/project/project-card-view.css b/webroot/rsrc/css/application/project/project-card-view.css index b960d55cef..cce4789ef7 100644 --- a/webroot/rsrc/css/application/project/project-card-view.css +++ b/webroot/rsrc/css/application/project/project-card-view.css @@ -36,22 +36,36 @@ } .project-card-view .project-card-image { + position: absolute; height: 140px; width: 140px; - margin: 6px; + top: 6px; + left: 6px; border-radius: 3px; } .project-card-view .project-card-image-href { - display: inline-block; + display: block; } .project-card-view .project-card-item div { display: inline; } +.project-card-inner { + position: relative; +} + +.people-card-view .project-card-inner { + padding: 6px; + min-height: 140px; +} + .project-card-view .project-card-item { margin-bottom: 2px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .project-card-view .project-card-item-text { @@ -63,9 +77,9 @@ } .project-card-view .project-card-header { - position: absolute; - top: 12px; - left: 158px; + margin-top: 6px; + margin-left: 152px; + overflow: hidden; } .project-card-header .project-card-name { diff --git a/webroot/rsrc/css/application/project/project-triggers.css b/webroot/rsrc/css/application/project/project-triggers.css new file mode 100644 index 0000000000..9b3ce8e462 --- /dev/null +++ b/webroot/rsrc/css/application/project/project-triggers.css @@ -0,0 +1,38 @@ +/** + * @provides project-triggers-css + */ + +.trigger-rules-table { + margin: 16px 0; + border-collapse: separate; + border-spacing: 0 4px; +} + +.trigger-rules-table tr { + background: {$bluebackground}; +} + +.trigger-rules-table td { + padding: 6px 4px; + vertical-align: middle; +} + +.trigger-rules-table td.type-cell { + padding-left: 6px; +} + +.trigger-rules-table td.remove-column { + padding-right: 6px; +} + +.trigger-rules-table td.invalid-cell { + padding-left: 12px; +} + +.trigger-rules-table td.invalid-cell .phui-icon-view { + margin-right: 4px; +} + +.trigger-rules-table td.value-cell { + width: 100%; +} diff --git a/webroot/rsrc/css/core/syntax.css b/webroot/rsrc/css/core/syntax.css index cfc82da09b..90f2981ba6 100644 --- a/webroot/rsrc/css/core/syntax.css +++ b/webroot/rsrc/css/core/syntax.css @@ -29,3 +29,9 @@ span.crossreference-item { color: #222222; background: #dddddd; } + +.suspicious-character { + background: #ff7700; + color: #ffffff; + cursor: default; +} diff --git a/webroot/rsrc/css/phui/object-item/phui-oi-big-ui.css b/webroot/rsrc/css/phui/object-item/phui-oi-big-ui.css index a793c018c3..2d2163f9e9 100644 --- a/webroot/rsrc/css/phui/object-item/phui-oi-big-ui.css +++ b/webroot/rsrc/css/phui/object-item/phui-oi-big-ui.css @@ -13,7 +13,12 @@ } .phui-oi-list-big .phui-oi-image-icon { - margin: 8px 2px 12px; + margin: 12px 2px 12px; + text-align: center; +} + +.phui-oi-list-big .phui-oi-image-icon .phui-icon-view { + position: relative; } .phui-oi-list-big a.phui-oi-link { @@ -31,7 +36,7 @@ } .device-desktop .phui-oi-list-big .phui-oi { - margin-bottom: 4px; + margin-bottom: 8px; } .phui-oi-list-big .phui-oi-col0 { @@ -60,13 +65,28 @@ border-radius: 3px; } +.phui-oi-list-big .phui-oi-frame { + padding: 2px 8px; +} + +.phui-oi-list-big .phui-oi-linked-container { + border: 1px solid {$lightblueborder}; + border-radius: 4px; + box-shadow: 1px 1px 2px rgba(0, 0, 0, 0.035); +} + +.phui-oi-list-big .phui-oi-disabled { + border-radius: 4px; + background: {$lightgreybackground}; +} + .device-desktop .phui-oi-linked-container { cursor: pointer; } .device-desktop .phui-oi-linked-container:hover { background-color: {$hoverblue}; - border-radius: 3px; + border-color: {$blueborder}; } .device-desktop .phui-oi-linked-container a:hover { diff --git a/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css b/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css index 6f2421ca2f..d8ac3a8bbb 100644 --- a/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css +++ b/webroot/rsrc/css/phui/object-item/phui-oi-list-view.css @@ -132,11 +132,15 @@ ul.phui-oi-list-view { background: url('/rsrc/image/texture/grip.png') center center no-repeat; } +.phui-oi-ungrippable .phui-oi-grip { + opacity: 0.25; +} + .device .phui-oi-grip { display: none; } -.phui-oi-grippable .phui-oi-frame { +.phui-oi-has-grip .phui-oi-frame { padding-left: 16px; } @@ -720,3 +724,9 @@ ul.phui-oi-list-view .phui-oi-selectable .differential-revision-small .phui-icon-view { color: #6699ba; } + +.phui-oi-tail { + text-align: center; + padding: 8px 0; + background: linear-gradient({$lightbluebackground}, #fff 66%, #fff); +} diff --git a/webroot/rsrc/css/phui/phui-action-list.css b/webroot/rsrc/css/phui/phui-action-list.css index e7ee38a8bf..3df4ff1b78 100644 --- a/webroot/rsrc/css/phui/phui-action-list.css +++ b/webroot/rsrc/css/phui/phui-action-list.css @@ -99,11 +99,20 @@ background-color: {$sh-redbackground}; } +.phabricator-action-view.action-item-green { + background-color: {$sh-greenbackground}; +} + .phabricator-action-view.action-item-red .phabricator-action-view-item, .phabricator-action-view.action-item-red .phabricator-action-view-icon { color: {$sh-redtext}; } +.phabricator-action-view.action-item-green .phabricator-action-view-item, +.phabricator-action-view.action-item-green .phabricator-action-view-icon { + color: {$sh-greentext}; +} + .device-desktop .phabricator-action-view.action-item-red:hover .phabricator-action-view-item, .device-desktop .phabricator-action-view.action-item-red:hover @@ -111,6 +120,14 @@ color: {$red}; } +.device-desktop .phabricator-action-view.action-item-green:hover + .phabricator-action-view-item, +.device-desktop .phabricator-action-view.action-item-green:hover + .phabricator-action-view-icon { + color: {$green}; +} + + .phabricator-action-view-label .phabricator-action-view-item, .phabricator-action-view-type-label .phabricator-action-view-item { font-size: {$smallerfontsize}; diff --git a/webroot/rsrc/css/phui/phui-form-view.css b/webroot/rsrc/css/phui/phui-form-view.css index f5665f639d..c2ab845ec9 100644 --- a/webroot/rsrc/css/phui/phui-form-view.css +++ b/webroot/rsrc/css/phui/phui-form-view.css @@ -578,3 +578,17 @@ properly, and submit values. */ .mfa-form-enroll-button { text-align: center; } + +.phui-form-timer-updated { + animation: phui-form-timer-fade-in 2s linear; +} + + +@keyframes phui-form-timer-fade-in { + 0% { + background-color: {$lightyellow}; + } + 100% { + background-color: transparent; + } +} diff --git a/webroot/rsrc/css/phui/phui-header-view.css b/webroot/rsrc/css/phui/phui-header-view.css index 6cafb3e330..6a096af76d 100644 --- a/webroot/rsrc/css/phui/phui-header-view.css +++ b/webroot/rsrc/css/phui/phui-header-view.css @@ -249,6 +249,16 @@ body .phui-header-shell.phui-bleed-header color: {$sh-indigotext}; } +.policy-header-callout.policy-adjusted-locked { + background: {$sh-pinkbackground}; +} + +.policy-header-callout.policy-adjusted-locked .policy-link, +.policy-header-callout.policy-adjusted-locked .phui-icon-view { + color: {$sh-pinktext}; +} + + .policy-header-callout .policy-space-container { font-weight: bold; color: {$sh-redtext}; diff --git a/webroot/rsrc/css/phui/phui-icon.css b/webroot/rsrc/css/phui/phui-icon.css index 4108074b08..5436bb04b1 100644 --- a/webroot/rsrc/css/phui/phui-icon.css +++ b/webroot/rsrc/css/phui/phui-icon.css @@ -183,3 +183,24 @@ a.phui-icon-view.phui-icon-square:hover { text-decoration: none; color: #fff; } + + +.phui-icon-emblem { + border-radius: 4px; +} + +.phui-timeline-extra .phui-icon-emblem { + padding: 4px 6px; +} + +.phui-icon-emblem-violet { + background-color: {$violet}; +} + +.phui-icon-emblem-red { + background-color: {$red}; +} + +.phui-icon-emblem-pink { + background-color: {$pink}; +} diff --git a/webroot/rsrc/css/phui/phui-object-box.css b/webroot/rsrc/css/phui/phui-object-box.css index 4999a4c2c2..f95e36eedc 100644 --- a/webroot/rsrc/css/phui/phui-object-box.css +++ b/webroot/rsrc/css/phui/phui-object-box.css @@ -158,3 +158,8 @@ div.phui-object-box.phui-object-box-flush { margin-top: 8px; margin-bottom: 8px; } + +.phui-object-box-instructions { + padding: 16px; + border-bottom: 1px solid {$thinblueborder}; +} diff --git a/webroot/rsrc/css/phui/phui-tag-view.css b/webroot/rsrc/css/phui/phui-tag-view.css index 73675a44d6..57529645a7 100644 --- a/webroot/rsrc/css/phui/phui-tag-view.css +++ b/webroot/rsrc/css/phui/phui-tag-view.css @@ -54,6 +54,14 @@ a.phui-tag-view:hover { border: 1px solid transparent; } +.tokenizer-result .phui-tag-dot { + margin-right: 6px; +} + +.jx-tokenizer-token .phui-tag-dot { + margin-left: 2px; +} + .phui-tag-type-state { color: #ffffff; text-shadow: rgba(100, 100, 100, 0.40) 0px -1px 1px; diff --git a/webroot/rsrc/css/phui/workboards/phui-workcard.css b/webroot/rsrc/css/phui/workboards/phui-workcard.css index e137e962bc..3c6a798fc8 100644 --- a/webroot/rsrc/css/phui/workboards/phui-workcard.css +++ b/webroot/rsrc/css/phui/workboards/phui-workcard.css @@ -59,14 +59,6 @@ vertical-align: top; } -.phui-workcard.phui-oi-grippable .phui-oi-frame { - padding-left: 0; -} - -.phui-workcard .phui-oi-grip { - display: none; -} - .device-desktop .phui-workcard .phui-list-item-icon { display: none; } @@ -88,6 +80,33 @@ opacity: 1; } +.device-desktop .phui-workcard.draggable-card { + cursor: grab; +} + +.jx-dragging .phui-workcard.draggable-card { + cursor: grabbing; +} + +.device-desktop .phui-workcard.undraggable-card { + cursor: not-allowed; +} + +.device-desktop .phui-workcard.phui-oi.not-editable:hover { + background: {$sh-redbackground}; +} + +.device-desktop .phui-workcard.phui-oi.not-editable:hover + .phui-list-item-href { + border-radius: 3px; + background: {$red}; +} + +.device-desktop .phui-workcard.phui-oi.not-editable:hover + .phui-list-item-href .phui-icon-view { + color: #fff; +} + .phui-workcard.phui-oi:hover .phui-list-item-icon { display: block; } diff --git a/webroot/rsrc/css/phui/workboards/phui-workpanel.css b/webroot/rsrc/css/phui/workboards/phui-workpanel.css index 617ff5aa6d..5ee54f2deb 100644 --- a/webroot/rsrc/css/phui/workboards/phui-workpanel.css +++ b/webroot/rsrc/css/phui/workboards/phui-workpanel.css @@ -137,3 +137,118 @@ .phui-workpanel-view.project-panel-over-limit .phui-header-shell { border-color: {$red}; } + +.phui-workpanel-view .phui-box-grey { + border: 1px solid transparent; +} + +.phui-workpanel-view.workboard-column-drop-target .phui-box-grey { + border-color: {$lightblueborder}; +} + +.workboard-group-header { + background: rgba({$alphablue}, 0.10); + padding: 6px 8px; + margin: 0 0 8px -8px; + border-bottom: 1px solid {$lightgreyborder}; + font-weight: bold; + color: {$darkgreytext}; + position: relative; +} + +.workboard-group-header .phui-icon-view { + position: absolute; + display: inline-block; + width: 24px; + padding: 5px 0 0 0; + height: 19px; + background-size: 100%; + border-radius: 3px; + background-repeat: no-repeat; + text-align: center; + background-color: {$lightgreybackground}; + border: 1px solid {$lightgreybackground}; +} + +.workboard-group-header .workboard-group-header-name { + display: block; + position: relative; + height: 24px; + line-height: 24px; + margin-left: 36px; + overflow: hidden; +} + +.workboard-drop-preview { + pointer-events: none; + position: absolute; + bottom: 12px; + right: 12px; + width: 300px; + border-radius: 3px; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.15); + border: 1px solid {$lightblueborder}; + padding: 4px 0; + background: #fff; +} + +.workboard-drop-preview li { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 4px 8px; + color: {$greytext}; + border-radius: 3px; +} + +.workboard-drop-preview li .phui-icon-view { + position: relative; + display: inline-block; + text-align: center; + width: 24px; + height: 18px; + padding-top: 6px; + border-radius: 3px; + background: {$bluebackground}; + margin-right: 6px; +} + +.workboard-drop-preview .workboard-drop-preview-header { + background: {$sky}; + color: #fff; +} + +.workboard-drop-preview .workboard-drop-preview-header .phui-icon-view { + background: {$blue}; + color: #fff; +} + +.workboard-drop-preview-fade { + animation: 0.1s workboard-drop-preview-fade-out; + opacity: 0.25; +} + +@keyframes workboard-drop-preview-fade-out { + 0% { + opacity: 1; + } + + 100% { + opacity: 0.25; + } +} + +.phui-workpanel-view .phui-header-action-item a.phui-icon-view { + width: 24px; + height: 24px; + line-height: 24px; + text-align: center; + border-radius: 3px; + box-shadow: inset -1px -1px 2px rgba(0, 0, 0, 0.05); + border: 1px solid {$lightgreyborder}; + background: {$lightgreybackground}; +} + +.phui-workpanel-view .phui-header-action-item .phui-tag-view { + line-height: 24px; +} diff --git a/webroot/rsrc/externals/javelin/lib/Sound.js b/webroot/rsrc/externals/javelin/lib/Sound.js index accbe3d29b..68181560ff 100644 --- a/webroot/rsrc/externals/javelin/lib/Sound.js +++ b/webroot/rsrc/externals/javelin/lib/Sound.js @@ -8,31 +8,75 @@ JX.install('Sound', { statics: { _sounds: {}, + _queue: [], + _playingQueue: false, load: function(uri) { var self = JX.Sound; if (!(uri in self._sounds)) { - self._sounds[uri] = JX.$N( + var audio = JX.$N( 'audio', { src: uri, preload: 'auto' }); + + // In Safari, it isn't good enough to just load a sound in response + // to a click: we must also play it. Once we've played it once, we + // can continue to play it freely. + + // Play the sound, then immediately pause it. This rejects the "play()" + // promise but marks the audio as playable, so our "play()" method will + // work correctly later. + if (window.webkitAudioContext) { + audio.play().then(JX.bag, JX.bag); + audio.pause(); + } + + self._sounds[uri] = audio; } }, - play: function(uri) { + play: function(uri, callback) { var self = JX.Sound; self.load(uri); var sound = self._sounds[uri]; try { - sound.play(); + sound.onended = callback || JX.bag; + sound.play().then(JX.bag, callback || JX.bag); } catch (ex) { JX.log(ex); } + }, + + queue: function(uri) { + var self = JX.Sound; + self._queue.push(uri); + self._playQueue(); + }, + + _playQueue: function() { + var self = JX.Sound; + if (self._playingQueue) { + return; + } + self._playingQueue = true; + self._nextQueue(); + }, + + _nextQueue: function() { + var self = JX.Sound; + if (self._queue.length) { + var next = self._queue[0]; + self._queue.splice(0, 1); + self.play(next, self._nextQueue); + } else { + self._playingQueue = false; + } } + } }); diff --git a/webroot/rsrc/image/chevron-in.png b/webroot/rsrc/image/chevron-in.png new file mode 100644 index 0000000000..373d39cfe1 Binary files /dev/null and b/webroot/rsrc/image/chevron-in.png differ diff --git a/webroot/rsrc/image/chevron-out.png b/webroot/rsrc/image/chevron-out.png new file mode 100644 index 0000000000..787772eb27 Binary files /dev/null and b/webroot/rsrc/image/chevron-out.png differ diff --git a/webroot/rsrc/js/application/diff/DiffChangeset.js b/webroot/rsrc/js/application/diff/DiffChangeset.js index 24d734573d..754f3b16e4 100644 --- a/webroot/rsrc/js/application/diff/DiffChangeset.js +++ b/webroot/rsrc/js/application/diff/DiffChangeset.js @@ -22,7 +22,6 @@ JX.install('DiffChangeset', { this._renderURI = data.renderURI; this._ref = data.ref; - this._whitespace = data.whitespace; this._renderer = data.renderer; this._highlight = data.highlight; this._encoding = data.encoding; @@ -46,7 +45,6 @@ JX.install('DiffChangeset', { _renderURI: null, _ref: null, - _whitespace: null, _renderer: null, _highlight: null, _encoding: null, @@ -310,7 +308,6 @@ JX.install('DiffChangeset', { _getViewParameters: function() { return { ref: this._ref, - whitespace: this._whitespace || '', renderer: this.getRenderer() || '', highlight: this._highlight || '', encoding: this._encoding || '' diff --git a/webroot/rsrc/js/application/diff/DiffChangesetList.js b/webroot/rsrc/js/application/diff/DiffChangesetList.js index 5ba43a7e6d..572faad987 100644 --- a/webroot/rsrc/js/application/diff/DiffChangesetList.js +++ b/webroot/rsrc/js/application/diff/DiffChangesetList.js @@ -70,13 +70,13 @@ JX.install('DiffChangesetList', { var onrangedown = JX.bind(this, this._ifawake, this._onrangedown); JX.Stratcom.listen( 'mousedown', - ['differential-changeset', 'tag:th'], + ['differential-changeset', 'tag:td'], onrangedown); var onrangemove = JX.bind(this, this._ifawake, this._onrangemove); JX.Stratcom.listen( ['mouseover', 'mouseout'], - ['differential-changeset', 'tag:th'], + ['differential-changeset', 'tag:td'], onrangemove); var onrangeup = JX.bind(this, this._ifawake, this._onrangeup); @@ -360,7 +360,7 @@ JX.install('DiffChangesetList', { while (row) { var header = row.firstChild; while (header) { - if (JX.DOM.isType(header, 'th')) { + if (this.getLineNumberFromHeader(header)) { if (header.className.indexOf('old') !== -1) { old_list.push(header); } else if (header.className.indexOf('new') !== -1) { @@ -1246,12 +1246,24 @@ JX.install('DiffChangesetList', { return changeset.getInlineForRow(inline_row); }, - getLineNumberFromHeader: function(th) { - try { - return parseInt(th.id.match(/^C\d+[ON]L(\d+)$/)[1], 10); - } catch (x) { + getLineNumberFromHeader: function(node) { + var n = parseInt(node.getAttribute('data-n')); + + if (!n) { return null; } + + // If this is a line number that's part of a row showing more context, + // we don't want to let users leave inlines here. + + try { + JX.DOM.findAbove(node, 'tr', 'context-target'); + return null; + } catch (ex) { + // Ignore. + } + + return n; }, getDisplaySideFromHeader: function(th) { @@ -1299,7 +1311,7 @@ JX.install('DiffChangesetList', { }, _updateRange: function(target, is_out) { - // Don't update the range if this "" doesn't correspond to a line + // Don't update the range if this target doesn't correspond to a line // number. For instance, this may be a dead line number, like the empty // line numbers on the left hand side of a newly added file. var number = this.getLineNumberFromHeader(target); diff --git a/webroot/rsrc/js/application/differential/behavior-user-select.js b/webroot/rsrc/js/application/differential/behavior-user-select.js deleted file mode 100644 index 8db48b704d..0000000000 --- a/webroot/rsrc/js/application/differential/behavior-user-select.js +++ /dev/null @@ -1,33 +0,0 @@ -/** - * @provides javelin-behavior-differential-user-select - * @requires javelin-behavior - * javelin-dom - * javelin-stratcom - */ - -JX.behavior('differential-user-select', function() { - - var unselectable; - - function isOnRight(node) { - return node.previousSibling && - node.parentNode.firstChild != node.previousSibling; - } - - JX.Stratcom.listen( - 'mousedown', - null, - function(e) { - var key = 'differential-unselectable'; - if (unselectable) { - JX.DOM.alterClass(unselectable, key, false); - } - var diff = e.getNode('differential-diff'); - var td = e.getNode('tag:td'); - if (diff && td && isOnRight(td)) { - unselectable = diff; - JX.DOM.alterClass(diff, key, true); - } - }); - -}); diff --git a/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js b/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js index 309f972324..5c4591b542 100644 --- a/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js +++ b/webroot/rsrc/js/application/diffusion/behavior-commit-graph.js @@ -44,11 +44,19 @@ JX.behavior('diffusion-commit-graph', function(config) { cxt.stroke(); } + // If the graph is going to be wide, squish it a bit so it doesn't take up + // quite as much space. + var default_width; + if (config.count >= 8) { + default_width = 6; + } else { + default_width = 12; + } for (var ii = 0; ii < nodes.length; ii++) { var data = JX.Stratcom.getData(nodes[ii]); - var cell = 12; // Width of each thread. + var cell = default_width; var xpos = function(col) { return (col * cell) + (cell / 2); }; diff --git a/webroot/rsrc/js/application/maniphest/behavior-batch-selector.js b/webroot/rsrc/js/application/maniphest/behavior-batch-selector.js index b62f40a589..b64abc3503 100644 --- a/webroot/rsrc/js/application/maniphest/behavior-batch-selector.js +++ b/webroot/rsrc/js/application/maniphest/behavior-batch-selector.js @@ -41,19 +41,6 @@ JX.behavior('maniphest-batch-selector', function(config) { update(); }; - var redraw = function (task) { - var selected = is_selected(task); - change(task, selected); - }; - JX.Stratcom.listen( - 'subpriority-changed', - null, - function (e) { - e.kill(); - var data = e.getData(); - redraw(data.task); - }); - // Change all tasks to some state (used by "select all" / "clear selection" // buttons). diff --git a/webroot/rsrc/js/application/maniphest/behavior-subpriorityeditor.js b/webroot/rsrc/js/application/maniphest/behavior-subpriorityeditor.js deleted file mode 100644 index 82f16854f8..0000000000 --- a/webroot/rsrc/js/application/maniphest/behavior-subpriorityeditor.js +++ /dev/null @@ -1,72 +0,0 @@ -/** - * @provides javelin-behavior-maniphest-subpriority-editor - * @requires javelin-behavior - * javelin-dom - * javelin-stratcom - * javelin-workflow - * phabricator-draggable-list - */ - -JX.behavior('maniphest-subpriority-editor', function(config) { - - var draggable = new JX.DraggableList('maniphest-task') - .setFindItemsHandler(function() { - var tasks = JX.DOM.scry(document.body, 'li', 'maniphest-task'); - var heads = JX.DOM.scry(document.body, 'div', 'task-group'); - return tasks.concat(heads); - }) - .setGhostHandler(function(ghost, target) { - if (!target) { - // The user is trying to drag a task above the first group header; - // don't permit that since it doesn't make sense. - return false; - } - - if (target.nextSibling) { - if (JX.DOM.isType(target, 'div')) { - target.nextSibling.insertBefore(ghost, target.nextSibling.firstChild); - } else { - target.parentNode.insertBefore(ghost, target.nextSibling); - } - } else { - target.parentNode.appendChild(ghost); - } - }); - - draggable.listen('shouldBeginDrag', function(e) { - if (e.getNode('slippery') || e.getNode('maniphest-edit-task')) { - JX.Stratcom.context().kill(); - } - }); - - draggable.listen('didDrop', function(node, after) { - var data = { - task: JX.Stratcom.getData(node).taskID - }; - - if (JX.DOM.isType(after, 'div')) { - data.priority = JX.Stratcom.getData(after).priority; - } else { - data.after = JX.Stratcom.getData(after).taskID; - } - - draggable.lock(); - JX.DOM.alterClass(node, 'drag-sending', true); - - var onresponse = function(r) { - var nodes = JX.$H(r.tasks).getFragment().firstChild; - var task = JX.DOM.find(nodes, 'li', 'maniphest-task'); - JX.DOM.replace(node, task); - draggable.unlock(); - JX.Stratcom.invoke( - 'subpriority-changed', - null, - { 'task' : task }); - }; - - new JX.Workflow(config.uri, data) - .setHandler(onresponse) - .start(); - }); - -}); diff --git a/webroot/rsrc/js/application/projects/WorkboardBoard.js b/webroot/rsrc/js/application/projects/WorkboardBoard.js index cac35c2d9a..74c0bdf23e 100644 --- a/webroot/rsrc/js/application/projects/WorkboardBoard.js +++ b/webroot/rsrc/js/application/projects/WorkboardBoard.js @@ -7,6 +7,9 @@ * javelin-workflow * phabricator-draggable-list * javelin-workboard-column + * javelin-workboard-header-template + * javelin-workboard-card-template + * javelin-workboard-order-template * @javelin */ @@ -17,9 +20,10 @@ JX.install('WorkboardBoard', { this._phid = phid; this._root = root; - this._templates = {}; - this._orderMaps = {}; - this._propertiesMap = {}; + this._headers = {}; + this._cards = {}; + this._orders = {}; + this._buildColumns(); }, @@ -33,9 +37,14 @@ JX.install('WorkboardBoard', { _phid: null, _root: null, _columns: null, - _templates: null, - _orderMaps: null, - _propertiesMap: null, + _headers: null, + _cards: null, + _dropPreviewNode: null, + _dropPreviewListNode: null, + _previewPHID: null, + _hidePreivew: false, + _previewPositionVector: null, + _previewDimState: false, getRoot: function() { return this._root; @@ -53,35 +62,68 @@ JX.install('WorkboardBoard', { return this._phid; }, - setCardTemplate: function(phid, template) { - this._templates[phid] = template; - return this; - }, - - setObjectProperties: function(phid, properties) { - this._propertiesMap[phid] = properties; - return this; - }, - - getObjectProperties: function(phid) { - return this._propertiesMap[phid]; - }, - getCardTemplate: function(phid) { - return this._templates[phid]; + if (!this._cards[phid]) { + this._cards[phid] = new JX.WorkboardCardTemplate(phid); + } + + return this._cards[phid]; + }, + + getHeaderTemplate: function(header_key) { + if (!this._headers[header_key]) { + this._headers[header_key] = new JX.WorkboardHeaderTemplate(header_key); + } + + return this._headers[header_key]; + }, + + getOrderTemplate: function(order_key) { + if (!this._orders[order_key]) { + this._orders[order_key] = new JX.WorkboardOrderTemplate(order_key); + } + + return this._orders[order_key]; + }, + + getHeaderTemplatesForOrder: function(order) { + var templates = []; + + for (var k in this._headers) { + var header = this._headers[k]; + + if (header.getOrder() !== order) { + continue; + } + + templates.push(header); + } + + templates.sort(JX.bind(this, this._sortHeaderTemplates)); + + return templates; + }, + + _sortHeaderTemplates: function(u, v) { + return this.compareVectors(u.getVector(), v.getVector()); }, getController: function() { return this._controller; }, - setOrderMap: function(phid, map) { - this._orderMaps[phid] = map; - return this; - }, + compareVectors: function(u_vec, v_vec) { + for (var ii = 0; ii < u_vec.length; ii++) { + if (u_vec[ii] > v_vec[ii]) { + return 1; + } - getOrderVector: function(phid, key) { - return this._orderMaps[phid][key]; + if (u_vec[ii] < v_vec[ii]) { + return -1; + } + } + + return 0; }, start: function() { @@ -103,23 +145,163 @@ JX.install('WorkboardBoard', { this._columns[phid] = new JX.WorkboardColumn(this, phid, node); } + + var on_over = JX.bind(this, this._showTriggerPreview); + var on_out = JX.bind(this, this._hideTriggerPreview); + JX.Stratcom.listen('mouseover', 'trigger-preview', on_over); + JX.Stratcom.listen('mouseout', 'trigger-preview', on_out); + + var on_move = JX.bind(this, this._dimPreview); + JX.Stratcom.listen('mousemove', null, on_move); + }, + + _dimPreview: function(e) { + var p = this._previewPositionVector; + if (!p) { + return; + } + + // When the mouse cursor gets near the drop preview element, fade it + // out so you can see through it. We can't do this with ":hover" because + // we disable cursor events. + + var cursor = JX.$V(e); + var margin = 64; + + var near_x = (cursor.x > (p.x - margin)); + var near_y = (cursor.y > (p.y - margin)); + var should_dim = (near_x && near_y); + + this._setPreviewDimState(should_dim); + }, + + _setPreviewDimState: function(is_dim) { + if (is_dim === this._previewDimState) { + return; + } + + this._previewDimState = is_dim; + var node = this._getDropPreviewNode(); + JX.DOM.alterClass(node, 'workboard-drop-preview-fade', is_dim); + }, + + _showTriggerPreview: function(e) { + if (this._disablePreview) { + return; + } + + var target = e.getTarget(); + var node = e.getNode('trigger-preview'); + + if (target !== node) { + return; + } + + var phid = JX.Stratcom.getData(node).columnPHID; + var column = this._columns[phid]; + + // Bail out if we don't know anything about this column. + if (!column) { + return; + } + + if (phid === this._previewPHID) { + return; + } + + this._previewPHID = phid; + + var effects = column.getDropEffects(); + + var triggers = []; + for (var ii = 0; ii < effects.length; ii++) { + if (effects[ii].getIsTriggerEffect()) { + triggers.push(effects[ii]); + } + } + + if (triggers.length) { + var header = column.getTriggerPreviewEffect(); + triggers = [header].concat(triggers); + } + + this._showEffects(triggers); + }, + + _hideTriggerPreview: function(e) { + if (this._disablePreview) { + return; + } + + var target = e.getTarget(); + + if (target !== e.getNode('trigger-preview')) { + return; + } + + this._removeTriggerPreview(); + }, + + _removeTriggerPreview: function() { + this._showEffects([]); + this._previewPHID = null; + }, + + _beginDrag: function() { + this._disablePreview = true; + this._showEffects([]); + }, + + _endDrag: function() { + this._disablePreview = false; }, _setupDragHandlers: function() { var columns = this.getColumns(); + var order_template = this.getOrderTemplate(this.getOrder()); + var has_headers = order_template.getHasHeaders(); + var can_reorder = order_template.getCanReorder(); + var lists = []; for (var k in columns) { var column = columns[k]; - var list = new JX.DraggableList('project-card', column.getRoot()) + var list = new JX.DraggableList('draggable-card', column.getRoot()) .setOuterContainer(this.getRoot()) - .setFindItemsHandler(JX.bind(column, column.getCardNodes)) + .setFindItemsHandler(JX.bind(column, column.getDropTargetNodes)) .setCanDragX(true) - .setHasInfiniteHeight(true); + .setHasInfiniteHeight(true) + .setIsDropTargetHandler(JX.bind(column, column.setIsDropTarget)); + + var default_handler = list.getGhostHandler(); + list.setGhostHandler( + JX.bind(column, column.handleDragGhost, default_handler)); + + // The "compare handler" locks cards into a specific position in the + // column. + list.setCompareHandler(JX.bind(column, column.compareHandler)); + + // If the view has group headers, we lock cards into the right position + // when moving them between columns, but not within a column. + if (has_headers) { + list.setCompareOnMove(true); + } + + // If we can't reorder cards, we always lock them into their current + // position. + if (!can_reorder) { + list.setCompareOnMove(true); + list.setCompareOnReorder(true); + } + + list.setTargetChangeHandler(JX.bind(this, this._didChangeDropTarget)); list.listen('didDrop', JX.bind(this, this._onmovecard, list)); + list.listen('didBeginDrag', JX.bind(this, this._beginDrag)); + list.listen('didEndDrag', JX.bind(this, this._endDrag)); + lists.push(list); } @@ -128,10 +310,171 @@ JX.install('WorkboardBoard', { } }, + _didChangeDropTarget: function(src_list, src_node, dst_list, dst_node) { + if (!dst_list) { + // The card is being dragged into a dead area, like the left menu. + this._showEffects([]); + return; + } + + if (dst_node === false) { + // The card is being dragged over itself, so dropping it won't + // affect anything. + this._showEffects([]); + return; + } + + var src_phid = JX.Stratcom.getData(src_list.getRootNode()).columnPHID; + var dst_phid = JX.Stratcom.getData(dst_list.getRootNode()).columnPHID; + + var src_column = this.getColumn(src_phid); + var dst_column = this.getColumn(dst_phid); + + var effects = []; + if (src_column !== dst_column) { + effects = effects.concat(dst_column.getDropEffects()); + } + + var context = this._getDropContext(dst_node); + if (context.headerKey) { + var header = this.getHeaderTemplate(context.headerKey); + effects = effects.concat(header.getDropEffects()); + } + + var card_phid = JX.Stratcom.getData(src_node).objectPHID; + var card = src_column.getCard(card_phid); + + var visible = []; + for (var ii = 0; ii < effects.length; ii++) { + if (effects[ii].isEffectVisibleForCard(card)) { + visible.push(effects[ii]); + } + } + effects = visible; + + this._showEffects(effects); + }, + + _showEffects: function(effects) { + var node = this._getDropPreviewNode(); + + if (!effects.length) { + JX.DOM.remove(node); + this._previewPositionVector = null; + return; + } + + var items = []; + for (var ii = 0; ii < effects.length; ii++) { + var effect = effects[ii]; + items.push(effect.newNode()); + } + + JX.DOM.setContent(this._getDropPreviewListNode(), items); + document.body.appendChild(node); + + // Undim the drop preview element if it was previously dimmed. + this._setPreviewDimState(false); + this._previewPositionVector = JX.$V(node); + }, + + _getDropPreviewNode: function() { + if (!this._dropPreviewNode) { + var attributes = { + className: 'workboard-drop-preview' + }; + + var content = [ + this._getDropPreviewListNode() + ]; + + this._dropPreviewNode = JX.$N('div', attributes, content); + } + + return this._dropPreviewNode; + }, + + _getDropPreviewListNode: function() { + if (!this._dropPreviewListNode) { + var attributes = {}; + this._dropPreviewListNode = JX.$N('ul', attributes); + } + + return this._dropPreviewListNode; + }, + _findCardsInColumn: function(column_node) { return JX.DOM.scry(column_node, 'li', 'project-card'); }, + _getDropContext: function(after_node, item) { + var header_key; + var after_phids = []; + var before_phids = []; + + // We're going to send an "afterPHID" and a "beforePHID" if the card + // was dropped immediately adjacent to another card. If a card was + // dropped before or after a header, we don't send a PHID for the card + // on the other side of the header. + + // If the view has headers, we always send the header the card was + // dropped under. + + var after_data; + var after_card = after_node; + while (after_card) { + after_data = JX.Stratcom.getData(after_card); + + if (after_data.headerKey) { + break; + } + + if (after_data.objectPHID) { + after_phids.push(after_data.objectPHID); + } + + after_card = after_card.previousSibling; + } + + if (item) { + var before_data; + var before_card = item.nextSibling; + while (before_card) { + before_data = JX.Stratcom.getData(before_card); + + if (before_data.headerKey) { + break; + } + + if (before_data.objectPHID) { + before_phids.push(before_data.objectPHID); + } + + before_card = before_card.nextSibling; + } + } + + var header_data; + var header_node = after_node; + while (header_node) { + header_data = JX.Stratcom.getData(header_node); + if (header_data.headerKey) { + break; + } + header_node = header_node.previousSibling; + } + + if (header_data) { + header_key = header_data.headerKey; + } + + return { + headerKey: header_key, + afterPHIDs: after_phids, + beforePHIDs: before_phids + }; + }, + _onmovecard: function(list, item, after_node, src_list) { list.lock(); JX.DOM.alterClass(item, 'drag-sending', true); @@ -146,16 +489,14 @@ JX.install('WorkboardBoard', { order: this.getOrder() }; - if (after_node) { - data.afterPHID = JX.Stratcom.getData(after_node).objectPHID; - } + var context = this._getDropContext(after_node, item); + data.afterPHIDs = context.afterPHIDs.join(','); + data.beforePHIDs = context.beforePHIDs.join(','); - var before_node = item.nextSibling; - if (before_node) { - var before_phid = JX.Stratcom.getData(before_node).objectPHID; - if (before_phid) { - data.beforePHID = before_phid; - } + if (context.headerKey) { + var properties = this.getHeaderTemplate(context.headerKey) + .getEditProperties(); + data.header = JX.JSON.stringify(properties); } var visible_phids = []; @@ -166,19 +507,49 @@ JX.install('WorkboardBoard', { data.visiblePHIDs = visible_phids.join(','); + // If the user cancels the workflow (for example, by hitting an MFA + // prompt that they click "Cancel" on), put the card back where it was + // and reset the UI state. + var on_revert = JX.bind( + this, + this._revertCard, + list, + item, + src_phid, + dst_phid); + + var after_phid = null; + if (data.afterPHIDs.length) { + after_phid = data.afterPHIDs[0]; + } + var onupdate = JX.bind( this, this._oncardupdate, list, src_phid, dst_phid, - data.afterPHID); + after_phid); new JX.Workflow(this.getController().getMoveURI(), data) .setHandler(onupdate) + .setCloseHandler(on_revert) .start(); }, + _revertCard: function(list, item, src_phid, dst_phid) { + JX.DOM.alterClass(item, 'drag-sending', false); + + var src_column = this.getColumn(src_phid); + var dst_column = this.getColumn(dst_phid); + + src_column.markForRedraw(); + dst_column.markForRedraw(); + this._redrawColumns(); + + list.unlock(); + }, + _oncardupdate: function(list, src_phid, dst_phid, after_phid, response) { var src_column = this.getColumn(src_phid); var dst_column = this.getColumn(dst_phid); @@ -191,6 +562,11 @@ JX.install('WorkboardBoard', { this.updateCard(response); + var sounds = response.sounds || []; + for (var ii = 0; ii < sounds.length; ii++) { + JX.Sound.queue(sounds[ii]); + } + list.unlock(); }, @@ -202,24 +578,15 @@ JX.install('WorkboardBoard', { var phid = response.objectPHID; - if (!this._templates[phid]) { - for (var add_phid in response.columnMaps) { - var target_column = this.getColumn(add_phid); + for (var add_phid in response.columnMaps) { + var target_column = this.getColumn(add_phid); - if (!target_column) { - // If the column isn't visible, don't try to add a card to it. - continue; - } - - target_column.newCard(phid); + if (!target_column) { + // If the column isn't visible, don't try to add a card to it. + continue; } - } - this.setCardTemplate(phid, response.cardHTML); - - var order_maps = response.orderMaps; - for (var order_phid in order_maps) { - this.setOrderMap(order_phid, order_maps[order_phid]); + target_column.newCard(phid); } var column_maps = response.columnMaps; @@ -237,9 +604,37 @@ JX.install('WorkboardBoard', { natural_column.setNaturalOrder(column_maps[natural_phid]); } - var property_maps = response.propertyMaps; - for (var property_phid in property_maps) { - this.setObjectProperties(property_phid, property_maps[property_phid]); + for (var card_phid in response.cards) { + var card_data = response.cards[card_phid]; + var card_template = this.getCardTemplate(card_phid); + + if (card_data.nodeHTMLTemplate) { + card_template.setNodeHTMLTemplate(card_data.nodeHTMLTemplate); + } + + var order; + for (order in card_data.vectors) { + card_template.setSortVector(order, card_data.vectors[order]); + } + + for (order in card_data.headers) { + card_template.setHeaderKey(order, card_data.headers[order]); + } + + for (var key in card_data.properties) { + card_template.setObjectProperty(key, card_data.properties[key]); + } + } + + var headers = response.headers; + for (var jj = 0; jj < headers.length; jj++) { + var header = headers[jj]; + + this.getHeaderTemplate(header.key) + .setOrder(header.order) + .setNodeHTMLTemplate(header.template) + .setVector(header.vector) + .setEditProperties(header.editProperties); } for (var column_phid in columns) { diff --git a/webroot/rsrc/js/application/projects/WorkboardCard.js b/webroot/rsrc/js/application/projects/WorkboardCard.js index b506e655c1..4a3be2a51d 100644 --- a/webroot/rsrc/js/application/projects/WorkboardCard.js +++ b/webroot/rsrc/js/application/projects/WorkboardCard.js @@ -29,7 +29,9 @@ JX.install('WorkboardCard', { }, getProperties: function() { - return this.getColumn().getBoard().getObjectProperties(this.getPHID()); + return this.getColumn().getBoard() + .getCardTemplate(this.getPHID()) + .getObjectProperties(); }, getPoints: function() { @@ -43,14 +45,23 @@ JX.install('WorkboardCard', { getNode: function() { if (!this._root) { var phid = this.getPHID(); - var template = this.getColumn().getBoard().getCardTemplate(phid); - this._root = JX.$H(template).getFragment().firstChild; - JX.Stratcom.getData(this._root).objectPHID = this.getPHID(); + var root = this.getColumn().getBoard() + .getCardTemplate(phid) + .newNode(); + + JX.Stratcom.getData(root).objectPHID = phid; + + this._root = root; } + return this._root; }, + isWorkboardHeader: function() { + return false; + }, + redraw: function() { var old_node = this._root; this._root = null; diff --git a/webroot/rsrc/js/application/projects/WorkboardCardTemplate.js b/webroot/rsrc/js/application/projects/WorkboardCardTemplate.js new file mode 100644 index 0000000000..58f3f9e97f --- /dev/null +++ b/webroot/rsrc/js/application/projects/WorkboardCardTemplate.js @@ -0,0 +1,64 @@ +/** + * @provides javelin-workboard-card-template + * @requires javelin-install + * @javelin + */ + +JX.install('WorkboardCardTemplate', { + + construct: function(phid) { + this._phid = phid; + this._vectors = {}; + this._headerKeys = {}; + + this.setObjectProperties({}); + }, + + properties: { + objectProperties: null + }, + + members: { + _phid: null, + _html: null, + _vectors: null, + _headerKeys: null, + + getPHID: function() { + return this._phid; + }, + + setNodeHTMLTemplate: function(html) { + this._html = html; + return this; + }, + + setSortVector: function(order, vector) { + this._vectors[order] = vector; + return this; + }, + + getSortVector: function(order) { + return this._vectors[order]; + }, + + setHeaderKey: function(order, key) { + this._headerKeys[order] = key; + return this; + }, + + getHeaderKey: function(order) { + return this._headerKeys[order]; + }, + + newNode: function() { + return JX.$H(this._html).getFragment().firstChild; + }, + + setObjectProperty: function(key, value) { + this.getObjectProperties()[key] = value; + return this; + } + } + +}); diff --git a/webroot/rsrc/js/application/projects/WorkboardColumn.js b/webroot/rsrc/js/application/projects/WorkboardColumn.js index 9973648593..a9bf0f8cc5 100644 --- a/webroot/rsrc/js/application/projects/WorkboardColumn.js +++ b/webroot/rsrc/js/application/projects/WorkboardColumn.js @@ -2,6 +2,7 @@ * @provides javelin-workboard-column * @requires javelin-install * javelin-workboard-card + * javelin-workboard-header * @javelin */ @@ -21,7 +22,14 @@ JX.install('WorkboardColumn', { 'column-points-content'); this._cards = {}; + this._headers = {}; + this._objects = []; this._naturalOrder = []; + this._dropEffects = []; + }, + + properties: { + triggerPreviewEffect: null }, members: { @@ -29,11 +37,15 @@ JX.install('WorkboardColumn', { _root: null, _board: null, _cards: null, + _headers: null, _naturalOrder: null, + _orderVectors: null, _panel: null, _pointsNode: null, _pointsContentNode: null, _dirty: true, + _objects: null, + _dropEffects: null, getPHID: function() { return this._phid; @@ -47,6 +59,10 @@ JX.install('WorkboardColumn', { return this._cards; }, + _getObjects: function() { + return this._objects; + }, + getCard: function(phid) { return this._cards[phid]; }, @@ -57,9 +73,19 @@ JX.install('WorkboardColumn', { setNaturalOrder: function(order) { this._naturalOrder = order; + this._orderVectors = null; return this; }, + setDropEffects: function(effects) { + this._dropEffects = effects; + return this; + }, + + getDropEffects: function() { + return this._dropEffects; + }, + getPointsNode: function() { return this._pointsNode; }, @@ -77,6 +103,7 @@ JX.install('WorkboardColumn', { this._cards[phid] = card; this._naturalOrder.push(phid); + this._orderVectors = null; return card; }, @@ -88,6 +115,7 @@ JX.install('WorkboardColumn', { for (var ii = 0; ii < this._naturalOrder.length; ii++) { if (this._naturalOrder[ii] == phid) { this._naturalOrder.splice(ii, 1); + this._orderVectors = null; break; } } @@ -118,15 +146,18 @@ JX.install('WorkboardColumn', { this._naturalOrder.splice(index, 0, phid); } + this._orderVectors = null; + return this; }, - getCardNodes: function() { - var cards = this.getCards(); + getDropTargetNodes: function() { + var objects = this._getObjects(); var nodes = []; - for (var k in cards) { - nodes.push(cards[k].getNode()); + for (var ii = 0; ii < objects.length; ii++) { + var object = objects[ii]; + nodes.push(object.getNode()); } return nodes; @@ -148,24 +179,108 @@ JX.install('WorkboardColumn', { return this._dirty; }, + getHeader: function(key) { + if (!this._headers[key]) { + this._headers[key] = new JX.WorkboardHeader(this, key); + } + return this._headers[key]; + }, + + handleDragGhost: function(default_handler, ghost, node) { + // If the column has headers, don't let the user drag a card above + // the topmost header: for example, you can't change a task to have + // a priority higher than the highest possible priority. + + if (this._hasColumnHeaders()) { + if (!node) { + return false; + } + } + + return default_handler(ghost, node); + }, + + _hasColumnHeaders: function() { + var board = this.getBoard(); + var order = board.getOrder(); + + return board.getOrderTemplate(order).getHasHeaders(); + }, + redraw: function() { var board = this.getBoard(); var order = board.getOrder(); - var list; - if (order == 'natural') { - list = this._getCardsSortedNaturally(); - } else { - list = this._getCardsSortedByKey(order); + var list = this._getCardsSortedByKey(order); + + var ii; + var objects = []; + + var has_headers = this._hasColumnHeaders(); + var header_keys = []; + var seen_headers = {}; + if (has_headers) { + var header_templates = board.getHeaderTemplatesForOrder(order); + for (var k in header_templates) { + header_keys.push(header_templates[k].getHeaderKey()); + } + header_keys.reverse(); } - var content = []; - for (var ii = 0; ii < list.length; ii++) { + var header_key; + var next; + for (ii = 0; ii < list.length; ii++) { var card = list[ii]; - var node = card.getNode(); - content.push(node); + // If a column has a "High" priority card and a "Low" priority card, + // we need to add the "Normal" header in between them. This allows + // you to change priority to "Normal" even if there are no "Normal" + // cards in a column. + if (has_headers) { + header_key = board.getCardTemplate(card.getPHID()) + .getHeaderKey(order); + + if (!seen_headers[header_key]) { + while (header_keys.length) { + next = header_keys.pop(); + + var header = this.getHeader(next); + objects.push(header); + seen_headers[header_key] = true; + + if (next === header_key) { + break; + } + } + } + } + + objects.push(card); + } + + // Add any leftover headers at the bottom of the column which don't have + // any cards in them. In particular, empty columns don't have any cards + // but should still have headers. + + while (header_keys.length) { + next = header_keys.pop(); + + if (seen_headers[next]) { + continue; + } + + objects.push(this.getHeader(next)); + } + + this._objects = objects; + + var content = []; + for (ii = 0; ii < this._objects.length; ii++) { + var object = this._objects[ii]; + + var node = object.getNode(); + content.push(node); } JX.DOM.setContent(this.getRoot(), content); @@ -175,15 +290,30 @@ JX.install('WorkboardColumn', { this._dirty = false; }, - _getCardsSortedNaturally: function() { - var list = []; + compareHandler: function(src_list, src_node, dst_list, dst_node) { + var board = this.getBoard(); + var order = board.getOrder(); - for (var ii = 0; ii < this._naturalOrder.length; ii++) { - var phid = this._naturalOrder[ii]; - list.push(this.getCard(phid)); + var u_vec = this._getNodeOrderVector(src_node, order); + var v_vec = this._getNodeOrderVector(dst_node, order); + + return board.compareVectors(u_vec, v_vec); + }, + + _getNodeOrderVector: function(node, order) { + var board = this.getBoard(); + var data = JX.Stratcom.getData(node); + + if (data.objectPHID) { + return this._getOrderVector(data.objectPHID, order); } - return list; + return board.getHeaderTemplate(data.headerKey).getVector(); + }, + + setIsDropTarget: function(is_target) { + var node = this.getWorkpanelNode(); + JX.DOM.alterClass(node, 'workboard-column-drop-target', is_target); }, _getCardsSortedByKey: function(order) { @@ -200,20 +330,65 @@ JX.install('WorkboardColumn', { }, _sortCards: function(order, u, v) { - var ud = this.getBoard().getOrderVector(u.getPHID(), order); - var vd = this.getBoard().getOrderVector(v.getPHID(), order); + var board = this.getBoard(); + var u_vec = this._getOrderVector(u.getPHID(), order); + var v_vec = this._getOrderVector(v.getPHID(), order); - for (var ii = 0; ii < ud.length; ii++) { - if (ud[ii] > vd[ii]) { - return 1; - } + return board.compareVectors(u_vec, v_vec); + }, - if (ud[ii] < vd[ii]) { - return -1; - } + _getOrderVector: function(phid, order) { + var board = this.getBoard(); + + if (!this._orderVectors) { + this._orderVectors = {}; } - return 0; + if (!this._orderVectors[order]) { + var cards = this.getCards(); + var vectors = {}; + + for (var k in cards) { + var card_phid = cards[k].getPHID(); + var vector = board.getCardTemplate(card_phid) + .getSortVector(order); + + vectors[card_phid] = [].concat(vector); + + // Push a "card" type, so cards always sort after headers; headers + // have a "0" in this position. + vectors[card_phid].push(1); + } + + for (var ii = 0; ii < this._naturalOrder.length; ii++) { + var natural_phid = this._naturalOrder[ii]; + if (vectors[natural_phid]) { + vectors[natural_phid].push(ii); + } + } + + this._orderVectors[order] = vectors; + } + + if (!this._orderVectors[order][phid]) { + // In this case, we're comparing a card being dragged in from another + // column to the cards already in this column. We're just going to + // build a temporary vector for it. + var incoming_vector = board.getCardTemplate(phid) + .getSortVector(order); + incoming_vector = [].concat(incoming_vector); + + // Add a "card" type to sort this after headers. + incoming_vector.push(1); + + // Add a "0" for the natural ordering to put this on top. A new card + // has no natural ordering on a column it isn't part of yet. + incoming_vector.push(0); + + return incoming_vector; + } + + return this._orderVectors[order][phid]; }, _redrawFrame: function() { @@ -279,9 +454,18 @@ JX.install('WorkboardColumn', { JX.DOM.setContent(content_node, display_value); - var is_empty = !this.getCardPHIDs().length; + // Only put the "empty" style on the column (which just adds some empty + // space so it's easier to drop cards into an empty column) if it has no + // cards and no headers. + + var is_empty = + (!this.getCardPHIDs().length) && + (!this._hasColumnHeaders()); + var panel = JX.DOM.findAbove(this.getRoot(), 'div', 'workpanel'); JX.DOM.alterClass(panel, 'project-panel-empty', is_empty); + + JX.DOM.alterClass(panel, 'project-panel-over-limit', over_limit); var color_map = { diff --git a/webroot/rsrc/js/application/projects/WorkboardDropEffect.js b/webroot/rsrc/js/application/projects/WorkboardDropEffect.js new file mode 100644 index 0000000000..0c729fc517 --- /dev/null +++ b/webroot/rsrc/js/application/projects/WorkboardDropEffect.js @@ -0,0 +1,73 @@ +/** + * @provides javelin-workboard-drop-effect + * @requires javelin-install + * javelin-dom + * @javelin + */ + +JX.install('WorkboardDropEffect', { + + properties: { + icon: null, + color: null, + content: null, + isTriggerEffect: false, + isHeader: false, + conditions: [] + }, + + statics: { + newFromDictionary: function(map) { + return new JX.WorkboardDropEffect() + .setIcon(map.icon) + .setColor(map.color) + .setContent(JX.$H(map.content)) + .setIsTriggerEffect(map.isTriggerEffect) + .setIsHeader(map.isHeader) + .setConditions(map.conditions || []); + } + }, + + members: { + newNode: function() { + var icon = new JX.PHUIXIconView() + .setIcon(this.getIcon()) + .setColor(this.getColor()) + .getNode(); + + var attributes = {}; + + if (this.getIsHeader()) { + attributes.className = 'workboard-drop-preview-header'; + } + + return JX.$N('li', attributes, [icon, this.getContent()]); + }, + + isEffectVisibleForCard: function(card) { + var conditions = this.getConditions(); + + var properties = card.getProperties(); + for (var ii = 0; ii < conditions.length; ii++) { + var condition = conditions[ii]; + + var field = properties[condition.field]; + var value = condition.value; + + var result = true; + switch (condition.operator) { + case '!=': + result = (field !== value); + break; + } + + if (!result) { + return false; + } + } + + return true; + } + + } +}); diff --git a/webroot/rsrc/js/application/projects/WorkboardHeader.js b/webroot/rsrc/js/application/projects/WorkboardHeader.js new file mode 100644 index 0000000000..a0cbfc13c7 --- /dev/null +++ b/webroot/rsrc/js/application/projects/WorkboardHeader.js @@ -0,0 +1,48 @@ +/** + * @provides javelin-workboard-header + * @requires javelin-install + * @javelin + */ + +JX.install('WorkboardHeader', { + + construct: function(column, header_key) { + this._column = column; + this._headerKey = header_key; + }, + + members: { + _root: null, + _column: null, + _headerKey: null, + + getColumn: function() { + return this._column; + }, + + getHeaderKey: function() { + return this._headerKey; + }, + + getNode: function() { + if (!this._root) { + var header_key = this.getHeaderKey(); + + var root = this.getColumn().getBoard() + .getHeaderTemplate(header_key) + .newNode(); + + JX.Stratcom.getData(root).headerKey = header_key; + + this._root = root; + } + + return this._root; + }, + + isWorkboardHeader: function() { + return true; + } + } + +}); diff --git a/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js b/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js new file mode 100644 index 0000000000..d64a56dd29 --- /dev/null +++ b/webroot/rsrc/js/application/projects/WorkboardHeaderTemplate.js @@ -0,0 +1,40 @@ +/** + * @provides javelin-workboard-header-template + * @requires javelin-install + * @javelin + */ + +JX.install('WorkboardHeaderTemplate', { + + construct: function(header_key) { + this._headerKey = header_key; + }, + + properties: { + template: null, + order: null, + vector: null, + editProperties: null, + dropEffects: [] + }, + + members: { + _headerKey: null, + _html: null, + + getHeaderKey: function() { + return this._headerKey; + }, + + setNodeHTMLTemplate: function(html) { + this._html = html; + return this; + }, + + newNode: function() { + return JX.$H(this._html).getFragment().firstChild; + } + + } + +}); diff --git a/webroot/rsrc/js/application/projects/WorkboardOrderTemplate.js b/webroot/rsrc/js/application/projects/WorkboardOrderTemplate.js new file mode 100644 index 0000000000..083dc78b50 --- /dev/null +++ b/webroot/rsrc/js/application/projects/WorkboardOrderTemplate.js @@ -0,0 +1,27 @@ +/** + * @provides javelin-workboard-order-template + * @requires javelin-install + * @javelin + */ + +JX.install('WorkboardOrderTemplate', { + + construct: function(order) { + this._orderKey = order; + }, + + properties: { + hasHeaders: false, + canReorder: false + }, + + members: { + _orderKey: null, + + getOrderKey: function() { + return this._orderKey; + } + + } + +}); diff --git a/webroot/rsrc/js/application/projects/behavior-project-boards.js b/webroot/rsrc/js/application/projects/behavior-project-boards.js index 83f41787ab..bba6db7a49 100644 --- a/webroot/rsrc/js/application/projects/behavior-project-boards.js +++ b/webroot/rsrc/js/application/projects/behavior-project-boards.js @@ -7,6 +7,7 @@ * javelin-stratcom * javelin-workflow * javelin-workboard-controller + * javelin-workboard-drop-effect */ JX.behavior('project-boards', function(config, statics) { @@ -83,28 +84,98 @@ JX.behavior('project-boards', function(config, statics) { var templates = config.templateMap; for (var k in templates) { - board.setCardTemplate(k, templates[k]); + board.getCardTemplate(k) + .setNodeHTMLTemplate(templates[k]); } - var column_maps = config.columnMaps; - for (var column_phid in column_maps) { - var column = board.getColumn(column_phid); - var column_map = column_maps[column_phid]; - for (var ii = 0; ii < column_map.length; ii++) { - column.newCard(column_map[ii]); + var ii; + var jj; + var effects; + + for (ii = 0; ii < config.columnTemplates.length; ii++) { + var spec = config.columnTemplates[ii]; + + var column = board.getColumn(spec.columnPHID); + + effects = []; + for (jj = 0; jj < spec.effects.length; jj++) { + effects.push( + JX.WorkboardDropEffect.newFromDictionary( + spec.effects[jj])); + } + column.setDropEffects(effects); + + for (jj = 0; jj < spec.cardPHIDs.length; jj++) { + column.newCard(spec.cardPHIDs[jj]); + } + + if (spec.triggerPreviewEffect) { + column.setTriggerPreviewEffect( + JX.WorkboardDropEffect.newFromDictionary( + spec.triggerPreviewEffect)); } } var order_maps = config.orderMaps; for (var object_phid in order_maps) { - board.setOrderMap(object_phid, order_maps[object_phid]); + var order_card = board.getCardTemplate(object_phid); + for (var order_key in order_maps[object_phid]) { + order_card.setSortVector(order_key, order_maps[object_phid][order_key]); + } } var property_maps = config.propertyMaps; for (var property_phid in property_maps) { - board.setObjectProperties(property_phid, property_maps[property_phid]); + board.getCardTemplate(property_phid) + .setObjectProperties(property_maps[property_phid]); + } + + var headers = config.headers; + for (ii = 0; ii < headers.length; ii++) { + var header = headers[ii]; + + effects = []; + for (jj = 0; jj < header.effects.length; jj++) { + effects.push( + JX.WorkboardDropEffect.newFromDictionary( + header.effects[jj])); + } + + board.getHeaderTemplate(header.key) + .setOrder(header.order) + .setNodeHTMLTemplate(header.template) + .setVector(header.vector) + .setEditProperties(header.editProperties) + .setDropEffects(effects); + } + + var orders = config.orders; + for (ii = 0; ii < orders.length; ii++) { + var order = orders[ii]; + + board.getOrderTemplate(order.orderKey) + .setHasHeaders(order.hasHeaders) + .setCanReorder(order.canReorder); + } + + var header_keys = config.headerKeys; + for (var header_phid in header_keys) { + board.getCardTemplate(header_phid) + .setHeaderKey(config.order, header_keys[header_phid]); } board.start(); + // In Safari, we can only play sounds that we've already loaded, and we can + // only load them in response to an explicit user interaction like a click. + var sounds = config.preloadSounds; + var listener = JX.Stratcom.listen('mousedown', null, function() { + for (var ii = 0; ii < sounds.length; ii++) { + JX.Sound.load(sounds[ii]); + } + + // Remove this callback once it has run once. + listener.remove(); + }); + }); diff --git a/webroot/rsrc/js/application/repository/repository-crossreference.js b/webroot/rsrc/js/application/repository/repository-crossreference.js index 548ef6173b..d6ff2a06aa 100644 --- a/webroot/rsrc/js/application/repository/repository-crossreference.js +++ b/webroot/rsrc/js/application/repository/repository-crossreference.js @@ -237,11 +237,6 @@ JX.behavior('repository-crossreference', function(config, statics) { } var content = '' + node.textContent; - - // Strip off any ZWS characters. These are marker characters used to - // improve copy/paste behavior. - content = content.replace(/\u200B/g, ''); - char += content.length; } diff --git a/webroot/rsrc/js/application/trigger/TriggerRule.js b/webroot/rsrc/js/application/trigger/TriggerRule.js new file mode 100644 index 0000000000..cf117e24d9 --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRule.js @@ -0,0 +1,138 @@ +/** + * @provides trigger-rule + * @javelin + */ + +JX.install('TriggerRule', { + + construct: function() { + }, + + properties: { + rowID: null, + type: null, + value: null, + editor: null, + isValidRule: true, + invalidView: null + }, + + statics: { + newFromDictionary: function(map) { + return new JX.TriggerRule() + .setType(map.type) + .setValue(map.value) + .setIsValidRule(map.isValidRule) + .setInvalidView(map.invalidView); + }, + }, + + members: { + _typeCell: null, + _valueCell: null, + _readValueCallback: null, + + newRowContent: function() { + if (!this.getIsValidRule()) { + var invalid_cell = JX.$N( + 'td', + { + colSpan: 2, + className: 'invalid-cell' + }, + JX.$H(this.getInvalidView())); + + return [invalid_cell]; + } + + var type_cell = this._getTypeCell(); + var value_cell = this._getValueCell(); + + + this._rebuildValueControl(); + + return [type_cell, value_cell]; + }, + + getValueForSubmit: function() { + this._readValueFromControl(); + + return { + type: this.getType(), + value: this.getValue() + }; + }, + + _getTypeCell: function() { + if (!this._typeCell) { + var editor = this.getEditor(); + var types = editor.getTypes(); + + var options = []; + for (var ii = 0; ii < types.length; ii++) { + var type = types[ii]; + + if (!type.getIsSelectable()) { + continue; + } + + options.push( + JX.$N('option', {value: type.getType()}, type.getName())); + } + + var control = JX.$N('select', {}, options); + + control.value = this.getType(); + + var on_change = JX.bind(this, this._onTypeChange, control); + JX.DOM.listen(control, 'change', null, on_change); + + var attributes = { + className: 'type-cell' + }; + + this._typeCell = JX.$N('td', attributes, control); + } + + return this._typeCell; + }, + + _onTypeChange: function(control) { + this.setType(control.value); + this._rebuildValueControl(); + }, + + _getValueCell: function() { + if (!this._valueCell) { + var attributes = { + className: 'value-cell' + }; + + this._valueCell = JX.$N('td', attributes); + } + + return this._valueCell; + }, + + _rebuildValueControl: function() { + var value_cell = this._getValueCell(); + + var editor = this.getEditor(); + var type = editor.getType(this.getType()); + var control = type.getControl(); + + var input = control.newInput(this); + this._readValueCallback = input.get; + + JX.DOM.setContent(value_cell, input.node); + }, + + _readValueFromControl: function() { + if (this._readValueCallback) { + this.setValue(this._readValueCallback()); + } + } + + } + +}); diff --git a/webroot/rsrc/js/application/trigger/TriggerRuleControl.js b/webroot/rsrc/js/application/trigger/TriggerRuleControl.js new file mode 100644 index 0000000000..a05e740ff9 --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRuleControl.js @@ -0,0 +1,40 @@ +/** + * @requires phuix-form-control-view + * @provides trigger-rule-control + * @javelin + */ + +JX.install('TriggerRuleControl', { + + construct: function() { + }, + + properties: { + type: null, + specification: null + }, + + statics: { + newFromDictionary: function(map) { + return new JX.TriggerRuleControl() + .setType(map.type) + .setSpecification(map.specification); + }, + }, + + members: { + newInput: function(rule) { + var phuix = new JX.PHUIXFormControl() + .setControl(this.getType(), this.getSpecification()); + + phuix.setValue(rule.getValue()); + + return { + node: phuix.getRawInputNode(), + get: JX.bind(phuix, phuix.getValue) + }; + } + + } + +}); diff --git a/webroot/rsrc/js/application/trigger/TriggerRuleEditor.js b/webroot/rsrc/js/application/trigger/TriggerRuleEditor.js new file mode 100644 index 0000000000..3574a8dbca --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRuleEditor.js @@ -0,0 +1,137 @@ +/** + * @requires multirow-row-manager + * trigger-rule + * @provides trigger-rule-editor + * @javelin + */ + +JX.install('TriggerRuleEditor', { + + construct: function(form_node) { + this._formNode = form_node; + this._rules = []; + this._types = []; + }, + + members: { + _formNode: null, + _tableNode: null, + _createButtonNode: null, + _inputNode: null, + _rowManager: null, + _rules: null, + _types: null, + + setTableNode: function(table) { + this._tableNode = table; + return this; + }, + + setCreateButtonNode: function(button) { + this._createButtonNode = button; + return this; + }, + + setInputNode: function(input) { + this._inputNode = input; + return this; + }, + + start: function() { + var on_submit = JX.bind(this, this._submitForm); + JX.DOM.listen(this._formNode, 'submit', null, on_submit); + + var manager = new JX.MultirowRowManager(this._tableNode); + this._rowManager = manager; + + var on_remove = JX.bind(this, this._rowRemoved); + manager.listen('row-removed', on_remove); + + var create_button = this._createButtonNode; + var on_create = JX.bind(this, this._createRow); + JX.DOM.listen(create_button, 'click', null, on_create); + }, + + _submitForm: function() { + var values = []; + for (var ii = 0; ii < this._rules.length; ii++) { + var rule = this._rules[ii]; + values.push(rule.getValueForSubmit()); + } + + this._inputNode.value = JX.JSON.stringify(values); + }, + + _createRow: function(e) { + var rule = this.newRule(); + this.addRule(rule); + e.kill(); + }, + + newRule: function() { + // Create new rules with the first valid rule type. + var types = this.getTypes(); + var type; + for (var ii = 0; ii < types.length; ii++) { + type = types[ii]; + if (!type.getIsSelectable()) { + continue; + } + + // If we make it here: this type is valid, so use it. + break; + } + + var default_value = type.getDefaultValue(); + + return new JX.TriggerRule() + .setType(type.getType()) + .setValue(default_value); + }, + + addRule: function(rule) { + rule.setEditor(this); + this._rules.push(rule); + + var manager = this._rowManager; + + var row = manager.addRow([]); + var row_id = manager.getRowID(row); + rule.setRowID(row_id); + + manager.updateRow(row_id, rule.newRowContent()); + }, + + addType: function(type) { + this._types.push(type); + return this; + }, + + getTypes: function() { + return this._types; + }, + + getType: function(type) { + for (var ii = 0; ii < this._types.length; ii++) { + if (this._types[ii].getType() === type) { + return this._types[ii]; + } + } + + return null; + }, + + _rowRemoved: function(row_id) { + for (var ii = 0; ii < this._rules.length; ii++) { + var rule = this._rules[ii]; + + if (rule.getRowID() === row_id) { + this._rules.splice(ii, 1); + break; + } + } + } + + } + +}); diff --git a/webroot/rsrc/js/application/trigger/TriggerRuleType.js b/webroot/rsrc/js/application/trigger/TriggerRuleType.js new file mode 100644 index 0000000000..1075eecedf --- /dev/null +++ b/webroot/rsrc/js/application/trigger/TriggerRuleType.js @@ -0,0 +1,36 @@ +/** + * @requires trigger-rule-control + * @provides trigger-rule-type + * @javelin + */ + +JX.install('TriggerRuleType', { + + construct: function() { + }, + + properties: { + type: null, + name: null, + isSelectable: true, + defaultValue: null, + control: null + }, + + statics: { + newFromDictionary: function(map) { + var control = JX.TriggerRuleControl.newFromDictionary(map.control); + + return new JX.TriggerRuleType() + .setType(map.type) + .setName(map.name) + .setIsSelectable(map.selectable) + .setDefaultValue(map.defaultValue) + .setControl(control); + }, + }, + + members: { + } + +}); diff --git a/webroot/rsrc/js/application/trigger/trigger-rule-editor.js b/webroot/rsrc/js/application/trigger/trigger-rule-editor.js new file mode 100644 index 0000000000..d2741cc337 --- /dev/null +++ b/webroot/rsrc/js/application/trigger/trigger-rule-editor.js @@ -0,0 +1,41 @@ +/** + * @requires javelin-behavior + * trigger-rule-editor + * trigger-rule + * trigger-rule-type + * @provides javelin-behavior-trigger-rule-editor + * @javelin + */ + +JX.behavior('trigger-rule-editor', function(config) { + var form_node = JX.$(config.formNodeID); + var table_node = JX.$(config.tableNodeID); + var create_node = JX.$(config.createNodeID); + var input_node = JX.$(config.inputNodeID); + + var editor = new JX.TriggerRuleEditor(form_node) + .setTableNode(table_node) + .setCreateButtonNode(create_node) + .setInputNode(input_node); + + editor.start(); + + var ii; + + for (ii = 0; ii < config.types.length; ii++) { + var type = JX.TriggerRuleType.newFromDictionary(config.types[ii]); + editor.addType(type); + } + + if (config.rules.length) { + for (ii = 0; ii < config.rules.length; ii++) { + var rule = JX.TriggerRule.newFromDictionary(config.rules[ii]); + editor.addRule(rule); + } + } else { + // If the trigger doesn't have any rules yet, add an empty rule to start + // with, so the user doesn't have to click "New Rule". + editor.addRule(editor.newRule()); + } + +}); diff --git a/webroot/rsrc/js/core/DraggableList.js b/webroot/rsrc/js/core/DraggableList.js index a545ed7272..5f19b7061d 100644 --- a/webroot/rsrc/js/core/DraggableList.js +++ b/webroot/rsrc/js/core/DraggableList.js @@ -39,9 +39,14 @@ JX.install('DraggableList', { properties : { findItemsHandler: null, + compareHandler: null, + isDropTargetHandler: null, canDragX: false, outerContainer: null, - hasInfiniteHeight: false + hasInfiniteHeight: false, + compareOnMove: false, + compareOnReorder: false, + targetChangeHandler: null }, members : { @@ -49,6 +54,7 @@ JX.install('DraggableList', { _dragging : null, _locked : 0, _target : null, + _lastTarget: null, _targets : null, _ghostHandler : null, _ghostNode : null, @@ -238,6 +244,7 @@ JX.install('DraggableList', { frame.appendChild(clone); document.body.appendChild(frame); + JX.DOM.alterClass(document.body, 'jx-dragging', true); this._dragging = drag; this._clone = clone; @@ -317,7 +324,7 @@ JX.install('DraggableList', { } } - JX.DOM.alterClass(root, 'drag-target-list', is_target); + group[ii]._setIsDropTarget(is_target); } } else { target_list = this; @@ -367,8 +374,54 @@ JX.install('DraggableList', { return this; }, + _didChangeTarget: function(dst_list, dst_node) { + if (dst_node === this._lastTarget) { + return; + } + + this._lastTarget = dst_node; + + var handler = this.getTargetChangeHandler(); + if (handler) { + handler(this, this._dragging, dst_list, dst_node); + } + }, + + _setIsDropTarget: function(is_target) { + var root = this.getRootNode(); + JX.DOM.alterClass(root, 'drag-target-list', is_target); + + var handler = this.getIsDropTargetHandler(); + if (handler) { + handler(is_target); + } + + return this; + }, + + _getOrderedTarget: function(src_list, src_node) { + var targets = this._getTargets(); + + // NOTE: The targets are ordered from the bottom of the column to the + // top, so we're looking for the first node that we sort below. If we + // don't find one, we'll sort to the head of the column. + + for (var ii = 0; ii < targets.length; ii++) { + var target = targets[ii]; + if (this._compareTargets(src_list, src_node, target.item) > 0) { + return target.item; + } + } + + return null; + }, + + _compareTargets: function(src_list, src_node, dst_node) { + var dst_list = this; + return this.getCompareHandler()(src_list, src_node, dst_list, dst_node); + }, + _getCurrentTarget : function(p) { - var ghost = this.getGhostNode(); var targets = this._getTargets(); var dragging = this._dragging; @@ -461,9 +514,34 @@ JX.install('DraggableList', { // Compute the size and position of the drop target indicator, because we // need to update our static position computations to account for it. + var compare_handler = this.getCompareHandler(); + var cur_target = false; if (target_list) { - cur_target = target_list._getCurrentTarget(p); + // Determine if we're going to use the compare handler or not: the + // compare hander locks items into a specific place in the list. For + // example, on Workboards, some operations permit the user to drag + // items between lists, but not to reorder items within a list. + + var should_compare = false; + + var is_reorder = (target_list === this); + var is_move = (target_list !== this); + + if (compare_handler) { + if (is_reorder && this.getCompareOnReorder()) { + should_compare = true; + } + if (is_move && this.getCompareOnMove()) { + should_compare = true; + } + } + + if (should_compare) { + cur_target = target_list._getOrderedTarget(this, this._dragging); + } else { + cur_target = target_list._getCurrentTarget(p); + } } // If we've selected a new target, update the UI to show where we're @@ -477,6 +555,8 @@ JX.install('DraggableList', { } } + this._didChangeTarget(target_list, cur_target); + this._updateAutoscroll(this._cursorPosition); var f = JX.$V(this._frame); @@ -577,6 +657,7 @@ JX.install('DraggableList', { this._autoscroller = null; JX.DOM.remove(this._frame); + JX.DOM.alterClass(document.body, 'jx-dragging', false); this._frame = null; this._clone = null; @@ -605,10 +686,12 @@ JX.install('DraggableList', { var group = this._group; for (var ii = 0; ii < group.length; ii++) { - JX.DOM.alterClass(group[ii].getRootNode(), 'drag-target-list', false); + group[ii]._setIsDropTarget(false); group[ii]._clearTarget(); } + this._didChangeTarget(null, null); + JX.DOM.alterClass(dragging, 'drag-dragging', false); JX.Tooltip.unlock(); diff --git a/webroot/rsrc/js/core/Prefab.js b/webroot/rsrc/js/core/Prefab.js index 979ad3473b..ff4467881b 100644 --- a/webroot/rsrc/js/core/Prefab.js +++ b/webroot/rsrc/js/core/Prefab.js @@ -125,15 +125,18 @@ JX.install('Prefab', { var icon; var type; var color; + var availability_color; if (result) { icon = result.icon; value = result.displayName; type = result.tokenType; color = result.color; + availability_color = result.availabilityColor; } else { icon = (config.icons || {})[key]; type = (config.types || {})[key]; color = (config.colors || {})[key]; + availability_color = (config.availabilityColors || {})[key]; } if (icon) { @@ -147,7 +150,16 @@ JX.install('Prefab', { JX.DOM.alterClass(container, color, true); } - return [icon, value]; + var dot; + if (availability_color) { + dot = JX.$N( + 'span', + { + className: 'phui-tag-dot phui-tag-color-' + availability_color + }); + } + + return [icon, dot, value]; }); if (config.placeholder) { @@ -275,10 +287,20 @@ JX.install('Prefab', { icon_ui = JX.Prefab._renderIcon(icon); } + var availability_ui; + var availability_color = fields[16]; + if (availability_color) { + availability_ui = JX.$N( + 'span', + { + className: 'phui-tag-dot phui-tag-color-' + availability_color + }); + } + var display = JX.$N( 'div', {className: 'tokenizer-result'}, - [icon_ui, fields[4] || fields[0], closed_ui]); + [icon_ui, availability_ui, fields[4] || fields[0], closed_ui]); if (closed) { JX.DOM.alterClass(display, 'tokenizer-result-closed', true); } @@ -300,7 +322,8 @@ JX.install('Prefab', { tokenType: fields[12], unique: fields[13] || false, autocomplete: fields[14], - sort: JX.TypeaheadNormalizer.normalize(fields[0]) + sort: JX.TypeaheadNormalizer.normalize(fields[0]), + availabilityColor: availability_color }; }, diff --git a/webroot/rsrc/js/core/behavior-oncopy.js b/webroot/rsrc/js/core/behavior-oncopy.js index aa8c684fee..b56e83ab32 100644 --- a/webroot/rsrc/js/core/behavior-oncopy.js +++ b/webroot/rsrc/js/core/behavior-oncopy.js @@ -4,62 +4,319 @@ * javelin-dom */ -/** - * Tools like Paste and Differential don't normally respond to the clipboard - * 'copy' operation well, because when a user copies text they'll get line - * numbers and other metadata. - * - * To improve this behavior, applications can embed markers that delimit - * metadata (left of the marker) from content (right of the marker). When - * we get a copy event, we strip out all the metadata and just copy the - * actual text. - */ JX.behavior('phabricator-oncopy', function() { + var copy_root; + var copy_mode; - var zws = '\u200B'; // Unicode Zero-Width Space + function onstartselect(e) { + var target = e.getTarget(); - JX.enableDispatch(document.body, 'copy'); - JX.Stratcom.listen( - ['copy'], - null, - function(e) { + var container; + try { + // NOTE: For now, all elements with custom oncopy behavior are tables, + // so this tag selection will hit everything we need it to. + container = JX.DOM.findAbove(target, 'table', 'intercept-copy'); + } catch (ex) { + container = null; + } - var selection; - var text; - if (window.getSelection) { - selection = window.getSelection(); - text = selection.toString(); + var old_mode = copy_mode; + clear_selection_mode(); + + if (!container) { + return; + } + + // If the potential selection is starting inside an inline comment, + // don't do anything special. + try { + if (JX.DOM.findAbove(target, 'div', 'differential-inline-comment')) { + return; + } + } catch (ex) { + // Continue. + } + + // Find the row and cell we're copying from. If we don't find anything, + // don't do anything special. + var row; + var cell; + try { + // The target may be the cell we're after, particularly if you click + // in the white area to the right of the text, towards the end of a line. + if (JX.DOM.isType(target, 'td')) { + cell = target; } else { - selection = document.selection; - text = selection.createRange().text; + cell = JX.DOM.findAbove(target, 'td'); + } + row = JX.DOM.findAbove(target, 'tr'); + } catch (ex) { + return; + } + + // If the row doesn't have enough nodes, bail out. Note that it's okay + // to begin a selection in the whitespace on the opposite side of an inline + // comment. For example, if there's an inline comment on the right side of + // a diff, it's okay to start selecting the left side of the diff by + // clicking the corresponding empty space on the left side. + if (row.childNodes.length < 4) { + return; + } + + // If the selection's cell is in the "old" diff or the "new" diff, we'll + // activate an appropriate copy mode. + var mode; + if (cell === row.childNodes[1]) { + mode = 'copy-l'; + } else if ((row.childNodes.length >= 4) && (cell === row.childNodes[4])) { + mode = 'copy-r'; + } else { + return; + } + + // We found a copy mode, so set it as the current active mode. + copy_root = container; + copy_mode = mode; + + // If the user makes a selection, then clicks again inside the same + // selection, browsers retain the selection. This is because the user may + // want to drag-and-drop the text to another window. + + // Handle special cases when the click is inside an existing selection. + + var ranges = get_selected_ranges(); + if (ranges.length) { + // We'll have an existing selection if the user selects text on the right + // side of a diff, then clicks the selection on the left side of the + // diff, even if the second click is clicking part of the selection + // range where the selection highlight is currently invisible because + // of CSS rules. + + // This behavior looks and feels glitchy: an invisible selection range + // suddenly pops into existence and there's a bunch of flicker. If we're + // switching selection modes, clear the old selection to avoid this: + // assume the user is not trying to drag-and-drop text which is not + // visually selected. + + if (old_mode !== copy_mode) { + window.getSelection().removeAllRanges(); } - if (text.indexOf(zws) == -1) { - // If there's no marker in the text, just let it copy normally. + // In the more mundane case, if the user selects some text on one side + // of a diff and then clicks that same selection in a normal way (in + // the visible part of the highlighted text), we may either be altering + // the selection range or may be initiating a text drag depending on how + // long they hold the button for. Regardless of what we're doing, we're + // still in a selection mode, so keep the visual hints active. + + JX.DOM.alterClass(copy_root, copy_mode, true); + } + + // We've chosen a mode and saved it now, but we don't actually update to + // apply any visual changes until the user actually starts making some + // kind of selection. + } + + // When the selection range changes, apply CSS classes if the selection is + // nonempty. We don't want to make visual changes to the document immediately + // when the user press the mouse button, since we aren't yet sure that + // they are starting a selection: instead, wait for them to actually select + // something. + function onchangeselect() { + if (!copy_mode) { + return; + } + + var ranges = get_selected_ranges(); + JX.DOM.alterClass(copy_root, copy_mode, !!ranges.length); + } + + // When the user releases the mouse, get rid of the selection mode if we + // don't have a selection. + function onendselect(e) { + if (!copy_mode) { + return; + } + + var ranges = get_selected_ranges(); + if (!ranges.length) { + clear_selection_mode(); + } + } + + function get_selected_ranges() { + var ranges = []; + + if (!window.getSelection) { + return ranges; + } + + var selection = window.getSelection(); + for (var ii = 0; ii < selection.rangeCount; ii++) { + var range = selection.getRangeAt(ii); + if (range.collapsed) { + continue; + } + + ranges.push(range); + } + + return ranges; + } + + function clear_selection_mode() { + if (!copy_root) { + return; + } + + JX.DOM.alterClass(copy_root, copy_mode, false); + copy_root = null; + copy_mode = null; + } + + function oncopy(e) { + // If we aren't in a special copy mode, just fall back to default + // behavior. + if (!copy_mode) { + return; + } + + var ranges = get_selected_ranges(); + if (!ranges.length) { + return; + } + + var text = []; + for (var ii = 0; ii < ranges.length; ii++) { + var range = ranges[ii]; + + var fragment = range.cloneContents(); + if (!fragment.childNodes.length) { + continue; + } + + // In Chrome and Firefox, because we've already applied "user-select" + // CSS to everything we don't intend to copy, the text in the selection + // range is correct, and the range will include only the correct text + // nodes. + + // However, in Safari, "user-select" does not apply to clipboard + // operations, so we get everything in the document between the beginning + // and end of the selection, even if it isn't visibly selected. + + // Even in Chrome and Firefox, we can get partial empty nodes: for + // example, where a "" is selectable but no content in the node is + // selectable. (We have to leave the "" itself selectable because + // of how Firefox applies "user-select" rules.) + + // The nodes we get here can also start and end more or less anywhere. + + // One saving grace is that we use "content: attr(data-n);" to render + // the line numbers and no browsers copy this content, so we don't have + // to worry about figuring out when text is line numbers. + + for (var jj = 0; jj < fragment.childNodes.length; jj++) { + var node = fragment.childNodes[jj]; + text.push(extract_text(node)); + } + } + + text = flatten_list(text); + text = text.join(''); + + var rawEvent = e.getRawEvent(); + var data; + if ('clipboardData' in rawEvent) { + data = rawEvent.clipboardData; + } else { + data = window.clipboardData; + } + data.setData('Text', text); + + e.prevent(); + } + + function extract_text(node) { + var ii; + var text = []; + + if (JX.DOM.isType(node, 'tr')) { + // This is an inline comment row, so we never want to copy any + // content inside of it. + if (JX.Stratcom.hasSigil(node, 'inline-row')) { + return null; + } + + // This is a "Show More Context" row, so we never want to copy any + // of the content inside. + if (JX.Stratcom.hasSigil(node, 'context-target')) { + return null; + } + + // Assume anything else is a source code row. Keep only "" cells + // with the correct mode. + for (ii = 0; ii < node.childNodes.length; ii++) { + text.push(extract_text(node.childNodes[ii])); + } + + return text; + } + + if (JX.DOM.isType(node, 'td')) { + var node_mode = node.getAttribute('data-copy-mode'); + if (node_mode !== copy_mode) { return; } - var result = []; + // Otherwise, fall through and extract this node's text normally. + } - // Strip everything before the marker (and the marker itself) out of the - // text. If a line doesn't have the marker, throw it away (the assumption - // is that it's a line number or part of some other meta-text). - var lines = text.split('\n'); - var pos; - for (var ii = 0; ii < lines.length; ii++) { - pos = lines[ii].indexOf(zws); - if (pos == -1 && ii !== 0) { - continue; - } - result.push(lines[ii].substring(pos + 1)); + if (node.getAttribute) { + var copy_text = node.getAttribute('data-copy-text'); + if (copy_text) { + return copy_text; } - result = result.join('\n'); + } - var rawEvent = e.getRawEvent(); - var clipboardData = 'clipboardData' in rawEvent ? - rawEvent.clipboardData : - window.clipboardData; - clipboardData.setData('Text', result); - e.prevent(); - }); + if (!node.childNodes || !node.childNodes.length) { + return node.textContent; + } + + for (ii = 0; ii < node.childNodes.length; ii++) { + var child = node.childNodes[ii]; + text.push(extract_text(child)); + } + + return text; + } + + function flatten_list(list) { + var stack = [list]; + var result = []; + while (stack.length) { + var next = stack.pop(); + if (JX.isArray(next)) { + for (var ii = 0; ii < next.length; ii++) { + stack.push(next[ii]); + } + } else if (next === null) { + continue; + } else if (next === undefined) { + continue; + } else { + result.push(next); + } + } + + return result.reverse(); + } + + JX.enableDispatch(document.body, 'copy'); + JX.enableDispatch(window, 'selectionchange'); + + JX.Stratcom.listen('mousedown', null, onstartselect); + JX.Stratcom.listen('selectionchange', null, onchangeselect); + JX.Stratcom.listen('mouseup', null, onendselect); + + JX.Stratcom.listen('copy', null, oncopy); }); diff --git a/webroot/rsrc/js/core/behavior-toggle-class.js b/webroot/rsrc/js/core/behavior-toggle-class.js index d4756eb6bb..18663b0487 100644 --- a/webroot/rsrc/js/core/behavior-toggle-class.js +++ b/webroot/rsrc/js/core/behavior-toggle-class.js @@ -17,7 +17,7 @@ JX.behavior('toggle-class', function(config, statics) { function install() { JX.Stratcom.listen( - ['touchstart', 'mousedown'], + 'click', 'jx-toggle-class', function(e) { e.kill(); @@ -29,15 +29,6 @@ JX.behavior('toggle-class', function(config, statics) { } }); - // Swallow the regular click handler event so e.g. Quicksand - // click handler doesn't get a hold of it - JX.Stratcom.listen( - ['click'], - 'jx-toggle-class', - function(e) { - e.kill(); - }); - return true; } diff --git a/webroot/rsrc/js/phui/behavior-phui-timer-control.js b/webroot/rsrc/js/phui/behavior-phui-timer-control.js new file mode 100644 index 0000000000..d5b73a5ee2 --- /dev/null +++ b/webroot/rsrc/js/phui/behavior-phui-timer-control.js @@ -0,0 +1,41 @@ +/** + * @provides javelin-behavior-phui-timer-control + * @requires javelin-behavior + * javelin-stratcom + * javelin-dom + */ + +JX.behavior('phui-timer-control', function(config) { + var node = JX.$(config.nodeID); + var uri = config.uri; + var state = null; + + function onupdate(result) { + var markup = result.markup; + if (markup) { + var new_node = JX.$H(markup).getFragment().firstChild; + JX.DOM.replace(node, new_node); + node = new_node; + + // If the overall state has changed from the previous display state, + // animate the control to draw the user's attention to the state change. + if (result.state !== state) { + state = result.state; + JX.DOM.alterClass(node, 'phui-form-timer-updated', true); + } + } + + var retry = result.retry; + if (retry) { + setTimeout(update, 1000); + } + } + + function update() { + new JX.Request(uri, onupdate) + .setTimeout(10000) + .send(); + } + + update(); +}); diff --git a/webroot/rsrc/js/phuix/PHUIXAutocomplete.js b/webroot/rsrc/js/phuix/PHUIXAutocomplete.js index f46e7666e2..2eaa9bafe1 100644 --- a/webroot/rsrc/js/phuix/PHUIXAutocomplete.js +++ b/webroot/rsrc/js/phuix/PHUIXAutocomplete.js @@ -185,7 +185,16 @@ JX.install('PHUIXAutocomplete', { .getNode(); } - var display = JX.$N('span', {}, [icon, map.displayName]); + var dot; + if (map.availabilityColor) { + dot = JX.$N( + 'span', + { + className: 'phui-tag-dot phui-tag-color-' + map.availabilityColor + }); + } + + var display = JX.$N('span', {}, [icon, dot, map.displayName]); JX.DOM.alterClass(display, 'tokenizer-result-closed', !!map.closed); map.display = display; @@ -546,6 +555,13 @@ JX.install('PHUIXAutocomplete', { if (prefix) { var pattern = new RegExp(prefix); if (!trim.match(pattern)) { + // If the prefix pattern can not match the text, deactivate. (This + // check might need to be more careful if we have a more varied + // set of prefixes in the future, but for now they're all a single + // prefix character.) + if (trim.length) { + this._deactivate(); + } return; } trim = trim.replace(pattern, '');