diff --git a/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php index dc51420..df49511 100644 --- a/include/class-PhabricatorAPI.php +++ b/include/class-PhabricatorAPI.php @@ -1,455 +1,458 @@ 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 ) { // 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" ); } // 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; } } // 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 ); 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; // check if the names are NOT the same // it also check if the ClickUp task name is not already part of the Phabricator Task title // this last check it's useful to allow a Phabricator Task called "[Foo] Pippo" and a ClickUp Task called "Pippo" if( strpos( $task_name_phabricator, $task_name_clickup ) === false ) { $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; } }