1
0
Fork 0
mirror of https://we.phorge.it/source/phorge.git synced 2024-12-18 19:40:55 +01:00

Add skeleton code for webhooks

Summary: Ref T11330. Adds general support for webhooks. This is still rough and missing a lot of pieces -- and not yet useful for anything -- but can make HTTP requests.

Test Plan: Used `bin/webhook call ...` to complete requests to a test endpoint.

Maniphest Tasks: T11330

Differential Revision: https://secure.phabricator.com/D19045
This commit is contained in:
epriestley 2018-02-09 06:14:29 -08:00
parent 9386e436fe
commit 0470125d9e
34 changed files with 1896 additions and 14 deletions

1
bin/webhook Symbolic link
View file

@ -0,0 +1 @@
../scripts/setup/manage_webhook.php

View file

@ -0,0 +1,12 @@
CREATE TABLE {$NAMESPACE}_herald.herald_webhook (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
phid VARBINARY(64) NOT NULL,
name VARCHAR(128) NOT NULL COLLATE {$COLLATE_TEXT},
webhookURI VARCHAR(255) NOT NULL COLLATE {$COLLATE_TEXT},
viewPolicy VARBINARY(64) NOT NULL,
editPolicy VARBINARY(64) NOT NULL,
status VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT},
hmacKey VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT},
dateCreated INT UNSIGNED NOT NULL,
dateModified INT UNSIGNED NOT NULL
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

View file

@ -0,0 +1,19 @@
CREATE TABLE {$NAMESPACE}_herald.herald_webhooktransaction (
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) COLLATE {$COLLATE_TEXT} NOT NULL,
oldValue LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
newValue LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
contentSource LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
metadata LONGTEXT COLLATE {$COLLATE_TEXT} NOT NULL,
dateCreated INT UNSIGNED NOT NULL,
dateModified INT UNSIGNED NOT NULL,
UNIQUE KEY `key_phid` (`phid`),
KEY `key_object` (`objectPHID`)
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

View file

@ -0,0 +1,12 @@
CREATE TABLE {$NAMESPACE}_herald.herald_webhookrequest (
id INT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
phid VARBINARY(64) NOT NULL,
webhookPHID VARBINARY(64) NOT NULL,
objectPHID VARBINARY(64) NOT NULL,
status VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT},
properties LONGTEXT NOT NULL COLLATE {$COLLATE_TEXT},
lastRequestResult VARCHAR(32) NOT NULL COLLATE {$COLLATE_TEXT},
lastRequestEpoch INT UNSIGNED NOT NULL,
dateCreated INT UNSIGNED NOT NULL,
dateModified INT UNSIGNED NOT NULL
) ENGINE=InnoDB, COLLATE {$COLLATE_TEXT};

View file

@ -0,0 +1,21 @@
#!/usr/bin/env php
<?php
$root = dirname(dirname(dirname(__FILE__)));
require_once $root.'/scripts/init/init-script.php';
$args = new PhutilArgumentParser($argv);
$args->setTagline(pht('manage webhooks'));
$args->setSynopsis(<<<EOSYNOPSIS
**webhook** __command__ [__options__]
Manage webhooks.
EOSYNOPSIS
);
$args->parseStandardArguments();
$workflows = id(new PhutilClassMapQuery())
->setAncestorClass('HeraldWebhookManagementWorkflow')
->execute();
$workflows[] = new PhutilHelpArgumentWorkflow();
$args->parseWorkflows($workflows);

View file

