HEX
Server: Apache
System: Linux srv13.cpanelhost.cl 3.10.0-962.3.2.lve1.5.38.el7.x86_64 #1 SMP Thu Jun 18 05:28:41 EDT 2020 x86_64
User: cca63905 (4205)
PHP: 7.3.20
Disabled: NONE
Upload Files
File: /home4/cca63905/www/guiaweb/dev/tools/github_pr_reviewers_webhook.php
<?php
/* Copyright (C) 2025       Marc de Lima Lucio      <marc-dll@user.noreply.github.com>
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 3 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

/*
 * This code handles GitHub webhooks so that when a pull request is
 * created or edited (in case it is not draft), or made ready for review,
 * a list of targeted users is assigned to it as reviewers. This is
 * done using GitHub REST API.
 *
 * You must first create an API token. I strongly advise to create a
 * "fine-grained token", this will enable you to restrict access to
 * features. To do so:
 * * Go to your account settings (top right menu > "Settings")
 * * Click on "Developer settings" on the bottom left
 * * Click on left menu "Personal access tokens" > "Fine-grained tokens"
 * * On the top right, click on "Generate new token"
 * * Fill the necessary information:
 *     - "Token name"
 *     - "Resource owner": if the targeted repository is part of an
 *       organization, you must choose it
 *     - "Expiration": self explanatory
 *           /!\ This script does not handle token renewal
 *     - "Repository access": unless you have a specific use-case,
 *       choose "Only select repositories" and select the targeted
 *       repository
 *           /!\ I couldn't select the repository with AdBlock enabled
 *     - "Permissions": only the following permission is required:
 *           + "Repository permissions" > "Pull requests" : Read and
 *             write
 *     - Click on "Generate token", confirm in the pop-in and copy the
 *       value
 *
 * Put this script on a publicly-available Web location. Create a config
 * PHP file that returns an array with the following indices (all are
 * required):
 * * `'reviewers'`: an `array` associating branches to their reviewers,
 *   identified by their GitHub login. The values are either another
 *   `array` or a `string` if there is only one reviewer
 * * `'secret'`: a `string` that will be used by GitHub to sign its
 *   request
 *       /!\ While GitHub doesn't require a secret to be set, this script
 *           currently does not work without one.
 * * `'token'`: the token created in the step above.
 *
 * Then, you must create said webhook on the repository of your choice:
 * * Go to your repository homepage
 * * Click on "Settings" tab
 * * Under left menu "Code and automation", click on "Webhooks"
 * * On the top right, click on "Add webhook"
 * * Fill the necessary information:
 *     - "Payload URL" is the path to this script
 *           You can add `?debug` to output more information:wq;
 *     - "Content type" should be left as "application/x-www-form-
 *       urlencoded"
 *           /!\ This script currently does not handle "application/json"
 *     - "Secret": copy the secret you set in the config file
 *     - If you want to restrict the events passed to this script, select
 *       "Let me select individual events", check "Pull requests" and
 *       uncheck everything else
 * * Validate by clicking on "Add webhook"
 *
 * A `ping` webhook will then be sent to the URL you set. You can see a
 * log of deliveries by clicking on "Edit" and then on the tab "Recent
 * Deliveries". By clicking on any delivery, you can see the request and
 * the response, and also "Redeliver" it.
 */

header('Content-Type: text/plain');

define('GITHUB_API_VERSION', '2022-11-28');


$config = @require_once __DIR__ . '/github_pr_reviewers_webhook.config.php';

if (false === $config) {
	_error('Could not load config');
}

$secret = $config['secret'] ?? '';

if (empty($secret)) {
	_error('Empty secret configuration');
}

$reviewers = $config['reviewers'] ?? [ ];

if (empty($reviewers)) {
	_error('Empty reviewers configuration');
}

$token = $config['token'] ?? '';

if (empty($token)) {
	_error('Empty token configuration');
}


$event = $_SERVER['HTTP_X_GITHUB_EVENT'] ?? '';

if (empty($event)) {
	_error('GitHub event name not found', 400);
}


$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';

if (empty($signature)) {
	_error('Message signature not found', 400);
}

$expectedSignature = 'sha256=' . hash_hmac('sha256', file_get_contents('php://input'), $secret);

_debug('expectedSignature = ' . var_export($expectedSignature, true));
_debug('signature = ' . var_export($signature, true));

// Use `hash_equals()` instead of direct comparison to avoir timing attacks (@see https://www.php.net/manual/en/function.hash-equals.php)
if (! hash_equals($signature, $expectedSignature)) {
	_error('Invalid webhook signature', 401);
}

_debug('event = ' . var_export($event, true));

if ('pull_request' !== $event) {
	_out('Event ' . var_export($event, true) . ' not qualified');
	exit;
}

$rawPayload = $_REQUEST['payload'] ?? '';

if (empty($rawPayload)) {
	_error('Empty payload', 400);
}

// _debug('rawPayload = ' . var_export($payload, true));

$payload = json_decode($rawPayload, /* associative: */ true);

if (null === $payload) {
	_error('Could not decode payload, got ' . var_export($rawPayload, true) . ' in input');
}

// _debug('payload = ' . var_export($payload, true));


$targetBranch = $payload['pull_request']['base']['ref'] ?? null;

