diff --git a/cli/clickup-folders.php b/cli/clickup-folders.php index 8c58093..c6d2096 100755 --- a/cli/clickup-folders.php +++ b/cli/clickup-folders.php @@ -1,109 +1,148 @@ #!/usr/bin/php ClickUp bot # Copyright (C) 2023 Valerio Bozzolan, contributors # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . /** * Get ClickUp folders */ // autoload libraries require __DIR__ . '/../autoload.php'; // ask data +$import = true; $populate = true; $cache = ClickUpPhabricatorCache::instance(); // import all ClickUp Folders from all Spaces -$clickup_spaces = ClickUpAPI::getSpaces(); -foreach( $clickup_spaces as $space ) { +if( $import ) { + echo "Start import\n"; - // $space->id - // $space->name + $clickup_spaces = ClickUpAPI::getSpaces(); + foreach( $clickup_spaces as $space ) { - // save each folder in the cache - $cache->lock(); - $folders = ClickUpAPI::getSpaceFolders( $space->id ); - foreach( $folders as $folder ) { - - // $folder->id - // $folder->name - - $cache->importClickupFolder( $folder ); + // also query space folders (it will import them) + echo "Importing folders of space {$space->name}\n"; + ClickUpAPI::getSpaceFolders( $space->id ); + sleep( 1 ); } - $cache->save(); } // loop cached folders foreach( $cache->getClickUpFolders() as $folder ) { $phab_tag_id = $cache->getPhabricatorTagIDFromClickupFolderID( $folder->id ); $phab_tag_phid = $cache->getPhabricatorTagPHIDFromClickupFolderID( $folder->id ); if( !$phab_tag_id && $populate ) { $phab_tag_id = readline( "Specify the Phabricator Tag name for {$folder->name} (example: pippo_pluto): " ); } if( $phab_tag_id && !$phab_tag_phid ) { // find fresh project from its slug - $phab_project = PhabricatorAPI::querySingle( 'project.search', [ - 'constraints' => [ - 'slugs' => [ $phab_tag_id, ] - ], - ] ); - + $phab_project = PhabricatorAPI::querySingleProjectBySlug( $phab_tag_id ); if( $phab_project ) { // associate the ClickUp folder to the Phabricator Tag PHID $cache ->lock() ->associateClickupFolderIDPhabricatorID( $folder->id, $phab_project['fields']['slug'] ) ->associateClickupFolderIDPhabricatorPHID( $folder->id, $phab_project['phid'] ) - ->save(); - - /* - // find columns from this project - $phab_columns = PhabricatorAPI::query( 'project.column.search', [ - 'constraints' => [ - 'projects' => [ $phab_project['phid'] ], - ], - ] ); - - $cache->lock(); - foreach( $phab_columns['data'] as $phab_column ) { - $cache->associateClickupFolderIDPhabricatorColumnPHID( $folder->id, $phab_column['phid'] ); + ->commit(); + } + } +} + +// loop cached folders +foreach( $cache->getClickUpLists() as $list ) { + + $phab_project = null; + $list_folder_id = $list->folderID; + $list_phab_tag_id = $list->phabData->id; + $list_phab_tag_phid = $list->phabData->phid; + $list_phab_tag_col_phid = $list->phabData->columnPhid; + + $list_folder = $cache->getClickupFolderByID( $list_folder_id ); + + if( !$list_phab_tag_id && !$list_phab_tag_phid ) { + + // try to automatically search a Phabricator Tag + $phab_project = PhabricatorAPI::guessPhabricatorTagFromHumanName( $list->name ); + if( $phab_project ) { + + // allow to undo the proposal + if( 'n' === readline( "Found Phabricator Tag {$phab_project['fields']['name']} for ClickUp Folder {$list_folder->name} > {$list->name}. Confirm with ENTER or press 'n' to undo." ) ) { + $phab_project = null; } - $cache->save(); - */ } + + // not found? ask manually (and probably it's a column) + if( !$phab_project ) { + + // ask Tag but allow to set a specific column + $list_phab_tag_id = readline( "Type the Phabricator Tag slug for ClickUp folder {$list_folder->name} list {$list->name} (example: pippo_pluto) or ENTER to skip (or the PHID-PCOL ID for a column): " ); + if( strpos( $list_phab_tag_id, 'PHID-PCOL' ) === 0 ) { + + $list_phab_tag_col_phid = $list_phab_tag_id; + $list_phab_tag_id = null; + + $phab_column = PhabricatorAPI::querySingleProjectColumnByPHID( $list_phab_tag_col_phid ); + $list_phab_tag_id = $phab_column['fields']['project']['id']; + $list_phab_tag_phid = $phab_column['fields']['project']['phid']; + + // associate this column to the ClickUp list + $cache + ->lock() + ->associateClickupListIDPhabricatorColumnPHID( $list->id, $list_phab_tag_col_phid ) + ->associateClickupListIDPhabricatorPHID( $list->id, $list_phab_tag_phid ) + ->associateClickupListIDPhabricatorID( $list->id, $list_phab_tag_id ) + ->commit(); + } + } + } + + // expand our knowledge + if( !$phab_project && $list_phab_tag_id && !$list_phab_tag_phid ) { + $phab_project = PhabricatorAPI::querySingleProjectBySlug( $list_phab_tag_id ); } + + // expand our knowledge + if( $phab_project ) { + $cache + ->lock() + ->associateClickupListIDPhabricatorID( $list->id, $phab_project['fields']['slug'] ) + ->associateClickupListIDPhabricatorPHID( $list->id, $phab_project['phid'] ) + ->commit(); + } + } // loop cached folders foreach( $cache->getClickUpFolders() as $folder ) { $phab_tag_id = $cache->getPhabricatorTagIDFromClickupFolderID( $folder->id ); $phab_tag_phid = $cache->getPhabricatorTagPHIDFromClickupFolderID( $folder->id ); $phab_tag_id_msg = $phab_tag_id ? "HAS Phab ID" : "MISSING Phab ID"; // print something nice to see echo $folder->id . "\t|" . $phab_tag_id_msg . "\t|" . $folder->name . "\n"; } diff --git a/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php index a015357..c4327c7 100644 --- a/include/class-PhabricatorAPI.php +++ b/include/class-PhabricatorAPI.php @@ -1,273 +1,289 @@ 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 string $slug Example 'foo_bar' + * @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; } }