@ -1364,6 +1364,7 @@ phutil_register_library_map(array(
'HeraldContentSourceField' => 'applications/herald/field/HeraldContentSourceField.php', 'HeraldContentSourceField' => 'applications/herald/field/HeraldContentSourceField.php',
'HeraldController' => 'applications/herald/controller/HeraldController.php', 'HeraldController' => 'applications/herald/controller/HeraldController.php',
'HeraldCoreStateReasons' => 'applications/herald/state/HeraldCoreStateReasons.php', 'HeraldCoreStateReasons' => 'applications/herald/state/HeraldCoreStateReasons.php',
'HeraldCreateWebhooksCapability' => 'applications/herald/capability/HeraldCreateWebhooksCapability.php',
'HeraldDAO' => 'applications/herald/storage/HeraldDAO.php', 'HeraldDAO' => 'applications/herald/storage/HeraldDAO.php',
'HeraldDeprecatedFieldGroup' => 'applications/herald/field/HeraldDeprecatedFieldGroup.php', 'HeraldDeprecatedFieldGroup' => 'applications/herald/field/HeraldDeprecatedFieldGroup.php',
'HeraldDifferentialAdapter' => 'applications/differential/herald/HeraldDifferentialAdapter.php', 'HeraldDifferentialAdapter' => 'applications/differential/herald/HeraldDifferentialAdapter.php',
@ -1440,6 +1441,30 @@ phutil_register_library_map(array(
'HeraldTranscriptSearchEngine' => 'applications/herald/query/HeraldTranscriptSearchEngine.php', 'HeraldTranscriptSearchEngine' => 'applications/herald/query/HeraldTranscriptSearchEngine.php',
'HeraldTranscriptTestCase' => 'applications/herald/storage/__tests__/HeraldTranscriptTestCase.php', 'HeraldTranscriptTestCase' => 'applications/herald/storage/__tests__/HeraldTranscriptTestCase.php',
'HeraldUtilityActionGroup' => 'applications/herald/action/HeraldUtilityActionGroup.php', 'HeraldUtilityActionGroup' => 'applications/herald/action/HeraldUtilityActionGroup.php',
'HeraldWebhook' => 'applications/herald/storage/HeraldWebhook.php',
'HeraldWebhookCallManagementWorkflow' => 'applications/herald/management/HeraldWebhookCallManagementWorkflow.php',
'HeraldWebhookController' => 'applications/herald/controller/HeraldWebhookController.php',
'HeraldWebhookEditController' => 'applications/herald/controller/HeraldWebhookEditController.php',
'HeraldWebhookEditEngine' => 'applications/herald/editor/HeraldWebhookEditEngine.php',
'HeraldWebhookEditor' => 'applications/herald/editor/HeraldWebhookEditor.php',
'HeraldWebhookListController' => 'applications/herald/controller/HeraldWebhookListController.php',
'HeraldWebhookManagementWorkflow' => 'applications/herald/management/HeraldWebhookManagementWorkflow.php',
'HeraldWebhookNameTransaction' => 'applications/herald/xaction/HeraldWebhookNameTransaction.php',
'HeraldWebhookPHIDType' => 'applications/herald/phid/HeraldWebhookPHIDType.php',
'HeraldWebhookQuery' => 'applications/herald/query/HeraldWebhookQuery.php',
'HeraldWebhookRequest' => 'applications/herald/storage/HeraldWebhookRequest.php',
'HeraldWebhookRequestListView' => 'applications/herald/view/HeraldWebhookRequestListView.php',
'HeraldWebhookRequestPHIDType' => 'applications/herald/phid/HeraldWebhookRequestPHIDType.php',
'HeraldWebhookRequestQuery' => 'applications/herald/query/HeraldWebhookRequestQuery.php',
'HeraldWebhookSearchEngine' => 'applications/herald/query/HeraldWebhookSearchEngine.php',
'HeraldWebhookStatusTransaction' => 'applications/herald/xaction/HeraldWebhookStatusTransaction.php',
'HeraldWebhookTestController' => 'applications/herald/controller/HeraldWebhookTestController.php',
'HeraldWebhookTransaction' => 'applications/herald/storage/HeraldWebhookTransaction.php',
'HeraldWebhookTransactionQuery' => 'applications/herald/query/HeraldWebhookTransactionQuery.php',
'HeraldWebhookTransactionType' => 'applications/herald/xaction/HeraldWebhookTransactionType.php',
'HeraldWebhookURITransaction' => 'applications/herald/xaction/HeraldWebhookURITransaction.php',
'HeraldWebhookViewController' => 'applications/herald/controller/HeraldWebhookViewController.php',
'HeraldWebhookWorker' => 'applications/herald/worker/HeraldWebhookWorker.php',
'Javelin' => 'infrastructure/javelin/Javelin.php', 'Javelin' => 'infrastructure/javelin/Javelin.php',
'LegalpadController' => 'applications/legalpad/controller/LegalpadController.php', 'LegalpadController' => 'applications/legalpad/controller/LegalpadController.php',
'LegalpadCreateDocumentsCapability' => 'applications/legalpad/capability/LegalpadCreateDocumentsCapability.php', 'LegalpadCreateDocumentsCapability' => 'applications/legalpad/capability/LegalpadCreateDocumentsCapability.php',
@ -6614,6 +6639,7 @@ phutil_register_library_map(array(
'HeraldContentSourceField' => 'HeraldField', 'HeraldContentSourceField' => 'HeraldField',
'HeraldController' => 'PhabricatorController', 'HeraldController' => 'PhabricatorController',
'HeraldCoreStateReasons' => 'HeraldStateReasons', 'HeraldCoreStateReasons' => 'HeraldStateReasons',
'HeraldCreateWebhooksCapability' => 'PhabricatorPolicyCapability',
'HeraldDAO' => 'PhabricatorLiskDAO', 'HeraldDAO' => 'PhabricatorLiskDAO',
'HeraldDeprecatedFieldGroup' => 'HeraldFieldGroup', 'HeraldDeprecatedFieldGroup' => 'HeraldFieldGroup',
'HeraldDifferentialAdapter' => 'HeraldAdapter', 'HeraldDifferentialAdapter' => 'HeraldAdapter',
@ -6704,6 +6730,39 @@ phutil_register_library_map(array(
'HeraldTranscriptSearchEngine' => 'PhabricatorApplicationSearchEngine', 'HeraldTranscriptSearchEngine' => 'PhabricatorApplicationSearchEngine',
'HeraldTranscriptTestCase' => 'PhabricatorTestCase', 'HeraldTranscriptTestCase' => 'PhabricatorTestCase',
'HeraldUtilityActionGroup' => 'HeraldActionGroup', 'HeraldUtilityActionGroup' => 'HeraldActionGroup',
'HeraldWebhook' => array(
'HeraldDAO',
'PhabricatorPolicyInterface',
'PhabricatorApplicationTransactionInterface',
'PhabricatorDestructibleInterface',
),
'HeraldWebhookCallManagementWorkflow' => 'HeraldWebhookManagementWorkflow',
'HeraldWebhookController' => 'HeraldController',
'HeraldWebhookEditController' => 'HeraldWebhookController',
'HeraldWebhookEditEngine' => 'PhabricatorEditEngine',
'HeraldWebhookEditor' => 'PhabricatorApplicationTransactionEditor',
'HeraldWebhookListController' => 'HeraldWebhookController',
'HeraldWebhookManagementWorkflow' => 'PhabricatorManagementWorkflow',
'HeraldWebhookNameTransaction' => 'HeraldWebhookTransactionType',
'HeraldWebhookPHIDType' => 'PhabricatorPHIDType',
'HeraldWebhookQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
'HeraldWebhookRequest' => array(
'HeraldDAO',
'PhabricatorPolicyInterface',
'PhabricatorExtendedPolicyInterface',
),
'HeraldWebhookRequestListView' => 'AphrontView',
'HeraldWebhookRequestPHIDType' => 'PhabricatorPHIDType',
'HeraldWebhookRequestQuery' => 'PhabricatorCursorPagedPolicyAwareQuery',
'HeraldWebhookSearchEngine' => 'PhabricatorApplicationSearchEngine',
'HeraldWebhookStatusTransaction' => 'HeraldWebhookTransactionType',
'HeraldWebhookTestController' => 'HeraldWebhookController',
'HeraldWebhookTransaction' => 'PhabricatorModularTransaction',
'HeraldWebhookTransactionQuery' => 'PhabricatorApplicationTransactionQuery',
'HeraldWebhookTransactionType' => 'PhabricatorModularTransactionType',
'HeraldWebhookURITransaction' => 'HeraldWebhookTransactionType',
'HeraldWebhookViewController' => 'HeraldWebhookController',
'HeraldWebhookWorker' => 'PhabricatorWorker',
'Javelin' => 'Phobject', 'Javelin' => 'Phobject',
'LegalpadController' => 'PhabricatorController', 'LegalpadController' => 'PhabricatorController',
'LegalpadCreateDocumentsCapability' => 'PhabricatorPolicyCapability', 'LegalpadCreateDocumentsCapability' => 'PhabricatorPolicyCapability',

View file

@ -62,6 +62,17 @@ final class PhabricatorHeraldApplication extends PhabricatorApplication {
'(?P<id>[1-9]\d*)/' '(?P<id>[1-9]\d*)/'
=> 'HeraldTranscriptController', => 'HeraldTranscriptController',
), ),
'webhook/' => array(
$this->getQueryRoutePattern() => 'HeraldWebhookListController',
'view/(?P<id>\d+)/(?:request/(?P<requestID>[^/]+)/)?' =>
'HeraldWebhookViewController',
$this->getEditRoutePattern('edit/') => 'HeraldWebhookEditController',
'test/(?P<id>\d+)/' => 'HeraldWebhookTestController',
'key/' => array(
'view/(?P<id>\d+)/' => 'HeraldWebhookViewKeyController',
'cycle/(?P<id>\d+)/' => 'HeraldWebhookCycleKeyController',
),
),
), ),
); );
} }
@ -72,6 +83,9 @@ final class PhabricatorHeraldApplication extends PhabricatorApplication {
'caption' => pht('Global rules can bypass access controls.'), 'caption' => pht('Global rules can bypass access controls.'),
'default' => PhabricatorPolicies::POLICY_ADMIN, 'default' => PhabricatorPolicies::POLICY_ADMIN,
), ),
HeraldCreateWebhooksCapability::CAPABILITY => array(
'default' => PhabricatorPolicies::POLICY_ADMIN,
),
); );
} }

