diff --git a/README.md b/README.md index 7018657..7c3e47c 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,56 @@ # 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 (and vice-versa) +* automatically import new Phabricator Task owner when set from ClickUp * automatically update the Phabricator Task status when changed from ClickUp (and vice-versa) * automatically update the Phabricator Task title when changed from ClickUp * automatically create (and attach) a Phabricator parent Task if a ClickUp task has a parent 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/include/class-ClickUpPhabricatorCache.php b/include/class-ClickUpPhabricatorCache.php index c5d15e4..7602d59 100644 --- a/include/class-ClickUpPhabricatorCache.php +++ b/include/class-ClickUpPhabricatorCache.php @@ -1,760 +1,789 @@ 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()->...->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 = 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 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 ) { // 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 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 */ private function close() { // undo a previous lock if( $this->locked ) { $this->locked--; } // 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 ?? ""; $phab_data->phid = $phab_data->phid ?? ""; 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 getClickupTaskPhabricatorPHID( $click_id ) { return $this->getClickupTaskPhabricatorData( $click_id )->phid ?? ""; } 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 registerClickupPhabricatorTaskPHID( $click_id, $phid ) { $phab_data = $this->getClickupTaskPhabricatorData( $click_id ); $phab_data->phid = $phid; // 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 associateClickupListIDPhabricatorID( $list_id, $phab_id ) { $this->getClickUpListByID( $list_id )->phabData->id = $phab_id; return $this; } public function associateClickupListIDPhabricatorPHID( $list_id, $phab_id ) { $this->getClickUpListByID( $list_id )->phabData->phid = $phab_id; return $this; } public function associateClickupListIDPhabricatorColumnPHID( $list_id, $phab_column_phid ) { $this->getClickUpListByID( $list_id )->phabData->columnPhid = $phab_column_phid; return $this; } public function getClickUpFolders() { $cache = $this->getCache(); $cache->clickupFolders = $cache->clickupFolders ?? new stdClass(); return $cache->clickupFolders; } /** * Get all the cached ClickUp lists (they live inside a folder) * * They are indexed by their original list ID * * @return mixed */ public function getClickUpLists() { $cache = $this->getCache(); $cache->clickupLists = $cache->clickupLists ?? new stdClass(); return $cache->clickupLists; } /** * Get a ClickUp list from its ID from the cache * * @return mixed */ public function getClickUpListByID( $clickup_list_id ) { $lists = $this->getClickupLists(); // create or init if( !isset( $lists->{ $clickup_list_id } ) ) { $lists->{ $clickup_list_id } = new stdClass(); } $list = $lists->{ $clickup_list_id }; // create or init $list->phabData = $list->phabData ?? new stdClass(); // eventually initialize important Phabricator data $list->phabData->id = $list->phabData->id ?? ""; $list->phabData->phid = $list->phabData->phid ?? ""; $list->phabData->columnPhid = $list->phabData->columnPhid ?? ""; return $list; } 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 []; } + /** + * Find a cached ClickUp user from the Phabricator PHID identifier + * + * @param $phid string + * @return mixed + */ + public function findClickupUserbyPHID( $phid ) { + foreach( $this->getIterableClickupUsers() as $user ) { + if( $this->getClickUpUserPHIDByID( $user->id ) === $phid ) { + return $user; + } + } + return false; + } + + /** + * Find a cached ClickUp user ID from the Phabricator PHID identifier + * + * @param $phid string + * @return string|false + */ + public function findClickupUserIDbyPHID( $phid ) { + $user = $this->findClickupUserbyPHID( $phid ); + if( $user ) { + return $user->id; + } + return false; + } + public function findClickupFolderByPhabricatorTagPHID( $phid ) { foreach( $this->getClickUpFolders() as $folder ) { if( $folder->phabData->phid === $phid ) { return $folder; } } return false; } public function findClickupFolderByPhabricatorTagPHIDs( $phids ) { foreach( $phids as $phid ) { $found = $this->findClickupFolderByPhabricatorTagPHID( $phid ); if( $found ) { return $found; } } return false; } 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; } /** * Get a ClickUp user by its ClickUp user ID * * @param $id string * @param $existing_data mixed * @return mixed */ public function getClickUpUserByID( $id, $existing_data = null ) { $users = $this->getClickUpUsers(); // get or create $user = $users->{ $id } = $users->{ $id } ?? new stdclass(); // attach the username $user->id = $user->id ?? $id; // 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; } /** * Import in the cache a ClickUp folder obtained from its API * * Also its ClickUp lists will be imported */ public function importClickupFolder( $data ) { $id = $data->id; $folder = $this->getClickupFolderByID( $id ); $folder->id = $data->id; $folder->name = $data->name; // import also each list $clickup_lists = $data->lists ?? []; foreach( $clickup_lists as $clickup_list ) { $this->importClickupListByFolderID( $folder->id, $clickup_list ); } // make chainable return $this; } public function importClickupListByFolderID( $folder_id, $clickup_list ) { $clickup_list_id = $clickup_list->id; // get or create $lists = $this->getClickupLists(); // get or create $list = $this->getClickupListByID( $clickup_list_id ); // eventually initialize important fields $list->folderID = $clickup_list->folderID ?? $folder_id; $list->id = $clickup_list->id; $list->name = $clickup_list->name; 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->isFilePointerVirgin() ) { // open the file if it already exists // create the file if it does not exist $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 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 */ private function forceLock() { $fp = $this->getFilePointer(); $locked = @flock( $fp, LOCK_EX ); if( !$locked ) { $this->throwFileException( "cannot acquire lock" ); } } /** * 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-phabricator/index.php b/public/webhook-phabricator/index.php index f99b616..a74ebf5 100644 --- a/public/webhook-phabricator/index.php +++ b/public/webhook-phabricator/index.php @@ -1,186 +1,197 @@ 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 ); // 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_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']; + $phab_owner_phid = $phab_task['fields']['ownerPHID']; // 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 ) { // 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)" ); if( $clickup_folder ) { // $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 ); } + + // check if we have to add a Task assignee in ClickUp + if( $phab_owner_phid ) { + $clickup_assignee_id = $cache->findClickupUserIDbyPHID( $phab_owner_phid ); + if( $clickup_assignee_id ) { + $clickup_changes['assignees.add'] = $clickup_assignee_id; + } + } else { + // TODO: somehow remove the removed assignee from ClickUp + } } 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 );