@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
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

Allow task statuses to specify that either "comments" or "edits" are "locked"

Summary:
Ref T13249. See PHI1059. This allows "locked" in `maniphest.statuses` to specify that either "comments" are locked (current behavior, advisory, overridable by users with edit permission, e.g. for calming discussion on a contentious issue or putting a guard rail on things); or "edits" are locked (hard lock, only task owner can edit things).

Roughly, "comments" is a soft/advisory lock. "edits" is a hard/strict lock. (I think both types of locks have reasonable use cases, which is why I'm not just making locks stronger across the board.)

When "edits" are locked:

- The edit policy looks like "no one" to normal callers.
- In one special case, we sneak the real value through a back channel using PolicyCodex in the specific narrow case that you're editing the object. Otherwise, the policy selector control incorrectly switches to "No One".
- We also have to do a little more validation around applying a mixture of status + owner transactions that could leave the task uneditable.

For now, I'm allowing you to reassign a hard-locked task to someone else. If you get this wrong, we can end up in a state where no one can edit the task. If this is an issue, we could respond in various ways: prevent these edits; prevent assigning to disabled users; provide a `bin/task reassign`; uh maybe have a quorum convene?

Test Plan:
- Defined "Soft Locked" and "Hard Locked" statues.
- "Hard Locked" a task, hit errors (trying to unassign myself, trying to hard lock an unassigned task).
- Saw nice new policy guidance icon in header.

{F6210362}

Reviewers: amckinley

Reviewed By: amckinley

Maniphest Tasks: T13249

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

+260 -14
+3 -3
resources/celerity/map.php
··· 9 9 'names' => array( 10 10 'conpherence.pkg.css' => '3c8a0668', 11 11 'conpherence.pkg.js' => '020aebcf', 12 - 'core.pkg.css' => '85a1da99', 12 + 'core.pkg.css' => 'f2319e1f', 13 13 'core.pkg.js' => '5c737607', 14 14 'differential.pkg.css' => 'b8df73d4', 15 15 'differential.pkg.js' => '67c9ea4c', ··· 154 154 'rsrc/css/phui/phui-form-view.css' => '01b796c0', 155 155 'rsrc/css/phui/phui-form.css' => '159e2d9c', 156 156 'rsrc/css/phui/phui-head-thing.css' => 'd7f293df', 157 - 'rsrc/css/phui/phui-header-view.css' => '93cea4ec', 157 + 'rsrc/css/phui/phui-header-view.css' => '285c9139', 158 158 'rsrc/css/phui/phui-hovercard.css' => '6ca90fa0', 159 159 'rsrc/css/phui/phui-icon-set-selector.css' => '7aa5f3ec', 160 160 'rsrc/css/phui/phui-icon.css' => '4cbc684a', ··· 821 821 'phui-form-css' => '159e2d9c', 822 822 'phui-form-view-css' => '01b796c0', 823 823 'phui-head-thing-view-css' => 'd7f293df', 824 - 'phui-header-view-css' => '93cea4ec', 824 + 'phui-header-view-css' => '285c9139', 825 825 'phui-hovercard' => '074f0783', 826 826 'phui-hovercard-view-css' => '6ca90fa0', 827 827 'phui-icon-set-selector-css' => '7aa5f3ec',
+3
src/__phutil_library_map__.php
··· 1743 1743 'ManiphestTaskParentTransaction' => 'applications/maniphest/xaction/ManiphestTaskParentTransaction.php', 1744 1744 'ManiphestTaskPoints' => 'applications/maniphest/constants/ManiphestTaskPoints.php', 1745 1745 'ManiphestTaskPointsTransaction' => 'applications/maniphest/xaction/ManiphestTaskPointsTransaction.php', 1746 + 'ManiphestTaskPolicyCodex' => 'applications/maniphest/policy/ManiphestTaskPolicyCodex.php', 1746 1747 'ManiphestTaskPriority' => 'applications/maniphest/constants/ManiphestTaskPriority.php', 1747 1748 'ManiphestTaskPriorityDatasource' => 'applications/maniphest/typeahead/ManiphestTaskPriorityDatasource.php', 1748 1749 'ManiphestTaskPriorityHeraldAction' => 'applications/maniphest/herald/ManiphestTaskPriorityHeraldAction.php', ··· 7384 7385 'PhabricatorEditEngineSubtypeInterface', 7385 7386 'PhabricatorEditEngineLockableInterface', 7386 7387 'PhabricatorEditEngineMFAInterface', 7388 + 'PhabricatorPolicyCodexInterface', 7387 7389 ), 7388 7390 'ManiphestTaskAssignHeraldAction' => 'HeraldAction', 7389 7391 'ManiphestTaskAssignOtherHeraldAction' => 'ManiphestTaskAssignHeraldAction', ··· 7435 7437 'ManiphestTaskParentTransaction' => 'ManiphestTaskTransactionType', 7436 7438 'ManiphestTaskPoints' => 'Phobject', 7437 7439 'ManiphestTaskPointsTransaction' => 'ManiphestTaskTransactionType', 7440 + 'ManiphestTaskPolicyCodex' => 'PhabricatorPolicyCodex', 7438 7441 'ManiphestTaskPriority' => 'ManiphestConstants', 7439 7442 'ManiphestTaskPriorityDatasource' => 'PhabricatorTypeaheadDatasource', 7440 7443 'ManiphestTaskPriorityHeraldAction' => 'HeraldAction',
+3 -2
src/applications/maniphest/config/PhabricatorManiphestConfigOptions.php
··· 210 210 - `claim` //Optional bool.// By default, closing an unassigned task claims 211 211 it. You can set this to `false` to disable this behavior for a particular 212 212 status. 213 - - `locked` //Optional bool.// Lock tasks in this status, preventing users 214 - from commenting. 213 + - `locked` //Optional string.// Lock tasks in this status. Specify "comments" 214 + to lock comments (users who can edit the task may override this lock). 215 + Specify "edits" to prevent anyone except the task owner from making edits. 215 216 - `mfa` //Optional bool.// Require all edits to this task to be signed with 216 217 multi-factor authentication. 217 218
+35 -3
src/applications/maniphest/constants/ManiphestTaskStatus.php
··· 16 16 const SPECIAL_CLOSED = 'closed'; 17 17 const SPECIAL_DUPLICATE = 'duplicate'; 18 18 19 + const LOCKED_COMMENTS = 'comments'; 20 + const LOCKED_EDITS = 'edits'; 21 + 19 22 private static function getStatusConfig() { 20 23 return PhabricatorEnv::getEnvConfig('maniphest.statuses'); 21 24 } ··· 156 159 return !self::isOpenStatus($status); 157 160 } 158 161 159 - public static function isLockedStatus($status) { 160 - return self::getStatusAttribute($status, 'locked', false); 162 + public static function areCommentsLockedInStatus($status) { 163 + return (bool)self::getStatusAttribute($status, 'locked', false); 164 + } 165 + 166 + public static function areEditsLockedInStatus($status) { 167 + $locked = self::getStatusAttribute($status, 'locked'); 168 + return ($locked === self::LOCKED_EDITS); 161 169 } 162 170 163 171 public static function isMFAStatus($status) { ··· 285 293 'keywords' => 'optional list<string>', 286 294 'disabled' => 'optional bool', 287 295 'claim' => 'optional bool', 288 - 'locked' => 'optional bool', 296 + 'locked' => 'optional bool|string', 289 297 'mfa' => 'optional bool', 290 298 )); 299 + } 300 + 301 + // Supported values are "comments" or "edits". For backward compatibility, 302 + // "true" is an alias of "comments". 303 + 304 + foreach ($config as $key => $value) { 305 + $locked = idx($value, 'locked', false); 306 + if ($locked === true || $locked === false) { 307 + continue; 308 + } 309 + 310 + if ($locked === self::LOCKED_EDITS || 311 + $locked === self::LOCKED_COMMENTS) { 312 + continue; 313 + } 314 + 315 + throw new Exception( 316 + pht( 317 + 'Task status ("%s") has unrecognized value for "locked" '. 318 + 'configuration ("%s"). Supported values are: "%s", "%s".', 319 + $key, 320 + $locked, 321 + self::LOCKED_COMMENTS, 322 + self::LOCKED_EDITS)); 291 323 } 292 324 293 325 $special_map = array();
+85
src/applications/maniphest/editor/ManiphestTransactionEditor.php
··· 552 552 $errors = array_merge($errors, $this->moreValidationErrors); 553 553 } 554 554 555 + foreach ($this->getLockValidationErrors($object, $xactions) as $error) { 556 + $errors[] = $error; 557 + } 558 + 555 559 return $errors; 556 560 } 557 561 ··· 1011 1015 } 1012 1016 1013 1017 1018 + private function getLockValidationErrors($object, array $xactions) { 1019 + $errors = array(); 1020 + 1021 + $old_owner = $object->getOwnerPHID(); 1022 + $old_status = $object->getStatus(); 1023 + 1024 + $new_owner = $old_owner; 1025 + $new_status = $old_status; 1026 + 1027 + $owner_xaction = null; 1028 + $status_xaction = null; 1029 + 1030 + foreach ($xactions as $xaction) { 1031 + switch ($xaction->getTransactionType()) { 1032 + case ManiphestTaskOwnerTransaction::TRANSACTIONTYPE: 1033 + $new_owner = $xaction->getNewValue(); 1034 + $owner_xaction = $xaction; 1035 + break; 1036 + case ManiphestTaskStatusTransaction::TRANSACTIONTYPE: 1037 + $new_status = $xaction->getNewValue(); 1038 + $status_xaction = $xaction; 1039 + break; 1040 + } 1041 + } 1042 + 1043 + $actor_phid = $this->getActingAsPHID(); 1044 + 1045 + $was_locked = ManiphestTaskStatus::areEditsLockedInStatus( 1046 + $old_status); 1047 + $now_locked = ManiphestTaskStatus::areEditsLockedInStatus( 1048 + $new_status); 1049 + 1050 + if (!$now_locked) { 1051 + // If we're not ending in an edit-locked status, everything is good. 1052 + } else if ($new_owner !== null) { 1053 + // If we ending the edit with some valid owner, this is allowed for 1054 + // now. We might need to revisit this. 1055 + } else { 1056 + // The edits end with the task locked and unowned. No one will be able 1057 + // to edit it, so we forbid this. We try to be specific about what the 1058 + // user did wrong. 1059 + 1060 + $owner_changed = ($old_owner && !$new_owner); 1061 + $status_changed = ($was_locked !== $now_locked); 1062 + $message = null; 1063 + 1064 + if ($status_changed && $owner_changed) { 1065 + $message = pht( 1066 + 'You can not lock this task and unassign it at the same time '. 1067 + 'because no one will be able to edit it anymore. Lock the task '. 1068 + 'or remove the owner, but not both.'); 1069 + $problem_xaction = $status_xaction; 1070 + } else if ($status_changed) { 1071 + $message = pht( 1072 + 'You can not lock this task because it does not have an owner. '. 1073 + 'No one would be able to edit the task. Assign the task to an '. 1074 + 'owner before locking it.'); 1075 + $problem_xaction = $status_xaction; 1076 + } else if ($owner_changed) { 1077 + $message = pht( 1078 + 'You can not remove the owner of this task because it is locked '. 1079 + 'and no one would be able to edit the task. Reassign the task or '. 1080 + 'unlock it before removing the owner.'); 1081 + $problem_xaction = $owner_xaction; 1082 + } else { 1083 + // If the task was already broken, we don't have a transaction to 1084 + // complain about so just let it through. In theory, this is 1085 + // impossible since policy rules should kick in before we get here. 1086 + } 1087 + 1088 + if ($message) { 1089 + $errors[] = new PhabricatorApplicationTransactionValidationError( 1090 + $problem_xaction->getTransactionType(), 1091 + pht('Lock Error'), 1092 + $message, 1093 + $problem_xaction); 1094 + } 1095 + } 1096 + 1097 + return $errors; 1098 + } 1014 1099 1015 1100 }
+70
src/applications/maniphest/policy/ManiphestTaskPolicyCodex.php
··· 1 + <?php 2 + 3 + final class ManiphestTaskPolicyCodex 4 + extends PhabricatorPolicyCodex { 5 + 6 + public function getPolicyShortName() { 7 + $object = $this->getObject(); 8 + 9 + if ($object->areEditsLocked()) { 10 + return pht('Edits Locked'); 11 + } 12 + 13 + return null; 14 + } 15 + 16 + public function getPolicyIcon() { 17 + $object = $this->getObject(); 18 + 19 + if ($object->areEditsLocked()) { 20 + return 'fa-lock'; 21 + } 22 + 23 + return null; 24 + } 25 + 26 + public function getPolicyTagClasses() { 27 + $object = $this->getObject(); 28 + $classes = array(); 29 + 30 + if ($object->areEditsLocked()) { 31 + $classes[] = 'policy-adjusted-locked'; 32 + } 33 + 34 + return $classes; 35 + } 36 + 37 + public function getPolicySpecialRuleDescriptions() { 38 + $object = $this->getObject(); 39 + 40 + $rules = array(); 41 + 42 + $rules[] = $this->newRule() 43 + ->setCapabilities( 44 + array( 45 + PhabricatorPolicyCapability::CAN_EDIT, 46 + )) 47 + ->setIsActive($object->areEditsLocked()) 48 + ->setDescription( 49 + pht( 50 + 'Tasks with edits locked may only be edited by their owner.')); 51 + 52 + return $rules; 53 + } 54 + 55 + public function getPolicyForEdit($capability) { 56 + 57 + // When a task has its edits locked, the effective edit policy is locked 58 + // to "No One". However, the task owner may still bypass the lock and edit 59 + // the task. When they do, we want the control in the UI to have the 60 + // correct value. Return the real value stored on the object. 61 + 62 + switch ($capability) { 63 + case PhabricatorPolicyCapability::CAN_EDIT: 64 + return $this->getObject()->getEditPolicy(); 65 + } 66 + 67 + return parent::getPolicyForEdit($capability); 68 + } 69 + 70 + }
+26 -5
src/applications/maniphest/storage/ManiphestTask.php
··· 20 20 DoorkeeperBridgedObjectInterface, 21 21 PhabricatorEditEngineSubtypeInterface, 22 22 PhabricatorEditEngineLockableInterface, 23 - PhabricatorEditEngineMFAInterface { 23 + PhabricatorEditEngineMFAInterface, 24 + PhabricatorPolicyCodexInterface { 24 25 25 26 const MARKUP_FIELD_DESCRIPTION = 'markup:desc'; 26 27 ··· 217 218 return ManiphestTaskStatus::isClosedStatus($this->getStatus()); 218 219 } 219 220 220 - public function isLocked() { 221 - return ManiphestTaskStatus::isLockedStatus($this->getStatus()); 221 + public function areCommentsLocked() { 222 + if ($this->areEditsLocked()) { 223 + return true; 224 + } 225 + 226 + return ManiphestTaskStatus::areCommentsLockedInStatus($this->getStatus()); 227 + } 228 + 229 + public function areEditsLocked() { 230 + return ManiphestTaskStatus::areEditsLockedInStatus($this->getStatus()); 222 231 } 223 232 224 233 public function setProperty($key, $value) { ··· 371 380 case PhabricatorPolicyCapability::CAN_VIEW: 372 381 return $this->getViewPolicy(); 373 382 case PhabricatorPolicyCapability::CAN_INTERACT: 374 - if ($this->isLocked()) { 383 + if ($this->areCommentsLocked()) { 375 384 return PhabricatorPolicies::POLICY_NOONE; 376 385 } else { 377 386 return $this->getViewPolicy(); 378 387 } 379 388 case PhabricatorPolicyCapability::CAN_EDIT: 380 - return $this->getEditPolicy(); 389 + if ($this->areEditsLocked()) { 390 + return PhabricatorPolicies::POLICY_NOONE; 391 + } else { 392 + return $this->getEditPolicy(); 393 + } 381 394 } 382 395 } 383 396 ··· 626 639 627 640 public function newEditEngineMFAEngine() { 628 641 return new ManiphestTaskMFAEngine(); 642 + } 643 + 644 + 645 + /* -( PhabricatorPolicyCodexInterface )------------------------------------ */ 646 + 647 + 648 + public function newPolicyCodex() { 649 + return new ManiphestTaskPolicyCodex(); 629 650 } 630 651 631 652 }
+4
src/applications/policy/codex/PhabricatorPolicyCodex.php
··· 29 29 return array(); 30 30 } 31 31 32 + public function getPolicyForEdit($capability) { 33 + return $this->getObject()->getPolicy($capability); 34 + } 35 + 32 36 public function getDefaultPolicy() { 33 37 return PhabricatorPolicyQuery::getDefaultPolicyForObject( 34 38 $this->viewer,
+21 -1
src/applications/policy/editor/PhabricatorPolicyEditEngineExtension.php
··· 68 68 ), 69 69 ); 70 70 71 + if ($object instanceof PhabricatorPolicyCodexInterface) { 72 + $codex = PhabricatorPolicyCodex::newFromObject( 73 + $object, 74 + $viewer); 75 + } else { 76 + $codex = null; 77 + } 78 + 71 79 $fields = array(); 72 80 foreach ($map as $type => $spec) { 73 81 if (empty($types[$type])) { ··· 82 90 $conduit_description = $spec['description.conduit']; 83 91 $edit = $spec['edit']; 84 92 93 + // Objects may present a policy value to the edit workflow that is 94 + // different from their nominal policy value: for example, when tasks 95 + // are locked, they appear as "Editable By: No One" to other applications 96 + // but we still want to edit the actual policy stored in the database 97 + // when we show the user a form with a policy control in it. 98 + 99 + if ($codex) { 100 + $policy_value = $codex->getPolicyForEdit($capability); 101 + } else { 102 + $policy_value = $object->getPolicy($capability); 103 + } 104 + 85 105 $policy_field = id(new PhabricatorPolicyEditField()) 86 106 ->setKey($key) 87 107 ->setLabel($label) ··· 94 114 ->setDescription($description) 95 115 ->setConduitDescription($conduit_description) 96 116 ->setConduitTypeDescription(pht('New policy PHID or constant.')) 97 - ->setValue($object->getPolicy($capability)); 117 + ->setValue($policy_value); 98 118 $fields[] = $policy_field; 99 119 100 120 if ($object instanceof PhabricatorSpacesInterface) {
+10
webroot/rsrc/css/phui/phui-header-view.css
··· 249 249 color: {$sh-indigotext}; 250 250 } 251 251 252 + .policy-header-callout.policy-adjusted-locked { 253 + background: {$sh-pinkbackground}; 254 + } 255 + 256 + .policy-header-callout.policy-adjusted-locked .policy-link, 257 + .policy-header-callout.policy-adjusted-locked .phui-icon-view { 258 + color: {$sh-pinktext}; 259 + } 260 + 261 + 252 262 .policy-header-callout .policy-space-container { 253 263 font-weight: bold; 254 264 color: {$sh-redtext};