View file

@ -0,0 +1,16 @@
<?php
final class HeraldCreateWebhooksCapability
extends PhabricatorPolicyCapability {
const CAPABILITY = 'herald.webhooks';
public function getCapabilityName() {
return pht('Can Create Webhooks');
}
public function describeCapabilityRejection() {
return pht('You do not have permission to create webhooks.');
}
}

View file

@ -6,18 +6,6 @@ abstract class HeraldController extends PhabricatorController {
return $this->buildSideNavView()->getMenu(); return $this->buildSideNavView()->getMenu();
} }
protected function buildApplicationCrumbs() {
$crumbs = parent::buildApplicationCrumbs();
$crumbs->addAction(
id(new PHUIListItemView())
->setName(pht('Create Herald Rule'))
->setHref($this->getApplicationURI('create/'))
->setIcon('fa-plus-square'));
return $crumbs;
}
public function buildSideNavView() { public function buildSideNavView() {
$viewer = $this->getViewer(); $viewer = $this->getViewer();
@ -29,8 +17,11 @@ abstract class HeraldController extends PhabricatorController {
->addNavigationItems($nav->getMenu()); ->addNavigationItems($nav->getMenu());
$nav->addLabel(pht('Utilities')) $nav->addLabel(pht('Utilities'))
->addFilter('test', pht('Test Console')) ->addFilter('test', pht('Test Console'))
->addFilter('transcript', pht('Transcripts')); ->addFilter('transcript', pht('Transcripts'));
$nav->addLabel(pht('Webhooks'))
->addFilter('webhook', pht('Webhooks'));
$nav->selectFilter(null); $nav->selectFilter(null);

View file

@ -17,5 +17,16 @@ final class HeraldRuleListController extends HeraldController {
return $this->delegateToController($controller); return $this->delegateToController($controller);
} }
protected function buildApplicationCrumbs() {
$crumbs = parent::buildApplicationCrumbs();
$crumbs->addAction(
id(new PHUIListItemView())
->setName(pht('Create Herald Rule'))
->setHref($this->getApplicationURI('create/'))
->setIcon('fa-plus-square'));
return $crumbs;
}
} }

View file

@ -0,0 +1,15 @@
<?php
abstract class HeraldWebhookController extends HeraldController {
protected function buildApplicationCrumbs() {
$crumbs = parent::buildApplicationCrumbs();
$crumbs->addTextCrumb(
pht('Webhooks'),
$this->getApplicationURI('webhook/'));
return $crumbs;
}
}

View file

@ -0,0 +1,12 @@
<?php
final class HeraldWebhookEditController
extends HeraldWebhookController {
public function handleRequest(AphrontRequest $request) {
return id(new HeraldWebhookEditEngine())
->setController($this)
->buildResponse();
}
}

View file

@ -0,0 +1,26 @@
<?php
final class HeraldWebhookListController
extends HeraldWebhookController {
public function shouldAllowPublic() {
return true;
}
public function handleRequest(AphrontRequest $request) {
return id(new HeraldWebhookSearchEngine())
->setController($this)
->buildResponse();
}
protected function buildApplicationCrumbs() {
$crumbs = parent::buildApplicationCrumbs();
id(new HeraldWebhookEditEngine())
->setViewer($this->getViewer())
->addActionToCrumbs($crumbs);
return $crumbs;
}
}

View file

@ -0,0 +1,45 @@
<?php
final class HeraldWebhookTestController
extends HeraldWebhookController {
public function handleRequest(AphrontRequest $request) {
$viewer = $this->getViewer();
$hook = id(new HeraldWebhookQuery())
->setViewer($viewer)
->withIDs(array($request->getURIData('id')))
->requireCapabilities(
array(
PhabricatorPolicyCapability::CAN_VIEW,
PhabricatorPolicyCapability::CAN_EDIT,
))
->executeOne();
if (!$hook) {
return new Aphront404Response();
}
if ($request->isFormPost()) {
$object = $hook;
$request = HeraldWebhookRequest::initializeNewWebhookRequest($hook)
->setObjectPHID($object->getPHID())
->save();
$request->queueCall();
$next_uri = $hook->getURI().'request/'.$request->getID().'/';
return id(new AphrontRedirectResponse())->setURI($next_uri);
}
return $this->newDialog()
->setTitle(pht('New Test Request'))
->appendParagraph(
pht('This will make a new test request to the configured URI.'))
->addCancelButton($hook->getURI())
->addSubmitButton(pht('Make Request'));
}
}

View file

