@recaptime-dev's working patches + fork for Phorge, a community fork of Phabricator. (Upstream dev and stable branches are at upstream/main and upstream/stable respectively.) hq.recaptime.dev/wiki/Phorge
phorge phabricator

When accepting revisions, allow users to accept on behalf of a subset of reviewers

Summary:
Ref T12271. Currenty, when you "Accept" a revision, you always accept it for all reviewers you have authority over.

There are some situations where communication can be more clear if users can accept as only themselves, or for only some packages, etc. T12271 discusses some of these use cases in more depth.

Instead of making "Accept" a blanket action, default it to doing what it does now but let the user uncheck reviewers.

In cases where project/package reviewers aren't in use, this doesn't change anything.

For now, "reject" still acts the old way (reject everything). We could make that use checkboxes too, but I'm not sure there's as much of a use case for it, and I generally want users who are blocking stuff to have more direct accountability in a product sense.

Test Plan:
- Accepted normally.
- Accepted a subset.
- Tried to accept none.
- Tried to accept bogus reviewers.
- Accepted with myself not a reviewer
- Accepted with only one reviewer (just got normal "this will be accepted" text).

{F4251255}

Reviewers: chad

Reviewed By: chad

Maniphest Tasks: T12271

Differential Revision: https://secure.phabricator.com/D17533

