diff --git a/README.md b/README.md index d447ce3..6b4077b 100644 --- a/README.md +++ b/README.md @@ -1,50 +1,50 @@ # 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`. ## License -Copyright (C) 2022 Valerio Bozzolan, contributors +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-task.php b/cli/clickup-task.php index 3d5310c..9b63472 100755 --- a/cli/clickup-task.php +++ b/cli/clickup-task.php @@ -1,56 +1,56 @@ #!/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 . /** * Get ClickUp Task info * * If you specify also a Phabricator Task ID, it connects both. */ // autoload libraries require __DIR__ . '/../autoload.php'; $clickup_task_id = $argv[1] ?? null; $phab_task_id = $argv[2] ?? null; if( !$clickup_task_id ) { echo "RTFM\n"; exit; } $clickup_task_details = ClickUpAPI::queryTaskData( $clickup_task_id ); print_r( $clickup_task_details ); echo "\n"; if( $phab_task_id ) { $phab_task_details = PhabricatorAPI::getTask( $phab_task_id ); if( $phab_task_details ) { $phab_task_id = $phab_task_details['id']; if( $phab_task_details['type'] === 'TASK' ) { ClickUpPhabricatorCache::instance() ->lock() ->registerClickupPhabricatorTaskID( $clickup_task_id, $phab_task_id ) - ->save(); + ->commit(); } } print_r( $phab_task_details ); echo "\n"; } diff --git a/cli/clickup-users.php b/cli/clickup-users.php index bc63402..3fedf68 100755 --- a/cli/clickup-users.php +++ b/cli/clickup-users.php @@ -1,86 +1,86 @@ #!/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 . /** * Get ClickUp users */ // autoload libraries require __DIR__ . '/../autoload.php'; $cache = ClickUpPhabricatorCache::instance(); $populate = true; if( $populate ) { foreach( $cache->getIterableClickupTasks() as $clickup_task ) { $clickup_task_details = ClickUpAPI::queryTaskData( $clickup_task->id ); if( $clickup_task_details ) { echo "Import users from ClickUp Task {$clickup_task->id}\n"; } sleep( 1 ); } } foreach( $cache->getIterableClickupUsers() as $clickup_user ) { $clickup_user_id = $clickup_user->id; $phab_username = $cache->getClickUpUserPhabUsernameByID( $clickup_user_id ); $phab_phid = $cache->getClickUpUserPHIDByID( $clickup_user_id ); if( !$phab_username ) { $phab_username = readline("# Type Phabricator nickname of user {$clickup_user->username} (example: ciccio.franco): "); } if( !$phab_phid && $phab_username ) { $phab_user = PhabricatorAPI::getUserByUsername( $phab_username ); if( $phab_user ) { $phab_phid = $phab_user['phid']; $phab_username = $phab_user['fields']['username']; $cache ->lock() ->setClickUpUserPhabUsernameByID( $clickup_user_id, $phab_username ) ->setClickUpUserPHIDByID( $clickup_user_id, $phab_phid ) - ->save(); + ->commit(); echo "# Saved\n\n"; } else { echo "# Not found\n\n"; } } } foreach( $cache->getIterableClickupUsers() as $clickup_user ) { $clickup_user_id = $clickup_user->id; $phab_username = $cache->getClickUpUserPhabUsernameByID( $clickup_user_id ); $phab_phid = $cache->getClickUpUserPHIDByID( $clickup_user_id ); echo $clickup_user->id; echo "\t|"; echo $clickup_user->username; echo "\t|"; echo $phab_username ? 'id CONNECTED TO PHAB' : 'id NOT CONNECTED TO PHAB'; echo "\n"; } diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php index c757ffa..15adce0 100644 --- a/include/class-ClickUpAPI.php +++ b/include/class-ClickUpAPI.php @@ -1,324 +1,324 @@ 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 ) { 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 ) - ->save(); + ->commit(); return false; } // save some stuff in the cache ClickUpPhabricatorCache::instance() ->lock() ->importClickupTaskData( $task_id, $task_details ) - ->save(); + ->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 ) - ->save(); + ->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}", [], [] ); } /** * 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 ) ) { // 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/include/class-ClickUpPhabricatorCache.php b/include/class-ClickUpPhabricatorCache.php index fb910f7..6e3958d 100644 --- a/include/class-ClickUpPhabricatorCache.php +++ b/include/class-ClickUpPhabricatorCache.php @@ -1,580 +1,637 @@ 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() + * lock()->...->commit() */ 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; + private $locked = 0; /** * 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. + * It automatically closes any pending file pointer. * NOTE: it does not write anything. */ function __destruct() { $this->close(); } /** * Assure a lock on the file on fresh data * + * Note that, if you lock twice, you lock just + * once (the first one). + * + * Remember to always call commit() in order to + * unlock. + * * @return self */ public function lock() { // no need to lock twice if( !$this->locked ) { - $this->close()->forceLock(); + + // since it was not locked before, let's force a data refresh + $this->close(); + + // acquire a lock + $this->forceLock(); } + // remember our current level + $this->locked++; + // make chainable return $this; } /** * Write the cache to filesystem in a secure way and release lock * + * If you already locked previously, your lock is maintained + * and not released and the file is not really closed. + * * @return self */ - public function save() { - return $this->lock()->write()->close(); + public function commit() { + + // no file, no party + if( $this->isFilePointerVirgin() ) { + throw new Exception( "trying to commit but the file is not opened" ); + } + + // no lock, no party + if( $this->locked <= 0 ) { + throw new Exception( "trying to commit but you never called lock()" ); + } + + // check if this is the first level of lock + if( $this->locked === 1 ) { + + // finally write to filesystem + $this->write(); + } + + // note: this does not really close the file if you have another pending lock + return $this->close(); } /** * Close the file, freeing any related file lock * + * Note that, if you locked twice, you need to close twice. + * * @return self */ - public function close() { + private function close() { - // no need to close twice - if( $this->fp ) { - fclose( $this->fp ); + // undo a previous lock + if( $this->locked ) { + $this->locked--; } - // mark resources as free to be re-used again - $this->fp = null; - $this->cache = null; - $this->locked = null; + // check if any commit was already concluded + if( !$this->locked ) { + + // no commits remaining, close + $this->forceClose(); + } // make chainable return $this; } + private function forceClose() { + // no need to close twice + if( $this->fp ) { + + // close the file, freeing any file pointer + fclose( $this->fp ); + + // mark resources as free to be re-used again + $this->fp = null; + $this->cache = null; + $this->locked = 0; + } + } + public function getAllClickupTasks() { $cache = $this->getCache(); if( !isset( $cache->clickupTasks ) ) { $cache->clickupTasks = new StdClass(); } return $cache->clickupTasks; } public function getIterableClickupTasks() { foreach( $this->getAllClickupTasks() as $id => $task ) { $task->id = $id; yield $task; } return []; } 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; } /** * Remove a ClickUp Task from the cache * * @return self */ public function removeClickupTask( $click_id ) { $tasks = $this->getAllClickupTasks(); unset( $tasks->{ $click_id } ); return $this; } /** * 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 ); $this->importClickUpUser( $data->creator ); $this->importClickupUsers( $data->assignees ); $this->importClickupUsers( $data->watchers ); // 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 getClickupTaskFromPhabricatorTaskID( $phab_task_id ) { $phab_task_id = PhabricatorAPI::sanitize_task_id( $phab_task_id ); // just search $clickup_tasks = $this->getAllClickupTasks(); foreach( $clickup_tasks as $clickup_id => $clickup_task ) { if( $clickup_task->phabData->id === $phab_task_id ) { // expose the ClickUp task ID obtained from the array key $clickup_task->id = $clickup_id; return $clickup_task; } } return false; } public function registerClickupPhabricatorTaskID( $click_id, $phab_id ) { $phab_data = $this->getClickupTaskPhabricatorData( $click_id ); $phab_data->id = $phab_id; // make chainable return $this; } public function associateClickupFolderIDPhabricatorPHID( $folder_id, $phab_phid ) { $this->getClickupFolderByID( $folder_id )->phabData->phid = $phab_phid; return $this; } public function associateClickupFolderIDPhabricatorID( $folder_id, $phab_id ) { $this->getClickupFolderByID( $folder_id )->phabData->id = $phab_id; return $this; } public function getClickUpFolders() { $cache = $this->getCache(); $cache->clickupFolders = $cache->clickupFolders ?? new StdClass(); return $cache->clickupFolders; } public function getClickUpUsers() { $cache = $this->getCache(); if( !isset( $cache->clickupUsers ) ) { $cache->clickupUsers = new StdClass(); } return $cache->clickupUsers; } public function getIterableClickupUsers() { foreach( $this->getClickUpUsers() as $id => $user ) { $user->id = $id; yield $user; } return []; } public function importClickUpUser( $user_data ) { $this->getClickUpUserByID( $user_data->id, $user_data ); return $this; } public function importClickUpUsers( $users_data ) { foreach( $users_data as $user_data ) { $this->importClickUpUser( $user_data ); } return $this; } public function getClickUpUserByID( $id, $existing_data = null ) { $users = $this->getClickUpUsers(); // get or create $user = $users->{ $id } = $users->{ $id } ?? new stdclass(); // eventually create if not found if( $existing_data ) { $user->username = $existing_data->username; } // get or init Phabricator data $phabData = $user->phabData = $user->phabData ?? new stdclass(); // assure initialization of useful User fields $phabData->username = $phabData->username ?? ""; $phabData->phid = $phabData->phid ?? ""; return $user; } public function getClickUpUserPhabdataByID( $id, $existing_data = null ) { return $this->getClickUpUserByID( $id, $existing_data )->phabData; } public function getClickUpUserPHIDByID( $id, $existing_data = null ) { return $this->getClickUpUserPhabdataByID( $id, $existing_data )->phid; } public function getClickUpUserPhabUsernameByID( $id, $existing_data = null ) { return $this->getClickUpUserPhabdataByID( $id, $existing_data )->username; } public function setClickUpUserPHIDByID( $id, $phid ) { $this->getClickUpUserPhabdataByID( $id )->phid = $phid; return $this; } public function setClickUpUserPhabUsernameByID( $id, $username ) { $this->getClickUpUserPhabdataByID( $id )->username = $username; return $this; } 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() ) { + if( $this->isFilePointerVirgin() ) { // open the file if it already exists // create the file if it does not exist - $this->fp = @fopen( $this->file, 'c+' ); - if( !$this->fp ) { + $fp = @fopen( $this->file, 'c+' ); + if( !$fp ) { $this->throwFileException( "cannot open file" ); } + + // if it works, assign it + $this->fp = $fp; } return $this->fp; } /** * Check if the file pointer was never allocated * * @return bool */ - private function virginFilePointer() { + private function isFilePointerVirgin() { 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() { + private 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 687a000..ef61ca9 100644 --- a/public/webhook-clickup/index.php +++ b/public/webhook-clickup/index.php @@ -1,219 +1,219 @@ 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"; } // 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 ) { $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 ) - ->save(); + ->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 );