diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php index 2e95709..2075298 100644 --- a/include/class-ClickUpAPI.php +++ b/include/class-ClickUpAPI.php @@ -1,355 +1,360 @@ 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; } /** * 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; } // 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", [], $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; } /** * 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 ); } /** * 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/DeleteWebhook/ */ public static function deleteWebhook( $webhook_id = null ) { if( !$webhook_id ) { $webhook_id = CLICKUP_REGISTERED_WEBHOOK_ID; } return self::request( "DELETE", "/webhook/{$webhook_id}", [], [] ); } /** * https://clickup.com/api/clickupreference/operation/UpdateWebhook/ */ public static function updateWebhook( $webhook_id = null, $endpoint = null, $events = [] ) { if( !$webhook_id ) { $webhook_id = CLICKUP_REGISTERED_WEBHOOK_ID; } if( !$endpoint ) { $endpoint = CLICKUP_WEBHOOK_PUBLIC_ENDPOINT; } $payload = [ 'endpoint' => $endpoint, ]; return self::requestPUT( "/webhook/{$webhook_id}", [], $payload ); } /** * 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 ); 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", "spaceDeleted", "goalCreated", "goalUpdated", "goalDeleted", "keyResultCreated", "keyResultUpdated", "keyResultDeleted", ]; } } diff --git a/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php index c4327c7..89a7fd4 100644 --- a/include/class-PhabricatorAPI.php +++ b/include/class-PhabricatorAPI.php @@ -1,289 +1,421 @@ 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; } 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 ) { + + $phab_task = null; + + // proposed changeset for a Phabricator task (to update or create) + $phab_task_args = []; + + $cache = ClickUpPhabricatorCache::instance(); + + // get fresh ClickUp Task data + $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; + + // 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; + } + } + + // 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; + } + } + + // probably Phabricator already created it from the Phabricator webhook + error_log( "updating existing ClickUp Task $clickup_task_id with Phabricator Task $phab_task_id" ); + + // update the Phabricator Task if there is something to be updated + if( $phab_task_args ) { + error_log("trying to edit task $phab_task_id (SKIP DEBUG) with: " . json_encode( $phab_task_args ) ); +// PhabricatorAPI::editTask( $phab_task_id, $phab_task_args ); + } + + } 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"; + + // 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"; + } + + $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 ) + ->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-clickup/index.php b/public/webhook-clickup/index.php index 02e4213..1bcea4b 100644 --- a/public/webhook-clickup/index.php +++ b/public/webhook-clickup/index.php @@ -1,218 +1,222 @@ 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 ClickUp webhooks * * Documentation: * https://clickup.com/api/developer-portal/webhooktaskpayloads/ */ // default response code http_response_code( 500 ); require realpath( __DIR__ ) . '/../autoload.php'; $response = file_get_contents('php://input'); $response_data = json_decode( $response ); -$event = $response_data->event ?? null; -$clickup_task_id = $response_data->task_id ?? null; +$event = $response_data->event ?? null; +$clickup_task_id = $response_data->task_id ?? null; // find history items (and the first one) $history_items = $response_data->history_items ?? []; $history_item_first = null; foreach( $history_items as $history_item_first ) { break; } // no signature no party $signature_header = $_SERVER['HTTP_X_SIGNATURE'] ?? null; if( !$signature_header ) { http_response_code( 400 ); echo "This is not a request from an official ClickUp webhook.\n"; exit; } // do the math $signature_calculated = hash_hmac( 'sha256', $response, CLICKUP_WEBHOOK_HMAC_KEY ); if( $signature_calculated !== $signature_header ) { http_response_code( 400 ); throw new Exception( "invalid signature, calculated $signature_calculated expected $signature_header" ); exit; } $cache = ClickUpPhabricatorCache::instance(); switch( $event ) { case 'taskCreated': // check if we already know about it $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id ) { // probably Phabricator already created it from the Phabricator webhook error_log( "skip new ClickUp Task $clickup_task_id: already imported in Phab as $phab_task_id" ); } else { // let's create the Task // get more ClickUp info (title and description etc.) and remember in cache $clickup_task_details = ClickUpAPI::queryTaskData( $clickup_task_id ); // TODO: move in the QueryTaskData some validation on these $clickup_task_title = $clickup_task_details->name; $clickup_task_descr = $clickup_task_details->description; $clickup_task_folder = $clickup_task_details->folder; $clickup_task_folder_id = $clickup_task_folder->id; $clickup_task_folder_name = $clickup_task_folder->name; if( !$clickup_task_title ) { throw new Exception( "missing title in new ClickUp Task" ); } // 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_args = []; // 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 { // or just give an hint $clickup_task_title = "[$clickup_task_folder_name] $clickup_task_title"; } // try to assign to an user $clickup_assignes = $clickup_task_details->assignees; foreach( $clickup_assignes as $clickup_assigne ) { $phab_assignee_phid = $cache->getClickUpUserPHIDByID( $clickup_assigne->id ); if( $phab_assignee_phid ) { $phab_task_args['owner'] = $phab_assignee_phid; break; } } $phab_task_args['title'] = $clickup_task_title; $phab_task_args['description'] = $phab_task_description; // create Task in Phabricator $phab_task_data = PhabricatorAPI::createTask( $phab_task_args ); $phab_task_phid = $phab_task_data['object']['phid']; // TASK-PHID-00000000 $phab_task_id = $phab_task_data['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 ) ->commit(); // update Click-Up adding the Phabricator URL $phab_task_url = PHABRICATOR_URL . "T{$phab_task_id}"; ClickUpAPI::putTaskData( $clickup_task_id, [], [ 'description' => "$clickup_task_descr\n\n$phab_task_url", ] ); } break; case 'taskDeleted': // if it's deleted in ClickUp, close as invalid in Phabricator $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id ) { PhabricatorAPI::editTask( $phab_task_id, [ 'status' => 'invalid', ] ); } // drop ClickUp Task from internal cache $cache->removeClickupTask( $clickup_task_id ); break; + case 'taskUpdated': + PhabricatorAPI::createOrUpdateTaskFromClickUpTaskID( $clickup_task_id ); + break; + case 'taskCommentPosted': // post the comment on Phabricator too $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id && $history_item_first ) { $clickup_comment_author_name = $history_item_first->user->username; $clickup_comment_author_id = $history_item_first->user->id; $clickup_task_description = $history_item_first->comment->text_content; // avoid to post on Phabricator, comments that are coming from our Phabricator bot if( strpos( $clickup_task_description, COMMENT_PREFIX_FROM_PHABRICATOR ) === 0 ) { error_log( "skip ClickUp comment that was posted by the Phabricator bot" ); } else { // add a nice comment to Phabricator $phab_comment = "From **{$clickup_comment_author_name}**:\n\n" . $clickup_task_description; $phab_task_data = PhabricatorAPI::editTask( $phab_task_id, [ 'comment' => $phab_comment, ] ); } } break; case 'taskStatusUpdated': // check i the Phabricator Task was already connected to this ClickUp Task $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); if( $phab_task_id && $history_item_first ) { // fetch fresh Phabricator Task data $phab_task = PhabricatorAPI::getTask( $phab_task_id ); $phab_task_status = $phab_task['fields']['status']['value']; $clickup_task_status = $history_item_first->data->status_type; $clickup_task_status_for_phab = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task_status ); // check if Phabricator has a different Task status than ClickUp $clickup_task_is_closed = ClickupAPI::isStatusClosed( $clickup_task_status ); $phab_task_is_closed = PhabricatorAPI::isStatusClosed( $phab_task_status ); if( $clickup_task_is_closed !== $phab_task_is_closed ) { // update Phab only if the status changes if( $phab_task_status !== $clickup_task_status_for_phab ) { error_log( "ClickUp Task $clickup_task_id status $clickup_task_status (-> $clickup_task_status_for_phab) - Phab Task $phab_task_id status: $phab_task_status - updating" ); // set the ClickUp Task status in Phabricator as well PhabricatorAPI::editTask( $phab_task_id, [ 'status' => $clickup_task_status_for_phab, ] ); } } else { error_log( "ClickUp skip status $clickup_task_status (Phab was $phab_task_status)" ); } } break; default: echo "Method not supported.\n"; error_log( "unsupported ClickUp webhoook: $event" ); } // success http_response_code( 200 );