@ -0,0 +1,160 @@
<?php
final class HeraldWebhookViewController
extends HeraldWebhookController {
public function shouldAllowPublic() {
return true;
}
public function handleRequest(AphrontRequest $request) {
$viewer = $this->getViewer();
$hook = id(new HeraldWebhookQuery())
->setViewer($viewer)
->withIDs(array($request->getURIData('id')))
->executeOne();
if (!$hook) {
return new Aphront404Response();
}
$header = $this->buildHeaderView($hook);
$warnings = null;
if ($hook->isInErrorBackoff($viewer)) {
$message = pht(
'Many requests to this webhook have failed recently (at least %s '.
'errors in the last %s seconds). New requests are temporarily paused.',
$hook->getErrorBackoffThreshold(),
$hook->getErrorBackoffWindow());
$warnings = id(new PHUIInfoView())
->setSeverity(PHUIInfoView::SEVERITY_WARNING)
->setErrors(
array(
$message,
));
}
$curtain = $this->buildCurtain($hook);
$properties_view = $this->buildPropertiesView($hook);
$timeline = $this->buildTransactionTimeline(
$hook,
new HeraldWebhookTransactionQuery());
$timeline->setShouldTerminate(true);
$requests = id(new HeraldWebhookRequestQuery())
->setViewer($viewer)
->withWebhookPHIDs(array($hook->getPHID()))
->setLimit(20)
->execute();
$requests_table = id(new HeraldWebhookRequestListView())
->setViewer($viewer)
->setRequests($requests)
->setHighlightID($request->getURIData('requestID'));
$requests_view = id(new PHUIObjectBoxView())
->setHeaderText(pht('Recent Requests'))
->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
->setTable($requests_table);
$hook_view = id(new PHUITwoColumnView())
->setHeader($header)
->setMainColumn(
array(
$warnings,
$properties_view,
$requests_view,
$timeline,
))
->setCurtain($curtain);
$crumbs = $this->buildApplicationCrumbs()
->addTextCrumb(pht('Webhook %d', $hook->getID()))
->setBorder(true);
return $this->newPage()
->setTitle(
array(
pht('Webhook %d', $hook->getID()),
$hook->getName(),
))
->setCrumbs($crumbs)
->setPageObjectPHIDs(
array(
$hook->getPHID(),
))
->appendChild($hook_view);
}
private function buildHeaderView(HeraldWebhook $hook) {
$viewer = $this->getViewer();
$title = $hook->getName();
$header = id(new PHUIHeaderView())
->setHeader($title)
->setViewer($viewer)
->setPolicyObject($hook)
->setHeaderIcon('fa-cloud-upload');
return $header;
}
private function buildCurtain(HeraldWebhook $hook) {
$viewer = $this->getViewer();
$curtain = $this->newCurtainView($hook);
$can_edit = PhabricatorPolicyFilter::hasCapability(
$viewer,
$hook,
PhabricatorPolicyCapability::CAN_EDIT);
$id = $hook->getID();
$edit_uri = $this->getApplicationURI("webhook/edit/{$id}/");
$test_uri = $this->getApplicationURI("webhook/test/{$id}/");
$curtain->addAction(
id(new PhabricatorActionView())
->setName(pht('Edit Webhook'))
->setIcon('fa-pencil')
->setDisabled(!$can_edit)
->setWorkflow(!$can_edit)
->setHref($edit_uri));
$curtain->addAction(
id(new PhabricatorActionView())
->setName(pht('New Test Request'))
->setIcon('fa-cloud-upload')
->setDisabled(!$can_edit)
->setWorkflow(true)
->setHref($test_uri));
return $curtain;
}
private function buildPropertiesView(HeraldWebhook $hook) {
$viewer = $this->getViewer();
$properties = id(new PHUIPropertyListView())
->setViewer($viewer);
$properties->addProperty(
pht('URI'),
$hook->getWebhookURI());
$properties->addProperty(
pht('Status'),
$hook->getStatusDisplayName());
return id(new PHUIObjectBoxView())
->setHeaderText(pht('Details'))
->setBackground(PHUIObjectBoxView::BLUE_PROPERTY)
->appendChild($properties);
}
}

View file

@ -0,0 +1,105 @@
<?php
final class HeraldWebhookEditEngine
extends PhabricatorEditEngine {
const ENGINECONST = 'herald.webhook';
public function isEngineConfigurable() {
return false;
}
public function getEngineName() {
return pht('Webhooks');
}
public function getSummaryHeader() {
return pht('Edit Webhook Configurations');
}
public function getSummaryText() {
return pht('This engine is used to edit webhooks.');
}
public function getEngineApplicationClass() {
return 'PhabricatorHeraldApplication';
}
protected function newEditableObject() {
$viewer = $this->getViewer();
return HeraldWebhook::initializeNewWebhook($viewer);
}
protected function newObjectQuery() {
return new HeraldWebhookQuery();
}
protected function getObjectCreateTitleText($object) {
return pht('Create Webhook');
}
protected function getObjectCreateButtonText($object) {
return pht('Create Webhook');
}
protected function getObjectEditTitleText($object) {
return pht('Edit Webhook: %s', $object->getName());
}
protected function getObjectEditShortText($object) {
return pht('Edit Webhook');
}
protected function getObjectCreateShortText() {
return pht('Create Webhook');
}
protected function getObjectName() {
return pht('Webhook');
}
protected function getEditorURI() {
return '/herald/webhook/edit/';
}
protected function getObjectCreateCancelURI($object) {
return '/herald/webhook/';
}
protected function getObjectViewURI($object) {
return $object->getURI();
}
protected function getCreateNewObjectPolicy() {
return $this->getApplication()->getPolicy(
HeraldCreateWebhooksCapability::CAPABILITY);
}
protected function buildCustomEditFields($object) {
return array(
id(new PhabricatorTextEditField())
->setKey('name')
->setLabel(pht('Name'))
->setDescription(pht('Name of the webhook.'))
->setTransactionType(HeraldWebhookNameTransaction::TRANSACTIONTYPE)
->setIsRequired(true)
->setValue($object->getName()),
id(new PhabricatorTextEditField())
->setKey('uri')
->setLabel(pht('URI'))
->setDescription(pht('URI for the webhook.'))
->setTransactionType(HeraldWebhookURITransaction::TRANSACTIONTYPE)
->setIsRequired(true)
->setValue($object->getWebhookURI()),
id(new PhabricatorSelectEditField())
->setKey('status')
->setLabel(pht('Status'))
->setDescription(pht('Status mode for the webhook.'))
->setTransactionType(HeraldWebhookStatusTransaction::TRANSACTIONTYPE)
->setOptions(HeraldWebhook::getStatusDisplayNameMap())
->setValue($object->getStatus()),
);
}
}

View file

@ -0,0 +1,22 @@
<?php
final class HeraldWebhookEditor
extends PhabricatorApplicationTransactionEditor {
public function getEditorApplicationClass() {
return 'PhabricatorHeraldApplication';
}
public function getEditorObjectsDescription() {
return pht('Webhooks');
}
public function getCreateObjectTitle($author, $object) {
return pht('%s created this webhook.', $author);
}
public function getCreateObjectTitleForFeed($author, $object) {
return pht('%s created %s.', $author, $object);
}
}

View file

@ -0,0 +1,64 @@
<?php
final class HeraldWebhookCallManagementWorkflow
extends HeraldWebhookManagementWorkflow {
protected function didConstruct() {
$this
->setName('call')
->setExamples('**call** --id __id__')
->setSynopsis(pht('Call a webhook.'))
->setArguments(
array(
array(
'name' => 'id',
'param' => 'id',
'help' => pht('Webhook ID to call'),
),
));
}
public function execute(PhutilArgumentParser $args) {
$viewer = $this->getViewer();
$id = $args->getArg('id');
if (!$id) {
throw new PhutilArgumentUsageException(
pht(
'Specify a webhook to call with "--id".'));
}
$hook = id(new HeraldWebhookQuery())
->setViewer($viewer)
->withIDs(array($id))
->executeOne();
if (!$hook) {
throw new PhutilArgumentUsageException(
pht(
'Unable to load specified webhook ("%s").',
$id));
}
$object = $hook;
$application_phid = id(new PhabricatorHeraldApplication())->getPHID();
$request = HeraldWebhookRequest::initializeNewWebhookRequest($hook)
->setObjectPHID($object->getPHID())
->save();
PhabricatorWorker::setRunAllTasksInProcess(true);
$request->queueCall();
$request->reload();
echo tsprintf(
"%s\n",
pht(
'Success, got HTTP %s from webhook.',
$request->getErrorCode()));
return 0;
}
}