+361 -16
+9 -9
resources/celerity/map.php
··· 9 9 'names' => array( 10 10 'conpherence.pkg.css' => '82aca405', 11 11 'conpherence.pkg.js' => '6249a1cf', 12 - 'core.pkg.css' => 'c012c648', 12 + 'core.pkg.css' => 'dc689e29', 13 13 'core.pkg.js' => '1fa7c0c5', 14 14 'darkconsole.pkg.js' => 'e7393ebb', 15 15 'differential.pkg.css' => '90b30783', ··· 145 145 'rsrc/css/phui/phui-document.css' => 'c32e8dec', 146 146 'rsrc/css/phui/phui-feed-story.css' => '44a9c8e9', 147 147 'rsrc/css/phui/phui-fontkit.css' => '1320ed01', 148 - 'rsrc/css/phui/phui-form-view.css' => 'cf198e10', 148 + 'rsrc/css/phui/phui-form-view.css' => '6175808d', 149 149 'rsrc/css/phui/phui-form.css' => 'b62c01d8', 150 150 'rsrc/css/phui/phui-head-thing.css' => 'fd311e5f', 151 151 'rsrc/css/phui/phui-header-view.css' => '9cf828ce', ··· 530 530 'rsrc/js/phuix/PHUIXActionView.js' => 'b3465b9b', 531 531 'rsrc/js/phuix/PHUIXAutocomplete.js' => '7c492cd2', 532 532 'rsrc/js/phuix/PHUIXDropdownMenu.js' => '8018ee50', 533 - 'rsrc/js/phuix/PHUIXFormControl.js' => 'bbece68d', 533 + 'rsrc/js/phuix/PHUIXFormControl.js' => '83e03671', 534 534 'rsrc/js/phuix/PHUIXIconView.js' => 'bff6884b', 535 535 ), 536 536 'symbols' => array( ··· 847 847 'phui-font-icon-base-css' => '870a7360', 848 848 'phui-fontkit-css' => '1320ed01', 849 849 'phui-form-css' => 'b62c01d8', 850 - 'phui-form-view-css' => 'cf198e10', 850 + 'phui-form-view-css' => '6175808d', 851 851 'phui-head-thing-view-css' => 'fd311e5f', 852 852 'phui-header-view-css' => '9cf828ce', 853 853 'phui-hovercard' => '1bd28176', ··· 887 887 'phuix-action-view' => 'b3465b9b', 888 888 'phuix-autocomplete' => '7c492cd2', 889 889 'phuix-dropdown-menu' => '8018ee50', 890 - 'phuix-form-control-view' => 'bbece68d', 890 + 'phuix-form-control-view' => '83e03671', 891 891 'phuix-icon-view' => 'bff6884b', 892 892 'policy-css' => '957ea14c', 893 893 'policy-edit-css' => '815c66f7', ··· 1518 1518 'javelin-behavior', 1519 1519 'javelin-scrollbar', 1520 1520 ), 1521 + '83e03671' => array( 1522 + 'javelin-install', 1523 + 'javelin-dom', 1524 + ), 1521 1525 '8499b6ab' => array( 1522 1526 'javelin-behavior', 1523 1527 'javelin-dom', ··· 1901 1905 'javelin-dom', 1902 1906 'javelin-vector', 1903 1907 'javelin-install', 1904 - ), 1905 - 'bbece68d' => array( 1906 - 'javelin-install', 1907 - 'javelin-dom', 1908 1908 ), 1909 1909 'bcaccd64' => array( 1910 1910 'javelin-behavior',
+2
src/__phutil_library_map__.php
··· 2602 2602 'PhabricatorEdgesDestructionEngineExtension' => 'infrastructure/edges/engineextension/PhabricatorEdgesDestructionEngineExtension.php', 2603 2603 'PhabricatorEditEngine' => 'applications/transactions/editengine/PhabricatorEditEngine.php', 2604 2604 'PhabricatorEditEngineAPIMethod' => 'applications/transactions/editengine/PhabricatorEditEngineAPIMethod.php', 2605 + 'PhabricatorEditEngineCheckboxesCommentAction' => 'applications/transactions/commentaction/PhabricatorEditEngineCheckboxesCommentAction.php', 2605 2606 'PhabricatorEditEngineColumnsCommentAction' => 'applications/transactions/commentaction/PhabricatorEditEngineColumnsCommentAction.php', 2606 2607 'PhabricatorEditEngineCommentAction' => 'applications/transactions/commentaction/PhabricatorEditEngineCommentAction.php', 2607 2608 'PhabricatorEditEngineCommentActionGroup' => 'applications/transactions/commentaction/PhabricatorEditEngineCommentActionGroup.php', ··· 7686 7687 'PhabricatorPolicyInterface', 7687 7688 ), 7688 7689 'PhabricatorEditEngineAPIMethod' => 'ConduitAPIMethod', 7690 + 'PhabricatorEditEngineCheckboxesCommentAction' => 'PhabricatorEditEngineCommentAction', 7689 7691 'PhabricatorEditEngineColumnsCommentAction' => 'PhabricatorEditEngineCommentAction', 7690 7692 'PhabricatorEditEngineCommentAction' => 'Phobject', 7691 7693 'PhabricatorEditEngineCommentActionGroup' => 'Phobject',
+31
src/applications/differential/storage/DifferentialReviewer.php
··· 50 50 return ($this->getReviewerStatus() == $status_resigned); 51 51 } 52 52 53 + public function isAccepted($diff_phid) { 54 + $status_accepted = DifferentialReviewerStatus::STATUS_ACCEPTED; 55 + 56 + if ($this->getReviewerStatus() != $status_accepted) { 57 + return false; 58 + } 59 + 60 + if (!$diff_phid) { 61 + return true; 62 + } 63 + 64 + $action_phid = $this->getLastActionDiffPHID(); 65 + 66 + if (!$action_phid) { 67 + return true; 68 + } 69 + 70 + if ($action_phid == $diff_phid) { 71 + return true; 72 + } 73 + 74 + $sticky_key = 'differential.sticky-accept'; 75 + $is_sticky = PhabricatorEnv::getEnvConfig($sticky_key); 76 + 77 + if ($is_sticky) { 78 + return true; 79 + } 80 + 81 + return false; 82 + } 83 + 53 84 }
+98 -3
src/applications/differential/xaction/DifferentialRevisionAcceptTransaction.php
··· 48 48 return pht('Accept a revision.'); 49 49 } 50 50 51 + protected function getActionOptions( 52 + PhabricatorUser $viewer, 53 + DifferentialRevision $revision) { 54 + 55 + $reviewers = $revision->getReviewers(); 56 + 57 + $options = array(); 58 + $value = array(); 59 + 60 + // Put the viewer's user reviewer first, if it exists, so that "Accept as 61 + // yourself" is always at the top. 62 + $head = array(); 63 + $tail = array(); 64 + foreach ($reviewers as $key => $reviewer) { 65 + if ($reviewer->isUser()) { 66 + $head[$key] = $reviewer; 67 + } else { 68 + $tail[$key] = $reviewer; 69 + } 70 + } 71 + $reviewers = $head + $tail; 72 + 73 + $diff_phid = $this->getActiveDiffPHID($revision); 74 + $reviewer_phids = array(); 75 + 76 + // If the viewer isn't a reviewer, add them to the list of options first. 77 + // This happens when you navigate to some revision you aren't involved in: 78 + // you can accept and become a reviewer. 79 + 80 + $viewer_phid = $viewer->getPHID(); 81 + if ($viewer_phid) { 82 + if (!isset($reviewers[$viewer_phid])) { 83 + $reviewer_phids[$viewer_phid] = $viewer_phid; 84 + } 85 + } 86 + 87 + foreach ($reviewers as $reviewer) { 88 + if (!$reviewer->hasAuthority($viewer)) { 89 + // If the viewer doesn't have authority to act on behalf of a reviewer, 90 + // don't include that reviewer as an option. 91 + continue; 92 + } 93 + 94 + if ($reviewer->isAccepted($diff_phid)) { 95 + // If a reviewer is already in a full "accepted" state, don't 96 + // include that reviewer as an option. 97 + continue; 98 + } 99 + 100 + $reviewer_phid = $reviewer->getReviewerPHID(); 101 + $reviewer_phids[$reviewer_phid] = $reviewer_phid; 102 + } 103 + 104 + $handles = $viewer->loadHandles($reviewer_phids); 105 + 106 + foreach ($reviewer_phids as $reviewer_phid) { 107 + $options[$reviewer_phid] = pht( 108 + 'Accept as %s', 109 + $viewer->renderHandle($reviewer_phid)); 110 + 111 + $value[] = $reviewer_phid; 112 + } 113 + 114 + return array($options, $value); 115 + } 116 + 51 117 public function generateOldValue($object) { 52 118 $actor = $this->getActor(); 53 119 return $this->isViewerFullyAccepted($object, $actor); ··· 87 153 } 88 154 } 89 155 156 + protected function validateOptionValue($object, $actor, array $value) { 157 + if (!$value) { 158 + throw new Exception( 159 + pht( 160 + 'When accepting a revision, you must accept on behalf of at '. 161 + 'least one reviewer.')); 162 + } 163 + 164 + list($options) = $this->getActionOptions($actor, $object); 165 + foreach ($value as $phid) { 166 + if (!isset($options[$phid])) { 167 + throw new Exception( 168 + pht( 169 + 'Reviewer "%s" is not a valid reviewer which you have authority '. 170 + 'to accept on behalf of.', 171 + $phid)); 172 + } 173 + } 174 + } 175 + 90 176 public function getTitle() { 91 - return pht( 92 - '%s accepted this revision.', 93 - $this->renderAuthor()); 177 + $new = $this->getNewValue(); 178 + if (is_array($new) && $new) { 179 + return pht( 180 + '%s accepted this revision as %s reviewer(s): %s.', 181 + $this->renderAuthor(), 182 + phutil_count($new), 183 + $this->renderHandleList($new)); 184 + } else { 185 + return pht( 186 + '%s accepted this revision.', 187 + $this->renderAuthor()); 188 + } 94 189 } 95 190 96 191 public function getTitleForFeed() {
+33
src/applications/differential/xaction/DifferentialRevisionActionTransaction.php
··· 19 19 abstract protected function validateAction($object, PhabricatorUser $viewer); 20 20 abstract protected function getRevisionActionLabel(); 21 21 22 + protected function validateOptionValue($object, $actor, array $value) { 23 + return null; 24 + } 25 + 22 26 public function getCommandKeyword() { 23 27 return null; 24 28 } ··· 70 74 return ($viewer->getPHID() === $revision->getAuthorPHID()); 71 75 } 72 76 77 + protected function getActionOptions( 78 + PhabricatorUser $viewer, 79 + DifferentialRevision $revision) { 80 + return array( 81 + array(), 82 + null, 83 + ); 84 + } 85 + 73 86 public function newEditField( 74 87 DifferentialRevision $revision, 75 88 PhabricatorUser $viewer) { ··· 107 120 // It's not clear that these combinations are actually useful, so just 108 121 // keep things simple for now. 109 122 $field->setActionConflictKey('revision.action'); 123 + 124 + list($options, $value) = $this->getActionOptions($viewer, $revision); 125 + if (count($options) > 1) { 126 + $field->setOptions($options); 127 + $field->setValue($value); 128 + } 110 129 } 111 130 } 112 131 ··· 128 147 if ($action_exception) { 129 148 $errors[] = $this->newInvalidError( 130 149 $action_exception->getMessage(), 150 + $xaction); 151 + continue; 152 + } 153 + 154 + $new = $xaction->getNewValue(); 155 + if (!is_array($new)) { 156 + continue; 157 + } 158 + 159 + try { 160 + $this->validateOptionValue($object, $actor, $new); 161 + } catch (Exception $ex) { 162 + $errors[] = $this->newInvalidError( 163 + $ex->getMessage(), 131 164 $xaction); 132 165 } 133 166 }
+26
src/applications/differential/xaction/DifferentialRevisionReviewTransaction.php
··· 7 7 return DifferentialRevisionEditEngine::ACTIONGROUP_REVIEW; 8 8 } 9 9 10 + public function generateNewValue($object, $value) { 11 + if (!is_array($value)) { 12 + return true; 13 + } 14 + 15 + // If the list of options is the same as the default list, just treat this 16 + // as a "take the default action" transaction. 17 + $viewer = $this->getActor(); 18 + list($options, $default) = $this->getActionOptions($viewer, $object); 19 + 20 + sort($default); 21 + sort($value); 22 + 23 + if ($default === $value) { 24 + return true; 25 + } 26 + 27 + return $value; 28 + } 29 + 10 30 protected function isViewerAnyReviewer( 11 31 DifferentialRevision $revision, 12 32 PhabricatorUser $viewer) { ··· 117 137 118 138 // In all cases, you affect yourself. 119 139 $map[$viewer->getPHID()] = $status; 140 + 141 + // If the user has submitted a specific list of reviewers to act as (by 142 + // unchecking some checkboxes under "Accept"), only affect those reviewers. 143 + if (is_array($value)) { 144 + $map = array_select_keys($map, $value); 145 + } 120 146 121 147 // Convert reviewer statuses into edge data. 122 148 foreach ($map as $reviewer_phid => $reviewer_status) {
+36
src/applications/transactions/commentaction/PhabricatorEditEngineCheckboxesCommentAction.php
··· 1 + <?php 2 + 3 + final class PhabricatorEditEngineCheckboxesCommentAction 4 + extends PhabricatorEditEngineCommentAction { 5 + 6 + private $options = array(); 7 + 8 + public function setOptions(array $options) { 9 + $this->options = $options; 10 + return $this; 11 + } 12 + 13 + public function getOptions() { 14 + return $this->options; 15 + } 16 + 17 + public function getPHUIXControlType() { 18 + return 'checkboxes'; 19 + } 20 + 21 + public function getPHUIXControlSpecification() { 22 + $options = $this->getOptions(); 23 + 24 + $labels = array(); 25 + foreach ($options as $key => $option) { 26 + $labels[$key] = hsprintf('%s', $option); 27 + } 28 + 29 + return array( 30 + 'value' => $this->getValue(), 31 + 'keys' => array_keys($options), 32 + 'labels' => $labels, 33 + ); 34 + } 35 + 36 + }
+25 -4
src/applications/transactions/editfield/PhabricatorApplyEditField.php
··· 5 5 6 6 private $actionDescription; 7 7 private $actionConflictKey; 8 + private $options; 8 9 9 10 protected function newControl() { 10 11 return null; ··· 28 29 return $this->actionConflictKey; 29 30 } 30 31 32 + public function setOptions(array $options) { 33 + $this->options = $options; 34 + return $this; 35 + } 36 + 37 + public function getOptions() { 38 + return $this->options; 39 + } 40 + 31 41 protected function newHTTPParameterType() { 32 - return new AphrontBoolHTTPParameterType(); 42 + if ($this->getOptions()) { 43 + return new AphrontPHIDListHTTPParameterType(); 44 + } else { 45 + return new AphrontBoolHTTPParameterType(); 46 + } 33 47 } 34 48 35 49 protected function newConduitParameterType() { ··· 43 57 } 44 58 45 59 protected function newCommentAction() { 46 - return id(new PhabricatorEditEngineStaticCommentAction()) 47 - ->setDescription($this->getActionDescription()) 48 - ->setConflictKey($this->getActionConflictKey()); 60 + $options = $this->getOptions(); 61 + if ($options) { 62 + return id(new PhabricatorEditEngineCheckboxesCommentAction()) 63 + ->setConflictKey($this->getActionConflictKey()) 64 + ->setOptions($options); 65 + } else { 66 + return id(new PhabricatorEditEngineStaticCommentAction()) 67 + ->setConflictKey($this->getActionConflictKey()) 68 + ->setDescription($this->getActionDescription()); 69 + } 49 70 } 50 71 51 72 }
+2
src/infrastructure/internationalization/translation/PhabricatorUSEnglishTranslation.php
··· 1608 1608 ), 1609 1609 ), 1610 1610 1611 + '%s accepted this revision as %s reviewer(s): %s.' => 1612 + '%s accepted this revision as: %3$s.', 1611 1613 ); 1612 1614 } 1613 1615
+13
webroot/rsrc/css/phui/phui-form-view.css
··· 548 548 padding: 4px; 549 549 color: {$bluetext}; 550 550 } 551 + 552 + .phuix-form-checkbox-action { 553 + padding: 4px; 554 + color: {$bluetext}; 555 + } 556 + 557 + .phuix-form-checkbox-action input[type=checkbox] { 558 + margin: 4px 0; 559 + } 560 + 561 + .phuix-form-checkbox-label { 562 + margin-left: 4px; 563 + }
+86
webroot/rsrc/js/phuix/PHUIXFormControl.js
··· 50 50 case 'static': 51 51 input = this._newStatic(spec); 52 52 break; 53 + case 'checkboxes': 54 + input = this._newCheckboxes(spec); 55 + break; 53 56 default: 54 57 // TODO: Default or better error? 55 58 JX.$E('Bad Input Type'); ··· 191 194 set: function() { 192 195 return; 193 196 } 197 + }; 198 + }, 199 + 200 + _newCheckboxes: function(spec) { 201 + var checkboxes = []; 202 + var checkbox_list = []; 203 + for (var ii = 0; ii < spec.keys.length; ii++) { 204 + var key = spec.keys[ii]; 205 + var checkbox_id = 'checkbox-' + Math.floor(Math.random() * 1000000); 206 + 207 + var checkbox = JX.$N( 208 + 'input', 209 + { 210 + type: 'checkbox', 211 + value: key, 212 + id: checkbox_id 213 + }); 214 + 215 + checkboxes.push(checkbox); 216 + 217 + var label = JX.$N( 218 + 'label', 219 + { 220 + className: 'phuix-form-checkbox-label', 221 + htmlFor: checkbox_id 222 + }, 223 + JX.$H(spec.labels[key] || '')); 224 + 225 + var display = JX.$N( 226 + 'div', 227 + { 228 + className: 'phuix-form-checkbox-item' 229 + }, 230 + [checkbox, label]); 231 + 232 + checkbox_list.push(display); 233 + } 234 + 235 + var node = JX.$N( 236 + 'div', 237 + { 238 + className: 'phuix-form-checkbox-action' 239 + }, 240 + checkbox_list); 241 + 242 + var get_value = function() { 243 + var list = []; 244 + for (var ii = 0; ii < checkboxes.length; ii++) { 245 + if (checkboxes[ii].checked) { 246 + list.push(checkboxes[ii].value); 247 + } 248 + } 249 + return list; 250 + }; 251 + 252 + var set_value = function(value) { 253 + value = value || []; 254 + 255 + if (!value.length) { 256 + value = []; 257 + } 258 + 259 + var map = {}; 260 + var ii; 261 + for (ii = 0; ii < value.length; ii++) { 262 + map[value[ii]] = true; 263 + } 264 + 265 + for (ii = 0; ii < checkboxes.length; ii++) { 266 + if (map.hasOwnProperty(checkboxes[ii].value)) { 267 + checkboxes[ii].checked = 'checked'; 268 + } else { 269 + checkboxes[ii].checked = false; 270 + } 271 + } 272 + }; 273 + 274 + set_value(spec.value); 275 + 276 + return { 277 + node: node, 278 + get: get_value, 279 + set: set_value 194 280 }; 195 281 }, 196 282