diff --git a/cli/clickup-folders.php b/cli/clickup-folders.php
new file mode 100755
index 0000000..27b9390
--- /dev/null
+++ b/cli/clickup-folders.php
@@ -0,0 +1,56 @@
+#!/usr/bin/php
+ ClickUp bot
+# Copyright (C) 2022 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 .
+
+/**
+ * Get ClickUp folders
+ */
+
+// autoload libraries
+require __DIR__ . '/../autoload.php';
+
+$cache = ClickUpPhabricatorCache::instance();
+
+// import all ClickUp Folders from all Spaces
+$clickup_spaces = ClickUpAPI::getSpaces();
+foreach( $clickup_spaces as $space ) {
+
+ // $space->id
+ // $space->name
+
+ // save each folder in the cache
+ $cache->newlock();
+ $folders = ClickUpAPI::getSpaceFolders( $space->id );
+ foreach( $folders as $folder ) {
+
+ // $folder->id
+ // $folder->name
+
+ $cache->importClickupFolder( $folder );
+ }
+ $cache->save();
+}
+
+// loop cached folders
+foreach( $cache->getClickUpFolders() as $folder ) {
+
+ $phab_tag_id = $cache->getPhabricatorTagIDFromClickupFolderID( $folder->id );
+ if( !$phab_tag_id ) {
+ echo $folder->id . "\t|" . $folder->name . "\n";
+ }
+
+}
diff --git a/config-example.php b/config-example.php
index 865d070..53e7a7c 100644
--- a/config-example.php
+++ b/config-example.php
@@ -1,45 +1,48 @@
ClickUp bot
# Copyright (C) 2022 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 .
// ClickUp Team ID - you can find it from your home URL
define( 'CLICKUP_TEAM_ID', 123 );
// obtained after registering the webhook using register-webhook.php
define( 'CLICKUP_REGISTERED_WEBHOOK_ID', '123-123-123-123-123' );
// https://app.clickup.com/36232585/settings/apps
define( 'CLICKUP_API_TOKEN', 'pk_123_ASD123' );
// ClickUp cache (connecting its stuff to Phabricator, avoid creating duplicate tasks, etc.)
define( 'CLICKUP_CACHE_JSON_FILE', __DIR__ . '/data/cache.json' );
+// ClickUp cache pretty print
+define( 'CLICKUP_CACHE_JSON_PRETTY', true );
+
// Phabricator home URL (to call API)
// MUST end with a slash
define( 'PHABRICATOR_URL', 'https://sviluppo.erinformatica.it/' );
// path to the Arcanist library of your Phabricator installation (to call APIs)
define( 'PHABRICATOR_ARCANIST_PATH', '/var/www/phabricator/arcanist/support/init/init-script.php' );
// Phabricator Conduit API token
// at the moment it's in use the one from the user er.clickup.bot in ER Phabricator
// https://sviluppo.erinformatica.it/p/er.clickup.bot/
// To generate a new one:
// https://sviluppo.erinformatica.it/conduit/token/edit/15/
define( 'PHABRICATOR_CONDUIT_API_TOKEN', 'api-asd123' );
// not supported already :D sorry
define( 'CLICKUP_VALIDATE_WEBHOOK', false );
diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php
index 502cbb6..09095a9 100644
--- a/include/class-ClickUpAPI.php
+++ b/include/class-ClickUpAPI.php
@@ -1,149 +1,156 @@
ClickUp bot
# Copyright (C) 2022 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, $data = [] ) {
- $task_details = self::requestPUT( "/task/$task_id", $data );
+ 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;
}
/**
* 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 = [] ) {
+ 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 = [] ) {
- return self::request( "GET", $path, $query );
+ public static function requestGET( $path, $query = [], $payload = [] ) {
+ return self::request( "GET", $path, $query, $payload );
}
- public static function requestPUT( $path, $query = [] ) {
- return self::request( "PUT", $path, $query );
+ public static function requestPUT( $path, $query, $payload = [] ) {
+ return self::request( "PUT", $path, $query, $payload );
}
private static function query_team( $query = [] ) {
$query[ 'team_id' ] = CLICKUP_TEAM_ID;
return $query;
}
}
diff --git a/include/class-ClickUpPhabricatorCache.php b/include/class-ClickUpPhabricatorCache.php
index 9d0f0b3..575e471 100644
--- a/include/class-ClickUpPhabricatorCache.php
+++ b/include/class-ClickUpPhabricatorCache.php
@@ -1,451 +1,468 @@
ClickUp bot
# Copyright (C) 2022 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 ?? "";
return $folder;
}
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+' );
+ $this->fp = @fopen( $this->file, 'c+' );
if( !$this->fp ) {
- throwFileException( "cannot open file" );
+ $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 ) {
- throwFileException( "cannot read file" );
+ $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 );
+ $data = @json_decode( $content );
if( $data === false ) {
- throwFileException( "cannot parse JSON" );
+ $this->throwFileException( "cannot parse JSON" );
}
// remember parser data
$this->cache = $data;
}
/**
* Force a lock on the file
*
* @param bool
*/
public function forceLock() {
- $locked = flock( $this->getFilePointer(), LOCK_EX );
+ $fp = $this->getFilePointer();
+ $locked = @flock( $fp, LOCK_EX );
if( !$locked ) {
- throwFileException( "cannot acquire lock" );
+ $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() );
+ $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( $this->getFilePointer() );
+ $ok = @rewind( $fp );
if( !$ok ) {
- throwFileException( "cannot rewind" );
+ $this->throwFileException( "cannot rewind" );
}
// try to write
- $ok = fwrite( $this->getFilePointer(), $raw );
+ $ok = @fwrite( $fp, $raw );
if( !$ok ) {
- throwFileException( "cannot write" );
+ $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 5cf3780..2113eb0 100644
--- a/include/class-PhabricatorAPI.php
+++ b/include/class-PhabricatorAPI.php
@@ -1,103 +1,120 @@
ClickUp bot
# Copyright (C) 2022 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 {
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 editTask( $entry_point, $transaction_values = [], $query = [] ) {
+ 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,
+ ] );
+ }
+
/**
* 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 ],
],
];
$results = self::query( 'maniphest.search', $query );
// just the first one is OK
foreach( $results['data'] as $entry ) {
return $entry;
}
throw new Exception( "Phabricator Task not found: $task_id" );
}
private static function transaction( $type, $value ) {
return [
'type' => $type,
'value' => $value,
];
}
/**
* Sanitize a Task ID
*
* @param $task_id mixed
* @return int
*/
private 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;
}
}