View file

@ -0,0 +1,4 @@
<?php
abstract class HeraldWebhookManagementWorkflow
extends PhabricatorManagementWorkflow {}

View file

@ -0,0 +1,49 @@
<?php
final class HeraldWebhookPHIDType extends PhabricatorPHIDType {
const TYPECONST = 'HWBH';
public function getTypeName() {
return pht('Webhook');
}
public function newObject() {
return new HeraldWebhook();
}
public function getPHIDTypeApplicationClass() {
return 'PhabricatorHeraldApplication';
}
protected function buildQueryForObjects(
PhabricatorObjectQuery $query,
array $phids) {
return id(new HeraldWebhookQuery())
->withPHIDs($phids);
}
public function loadHandles(
PhabricatorHandleQuery $query,
array $handles,
array $objects) {
foreach ($handles as $phid => $handle) {
$hook = $objects[$phid];
$name = $hook->getName();
$id = $hook->getID();
$handle
->setName($name)
->setURI($hook->getURI())
->setFullName(pht('Webhook %d %s', $id, $name));
if ($hook->isDisabled()) {
$handle->setStatus(PhabricatorObjectHandle::STATUS_CLOSED);
}
}
}
}

View file

@ -0,0 +1,39 @@
<?php
final class HeraldWebhookRequestPHIDType extends PhabricatorPHIDType {
const TYPECONST = 'HWBR';
public function getTypeName() {
return pht('Webhook Request');
}
public function newObject() {
return new HeraldWebhook();
}
public function getPHIDTypeApplicationClass() {
return 'PhabricatorHeraldApplication';
}
protected function buildQueryForObjects(
PhabricatorObjectQuery $query,
array $phids) {
return id(new HeraldWebhookRequestQuery())
->withPHIDs($phids);
}
public function loadHandles(
PhabricatorHandleQuery $query,
array $handles,
array $objects) {
foreach ($handles as $phid => $handle) {
$request = $objects[$phid];
// TODO: Fill this in.
}
}
}

View file

@ -0,0 +1,64 @@
<?php
final class HeraldWebhookQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $statuses;
public function withIDs(array $ids) {
$this->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 HeraldWebhook();
}
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 'PhabricatorHeraldApplication';
}
}

View file

@ -0,0 +1,126 @@
<?php
final class HeraldWebhookRequestQuery
extends PhabricatorCursorPagedPolicyAwareQuery {
private $ids;
private $phids;
private $webhookPHIDs;
private $lastRequestEpochMin;
private $lastRequestEpochMax;
private $lastRequestResults;
public function withIDs(array $ids) {
$this->ids = $ids;
return $this;
}
public function withPHIDs(array $phids) {
$this->phids = $phids;
return $this;
}
public function withWebhookPHIDs(array $phids) {
$this->webhookPHIDs = $phids;
return $this;
}
public function newResultObject() {
return new HeraldWebhookRequest();
}
protected function loadPage() {
return $this->loadStandardPage($this->newResultObject());
}
public function withLastRequestEpochBetween($epoch_min, $epoch_max) {
$this->lastRequestEpochMin = $epoch_min;
$this->lastRequestEpochMax = $epoch_max;
return $this;
}
public function withLastRequestResults(array $results) {
$this->lastRequestResults = $results;
return $this;
}
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->webhookPHIDs !== null) {
$where[] = qsprintf(
$conn,
'webhookPHID IN (%Ls)',
$this->webhookPHIDs);
}
if ($this->lastRequestEpochMin !== null) {
$where[] = qsprintf(
$conn,
'lastRequestEpoch >= %d',
$this->lastRequestEpochMin);
}
if ($this->lastRequestEpochMax !== null) {
$where[] = qsprintf(
$conn,
'lastRequestEpoch <= %d',
$this->lastRequestEpochMax);
}
if ($this->lastRequestResults !== null) {
$where[] = qsprintf(
$conn,
'lastRequestResult IN (%Ls)',
$this->lastRequestResults);
}
return $where;
}
protected function willFilterPage(array $requests) {
$hook_phids = mpull($requests, 'getWebhookPHID');
$hooks = id(new HeraldWebhookQuery())
->setViewer($this->getViewer())
->setParentQuery($this)
->withPHIDs($hook_phids)
->execute();
$hooks = mpull($hooks, null, 'getPHID');
foreach ($requests as $key => $request) {
$hook_phid = $request->getWebhookPHID();
$hook = idx($hooks, $hook_phid);
if (!$hook) {
unset($requests[$key]);
$this->didRejectResult($request);
continue;
}
$request->attachWebhook($hook);
}
return $requests;
}
public function getQueryApplicationClass() {
return 'PhabricatorHeraldApplication';
}
}

View file

@ -0,0 +1,95 @@
<?php
final class HeraldWebhookSearchEngine
extends PhabricatorApplicationSearchEngine {
public function getResultTypeDescription() {
return pht('Webhooks');
}
public function getApplicationClassName() {
return 'PhabricatorHeraldApplication';
}
public function newQuery() {
return new HeraldWebhookQuery();
}
protected function buildQueryFromParameters(array $map) {
$query = $this->newQuery();
if ($map['statuses']) {
$query->withStatuses($map['statuses']);
}
return $query;
}
protected function buildCustomSearchFields() {
return array(
id(new PhabricatorSearchCheckboxesField())
->setKey('statuses')
->setLabel(pht('Status'))
->setDescription(
pht('Search for archived or active pastes.'))
->setOptions(HeraldWebhook::getStatusDisplayNameMap()),
);
}
protected function getURI($path) {
return '/herald/webhook/'.$path;
}
protected function getBuiltinQueryNames() {
$names = array();
$names['active'] = pht('Active');
$names['all'] = pht('All');
return $names;
}
public function buildSavedQueryFromBuiltin($query_key) {
$query = $this->newSavedQuery();
$query->setQueryKey($query_key);
switch ($query_key) {
case 'all':
return $query;
case 'active':
return $query->setParameter(
'statuses',
array(
HeraldWebhook::HOOKSTATUS_FIREHOSE,
HeraldWebhook::HOOKSTATUS_ENABLED,
));
}
return parent::buildSavedQueryFromBuiltin($query_key);
}
protected function renderResultList(
array $hooks,
PhabricatorSavedQuery $query,
array $handles) {
assert_instances_of($hooks, 'HeraldWebhook');
$viewer = $this->requireViewer();
$list = id(new PHUIObjectItemListView())
->setViewer($viewer);
foreach ($hooks as $hook) {
$item = id(new PHUIObjectItemView())
->setObjectName(pht('Hook %d', $hook->getID()))
->setHeader($hook->getName())
->setHref($hook->getURI());
$list->addItem($item);
}
return id(new PhabricatorApplicationSearchResultView())
->setObjectList($list)
->setNoDataString(pht('No webhooks found.'));
}
}

