diff --git a/cli/clickup-webhook-delete.php b/cli/clickup-webhook-delete.php new file mode 100755 index 0000000..c04bc8a --- /dev/null +++ b/cli/clickup-webhook-delete.php @@ -0,0 +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 + */ + +// 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 new file mode 100755 index 0000000..f65cd62 --- /dev/null +++ b/cli/clickup-webhook-list.php @@ -0,0 +1,30 @@ +#!/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 + */ + +// 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 new file mode 100755 index 0000000..5bba1a7 --- /dev/null +++ b/cli/clickup-webhook-register.php @@ -0,0 +1,27 @@ +#!/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 + */ + +// autoload libraries +require __DIR__ . '/../autoload.php'; + +// yeah +ClickUpAPI::registerWebhook(); diff --git a/cli/clickup-webhook-update.php b/cli/clickup-webhook-update.php new file mode 100755 index 0000000..ebec265 --- /dev/null +++ b/cli/clickup-webhook-update.php @@ -0,0 +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 + */ + +// autoload libraries +require __DIR__ . '/../autoload.php'; + +$result = ClickUpAPI::updateWebhook(); + +print_r( $result ); diff --git a/config-example.php b/config-example.php index e49f84b..66ed01e 100644 --- a/config-example.php +++ b/config-example.php @@ -1,53 +1,67 @@ ClickUp bot -# Copyright (C) 2022 Valerio Bozzolan, contributors +# 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 "clickup-webhook" 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/clickup-webhook/?checkorigintoken=abcdefghilmpopqrstuvz123' ); + // 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' ); + +// when true, CLICKUP_WEBHOOK_PUBLIC_ENDPOINT is compared to the one in CLICKUP_WEBHOOK_PUBLIC_ENDPOINT +define( 'CLICKUP_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' ); -// not supported already :D sorry -define( 'CLICKUP_VALIDATE_WEBHOOK', false ); +// 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 ); // 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 ed7f288..c77aee1 100644 --- a/include/class-ClickUpAPI.php +++ b/include/class-ClickUpAPI.php @@ -1,156 +1,254 @@ 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; } + /** + * 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 ); + } + + /** + * 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/public/clickup-webhook/index.php b/public/clickup-webhook/index.php index f80be04..d98a4f5 100644 --- a/public/clickup-webhook/index.php +++ b/public/clickup-webhook/index.php @@ -1,114 +1,123 @@ event ?? null; $clickup_task_id = $response_data->task_id ?? null; $webhook_id = $_SERVER['HTTP_X_SIGNATURE'] ?? null; // check incoming request -if( CLICKUP_VALIDATE_WEBHOOK && $webhook_id !== CLICKUP_REGISTERED_WEBHOOK_ID ) { - echo "Unknown webhook signature\n"; - throw new Exception( sprintf( "unknown webhook ID: %s", $webhook_id ) ); +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"; + exit; + } } $cache = ClickUpPhabricatorCache::instance(); switch( $event ) { case 'taskCreated': // 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 $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id ) { PhabricatorAPI::editTask( $phab_task_id, [ 'status' => 'invalid', ] ); } // drop from 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 ) { $phab_comment = "Test:\n\n"; $phab_task_data = PhabricatorAPI::editTask( $phab_task_id, [ 'comment' => $phab_comment, ] ); } break; // case 'taskUpdated': // break; default: echo "Method not supported.\n"; error_log( "unsupported ClickUp webhoook: $event" ); } $cache->save();