diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php index a2b74b2..f367e11 100644 --- a/include/class-ClickUpAPI.php +++ b/include/class-ClickUpAPI.php @@ -1,609 +1,609 @@ 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 ClickUp */ class ClickUpAPI { /** * https://clickup.com/api/clickupreference/operation/GetSpaces/ * * @return array */ public static function getSpaces() { return self::querySpaces()->spaces ?? []; } /** * Get all the known space folders * * It also saves some info in the cache * * https://clickup.com/api/clickupreference/operation/GetSpaces/ * * @return array */ public static function getSpaceFolders( $space_id ) { // request $folders = self::querySpaceFolders( $space_id ); // import results $cache = ClickUpPhabricatorCache::instance(); $cache->lock(); foreach( $folders as $folder ) { $cache->importClickupFolder( $folder ); } $cache->commit(); // expose return $folders; } /** * Get the ClickUp Task data from cache * If this is not possible, it's fecthed from an API query * @param $click_id string * @return object */ public static function getTaskDataByID( $click_id ) { // try to get from the cache $cache = ClickUpPhabricatorCache::instance(); $task_data = $cache->findClickupTaskByID( $click_id ); if( !$task_data ) { // try to fecth from API (it updates the cache) self::queryTaskData( $click_id ); } // get the fresh copy from the cache (or a weird object with NULL fields) return $cache->getClickupTask( $click_id ); } /** * Find a ClickUp top parent, from a ClickUp Task ID */ public static function findTopParentOfTaskID( $click_id, $tried = [] ) { // loop detector if( in_array( $click_id, $tried, true ) ) { error_log( "loop detected in ClickUp Task $click_id" ); return null; } $task = self::getTaskDataByID( $click_id ); if( $task->parent ) { $tried[] = $click_id; $task = self::findTopParentOfTaskID( $task->parent, $tried ); } return $task; } /** * Execute an API query to fecth a fresh result * https://clickup.com/api/clickupreference/operation/GetTask/ */ public static function queryTaskData( $task_id ) { // no Task no party if( !$task_id ) { throw new Exception( "missing Task ID" ); } try { // try to fetch fresk ClickUp Task info $task_details = self::requestGET( "/task/$task_id", self::query_team() ); } catch( ClickupExceptionTaskNotFound $e ) { // drop unuseful stuff from the cache ClickUpPhabricatorCache::instance() ->lock() ->removeClickupTask( $task_id ) ->commit(); return false; } catch ( ClickupExceptionAccessDenied $e ) { error_log( "cannot access to ClickUp task $task_id: " . $e->getMessage() ); return false; } // save some stuff in the cache ClickUpPhabricatorCache::instance() ->lock() ->importClickupTaskData( $task_id, $task_details ) ->commit(); return $task_details; } /** * https://clickup.com/api/clickupreference/operation/UpdateTask/ */ public static function putTaskData( $task_id, $query = [], $payload = [] ) { $task_details = self::requestPUT( "/task/$task_id", $query, $payload ); ClickUpPhabricatorCache::instance() ->lock() ->importClickupTaskData( $task_id, $task_details ) ->commit(); return $task_details; } /** * https://clickup.com/api/clickupreference/operation/CreateTask/ */ public static function createTaskOnList( $list_id, $payload = [] ) { $task_details = self::requestPOST( "/list/$list_id/task", [], $payload ); if( !isset( $task_details->id ) ) { throw new Exception( "something bad happened saving a new ClickUp Task on List '$list_id' with this payload: " . json_encode( $payload ) ); } ClickUpPhabricatorCache::instance() ->lock() ->importClickupTaskData( $task_details->id, $task_details ) ->commit(); return $task_details; } /** * Convert a ClickUp status to a Phabricator one * * Note that ClickUp has a "type" and a "status". * * @return boolean */ public static function isStatusClosed( $type, $status = null ) { if( $type === 'closed' ) { return true; } if( $type === 'open' ) { return false; } // "In progress" etc. return false; } /** * Convert a ClickUp status to a Phabricator one * * Note that ClickUp has a "type" and a "status". * * @return string */ public static function taskStatusTypeToPhabricator( $type, $status = null ) { if( $type === 'closed' ) { return 'resolved'; } if( $type === 'open' ) { return $type; } if( $type === 'custom' ) { if( $status === 'in progress' ) { return 'doing'; } } return $status ?? $type; } /** * Try to create or update a ClickUp Task from a Phabricator Task PHID */ public static function createOrUpdateFromPhabricatorTaskID( $id, $args = [] ) { // query fresh Task data $phab_task = PhabricatorAPI::getTaskByID( $id ); if( !$phab_task ) { throw new Exception( "cannot get Phabricator Task by ID: {$id}" ); } - return self::createOrUpdateFromPhabricatorTaskData( $phab_task ); + return self::createOrUpdateFromPhabricatorTaskData( $phab_task, $args ); } /** * Try to create or update a ClickUp Task from a Phabricator Task PHID */ public static function createOrUpdateFromPhabricatorTaskPHID( $phid, $args = [] ) { // query fresh Task data $phab_task = PhabricatorAPI::getTaskByPHID( $phid ); if( !$phab_task ) { throw new Exception( "cannot get Phabricator Task by PHID: {$phid}" ); } - return self::createOrUpdateFromPhabricatorTaskData( $phab_task ); + return self::createOrUpdateFromPhabricatorTaskData( $phab_task, $args ); } /** * Try to create or update a ClickUp Task from a Phabricator Task's data */ public static function createOrUpdateFromPhabricatorTaskData( $phab_task, $args = [] ) { $args = array_replace( [ // after this threashold, a ClickUp task wil be not imported // to disable this, set something really big 'max-clickup-age-seconds' => 172800, ], $args ); $cache = ClickUpPhabricatorCache::instance(); // array of stuff to be set in the ClickUp Task $clickup_changes = []; // ClickUp List in which we will save the Task $clickup_list_id = null; // resolved / open / etc. $phab_task_id = $phab_task['id']; $phab_task_phid = $phab_task['phid']; $phab_task_status = $phab_task['fields']['status']['value']; $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 ); } // check if we already know the related ClickUp Task if( $clickup_task ) { // update an existing ClickUp Task // check if Phabricator has a different Task status than ClickUp to be updated $phab_task_is_closed = PhabricatorAPI::isStatusClosed( $phab_task_status ); $clickup_task_is_closed = ClickupAPI::isStatusClosed( $clickup_task->status->type ); if( $phab_task_is_closed !== $clickup_task_is_closed ) { $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 { error_log( "ClickUp Task {$clickup_task->id}: no ClickUp user matches Phabricator User $phab_owner_phid - please run ./cli/clickup-users.php" ); } } else { // TODO: somehow remove the removed assignee from ClickUp } // eventally update ClickUp with some changes if( $clickup_changes ) { ClickUpAPI::putTaskData( $clickup_task->id, [], $clickup_changes ); $clickup_changes = []; } } else { // create a fresh 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 < $args['max-clickup-age-seconds'] ) { // set ClickUp basic fields $clickup_changes['name'] = $phab_task['fields']['name']; $clickup_changes['status'] = PhabricatorAPI::status2clickup( $phab_task_status ); // import the description (adding the self Phabricator URL) $clickup_changes['description'] = $phab_task['fields']['description']['raw']; $clickup_changes['description'] .= "\n\n"; $clickup_changes['description'] .= PHABRICATOR_URL . "T{$phab_task_id}"; $clickup_changes['description'] = trim( $clickup_changes['description'] ); // try to assign this ClickUp Task to somebody $clickup_assignee = $cache->findClickupUserbyPHID( $phab_owner_phid ); if( $clickup_assignee ) { $clickup_changes['assignees'] = [ $clickup_assignee->id ]; } 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" ); } // check if this Phabricator Tag has some parents (and find a ClickUp parent) $clickup_task_parent = null; $phab_task_parent_phids = PhabricatorAPI::queryParentTaskPHIDsFromTaskPHID( $phab_task_phid ); $clickup_task_parents = $cache->getClickupTasksFromPhabricatorTaskPHIDs( $phab_task_parent_phids ); // to avoid {"err":"Cannot make subtasks of subtasks","ECODE":"ITEM_002"} foreach( $clickup_task_parents as $i => $clickup_task_parent_candidate ) { // replace these ClickUp tasks with their top parent $clickup_task_parents[$i] = ClickUpAPI::findTopParentOfTaskID( $clickup_task_parent_candidate->id ); } $clickup_task_parent = array_pop( $clickup_task_parents ); // just the first one asd // we have a ClickUp Task parent! so we have a 100% sure List ID if( $clickup_task_parent ) { // let's save this ClickUp Task on the same List of the parent ClickUp Task // and also let's connect the parent ClickUp Task to the current one $clickup_list_id = $clickup_task_parent->list->id; $clickup_changes['parent'] = $clickup_task_parent->id; } else { // try to find the most relevant ClickUp List from the Phabricator Task (from its Tags etc.) $clickup_list = PhabricatorAPI::findClickupListFromTaskObject( $phab_task ); if( $clickup_list ) { $clickup_list_id = $clickup_list->id; } else { error_log( "cannot determine ClickUp List for Phabricator Task T{$phab_task_id} - please run ./cli/clickup-folders.php" ); } } // if we know the ClickUp List ID, let's create this new ClickUp Task if( $clickup_changes && $clickup_list_id ) { // create the ClickUp task $clickup_task = ClickUpAPI::createTaskOnList( $clickup_list_id, $clickup_changes ); $clickup_task_id = $clickup_task->id; // after creation, reset changes, to avoid to save them again $clickup_changes = []; // associate the ClickUp task to Phabricator $cache ->lock() ->registerClickupPhabricatorTaskID( $clickup_task_id, $phab_task_id ) ->registerClickupPhabricatorTaskPHID( $clickup_task_id, $phab_task_phid ) ->commit(); // edit the Phabricator Task comment to put a link to the ClickUp Task $phab_new_description = $phab_task['fields']['description']['raw']; $phab_new_description .= "\n\n"; $phab_new_description .= "> https://app.clickup.com/t/$clickup_task_id"; $phab_new_description = trim( $phab_new_description ); PhabricatorAPI::editTask( $phab_task_id, [ 'description' => $phab_new_description, ] ); } } else { error_log( "Phabricator Task T{$phab_task_id} has not a related ClickUp Task and it's too old ($time_diff seconds)" ); } } return $clickup_task; } /** * Register the ClickUp webhook */ public static function registerWebhook( $endpoint = null, $events = null ) { // assume the default endpoint if( !$endpoint) { $endpoint = CLICKUP_WEBHOOK_PUBLIC_ENDPOINT; } // assume all events if( !$events ) { $events = self::default_webhook_events(); } $payload = [ "endpoint" => $endpoint, "events" => $events, ]; $team_id = CLICKUP_TEAM_ID; return self::requestPOST( "/team/$team_id/webhook", [], $payload ); } /** * https://clickup.com/api/clickupreference/operation/DeleteWebhook/ */ public static function deleteWebhook( $webhook_id = null ) { if( !$webhook_id ) { $webhook_id = CLICKUP_REGISTERED_WEBHOOK_ID; } return self::request( "DELETE", "/webhook/{$webhook_id}", [], [] ); } /** * Update an already-defined ClickUp webhook * * @param $webhook_id string * @param $endpoint string * @param $events array * https://clickup.com/api/clickupreference/operation/UpdateWebhook/ */ public static function updateWebhook( $webhook_id = null, $endpoint = null, $events = null ) { if( !$webhook_id ) { $webhook_id = CLICKUP_REGISTERED_WEBHOOK_ID; } if( !$endpoint ) { $endpoint = CLICKUP_WEBHOOK_PUBLIC_ENDPOINT; } if( !$events ) { $events = self::default_webhook_events(); } $payload = [ 'endpoint' => $endpoint, 'events' => $events, ]; return self::requestPUT( "/webhook/{$webhook_id}", [], $payload ); } /** * Add comment to a Task * * https://clickup.com/api/clickupreference/operation/CreateTaskComment/ */ public static function addTaskComment( $task_id, $comment_text ) { $payload = [ 'comment_text' => $comment_text, // 'assignee' => 123, ]; return self::requestPOST( "/task/$task_id/comment", [], $payload ); } /** * https://clickup.com/api/clickupreference/operation/GetWebhooks/ */ public static function queryWebhooks() { $team_id = CLICKUP_TEAM_ID; $response = self::requestGET( "/team/{$team_id}/webhook", [], [] ); return $response->webhooks; } /** * 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" )->folders; } 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 ) ) { // spawn a nice exact exception if possible switch( $data->ECODE ) { case 'ITEM_013': throw new ClickupExceptionTaskNotFound( $data->err ); case 'ACCESS_079': throw new ClickupExceptionAccessDenied( $data->err ); default: throw new Exception( sprintf( "ClickUp API error: %s from URL: %s payload: %s", json_encode( $data ), $full_url, json_encode( $payload ) ) ); } } return $data; } public static function requestGET( $path, $query = [], $payload = [] ) { return self::request( "GET", $path, $query, $payload ); } public static function requestPUT( $path, $query, $payload = [] ) { return self::request( "PUT", $path, $query, $payload ); } public static function requestPOST( $path, $query, $payload = [] ) { return self::request( "POST", $path, $query, $payload ); } private static function query_team( $query = [] ) { $query[ 'team_id' ] = CLICKUP_TEAM_ID; return $query; } private static function default_webhook_events() { return [ "taskCreated", "taskUpdated", "taskDeleted", "taskPriorityUpdated", "taskStatusUpdated", "taskAssigneeUpdated", "taskDueDateUpdated", "taskTagUpdated", "taskMoved", "taskCommentPosted", "taskCommentUpdated", "taskTimeEstimateUpdated", "taskTimeTrackedUpdated", "listCreated", "listUpdated", "listDeleted", "folderCreated", "folderUpdated", "folderDeleted", "spaceCreated", // "spaceUpdated", // this is nonsense since every time you visit a space it fires "spaceDeleted", "goalCreated", "goalUpdated", "goalDeleted", "keyResultCreated", "keyResultUpdated", "keyResultDeleted", ]; } }