diff --git a/include/class-ClickUpPhabricatorCache.php b/include/class-ClickUpPhabricatorCache.php
index 7602d59..12888c1 100644
--- a/include/class-ClickUpPhabricatorCache.php
+++ b/include/class-ClickUpPhabricatorCache.php
@@ -1,789 +1,825 @@
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 .
/**
* A simple but robust cache system to store stuff about ClickUp/Phabricator
*
* Workflow:
* lock()->...->commit()
*/
class ClickUpPhabricatorCache {
/**
* Cache file pathname
*
* @param string
*/
private $file;
/**
* File pointer
*
* NULL: virgin
* false: error
* other: valid file pointer
*
* @var mixed
*/
private $fp;
/**
* Check if we are under a file lock
*/
private $locked = 0;
/**
* Cache content
*
* @var mixed
*/
private $cache;
/**
* This singleton instance
*
* @var self
*/
private static $_instance;
/**
* Get the singleton instance
*
* @return self
*/
public static function instance() {
// instantiate only once
if( !self::$_instance ) {
self::$_instance = new self();
}
return self::$_instance;
}
/**
* Constructor
*
* Please DON'T call this directly.
* Use the instance() static method instead.
*
* @param $file string
*/
public function __construct( $file = null ) {
// take default from global configuration
if( !$file ) {
$file = CLICKUP_CACHE_JSON_FILE;
}
$this->file = $file;
}
/**
* Destructor
*
* It automatically closes any pending file pointer.
* NOTE: it does not write anything.
*/
function __destruct() {
$this->close();
}
/**
* Assure a lock on the file on fresh data
*
* Note that, if you lock twice, you lock just
* once (the first one).
*
* Remember to always call commit() in order to
* unlock.
*
* @return self
*/
public function lock() {
// no need to lock twice
if( !$this->locked ) {
// since it was not locked before, let's force a data refresh
$this->close();
// acquire a lock
$this->forceLock();
}
// remember our current level
$this->locked++;
// make chainable
return $this;
}
/**
* Write the cache to filesystem in a secure way and release lock
*
* If you already locked previously, your lock is maintained
* and not released and the file is not really closed.
*
* @return self
*/
public function commit() {
// no file, no party
if( $this->isFilePointerVirgin() ) {
throw new Exception( "trying to commit but the file is not opened" );
}
// no lock, no party
if( $this->locked <= 0 ) {
throw new Exception( "trying to commit but you never called lock()" );
}
// check if this is the first level of lock
if( $this->locked === 1 ) {
// finally write to filesystem
$this->write();
}
// note: this does not really close the file if you have another pending lock
return $this->close();
}
/**
* Close the file, freeing any related file lock
*
* Note that, if you locked twice, you need to close twice.
*
* @return self
*/
private function close() {
// undo a previous lock
if( $this->locked ) {
$this->locked--;
}
// check if any commit was already concluded
if( !$this->locked ) {
// no commits remaining, close
$this->forceClose();
}
// make chainable
return $this;
}
private function forceClose() {
// no need to close twice
if( $this->fp ) {
// close the file, freeing any file pointer
fclose( $this->fp );
// mark resources as free to be re-used again
$this->fp = null;
$this->cache = null;
$this->locked = 0;
}
}
public function getAllClickupTasks() {
$cache = $this->getCache();
if( !isset( $cache->clickupTasks ) ) {
$cache->clickupTasks = new stdClass();
}
return $cache->clickupTasks;
}
public function getIterableClickupTasks() {
foreach( $this->getAllClickupTasks() as $id => $task ) {
$task->id = $id;
yield $task;
}
return [];
}
public function getClickupTask( $click_id ) {
// get all indexed tasks
$tasks = $this->getAllClickupTasks();
// get or create one Task
$tasks->{ $click_id } = $tasks->{ $click_id } ?? new stdClass();
$task = $tasks->{ $click_id };
// get or create task name
// $task->name = $task->name ?? null;
// get or create its folder
$task->folder = $task->folder ?? new stdClass();
// get or create one Tasks's Phabricator data
$task->phabData = $task->phabData ?? new stdClass();
$phab_data = $task->phabData;
// get or create one Tasks's Phabricator data ID
$phab_data->id = $phab_data->id ?? "";
$phab_data->phid = $phab_data->phid ?? "";
return $task;
}
/**
* Remove a ClickUp Task from the cache
*
* @return self
*/
public function removeClickupTask( $click_id ) {
$tasks = $this->getAllClickupTasks();
unset( $tasks->{ $click_id } );
return $this;
}
/**
* Import a ClickUp Task having its ID and its data
* retrieved from its API
*
* @param $click_id string
* @param $data array
* @return self
*/
public function importClickupTaskData( $click_id, $data ) {
$task = $this->getClickUpTask( $click_id );
// import name or leave as-is
// $task->name = $data->name ?? null;
// import useful fields
// NOTE: folder always exists here
$task->folder->id = $data->folder->id ?? null;
// $task->folder->name = $data->folder->name ?? null;
// try to read Phabricator data (so the cache is created at least once)
$this->getClickUpTaskPhabricatorData( $click_id );
// let's also import the related folder info
$this->importClickUpFolder( $data->folder );
$this->importClickUpUser( $data->creator );
$this->importClickupUsers( $data->assignees );
$this->importClickupUsers( $data->watchers );
// make chainable
return $this;
}
public function getClickupTaskPhabricatorData( $click_id ) {
// NOTE: phabData always exists
return $this->getClickUpTask( $click_id )->phabData;
}
public function getClickupTaskPhabricatorID( $click_id ) {
// NOTE: ID always exists
return $this->getClickupTaskPhabricatorData( $click_id )->id;
}
public function getClickupTaskPhabricatorPHID( $click_id ) {
return $this->getClickupTaskPhabricatorData( $click_id )->phid ?? "";
}
public function getClickupTaskFromPhabricatorTaskID( $phab_task_id ) {
$phab_task_id = PhabricatorAPI::sanitize_task_id( $phab_task_id );
// just search
$clickup_tasks = $this->getAllClickupTasks();
foreach( $clickup_tasks as $clickup_id => $clickup_task ) {
if( $clickup_task->phabData->id === $phab_task_id ) {
// expose the ClickUp task ID obtained from the array key
$clickup_task->id = $clickup_id;
return $clickup_task;
}
}
return false;
}
public function registerClickupPhabricatorTaskID( $click_id, $phab_id ) {
$phab_data = $this->getClickupTaskPhabricatorData( $click_id );
$phab_data->id = $phab_id;
// make chainable
return $this;
}
public function registerClickupPhabricatorTaskPHID( $click_id, $phid ) {
$phab_data = $this->getClickupTaskPhabricatorData( $click_id );
$phab_data->phid = $phid;
// make chainable
return $this;
}
public function associateClickupFolderIDPhabricatorPHID( $folder_id, $phab_phid ) {
$this->getClickupFolderByID( $folder_id )->phabData->phid = $phab_phid;
return $this;
}
public function associateClickupFolderIDPhabricatorID( $folder_id, $phab_id ) {
$this->getClickupFolderByID( $folder_id )->phabData->id = $phab_id;
return $this;
}
public function associateClickupListIDPhabricatorID( $list_id, $phab_id ) {
$this->getClickUpListByID( $list_id )->phabData->id = $phab_id;
return $this;
}
public function associateClickupListIDPhabricatorPHID( $list_id, $phab_id ) {
$this->getClickUpListByID( $list_id )->phabData->phid = $phab_id;
return $this;
}
public function associateClickupListIDPhabricatorColumnPHID( $list_id, $phab_column_phid ) {
$this->getClickUpListByID( $list_id )->phabData->columnPhid = $phab_column_phid;
return $this;
}
public function getClickUpFolders() {
$cache = $this->getCache();
$cache->clickupFolders = $cache->clickupFolders ?? new stdClass();
return $cache->clickupFolders;
}
/**
* Get all the cached ClickUp lists (they live inside a folder)
*
* They are indexed by their original list ID
*
* @return mixed
*/
public function getClickUpLists() {
$cache = $this->getCache();
$cache->clickupLists = $cache->clickupLists ?? new stdClass();
return $cache->clickupLists;
}
/**
* Get a ClickUp list from its ID from the cache
*
* @return mixed
*/
public function getClickUpListByID( $clickup_list_id ) {
$lists = $this->getClickupLists();
// create or init
if( !isset( $lists->{ $clickup_list_id } ) ) {
$lists->{ $clickup_list_id } = new stdClass();
}
$list = $lists->{ $clickup_list_id };
// create or init
$list->phabData = $list->phabData ?? new stdClass();
// eventually initialize important Phabricator data
$list->phabData->id = $list->phabData->id ?? "";
$list->phabData->phid = $list->phabData->phid ?? "";
$list->phabData->columnPhid = $list->phabData->columnPhid ?? "";
return $list;
}
public function getClickUpUsers() {
$cache = $this->getCache();
if( !isset( $cache->clickupUsers ) ) {
$cache->clickupUsers = new stdClass();
}
return $cache->clickupUsers;
}
public function getIterableClickupUsers() {
foreach( $this->getClickUpUsers() as $id => $user ) {
$user->id = $id;
yield $user;
}
return [];
}
+ /**
+ * Find a ClickUp list by a Phabricator PHID
+ *
+ * The PHID can be both a Project or a Project Column.
+ *
+ * @param $phid string
+ * @return object|null
+ */
+ public function findClickupListByPhabricatorTagPHID( $phid ) {
+ foreach( $this->getClickUpLists() as $list ) {
+ $phabData = $list->phabData;
+ if( $phabData->phid === $phid || $phabData->columnPhid === $phid ) {
+ return $list;
+ }
+ }
+ return false;
+ }
+
+ /**
+ * Find a ClickUp list by an array of Phabricator PHIDs
+ *
+ * Each PHID can be both a Project or a Project Column.
+ *
+ * @param $phid array
+ * @return object|null
+ */
+ public function findClickupListByPhabricatorTagPHIDs( $phids ) {
+ foreach( $phids as $phid ) {
+ $list = $this->findClickupListByPhabricatorTagPHID( $phid );
+ if( $list ) {
+ return $list;
+ }
+ }
+ return false;
+ }
+
/**
* Find a cached ClickUp user from the Phabricator PHID identifier
*
* @param $phid string
* @return mixed
*/
public function findClickupUserbyPHID( $phid ) {
foreach( $this->getIterableClickupUsers() as $user ) {
if( $this->getClickUpUserPHIDByID( $user->id ) === $phid ) {
return $user;
}
}
return false;
}
/**
* Find a cached ClickUp user ID from the Phabricator PHID identifier
*
* @param $phid string
* @return string|false
*/
public function findClickupUserIDbyPHID( $phid ) {
$user = $this->findClickupUserbyPHID( $phid );
if( $user ) {
return $user->id;
}
return false;
}
public function findClickupFolderByPhabricatorTagPHID( $phid ) {
foreach( $this->getClickUpFolders() as $folder ) {
if( $folder->phabData->phid === $phid ) {
return $folder;
}
}
return false;
}
public function findClickupFolderByPhabricatorTagPHIDs( $phids ) {
foreach( $phids as $phid ) {
$found = $this->findClickupFolderByPhabricatorTagPHID( $phid );
if( $found ) {
return $found;
}
}
return false;
}
public function importClickUpUser( $user_data ) {
$this->getClickUpUserByID( $user_data->id, $user_data );
return $this;
}
public function importClickUpUsers( $users_data ) {
foreach( $users_data as $user_data ) {
$this->importClickUpUser( $user_data );
}
return $this;
}
/**
* Get a ClickUp user by its ClickUp user ID
*
* @param $id string
* @param $existing_data mixed
* @return mixed
*/
public function getClickUpUserByID( $id, $existing_data = null ) {
$users = $this->getClickUpUsers();
// get or create
$user = $users->{ $id } = $users->{ $id } ?? new stdclass();
// attach the username
$user->id = $user->id ?? $id;
// eventually create if not found
if( $existing_data ) {
$user->username = $existing_data->username;
}
// get or init Phabricator data
$phabData = $user->phabData = $user->phabData ?? new stdclass();
// assure initialization of useful User fields
$phabData->username = $phabData->username ?? "";
$phabData->phid = $phabData->phid ?? "";
return $user;
}
public function getClickUpUserPhabdataByID( $id, $existing_data = null ) {
return $this->getClickUpUserByID( $id, $existing_data )->phabData;
}
public function getClickUpUserPHIDByID( $id, $existing_data = null ) {
return $this->getClickUpUserPhabdataByID( $id, $existing_data )->phid;
}
public function getClickUpUserPhabUsernameByID( $id, $existing_data = null ) {
return $this->getClickUpUserPhabdataByID( $id, $existing_data )->username;
}
public function setClickUpUserPHIDByID( $id, $phid ) {
$this->getClickUpUserPhabdataByID( $id )->phid = $phid;
return $this;
}
public function setClickUpUserPhabUsernameByID( $id, $username ) {
$this->getClickUpUserPhabdataByID( $id )->username = $username;
return $this;
}
public function getClickupFolderByID( $id ) {
$folders = $this->getClickupFolders();
$folders->{ $id } = $folders->{ $id } ?? new stdClass();
$folder = $folders->{ $id };
$folder->phabData = $folder->phabData ?? new stdClass();
$folder->phabData->id = $folder->phabData->id ?? "";
$folder->phabData->phid = $folder->phabData->phid ?? "";
return $folder;
}
public function getPhabricatorTagPHIDFromClickupFolderID( $click_id ) {
return $this->getClickupFolderByID( $click_id )->phabData->phid;
}
public function getPhabricatorTagIDFromClickupFolderID( $click_id ) {
return $this->getClickupFolderByID( $click_id )->phabData->id;
}
public function getPhabricatorTagFromClickupFolderID( $id ) {
$folder = $this->getClickupFolderByID( $id );
return $folder->phabData->id;
}
/**
* Import in the cache a ClickUp folder obtained from its API
*
* Also its ClickUp lists will be imported
*/
public function importClickupFolder( $data ) {
$id = $data->id;
$folder = $this->getClickupFolderByID( $id );
$folder->id = $data->id;
$folder->name = $data->name;
// import also each list
$clickup_lists = $data->lists ?? [];
foreach( $clickup_lists as $clickup_list ) {
$this->importClickupListByFolderID( $folder->id, $clickup_list );
}
// make chainable
return $this;
}
public function importClickupListByFolderID( $folder_id, $clickup_list ) {
$clickup_list_id = $clickup_list->id;
// get or create
$lists = $this->getClickupLists();
// get or create
$list = $this->getClickupListByID( $clickup_list_id );
// eventually initialize important fields
$list->folderID = $clickup_list->folderID ?? $folder_id;
$list->id = $clickup_list->id;
$list->name = $clickup_list->name;
return $this;
}
/**
* Get the cache content
*
* @return mixed
*/
public function getCache() {
return $this->read()->cache;
}
/**
* Open the file
*
* @return self
*/
public function open() {
// just assure that there is a file pointer
$this->getFilePointer();
// make chainable
return $this;
}
/**
* Get the file pointer to the cache file
*
* This does NOT lock anything. You have to call lock() if you need.
*
* @return self
*/
private function getFilePointer() {
// avoid to open twice
if( $this->isFilePointerVirgin() ) {
// open the file if it already exists
// create the file if it does not exist
$fp = @fopen( $this->file, 'c+' );
if( !$fp ) {
$this->throwFileException( "cannot open file" );
}
// if it works, assign it
$this->fp = $fp;
}
return $this->fp;
}
/**
* Check if the file pointer was never allocated
*
* @return bool
*/
private function isFilePointerVirgin() {
return $this->fp === null;
}
/**
* Read and parse
*
* This method automatically open the file.
* This method is safe to be called multiple times.
*
* @return self
*/
private function read() {
// read just at startup or after any close
if( !$this->cache ) {
$this->forceRead();
}
// make chainable
return $this;
}
/**
* Force reading the content of the file
*/
private function forceRead() {
$fp = $this->getFilePointer();
// read content from opened file
$content = '';
while( !feof( $fp ) ) {
// read in chunks
$part = fread( $fp, 8192 );
// no chunk no party
if( $part === false ) {
$this->throwFileException( "cannot read file" );
}
$content .= $part;
}
// assume at least a valid empty JSON
if( !$content ) {
$content = '{}';
}
// try to parse content
$data = @json_decode( $content );
if( $data === false ) {
$this->throwFileException( "cannot parse JSON" );
}
// remember parser data
$this->cache = $data;
}
/**
* Force a lock on the file
*
* @param bool
*/
private function forceLock() {
$fp = $this->getFilePointer();
$locked = @flock( $fp, LOCK_EX );
if( !$locked ) {
$this->throwFileException( "cannot acquire lock" );
}
}
/**
* Write the cache on the filesystem
*
* @return self
*/
private function write() {
$flags = 0;
if( CLICKUP_CACHE_JSON_PRETTY ) {
$flags = JSON_PRETTY_PRINT;
}
$fp = $this->getFilePointer();
// encode the cache in JSON
$raw = @json_encode( $this->getCache(), $flags );
if( $raw === false ) {
throw new Exception( "cannot JSON encode" );
}
// clear the file
$ok = @ftruncate( $fp, 0 );
if( !$ok ) {
$this->throwFileException( "cannot truncate" );
}
// turn back to the start of the file
$ok = @rewind( $fp );
if( !$ok ) {
$this->throwFileException( "cannot rewind" );
}
// try to write
$ok = @fwrite( $fp, $raw );
if( !$ok ) {
$this->throwFileException( "cannot write" );
}
// make chainable
return $this;
}
/**
* Create an exception with some file info
*
* @param $msg
*/
private function throwFileException( $msg ) {
throw new Exception( sprintf(
"error: %s (file: %s, current user: %s)",
$msg,
$this->file,
getmyuid()
) );
}
}
diff --git a/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php
index df49511..a457833 100644
--- a/include/class-PhabricatorAPI.php
+++ b/include/class-PhabricatorAPI.php
@@ -1,458 +1,527 @@
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;
}
+ /**
+ * 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';
}
/**
* 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;
}
}
diff --git a/public/webhook-phabricator/index.php b/public/webhook-phabricator/index.php
index a74ebf5..2d1c7c2 100644
--- a/public/webhook-phabricator/index.php
+++ b/public/webhook-phabricator/index.php
@@ -1,197 +1,208 @@
ClickUp bot
# Copyright (C) 2023 Valerio Bozzolan, contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
/**
* Receive Phabricator webhooks
*
* Documentation:
* https://we.phorge.it/conduit/
*
* See also Herald rule.
*/
// default response code
http_response_code( 500 );
// load needed libraries
require realpath( __DIR__ ) . '/../autoload.php';
// parse HTTP payload
$response = file_get_contents('php://input');
// parse as an array since native Conduit also handles everything as an array
$response_data = json_decode( $response, JSON_OBJECT_AS_ARRAY );
// no signature no party
$signature_header = $_SERVER['HTTP_X_PHABRICATOR_WEBHOOK_SIGNATURE'] ?? null;
if( !$signature_header ) {
http_response_code( 400 );
echo "This is not a request from an official Phabricator webhook.\n";
exit;
}
// do the math
$signature_calculated = hash_hmac( 'sha256', $response, PHABRICATOR_WEBHOOK_HMAC_KEY );
if( $signature_calculated !== $signature_header ) {
http_response_code( 400 );
throw new Exception( "invalid signature, calculated $signature_calculated expected $signature_header" );
exit;
}
// no object no party
$object = $response_data['object'] ?? null;
if( !$object ) {
http_response_code( 400 );
throw new Exception( "received empty Phabricator object" );
}
$transactions = $response_data['transactions'] ?? [];
$object_type = $object['type'];
$object_phid = $object['phid'];
$cache = ClickUpPhabricatorCache::instance();
switch( $object_type ) {
case 'TASK':
// query fresh Task data
$phab_task = PhabricatorAPI::getTaskByPHID( $object_phid );
if( !$phab_task ) {
throw new Exception( "missing Task: {$object_phid}" );
}
$clickup_changes = [];
// resolved / open / etc.
$phab_task_id = $phab_task['id'];
$phab_task_phid = $phab_task['phid'];
- $phab_project_phids = $phab_task['attachments']['projects']['projectPHIDs'];
$phab_task_status = $phab_task['fields']['status']['value'];
- $phab_owner_phid = $phab_task['fields']['ownerPHID'];
+ $phab_owner_phid = $phab_task['fields']['ownerPHID'] ?? null;
// 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 );
}
// no related ClickUp Task?
if( !$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 < 240 ) {
- // create in ClickUp as well
+ if( $time_diff < 600 ) {
- $clickup_folder = $cache->findClickupFolderByPhabricatorTagPHIDs( $phab_project_phids );
+ // try to find the most relevant ClickUp List
+ $clickup_list = PhabricatorAPI::findClickupListFromTaskObject( $phab_task );
+ if( $clickup_list ) {
- error_log( "Phabricator Task T{$phab_task_id} has not a related ClickUp Task and has a folder (find its list: to be implemented)" );
+ error_log( "creating ClickUp Task from Phabricator Task T{$phab_task_id} on ClickUp List '{$clickup_list->name}' ID {$clickup_list->id}" );
- if( $clickup_folder ) {
+ // try to assign this ClickUp Task to somebody
+ $clickup_assignees = [];
+ $clickup_assignee = $cache->findClickupUserbyPHID( $phab_owner_phid );
+ if( $clickup_assignee ) {
+ $clickup_assignees[] = $clickup_assignee;
+ } 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" );
+ }
-// $clickup_list_id = "TODO";
-// $clickup_task = ClickUpAPI::createTaskOnList( $clickup_list_id, [
-// 'name' => '',
-// 'description' => '',
-// 'assignees' => [],
-// 'parent' => '',
-// ] );
+ error_log( "to be implemented: find ClickUp parent Task from Conduit API edge.search with edge type = task.parent https://sviluppo.erinformatica.it/conduit/method/edge.search/" );
- } else {
+ // create in ClickUp as well
+// $clickup_task = ClickUpAPI::createTaskOnList( $clickup_list->id, [
+// 'name' => $phab_task['fields']['name'],
+// 'description' => $phab_task['fields']['description']['raw'],
+// 'assignees' => $clickup_assignees,
+// 'status' => PhabricatorAPI::status2clickup( $phab_task_status ),
+// 'parent' => '',
+ ] );
+ } else {
+ error_log( "cannot determine ClickUp List for Phabricator Task T{$phab_task_id} - please run ./cli/clickup-folders.php" );
}
+
} else {
error_log( "Phabricator Task T{$phab_task_id} has not a related ClickUp Task and it's too old ($time_diff seconds)" );
}
}
if( $clickup_task ) {
// check if Phabricator has a different Task status than ClickUp
$phab_task_is_closed = PhabricatorAPI::isStatusClosed( $phab_task_status );
$clickup_task_is_closed = ClickupAPI::isStatusClosed( $clickup_task->status->type );
if( $clickup_task->status->type !== $clickup_task_is_closed ) {
// update the Task status in ClickUp
$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 {
// TODO: somehow remove the removed assignee from ClickUp
}
}
if( $transactions ) {
$transaction_results = PhabricatorAPI::searchObjectTransactionsFromTransactions( $phab_task_phid, $transactions );
foreach( $transaction_results as $transaction_result ) {
// found some new Phabricator comments
$phab_comments = $transaction_result['comments'] ?? [];
foreach( $phab_comments as $phab_comment ) {
// Phabricator comment content
$comment_content_raw = $phab_comment['content']['raw'];
// Phabricator author (NOTE: it MUST exists)
$comment_author = PhabricatorAPI::searchSingleUserByPHID( $phab_comment['authorPHID'] );
$comment_author_name = $comment_author['fields']['realName'];
// post the comment on ClickUp
if( $clickup_task ) {
// "From Phabricator: Mario Rossi:\n\nHello world!"
// IMPORTANT: keep the COMMENT_PREFIX_FROM_PHABRICATOR at startup since now
// the ClickUp webhook controls this token at startup.
$clickup_comment = sprintf(
"%s %s:\n\n%s",
COMMENT_PREFIX_FROM_PHABRICATOR,
$comment_author_name,
$comment_content_raw
);
ClickupAPI::addTaskComment( $clickup_task->id, $clickup_comment );
}
}
}
}
// eventally update ClickUp with some changes
if( $clickup_task && $clickup_changes ) {
ClickUpAPI::putTaskData( $clickup_task->id, [], $clickup_changes );
}
break;
case 'HWBH':
error_log( "received Phabricator Webhook (test?)" );
break;
default:
error_log( "unknown Phabricator webhook: {$response}" );
}
// success
http_response_code( 200 );