diff --git a/config-example.php b/config-example.php index 431cef9..22cbd3e 100644 --- a/config-example.php +++ b/config-example.php @@ -1,67 +1,82 @@ ClickUp bot # Copyright (C) 2023 Valerio Bozzolan, contributors # # 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 . // ClickUp Team ID - you can find it from your home URL // Example: "123" if your home URL is "https://app.clickup.com/123/v/l/li/456456456456" define( 'CLICKUP_TEAM_ID', 123 ); // URL exposing the "webhook-clickup" directory from this repository // NOTE: this MUST be a valid and public URL so ClickUp can use that as webhook // NOTE: the checkorigin token is just a random token you should invent define( 'CLICKUP_WEBHOOK_PUBLIC_ENDPOINT', 'https://example.com/clickup-phabricator-phorge-bot/webhook-clickup/?checkorigintoken=abcdefghilmpopqrstuvz123' ); +// URL exposing the "webhook-phabricator" directory from this repository +// NOTE: this MUST be a valid and public URL so ClickUp can use that as webhook +// NOTE: the checkorigin token is just a random token you should invent +define( 'PHABRICATOR_WEBHOOK_PUBLIC_ENDPOINT', 'https://example.com/clickup-phabricator-phorge-bot/webhook-phabricator/' ); + // obtained after registering the webhook using register-webhook.php define( 'CLICKUP_REGISTERED_WEBHOOK_ID', '123-123-123-123-123' ); // keep this in sync with the random token you invented in CLICKUP_WEBHOOK_PUBLIC_ENDPOINT define( 'CLICKUP_WEBHOOK_CHECKORIGINTOKEN', 'abcdefghilmpopqrstuvz123' ); +// the crypto Phabricator Webhook HMAC key obtained from the Herald > Webhooks page +define( 'PHABRICATOR_WEBHOOK_HMAC_KEY', 'abcdefghilmpopqrstuvz123' ); + // when true, CLICKUP_WEBHOOK_PUBLIC_ENDPOINT is compared to the one in CLICKUP_WEBHOOK_PUBLIC_ENDPOINT define( 'CLICKUP_VALIDATE_WEBHOOK', true ); +// when true, PHABRICATOR_WEBHOOK_PUBLIC_ENDPOINT is compared to the one in CLICKUP_WEBHOOK_PUBLIC_ENDPOINT +define( 'PHABRICATOR_VALIDATE_WEBHOOK', true ); + // https://app.clickup.com/36232585/settings/apps define( 'CLICKUP_API_TOKEN', 'pk_123_ASD123' ); // ClickUp cache (connecting its stuff to Phabricator, avoid creating duplicate tasks, etc.) define( 'CLICKUP_CACHE_JSON_FILE', __DIR__ . '/data/cache.json' ); // ClickUp cache pretty print define( 'CLICKUP_CACHE_JSON_PRETTY', true ); // Phabricator home URL (to call API) // MUST end with a slash define( 'PHABRICATOR_URL', 'https://sviluppo.erinformatica.it/' ); // path to the Arcanist library of your Phabricator installation (to call APIs) define( 'PHABRICATOR_ARCANIST_PATH', '/var/www/phabricator/arcanist/support/init/init-script.php' ); // Phabricator Conduit API token // at the moment it's in use the one from the user er.clickup.bot in ER Phabricator // https://sviluppo.erinformatica.it/p/er.clickup.bot/ // To generate a new one: // https://sviluppo.erinformatica.it/conduit/token/edit/15/ define( 'PHABRICATOR_CONDUIT_API_TOKEN', 'api-asd123' ); // validate the exact webhook URL // if it does not match it does nothing // this is useful if you put some very weird and secret query strings here: // CLICKUP_WEBHOOK_PUBLIC_ENDPOINT define( 'CLICKUP_VALIDATE_WEBHOOK', true ); +// comment prefix from Phabricator to ClickUp +// this is also used to identify comments posted FROM Phabricator TO ClickUp from our bot itself +define( 'COMMENT_PREFIX_FROM_PHABRICATOR', "From Phabricator:" ); + // homepage of the project // just to know where you can send bugs define( 'REPOSITORY_URL', 'https://gitpull.it/source/clickup-phabricator-bot/' ); diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php index 05bf5ff..53ff017 100644 --- a/include/class-ClickUpAPI.php +++ b/include/class-ClickUpAPI.php @@ -1,276 +1,289 @@ ClickUp bot # Copyright (C) 2023 Valerio Bozzolan, contributors # # 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 . /** * Utility to run HTTP queries against ClickUp */ class ClickUpAPI { /** * https://clickup.com/api/clickupreference/operation/GetSpaces/ * * @return array */ public static function getSpaces() { return self::querySpaces()->spaces ?? []; } /** * https://clickup.com/api/clickupreference/operation/GetSpaces/ * * @return array */ public static function getSpaceFolders( $space_id ) { return self::querySpaceFolders( $space_id )->folders ?? []; } /** * https://clickup.com/api/clickupreference/operation/GetTask/ */ public static function queryTaskData( $task_id ) { // append the Team ID since it does not work otherwise $task_details = self::requestGET( "/task/$task_id", self::query_team() ); // no Task no party if( !$task_details ) { throw new Exception( "missing ClickUp Task $task_id" ); } // save some stuff in the cache ClickUpPhabricatorCache::instance() ->newlock() ->importClickupTaskData( $task_id, $task_details ) ->save(); return $task_details; } /** * https://clickup.com/api/clickupreference/operation/UpdateTask/ */ public static function putTaskData( $task_id, $query = [], $payload = [] ) { $task_details = self::requestPUT( "/task/$task_id", $query, $payload ); ClickUpPhabricatorCache::instance() ->newlock() ->importClickupTaskData( $task_id, $task_details ) ->save(); return $task_details; } /** * Convert a ClickUp status to a Phabricator one * * Note that ClickUp has a "type" and a "status". * * @return string */ public static function taskStatusTypeToPhabricator( $type, $status = null ) { if( $type === 'closed' ) { return 'resolved'; } if( $type === 'open' ) { return $type; } if( $type === 'custom' ) { if( $status === 'in progress' ) { return 'doing'; } } return $status ?? $type; } /** * Register the ClickUp webhook */ public static function registerWebhook( $endpoint = null, $events = null ) { // assume the default endpoint if( !$endpoint) { $endpoint = CLICKUP_WEBHOOK_PUBLIC_ENDPOINT; } // assume all events if( !$events ) { $events = self::default_webhook_events(); } $payload = [ "endpoint" => $endpoint, "events" => $events, ]; $team_id = CLICKUP_TEAM_ID; return self::requestPOST( "/team/$team_id/webhook", [], $payload ); } + /** + * Add comment to a Task + * + * https://clickup.com/api/clickupreference/operation/CreateTaskComment/ + */ + public static function addTaskComment( $task_id, $comment_text ) { + $payload = [ + 'comment_text' => $comment_text, +// 'assignee' => 123, + ]; + return self::requestPOST( "/task/$task_id/comment", [], $payload ); + } + /** * https://clickup.com/api/clickupreference/operation/GetWebhooks/ */ public static function queryWebhooks() { $team_id = CLICKUP_TEAM_ID; $response = self::requestGET( "/team/{$team_id}/webhook", [], [] ); return $response->webhooks; } /** * https://clickup.com/api/clickupreference/operation/DeleteWebhook/ */ public static function deleteWebhook( $webhook_id = null ) { if( !$webhook_id ) { $webhook_id = CLICKUP_REGISTERED_WEBHOOK_ID; } $team_id = CLICKUP_TEAM_ID; return self::request( "DELETE", "/team/{$team_id}/webhook/{$webhook_id}", [], [] ); } /** * https://clickup.com/api/clickupreference/operation/UpdateWebhook/ */ public static function updateWebhook( $webhook_id = null, $endpoint = null, $events = [] ) { if( !$webhook_id ) { $webhook_id = CLICKUP_REGISTERED_WEBHOOK_ID; } if( !$endpoint ) { $endpoint = CLICKUP_WEBHOOK_PUBLIC_ENDPOINT; } $payload = [ 'endpoint' => $endpoint, ]; return self::requestPUT( "/webhook/{$webhook_id}", [], $payload ); } /** * https://clickup.com/api/clickupreference/operation/GetSpaces/ * * @return mixed */ private static function querySpaces() { $team_id = CLICKUP_TEAM_ID; return self::requestGET( "/team/$team_id/space" ); } /** * https://clickup.com/api/clickupreference/operation/GetFolders/ */ private static function querySpaceFolders( $space_id ) { $team_id = CLICKUP_TEAM_ID; return self::requestGET( "/space/$space_id/folder" ); } private static function request( $method, $path, $query = [], $payload = [] ) { $API_TOKEN = CLICKUP_API_TOKEN; $url = "https://api.clickup.com/api/v2" . $path; $full_url = $url . '?' . http_build_query( $query ); $curl_opts = [ CURLOPT_HTTPHEADER => [ "Authorization: $API_TOKEN", "Content-Type: application/json" ], CURLOPT_URL => $full_url, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, ]; // PUT requests if( $payload ) { $payload_raw = json_encode( $payload ); $curl_opts[CURLOPT_POSTFIELDS] = $payload_raw; } $curl = curl_init(); curl_setopt_array( $curl, $curl_opts ); $response = curl_exec( $curl ); $error = curl_error( $curl ); curl_close( $curl ); if( $error ) { throw new Exception( "cURL Error ClickUp #:" . $error ); } $data = json_decode( $response ); if( $data === false ) { throw new Exception( "cannot parse JSON of ClickUp response" ); } // formal error from ClickUp if( isset( $data->err ) ) { throw new Exception( sprintf( "ClickUp API error: %s", json_encode( $data ) ) ); } return $data; } public static function requestGET( $path, $query = [], $payload = [] ) { return self::request( "GET", $path, $query, $payload ); } public static function requestPUT( $path, $query, $payload = [] ) { return self::request( "PUT", $path, $query, $payload ); } public static function requestPOST( $path, $query, $payload = [] ) { return self::request( "POST", $path, $query, $payload ); } private static function query_team( $query = [] ) { $query[ 'team_id' ] = CLICKUP_TEAM_ID; return $query; } private static function default_webhook_events() { return [ "taskCreated", "taskUpdated", "taskDeleted", "taskPriorityUpdated", "taskStatusUpdated", "taskAssigneeUpdated", "taskDueDateUpdated", "taskTagUpdated", "taskMoved", "taskCommentPosted", "taskCommentUpdated", "taskTimeEstimateUpdated", "taskTimeTrackedUpdated", "listCreated", "listUpdated", "listDeleted", "folderCreated", "folderUpdated", "folderDeleted", "spaceCreated", "spaceUpdated", "spaceDeleted", "goalCreated", "goalUpdated", "goalDeleted", "keyResultCreated", "keyResultUpdated", "keyResultDeleted", ]; } } diff --git a/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php index da425b8..70fd623 100644 --- a/include/class-PhabricatorAPI.php +++ b/include/class-PhabricatorAPI.php @@ -1,163 +1,198 @@ ClickUp bot # Copyright (C) 2023 Valerio Bozzolan, contributors # # 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 . /** * Utility to run HTTP queries against Phabricator / Phorge */ class PhabricatorAPI { public static function query( $entry_point, $query = [] ) { $client = new ConduitClient( PHABRICATOR_URL ); $client->setConduitToken( PHABRICATOR_CONDUIT_API_TOKEN ); return $client->callMethodSynchronous( $entry_point, $query ); } /** * Simplified query to the maniphest.edit Conduit Phabricator API * * https://sviluppo.erinformatica.it/conduit/method/maniphest.edit/ * * @param $transaction_values array Example: [ 'title' => 'ASD' ] * @param $extra_query array Example: ['objectIdentifier' => 'PHID--...'] to edit * @return mixed */ public static function createTask( $transaction_values = [], $query = [] ) { // build transactions $query['transactions'] = []; foreach( $transaction_values as $key => $value ) { $query['transactions'][] = self::transaction( $key, $value ); } return self::query( 'maniphest.edit', $query ); } /** * Simplified query to the maniphest.edit Conduit Phabricator API * * https://sviluppo.erinformatica.it/conduit/method/maniphest.edit/ * * @param $id string Task ID (e.g. 123 for T123) * @param $transaction_values array Example: [ 'title' => 'ASD' ] * @return mixed */ public static function editTask( $id, $transaction_values = [] ) { return self::createTask( $transaction_values, [ 'objectIdentifier' => $id, ] ); } + /** + * Convert a Phabricator status to a Clickup status + */ + public static function status2clickup( $task_status ) { + // TODO: read from "maniphest.status.search" and set in cache and read from there + $closed_statuses = [ + 'resolved', + 'wontfix', + 'invalid', + 'duplicate', + 'spite', + ]; + if( in_array( $task_status, $closed_statuses, true ) ) { + return 'closed'; + } + return 'open'; + } + /** * Get a Task by its ID * * It returns just one element. * * https://sviluppo.erinformatica.it/conduit/method/maniphest.search/ * * @param $task_id mixed */ public static function getTask( $task_id ) { $task_id = self::sanitize_task_id( $task_id ); $query = [ 'constraints' => [ 'ids' => [ $task_id ], ], 'attachments' => [ 'columns' => true, 'projects' => true, ], ]; return self::searchSingleTask( $query ); } public static function getTaskByPHID( $phid ) { $query = [ 'constraints' => [ 'phids' => [ $phid ], ], 'attachments' => [ 'columns' => true, 'projects' => true, ], ]; return self::searchSingleTask( $query ); } + public static function searchSingleUserByPHID( $phid ) { + $query = [ + 'constraints' => [ + 'phids' => [ $phid ], + ], + ]; + return self::searchSingleUser( $query ); + } + public static function searchSingleTask( $query ) { - $results = self::query( 'maniphest.search', $query ); + return self::searchSingleResult( 'maniphest.search', $query ); + } + + public static function searchSingleUser( $query ) { + return self::searchSingleResult( 'user.search', $query ); + } + + public static function searchSingleResult( $method, $query ) { + + $results = self::query( $method, $query ); // just the first one is OK foreach( $results['data'] as $entry ) { return $entry; } - throw new Exception( "Phabricator Task not found: $task_id" ); + throw new Exception( "Phabricator result not found from $method using: " . json_encode( $query ) ); } public static function searchObjectTransactionsFromTransactions( $phab_object_id, $transactions ) { $transaction_phids = []; foreach( $transactions as $transaction ) { $transaction_phids[] = $transaction['phid']; } return self::searchObjectTransactionsFromPHIDs( $phab_object_id, $transaction_phids ); } public static function searchObjectTransactionsFromPHIDs( $phab_object_id, $transaction_phids ) { $query = [ 'objectIdentifier' => $phab_object_id, 'constraints' => [ - 'phids' => $phids, + 'phids' => $transaction_phids, ], ]; - - $results = self::query( 'maniphest.search', $query ); + $results = self::query( 'transaction.search', $query ); return $results['data']; } private static function transaction( $type, $value ) { return [ 'type' => $type, 'value' => $value, ]; } /** * Sanitize a Task ID * * @param $task_id mixed * @return int */ public static function sanitize_task_id( $task_id ) { // strip the damn 'T' since the 'id' API only accepts numeric $task_id = ltrim( $task_id, 'T' ); // no numeric no party $task_id = (int)$task_id; if( !$task_id ) { throw new Exception( "invalid Task ID" ); } return $task_id; } } diff --git a/public/webhook-clickup/index.php b/public/webhook-clickup/index.php index 687a372..65898a2 100644 --- a/public/webhook-clickup/index.php +++ b/public/webhook-clickup/index.php @@ -1,176 +1,199 @@ ClickUp bot # Copyright (C) 2023 Valerio Bozzolan, contributors # # 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 . /** * Receive ClickUp webhooks * * Documentation: * https://clickup.com/api/developer-portal/webhooktaskpayloads/ */ // default response code http_response_code( 500 ); require realpath( __DIR__ ) . '/../autoload.php'; $response = file_get_contents('php://input'); $response_data = json_decode( $response ); $event = $response_data->event ?? null; $clickup_task_id = $response_data->task_id ?? null; $webhook_id = $_SERVER['HTTP_X_SIGNATURE'] ?? null; // find history items (and the first one) $history_items = $response_data->history_items ?? []; $history_item_first = null; foreach( $history_items as $history_item_first ) { break; } // check incoming request if( CLICKUP_VALIDATE_WEBHOOK ) { // TODO //if( $webhook_id !== CLICKUP_REGISTERED_WEBHOOK_ID ) { // throw new Exception( sprintf( "unknown webhook ID: %s", $webhook_id ) ); //} $checkorigintoken = $_GET['checkorigintoken'] ?? null; if( $checkorigintoken !== CLICKUP_WEBHOOK_CHECKORIGINTOKEN ) { echo "Invalid check origin token\n"; http_response_code( 400 ); exit; } } $cache = ClickUpPhabricatorCache::instance(); switch( $event ) { case 'taskCreated': // check if we already know about it $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id ) { // probably Phabricator already created it from the Phabricator webhook error_log( "skip new ClickUp Task $clickup_task_id: already imported in Phab as $phab_task_id" ); } else { // let's create the Task // get more ClickUp info (title and description etc.) and remember in cache $clickup_task_details = ClickUpAPI::queryTaskData( $clickup_task_id ); // TODO: move in the QueryTaskData some validation on these $clickup_task_title = $clickup_task_details->name; $clickup_task_descr = $clickup_task_details->description; $clickup_task_folder = $clickup_task_details->folder; $clickup_task_folder_id = $clickup_task_folder->id; $clickup_task_folder_name = $clickup_task_folder->name; if( !$clickup_task_title ) { throw new Exception( "missing title in new ClickUp Task" ); } // prepare description for new Task in Phabricator $phab_task_description = $clickup_task_descr; $phab_task_description .= "\n\n"; $phab_task_description .= "> https://app.clickup.com/t/$clickup_task_id"; $phab_task_args = []; // try to assign to the right project $phab_task_project = $cache->getPhabricatorTagIDFromClickupFolderID( $clickup_task_folder_id ); if( $phab_task_project ) { // assign to the right project $phab_task_args['projects.add'] = [ $phab_task_project ]; } else { // or just give an hint $clickup_task_title = "[$clickup_task_folder_name] $clickup_task_title"; } $phab_task_args['title'] = $clickup_task_title; $phab_task_args['description'] = $phab_task_description; // create Task in Phabricator $phab_task_data = PhabricatorAPI::createTask( $phab_task_args ); $phab_task_phid = $phab_task_data['object']['phid']; // TASK-PHID-00000000 $phab_task_id = $phab_task_data['object']['id']; // 123 for T123 // associate the Phabricator Task to the Conduit Task in the cache $cache->newlock()->registerClickupPhabricatorTaskID( $clickup_task_id, $phab_task_id )->save(); // update Click-Up adding the Phabricator URL $phab_task_url = PHABRICATOR_URL . "T{$phab_task_id}"; ClickUpAPI::putTaskData( $clickup_task_id, [], [ 'description' => "$clickup_task_descr\n\n$phab_task_url", ] ); } break; case 'taskDeleted': - // mark as invalid in Phabricator + // if it's deleted in ClickUp, close as invalid in Phabricator $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id ) { PhabricatorAPI::editTask( $phab_task_id, [ 'status' => 'invalid', ] ); } - // drop from cache + // drop ClickUp Task from internal cache $cache->removeClickupTask( $clickup_task_id ); break; case 'taskCommentPosted': // post the comment on Phabricator too $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id && $history_item_first ) { $clickup_comment_author_name = $history_item_first->user->username; $clickup_comment_author_id = $history_item_first->user->id; $clickup_task_description = $history_item_first->comment->text_content; - $phab_comment = "From **{$clickup_comment_author_name}**:\n\n" . $clickup_task_description; - $phab_task_data = PhabricatorAPI::editTask( $phab_task_id, [ - 'comment' => $phab_comment, - ] ); + // avoid to post on Phabricator, comments that are coming from our Phabricator bot + if( strpos( $clickup_task_description, COMMENT_PREFIX_FROM_PHABRICATOR ) === 0 ) { + + error_log( "skip ClickUp comment that was posted by the Phabricator bot" ); + + } else { + + // add a nice comment to Phabricator + $phab_comment = "From **{$clickup_comment_author_name}**:\n\n" . $clickup_task_description; + $phab_task_data = PhabricatorAPI::editTask( $phab_task_id, [ + 'comment' => $phab_comment, + ] ); + } } break; case 'taskStatusUpdated': + // check i the Phabricator Task was already connected to this ClickUp Task $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id && $history_item_first ) { $clickup_task_status = $history_item_first->data->status_type; - $phab_task_status_new = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task_status ); - if( $phab_task_status_new === 'resolved' || $phab_task_status_new === 'open' ) { - PhabricatorAPI::editTask( $phab_task_id, [ - 'status' => $phab_task_status_new, - ] ); + $clickup_task_status_for_phab = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task_status ); + if( $clickup_task_status_for_phab === 'resolved' || $clickup_task_status_for_phab === 'open' ) { + + // fetch fresh Phabricator Task data + $phab_task = PhabricatorAPI::getTask( $phab_task_id ); + $phab_task_status = $phab_task['fields']['status']['value']; + + // update Phab only if the status changes + if( $phab_task_status !== $clickup_task_status_for_phab ) { + + error_log( "ClickUp Task $clickup_task_id status: $clickup_task_status_for_phab - Phab Task $phab_task_id status: $phab_task_status - updating" ); + + // set the ClickUp Task status in Phabricator as well + PhabricatorAPI::editTask( $phab_task_id, [ + 'status' => $clickup_task_status_for_phab, + ] ); + } + } else { - error_log( "do nothing for status: $phab_task_status_new" ); + error_log( "do nothing for status: $clickup_task_status (Phab: $clickup_task_status_for_phab)" ); } } break; default: echo "Method not supported.\n"; error_log( "unsupported ClickUp webhoook: $event" ); } // success http_response_code( 200 ); diff --git a/public/webhook-phabricator/index.php b/public/webhook-phabricator/index.php new file mode 100644 index 0000000..d026b7d --- /dev/null +++ b/public/webhook-phabricator/index.php @@ -0,0 +1,169 @@ + ClickUp bot +# Copyright (C) 2023 Valerio Bozzolan, contributors +# +# 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 . + +/** + * Receive Phabricator webhooks + * + * Documentation: + * https://we.phorge.it/conduit/ + * + * See also Herald rule. + */ + +// default response code +http_response_code( 500 ); + +// load needed libraries +require realpath( __DIR__ ) . '/../autoload.php'; + +// parse HTTP payload +$response = file_get_contents('php://input'); + +// parse as an array since native Conduit also handles everything as an array +$response_data = json_decode( $response, JSON_OBJECT_AS_ARRAY ); + +// check incoming request +if( PHABRICATOR_VALIDATE_WEBHOOK ) { + + // no signature no party + $signature_header = $_SERVER['HTTP_X_PHABRICATOR_WEBHOOK_SIGNATURE'] ?? null; + if( !$signature_header ) { + http_response_code( 400 ); + echo "This is not a request from an official Phabricator webhook.\n"; + exit; + } + + // do the math + $signature_calculated = hash_hmac( 'sha256', $response, PHABRICATOR_WEBHOOK_HMAC_KEY ); + if( $signature_calculated !== $signature_header ) { + http_response_code( 400 ); + error_log( "invalid signature, calculated $signature_calculated" ); + exit; + } +} + +// no object no party +$object = $response_data['object'] ?? null; +if( !$object ) { + http_response_code( 400 ); + throw new Exception( "received empty Phabricator object" ); +} + +$transactions = $response_data['transactions'] ?? []; + +$object_type = $object['type']; +$object_phid = $object['phid']; + +$cache = ClickUpPhabricatorCache::instance(); + +switch( $object_type ) { + + case 'TASK': + + // query fresh Task data + $phab_task = PhabricatorAPI::getTaskByPHID( $object_phid ); + if( !$phab_task ) { + throw new Exception( "missing Task: {$object_phid}" ); + } + + $clickup_changes = []; + + // resolved / open / etc. + $phab_task_status = $phab_task['fields']['status']['value']; + $phab_task_id = $phab_task['id']; + $phab_task_phid = $phab_task['phid']; + + // check if the Phabricator Task is already known to be connected to ClickUp + $clickup_task = null; + $clickup_task_cache = $cache->getClickupTaskFromPhabricatorTaskID( $phab_task_id ); + if( $clickup_task_cache ) { + $clickup_task = ClickUpAPI::queryTaskData( $clickup_task_cache->id ); + } + + // no related ClickUp Task? + if( !$clickup_task ) { + + // TODO: find Task projects + $phab_projects = []; + + // TODO: find the related ClickUp folder + $clickup_folder = null; + + error_log( "Phabricator Task T{$phab_task_id} has not a related ClickUp Task" ); + } + + if( $clickup_task ) { + // check if Phabricator has a different Task status than ClickUp + $clickup_task_status_for_phab = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task->status ); + if( $clickup_task_status_for_phab !== $phab_task_status ) { + + // update the Task status in ClickUp + $clickup_changes[ 'status' ] = PhabricatorAPI::status2clickup( $phab_task_status ); + } + } + + $phab_fields = $phab_task['fields']; + $phab_attachments = $phab_task['attachments']; + $phab_project_phids = $phab_attachments['projects']['projectPHIDs']; + + if( $transactions ) { + $transaction_results = PhabricatorAPI::searchObjectTransactionsFromTransactions( $phab_task_phid, $transactions ); + foreach( $transaction_results as $transaction_result ) { + + // found some new Phabricator comments + $phab_comments = $transaction_result['comments'] ?? []; + foreach( $phab_comments as $phab_comment ) { + + // Phabricator comment content + $comment_content_raw = $phab_comment['content']['raw']; + + // Phabricator author (NOTE: it MUST exists) + $comment_author = PhabricatorAPI::searchSingleUserByPHID( $phab_comment['authorPHID'] ); + $comment_author_name = $comment_author['fields']['realName']; + + // post the comment on ClickUp + if( $clickup_task ) { + // "From Phabricator: Mario Rossi:\n\nHello world!" + // IMPORTANT: keep the COMMENT_PREFIX_FROM_PHABRICATOR at startup since now + // the ClickUp webhook controls this token at startup. + $clickup_comment = sprintf( + "%s %s:\n\n%s", + COMMENT_PREFIX_FROM_PHABRICATOR, + $comment_author_name, + $comment_content_raw + ); + ClickupAPI::addTaskComment( $clickup_task->id, $clickup_comment ); + } + } + } + } + + // eventally update ClickUp with some changes + if( $clickup_task && $clickup_changes ) { + ClickUpAPI::putTaskData( $clickup_task->id, [], $clickup_changes ); + } + + break; + + case 'HWBH': + error_log( "received Phabricator Webhook (test?)" ); + break; + +} + +// success +http_response_code( 200 );