diff --git a/README.md b/README.md index 6b4077b..83f4fc3 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,52 @@ # ClickUp <-> Phabricator / Phorge bot Bot to help migration from ClickUp to Phabricator / Phorge (and vice-versa honestly). ## Available features * automatically import new ClickUp Tasks in Phabricator (with title, description) * automatically update the ClickUp Task to mention the Phabricator Task URL * automatically update the Phabricator Task to mention the ClickUp Task URL * automatically assign the right Phabricator Tag (associated to the corresponding ClickUp folder) * automatically import new ClickUp Task comments into their related Phabricator Task ## Dev preparation ``` sudo apt install php-cli composer ``` ## Dev installation ``` composer install ``` ## Configuration Copy the file `config-example.php` to `config.php`. +Read carefully the example configuration file. + ## License Copyright (C) 2023 Valerio Bozzolan, contributors This software was developed and tested and released in non-working hours so the copyright does NOT belong to the company. Any company can use, study, modify, share this software (as long as the terms are respected). Any company can do this, included ER Informatica, without Valerio's written consent. You're welcome. 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 . diff --git a/cli/clickup-webhook-delete.php b/cli/clickup-webhook-delete.php index c04bc8a..8e0e3b3 100755 --- a/cli/clickup-webhook-delete.php +++ b/cli/clickup-webhook-delete.php @@ -1,28 +1,28 @@ #!/usr/bin/php 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 . /** * Delete a previously-registered webhook from ClickUp + * + * https://clickup.com/api/clickupreference/operation/DeleteWebhook/ */ // autoload libraries require __DIR__ . '/../autoload.php'; $result = ClickUpAPI::deleteWebhook(); - -print_r( $result ); diff --git a/cli/clickup-webhook-list.php b/cli/clickup-webhook-list.php index f65cd62..8fb0c8b 100755 --- a/cli/clickup-webhook-list.php +++ b/cli/clickup-webhook-list.php @@ -1,30 +1,32 @@ #!/usr/bin/php 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 . /** - * Register the ClickUp webhook to receive info + * List any registered ClickUp webhook + * + * https://clickup.com/api/clickupreference/operation/GetWebhooks/ */ // autoload libraries require __DIR__ . '/../autoload.php'; // yeah $webhooks = ClickUpAPI::queryWebhooks(); foreach( $webhooks as $webhook ) { print_r( $webhook ); } diff --git a/cli/clickup-webhook-register.php b/cli/clickup-webhook-register.php index 5bba1a7..1e95c97 100755 --- a/cli/clickup-webhook-register.php +++ b/cli/clickup-webhook-register.php @@ -1,27 +1,35 @@ #!/usr/bin/php 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 . /** * Register the ClickUp webhook to receive info + * + * https://clickup.com/api/clickupreference/operation/CreateWebhook/ */ // autoload libraries require __DIR__ . '/../autoload.php'; // yeah -ClickUpAPI::registerWebhook(); +$result = ClickUpAPI::registerWebhook(); + +print_r( $result ); + +echo "Please update your configuration setting these:\n"; +echo "CLICKUP_REGISTERED_WEBHOOK_ID: {$result->id}\n"; +echo "CLICKUP_WEBHOOK_HMAC_KEY: {$result->webhook->secret}\n"; diff --git a/config-example.php b/config-example.php index 22cbd3e..de94423 100644 --- a/config-example.php +++ b/config-example.php @@ -1,82 +1,84 @@ 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' ); +// ClickUp API token +// +// https://clickup.com/api/developer-portal/authentication#personal-token +define( 'CLICKUP_API_TOKEN', 'pk_123_ASD123' ); + +// ClickUp Webhook ID +// +// You obtain this after registering your webhook using this script: +// ./cli/clickup-webhook-create.php +// You can always see this value using the tool: +// ./cli/clickup-webhook-list.php +// This is only used to be able to quickly delete your webhook using the related script: +// ./cli/clickup-webhook-delete.php +define( 'CLICKUP_REGISTERED_WEBHOOK_ID', '123-123-123-123-123' ); + +// ClickUp HMAC secret key +// +// The ClickUp HMAC "secret" key provided from the ClickUp webhook, after you create it. +// You can always see this value using the tool: +// ./cli/clickup-webhook-list.php +// Documentation: +// https://clickup.com/api/developer-portal/webhooksignature/ +define( 'CLICKUP_WEBHOOK_HMAC_KEY', '123ASD123ASD123ASD123ASD123ASD123ASD123ASD123ASD123ASD' ); + +// Phabricator Conduit API token +// You can create this token by creating a simple User of type = bot +// in your Phabricator and then going to this page (from that user): +// https://yourphabricator/conduit/token/ +define( 'PHABRICATOR_CONDUIT_API_TOKEN', 'api-123asd123asd123asd123asd123' ); + +// 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 home URL (to call API) +// MUST end with a slash +define( 'PHABRICATOR_URL', 'https://yourphabricator/' ); // 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' ); +// 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' ); // 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 1f58735..2e95709 100644 --- a/include/class-ClickUpAPI.php +++ b/include/class-ClickUpAPI.php @@ -1,341 +1,355 @@ 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 ?? []; } /** * Get all the known space folders * * It also saves some info in the cache * * https://clickup.com/api/clickupreference/operation/GetSpaces/ * * @return array */ public static function getSpaceFolders( $space_id ) { // request $folders = self::querySpaceFolders( $space_id ); // import results $cache = ClickUpPhabricatorCache::instance(); $cache->lock(); foreach( $folders as $folder ) { $cache->importClickupFolder( $folder ); } $cache->commit(); // expose return $folders; } /** * https://clickup.com/api/clickupreference/operation/GetTask/ */ public static function queryTaskData( $task_id ) { try { // try to fetch fresk ClickUp Task info $task_details = self::requestGET( "/task/$task_id", self::query_team() ); } catch( ClickupExceptionTaskNotFound $e ) { // drop unuseful stuff from the cache ClickUpPhabricatorCache::instance() ->lock() ->removeClickupTask( $task_id ) ->commit(); return false; } // save some stuff in the cache ClickUpPhabricatorCache::instance() ->lock() ->importClickupTaskData( $task_id, $task_details ) ->commit(); 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() ->lock() ->importClickupTaskData( $task_id, $task_details ) ->commit(); return $task_details; } + /** + * https://clickup.com/api/clickupreference/operation/CreateTask/ + */ + public static function createTaskOnList( $list_id, $payload = [] ) { + + $task_details = self::requestPOST( "/list/$list_id", [], $payload ); + + ClickUpPhabricatorCache::instance() + ->lock() + ->importClickupTaskData( $task_details->id, $task_details ) + ->commit(); + + return $task_details; + } + /** * Convert a ClickUp status to a Phabricator one * * Note that ClickUp has a "type" and a "status". * * @return boolean */ public static function isStatusClosed( $type, $status = null ) { if( $type === 'closed' ) { return true; } if( $type === 'open' ) { return false; } // "In progress" etc. return false; } /** * 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}", [], [] ); + return self::request( "DELETE", "/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" )->folders; } 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 ) ) { // spawn a nice exact exception if possible switch( $data->ECODE ) { case 'ITEM_013': throw new ClickupExceptionTaskNotFound( $data->err ); default: throw new Exception( sprintf( "ClickUp API error: %s from URL: %s payload: %s", json_encode( $data ), $full_url, json_encode( $payload ) ) ); } } 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/public/webhook-clickup/index.php b/public/webhook-clickup/index.php index 00a75e1..02e4213 100644 --- a/public/webhook-clickup/index.php +++ b/public/webhook-clickup/index.php @@ -1,219 +1,218 @@ 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 ) ); - //} +// no signature no party +$signature_header = $_SERVER['HTTP_X_SIGNATURE'] ?? null; +if( !$signature_header ) { + http_response_code( 400 ); + echo "This is not a request from an official ClickUp webhook.\n"; + exit; +} - $checkorigintoken = $_GET['checkorigintoken'] ?? null; - if( $checkorigintoken !== CLICKUP_WEBHOOK_CHECKORIGINTOKEN ) { - echo "Invalid check origin token\n"; - http_response_code( 400 ); - exit; - } +// do the math +$signature_calculated = hash_hmac( 'sha256', $response, CLICKUP_WEBHOOK_HMAC_KEY ); +if( $signature_calculated !== $signature_header ) { + http_response_code( 400 ); + throw new Exception( "invalid signature, calculated $signature_calculated expected $signature_header" ); + 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"; } // try to assign to an user $clickup_assignes = $clickup_task_details->assignees; foreach( $clickup_assignes as $clickup_assigne ) { $phab_assignee_phid = $cache->getClickUpUserPHIDByID( $clickup_assigne->id ); if( $phab_assignee_phid ) { $phab_task_args['owner'] = $phab_assignee_phid; break; } } $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 ->lock() ->registerClickupPhabricatorTaskID( $clickup_task_id, $phab_task_id ) ->commit(); // 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': // 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 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; // 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 ) { // fetch fresh Phabricator Task data $phab_task = PhabricatorAPI::getTask( $phab_task_id ); $phab_task_status = $phab_task['fields']['status']['value']; $clickup_task_status = $history_item_first->data->status_type; $clickup_task_status_for_phab = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task_status ); // check if Phabricator has a different Task status than ClickUp $clickup_task_is_closed = ClickupAPI::isStatusClosed( $clickup_task_status ); $phab_task_is_closed = PhabricatorAPI::isStatusClosed( $phab_task_status ); if( $clickup_task_is_closed !== $phab_task_is_closed ) { // 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 (-> $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( "ClickUp skip status $clickup_task_status (Phab was $phab_task_status)" ); } } 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 index 2005873..f99b616 100644 --- a/public/webhook-phabricator/index.php +++ b/public/webhook-phabricator/index.php @@ -1,172 +1,186 @@ 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 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 ); + throw new Exception( "invalid signature, calculated $signature_calculated expected $signature_header" ); + 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']; + $phab_task_id = $phab_task['id']; + $phab_task_phid = $phab_task['phid']; + $phab_project_phids = $phab_task['attachments']['projects']['projectPHIDs']; + $phab_task_status = $phab_task['fields']['status']['value']; // 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 = []; + // check if this is recent + $phab_task_data_created = $phab_task['fields']['dateCreated']; + $time_diff = time() - $phab_task_data_created; + if( $time_diff < 240 ) { + // create in ClickUp as well + + $clickup_folder = $cache->findClickupFolderByPhabricatorTagPHIDs( $phab_project_phids ); + + error_log( "Phabricator Task T{$phab_task_id} has not a related ClickUp Task and has a folder (find its list: to be implemented)" ); - // TODO: find the related ClickUp folder - $clickup_folder = null; + if( $clickup_folder ) { - error_log( "Phabricator Task T{$phab_task_id} has not a related ClickUp Task" ); +// $clickup_list_id = "TODO"; +// $clickup_task = ClickUpAPI::createTaskOnList( $clickup_list_id, [ +// 'name' => '', +// 'description' => '', +// 'assignees' => [], +// 'parent' => '', +// ] ); + + } else { + + } + } else { + error_log( "Phabricator Task T{$phab_task_id} has not a related ClickUp Task and it's too old ($time_diff seconds)" ); + } } if( $clickup_task ) { // check if Phabricator has a different Task status than ClickUp $phab_task_is_closed = PhabricatorAPI::isStatusClosed( $phab_task_status ); $clickup_task_is_closed = ClickupAPI::isStatusClosed( $clickup_task->status->type ); if( $clickup_task->status->type !== $clickup_task_is_closed ) { // 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; + default: + error_log( "unknown Phabricator webhook: {$response}" ); } // success http_response_code( 200 );