diff --git a/cli/clickup-folders.php b/cli/clickup-folders.php new file mode 100755 index 0000000..27b9390 --- /dev/null +++ b/cli/clickup-folders.php @@ -0,0 +1,56 @@ +#!/usr/bin/php + ClickUp bot +# Copyright (C) 2022 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"; + } + +} diff --git a/config-example.php b/config-example.php index 865d070..53e7a7c 100644 --- a/config-example.php +++ b/config-example.php @@ -1,45 +1,48 @@ ClickUp bot # Copyright (C) 2022 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 . // ClickUp Team ID - you can find it from your home URL define( 'CLICKUP_TEAM_ID', 123 ); // obtained after registering the webhook using register-webhook.php define( 'CLICKUP_REGISTERED_WEBHOOK_ID', '123-123-123-123-123' ); // https://app.clickup.com/36232585/settings/apps define( 'CLICKUP_API_TOKEN', 'pk_123_ASD123' ); // ClickUp cache (connecting its stuff to Phabricator, avoid creating duplicate tasks, etc.) define( 'CLICKUP_CACHE_JSON_FILE', __DIR__ . '/data/cache.json' ); +// ClickUp cache pretty print +define( 'CLICKUP_CACHE_JSON_PRETTY', true ); + // Phabricator home URL (to call API) // MUST end with a slash define( 'PHABRICATOR_URL', 'https://sviluppo.erinformatica.it/' ); // path to the Arcanist library of your Phabricator installation (to call APIs) define( 'PHABRICATOR_ARCANIST_PATH', '/var/www/phabricator/arcanist/support/init/init-script.php' ); // Phabricator Conduit API token // at the moment it's in use the one from the user er.clickup.bot in ER Phabricator // https://sviluppo.erinformatica.it/p/er.clickup.bot/ // To generate a new one: // https://sviluppo.erinformatica.it/conduit/token/edit/15/ define( 'PHABRICATOR_CONDUIT_API_TOKEN', 'api-asd123' ); // not supported already :D sorry define( 'CLICKUP_VALIDATE_WEBHOOK', false ); diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php index 502cbb6..09095a9 100644 --- a/include/class-ClickUpAPI.php +++ b/include/class-ClickUpAPI.php @@ -1,149 +1,156 @@ ClickUp bot # Copyright (C) 2022 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 ClickUp */ class ClickUpAPI { /** * https://clickup.com/api/clickupreference/operation/GetSpaces/ * * @return array */ public static function getSpaces() { return self::querySpaces()->spaces ?? []; } /** * https://clickup.com/api/clickupreference/operation/GetSpaces/ * * @return array */ public static function getSpaceFolders( $space_id ) { return self::querySpaceFolders( $space_id )->folders ?? []; } /** * https://clickup.com/api/clickupreference/operation/GetTask/ */ public static function queryTaskData( $task_id ) { // append the Team ID since it does not work otherwise $task_details = self::requestGET( "/task/$task_id", self::query_team() ); // no Task no party if( !$task_details ) { throw new Exception( "missing ClickUp Task $task_id" ); } // save some stuff in the cache ClickUpPhabricatorCache::instance() ->newlock() ->importClickupTaskData( $task_id, $task_details ) ->save(); return $task_details; } /** * https://clickup.com/api/clickupreference/operation/UpdateTask/ */ - public static function putTaskData( $task_id, $data = [] ) { - $task_details = self::requestPUT( "/task/$task_id", $data ); + public static function putTaskData( $task_id, $query = [], $payload = [] ) { + $task_details = self::requestPUT( "/task/$task_id", $query, $payload ); ClickUpPhabricatorCache::instance() ->newlock() ->importClickupTaskData( $task_id, $task_details ) ->save(); return $task_details; } /** * https://clickup.com/api/clickupreference/operation/GetSpaces/ * * @return mixed */ private static function querySpaces() { $team_id = CLICKUP_TEAM_ID; return self::requestGET( "/team/$team_id/space" ); } /** * https://clickup.com/api/clickupreference/operation/GetFolders/ */ private static function querySpaceFolders( $space_id ) { $team_id = CLICKUP_TEAM_ID; return self::requestGET( "/space/$space_id/folder" ); } - private static function request( $method, $path, $query = [] ) { + private static function request( $method, $path, $query = [], $payload = [] ) { $API_TOKEN = CLICKUP_API_TOKEN; $url = "https://api.clickup.com/api/v2" . $path; $full_url = $url . '?' . http_build_query( $query ); $curl_opts = [ CURLOPT_HTTPHEADER => [ "Authorization: $API_TOKEN", "Content-Type: application/json" ], CURLOPT_URL => $full_url, CURLOPT_CUSTOMREQUEST => $method, CURLOPT_RETURNTRANSFER => true, ]; + + // PUT requests + if( $payload ) { + $payload_raw = json_encode( $payload ); + $curl_opts[CURLOPT_POSTFIELDS] = $payload_raw; + } + $curl = curl_init(); curl_setopt_array( $curl, $curl_opts ); $response = curl_exec( $curl ); $error = curl_error( $curl ); curl_close( $curl ); if( $error ) { throw new Exception( "cURL Error ClickUp #:" . $error ); } $data = json_decode( $response ); if( $data === false ) { throw new Exception( "cannot parse JSON of ClickUp response" ); } // formal error from ClickUp if( isset( $data->err ) ) { throw new Exception( sprintf( "ClickUp API error: %s", json_encode( $data ) ) ); } return $data; } - public static function requestGET( $path, $query = [] ) { - return self::request( "GET", $path, $query ); + public static function requestGET( $path, $query = [], $payload = [] ) { + return self::request( "GET", $path, $query, $payload ); } - public static function requestPUT( $path, $query = [] ) { - return self::request( "PUT", $path, $query ); + public static function requestPUT( $path, $query, $payload = [] ) { + return self::request( "PUT", $path, $query, $payload ); } private static function query_team( $query = [] ) { $query[ 'team_id' ] = CLICKUP_TEAM_ID; return $query; } } diff --git a/include/class-ClickUpPhabricatorCache.php b/include/class-ClickUpPhabricatorCache.php index 9d0f0b3..575e471 100644 --- a/include/class-ClickUpPhabricatorCache.php +++ b/include/class-ClickUpPhabricatorCache.php @@ -1,451 +1,468 @@ ClickUp bot # Copyright (C) 2022 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 registerClickupPhabricatorTaskID( $click_id, $phab_id ) { $phab_data = $this->getClickUpTaskPhabricatorData( $click_id ); $phab_data->id = $phab_id; + + // make chainable + 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 ?? ""; return $folder; } 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+' ); + $this->fp = @fopen( $this->file, 'c+' ); if( !$this->fp ) { - throwFileException( "cannot open file" ); + $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 ) { - throwFileException( "cannot read file" ); + $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 ); + $data = @json_decode( $content ); if( $data === false ) { - throwFileException( "cannot parse JSON" ); + $this->throwFileException( "cannot parse JSON" ); } // remember parser data $this->cache = $data; } /** * Force a lock on the file * * @param bool */ public function forceLock() { - $locked = flock( $this->getFilePointer(), LOCK_EX ); + $fp = $this->getFilePointer(); + $locked = @flock( $fp, LOCK_EX ); if( !$locked ) { - throwFileException( "cannot acquire lock" ); + $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() ); + $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( $this->getFilePointer() ); + $ok = @rewind( $fp ); if( !$ok ) { - throwFileException( "cannot rewind" ); + $this->throwFileException( "cannot rewind" ); } // try to write - $ok = fwrite( $this->getFilePointer(), $raw ); + $ok = @fwrite( $fp, $raw ); if( !$ok ) { - throwFileException( "cannot write" ); + $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 5cf3780..2113eb0 100644 --- a/include/class-PhabricatorAPI.php +++ b/include/class-PhabricatorAPI.php @@ -1,103 +1,120 @@ ClickUp bot # Copyright (C) 2022 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 editTask( $entry_point, $transaction_values = [], $query = [] ) { + 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, + ] ); + } + /** * 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 ], ], ]; $results = self::query( 'maniphest.search', $query ); // just the first one is OK foreach( $results['data'] as $entry ) { return $entry; } throw new Exception( "Phabricator Task not found: $task_id" ); } private static function transaction( $type, $value ) { return [ 'type' => $type, 'value' => $value, ]; } /** * Sanitize a Task ID * * @param $task_id mixed * @return int */ private 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; } }