View file

@ -0,0 +1,10 @@
<?php
final class HeraldWebhookTransactionQuery
extends PhabricatorApplicationTransactionQuery {
public function getTemplateApplicationTransaction() {
return new HeraldWebhookTransaction();
}
}

View file

@ -0,0 +1,177 @@
<?php
final class HeraldWebhook
extends HeraldDAO
implements
PhabricatorPolicyInterface,
PhabricatorApplicationTransactionInterface,
PhabricatorDestructibleInterface {
protected $name;
protected $webhookURI;
protected $viewPolicy;
protected $editPolicy;
protected $status;
protected $hmacKey;
const HOOKSTATUS_FIREHOSE = 'firehose';
const HOOKSTATUS_ENABLED = 'enabled';
const HOOKSTATUS_DISABLED = 'disabled';
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_COLUMN_SCHEMA => array(
'name' => 'text128',
'webhookURI' => 'text255',
'status' => 'text32',
'hmacKey' => 'text32',
),
self::CONFIG_KEY_SCHEMA => array(
'key_status' => array(
'columns' => array('status'),
),
),
) + parent::getConfiguration();
}
public function getPHIDType() {
return HeraldWebhookPHIDType::TYPECONST;
}
public static function initializeNewWebhook(PhabricatorUser $viewer) {
return id(new self())
->setStatus(self::HOOKSTATUS_ENABLED)
->setViewPolicy(PhabricatorPolicies::getMostOpenPolicy())
->setEditPolicy($viewer->getPHID())
->setHmacKey(Filesystem::readRandomCharacters(32));
}
public function getURI() {
return '/herald/webhook/view/'.$this->getID().'/';
}
public function isDisabled() {
return ($this->getStatus() === self::HOOKSTATUS_DISABLED);
}
public static function getStatusDisplayNameMap() {
return array(
self::HOOKSTATUS_FIREHOSE => pht('Firehose'),
self::HOOKSTATUS_ENABLED => pht('Enabled'),
self::HOOKSTATUS_DISABLED => pht('Disabled'),
);
}
public function getStatusDisplayName() {
$status = $this->getStatus();
return idx($this->getStatusDisplayNameMap(), $status);
}
public function getErrorBackoffWindow() {
return phutil_units('5 minutes in seconds');
}
public function getErrorBackoffThreshold() {
return 10;
}
public function isInErrorBackoff(PhabricatorUser $viewer) {
$backoff_window = $this->getErrorBackoffWindow();
$backoff_threshold = $this->getErrorBackoffThreshold();
$now = PhabricatorTime::getNow();
$window_start = ($now - $backoff_window);
$requests = id(new HeraldWebhookRequestQuery())
->setViewer($viewer)
->withWebhookPHIDs(array($this->getPHID()))
->withLastRequestEpochBetween($window_start, null)
->withLastRequestResults(
array(
HeraldWebhookRequest::RESULT_FAIL,
))
->execute();
if (count($requests) >= $backoff_threshold) {
return true;
}
return false;
}
/* -( 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;
}
/* -( PhabricatorApplicationTransactionInterface )------------------------- */
public function getApplicationTransactionEditor() {
return new HeraldWebhookEditor();
}
public function getApplicationTransactionObject() {
return $this;
}
public function getApplicationTransactionTemplate() {
return new HeraldWebhookTransaction();
}
public function willRenderTimeline(
PhabricatorApplicationTransactionView $timeline,
AphrontRequest $request) {
return $timeline;
}
/* -( PhabricatorDestructibleInterface )----------------------------------- */
public function destroyObjectPermanently(
PhabricatorDestructionEngine $engine) {
while (true) {
$requests = id(new HeraldWebhookRequestQuery())
->setViewer($engine->getViewer())
->withWebhookPHIDs(array($this->getPHID()))
->setLimit(100)
->execute();
if (!$requests) {
break;
}
foreach ($requests as $request) {
$request->delete();
}
}
$this->delete();
}
}

View file

@ -0,0 +1,191 @@
<?php
final class HeraldWebhookRequest
extends HeraldDAO
implements
PhabricatorPolicyInterface,
PhabricatorExtendedPolicyInterface {
protected $webhookPHID;
protected $objectPHID;
protected $status;
protected $properties = array();
protected $lastRequestResult;
protected $lastRequestEpoch;
private $webhook = self::ATTACHABLE;
const RETRY_NEVER = 'never';
const RETRY_FOREVER = 'forever';
const STATUS_QUEUED = 'queued';
const STATUS_FAILED = 'failed';
const STATUS_SENT = 'sent';
const RESULT_NONE = 'none';
const RESULT_OKAY = 'okay';
const RESULT_FAIL = 'fail';
protected function getConfiguration() {
return array(
self::CONFIG_AUX_PHID => true,
self::CONFIG_SERIALIZATION => array(
'properties' => self::SERIALIZATION_JSON,
),
self::CONFIG_COLUMN_SCHEMA => array(
'status' => 'text32',
'lastRequestResult' => 'text32',
'lastRequestEpoch' => 'epoch',
),
self::CONFIG_KEY_SCHEMA => array(
'key_ratelimit' => array(
'columns' => array(
'webhookPHID',
'lastRequestResult',
'lastRequestEpoch',
),
),
'key_collect' => array(
'columns' => array('dateCreated'),
),
),
) + parent::getConfiguration();
}
public function getPHIDType() {
return HeraldWebhookRequestPHIDType::TYPECONST;
}
public static function initializeNewWebhookRequest(HeraldWebhook $hook) {
return id(new self())
->setWebhookPHID($hook->getPHID())
->attachWebhook($hook)
->setStatus(self::STATUS_QUEUED)
->setRetryMode(self::RETRY_NEVER)
->setLastRequestResult(self::RESULT_NONE)
->setLastRequestEpoch(0);
}
public function getWebhook() {
return $this->assertAttached($this->webhook);
}
public function attachWebhook(HeraldWebhook $hook) {
$this->webhook = $hook;
return $this;
}
protected function setProperty($key, $value) {
$this->properties[$key] = $value;
return $this;
}
protected function getProperty($key, $default = null) {
return idx($this->properties, $key, $default);
}
public function setRetryMode($mode) {
return $this->setProperty('retry', $mode);
}
public function getRetryMode() {
return $this->getProperty('retry');
}
public function setErrorType($error_type) {
return $this->setProperty('errorType', $error_type);
}
public function getErrorType() {
return $this->getProperty('errorType');
}
public function setErrorCode($error_code) {
return $this->setProperty('errorCode', $error_code);
}
public function getErrorCode() {
return $this->getProperty('errorCode');
}
public function setTransactionPHIDs(array $phids) {
return $this->setProperty('transactionPHIDs', $phids);
}
public function getTransactionPHIDs() {
return $this->getProperty('transactionPHIDs', array());
}
public function queueCall() {
PhabricatorWorker::scheduleTask(
'HeraldWebhookWorker',
array(
'webhookRequestPHID' => $this->getPHID(),
),
array(
'objectPHID' => $this->getPHID(),
));
return $this;
}
public function newStatusIcon() {
switch ($this->getStatus()) {
case self::STATUS_QUEUED:
$icon = 'fa-refresh';
$color = 'blue';
$tooltip = pht('Queued');
break;
case self::STATUS_SENT:
$icon = 'fa-check';
$color = 'green';
$tooltip = pht('Sent');
break;
case self::STATUS_FAILED:
default:
$icon = 'fa-times';
$color = 'red';
$tooltip = pht('Failed');
break;
}
return id(new PHUIIconView())
->setIcon($icon, $color)
->setTooltip($tooltip);
}
/* -( 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;
}
/* -( PhabricatorExtendedPolicyInterface )--------------------------------- */
public function getExtendedPolicy($capability, PhabricatorUser $viewer) {
return array(
array($this->getWebhook(), PhabricatorPolicyCapability::CAN_VIEW),
);
}
}

