diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php index c77aee1..05bf5ff 100644 --- a/include/class-ClickUpAPI.php +++ b/include/class-ClickUpAPI.php @@ -1,254 +1,276 @@ 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 ); } /** * 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-ClickUpPhabricatorCache.php b/include/class-ClickUpPhabricatorCache.php index 09992dc..437b626 100644 --- a/include/class-ClickUpPhabricatorCache.php +++ b/include/class-ClickUpPhabricatorCache.php @@ -1,469 +1,473 @@ 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 . /** * A simple but robust cache system to store stuff about ClickUp/Phabricator * * Workflow: * lock()->...->save()->close() */ class ClickUpPhabricatorCache { /** * Cache file pathname * * @param string */ private $file; /** * File pointer * * NULL: virgin * false: error * other: valid file pointer * * @var mixed */ private $fp; /** * Check if we are under a file lock */ private $locked; /** * Cache content * * @var mixed */ private $cache; /** * This singleton instance * * @var self */ private static $_instance; /** * Get the singleton instance * * @return self */ public static function instance() { // instantiate only once if( !self::$_instance ) { self::$_instance = new self(); } return self::$_instance; } /** * Constructor * * Please DON'T call this directly. * Use the instance() static method instead. * * @param $file string */ public function __construct( $file = null ) { // take default from global configuration if( !$file ) { $file = CLICKUP_CACHE_JSON_FILE; } $this->file = $file; } /** * Destructor * * It automatically closes any file pointer. * NOTE: it does not write anything. */ function __destruct() { $this->close(); } /** * Acquire a new lock to read and then write * * @return self */ public function newlock() { return $this->close()->lock(); } /** * Assure a lock on the file * * @return self */ public function lock() { // no need to lock twice if( !$this->locked ) { $this->forceLock(); } // make chainable return $this; } /** * Write the cache to filesystem in a secure way * * NOTE: this does NOT close and does not free any lock. * You may need to call close() after this. * @return self */ public function save() { return $this->lock()->write()->close(); } /** * Close the file, freeing any related file lock * * @return self */ public function close() { // no need to close twice if( $this->fp ) { fclose( $this->fp ); } // mark resources as free to be re-used again $this->fp = null; $this->cache = null; $this->locked = null; // make chainable return $this; } public function getAllClickupTasks() { $cache = $this->getCache(); if( !isset( $cache->clickupTasks ) ) { $cache->clickupTasks = new StdClass(); } return $cache->clickupTasks; } public function getClickupTask( $click_id ) { // get all indexed tasks $tasks = $this->getAllClickupTasks(); // get or create one Task $tasks->{ $click_id } = $tasks->{ $click_id } ?? new StdClass(); $task = $tasks->{ $click_id }; // get or create task name // $task->name = $task->name ?? null; // get or create its folder $task->folder = $task->folder ?? new StdClass(); // get or create one Tasks's Phabricator data $task->phabData = $task->phabData ?? new StdClass(); $phab_data = $task->phabData; // get or create one Tasks's Phabricator data ID $phab_data->id = $phab_data->id ?? ""; return $task; } public function removeClickupTask( $click_id ) { $tasks = $this->getAllClickupTasks(); unset( $tasks->{ $click_id } ); } /** * Import a ClickUp Task having its ID and its data * retrieved from its API * * @param $click_id string * @param $data array * @return self */ public function importClickupTaskData( $click_id, $data ) { $task = $this->getClickUpTask( $click_id ); // import name or leave as-is // $task->name = $data->name ?? null; // import useful fields // NOTE: folder always exists here $task->folder->id = $data->folder->id ?? null; // $task->folder->name = $data->folder->name ?? null; // try to read Phabricator data (so the cache is created at least once) $this->getClickUpTaskPhabricatorData( $click_id ); // let's also import the related folder info $this->importClickUpFolder( $data->folder ); // make chainable return $this; } public function getClickupTaskPhabricatorData( $click_id ) { // NOTE: phabData always exists return $this->getClickUpTask( $click_id )->phabData; } public function getClickupTaskPhabricatorID( $click_id ) { // NOTE: ID always exists return $this->getClickupTaskPhabricatorData( $click_id )->id; } public function registerClickupPhabricatorTaskID( $click_id, $phab_id ) { $phab_data = $this->getClickUpTaskPhabricatorData( $click_id ); $phab_data->id = $phab_id; // make chainable return $this; } public function getClickUpFolders() { $cache = $this->getCache(); $cache->clickupFolders = $cache->clickupFolders ?? new StdClass(); return $cache->clickupFolders; } public function getClickupFolderByID( $id ) { $folders = $this->getClickupFolders(); $folders->{ $id } = $folders->{ $id } ?? new StdClass(); $folder = $folders->{ $id }; $folder->phabData = $folder->phabData ?? new StdClass(); $folder->phabData->id = $folder->phabData->id ?? ""; $folder->phabData->phid = $folder->phabData->phid ?? ""; return $folder; } + public function getPhabricatorTagPHIDFromClickupFolderID( $click_id ) { + return $this->getClickupFolderByID( $click_id )->phabData->phid; + } + public function getPhabricatorTagIDFromClickupFolderID( $click_id ) { return $this->getClickupFolderByID( $click_id )->phabData->id; } public function getPhabricatorTagFromClickupFolderID( $id ) { $folder = $this->getClickupFolderByID( $id ); return $folder->phabData->id; } public function importClickupFolder( $data ) { $id = $data->id; $folder = $this->getClickupFolderByID( $id ); $folder->id = $data->id; $folder->name = $data->name; // make chainable return $this; } /** * Get the cache content * * @return mixed */ public function getCache() { return $this->read()->cache; } /** * Open the file * * @return self */ public function open() { // just assure that there is a file pointer $this->getFilePointer(); // make chainable return $this; } /** * Get the file pointer to the cache file * * This does NOT lock anything. You have to call lock() if you need. * * @return self */ private function getFilePointer() { // avoid to open twice if( $this->virginFilePointer() ) { // open the file if it already exists // create the file if it does not exist $this->fp = @fopen( $this->file, 'c+' ); if( !$this->fp ) { $this->throwFileException( "cannot open file" ); } } return $this->fp; } /** * Check if the file pointer was never allocated * * @return bool */ private function virginFilePointer() { return $this->fp === null; } /** * Read and parse * * This method automatically open the file. * This method is safe to be called multiple times. * * @return self */ private function read() { // read just at startup or after any close if( !$this->cache ) { $this->forceRead(); } // make chainable return $this; } /** * Force reading the content of the file */ private function forceRead() { $fp = $this->getFilePointer(); // read content from opened file $content = ''; while( !feof( $fp ) ) { // read in chunks $part = fread( $fp, 8192 ); // no chunk no party if( $part === false ) { $this->throwFileException( "cannot read file" ); } $content .= $part; } // assume at least a valid empty JSON if( !$content ) { $content = '{}'; } // try to parse content $data = @json_decode( $content ); if( $data === false ) { $this->throwFileException( "cannot parse JSON" ); } // remember parser data $this->cache = $data; } /** * Force a lock on the file * * @param bool */ public function forceLock() { $fp = $this->getFilePointer(); $locked = @flock( $fp, LOCK_EX ); if( !$locked ) { $this->throwFileException( "cannot acquire lock" ); } $this->locked = true; } /** * Write the cache on the filesystem * * @return self */ private function write() { $flags = 0; if( CLICKUP_CACHE_JSON_PRETTY ) { $flags = JSON_PRETTY_PRINT; } $fp = $this->getFilePointer(); // encode the cache in JSON $raw = @json_encode( $this->getCache(), $flags ); if( $raw === false ) { throw new Exception( "cannot JSON encode" ); } // clear the file $ok = @ftruncate( $fp, 0 ); if( !$ok ) { $this->throwFileException( "cannot truncate" ); } // turn back to the start of the file $ok = @rewind( $fp ); if( !$ok ) { $this->throwFileException( "cannot rewind" ); } // try to write $ok = @fwrite( $fp, $raw ); if( !$ok ) { $this->throwFileException( "cannot write" ); } // make chainable return $this; } /** * Create an exception with some file info * * @param $msg */ private function throwFileException( $msg ) { throw new Exception( sprintf( "error: %s (file: %s, current user: %s)", $msg, $this->file, getmyuid() ) ); } } diff --git a/public/webhook-clickup/index.php b/public/webhook-clickup/index.php index 68ee755..7c42a8e 100644 --- a/public/webhook-clickup/index.php +++ b/public/webhook-clickup/index.php @@ -1,144 +1,163 @@ 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': // 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': + case 'taskStatusUpdated': -// break; + $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, + ] ); + } else { + error_log( "do nothing for status: $phab_task_status_new" ); + } + } + break; default: echo "Method not supported.\n"; error_log( "unsupported ClickUp webhoook: $event" ); } // success http_response_code( 200 );