@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 Herald to "Require legal signatures" for reviews

Summary:
Ref T3116. Add a Herald action "Require legal signatures" which requires revision authors to accept legal agreements before their revisions can be accepted.

- Herald will check which documents the author has signed, and trigger a "you have to sign X, Y, Z" for other documents.
- If the author has already signed everything, we don't spam the revision -- basically, this only triggers when signatures are missing.
- The UI will show which documents must be signed and warn that the revision can't be accepted until they're completed.
- Users aren't allowed to "Accept" the revision until documents are cleared.

Fixes T1157. The original install making the request (Hive) no longer uses Phabricator, and this satisfies our requirements.

Test Plan:
- Added a Herald rule.
- Created a revision, saw the rule trigger.
- Viewed as author and non-author, saw field UI (generic for non-author, specific for author), transaction UI, and accept-warning UI.
- Tried to accept revision.
- Signed document, saw UI update. Note that signatures don't currently //push// an update to the revision, but could eventually (like blocking tasks work).
- Accepted revision.
- Created another revision, saw rules not add the document (since it's already signed, this is the "no spam" case).

Reviewers: btrahan, chad

Reviewed By: chad

Subscribers: asherkin, epriestley

Maniphest Tasks: T1157, T3116

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

+254 -23
+12 -12
resources/celerity/map.php
··· 386 386 'rsrc/js/application/files/behavior-icon-composer.js' => '8ef9ab58', 387 387 'rsrc/js/application/files/behavior-launch-icon-composer.js' => '48086888', 388 388 'rsrc/js/application/harbormaster/behavior-reorder-steps.js' => 'b716477f', 389 - 'rsrc/js/application/herald/HeraldRuleEditor.js' => '6c9e6fb8', 389 + 'rsrc/js/application/herald/HeraldRuleEditor.js' => '58e048fc', 390 390 'rsrc/js/application/herald/PathTypeahead.js' => 'f7fc67ec', 391 391 'rsrc/js/application/herald/herald-rule-editor.js' => '7ebaeed3', 392 392 'rsrc/js/application/maniphest/behavior-batch-editor.js' => 'f588412e', ··· 537 537 'global-drag-and-drop-css' => '697324ad', 538 538 'harbormaster-css' => 'cec833b7', 539 539 'herald-css' => 'c544dd1c', 540 - 'herald-rule-editor' => '6c9e6fb8', 540 + 'herald-rule-editor' => '58e048fc', 541 541 'herald-test-css' => '778b008e', 542 542 'inline-comment-summary-css' => '8cfd34e8', 543 543 'javelin-aphlict' => '4a07e8e3', ··· 1249 1249 2 => 'javelin-vector', 1250 1250 3 => 'javelin-dom', 1251 1251 ), 1252 + '58e048fc' => 1253 + array( 1254 + 0 => 'multirow-row-manager', 1255 + 1 => 'javelin-install', 1256 + 2 => 'javelin-util', 1257 + 3 => 'javelin-dom', 1258 + 4 => 'javelin-stratcom', 1259 + 5 => 'javelin-json', 1260 + 6 => 'phabricator-prefab', 1261 + ), 1252 1262 '58f7803f' => 1253 1263 array( 1254 1264 0 => 'javelin-behavior', ··· 1335 1345 array( 1336 1346 0 => 'javelin-install', 1337 1347 1 => 'javelin-util', 1338 - ), 1339 - '6c9e6fb8' => 1340 - array( 1341 - 0 => 'multirow-row-manager', 1342 - 1 => 'javelin-install', 1343 - 2 => 'javelin-util', 1344 - 3 => 'javelin-dom', 1345 - 4 => 'javelin-stratcom', 1346 - 5 => 'javelin-json', 1347 - 6 => 'phabricator-prefab', 1348 1348 ), 1349 1349 '6d3e1947' => 1350 1350 array(
+2
src/__phutil_library_map__.php
··· 423 423 'DifferentialReplyHandler' => 'applications/differential/mail/DifferentialReplyHandler.php', 424 424 'DifferentialRepositoryField' => 'applications/differential/customfield/DifferentialRepositoryField.php', 425 425 'DifferentialRepositoryLookup' => 'applications/differential/query/DifferentialRepositoryLookup.php', 426 + 'DifferentialRequiredSignaturesField' => 'applications/differential/customfield/DifferentialRequiredSignaturesField.php', 426 427 'DifferentialResultsTableView' => 'applications/differential/view/DifferentialResultsTableView.php', 427 428 'DifferentialRevertPlanField' => 'applications/differential/customfield/DifferentialRevertPlanField.php', 428 429 'DifferentialReviewedByField' => 'applications/differential/customfield/DifferentialReviewedByField.php', ··· 3121 3122 'DifferentialReplyHandler' => 'PhabricatorMailReplyHandler', 3122 3123 'DifferentialRepositoryField' => 'DifferentialCoreCustomField', 3123 3124 'DifferentialRepositoryLookup' => 'Phobject', 3125 + 'DifferentialRequiredSignaturesField' => 'DifferentialCoreCustomField', 3124 3126 'DifferentialResultsTableView' => 'AphrontView', 3125 3127 'DifferentialRevertPlanField' => 'DifferentialStoredCustomField', 3126 3128 'DifferentialReviewedByField' => 'DifferentialCoreCustomField',
+134
src/applications/differential/customfield/DifferentialRequiredSignaturesField.php
··· 1 + <?php 2 + 3 + final class DifferentialRequiredSignaturesField 4 + extends DifferentialCoreCustomField { 5 + 6 + public function getFieldKey() { 7 + return 'differential:required-signatures'; 8 + } 9 + 10 + public function getFieldName() { 11 + return pht('Required Signatures'); 12 + } 13 + 14 + public function getFieldDescription() { 15 + return pht('Display required legal agreements.'); 16 + } 17 + 18 + public function shouldAppearInPropertyView() { 19 + return true; 20 + } 21 + 22 + public function shouldAppearInEditView() { 23 + return false; 24 + } 25 + 26 + protected function readValueFromRevision(DifferentialRevision $revision) { 27 + return self::loadForRevision($revision); 28 + } 29 + 30 + public static function loadForRevision($revision) { 31 + $app_legalpad = 'PhabricatorApplicationLegalpad'; 32 + if (!PhabricatorApplication::isClassInstalled($app_legalpad)) { 33 + return array(); 34 + } 35 + 36 + if (!$revision->getPHID()) { 37 + return array(); 38 + } 39 + 40 + $phids = PhabricatorEdgeQuery::loadDestinationPHIDs( 41 + $revision->getPHID(), 42 + PhabricatorEdgeConfig::TYPE_OBJECT_NEEDS_SIGNATURE); 43 + 44 + if ($phids) { 45 + 46 + // NOTE: We're bypassing permissions to pull these. We have to expose 47 + // some information about signature status in order to implement this 48 + // field meaningfully (otherwise, we could not tell reviewers that they 49 + // can't accept the revision yet), but that's OK because the only way to 50 + // require signatures is with a "Global" Herald rule, which requires a 51 + // high level of access. 52 + 53 + $signatures = id(new LegalpadDocumentSignatureQuery()) 54 + ->setViewer(PhabricatorUser::getOmnipotentUser()) 55 + ->withDocumentPHIDs($phids) 56 + ->withSignerPHIDs(array($revision->getAuthorPHID())) 57 + ->execute(); 58 + $signatures = mpull($signatures, null, 'getDocumentPHID'); 59 + 60 + $phids = array_fuse($phids); 61 + foreach ($phids as $phid) { 62 + $phids[$phid] = isset($signatures[$phid]); 63 + } 64 + } 65 + 66 + return $phids; 67 + } 68 + 69 + public function getRequiredHandlePHIDsForPropertyView() { 70 + return array_keys($this->getValue()); 71 + } 72 + 73 + public function renderPropertyViewValue(array $handles) { 74 + if (!$handles) { 75 + return null; 76 + } 77 + 78 + $author_phid = $this->getObject()->getAuthorPHID(); 79 + $viewer_phid = $this->getViewer()->getPHID(); 80 + 81 + $viewer_is_author = ($author_phid == $viewer_phid); 82 + 83 + $view = new PHUIStatusListView(); 84 + foreach ($handles as $handle) { 85 + $item = id(new PHUIStatusItemView()) 86 + ->setTarget($handle->renderLink()); 87 + 88 + // NOTE: If the viewer isn't the author, we just show generic document 89 + // icons, because the granular information isn't very useful and there 90 + // is no need to disclose it. 91 + 92 + // If the viewer is the author, we show exactly what they need to sign. 93 + 94 + if (!$viewer_is_author) { 95 + $item->setIcon('fa-file-text-o bluegrey'); 96 + } else { 97 + if (idx($this->getValue(), $handle->getPHID())) { 98 + $item->setIcon('fa-check-square-o green'); 99 + } else { 100 + $item->setIcon('fa-times red'); 101 + } 102 + } 103 + 104 + $view->addItem($item); 105 + } 106 + 107 + return $view; 108 + } 109 + 110 + public function getWarningsForDetailView() { 111 + if (!$this->haveAnyUnsignedDocuments()) { 112 + return array(); 113 + } 114 + 115 + return array( 116 + pht( 117 + 'The author of this revision has not signed all the required '. 118 + 'legal documents. The revision can not be accepted until the '. 119 + 'documents are signed.'), 120 + ); 121 + } 122 + 123 + private function haveAnyUnsignedDocuments() { 124 + foreach ($this->getValue() as $phid => $signed) { 125 + if (!$signed) { 126 + return true; 127 + } 128 + } 129 + 130 + return false; 131 + } 132 + 133 + 134 + }
+49
src/applications/differential/editor/DifferentialTransactionEditor.php
··· 825 825 'You can not accept this revision because it has already been '. 826 826 'closed.'); 827 827 } 828 + 829 + // TODO: It would be nice to make this generic at some point. 830 + $signatures = DifferentialRequiredSignaturesField::loadForRevision( 831 + $revision); 832 + foreach ($signatures as $phid => $signed) { 833 + if (!$signed) { 834 + return pht( 835 + 'You can not accept this revision because the author has '. 836 + 'not signed all of the required legal documents.'); 837 + } 838 + } 839 + 828 840 break; 829 841 830 842 case DifferentialAction::ACTION_REJECT: ··· 1377 1389 if (!$this->getIsCloseByCommit()) { 1378 1390 return true; 1379 1391 } 1392 + break; 1393 + case DifferentialTransaction::TYPE_ACTION: 1394 + switch ($xaction->getNewValue()) { 1395 + case DifferentialAction::ACTION_CLAIM: 1396 + // When users commandeer revisions, we may need to trigger 1397 + // signatures or author-based rules. 1398 + return true; 1399 + } 1400 + break; 1380 1401 } 1381 1402 } 1382 1403 ··· 1499 1520 ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) 1500 1521 ->setMetadataValue('edge:type', $edge_reviewer) 1501 1522 ->setNewValue($value); 1523 + } 1524 + 1525 + // Require legalpad document signatures. 1526 + $legal_phids = $adapter->getRequiredSignatureDocumentPHIDs(); 1527 + if ($legal_phids) { 1528 + // We only require signatures of documents which have not already 1529 + // been signed. In general, this reduces the amount of churn that 1530 + // signature rules cause. 1531 + 1532 + $signatures = id(new LegalpadDocumentSignatureQuery()) 1533 + ->setViewer(PhabricatorUser::getOmnipotentUser()) 1534 + ->withDocumentPHIDs($legal_phids) 1535 + ->withSignerPHIDs(array($object->getAuthorPHID())) 1536 + ->execute(); 1537 + $signed_phids = mpull($signatures, 'getDocumentPHID'); 1538 + $legal_phids = array_diff($legal_phids, $signed_phids); 1539 + 1540 + // If we still have something to trigger, add the edges. 1541 + if ($legal_phids) { 1542 + $edge_legal = PhabricatorEdgeConfig::TYPE_OBJECT_NEEDS_SIGNATURE; 1543 + $xactions[] = id(new DifferentialTransaction()) 1544 + ->setTransactionType(PhabricatorTransactions::TYPE_EDGE) 1545 + ->setMetadataValue('edge:type', $edge_legal) 1546 + ->setNewValue( 1547 + array( 1548 + '+' => array_fuse($legal_phids), 1549 + )); 1550 + } 1502 1551 } 1503 1552 1504 1553 // Save extra email PHIDs for later.
+16 -8
src/applications/differential/parser/DifferentialChangesetParser.php
··· 376 376 $conn_w = $changeset->establishConnection('w'); 377 377 378 378 $unguarded = AphrontWriteGuard::beginScopedUnguardedWrites(); 379 - queryfx( 380 - $conn_w, 381 - 'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %B, %d) 382 - ON DUPLICATE KEY UPDATE cache = VALUES(cache)', 383 - DifferentialChangeset::TABLE_CACHE, 384 - $render_cache_key, 385 - $cache, 386 - time()); 379 + try { 380 + queryfx( 381 + $conn_w, 382 + 'INSERT INTO %T (id, cache, dateCreated) VALUES (%d, %B, %d) 383 + ON DUPLICATE KEY UPDATE cache = VALUES(cache)', 384 + DifferentialChangeset::TABLE_CACHE, 385 + $render_cache_key, 386 + $cache, 387 + time()); 388 + } catch (AphrontQueryException $ex) { 389 + // Ignore these exceptions. A common cause is that the cache is 390 + // larger than 'max_allowed_packet', in which case we're better off 391 + // not writing it. 392 + 393 + // TODO: It would be nice to tailor this more narrowly. 394 + } 387 395 unset($unguarded); 388 396 } 389 397
+5
src/applications/herald/adapter/HeraldAdapter.php
··· 79 79 const ACTION_ADD_BLOCKING_REVIEWERS = 'addblockingreviewers'; 80 80 const ACTION_APPLY_BUILD_PLANS = 'applybuildplans'; 81 81 const ACTION_BLOCK = 'block'; 82 + const ACTION_REQUIRE_SIGNATURE = 'signature'; 82 83 83 84 const VALUE_TEXT = 'text'; 84 85 const VALUE_NONE = 'none'; ··· 95 96 const VALUE_BUILD_PLAN = 'buildplan'; 96 97 const VALUE_TASK_PRIORITY = 'taskpriority'; 97 98 const VALUE_ARCANIST_PROJECT = 'arcanistprojects'; 99 + const VALUE_LEGAL_DOCUMENTS = 'legaldocuments'; 98 100 99 101 private $contentSource; 100 102 private $isNewObject; ··· 661 663 self::ACTION_ADD_REVIEWERS => pht('Add reviewers'), 662 664 self::ACTION_ADD_BLOCKING_REVIEWERS => pht('Add blocking reviewers'), 663 665 self::ACTION_APPLY_BUILD_PLANS => pht('Run build plans'), 666 + self::ACTION_REQUIRE_SIGNATURE => pht('Require legal signatures'), 664 667 self::ACTION_BLOCK => pht('Block change with message'), 665 668 ); 666 669 case HeraldRuleTypeConfig::RULE_TYPE_PERSONAL: ··· 852 855 return self::VALUE_USER_OR_PROJECT; 853 856 case self::ACTION_APPLY_BUILD_PLANS: 854 857 return self::VALUE_BUILD_PLAN; 858 + case self::ACTION_REQUIRE_SIGNATURE: 859 + return self::VALUE_LEGAL_DOCUMENTS; 855 860 case self::ACTION_BLOCK: 856 861 return self::VALUE_TEXT; 857 862 default:
+15
src/applications/herald/adapter/HeraldDifferentialRevisionAdapter.php
··· 15 15 protected $addReviewerPHIDs = array(); 16 16 protected $blockingReviewerPHIDs = array(); 17 17 protected $buildPlans = array(); 18 + protected $requiredSignatureDocumentPHIDs = array(); 18 19 19 20 protected $repository; 20 21 protected $affectedPackages; ··· 143 144 return $this->blockingReviewerPHIDs; 144 145 } 145 146 147 + public function getRequiredSignatureDocumentPHIDs() { 148 + return $this->requiredSignatureDocumentPHIDs; 149 + } 150 + 146 151 public function getBuildPlans() { 147 152 return $this->buildPlans; 148 153 } ··· 347 352 self::ACTION_ADD_REVIEWERS, 348 353 self::ACTION_ADD_BLOCKING_REVIEWERS, 349 354 self::ACTION_APPLY_BUILD_PLANS, 355 + self::ACTION_REQUIRE_SIGNATURE, 350 356 self::ACTION_NOTHING, 351 357 ); 352 358 case HeraldRuleTypeConfig::RULE_TYPE_PERSONAL: ··· 474 480 $effect, 475 481 true, 476 482 pht('Applied build plans.')); 483 + break; 484 + case self::ACTION_REQUIRE_SIGNATURE: 485 + foreach ($effect->getTarget() as $phid) { 486 + $this->requiredSignatureDocumentPHIDs[] = $phid; 487 + } 488 + $result[] = new HeraldApplyTranscript( 489 + $effect, 490 + true, 491 + pht('Required signatures.')); 477 492 break; 478 493 default: 479 494 throw new Exception("No rules to handle action '{$action}'.");
+1
src/applications/herald/controller/HeraldRuleController.php
··· 595 595 'buildplan' => '/typeahead/common/buildplans/', 596 596 'taskpriority' => '/typeahead/common/taskpriority/', 597 597 'arcanistprojects' => '/typeahead/common/arcanistprojects/', 598 + 'legaldocuments' => '/typeahead/common/legalpaddocuments/', 598 599 ), 599 600 'username' => $this->getRequest()->getUser()->getUserName(), 600 601 'icons' => mpull($handles, 'getTypeIcon', 'getPHID'),
+1 -1
src/applications/herald/storage/HeraldRule.php
··· 18 18 protected $isDisabled = 0; 19 19 protected $triggerObjectPHID; 20 20 21 - protected $configVersion = 36; 21 + protected $configVersion = 37; 22 22 23 23 // phids for which this rule has been applied 24 24 private $ruleApplied = self::ATTACHABLE;
-1
src/applications/legalpad/controller/LegalpadDocumentSignController.php
··· 64 64 ->setViewer(PhabricatorUser::getOmnipotentUser()) 65 65 ->withDocumentPHIDs(array($document->getPHID())) 66 66 ->withSignerPHIDs(array($signer_phid)) 67 - ->withDocumentVersions(array($document->getVersions())) 68 67 ->executeOne(); 69 68 70 69 if ($signature && !$viewer->isLoggedIn()) {
+11 -1
src/infrastructure/edges/constants/PhabricatorEdgeConfig.php
··· 75 75 const TYPE_OBJECT_HAS_WATCHER = 47; 76 76 const TYPE_WATCHER_HAS_OBJECT = 48; 77 77 78 + const TYPE_OBJECT_NEEDS_SIGNATURE = 49; 79 + const TYPE_SIGNATURE_NEEDED_BY_OBJECT = 50; 80 + 78 81 const TYPE_TEST_NO_CYCLE = 9000; 79 82 80 83 const TYPE_PHOB_HAS_ASANATASK = 80001; ··· 164 167 self::TYPE_DASHBOARD_HAS_PANEL => self::TYPE_PANEL_HAS_DASHBOARD, 165 168 166 169 self::TYPE_OBJECT_HAS_WATCHER => self::TYPE_WATCHER_HAS_OBJECT, 167 - self::TYPE_WATCHER_HAS_OBJECT => self::TYPE_OBJECT_HAS_WATCHER 170 + self::TYPE_WATCHER_HAS_OBJECT => self::TYPE_OBJECT_HAS_WATCHER, 171 + 172 + self::TYPE_OBJECT_NEEDS_SIGNATURE => 173 + self::TYPE_SIGNATURE_NEEDED_BY_OBJECT, 174 + self::TYPE_SIGNATURE_NEEDED_BY_OBJECT => 175 + self::TYPE_OBJECT_NEEDS_SIGNATURE, 168 176 ); 169 177 170 178 return idx($map, $edge_type); ··· 352 360 return '%s added %d dashboard(s): %s.'; 353 361 case self::TYPE_OBJECT_HAS_WATCHER: 354 362 return '%s added %d watcher(s): %s.'; 363 + case self::TYPE_OBJECT_NEEDS_SIGNATURE: 364 + return '%s added %d required legal document(s): %s.'; 355 365 case self::TYPE_SUBSCRIBED_TO_OBJECT: 356 366 case self::TYPE_UNSUBSCRIBED_FROM_OBJECT: 357 367 case self::TYPE_FILE_HAS_OBJECT:
+7
src/infrastructure/internationalization/translation/PhabricatorBaseEnglishTranslation.php
··· 926 926 ), 927 927 ), 928 928 929 + '%s added %d required legal document(s): %s.' => array( 930 + array( 931 + '%s added a required legal document: %3$s.', 932 + '%s added required legal documents: %3$s.', 933 + ), 934 + ), 935 + 929 936 '%s updated JIRA issue(s): added %d %s; removed %d %s.' => 930 937 '%s updated JIRA issues: added %3$s; removed %5$s.', 931 938
+1
webroot/rsrc/js/application/herald/HeraldRuleEditor.js
··· 220 220 case 'buildplan': 221 221 case 'taskpriority': 222 222 case 'arcanistprojects': 223 + case 'legaldocuments': 223 224 var tokenizer = this._newTokenizer(type); 224 225 input = tokenizer[0]; 225 226 get_fn = tokenizer[1];