diff --git a/include/class-ClickUpPhabricatorCache.php b/include/class-ClickUpPhabricatorCache.php index 7602d59..12888c1 100644 --- a/include/class-ClickUpPhabricatorCache.php +++ b/include/class-ClickUpPhabricatorCache.php @@ -1,789 +1,825 @@ 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 ClickUp list by a Phabricator PHID + * + * The PHID can be both a Project or a Project Column. + * + * @param $phid string + * @return object|null + */ + public function findClickupListByPhabricatorTagPHID( $phid ) { + foreach( $this->getClickUpLists() as $list ) { + $phabData = $list->phabData; + if( $phabData->phid === $phid || $phabData->columnPhid === $phid ) { + return $list; + } + } + return false; + } + + /** + * Find a ClickUp list by an array of Phabricator PHIDs + * + * Each PHID can be both a Project or a Project Column. + * + * @param $phid array + * @return object|null + */ + public function findClickupListByPhabricatorTagPHIDs( $phids ) { + foreach( $phids as $phid ) { + $list = $this->findClickupListByPhabricatorTagPHID( $phid ); + if( $list ) { + return $list; + } + } + return false; + } + /** * 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/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php index df49511..a457833 100644 --- a/include/class-PhabricatorAPI.php +++ b/include/class-PhabricatorAPI.php @@ -1,458 +1,527 @@ 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; } + /** + * Extract some Column objects from a Phabricator Task object + * + * @param $task mixed + * @return array + */ + public static function getColumnsFromTaskObject( $task ) { + $all_columns = []; + foreach( $task['attachments']['columns']['boards'] as $project_phid => $data ) { + foreach( $data['columns'] as $column ) { + // expose the Project PHID + $column['projectPHID'] = $project_phid; + + // expose the full column object + $all_columns[] = $column; + } + } + return $all_columns; + } + + /** + * Extract some Column PHIDs from a Phabricator Task object + * + * @param $task mixed + * @return array + */ + public static function getColumnPHIDsFromTaskObject( $task ) { + $column_phids = []; + foreach( self::getColumnsFromTaskObject( $task ) as $column ) { + $column_phids[] = $column['phid']; + } + return $column_phids; + } + + /** + * Extract Project PHIDs from a Phabricator Task object + * + * @param $task mixed + * @return array + */ + public static function getProjectPHIDsFromTaskObject( $task ) { + return $task['attachments']['projects']['projectPHIDs']; + } + + /** + * Try to find the most suitable ClickUp List from a Task object + * + * Note that ClickUp is a toy and so it can be difficult to find + * just ONE list from multiple Phabricator columns. + * + * @param $task mixed + * @return string|false + */ + public static function findClickUpListFromTaskObject( $task ) { + $cache = ClickUpPhabricatorCache::instance(); + + // try with the column PHIDs + $column_phids = self::getColumnPHIDsFromTaskObject( $task ); + $found = $cache->findClickupListByPhabricatorTagPHIDs( $column_phids ); + if( !$found ) { + + // otherwise try with the Project PHIDs + $project_phids = self::getProjectPHIDsFromTaskObject( $task ); + $found = $cache->findClickupListByPhabricatorTagPHIDs( $project_phids ); + } + + return $found; + } + 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 ) { // dependency $cache = ClickUpPhabricatorCache::instance(); // proposed changeset for a Phabricator task (to update or create) $phab_task_args = []; // 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; // TODO: import ClickUp "due date" into our Phabricator "custom.deadline" epoch // $clickup_task->due_date; // unix timestamp // 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 ) ->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-phabricator/index.php b/public/webhook-phabricator/index.php index a74ebf5..2d1c7c2 100644 --- a/public/webhook-phabricator/index.php +++ b/public/webhook-phabricator/index.php @@ -1,197 +1,208 @@ 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']; + $phab_owner_phid = $phab_task['fields']['ownerPHID'] ?? null; // 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 + if( $time_diff < 600 ) { - $clickup_folder = $cache->findClickupFolderByPhabricatorTagPHIDs( $phab_project_phids ); + // try to find the most relevant ClickUp List + $clickup_list = PhabricatorAPI::findClickupListFromTaskObject( $phab_task ); + if( $clickup_list ) { - error_log( "Phabricator Task T{$phab_task_id} has not a related ClickUp Task and has a folder (find its list: to be implemented)" ); + error_log( "creating ClickUp Task from Phabricator Task T{$phab_task_id} on ClickUp List '{$clickup_list->name}' ID {$clickup_list->id}" ); - if( $clickup_folder ) { + // try to assign this ClickUp Task to somebody + $clickup_assignees = []; + $clickup_assignee = $cache->findClickupUserbyPHID( $phab_owner_phid ); + if( $clickup_assignee ) { + $clickup_assignees[] = $clickup_assignee; + } else { + error_log( "cannot determine ClickUp assignee from Phabricator Task T{$phab_task_id} that has owner {$phab_owner_phid} - please run ./cli/clickup-users.php" ); + } -// $clickup_list_id = "TODO"; -// $clickup_task = ClickUpAPI::createTaskOnList( $clickup_list_id, [ -// 'name' => '', -// 'description' => '', -// 'assignees' => [], -// 'parent' => '', -// ] ); + error_log( "to be implemented: find ClickUp parent Task from Conduit API edge.search with edge type = task.parent https://sviluppo.erinformatica.it/conduit/method/edge.search/" ); - } else { + // create in ClickUp as well +// $clickup_task = ClickUpAPI::createTaskOnList( $clickup_list->id, [ +// 'name' => $phab_task['fields']['name'], +// 'description' => $phab_task['fields']['description']['raw'], +// 'assignees' => $clickup_assignees, +// 'status' => PhabricatorAPI::status2clickup( $phab_task_status ), +// 'parent' => '', + ] ); + } else { + error_log( "cannot determine ClickUp List for Phabricator Task T{$phab_task_id} - please run ./cli/clickup-folders.php" ); } + } 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 );