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;
}
}