diff --git a/include/class-ClickUpPhabricatorCache.php b/include/class-ClickUpPhabricatorCache.php
index 95b0fb8..d9f02fb 100644
--- a/include/class-ClickUpPhabricatorCache.php
+++ b/include/class-ClickUpPhabricatorCache.php
@@ -1,492 +1,546 @@
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()->...->save()->close()
*/
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;
/**
* 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 file pointer.
* NOTE: it does not write anything.
*/
function __destruct() {
$this->close();
}
/**
* Assure a lock on the file
*
* @return self
*/
public function lock() {
// no need to lock twice
if( !$this->locked ) {
$this->forceLock();
}
// make chainable
return $this;
}
/**
* Write the cache to filesystem in a secure way
*
* NOTE: this closes and frees any lock.
* @return self
*/
public function save() {
return $this->lock()->write()->close();
}
/**
* Close the file, freeing any related file lock
*
* @return self
*/
public function close() {
// no need to close twice
if( $this->fp ) {
fclose( $this->fp );
}
// mark resources as free to be re-used again
$this->fp = null;
$this->cache = null;
$this->locked = null;
// make chainable
return $this;
}
public function getAllClickupTasks() {
$cache = $this->getCache();
if( !isset( $cache->clickupTasks ) ) {
$cache->clickupTasks = new StdClass();
}
return $cache->clickupTasks;
}
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 ?? "";
return $task;
}
public function removeClickupTask( $click_id ) {
$tasks = $this->getAllClickupTasks();
unset( $tasks->{ $click_id } );
}
/**
* 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 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 associateClickupFolderIDPhabricatorPHID( $folder_id, $phab_phid ) {
$this->getClickupFolderByID( $folder_id )->phabData->phid = $phab_phid;
return $this;
}
public function getClickUpFolders() {
$cache = $this->getCache();
$cache->clickupFolders = $cache->clickupFolders ?? new StdClass();
return $cache->clickupFolders;
}
+ public function getClickUpUsers() {
+ $cache = $this->getCache();
+ $cache->clickupUsers = $cache->clickupUsers ?? new StdClass();
+ return $cache->clickupUsers;
+ }
+
+ 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;
+ }
+
+ public function getClickUpUserByID( $id, $existing_data = null ) {
+ $users = $this->getClickUpUsers();
+
+ // get or create
+ $user = $users->{ $id } = $users->{ $id } ?? new stdclass();
+
+ // eventually create if not found
+ if( $existing_data ) {
+ $user->username = $existing_data->username;
+ }
+
+ // get or init
+ $user->phabData = $user->phabData ?? new stdclass();
+ $user->phabData->id = $user->phabData->id ?? "";
+ $user->phabData->phid = $user->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 setClickUpUserPHIDByID( $id, $phid ) {
+ $this->getClickUpUserPhabdataByID( $id )->phid = $phid;
+ 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;
}
public function importClickupFolder( $data ) {
$id = $data->id;
$folder = $this->getClickupFolderByID( $id );
$folder->id = $data->id;
$folder->name = $data->name;
// make chainable
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->virginFilePointer() ) {
// open the file if it already exists
// create the file if it does not exist
$this->fp = @fopen( $this->file, 'c+' );
if( !$this->fp ) {
$this->throwFileException( "cannot open file" );
}
}
return $this->fp;
}
/**
* Check if the file pointer was never allocated
*
* @return bool
*/
private function virginFilePointer() {
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
*/
public function forceLock() {
$fp = $this->getFilePointer();
$locked = @flock( $fp, LOCK_EX );
if( !$locked ) {
$this->throwFileException( "cannot acquire lock" );
}
$this->locked = true;
// after a lock, mark the cache as to-be-read-again
// so we are sure that data is read after the lock
// and read+save is consistent
$this->cache = null;
}
/**
* 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/public/webhook-clickup/index.php b/public/webhook-clickup/index.php
index 591c987..687a000 100644
--- a/public/webhook-clickup/index.php
+++ b/public/webhook-clickup/index.php
@@ -1,208 +1,219 @@
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 ClickUp webhooks
*
* Documentation:
* https://clickup.com/api/developer-portal/webhooktaskpayloads/
*/
// default response code
http_response_code( 500 );
require realpath( __DIR__ ) . '/../autoload.php';
$response = file_get_contents('php://input');
$response_data = json_decode( $response );
$event = $response_data->event ?? null;
$clickup_task_id = $response_data->task_id ?? null;
$webhook_id = $_SERVER['HTTP_X_SIGNATURE'] ?? null;
// find history items (and the first one)
$history_items = $response_data->history_items ?? [];
$history_item_first = null;
foreach( $history_items as $history_item_first ) {
break;
}
// check incoming request
if( CLICKUP_VALIDATE_WEBHOOK ) {
// TODO
//if( $webhook_id !== CLICKUP_REGISTERED_WEBHOOK_ID ) {
// throw new Exception( sprintf( "unknown webhook ID: %s", $webhook_id ) );
//}
$checkorigintoken = $_GET['checkorigintoken'] ?? null;
if( $checkorigintoken !== CLICKUP_WEBHOOK_CHECKORIGINTOKEN ) {
echo "Invalid check origin token\n";
http_response_code( 400 );
exit;
}
}
$cache = ClickUpPhabricatorCache::instance();
switch( $event ) {
case 'taskCreated':
// check if we already know about it
$phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id );
if( $phab_task_id ) {
// probably Phabricator already created it from the Phabricator webhook
error_log( "skip new ClickUp Task $clickup_task_id: already imported in Phab as $phab_task_id" );
} else {
// let's create the Task
// get more ClickUp info (title and description etc.) and remember in cache
$clickup_task_details = ClickUpAPI::queryTaskData( $clickup_task_id );
// TODO: move in the QueryTaskData some validation on these
$clickup_task_title = $clickup_task_details->name;
$clickup_task_descr = $clickup_task_details->description;
$clickup_task_folder = $clickup_task_details->folder;
$clickup_task_folder_id = $clickup_task_folder->id;
$clickup_task_folder_name = $clickup_task_folder->name;
if( !$clickup_task_title ) {
throw new Exception( "missing title in new ClickUp Task" );
}
// 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_args = [];
// 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 {
// or just give an hint
$clickup_task_title = "[$clickup_task_folder_name] $clickup_task_title";
}
+ // try to assign to an user
+ $clickup_assignes = $clickup_task_details->assignees;
+ foreach( $clickup_assignes as $clickup_assigne ) {
+
+ $phab_assignee_phid = $cache->getClickUpUserPHIDByID( $clickup_assigne->id );
+ if( $phab_assignee ) {
+ $phab_task_args['owner'] = $phab_assignee_phid;
+ break;
+ }
+ }
+
$phab_task_args['title'] = $clickup_task_title;
$phab_task_args['description'] = $phab_task_description;
// create Task in Phabricator
$phab_task_data = PhabricatorAPI::createTask( $phab_task_args );
$phab_task_phid = $phab_task_data['object']['phid']; // TASK-PHID-00000000
$phab_task_id = $phab_task_data['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 )
->save();
// update Click-Up adding the Phabricator URL
$phab_task_url = PHABRICATOR_URL . "T{$phab_task_id}";
ClickUpAPI::putTaskData( $clickup_task_id, [], [
'description' => "$clickup_task_descr\n\n$phab_task_url",
] );
}
break;
case 'taskDeleted':
// if it's deleted in ClickUp, close as invalid in Phabricator
$phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id );
if( $phab_task_id ) {
PhabricatorAPI::editTask( $phab_task_id, [
'status' => 'invalid',
] );
}
// drop ClickUp Task from internal cache
$cache->removeClickupTask( $clickup_task_id );
break;
case 'taskCommentPosted':
// post the comment on Phabricator too
$phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id );
if( $phab_task_id && $history_item_first ) {
$clickup_comment_author_name = $history_item_first->user->username;
$clickup_comment_author_id = $history_item_first->user->id;
$clickup_task_description = $history_item_first->comment->text_content;
// avoid to post on Phabricator, comments that are coming from our Phabricator bot
if( strpos( $clickup_task_description, COMMENT_PREFIX_FROM_PHABRICATOR ) === 0 ) {
error_log( "skip ClickUp comment that was posted by the Phabricator bot" );
} else {
// add a nice comment to Phabricator
$phab_comment = "From **{$clickup_comment_author_name}**:\n\n" . $clickup_task_description;
$phab_task_data = PhabricatorAPI::editTask( $phab_task_id, [
'comment' => $phab_comment,
] );
}
}
break;
case 'taskStatusUpdated':
// check i the Phabricator Task was already connected to this ClickUp Task
$phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id );
if( $phab_task_id && $history_item_first ) {
// fetch fresh Phabricator Task data
$phab_task = PhabricatorAPI::getTask( $phab_task_id );
$phab_task_status = $phab_task['fields']['status']['value'];
$clickup_task_status = $history_item_first->data->status_type;
$clickup_task_status_for_phab = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task_status );
// check if Phabricator has a different Task status than ClickUp
$clickup_task_is_closed = ClickupAPI::isStatusClosed( $clickup_task_status );
$phab_task_is_closed = PhabricatorAPI::isStatusClosed( $phab_task_status );
if( $clickup_task_is_closed !== $phab_task_is_closed ) {
// update Phab only if the status changes
if( $phab_task_status !== $clickup_task_status_for_phab ) {
error_log( "ClickUp Task $clickup_task_id status $clickup_task_status (-> $clickup_task_status_for_phab) - Phab Task $phab_task_id status: $phab_task_status - updating" );
// set the ClickUp Task status in Phabricator as well
PhabricatorAPI::editTask( $phab_task_id, [
'status' => $clickup_task_status_for_phab,
] );
}
} else {
error_log( "ClickUp skip status $clickup_task_status (Phab was $phab_task_status)" );
}
}
break;
default:
echo "Method not supported.\n";
error_log( "unsupported ClickUp webhoook: $event" );
}
// success
http_response_code( 200 );