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; + } + +}