diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php index f367e11..c0871ba 100644 --- a/include/class-ClickUpAPI.php +++ b/include/class-ClickUpAPI.php @@ -1,609 +1,638 @@ 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, $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, $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, + + // minimum difference in seconds between the two estimation times + // (Phabricator Story points, in hours; and ClickUp estimation time, in seconds) + 'min-estimation-time-ms-threshold' => 1200 * 1000, // 20 minutes in milliseconds ], $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; + $phab_task_points_s = PhabricatorAPI::extractStoryPointsInMillisecondsFromPhabTaskData( $phab_task ); // 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 ); } + // ClickUp time estimation (it is in seconds...) + // note that the "Story Points" in Phabricator are usually considered hours instead + $clickup_time_estimate = null; + if( $clickup_task ) { + $clickup_time_estimate = $clickup_task->time_estimate; + } + + // ClickUp import time estimation from Phabricator Story Points + $import_story_points = false; + if( $phab_task_points_s && !$clickup_time_estimate ) { + // simplest case, Phabricator has Story Points, and ClickUp has not + $import_story_points = true; + } else { + // update the ClickUp estimation time only if the delta is somehow relevant + $estimation_delta_seconds = abs( $phab_task_points_s - $clickup_time_estimate ); + if( $estimation_delta_seconds >= $args['min-estimation-time-ms-threshold'] ) { + $import_story_points = true; + } + } + + // really import Phabricator Story points (originally in hours) to ClickUp (seconds) + if( $import_story_points ) { + $clickup_changes['time_estimate'] = $phab_task_points_s; + } + // 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", ]; } } diff --git a/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php index c1d62fc..541b195 100644 --- a/include/class-PhabricatorAPI.php +++ b/include/class-PhabricatorAPI.php @@ -1,613 +1,618 @@ 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 by its PHID * * If this is not possible, it throws an exception. * * @param $phid string * @return mixed */ public static function querySingleProjectByPHID( $phid ) { return PhabricatorAPI::querySingle( 'project.search', [ 'constraints' => [ 'phids' => [ $phid ], ], ] ); } /** * Query multiple Phabricator projects by their PHIDs * * @param $phids array * @return mixed */ public static function queryProjectsByPHIDs( $phids ) { return PhabricatorAPI::query( 'project.search', [ 'constraints' => [ 'phids' => $phids, ], ] ); } /** * 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 ], ], ] ); } /** * Query the Parent Tasks PHIDs from a Phabricator Task PHID * * @param $phid string * @return array */ public static function queryParentTaskPHIDsFromTaskPHID( $phid ) { $parents = []; $response = self::query( 'edge.search', [ 'sourcePHIDs' => [ $phid ], 'types' => [ 'task.parent' ], ] ); foreach( $response['data'] as $result ) { $parents[] = $result['destinationPHID']; } return $parents; } /** * 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; } /** * Extract some Column objects from a Phabricator Task object * * @param $task mixed * @return array */ public static function getColumnsFromTaskObject( $task ) { $all_columns = []; foreach( $task['attachments']['columns']['boards'] as $project_phid => $data ) { foreach( $data['columns'] as $column ) { // expose the Project PHID $column['projectPHID'] = $project_phid; // expose the full column object $all_columns[] = $column; } } return $all_columns; } /** * Extract some Column PHIDs from a Phabricator Task object * * @param $task mixed * @return array */ public static function getColumnPHIDsFromTaskObject( $task ) { $column_phids = []; foreach( self::getColumnsFromTaskObject( $task ) as $column ) { $column_phids[] = $column['phid']; } return $column_phids; } /** * Extract Project PHIDs from a Phabricator Task object * * @param $task mixed * @return array */ public static function getProjectPHIDsFromTaskObject( $task ) { return $task['attachments']['projects']['projectPHIDs']; } /** * Try to find the most suitable ClickUp List from a Task object * * Note that ClickUp is a toy and so it can be difficult to find * just ONE list from multiple Phabricator columns. * * @param $task mixed * @return string|false */ public static function findClickUpListFromTaskObject( $task ) { $cache = ClickUpPhabricatorCache::instance(); // try with the column PHIDs $column_phids = self::getColumnPHIDsFromTaskObject( $task ); $found = $cache->findClickupListByPhabricatorTagPHIDs( $column_phids ); if( !$found ) { // otherwise try with the Project PHIDs $project_phids = self::getProjectPHIDsFromTaskObject( $task ); $found = $cache->findClickupListByPhabricatorTagPHIDs( $project_phids ); } return $found; } 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'; } /** * @deprecated */ public static function getTask( $task_id ) { return self::getTaskByID( $task_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 getTaskByID( $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']; } + public static function extractStoryPointsInMillisecondsFromPhabTaskData( $task_data ) { + $points_raw = $task_data['fields']['points'] ?? null; + return self::storyPointsToMilliseconds( $points_raw ); + } + /** - * Convert a Phabricator "Story Points" field to seconds (or zero) + * Convert a Phabricator "Story Points" field to milliseconds (or zero) * * @param $story_points mixed * @return int Seconds of time extimated */ - public static function storyPointsToSeconds( $story_points ) { + public static function storyPointsToMilliseconds( $story_points ) { - $seconds = 0; - $points = (float) $story_points; - if( $points ) { - $seconds_float = $story_points * 60 * 60; - $seconds = (int) $seconds_float; + $ms = 0; + if( $story_points ) { + $points_float = (float) $story_points; + $ms_float = $points_float * 60 * 60 * 1000; + $ms = (int) $ms_float; } - return $seconds; + return $ms; } 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 * @param $args array Extra arguments. * @return mixed */ public static function createOrUpdateTaskFromClickUpTaskID( $clickup_task_id, $args = [] ) { // dependency $cache = ClickUpPhabricatorCache::instance(); // proposed changeset for a Phabricator task (to update or create) $phab_task_args = []; // get fresh ClickUp Task data $phab_task = null; $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; $clickup_task_parent_id = $clickup_task->parent ?? null; // TODO: import ClickUp "due date" into our Phabricator "custom.deadline" epoch // $clickup_task->due_date; // unix timestamp // unuseful checks if( !$clickup_task_title ) { throw new Exception( "missing title in new ClickUp Task" ); } // try to find the parent task if( $clickup_task_parent_id ) { $phab_task_parent_id = $cache->getClickupTaskPhabricatorID( $clickup_task_parent_id ); $phab_task_parent_phid = $cache->getClickupTaskPhabricatorPHID( $clickup_task_parent_id ); if( !$phab_task_parent_id && !$phab_task_parent_phid ) { // try to create the parent as well :D error_log( "missing Phabricator Task for {$clickup_task_parent_id}: importing" ); self::createOrUpdateTaskFromClickUpTaskID( $clickup_task_parent_id ); // try again to fetch it from the cache (now MUST exists) $phab_task_parent_phid = $cache->getClickupTaskPhabricatorPHID( $clickup_task_parent_id ); } // attaching to the parent if( $phab_task_parent_phid ) { $phab_task_args['parents.add'] = [ $phab_task_parent_phid ]; } } // check if this ClickUp task is already known by Phabricator $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id ); // ClickUp assignee // // It's very difficult to import ClickUp assignee changes, since // ClickUp has multiple assignees, and Phabricator has just one element. // So, at the moment, any ClickUp change on the assignee is just ignored // after the creation in Phabricator, to avoid weird situations, since // it's not clear from ClickUp who is the PRIMARY assignee. // // TODO: change the Phabricator owner, only when the already existing // Phabricator owner is really not even mentioned in ClickUp if( !$phab_task_id ) { $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 ) { // at the moment one is enough $phab_task_args['owner'] = $phab_assignee_phid; break; } } } 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; } } // task names $task_name_phabricator = $phab_task['fields']['name']; $task_name_clickup = $clickup_task->name; // eventually update the title // since this can be frustrating it can be skipped $skip_update_title = $args['skip_update_title'] ?? null; if( !$skip_update_title ) { $phab_task_args['title'] = $task_name_clickup; } // update the Phabricator Task if there is something to be updated if( $phab_task_args ) { PhabricatorAPI::editTask( $phab_task_id, $phab_task_args ); } else { error_log( "nothing to be updated from ClickUp Task $clickup_task_id to Phabricator Task $phab_task_id" ); } } 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"; $phab_task_description = trim( $phab_task_description ); // 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"; } // assign basic info $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 ) ->registerClickupPhabricatorTaskPHID( $clickup_task_id, $phab_task_phid ) ->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; } }