diff --git a/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php index fefba3b..c1d62fc 100644 --- a/include/class-PhabricatorAPI.php +++ b/include/class-PhabricatorAPI.php @@ -1,595 +1,613 @@ 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']; } + /** + * Convert a Phabricator "Story Points" field to seconds (or zero) + * + * @param $story_points mixed + * @return int Seconds of time extimated + */ + public static function storyPointsToSeconds( $story_points ) { + + $seconds = 0; + $points = (float) $story_points; + if( $points ) { + $seconds_float = $story_points * 60 * 60; + $seconds = (int) $seconds_float; + } + + return $seconds; + } + 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; } }