if (null === $targetBranch) {
	_error('Target branch not in payload');
}

_debug('targetBranch = ' . var_export($targetBranch, true));


if (! array_key_exists($targetBranch, $reviewers)) {
	_out('Target branch ' . var_export($targetBranch, true) . ' not qualified');
	exit;
}

$wantedReviewers = $reviewers[$targetBranch];

if (! is_array($wantedReviewers) && ! is_string($wantedReviewers)) {
	_error('Wanted reviewers incorrectly set in config for branch ' . var_export($targetBranch, true));
}

if (! is_array($wantedReviewers) && ! empty($wantedReviewers)) {
	$wantedReviewers = [ $wantedReviewers ];
}

if (empty($wantedReviewers)) {
	_out('Branch ' . var_export($targetBranch, true) . ' configured with no reviewers, not qualified');
	exit;
}

$action = $payload['action'] ?? null;

if (null === $action) {
	_error('Pull request action not in payload');
}


if ('opened' !== $action && 'edited' !== $action && 'ready_for_review' !== $action) {
	_out('Action ' . var_export($action, true) . ' not qualified');
	exit;
}


$isDraft = $payload['pull_request']['draft'] ?? null;

if (null === $isDraft) {
	_error('Pull request draft boolean not in payload');
}

if ('ready_for_review' !== $action && $isDraft) {
	_out('Pull request opened as draft : not qualified');
	exit;
}


$author = $payload['pull_request']['user']['login'] ?? null;

if (null === $author) {
	_error('Pull request author not in payload');
}

$currentReviewers = $payload['pull_request']['requested_reviewers'] ?? null;

if (null === $currentReviewers) {
	_error('Current reviewers not in payload');
}

// GitHub API returns an error 422 if we try to add the author as a reviewer, we have to filter them out
$reviewersToBeAdded = array_diff($wantedReviewers, $currentReviewers, empty($author) ? [ ] : [ $author ]);

if (empty($reviewersToBeAdded)) {
	_out('Reviewers already requested or author of the pull request : not qualified');
	exit;
}


_out('Webhook qualified');
_debug('Adding reviewers: ' . implode(', ', $reviewersToBeAdded));


$pullRequestUrl = $payload['pull_request']['url'] ?? null;

if (null === $pullRequestUrl) {
	_error('Pull request API URL not in payload');
}

$c = curl_init($pullRequestUrl . '/requested_reviewers');

if (false === $c) {
	_error('Could not init cURL');
}


$setMethodReturn = curl_setopt($c, CURLOPT_CUSTOMREQUEST, 'POST');

if (false === $setMethodReturn) {
	_error('Could not set request method: ' . curl_error($c));
}

$setHeadersReturn = curl_setopt($c, CURLOPT_HTTPHEADER, [
	'Accept: application/vnd.github+json',
	'Authorization: Bearer ' . $token,
	'X-GitHub-Api-Version: ' . GITHUB_API_VERSION,
	'User-Agent: dolibarr-github-webhook-handler/1.0 dolibarr/20250616', // PHP cURL implementation has no default User-Agent yet, and GitHub REST API requires one
	'Content-Type: application/json',
]);

if (false === $setHeadersReturn) {
	_error('Could not set request headers: ' . curl_error($c));
}

$setBodyReturn = curl_setopt($c, CURLOPT_POSTFIELDS, json_encode([
	'reviewers' => $reviewersToBeAdded,
	'team_reviewers' => [ ], // TODO
]));


/*
$setFailOnErrorReturn = curl_setopt($c, CURLOPT_FAILONERROR, true);

if (false === $setFailOnErrorReturn) {
	_error('Could not set fail on error: ' . curl_error($c));
}
 */

$setReturnTransferReturn = curl_setopt($c, CURLOPT_RETURNTRANSFER, true);

if (false === $setReturnTransferReturn) {
	_error('Could not set return transfer: ' . curl_error($c));
}

$response = curl_exec($c);

if (false === $response) {
	_error('Error handling cURL request: ' . curl_error($c));
}

$responseCode = curl_getinfo($c, CURLINFO_RESPONSE_CODE);

if (false === $responseCode) {
	_error('Error getting response code: ' . curl_error($c));
}

_debug('responseCode = ' . $responseCode);

if ($responseCode < 200 || $responseCode > 399) {
	_error('Error from GitHub API, code ' . $responseCode . ': ' . $response);
}

_out('Added the following reviewers: ' . implode(', ', $reviewersToBeAdded));

curl_close($c);


/**
 * Echoes a message with an EOL
 *
 * @param	string	$message	The message to print
 * @return	void
 */
function _out(string $message): void
{
	echo $message . PHP_EOL;
}

/**
 * Echoes a message if debug mode is enabled
 *
 * @param	string	$message	The message to print
 * @return	void
 */
function _debug(string $message): void
{
	if (isset($_REQUEST['debug'])) {
		_out($message);
	}
}

/**
 * Exits with an error message and an HTTP response code
 *
 * @param	string	$message	The message to print
 * @param	int		$status		HTTP response code
 * @return	void
 */
function _error(string $message, int $status = 500): void
{
	http_response_code($status);
	_out('Error: ' . $message);
	exit;
}