diff --git a/config-example.php b/config-example.php
index 431cef9..22cbd3e 100644
--- a/config-example.php
+++ b/config-example.php
@@ -1,67 +1,82 @@
ClickUp bot
# Copyright (C) 2023 Valerio Bozzolan, contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
// ClickUp Team ID - you can find it from your home URL
// Example: "123" if your home URL is "https://app.clickup.com/123/v/l/li/456456456456"
define( 'CLICKUP_TEAM_ID', 123 );
// URL exposing the "webhook-clickup" directory from this repository
// NOTE: this MUST be a valid and public URL so ClickUp can use that as webhook
// NOTE: the checkorigin token is just a random token you should invent
define( 'CLICKUP_WEBHOOK_PUBLIC_ENDPOINT', 'https://example.com/clickup-phabricator-phorge-bot/webhook-clickup/?checkorigintoken=abcdefghilmpopqrstuvz123' );
+// URL exposing the "webhook-phabricator" directory from this repository
+// NOTE: this MUST be a valid and public URL so ClickUp can use that as webhook
+// NOTE: the checkorigin token is just a random token you should invent
+define( 'PHABRICATOR_WEBHOOK_PUBLIC_ENDPOINT', 'https://example.com/clickup-phabricator-phorge-bot/webhook-phabricator/' );
+
// obtained after registering the webhook using register-webhook.php
define( 'CLICKUP_REGISTERED_WEBHOOK_ID', '123-123-123-123-123' );
// keep this in sync with the random token you invented in CLICKUP_WEBHOOK_PUBLIC_ENDPOINT
define( 'CLICKUP_WEBHOOK_CHECKORIGINTOKEN', 'abcdefghilmpopqrstuvz123' );
+// the crypto Phabricator Webhook HMAC key obtained from the Herald > Webhooks page
+define( 'PHABRICATOR_WEBHOOK_HMAC_KEY', 'abcdefghilmpopqrstuvz123' );
+
// when true, CLICKUP_WEBHOOK_PUBLIC_ENDPOINT is compared to the one in CLICKUP_WEBHOOK_PUBLIC_ENDPOINT
define( 'CLICKUP_VALIDATE_WEBHOOK', true );
+// when true, PHABRICATOR_WEBHOOK_PUBLIC_ENDPOINT is compared to the one in CLICKUP_WEBHOOK_PUBLIC_ENDPOINT
+define( 'PHABRICATOR_VALIDATE_WEBHOOK', true );
+
// 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' );
// validate the exact webhook URL
// if it does not match it does nothing
// this is useful if you put some very weird and secret query strings here:
// CLICKUP_WEBHOOK_PUBLIC_ENDPOINT
define( 'CLICKUP_VALIDATE_WEBHOOK', true );
+// comment prefix from Phabricator to ClickUp
+// this is also used to identify comments posted FROM Phabricator TO ClickUp from our bot itself
+define( 'COMMENT_PREFIX_FROM_PHABRICATOR', "From Phabricator:" );
+
// homepage of the project
// just to know where you can send bugs
define( 'REPOSITORY_URL', 'https://gitpull.it/source/clickup-phabricator-bot/' );
diff --git a/include/class-ClickUpAPI.php b/include/class-ClickUpAPI.php
index 05bf5ff..53ff017 100644
--- a/include/class-ClickUpAPI.php
+++ b/include/class-ClickUpAPI.php
@@ -1,276 +1,289 @@
ClickUp bot
# Copyright (C) 2023 Valerio Bozzolan, contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
/**
* Utility to run HTTP queries against ClickUp
*/
class ClickUpAPI {
/**
* https://clickup.com/api/clickupreference/operation/GetSpaces/
*
* @return array
*/
public static function getSpaces() {
return self::querySpaces()->spaces ?? [];
}
/**
* https://clickup.com/api/clickupreference/operation/GetSpaces/
*
* @return array
*/
public static function getSpaceFolders( $space_id ) {
return self::querySpaceFolders( $space_id )->folders ?? [];
}
/**
* https://clickup.com/api/clickupreference/operation/GetTask/
*/
public static function queryTaskData( $task_id ) {
// append the Team ID since it does not work otherwise
$task_details = self::requestGET( "/task/$task_id", self::query_team() );
// no Task no party
if( !$task_details ) {
throw new Exception( "missing ClickUp Task $task_id" );
}
// save some stuff in the cache
ClickUpPhabricatorCache::instance()
->newlock()
->importClickupTaskData( $task_id, $task_details )
->save();
return $task_details;
}
/**
* https://clickup.com/api/clickupreference/operation/UpdateTask/
*/
public static function putTaskData( $task_id, $query = [], $payload = [] ) {
$task_details = self::requestPUT( "/task/$task_id", $query, $payload );
ClickUpPhabricatorCache::instance()
->newlock()
->importClickupTaskData( $task_id, $task_details )
->save();
return $task_details;
}
/**
* Convert a ClickUp status to a Phabricator one
*
* Note that ClickUp has a "type" and a "status".
*
* @return string
*/
public static function taskStatusTypeToPhabricator( $type, $status = null ) {
if( $type === 'closed' ) {
return 'resolved';
}
if( $type === 'open' ) {
return $type;
}
if( $type === 'custom' ) {
if( $status === 'in progress' ) {
return 'doing';
}
}
return $status ?? $type;
}
/**
* Register the ClickUp webhook
*/
public static function registerWebhook( $endpoint = null, $events = null ) {
// assume the default endpoint
if( !$endpoint) {
$endpoint = CLICKUP_WEBHOOK_PUBLIC_ENDPOINT;
}
// assume all events
if( !$events ) {
$events = self::default_webhook_events();
}
$payload = [
"endpoint" => $endpoint,
"events" => $events,
];
$team_id = CLICKUP_TEAM_ID;
return self::requestPOST( "/team/$team_id/webhook", [], $payload );
}
+ /**
+ * Add comment to a Task
+ *
+ * https://clickup.com/api/clickupreference/operation/CreateTaskComment/
+ */
+ public static function addTaskComment( $task_id, $comment_text ) {
+ $payload = [
+ 'comment_text' => $comment_text,
+// 'assignee' => 123,
+ ];
+ return self::requestPOST( "/task/$task_id/comment", [], $payload );
+ }
+
/**
* https://clickup.com/api/clickupreference/operation/GetWebhooks/
*/
public static function queryWebhooks() {
$team_id = CLICKUP_TEAM_ID;
$response = self::requestGET( "/team/{$team_id}/webhook", [], [] );
return $response->webhooks;
}
/**
* https://clickup.com/api/clickupreference/operation/DeleteWebhook/
*/
public static function deleteWebhook( $webhook_id = null ) {
if( !$webhook_id ) {
$webhook_id = CLICKUP_REGISTERED_WEBHOOK_ID;
}
$team_id = CLICKUP_TEAM_ID;
return self::request( "DELETE", "/team/{$team_id}/webhook/{$webhook_id}", [], [] );
}
/**
* https://clickup.com/api/clickupreference/operation/UpdateWebhook/
*/
public static function updateWebhook( $webhook_id = null, $endpoint = null, $events = [] ) {
if( !$webhook_id ) {
$webhook_id = CLICKUP_REGISTERED_WEBHOOK_ID;
}
if( !$endpoint ) {
$endpoint = CLICKUP_WEBHOOK_PUBLIC_ENDPOINT;
}
$payload = [
'endpoint' => $endpoint,
];
return self::requestPUT( "/webhook/{$webhook_id}", [], $payload );
}
/**
* https://clickup.com/api/clickupreference/operation/GetSpaces/
*
* @return mixed
*/
private static function querySpaces() {
$team_id = CLICKUP_TEAM_ID;
return self::requestGET( "/team/$team_id/space" );
}
/**
* https://clickup.com/api/clickupreference/operation/GetFolders/
*/
private static function querySpaceFolders( $space_id ) {
$team_id = CLICKUP_TEAM_ID;
return self::requestGET( "/space/$space_id/folder" );
}
private static function request( $method, $path, $query = [], $payload = [] ) {
$API_TOKEN = CLICKUP_API_TOKEN;
$url = "https://api.clickup.com/api/v2" . $path;
$full_url = $url . '?' . http_build_query( $query );
$curl_opts = [
CURLOPT_HTTPHEADER => [
"Authorization: $API_TOKEN",
"Content-Type: application/json"
],
CURLOPT_URL => $full_url,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
];
// PUT requests
if( $payload ) {
$payload_raw = json_encode( $payload );
$curl_opts[CURLOPT_POSTFIELDS] = $payload_raw;
}
$curl = curl_init();
curl_setopt_array( $curl, $curl_opts );
$response = curl_exec( $curl );
$error = curl_error( $curl );
curl_close( $curl );
if( $error ) {
throw new Exception( "cURL Error ClickUp #:" . $error );
}
$data = json_decode( $response );
if( $data === false ) {
throw new Exception( "cannot parse JSON of ClickUp response" );
}
// formal error from ClickUp
if( isset( $data->err ) ) {
throw new Exception( sprintf(
"ClickUp API error: %s",
json_encode( $data )
) );
}
return $data;
}
public static function requestGET( $path, $query = [], $payload = [] ) {
return self::request( "GET", $path, $query, $payload );
}
public static function requestPUT( $path, $query, $payload = [] ) {
return self::request( "PUT", $path, $query, $payload );
}
public static function requestPOST( $path, $query, $payload = [] ) {
return self::request( "POST", $path, $query, $payload );
}
private static function query_team( $query = [] ) {
$query[ 'team_id' ] = CLICKUP_TEAM_ID;
return $query;
}
private static function default_webhook_events() {
return [
"taskCreated",
"taskUpdated",
"taskDeleted",
"taskPriorityUpdated",
"taskStatusUpdated",
"taskAssigneeUpdated",
"taskDueDateUpdated",
"taskTagUpdated",
"taskMoved",
"taskCommentPosted",
"taskCommentUpdated",
"taskTimeEstimateUpdated",
"taskTimeTrackedUpdated",
"listCreated",
"listUpdated",
"listDeleted",
"folderCreated",
"folderUpdated",
"folderDeleted",
"spaceCreated",
"spaceUpdated",
"spaceDeleted",
"goalCreated",
"goalUpdated",
"goalDeleted",
"keyResultCreated",
"keyResultUpdated",
"keyResultDeleted",
];
}
}
diff --git a/include/class-PhabricatorAPI.php b/include/class-PhabricatorAPI.php
index da425b8..70fd623 100644
--- a/include/class-PhabricatorAPI.php
+++ b/include/class-PhabricatorAPI.php
@@ -1,163 +1,198 @@
ClickUp bot
# Copyright (C) 2023 Valerio Bozzolan, contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
/**
* Utility to run HTTP queries against 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 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,
] );
}
+ /**
+ * Convert a Phabricator status to a Clickup status
+ */
+ public static function status2clickup( $task_status ) {
+ // TODO: read from "maniphest.status.search" and set in cache and read from there
+ $closed_statuses = [
+ 'resolved',
+ 'wontfix',
+ 'invalid',
+ 'duplicate',
+ 'spite',
+ ];
+ if( in_array( $task_status, $closed_statuses, true ) ) {
+ return 'closed';
+ }
+ return 'open';
+ }
+
/**
* 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 ],
],
'attachments' => [
'columns' => true,
'projects' => true,
],
];
return self::searchSingleTask( $query );
}
public static function getTaskByPHID( $phid ) {
$query = [
'constraints' => [
'phids' => [ $phid ],
],
'attachments' => [
'columns' => true,
'projects' => true,
],
];
return self::searchSingleTask( $query );
}
+ public static function searchSingleUserByPHID( $phid ) {
+ $query = [
+ 'constraints' => [
+ 'phids' => [ $phid ],
+ ],
+ ];
+ return self::searchSingleUser( $query );
+ }
+
public static function searchSingleTask( $query ) {
- $results = self::query( 'maniphest.search', $query );
+ return self::searchSingleResult( 'maniphest.search', $query );
+ }
+
+ public static function searchSingleUser( $query ) {
+ return self::searchSingleResult( 'user.search', $query );
+ }
+
+ public static function searchSingleResult( $method, $query ) {
+
+ $results = self::query( $method, $query );
// just the first one is OK
foreach( $results['data'] as $entry ) {
return $entry;
}
- throw new Exception( "Phabricator Task not found: $task_id" );
+ throw new Exception( "Phabricator result not found from $method using: " . json_encode( $query ) );
}
public static function searchObjectTransactionsFromTransactions( $phab_object_id, $transactions ) {
$transaction_phids = [];
foreach( $transactions as $transaction ) {
$transaction_phids[] = $transaction['phid'];
}
return self::searchObjectTransactionsFromPHIDs( $phab_object_id, $transaction_phids );
}
public static function searchObjectTransactionsFromPHIDs( $phab_object_id, $transaction_phids ) {
$query = [
'objectIdentifier' => $phab_object_id,
'constraints' => [
- 'phids' => $phids,
+ 'phids' => $transaction_phids,
],
];
-
- $results = self::query( 'maniphest.search', $query );
+ $results = self::query( 'transaction.search', $query );
return $results['data'];
}
private static function transaction( $type, $value ) {
return [
'type' => $type,
'value' => $value,
];
}
/**
* Sanitize a Task ID
*
* @param $task_id mixed
* @return int
*/
public 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;
}
}
diff --git a/public/webhook-clickup/index.php b/public/webhook-clickup/index.php
index 687a372..65898a2 100644
--- a/public/webhook-clickup/index.php
+++ b/public/webhook-clickup/index.php
@@ -1,176 +1,199 @@
ClickUp bot
# Copyright (C) 2023 Valerio Bozzolan, contributors
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
/**
* Receive ClickUp webhooks
*
* Documentation:
* https://clickup.com/api/developer-portal/webhooktaskpayloads/
*/
// default response code
http_response_code( 500 );
require realpath( __DIR__ ) . '/../autoload.php';
$response = file_get_contents('php://input');
$response_data = json_decode( $response );
$event = $response_data->event ?? null;
$clickup_task_id = $response_data->task_id ?? null;
$webhook_id = $_SERVER['HTTP_X_SIGNATURE'] ?? null;
// find history items (and the first one)
$history_items = $response_data->history_items ?? [];
$history_item_first = null;
foreach( $history_items as $history_item_first ) {
break;
}
// check incoming request
if( CLICKUP_VALIDATE_WEBHOOK ) {
// TODO
//if( $webhook_id !== CLICKUP_REGISTERED_WEBHOOK_ID ) {
// throw new Exception( sprintf( "unknown webhook ID: %s", $webhook_id ) );
//}
$checkorigintoken = $_GET['checkorigintoken'] ?? null;
if( $checkorigintoken !== CLICKUP_WEBHOOK_CHECKORIGINTOKEN ) {
echo "Invalid check origin token\n";
http_response_code( 400 );
exit;
}
}
$cache = ClickUpPhabricatorCache::instance();
switch( $event ) {
case 'taskCreated':
// check if we already know about it
$phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id );
if( $phab_task_id ) {
// probably Phabricator already created it from the Phabricator webhook
error_log( "skip new ClickUp Task $clickup_task_id: already imported in Phab as $phab_task_id" );
} else {
// let's create the Task
// get more ClickUp info (title and description etc.) and remember in cache
$clickup_task_details = ClickUpAPI::queryTaskData( $clickup_task_id );
// TODO: move in the QueryTaskData some validation on these
$clickup_task_title = $clickup_task_details->name;
$clickup_task_descr = $clickup_task_details->description;
$clickup_task_folder = $clickup_task_details->folder;
$clickup_task_folder_id = $clickup_task_folder->id;
$clickup_task_folder_name = $clickup_task_folder->name;
if( !$clickup_task_title ) {
throw new Exception( "missing title in new ClickUp Task" );
}
// prepare description for new Task in Phabricator
$phab_task_description = $clickup_task_descr;
$phab_task_description .= "\n\n";
$phab_task_description .= "> https://app.clickup.com/t/$clickup_task_id";
$phab_task_args = [];
// try to assign to the right project
$phab_task_project = $cache->getPhabricatorTagIDFromClickupFolderID( $clickup_task_folder_id );
if( $phab_task_project ) {
// assign to the right project
$phab_task_args['projects.add'] = [ $phab_task_project ];
} else {
// or just give an hint
$clickup_task_title = "[$clickup_task_folder_name] $clickup_task_title";
}
$phab_task_args['title'] = $clickup_task_title;
$phab_task_args['description'] = $phab_task_description;
// create Task in Phabricator
$phab_task_data = PhabricatorAPI::createTask( $phab_task_args );
$phab_task_phid = $phab_task_data['object']['phid']; // TASK-PHID-00000000
$phab_task_id = $phab_task_data['object']['id']; // 123 for T123
// associate the Phabricator Task to the Conduit Task in the cache
$cache->newlock()->registerClickupPhabricatorTaskID( $clickup_task_id, $phab_task_id )->save();
// update Click-Up adding the Phabricator URL
$phab_task_url = PHABRICATOR_URL . "T{$phab_task_id}";
ClickUpAPI::putTaskData( $clickup_task_id, [], [
'description' => "$clickup_task_descr\n\n$phab_task_url",
] );
}
break;
case 'taskDeleted':
- // mark as invalid in Phabricator
+ // if it's deleted in ClickUp, close as invalid in Phabricator
$phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id );
if( $phab_task_id ) {
PhabricatorAPI::editTask( $phab_task_id, [
'status' => 'invalid',
] );
}
- // drop from cache
+ // drop ClickUp Task from internal cache
$cache->removeClickupTask( $clickup_task_id );
break;
case 'taskCommentPosted':
// post the comment on Phabricator too
$phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id );
if( $phab_task_id && $history_item_first ) {
$clickup_comment_author_name = $history_item_first->user->username;
$clickup_comment_author_id = $history_item_first->user->id;
$clickup_task_description = $history_item_first->comment->text_content;
- $phab_comment = "From **{$clickup_comment_author_name}**:\n\n" . $clickup_task_description;
- $phab_task_data = PhabricatorAPI::editTask( $phab_task_id, [
- 'comment' => $phab_comment,
- ] );
+ // avoid to post on Phabricator, comments that are coming from our Phabricator bot
+ if( strpos( $clickup_task_description, COMMENT_PREFIX_FROM_PHABRICATOR ) === 0 ) {
+
+ error_log( "skip ClickUp comment that was posted by the Phabricator bot" );
+
+ } else {
+
+ // add a nice comment to Phabricator
+ $phab_comment = "From **{$clickup_comment_author_name}**:\n\n" . $clickup_task_description;
+ $phab_task_data = PhabricatorAPI::editTask( $phab_task_id, [
+ 'comment' => $phab_comment,
+ ] );
+ }
}
break;
case 'taskStatusUpdated':
+ // check i the Phabricator Task was already connected to this ClickUp Task
$phab_task_id = $cache->getClickupTaskPhabricatorID( $clickup_task_id );
if( $phab_task_id && $history_item_first ) {
$clickup_task_status = $history_item_first->data->status_type;
- $phab_task_status_new = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task_status );
- if( $phab_task_status_new === 'resolved' || $phab_task_status_new === 'open' ) {
- PhabricatorAPI::editTask( $phab_task_id, [
- 'status' => $phab_task_status_new,
- ] );
+ $clickup_task_status_for_phab = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task_status );
+ if( $clickup_task_status_for_phab === 'resolved' || $clickup_task_status_for_phab === 'open' ) {
+
+ // fetch fresh Phabricator Task data
+ $phab_task = PhabricatorAPI::getTask( $phab_task_id );
+ $phab_task_status = $phab_task['fields']['status']['value'];
+
+ // update Phab only if the status changes
+ if( $phab_task_status !== $clickup_task_status_for_phab ) {
+
+ error_log( "ClickUp Task $clickup_task_id status: $clickup_task_status_for_phab - Phab Task $phab_task_id status: $phab_task_status - updating" );
+
+ // set the ClickUp Task status in Phabricator as well
+ PhabricatorAPI::editTask( $phab_task_id, [
+ 'status' => $clickup_task_status_for_phab,
+ ] );
+ }
+
} else {
- error_log( "do nothing for status: $phab_task_status_new" );
+ error_log( "do nothing for status: $clickup_task_status (Phab: $clickup_task_status_for_phab)" );
}
}
break;
default:
echo "Method not supported.\n";
error_log( "unsupported ClickUp webhoook: $event" );
}
// success
http_response_code( 200 );
diff --git a/public/webhook-phabricator/index.php b/public/webhook-phabricator/index.php
new file mode 100644
index 0000000..d026b7d
--- /dev/null
+++ b/public/webhook-phabricator/index.php
@@ -0,0 +1,169 @@
+ ClickUp bot
+# Copyright (C) 2023 Valerio Bozzolan, contributors
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+
+/**
+ * Receive Phabricator webhooks
+ *
+ * Documentation:
+ * https://we.phorge.it/conduit/
+ *
+ * See also Herald rule.
+ */
+
+// default response code
+http_response_code( 500 );
+
+// load needed libraries
+require realpath( __DIR__ ) . '/../autoload.php';
+
+// parse HTTP payload
+$response = file_get_contents('php://input');
+
+// parse as an array since native Conduit also handles everything as an array
+$response_data = json_decode( $response, JSON_OBJECT_AS_ARRAY );
+
+// check incoming request
+if( PHABRICATOR_VALIDATE_WEBHOOK ) {
+
+ // no signature no party
+ $signature_header = $_SERVER['HTTP_X_PHABRICATOR_WEBHOOK_SIGNATURE'] ?? null;
+ if( !$signature_header ) {
+ http_response_code( 400 );
+ echo "This is not a request from an official Phabricator webhook.\n";
+ exit;
+ }
+
+ // do the math
+ $signature_calculated = hash_hmac( 'sha256', $response, PHABRICATOR_WEBHOOK_HMAC_KEY );
+ if( $signature_calculated !== $signature_header ) {
+ http_response_code( 400 );
+ error_log( "invalid signature, calculated $signature_calculated" );
+ exit;
+ }
+}
+
+// no object no party
+$object = $response_data['object'] ?? null;
+if( !$object ) {
+ http_response_code( 400 );
+ throw new Exception( "received empty Phabricator object" );
+}
+
+$transactions = $response_data['transactions'] ?? [];
+
+$object_type = $object['type'];
+$object_phid = $object['phid'];
+
+$cache = ClickUpPhabricatorCache::instance();
+
+switch( $object_type ) {
+
+ case 'TASK':
+
+ // query fresh Task data
+ $phab_task = PhabricatorAPI::getTaskByPHID( $object_phid );
+ if( !$phab_task ) {
+ throw new Exception( "missing Task: {$object_phid}" );
+ }
+
+ $clickup_changes = [];
+
+ // resolved / open / etc.
+ $phab_task_status = $phab_task['fields']['status']['value'];
+ $phab_task_id = $phab_task['id'];
+ $phab_task_phid = $phab_task['phid'];
+
+ // check if the Phabricator Task is already known to be connected to ClickUp
+ $clickup_task = null;
+ $clickup_task_cache = $cache->getClickupTaskFromPhabricatorTaskID( $phab_task_id );
+ if( $clickup_task_cache ) {
+ $clickup_task = ClickUpAPI::queryTaskData( $clickup_task_cache->id );
+ }
+
+ // no related ClickUp Task?
+ if( !$clickup_task ) {
+
+ // TODO: find Task projects
+ $phab_projects = [];
+
+ // TODO: find the related ClickUp folder
+ $clickup_folder = null;
+
+ error_log( "Phabricator Task T{$phab_task_id} has not a related ClickUp Task" );
+ }
+
+ if( $clickup_task ) {
+ // check if Phabricator has a different Task status than ClickUp
+ $clickup_task_status_for_phab = ClickUpAPI::taskStatusTypeToPhabricator( $clickup_task->status );
+ if( $clickup_task_status_for_phab !== $phab_task_status ) {
+
+ // update the Task status in ClickUp
+ $clickup_changes[ 'status' ] = PhabricatorAPI::status2clickup( $phab_task_status );
+ }
+ }
+
+ $phab_fields = $phab_task['fields'];
+ $phab_attachments = $phab_task['attachments'];
+ $phab_project_phids = $phab_attachments['projects']['projectPHIDs'];
+
+ if( $transactions ) {
+ $transaction_results = PhabricatorAPI::searchObjectTransactionsFromTransactions( $phab_task_phid, $transactions );
+ foreach( $transaction_results as $transaction_result ) {
+
+ // found some new Phabricator comments
+ $phab_comments = $transaction_result['comments'] ?? [];
+ foreach( $phab_comments as $phab_comment ) {
+
+ // Phabricator comment content
+ $comment_content_raw = $phab_comment['content']['raw'];
+
+ // Phabricator author (NOTE: it MUST exists)
+ $comment_author = PhabricatorAPI::searchSingleUserByPHID( $phab_comment['authorPHID'] );
+ $comment_author_name = $comment_author['fields']['realName'];
+
+ // post the comment on ClickUp
+ if( $clickup_task ) {
+ // "From Phabricator: Mario Rossi:\n\nHello world!"
+ // IMPORTANT: keep the COMMENT_PREFIX_FROM_PHABRICATOR at startup since now
+ // the ClickUp webhook controls this token at startup.
+ $clickup_comment = sprintf(
+ "%s %s:\n\n%s",
+ COMMENT_PREFIX_FROM_PHABRICATOR,
+ $comment_author_name,
+ $comment_content_raw
+ );
+ ClickupAPI::addTaskComment( $clickup_task->id, $clickup_comment );
+ }
+ }
+ }
+ }
+
+ // eventally update ClickUp with some changes
+ if( $clickup_task && $clickup_changes ) {
+ ClickUpAPI::putTaskData( $clickup_task->id, [], $clickup_changes );
+ }
+
+ break;
+
+ case 'HWBH':
+ error_log( "received Phabricator Webhook (test?)" );
+ break;
+
+}
+
+// success
+http_response_code( 200 );