diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php
index c77aee1..05bf5ff 100644
--- a/include/class-ClickUpAPI.php
+++ b/include/class-ClickUpAPI.php
@@ -1,254 +1,276 @@
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 ?? [];
}
/**
* https://clickup.com/api/clickupreference/operation/GetSpaces/
*
* @return array
*/
public static function getSpaceFolders( $space_id ) {
return self::querySpaceFolders( $space_id )->folders ?? [];
}
/**
* https://clickup.com/api/clickupreference/operation/GetTask/
*/
public static function queryTaskData( $task_id ) {
// append the Team ID since it does not work otherwise
$task_details = self::requestGET( "/task/$task_id", self::query_team() );
// no Task no party
if( !$task_details ) {
throw new Exception( "missing ClickUp Task $task_id" );
}
// save some stuff in the cache
ClickUpPhabricatorCache::instance()
->newlock()
->importClickupTaskData( $task_id, $task_details )
->save();
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()
->newlock()
->importClickupTaskData( $task_id, $task_details )
->save();
return $task_details;
}
+ /**
+ * 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;
+ }
+
/**
* 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/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/DeleteWebhook/
*/
public static function deleteWebhook( $webhook_id = null ) {
if( !$webhook_id ) {
$webhook_id = CLICKUP_REGISTERED_WEBHOOK_ID;
}
$team_id = CLICKUP_TEAM_ID;
return self::request( "DELETE", "/team/{$team_id}/webhook/{$webhook_id}", [], [] );
}
/**
* https://clickup.com/api/clickupreference/operation/UpdateWebhook/
*/
public static function updateWebhook( $webhook_id = null, $endpoint = null, $events = [] ) {
if( !$webhook_id ) {
$webhook_id = CLICKUP_REGISTERED_WEBHOOK_ID;
}
if( !$endpoint ) {
$endpoint = CLICKUP_WEBHOOK_PUBLIC_ENDPOINT;
}
$payload = [
'endpoint' => $endpoint,
];
return self::requestPUT( "/webhook/{$webhook_id}", [], $payload );
}
/**
* 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" );
}
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 ) ) {
throw new Exception( sprintf(
"ClickUp API error: %s",
json_encode( $data )
) );
}
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",
"spaceDeleted",
"goalCreated",
"goalUpdated",
"goalDeleted",
"keyResultCreated",
"keyResultUpdated",
"keyResultDeleted",
];
}
}
diff --git a/include/class-ClickUpPhabricatorCache.php b/include/class-ClickUpPhabricatorCache.php
index 09992dc..437b626 100644
--- a/include/class-ClickUpPhabricatorCache.php
+++ b/include/class-ClickUpPhabricatorCache.php
@@ -1,469 +1,473 @@
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();
}
/**
* Acquire a new lock to read and then write
*
* @return self
*/
public function newlock() {
return $this->close()->lock();
}
/**
* 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 does NOT close and does not free any lock.
* You may need to call close() after this.
* @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 );
// 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 registerClickupPhabricatorTaskID( $click_id, $phab_id ) {
$phab_data = $this->getClickUpTaskPhabricatorData( $click_id );
$phab_data->id = $phab_id;
// make chainable
return $this;
}
public function getClickUpFolders() {
$cache = $this->getCache();
$cache->clickupFolders = $cache->clickupFolders ?? new StdClass();
return $cache->clickupFolders;
}
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;
}
/**
* 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 68ee755..7c42a8e 100644
--- a/public/webhook-clickup/index.php
+++ b/public/webhook-clickup/index.php
@@ -1,144 +1,163 @@
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':
// 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";
}
$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->newlock()->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':
// mark as invalid in Phabricator
$phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id );
if( $phab_task_id ) {
PhabricatorAPI::editTask( $phab_task_id, [
'status' => 'invalid',
] );
}
// drop from 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 ) {
$phab_comment = "Test:\n\n";
$phab_task_data = PhabricatorAPI::editTask( $phab_task_id, [
'comment' => $phab_comment,
] );
}
break;
-// case 'taskUpdated':
+ case 'taskStatusUpdated':
-// break;
+ $phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id );
+ if( $phab_task_id && $history_item_first ) {
+ $clickup_task_status = $history_item_first->data->status_type;
+ $phab_task_status_new = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task_status );
+ if( $phab_task_status_new === 'resolved' || $phab_task_status_new === 'open' ) {
+ PhabricatorAPI::editTask( $phab_task_id, [
+ 'status' => $phab_task_status_new,
+ ] );
+ } else {
+ error_log( "do nothing for status: $phab_task_status_new" );
+ }
+ }
+ break;
default:
echo "Method not supported.\n";
error_log( "unsupported ClickUp webhoook: $event" );
}
// success
http_response_code( 200 );