diff --git a/cli/clickup-folders.php b/cli/clickup-folders.php index c1d20f6..6ba32fa 100755 --- a/cli/clickup-folders.php +++ b/cli/clickup-folders.php @@ -1,56 +1,76 @@ #!/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'; $cache = ClickUpPhabricatorCache::instance(); // import all ClickUp Folders from all Spaces $clickup_spaces = ClickUpAPI::getSpaces(); foreach( $clickup_spaces as $space ) { // $space->id // $space->name // save each folder in the cache $cache->newlock(); $folders = ClickUpAPI::getSpaceFolders( $space->id ); foreach( $folders as $folder ) { // $folder->id // $folder->name $cache->importClickupFolder( $folder ); } $cache->save(); } // loop cached folders foreach( $cache->getClickUpFolders() as $folder ) { - $phab_tag_id = $cache->getPhabricatorTagIDFromClickupFolderID( $folder->id ); - if( !$phab_tag_id ) { - echo $folder->id . "\t|" . $folder->name . "\n"; + $phab_tag_id = $cache->getPhabricatorTagIDFromClickupFolderID( $folder->id ); + $phab_tag_phid = $cache->getPhabricatorTagPHIDFromClickupFolderID( $folder->id ); + if( $phab_tag_id ) { + + // find fresh project from its slug + $phab_project = PhabricatorAPI::querySingle( 'project.search', [ + 'constraints' => [ + 'slugs' => [ $phab_tag_id, ] + ], + ] ); + + // force association to this ClickUp folder + $cache + ->newlock() + ->associateClickupFolderIDPhabricatorPHID( $folder->id, $phab_project['phid'] ) + ->save(); } + $phab_tag_id_msg = $phab_tag_id + ? "HAS Phab ID" + : "MISSING Phab ID"; + + echo $folder->id . "\t|" . $phab_tag_id_msg . "\t|" . $folder->name . "\n"; + + } diff --git a/cli/phab-task.php b/cli/phab-task.php index 7b4b30b..08e61ce 100755 --- a/cli/phab-task.php +++ b/cli/phab-task.php @@ -1,36 +1,44 @@ #!/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 . /** * Receive Phabricator/Phorge Task info */ // autoload libraries require __DIR__ . '/../autoload.php'; $phab_task_id = $argv[1] ?? null; if( !$phab_task_id ) { echo "RTFM\n"; exit; } $phab_task_details = PhabricatorAPI::getTask( $phab_task_id ); echo print_r( $phab_task_details, JSON_PRETTY_PRINT ); echo "\n"; + +if( $phab_task_details ) { + $phab_task_id = $phab_task_details['id']; + + $clickup_task = ClickUpPhabricatorCache::instance()->getClickupTaskFromPhabricatorTaskID( $phab_task_id ); + print_r( $clickup_task ); + echo "\n"; +} diff --git a/include/class-ClickUpPhabricatorCache.php b/include/class-ClickUpPhabricatorCache.php index 00a3286..a670a01 100644 --- a/include/class-ClickUpPhabricatorCache.php +++ b/include/class-ClickUpPhabricatorCache.php @@ -1,492 +1,497 @@ ClickUp bot # Copyright (C) 2023 Valerio Bozzolan, contributors # # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. # # You should have received a copy of the GNU General Public License # along with this program. If not, see . /** * A simple but robust cache system to store stuff about ClickUp/Phabricator * * Workflow: * lock()->...->save()->close() */ 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; /** * Cache content * * @var mixed */ private $cache; /** * This singleton instance * * @var self */ private static $_instance; /** * Get the singleton instance * * @return self */ public static function instance() { // instantiate only once if( !self::$_instance ) { self::$_instance = new self(); } return self::$_instance; } /** * Constructor * * Please DON'T call this directly. * Use the instance() static method instead. * * @param $file string */ public function __construct( $file = null ) { // take default from global configuration if( !$file ) { $file = CLICKUP_CACHE_JSON_FILE; } $this->file = $file; } /** * Destructor * * It automatically closes any file pointer. * NOTE: it does not write anything. */ function __destruct() { $this->close(); } /** * Acquire a new lock to read and then write * * @return self */ public function newlock() { return $this->close()->lock(); } /** * Assure a lock on the file * * @return self */ public function lock() { // no need to lock twice if( !$this->locked ) { $this->forceLock(); } // make chainable return $this; } /** * Write the cache to filesystem in a secure way * * NOTE: this does NOT close and does not free any lock. * You may need to call close() after this. * @return self */ public function save() { return $this->lock()->write()->close(); } /** * Close the file, freeing any related file lock * * @return self */ public function close() { // no need to close twice if( $this->fp ) { fclose( $this->fp ); } // mark resources as free to be re-used again $this->fp = null; $this->cache = null; $this->locked = null; // make chainable return $this; } public function getAllClickupTasks() { $cache = $this->getCache(); if( !isset( $cache->clickupTasks ) ) { $cache->clickupTasks = new StdClass(); } return $cache->clickupTasks; } public function getClickupTask( $click_id ) { // get all indexed tasks $tasks = $this->getAllClickupTasks(); // get or create one Task $tasks->{ $click_id } = $tasks->{ $click_id } ?? new StdClass(); $task = $tasks->{ $click_id }; // get or create task name // $task->name = $task->name ?? null; // get or create its folder $task->folder = $task->folder ?? new StdClass(); // get or create one Tasks's Phabricator data $task->phabData = $task->phabData ?? new StdClass(); $phab_data = $task->phabData; // get or create one Tasks's Phabricator data ID $phab_data->id = $phab_data->id ?? ""; return $task; } public function removeClickupTask( $click_id ) { $tasks = $this->getAllClickupTasks(); unset( $tasks->{ $click_id } ); } /** * 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 ); // make chainable return $this; } public function getClickupTaskPhabricatorData( $click_id ) { // NOTE: phabData always exists return $this->getClickUpTask( $click_id )->phabData; } public function getClickupTaskPhabricatorID( $click_id ) { // NOTE: ID always exists return $this->getClickupTaskPhabricatorData( $click_id )->id; } public function getClickupTaskFromPhabricatorTaskID( $phab_task_id ) { $phab_task_id = PhabricatorAPI::sanitize_task_id( $phab_task_id ); // just search $clickup_tasks = $this->getAllClickupTasks(); foreach( $clickup_tasks as $clickup_id => $clickup_task ) { if( $clickup_task->phabData->id === $phab_task_id ) { // expose the ClickUp task ID obtained from the array key $clickup_task->id = $clickup_id; return $clickup_task; } } return false; } public function registerClickupPhabricatorTaskID( $click_id, $phab_id ) { $phab_data = $this->getClickupTaskPhabricatorData( $click_id ); $phab_data->id = $phab_id; // make chainable return $this; } + public function associateClickupFolderIDPhabricatorPHID( $folder_id, $phab_phid ) { + $this->getClickupFolderByID( $folder_id )->phabData->phid = $phab_phid; + return $this; + } + public function getClickUpFolders() { $cache = $this->getCache(); $cache->clickupFolders = $cache->clickupFolders ?? new StdClass(); return $cache->clickupFolders; } public function getClickupFolderByID( $id ) { $folders = $this->getClickupFolders(); $folders->{ $id } = $folders->{ $id } ?? new StdClass(); $folder = $folders->{ $id }; $folder->phabData = $folder->phabData ?? new StdClass(); $folder->phabData->id = $folder->phabData->id ?? ""; $folder->phabData->phid = $folder->phabData->phid ?? ""; return $folder; } public function getPhabricatorTagPHIDFromClickupFolderID( $click_id ) { return $this->getClickupFolderByID( $click_id )->phabData->phid; } public function getPhabricatorTagIDFromClickupFolderID( $click_id ) { return $this->getClickupFolderByID( $click_id )->phabData->id; } public function getPhabricatorTagFromClickupFolderID( $id ) { $folder = $this->getClickupFolderByID( $id ); return $folder->phabData->id; } public function importClickupFolder( $data ) { $id = $data->id; $folder = $this->getClickupFolderByID( $id ); $folder->id = $data->id; $folder->name = $data->name; // make chainable return $this; } /** * Get the cache content * * @return mixed */ public function getCache() { return $this->read()->cache; } /** * Open the file * * @return self */ public function open() { // just assure that there is a file pointer $this->getFilePointer(); // make chainable return $this; } /** * Get the file pointer to the cache file * * This does NOT lock anything. You have to call lock() if you need. * * @return self */ private function getFilePointer() { // avoid to open twice if( $this->virginFilePointer() ) { // open the file if it already exists // create the file if it does not exist $this->fp = @fopen( $this->file, 'c+' ); if( !$this->fp ) { $this->throwFileException( "cannot open file" ); } } return $this->fp; } /** * Check if the file pointer was never allocated * * @return bool */ private function virginFilePointer() { return $this->fp === null; } /** * Read and parse * * This method automatically open the file. * This method is safe to be called multiple times. * * @return self */ private function read() { // read just at startup or after any close if( !$this->cache ) { $this->forceRead(); } // make chainable return $this; } /** * Force reading the content of the file */ private function forceRead() { $fp = $this->getFilePointer(); // read content from opened file $content = ''; while( !feof( $fp ) ) { // read in chunks $part = fread( $fp, 8192 ); // no chunk no party if( $part === false ) { $this->throwFileException( "cannot read file" ); } $content .= $part; } // assume at least a valid empty JSON if( !$content ) { $content = '{}'; } // try to parse content $data = @json_decode( $content ); if( $data === false ) { $this->throwFileException( "cannot parse JSON" ); } // remember parser data $this->cache = $data; } /** * Force a lock on the file * * @param bool */ public function forceLock() { $fp = $this->getFilePointer(); $locked = @flock( $fp, LOCK_EX ); if( !$locked ) { $this->throwFileException( "cannot acquire lock" ); } $this->locked = true; } /** * Write the cache on the filesystem * * @return self */ private function write() { $flags = 0; if( CLICKUP_CACHE_JSON_PRETTY ) { $flags = JSON_PRETTY_PRINT; } $fp = $this->getFilePointer(); // encode the cache in JSON $raw = @json_encode( $this->getCache(), $flags ); if( $raw === false ) { throw new Exception( "cannot JSON encode" ); } // clear the file $ok = @ftruncate( $fp, 0 ); if( !$ok ) { $this->throwFileException( "cannot truncate" ); } // turn back to the start of the file $ok = @rewind( $fp ); if( !$ok ) { $this->throwFileException( "cannot rewind" ); } // try to write $ok = @fwrite( $fp, $raw ); if( !$ok ) { $this->throwFileException( "cannot write" ); } // make chainable return $this; } /** * Create an exception with some file info * * @param $msg */ private function throwFileException( $msg ) { throw new Exception( sprintf( "error: %s (file: %s, current user: %s)", $msg, $this->file, getmyuid() ) ); } } diff --git a/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php index a1e762e..74c7777 100644 --- a/include/class-PhabricatorAPI.php +++ b/include/class-PhabricatorAPI.php @@ -1,210 +1,210 @@ 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 { 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 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::searchSingleResult( 'maniphest.search', $query ); + return self::querySingle( 'maniphest.search', $query ); } public static function searchSingleUser( $query ) { - return self::searchSingleResult( 'user.search', $query ); + return self::querySingle( 'user.search', $query ); } - public static function searchSingleResult( $method, $query ) { + 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; } }