View file

@ -0,0 +1,22 @@
<?php
final class HeraldWebhookTransaction
extends PhabricatorModularTransaction {
public function getApplicationName() {
return 'herald';
}
public function getApplicationTransactionType() {
return HeraldWebhookPHIDType::TYPECONST;
}
public function getApplicationTransactionCommentObject() {
return null;
}
public function getBaseTransactionClass() {
return 'HeraldWebhookTransactionType';
}
}

View file

@ -0,0 +1,78 @@
<?php
final class HeraldWebhookRequestListView
extends AphrontView {
private $requests;
private $highlightID;
public function setRequests(array $requests) {
assert_instances_of($requests, 'HeraldWebhookRequest');
$this->requests = $requests;
return $this;
}
public function setHighlightID($highlight_id) {
$this->highlightID = $highlight_id;
return $this;
}
public function getHighlightID() {
return $this->highlightID;
}
public function render() {
$viewer = $this->getViewer();
$requests = $this->requests;
$handle_phids = array();
foreach ($requests as $request) {
$handle_phids[] = $request->getObjectPHID();
}
$handles = $viewer->loadHandles($handle_phids);
$highlight_id = $this->getHighlightID();
$rows = array();
$rowc = array();
foreach ($requests as $request) {
$icon = $request->newStatusIcon();
if ($highlight_id == $request->getID()) {
$rowc[] = 'highlighted';
} else {
$rowc[] = null;
}
$rows[] = array(
$request->getID(),
$icon,
$handles[$request->getObjectPHID()]->renderLink(),
$request->getErrorType(),
$request->getErrorCode(),
);
}
$table = id(new AphrontTableView($rows))
->setRowClasses($rowc)
->setHeaders(
array(
pht('ID'),
'',
pht('Object'),
pht('Type'),
pht('Code'),
))
->setColumnClasses(
array(
'n',
'',
'wide',
'',
'',
));
return $table;
}
}

View file

@ -0,0 +1,229 @@
<?php
final class HeraldWebhookWorker
extends PhabricatorWorker {
protected function doWork() {
$viewer = PhabricatorUser::getOmnipotentUser();
$data = $this->getTaskData();
$request_phid = idx($data, 'webhookRequestPHID');
$request = id(new HeraldWebhookRequestQuery())
->setViewer($viewer)
->withPHIDs(array($request_phid))
->executeOne();
if (!$request) {
throw new PhabricatorWorkerPermanentFailureException(
pht(
'Unable to load webhook request ("%s"). It may have been '.
'garbage collected.',
$request_phid));
}
$status = $request->getStatus();
if ($status !== HeraldWebhookRequest::STATUS_QUEUED) {
throw new PhabricatorWorkerPermanentFailureException(
pht(
'Webhook request ("%s") is not in "%s" status (actual '.
'status is "%s"). Declining call to hook.',
$request_phid,
HeraldWebhookRequest::STATUS_QUEUED,
$status));
}
$hook = $request->getWebhook();
if ($hook->isDisabled()) {
$this->failRequest($request, 'hook', 'disabled');
throw new PhabricatorWorkerPermanentFailureException(
pht(
'Associated hook ("%s") for webhook request ("%s") is disabled.',
$hook->getPHID(),
$request_phid));
}
$uri = $hook->getWebhookURI();
try {
PhabricatorEnv::requireValidRemoteURIForFetch(
$uri,
array(
'http',
'https',
));
} catch (Exception $ex) {
$this->failRequest($request, 'hook', 'uri');
throw new PhabricatorWorkerPermanentFailureException(
pht(
'Associated hook ("%s") for webhook request ("%s") has invalid '.
'fetch URI: %s',
$hook->getPHID(),
$request_phid,
$ex->getMessage()));
}
$object_phid = $request->getObjectPHID();
$object = id(new PhabricatorObjectQuery())
->setViewer($viewer)
->withPHIDs(array($object_phid))
->executeOne();
if (!$object) {
$this->failRequest($request, 'hook', 'object');
throw new PhabricatorWorkerPermanentFailureException(
pht(
'Unable to load object ("%s") for webhook request ("%s").',
$object_phid,
$request_phid));
}
$xaction_query = PhabricatorApplicationTransactionQuery::newQueryForObject(
$object);
$xaction_phids = $request->getTransactionPHIDs();
if ($xaction_phids) {
$xactions = $xaction_query
->setViewer($viewer)
->withObjectPHIDs(array($object_phid))
->withPHIDs($xaction_phids)
->execute();
$xactions = mpull($xactions, null, 'getPHID');
} else {
$xactions = array();
}
// To prevent thundering herd issues for high volume webhooks (where
// a large number of workers might try to work through a request backlog
// simultaneously, before the error backoff can catch up), we never
// parallelize requests to a particular webhook.
$lock_key = 'webhook('.$hook->getPHID().')';
$lock = PhabricatorGlobalLock::newLock($lock_key);
try {
$lock->lock();
} catch (Exception $ex) {
phlog($ex);
throw new PhabricatorWorkerYieldException(15);
}
$caught = null;
try {
$this->callWebhookWithLock($hook, $request, $object, $xactions);
} catch (Exception $ex) {
$caught = $ex;
}
$lock->unlock();
if ($caught) {
throw $caught;
}
}
private function callWebhookWithLock(
HeraldWebhook $hook,
HeraldWebhookRequest $request,
$object,
array $xactions) {
$viewer = PhabricatorUser::getOmnipotentUser();
if ($hook->isInErrorBackoff($viewer)) {
throw new PhabricatorWorkerYieldException($hook->getErrorBackoffWindow());
}
$xaction_data = array();
foreach ($xactions as $xaction) {
$xaction_data[] = array(
'phid' => $xaction->getPHID(),
);
}
$payload = array(
'triggers' => array(),
'object' => array(
'phid' => $object->getPHID(),
),
'transactions' => $xaction_data,
);
$payload = phutil_json_encode($payload);
$key = $hook->getHmacKey();
$signature = PhabricatorHash::digestHMACSHA256($payload, $key);
$uri = $hook->getWebhookURI();
$future = id(new HTTPSFuture($uri))
->setMethod('POST')
->addHeader('Content-Type', 'application/json')
->addHeader('X-Phabricator-Webhook-Signature', $signature)
->setTimeout(15)
->setData($payload);
list($status) = $future->resolve();
if ($status->isTimeout()) {
$error_type = 'timeout';
} else {
$error_type = 'http';
}
$error_code = $status->getStatusCode();
$request
->setErrorType($error_type)
->setErrorCode($error_code)
->setLastRequestEpoch(PhabricatorTime::getNow());
$retry_forever = HeraldWebhookRequest::RETRY_FOREVER;
if ($status->isTimeout() || $status->isError()) {
$should_retry = ($request->getRetryMode() === $retry_forever);
$request
->setLastRequestResult(HeraldWebhookRequest::RESULT_FAIL);
if ($should_retry) {
$request->save();
throw new Exception(
pht(
'Webhook request ("%s", to "%s") failed (%s / %s). The request '.
'will be retried.',
$request->getPHID(),
$uri,
$error_type,
$error_code));
} else {
$request
->setStatus(HeraldWebhookRequest::STATUS_FAILED)
->save();
throw new PhabricatorWorkerPermanentFailureException(
pht(
'Webhook request ("%s", to "%s") failed (%s / %s). The request '.
'will not be retried.',
$request->getPHID(),
$uri,
$error_type,
$error_code));
}
} else {
$request
->setLastRequestResult(HeraldWebhookRequest::RESULT_OKAY)
->setStatus(HeraldWebhookRequest::STATUS_SENT)
->save();
}
}
private function failRequest(
HeraldWebhookRequest $request,
$error_type,
$error_code) {
$request
->setStatus(HeraldWebhookRequest::STATUS_FAILED)
->setErrorType($error_type)
->setErrorCode($error_code)
->setLastRequestResult(HeraldWebhookRequest::RESULT_NONE)
->setLastRequestEpoch(0)
->save();
}
}

