diff --git a/README.md b/README.md index 83f4fc3..7018657 100644 --- a/README.md +++ b/README.md @@ -1,52 +1,55 @@ # 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 +* automatically import new ClickUp Task comments into their related Phabricator Task (and vice-versa) +* 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 c89f267..3a23726 100644 --- a/include/class-ClickUpPhabricatorCache.php +++ b/include/class-ClickUpPhabricatorCache.php @@ -1,737 +1,750 @@ 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->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 []; } 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; } 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; } /** * 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/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php index e41cf83..dc51420 100644 --- a/include/class-PhabricatorAPI.php +++ b/include/class-PhabricatorAPI.php @@ -1,431 +1,455 @@ 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 Phabricator / Phorge */ class PhabricatorAPI { /** * Query a single Phabricator Project by its slug * * If this is not possible, it throws an exception. * * @param $slug string Example 'foo_bar' * @return mixed */ public static function querySingleProjectBySlug( $slug ) { return PhabricatorAPI::querySingle( 'project.search', [ 'constraints' => [ 'slugs' => [ $slug ], ], ] ); } /** * Query a single Phabricator Project's Column by its PHID * * If this is not possible, it throws an exception. * * @param $phid string * @return mixed */ public static function querySingleProjectColumnByPHID( $phid ) { return PhabricatorAPI::querySingle( 'project.column.search', [ 'constraints' => [ 'phids' => [ $phid ], ], ] ); } /** * Try to guess a single Phabricator Project by its human name * * If this is not possible, NULL is returned. * * @param string $slug Example 'Foo Bar' * @return mixed */ public static function guessPhabricatorTagFromHumanName( $name ) { $name = str_replace( ' ', '_', $name ); $name = strtolower( $name ); $project = null; try { $project = self::querySingleProjectBySlug( $name ); } catch( Exception $e ) { // do nothing } return $project; } public static function query( $entry_point, $query = [] ) { $client = new ConduitClient( PHABRICATOR_URL ); $client->setConduitToken( PHABRICATOR_CONDUIT_API_TOKEN ); return $client->callMethodSynchronous( $entry_point, $query ); } /** * Simplified query to the maniphest.edit Conduit Phabricator API * * https://sviluppo.erinformatica.it/conduit/method/maniphest.edit/ * * @param $transaction_values array Example: [ 'title' => 'ASD' ] * @param $extra_query array Example: ['objectIdentifier' => 'PHID--...'] to edit * @return mixed */ public static function createTask( $transaction_values = [], $query = [] ) { // build transactions $query['transactions'] = []; foreach( $transaction_values as $key => $value ) { $query['transactions'][] = self::transaction( $key, $value ); } return self::query( 'maniphest.edit', $query ); } /** * Simplified query to the maniphest.edit Conduit Phabricator API * * https://sviluppo.erinformatica.it/conduit/method/maniphest.edit/ * * @param $id string Task ID (e.g. 123 for T123) * @param $transaction_values array Example: [ 'title' => 'ASD' ] * @return mixed */ public static function editTask( $id, $transaction_values = [] ) { return self::createTask( $transaction_values, [ 'objectIdentifier' => $id, ] ); } /** * Check if a Phabricator status is equivalent to "closed" * * @return self */ public static function isStatusClosed( $task_status ) { // TODO: read from "maniphest.status.search" and set in cache and read from there $closed_statuses = [ 'resolved', 'wontfix', 'invalid', 'duplicate', 'spite', ]; return in_array( $task_status, $closed_statuses, true ); } /** * Convert a Phabricator status to a Clickup status * * @return "closed" or "open" */ public static function status2clickup( $task_status ) { $is_closed = self::isStatusClosed( $task_status ); if( $is_closed ) { return 'closed'; } return 'open'; } /** * Get a Task by its ID * * It returns just one element. * * https://sviluppo.erinformatica.it/conduit/method/maniphest.search/ * * @param $task_id mixed */ public static function getTask( $task_id ) { $task_id = self::sanitize_task_id( $task_id ); $query = [ 'constraints' => [ 'ids' => [ $task_id ], ], 'attachments' => [ 'columns' => true, 'projects' => true, ], ]; return self::searchSingleTask( $query ); } public static function getUserByPHID( $phid ) { $query = [ 'constraints' => [ 'phids' => [ $phid ], ], ]; return self::querySingle( 'user.search', $query ); } public static function getUserByUsername( $username ) { $query = [ 'constraints' => [ 'usernames' => [ $username ], ], ]; return self::querySingle( 'user.search', $query ); } public static function getTaskByPHID( $phid ) { $query = [ 'constraints' => [ 'phids' => [ $phid ], ], 'attachments' => [ 'columns' => true, 'projects' => true, ], ]; return self::searchSingleTask( $query ); } public static function searchSingleUserByPHID( $phid ) { $query = [ 'constraints' => [ 'phids' => [ $phid ], ], ]; return self::searchSingleUser( $query ); } public static function searchSingleTask( $query ) { return self::querySingle( 'maniphest.search', $query ); } public static function searchSingleUser( $query ) { return self::querySingle( 'user.search', $query ); } /** * Return a single element from Phabricator or throw an exception * * @param $method string * @param $query array * @return mixed */ public static function querySingle( $method, $query ) { $results = self::query( $method, $query ); // just the first one is OK foreach( $results['data'] as $entry ) { return $entry; } throw new Exception( "Phabricator result not found from $method using: " . json_encode( $query ) ); } public static function searchObjectTransactionsFromTransactions( $phab_object_id, $transactions ) { $transaction_phids = []; foreach( $transactions as $transaction ) { $transaction_phids[] = $transaction['phid']; } return self::searchObjectTransactionsFromPHIDs( $phab_object_id, $transaction_phids ); } public static function searchObjectTransactionsFromPHIDs( $phab_object_id, $transaction_phids ) { $query = [ 'objectIdentifier' => $phab_object_id, 'constraints' => [ 'phids' => $transaction_phids, ], ]; $results = self::query( 'transaction.search', $query ); return $results['data']; } private static function transaction( $type, $value ) { return [ 'type' => $type, 'value' => $value, ]; } /** * Sanitize a Task ID * * @param $task_id mixed * @return int */ public static function sanitize_task_id( $task_id ) { // strip the damn 'T' since the 'id' API only accepts numeric $task_id = ltrim( $task_id, 'T' ); // no numeric no party $task_id = (int)$task_id; if( !$task_id ) { throw new Exception( "invalid Task ID" ); } return $task_id; } /** * Try to create (or update) a Phabricator Task from a ClickUp Task ID * * @param $clickup_task_id string * @return mixed */ public static function createOrUpdateTaskFromClickUpTaskID( $clickup_task_id ) { - $phab_task = null; + // dependency + $cache = ClickUpPhabricatorCache::instance(); // proposed changeset for a Phabricator task (to update or create) $phab_task_args = []; - $cache = ClickUpPhabricatorCache::instance(); - // get fresh ClickUp Task data + $phab_task = null; $clickup_task = ClickUpAPI::queryTaskData( $clickup_task_id ); // TODO: move in the QueryTaskData some validation on these $clickup_task_title = $clickup_task->name; $clickup_task_descr = $clickup_task->description; $clickup_task_folder = $clickup_task->folder; $clickup_task_folder_id = $clickup_task_folder->id; $clickup_task_folder_name = $clickup_task_folder->name; + $clickup_task_parent_id = $clickup_task->parent ?? null; // unuseful checks if( !$clickup_task_title ) { throw new Exception( "missing title in new ClickUp Task" ); } // check the ClickUp assignees on this Task $clickup_assignes = $clickup_task->assignees; foreach( $clickup_assignes as $clickup_assigne ) { // find a Task owner for Phabricator $phab_assignee_phid = $cache->getClickUpUserPHIDByID( $clickup_assigne->id ); if( $phab_assignee_phid ) { // one is enough $phab_task_args['owner'] = $phab_assignee_phid; break; } } + // try to find the parent task + if( $clickup_task_parent_id ) { + $phab_task_parent_id = $cache->getClickupTaskPhabricatorID( $clickup_task_parent_id ); + $phab_task_parent_phid = $cache->getClickupTaskPhabricatorPHID( $clickup_task_parent_id ); + if( !$phab_task_parent_id && !$phab_task_parent_phid ) { + + // try to create the parent as well :D + error_log( "missing Phabricator Task for {$clickup_task_parent_id}: importing" ); + self::createOrUpdateTaskFromClickUpTaskID( $clickup_task_parent_id ); + + // try again to fetch it from the cache (now MUST exists) + $phab_task_parent_phid = $cache->getClickupTaskPhabricatorPHID( $clickup_task_parent_id ); + } + + // attaching to the parent + if( $phab_task_parent_phid ) { + $phab_task_args['parents.add'] = [ $phab_task_parent_phid ]; + } + } + + // check if this ClickUp task is already known by Phabricator $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id ) { $phab_task = PhabricatorAPI::getTask( $phab_task_id ); // status of the Phabricator Task $phab_task_status = $phab_task['fields']['status']['value']; // status of the ClickUp Task $clickup_task_status = $clickup_task->status->type; // status of the ClickUp Task translated for Phabricator $clickup_task_status_for_phab = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task_status ); // check the respective openess status $clickup_task_is_closed = ClickupAPI::isStatusClosed( $clickup_task_status ); $phab_task_is_closed = PhabricatorAPI::isStatusClosed( $phab_task_status ); // verify that the generical status changed if( $clickup_task_is_closed !== $phab_task_is_closed ) { // verify that the specific status will change if( $phab_task_status !== $clickup_task_status_for_phab ) { // update the status of the Phabricator Task $phab_task_args['status'] = $clickup_task_status_for_phab; } } // task names $task_name_phabricator = $phab_task['fields']['name']; $task_name_clickup = $clickup_task->name; // check if the names are NOT the same // it also check if the ClickUp task name is not already part of the Phabricator Task title // this last check it's useful to allow a Phabricator Task called "[Foo] Pippo" and a ClickUp Task called "Pippo" if( strpos( $task_name_phabricator, $task_name_clickup ) === false ) { $phab_task_args['title'] = $task_name_clickup; } // update the Phabricator Task if there is something to be updated if( $phab_task_args ) { PhabricatorAPI::editTask( $phab_task_id, $phab_task_args ); } else { error_log( "nothing to be updated from ClickUp Task $clickup_task_id to Phabricator Task $phab_task_id" ); } } else { // probably Phabricator already created it from the Phabricator webhook error_log( "creating ClickUp Task $clickup_task_id in Phabricator" ); // let's create a new Task in Phabricator // 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_description = trim( $phab_task_description ); // 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 { // whops error_log( "unable to recognize the Phabricator Tag for Task $clickup_task_id - please run ./cli/clickup-folders.php" ); // or just give an hint $clickup_task_title = "[$clickup_task_folder_name] $clickup_task_title"; } + // assign basic info $phab_task_args['title'] = $clickup_task_title; $phab_task_args['description'] = $phab_task_description; // create Task in Phabricator $phab_task = PhabricatorAPI::createTask( $phab_task_args ); $phab_task_phid = $phab_task['object']['phid']; // TASK-PHID-00000000 $phab_task_id = $phab_task['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 ) + ->registerClickupPhabricatorTaskID( $clickup_task_id, $phab_task_id ) + ->registerClickupPhabricatorTaskPHID( $clickup_task_id, $phab_task_phid ) ->commit(); // update Click-Up adding the Phabricator URL :D $phab_task_url = PHABRICATOR_URL . "T{$phab_task_id}"; ClickUpAPI::putTaskData( $clickup_task_id, [], [ 'description' => "$clickup_task_descr\n\n$phab_task_url", ] ); } return $phab_task; } } diff --git a/public/webhook-clickup/index.php b/public/webhook-clickup/index.php index 1bcea4b..2912938 100644 --- a/public/webhook-clickup/index.php +++ b/public/webhook-clickup/index.php @@ -1,222 +1,223 @@ 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; // 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; } // no signature no party $signature_header = $_SERVER['HTTP_X_SIGNATURE'] ?? null; if( !$signature_header ) { http_response_code( 400 ); echo "This is not a request from an official ClickUp webhook.\n"; exit; } // do the math $signature_calculated = hash_hmac( 'sha256', $response, CLICKUP_WEBHOOK_HMAC_KEY ); if( $signature_calculated !== $signature_header ) { http_response_code( 400 ); throw new Exception( "invalid signature, calculated $signature_calculated expected $signature_header" ); exit; } $cache = ClickUpPhabricatorCache::instance(); switch( $event ) { case 'taskCreated': // check if we already know about it $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id ) { // probably Phabricator already created it from the Phabricator webhook error_log( "skip new ClickUp Task $clickup_task_id: already imported in Phab as $phab_task_id" ); } else { // let's create the Task // get more ClickUp info (title and description etc.) and remember in cache $clickup_task_details = ClickUpAPI::queryTaskData( $clickup_task_id ); // TODO: move in the QueryTaskData some validation on these $clickup_task_title = $clickup_task_details->name; $clickup_task_descr = $clickup_task_details->description; $clickup_task_folder = $clickup_task_details->folder; $clickup_task_folder_id = $clickup_task_folder->id; $clickup_task_folder_name = $clickup_task_folder->name; if( !$clickup_task_title ) { throw new Exception( "missing title in new ClickUp Task" ); } // prepare description for new Task in Phabricator $phab_task_description = $clickup_task_descr; $phab_task_description .= "\n\n"; $phab_task_description .= "> https://app.clickup.com/t/$clickup_task_id"; $phab_task_args = []; // try to assign to the right project $phab_task_project = $cache->getPhabricatorTagIDFromClickupFolderID( $clickup_task_folder_id ); if( $phab_task_project ) { // assign to the right project $phab_task_args['projects.add'] = [ $phab_task_project ]; } else { // or just give an hint $clickup_task_title = "[$clickup_task_folder_name] $clickup_task_title"; } // try to assign to an user $clickup_assignes = $clickup_task_details->assignees; foreach( $clickup_assignes as $clickup_assigne ) { $phab_assignee_phid = $cache->getClickUpUserPHIDByID( $clickup_assigne->id ); if( $phab_assignee_phid ) { $phab_task_args['owner'] = $phab_assignee_phid; break; } } $phab_task_args['title'] = $clickup_task_title; $phab_task_args['description'] = $phab_task_description; // create Task in Phabricator $phab_task_data = PhabricatorAPI::createTask( $phab_task_args ); $phab_task_phid = $phab_task_data['object']['phid']; // TASK-PHID-00000000 $phab_task_id = $phab_task_data['object']['id']; // 123 for T123 // associate the Phabricator Task to the Conduit Task in the cache $cache ->lock() - ->registerClickupPhabricatorTaskID( $clickup_task_id, $phab_task_id ) + ->registerClickupPhabricatorTaskID( $clickup_task_id, $phab_task_id ) + ->registerClickupPhabricatorTaskPHID( $clickup_task_id, $phab_task_phid ) ->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 'taskUpdated': PhabricatorAPI::createOrUpdateTaskFromClickUpTaskID( $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)" ); + error_log( "skip ClickUp Task $clickup_task_id with 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 );