diff --git a/.gitignore b/.gitignore
index b74decb..9572d9f 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1 +1,3 @@
/config.php
+/vendor
+/data/*
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3cf8e67
--- /dev/null
+++ b/README.md
@@ -0,0 +1,21 @@
+# ClickUp <-> Phabricator / Phorge bot
+
+Bot to help migration from ClickUp to Phabricator / Phorge (and vice-versa honestly).
+
+## Dev preparation
+
+```
+sudo apt install php-cli composer
+```
+
+## Dev installation
+
+```
+composer install
+```
+
+## Deploy on production
+
+```
+rsync --recursive --delete ../clickup-phabricator/ ravotti93:/var/www/phabricator/scripts/public/clickup
+```
diff --git a/autoload.php b/autoload.php
new file mode 100644
index 0000000..f6b33a0
--- /dev/null
+++ b/autoload.php
@@ -0,0 +1,35 @@
+ 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 .
+
+/**
+ * Autoload all the needed libraries and dependencies
+ */
+
+// load Guzzle
+require __DIR__ . '/vendor/autoload.php';
+
+// load classes
+require __DIR__ . '/include/class-ClickUpPhabricatorCache.php';
+require __DIR__ . '/include/class-ClickUpAPI.php';
+require __DIR__ . '/include/class-PhabricatorAPI.php';
+
+// load user config
+require __DIR__ . '/config.php';
+
+// load Arcanist
+require PHABRICATOR_ARCANIST_PATH;
+
diff --git a/cli/clickup-task.php b/cli/clickup-task.php
new file mode 100755
index 0000000..1c4ce2e
--- /dev/null
+++ b/cli/clickup-task.php
@@ -0,0 +1,48 @@
+#!/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 Task info
+ *
+ * If you specify also a Phabricator Task ID, it connects both.
+ */
+
+// autoload libraries
+require __DIR__ . '/../autoload.php';
+
+$clickup_task_id = $argv[1] ?? null;
+$phab_task_id = $argv[2] ?? null;
+
+if( !$clickup_task_id ) {
+ echo "RTFM\n";
+ exit;
+}
+
+$clickup_task_details = ClickUpAPI::queryTaskData( $clickup_task_id );
+
+echo json_encode( $clickup_task_details, JSON_PRETTY_PRINT );
+echo "\n";
+
+if( $phab_task_id ) {
+ $phab_task_details = PhabricatorAPI::getTask( $phab_task_id );
+
+ ClickUpPhabricatorCache::instance()->newlock()->registerClickupPhabricatorTaskID( $clickup_task_id, $phab_task_details )->save();
+
+ echo json_encode( $phab_task_details, JSON_PRETTY_PRINT );
+ echo "\n";
+}
diff --git a/cli/phab-task.php b/cli/phab-task.php
new file mode 100755
index 0000000..5c2e163
--- /dev/null
+++ b/cli/phab-task.php
@@ -0,0 +1,36 @@
+#!/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 .
+
+/**
+ * Receive Phabricator/Phorge Task info
+ */
+
+// autoload libraries
+require __DIR__ . '/../autoload.php';
+
+$phab_task_id = $argv[1] ?? null;
+
+if( !$phab_task_id ) {
+ echo "RTFM\n";
+ exit;
+}
+
+$phab_task_details = PhabricatorAPI::getTask( $phab_task_id );
+
+echo json_encode( $phab_task_details, JSON_PRETTY_PRINT );
+echo "\n";
diff --git a/composer.json b/composer.json
new file mode 100644
index 0000000..9750c5f
--- /dev/null
+++ b/composer.json
@@ -0,0 +1,15 @@
+{
+ "name": "valerio/er-clickup-phabricator",
+ "description": "Integrate ClickUp with Phabricator",
+ "type": "project",
+ "require": {
+ "guzzlehttp/guzzle": "^7.5"
+ },
+ "license": "GNU GPL",
+ "authors": [
+ {
+ "name": "Valerio Bozzolan",
+ "email": "nsa+gitspam@succhia.cz"
+ }
+ ]
+}
diff --git a/composer.lock b/composer.lock
new file mode 100644
index 0000000..aa361b4
--- /dev/null
+++ b/composer.lock
@@ -0,0 +1,593 @@
+{
+ "_readme": [
+ "This file locks the dependencies of your project to a known state",
+ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
+ "This file is @generated automatically"
+ ],
+ "content-hash": "b53040c580b4fe7bc79702fe20e51ba2",
+ "packages": [
+ {
+ "name": "guzzlehttp/guzzle",
+ "version": "7.5.0",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/guzzle.git",
+ "reference": "b50a2a1251152e43f6a37f0fa053e730a67d25ba"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/guzzle/zipball/b50a2a1251152e43f6a37f0fa053e730a67d25ba",
+ "reference": "b50a2a1251152e43f6a37f0fa053e730a67d25ba",
+ "shasum": ""
+ },
+ "require": {
+ "ext-json": "*",
+ "guzzlehttp/promises": "^1.5",
+ "guzzlehttp/psr7": "^1.9 || ^2.4",
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-client": "^1.0",
+ "symfony/deprecation-contracts": "^2.2 || ^3.0"
+ },
+ "provide": {
+ "psr/http-client-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.1",
+ "ext-curl": "*",
+ "php-http/client-integration-tests": "^3.0",
+ "phpunit/phpunit": "^8.5.29 || ^9.5.23",
+ "psr/log": "^1.1 || ^2.0 || ^3.0"
+ },
+ "suggest": {
+ "ext-curl": "Required for CURL handler support",
+ "ext-intl": "Required for Internationalized Domain Name (IDN) support",
+ "psr/log": "Required for using the Log middleware"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-master": "7.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions_include.php"
+ ],
+ "psr-4": {
+ "GuzzleHttp\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Jeremy Lindblom",
+ "email": "jeremeamia@gmail.com",
+ "homepage": "https://github.com/jeremeamia"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle is a PHP HTTP client library",
+ "keywords": [
+ "client",
+ "curl",
+ "framework",
+ "http",
+ "http client",
+ "psr-18",
+ "psr-7",
+ "rest",
+ "web service"
+ ],
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/guzzle",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-08-28T15:39:27+00:00"
+ },
+ {
+ "name": "guzzlehttp/promises",
+ "version": "1.5.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/promises.git",
+ "reference": "b94b2807d85443f9719887892882d0329d1e2598"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/promises/zipball/b94b2807d85443f9719887892882d0329d1e2598",
+ "reference": "b94b2807d85443f9719887892882d0329d1e2598",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.5"
+ },
+ "require-dev": {
+ "symfony/phpunit-bridge": "^4.4 || ^5.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.5-dev"
+ }
+ },
+ "autoload": {
+ "files": [
+ "src/functions_include.php"
+ ],
+ "psr-4": {
+ "GuzzleHttp\\Promise\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ }
+ ],
+ "description": "Guzzle promises library",
+ "keywords": [
+ "promise"
+ ],
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/promises",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-08-28T14:55:35+00:00"
+ },
+ {
+ "name": "guzzlehttp/psr7",
+ "version": "2.4.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/guzzle/psr7.git",
+ "reference": "67c26b443f348a51926030c83481b85718457d3d"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/guzzle/psr7/zipball/67c26b443f348a51926030c83481b85718457d3d",
+ "reference": "67c26b443f348a51926030c83481b85718457d3d",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.2.5 || ^8.0",
+ "psr/http-factory": "^1.0",
+ "psr/http-message": "^1.0",
+ "ralouphie/getallheaders": "^3.0"
+ },
+ "provide": {
+ "psr/http-factory-implementation": "1.0",
+ "psr/http-message-implementation": "1.0"
+ },
+ "require-dev": {
+ "bamarni/composer-bin-plugin": "^1.8.1",
+ "http-interop/http-factory-tests": "^0.9",
+ "phpunit/phpunit": "^8.5.29 || ^9.5.23"
+ },
+ "suggest": {
+ "laminas/laminas-httphandlerrunner": "Emit PSR-7 responses"
+ },
+ "type": "library",
+ "extra": {
+ "bamarni-bin": {
+ "bin-links": true,
+ "forward-command": false
+ },
+ "branch-alias": {
+ "dev-master": "2.4-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "GuzzleHttp\\Psr7\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Graham Campbell",
+ "email": "hello@gjcampbell.co.uk",
+ "homepage": "https://github.com/GrahamCampbell"
+ },
+ {
+ "name": "Michael Dowling",
+ "email": "mtdowling@gmail.com",
+ "homepage": "https://github.com/mtdowling"
+ },
+ {
+ "name": "George Mponos",
+ "email": "gmponos@gmail.com",
+ "homepage": "https://github.com/gmponos"
+ },
+ {
+ "name": "Tobias Nyholm",
+ "email": "tobias.nyholm@gmail.com",
+ "homepage": "https://github.com/Nyholm"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://github.com/sagikazarmark"
+ },
+ {
+ "name": "Tobias Schultze",
+ "email": "webmaster@tubo-world.de",
+ "homepage": "https://github.com/Tobion"
+ },
+ {
+ "name": "Márk Sági-Kazár",
+ "email": "mark.sagikazar@gmail.com",
+ "homepage": "https://sagikazarmark.hu"
+ }
+ ],
+ "description": "PSR-7 message implementation that also provides common utility methods",
+ "keywords": [
+ "http",
+ "message",
+ "psr-7",
+ "request",
+ "response",
+ "stream",
+ "uri",
+ "url"
+ ],
+ "funding": [
+ {
+ "url": "https://github.com/GrahamCampbell",
+ "type": "github"
+ },
+ {
+ "url": "https://github.com/Nyholm",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/guzzlehttp/psr7",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-10-26T14:07:24+00:00"
+ },
+ {
+ "name": "psr/http-client",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-client.git",
+ "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-client/zipball/2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
+ "reference": "2dfb5f6c5eff0e91e20e913f8c5452ed95b86621",
+ "shasum": ""
+ },
+ "require": {
+ "php": "^7.0 || ^8.0",
+ "psr/http-message": "^1.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Client\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP clients",
+ "homepage": "https://github.com/php-fig/http-client",
+ "keywords": [
+ "http",
+ "http-client",
+ "psr",
+ "psr-18"
+ ],
+ "time": "2020-06-29T06:28:15+00:00"
+ },
+ {
+ "name": "psr/http-factory",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-factory.git",
+ "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-factory/zipball/12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+ "reference": "12ac7fcd07e5b077433f5f2bee95b3a771bf61be",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.0.0",
+ "psr/http-message": "^1.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interfaces for PSR-7 HTTP message factories",
+ "keywords": [
+ "factory",
+ "http",
+ "message",
+ "psr",
+ "psr-17",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "time": "2019-04-30T12:38:16+00:00"
+ },
+ {
+ "name": "psr/http-message",
+ "version": "1.0.1",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/php-fig/http-message.git",
+ "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/php-fig/http-message/zipball/f6561bf28d520154e4b0ec72be95418abe6d9363",
+ "reference": "f6561bf28d520154e4b0ec72be95418abe6d9363",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.3.0"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-master": "1.0.x-dev"
+ }
+ },
+ "autoload": {
+ "psr-4": {
+ "Psr\\Http\\Message\\": "src/"
+ }
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "PHP-FIG",
+ "homepage": "http://www.php-fig.org/"
+ }
+ ],
+ "description": "Common interface for HTTP messages",
+ "homepage": "https://github.com/php-fig/http-message",
+ "keywords": [
+ "http",
+ "http-message",
+ "psr",
+ "psr-7",
+ "request",
+ "response"
+ ],
+ "time": "2016-08-06T14:39:51+00:00"
+ },
+ {
+ "name": "ralouphie/getallheaders",
+ "version": "3.0.3",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/ralouphie/getallheaders.git",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/ralouphie/getallheaders/zipball/120b605dfeb996808c31b6477290a714d356e822",
+ "reference": "120b605dfeb996808c31b6477290a714d356e822",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=5.6"
+ },
+ "require-dev": {
+ "php-coveralls/php-coveralls": "^2.1",
+ "phpunit/phpunit": "^5 || ^6.5"
+ },
+ "type": "library",
+ "autoload": {
+ "files": [
+ "src/getallheaders.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Ralph Khattar",
+ "email": "ralph.khattar@gmail.com"
+ }
+ ],
+ "description": "A polyfill for getallheaders.",
+ "time": "2019-03-08T08:55:37+00:00"
+ },
+ {
+ "name": "symfony/deprecation-contracts",
+ "version": "v2.5.2",
+ "source": {
+ "type": "git",
+ "url": "https://github.com/symfony/deprecation-contracts.git",
+ "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66"
+ },
+ "dist": {
+ "type": "zip",
+ "url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/e8b495ea28c1d97b5e0c121748d6f9b53d075c66",
+ "reference": "e8b495ea28c1d97b5e0c121748d6f9b53d075c66",
+ "shasum": ""
+ },
+ "require": {
+ "php": ">=7.1"
+ },
+ "type": "library",
+ "extra": {
+ "branch-alias": {
+ "dev-main": "2.5-dev"
+ },
+ "thanks": {
+ "name": "symfony/contracts",
+ "url": "https://github.com/symfony/contracts"
+ }
+ },
+ "autoload": {
+ "files": [
+ "function.php"
+ ]
+ },
+ "notification-url": "https://packagist.org/downloads/",
+ "license": [
+ "MIT"
+ ],
+ "authors": [
+ {
+ "name": "Nicolas Grekas",
+ "email": "p@tchwork.com"
+ },
+ {
+ "name": "Symfony Community",
+ "homepage": "https://symfony.com/contributors"
+ }
+ ],
+ "description": "A generic function and convention to trigger deprecation notices",
+ "homepage": "https://symfony.com",
+ "funding": [
+ {
+ "url": "https://symfony.com/sponsor",
+ "type": "custom"
+ },
+ {
+ "url": "https://github.com/fabpot",
+ "type": "github"
+ },
+ {
+ "url": "https://tidelift.com/funding/github/packagist/symfony/symfony",
+ "type": "tidelift"
+ }
+ ],
+ "time": "2022-01-02T09:53:40+00:00"
+ }
+ ],
+ "packages-dev": [],
+ "aliases": [],
+ "minimum-stability": "stable",
+ "stability-flags": [],
+ "prefer-stable": false,
+ "prefer-lowest": false,
+ "platform": [],
+ "platform-dev": [],
+ "plugin-api-version": "1.1.0"
+}
diff --git a/config-example.php b/config-example.php
index b937604..865d070 100644
--- a/config-example.php
+++ b/config-example.php
@@ -1,25 +1,45 @@
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' );
// 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/data/.keep b/data/.keep
new file mode 100644
index 0000000..e69de29
diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php
new file mode 100644
index 0000000..d7c6fb6
--- /dev/null
+++ b/include/class-ClickUpAPI.php
@@ -0,0 +1,141 @@
+ 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", self::query_team() );
+
+ 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;
+ $query = [
+ 'archived' => false,
+ ];
+ return self::requestGET( "/team/$team_id/spaces", $query );
+ }
+
+ /**
+ * https://clickup.com/api/clickupreference/operation/GetFolders/
+ */
+ private static function querySpaceFolders( $space_id ) {
+ $team_id = CLICKUP_TEAM_ID;
+ $query = [
+ 'archived' => false,
+ ];
+ return self::requestGET( "/space/$space_id/folders", $query );
+ }
+
+ private static function request( $method, $path, $query = [] ) {
+
+ $url = "https://api.clickup.com/api/v2" . $path;
+ $API_TOKEN = CLICKUP_API_TOKEN;
+ $curl_opts = [
+ CURLOPT_HTTPHEADER => [
+ "Authorization: $API_TOKEN",
+ "Content-Type: application/json"
+ ],
+ CURLOPT_URL => $url . '?' . http_build_query( $query ),
+ CURLOPT_CUSTOMREQUEST => $method,
+ CURLOPT_RETURNTRANSFER => true,
+ ];
+
+ $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 );
+ }
+
+ return json_decode( $response );
+ }
+
+ public static function requestGET( $path, $query = [] ) {
+ return self::request( "GET", $path, $query );
+ }
+
+ public static function requestPUT( $path, $query = [] ) {
+ return self::request( "PUT", $path, $query );
+ }
+
+ 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
new file mode 100644
index 0000000..42a98f8
--- /dev/null
+++ b/include/class-ClickUpPhabricatorCache.php
@@ -0,0 +1,437 @@
+ 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->phabricatorData = $task->phabricatorData ?? new StdClass();
+ $phab_data = $task->phabricatorData;
+
+ // 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: phabricatorData always exists
+ return $this->getClickUpTask( $click_id )->phabricatorData;
+ }
+
+ 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;
+ }
+
+ 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->phabricatorData = $folder->phabricatorData ?? new StdClass();
+
+ return $folders->{ $id };
+ }
+
+ public function getPhabricatorTagFromClickupTaskID( $id ) {
+ $task = $this->getClickupTask();
+ }
+
+ public function getPhabricatorTagFromClickupFolderID( $id ) {
+ $folder = $this->getClickupFolderByID( $id );
+ return $folder->phabricatorData->id;
+ }
+
+ public function importClickupFolder( $data ) {
+ $id = $data->id;
+ $folder = $this->getClickupFolderByID( $id );
+ $folder->id = $data->id;
+ $folder->name = $data->name;
+ }
+
+ /**
+ * 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() ) {
+ $this->fp = fopen( $this->file, 'w+' );
+ if( !$this->fp ) {
+ 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" );
+ }
+
+ $content .= $part;
+ }
+
+ // assume at least a valid empty JSON
+ if( !$content ) {
+ $content = '{}';
+ }
+
+ // try to parse content
+ $data = json_decode( $content );
+ if( $data === false ) {
+ 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 );
+ if( !$locked ) {
+ throwFileException( "cannot acquire lock" );
+ }
+ $this->locked = true;
+ }
+
+ /**
+ * Write the cache on the filesystem
+ *
+ * @return self
+ */
+ private function write() {
+
+ // encode the cache in JSON
+ $raw = json_encode( $this->getCache() );
+ if( $raw === false ) {
+ throw new Exception( "cannot JSON encode" );
+ }
+
+ // try to write
+ $ok = fwrite( $this->getFilePointer(), $raw );
+ if( !$ok ) {
+ 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
new file mode 100644
index 0000000..5cf3780
--- /dev/null
+++ b/include/class-PhabricatorAPI.php
@@ -0,0 +1,103 @@
+ 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 $extra_query array Example: ['objectIdentifier' => 'PHID--...'] to edit
+ * @return mixed
+ */
+ public static function editTask( $entry_point, $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 );
+ }
+
+ /**
+ * 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;
+ }
+
+}