View file

@ -0,0 +1,60 @@
<?php
final class HeraldWebhookNameTransaction
extends HeraldWebhookTransactionType {
const TRANSACTIONTYPE = 'name';
public function generateOldValue($object) {
return $object->getName();
}
public function applyInternalEffects($object, $value) {
$object->setName($value);
}
public function getTitle() {
return pht(
'%s renamed this webhook from %s to %s.',
$this->renderAuthor(),
$this->renderOldValue(),
$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) {
$errors = array();
$viewer = $this->getActor();
if ($this->isEmptyTextTransaction($object->getName(), $xactions)) {
$errors[] = $this->newRequiredError(
pht('Webhooks must have a name.'));
return $errors;
}
$max_length = $object->getColumnMaximumByteLength('name');
foreach ($xactions as $xaction) {
$old_value = $this->generateOldValue($object);
$new_value = $xaction->getNewValue();
$new_length = strlen($new_value);
if ($new_length > $max_length) {
$errors[] = $this->newInvalidError(
pht(
'Webhook names can be no longer than %s characters.',
new PhutilNumber($max_length)));
}
}
return $errors;
}
}

View file

@ -0,0 +1,55 @@
<?php
final class HeraldWebhookStatusTransaction
extends HeraldWebhookTransactionType {
const TRANSACTIONTYPE = 'status';
public function generateOldValue($object) {
return $object->getStatus();
}
public function applyInternalEffects($object, $value) {
$object->setStatus($value);
}
public function getTitle() {
return pht(
'%s changed hook status from %s to %s.',
$this->renderAuthor(),
$this->renderOldValue(),
$this->renderNewValue());
}
public function getTitleForFeed() {
return pht(
'%s changed %s from %s to %s.',
$this->renderAuthor(),
$this->renderObject(),
$this->renderOldValue(),
$this->renderNewValue());
}
public function validateTransactions($object, array $xactions) {
$errors = array();
$viewer = $this->getActor();
$options = HeraldWebhook::getStatusDisplayNameMap();
foreach ($xactions as $xaction) {
$new_value = $xaction->getNewValue();
if (!isset($options[$new_value])) {
$errors[] = $this->newInvalidError(
pht(
'Webhook status "%s" is not valid. Valid statuses are: %s.',
$new_value,
implode(', ', array_keys($options))),
$xaction);
}
}
return $errors;
}
}

View file

@ -0,0 +1,4 @@
<?php
abstract class HeraldWebhookTransactionType
extends PhabricatorModularTransactionType {}

View file

@ -0,0 +1,74 @@
<?php
final class HeraldWebhookURITransaction
extends HeraldWebhookTransactionType {
const TRANSACTIONTYPE = 'uri';
public function generateOldValue($object) {
return $object->getWebhookURI();
}
public function applyInternalEffects($object, $value) {
$object->setWebhookURI($value);
}
public function getTitle() {
return pht(
'%s changed the URI for this webhook from %s to %s.',
$this->renderAuthor(),
$this->renderOldValue(),
$this->renderNewValue());
}
public function getTitleForFeed() {
return pht(
'%s changed the URI for %s from %s to %s.',
$this->renderAuthor(),
$this->renderObject(),
$this->renderOldValue(),
$this->renderNewValue());
}
public function validateTransactions($object, array $xactions) {
$errors = array();
$viewer = $this->getActor();
if ($this->isEmptyTextTransaction($object->getName(), $xactions)) {
$errors[] = $this->newRequiredError(
pht('Webhooks must have a URI.'));
return $errors;
}
$max_length = $object->getColumnMaximumByteLength('webhookURI');
foreach ($xactions as $xaction) {
$old_value = $this->generateOldValue($object);
$new_value = $xaction->getNewValue();
$new_length = strlen($new_value);
if ($new_length > $max_length) {
$errors[] = $this->newInvalidError(
pht(
'Webhook URIs can be no longer than %s characters.',
new PhutilNumber($max_length)),
$xaction);
}
try {
PhabricatorEnv::requireValidRemoteURIForFetch(
$new_value,
array(
'http',
'https',
));
} catch (Exception $ex) {
$errors[] = $this->newInvalidError(
$ex->getMessage(),
$xaction);
}
}
return $errors;
}
}