From c839c85f725cdc79d1af8645a808d3f45830f389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20TISNE?= Date: Sun, 20 Oct 2024 11:44:32 +0200 Subject: [PATCH] Connector for Tuleap ITS (#275) Co-authored-by: Francisco Mancardi --- lib/functions/tlIssueTracker.class.php | 6 +- .../tuleaprestInterface.class.php | 523 ++++++++++++++++++ locale/en_GB/strings.txt | 7 + locale/en_US/strings.txt | 1 + locale/fr_FR/strings.txt | 2 + .../tuleap-php-api/lib/tuleap-rest-api.php | 287 ++++++++++ 6 files changed, 824 insertions(+), 2 deletions(-) create mode 100644 lib/issuetrackerintegration/tuleaprestInterface.class.php create mode 100644 third_party/tuleap-php-api/lib/tuleap-rest-api.php diff --git a/lib/functions/tlIssueTracker.class.php b/lib/functions/tlIssueTracker.class.php index 409eb6d283..6128dd9fa0 100644 --- a/lib/functions/tlIssueTracker.class.php +++ b/lib/functions/tlIssueTracker.class.php @@ -83,7 +83,9 @@ class tlIssueTracker extends tlObject 25 => array('type' => 'github','api' =>'rest', 'enabled' => false, 'order' => 25), 26 => array('type' => 'trello','api' =>'rest', - 'enabled' => true, 'order' => 26) + 'enabled' => true, 'order' => 26), + 27 => array('type' => 'tuleap','api' =>'rest', + 'enabled' => true, 'order' => 27) ); var $entitySpec = array('name' => 'string','cfg' => 'string','type' => 'int'); @@ -758,4 +760,4 @@ function checkXMLCfg($xmlString) return $op; } -} // end class \ No newline at end of file +} // end class diff --git a/lib/issuetrackerintegration/tuleaprestInterface.class.php b/lib/issuetrackerintegration/tuleaprestInterface.class.php new file mode 100644 index 0000000000..e98cdacdc7 --- /dev/null +++ b/lib/issuetrackerintegration/tuleaprestInterface.class.php @@ -0,0 +1,523 @@ +name = $name; + $this->interfaceViaDB = false; + $this->defaultResolvedStatus = array(); + $this->defaultResolvedStatus[] = 'Invalid'; + $this->defaultResolvedStatus[] = 'Wont Fix'; + $this->defaultResolvedStatus[] = 'Fixed'; + $this->defaultResolvedStatus[] = 'Works for me'; + $this->defaultResolvedStatus[] = 'Duplicate'; + $this->methodOpt = array('buildViewBugLink' => array('addSummary' => true, 'colorByStatus' => false)); + $this->connected = false; + + if( !$this->setCfg($config) ) + { + return false; + } else { + // check the tracker ID + if (property_exists($this->cfg, 'tracker')) { + $this->trackerID = trim((string) $this->cfg->tracker); + if ( strlen($this->trackerID) > 0 + && ! $this->checkTrackerIDSyntax($this->trackerID) ) { + return false; + } + } else { + // tracker ID may be absent (bug creation from Testlink will not be possible) + $this->trackerID = ""; + } + + // check the base URI + if (property_exists($this->cfg, 'uribase')) { + $this->URIBase = trim((string) $this->cfg->uribase); + if ( strlen($this->URIBase) > 0 + && ! $this->checkURLSyntax($this->URIBase) ) { + return false; + } + } else { + return false; + } + } + + $this->completeCfg(); + $this->connect(); + $this->setResolvedStatusCfg(); + + } + + /** + * checks a tracker id for validity (a numeric value) + * + * @param string tracker ID + * + * @return bool returns true if the tracker id has the right format + **/ + private function checkTrackerIDSyntax($trackerID) + { + $valid = true; + $blackList = '/\D/i'; + if (preg_match($blackList, $trackerID)) { + $valid = false; + } else { + $valid = (intval($trackerID) > 0); + } + + return $valid; + } + + /** + * checks a URL for validity + * + * @param string URL + * + * @return bool returns true if the param is an URL + **/ + private function checkURLSyntax($url) { + return (filter_var($url, FILTER_VALIDATE_URL) + && stripos($url, "http") === 0); + } + + /** + * useful for testing + * + * + **/ + function getAPIClient() + { + return $this->APIClient; + } + + /** + * Set resolved status + * + * @author Aurelien TISNE + **/ + public function setResolvedStatusCfg() + { + $statusCfg = $this->getResolvedStatus(); + if (! $statusCfg) { + if( property_exists($this->cfg, 'resolvedstatus') ) { + $statusCfg = (array)$this->cfg->resolvedstatus; + } + else { + $statusCfg['status'] = $this->defaultResolvedStatus; + } + } + + $this->resolvedStatus = new stdClass(); + foreach($statusCfg['status'] as $cfx) { + $this->resolvedStatus->byName[] = $cfx; + } + } + + + /** + * Get resolved status from ITS + * + * @author Aurelien TISNE + **/ + public function getResolvedStatus() + { + if (!$this->isConnected()) + return null; + + if ($this->trackerID == '') + return null; + + $ret = null; + try { + $tracker = $this->APIClient->getTrackerById($this->trackerID); + if ($tracker) { + // field ID containing the status semantic + $statusID = $tracker->semantics->status->field_id; + // opened values ID + $statusValuesID = $tracker->semantics->status->value_ids; + //$ret = array(); + // retrieve the field containing the status semantic + $status = $this->getField($tracker, $statusID); + if (! $status ) + throw new Exception('The field ' . $statusID . ' cannot be found in the tracker "' + . $tracker->label . '" (' . $tracker->id . ').'); + // retrieve the labels of closed status + $ret['status'] = $this->getClosedLabels($status, $statusValuesID); + // check that all labels have been found + if ( count($ret['status']) != (count($status->values) - count($statusValuesID)) ) + throw new Exception('Some labels was not found.'); + } else + throw new Exception('The tracker ' . $this->trackerID . ' was not found.'); + } catch(Exception $e) { + tLog($e->getMessage(),'ERROR'); + $ret = null; + } + + return $ret; + } + + /** + * Retrieve a field from a tracker + * + * @param object $tracker A tracker + * @param string $fieldID A tracker item ID + * + * @author Aurelien TISNE + **/ + private function getField($tracker, $fieldID) { + $i = count($tracker->fields); + $field = null; + while ($i > 0 && ! $field) { + if ($tracker->fields[$i - 1]->field_id == $fieldID) + $field = $tracker->fields[$i - 1]; + else + $i -= 1; + } + + return $field; + } + + /** + * Retrieve labels of closed status values ID from the status field + * + * @param object $statusField Tracker field containing the status semantic + * @param array $valuesID List of opened values ID + * + * @author Aurelien TISNE + **/ + private function getClosedLabels($statusField, $openValuesID) { + if (! property_exists($statusField, "values")) + return null; + + $ret = array(); + foreach($statusField->values as $value) { + if ( ! in_array($value->id, $openValuesID) ) + $ret[] = $value->label; + } + + return $ret; + } + + /** + * checks id for validity + * + * @param string issueID + * + * @return bool returns true if the bugid has the right format, false else + **/ + function checkBugIDSyntax($issueID) + { + return $this->checkBugIDSyntaxNumeric($issueID); + } + + /** + * establishes connection to the bugtracking system + * + * @return bool + * + **/ + function connect() + { + + $processCatch = false; + + try + { + + $this->APIClient = new tuleap((string)trim($this->cfg->uriapi), + (string)trim($this->cfg->username), (string)trim($this->cfg->password)); + + try + { + $this->connected = $this->APIClient->Connect(); + + } + catch(Exception $e) + { + $processCatch = true; + } + } + catch(Exception $e) + { + $processCatch = true; + } + + if($processCatch) + { + $logDetails = ''; + foreach(array('uribase', 'username') as $v) + { + $logDetails .= "$v={$this->cfg->$v} / "; + } + $logDetails = trim($logDetails,'/ '); + $this->connected = false; + tLog(__METHOD__ . " [$logDetails] " . $e->getMessage(), 'ERROR'); + } + } + + /** + * + * + **/ + function isConnected() + { + return $this->connected; + } + + + /** + * + **/ + function buildStatusHTMLString($status) + { + if (in_array($status, $this->resolvedStatus->byName) )// Closed type status + { + $str = "" . $status . ""; + }else{ + $str = $status; + } + return "[{$str}] "; + } + + /** + * + * + **/ + function getIssue($issueID) + { + if (!$this->isConnected()) + { + return false; + } + + try + { + $issue = $this->APIClient->getArtifactById((int)$issueID); + if( !is_null($issue) && is_object($issue) ) + { + $issue->IDHTMLString = "{$issueID} : "; + //$issue->statusCode = $issue->State; + $issue->statusVerbose = $issue->status; + $issue->statusHTMLString = $this->buildStatusHTMLString($issue->status); + $issue->summaryHTMLString = $issue->title; + + $issue->isResolved = isset($this->resolvedStatus->byName[$issue->statusVerbose]); + } + + } + catch(Exception $e) + { + tLog($e->getMessage(),'ERROR'); + $issue = null; + } + return $issue; + } + + + /** + * Returns status for issueID + * + * @param string issueID + * + * @return boolean + **/ + function getIssueStatusCode($issueID) + { + $issue = $this->getIssue($issueID); + return (!is_null($issue) && is_object($issue))? $issue->statusCode : false; + } + + /** + * Returns status in a readable form (HTML context) for the bug with the given id + * + * @param string issueID + * + * @return string + * + **/ + function getIssueStatusVerbose($issueID) + { + return $this->getIssueStatusCode($issueID); + } + + + + /** + * Returns a configuration template + * + * @return string + **/ + public static function getCfgTemplate() + { + $template = "\n" . + "\n" . + "TULEAP LOGIN NAME\n" . + "TULEAP PASSWORD\n" . + "\n" . + "https://" . $_SERVER['SERVER_NAME'] . "\n". + "\n" . + "\n" . + "\n" . + "https://" . $_SERVER['SERVER_NAME'] . "/api\n". + "https://" . $_SERVER['SERVER_NAME'] . "/plugins/tracker/?aid=\n". + "https://" . $_SERVER['SERVER_NAME'] . "/plugins/tracker/?func=new-artifact&tracker=TULEAP TRACKER ID\n". + "\n" . + "\n" . + "\n" . + "TULEAP TRACKER ID\n" . + "\n" . + "\n" . + "\n" . + "\n" . + "Fixed\n" . + "Invalid\n" . + "Wont Fix\n" . + "Works for me\n" . + "Duplicate\n" . + "\n" . + "\n"; + return $template; + } + + + /** + * + * check for configuration attributes than can be provided on + * user configuration, but that can be considered standard. + * If they are MISSING we will use 'these carved on the stone values' + * in order to simplify configuration. + * + * + **/ + function completeCfg() + { + // '/' at uribase name creates issue with API + $this->URIBase = trim($this->URIBase, "/"); + + $base = $this->URIBase . '/'; + + if( !property_exists($this->cfg,'uriapi') ) + { + $this->cfg->uriapi = $base . 'api'; + } + + if( !property_exists($this->cfg,'uriview') ) + { + $this->cfg->uriview = $base . 'plugins/tracker/?aid='; + } + + if( !property_exists($this->cfg,'uricreate') ) + { + if ( $this->trackerID != "" ) { + $this->cfg->uricreate = $base . 'plugins/tracker/?tracker=' + . $this->trackerID . '&func=new-artifact'; + } else { + $this->cfg->uricreate = ''; + } + } + + } + + /** + * @param string issueID + * + * @return bool true if issue exists on BTS + **/ + function checkBugIDExistence($issueID) + { + if(($status_ok = $this->checkBugIDSyntax($issueID))) + { + $issue = $this->getIssue($issueID); + $status_ok = (!is_null($issue) && is_object($issue)); + } + return $status_ok; + } + + /** + * + */ + public function addIssue($summary, $description, $opt=null) + { + try + { + $issue = array('tracker' => (int)$this->trackerID, + 'summary' => $summary, + 'description' => $description); + + $op = $this->APIClient->createIssue((int)$this->trackerID, $summary, $description); + + if (is_null($op)) { + throw new Exception("Something's wrong when creating an artefact"); + } else { + $ret = array('status_ok' => true, 'id' => (string)$op->id, + 'msg' => sprintf(lang_get('tuleap_bug_created'), $summary, (string)$op->tracker->project->id)); + } + } + catch (Exception $e) + { + $msg = "Create artifact FAILURE => " . $e->getMessage(); + tLog($msg, 'WARNING'); + $ret = array('status_ok' => false, 'id' => -1, 'msg' => $msg); + } + return $ret; + } + + /** + * + */ + public function addNote($bugId, $noteText, $opt=null) + { + if (!$this->isConnected()) + return null; + + try{ + $noteText = "Reporter: " . $opt->reporter . " <" . $opt->reporter_email . ">\n" . $noteText; + $op = $this->APIClient->addTrackerArtifactMessage( (int)$bugId, $noteText); + $ret = array('status_ok' => true, + 'msg' => sprintf(lang_get('tuleap_bug_comment'), $noteText)); + }catch (Exception $e){ + $msg = "Add note FAILURE for bug " . $bugId . " => " . $e->getMessage(); + tLog($msg, 'WARNING'); + $ret = array('status_ok' => false, 'msg' => $msg); + } + + return $ret; + } + + + /** + * + **/ + function canCreateViaAPI() + { + return ($this->trackerID !== ''); + } + + +} +?> diff --git a/locale/en_GB/strings.txt b/locale/en_GB/strings.txt index 7b34b6d4bb..fed1ddc6ed 100644 --- a/locale/en_GB/strings.txt +++ b/locale/en_GB/strings.txt @@ -3777,12 +3777,19 @@ $TLS_fogbugz_bug_created = "FOGBUGZ Issue Created (summary:%s) on project:%s"; $TLS_youtrack_bug_created = "YOUTRACK Issue Created (summary:%s) on project with id:%s"; $TLS_mantis_bug_created = "MANTIS Issue %s - Created (summary:%s) on project with key:%s"; $TLS_bugzilla_bug_created = "BUGZILLA Issue Created (summary:%s) on product:%s"; + $TLS_gitlab_bug_created = "GITLAB Issue Created (summary:%s) on project with identifier:%s"; $TLS_gitlab_bug_comment = "GITLAB Commented Issue (summary:%s)"; + $TLS_kaiten_bug_created = "KAITEN Issue Created (summary:%s) on project with identifier:%s"; $TLS_kaiten_bug_comment = "KAITEN Commented Issue (summary:%s)"; + +$TLS_tuleap_bug_created = "TULEAP Issue Created (summary:%s) on project with identifier:%s"; +$TLS_tuleap_bug_comment = "TULEAP Commented Issue (summary:%s)"; + $TLS_trello_bug_created = "TRELLO Card/Issue Created (summary:%s) on Board/List: %s/%s"; $TLS_trello_bug_comment = "TRELLO Commented Card/Issue (summary:%s)"; + $TLS_bts_check_ok = "Connection is OK"; $TLS_bts_check_ko = "Connection is KO (detailed messages on TestLink Event Log), check configuration"; $TLS_check_bts_connection = "Check connection"; diff --git a/locale/en_US/strings.txt b/locale/en_US/strings.txt index db58daff31..7ef0844478 100644 --- a/locale/en_US/strings.txt +++ b/locale/en_US/strings.txt @@ -3640,6 +3640,7 @@ $TLS_gitlab_bug_created = "GITLAB Issue Created (summary:%s) on project with ide $TLS_gitlab_bug_comment = "GITLAB Commented Issue (summary:%s)"; $TLS_kaiten_bug_created = "KAITEN Issue Created (summary:%s) on project with identifier:%s"; $TLS_kaiten_bug_comment = "KAITEN Commented Issue (summary:%s)"; +$TLS_tuleap_bug_created = "TULEAP Issue Created (summary:%s) on project with identifier:%s"; $TLS_bts_check_ok = "Connection is OK"; $TLS_bts_check_ko = "Connection is KO (detailed messages on TestLink Event Log), check configuration"; $TLS_check_bts_connection = "Check connection"; diff --git a/locale/fr_FR/strings.txt b/locale/fr_FR/strings.txt index b2535835fe..9095c590b2 100644 --- a/locale/fr_FR/strings.txt +++ b/locale/fr_FR/strings.txt @@ -3769,6 +3769,8 @@ $TLS_gitlab_bug_created = "Anomalie créée par GITLAB (résumé:%s) dans le pro $TLS_gitlab_bug_comment = "Anomalie commentée par GITLAB (résumé:%s)"; $TLS_kaiten_bug_created = "Anomalie créée par KAITEN (résumé:%s) dans le projet d’identifiant : %s"; $TLS_kaiten_bug_comment = "Anomalie commentée par KAITEN (résumé:%s)"; +$TLS_tuleap_bug_created = "Anomalie créée par TULEAP (résumé : %s) dans le projet d’identifiant : %s"; +$TLS_tuleap_bug_comment = "Anomalie commentée par TULEAP (résumé : %s)"; $TLS_bts_check_ok = "La connexion est OK"; $TLS_bts_check_ko = "La connexion est KO (plus de détails dans le moniteur d’événements). Vérifier la configuration"; $TLS_check_bts_connection = "Vérifier la connexion"; diff --git a/third_party/tuleap-php-api/lib/tuleap-rest-api.php b/third_party/tuleap-php-api/lib/tuleap-rest-api.php new file mode 100644 index 0000000000..17c76995ce --- /dev/null +++ b/third_party/tuleap-php-api/lib/tuleap-rest-api.php @@ -0,0 +1,287 @@ +$arg = $$arg; + } + } + + } + + + /** + * + * + */ + public function initCurl() + { + $agent = "TestLink 1.9.14"; + try + { + $this->curl = curl_init(); + } + catch (Exception $e) + { + var_dump($e); + } + + // set the agent, forwarding, and turn off ssl checking + // Timeout in Seconds + curl_setopt_array($this->curl,array(CURLOPT_USERAGENT => $agent, + CURLOPT_VERBOSE => 0, + CURLOPT_FOLLOWLOCATION => TRUE, + CURLOPT_RETURNTRANSFER => TRUE, + CURLOPT_AUTOREFERER => TRUE, + CURLOPT_TIMEOUT => 60, + CURLOPT_SSL_VERIFYPEER => FALSE + + )); + } + + public function Connect(){ + try { + $response = $this->_postJson($this->url."/tokens", json_encode(array( "username" => $this->username, "password" => $this->password) )); + + if(is_object($response)){ + $this->token = $response->token; + $this->userId = $response->user_id; + + return isset($response->user_id); + + }else{ + return false; + } + + }catch (\InvalidArgumentException $e) { + if(gettype($this->curl) == 'resource') curl_close($this->curl); + throw $e; + } catch (\Exception $e) { + if(gettype($this->curl) == 'resource') curl_close($this->curl); + throw $e; + } + } + + /** + * + * + */ + function getArtifactById($id) + { + $item = $this->_getJson($this->url."/artifacts/{$id}"); + $ret = is_object($item) ? $item : null; + return $ret; + } + + + /** + * @param string $id Tracker ID + * @return unknown Tracker definition + */ + public function getTrackerById($id) + { + $item = $this->_getJson($this->url."/trackers/{$id}"); + $ret = is_object($item) ? $item : null; + return $ret; + } + + protected function _getJson($url) + { + try { + $this->initCurl(); + $header = array("X-Auth-Token: {$this->token}", "X-Auth-UserId: {$this->userId}"); + + curl_setopt($this->curl, CURLOPT_URL, $url); + curl_setopt($this->curl, CURLOPT_DNS_USE_GLOBAL_CACHE, false ); + curl_setopt($this->curl, CURLOPT_DNS_CACHE_TIMEOUT, 2 ); + + curl_setopt($this->curl, CURLOPT_HEADER, 0); + curl_setopt($this->curl, CURLOPT_HTTPHEADER, array('Content-Type: application/json')); + curl_setopt($this->curl, CURLOPT_HTTPHEADER, $header); + curl_setopt($this->curl, CURLOPT_HTTPGET, TRUE); + + $responseBody = curl_exec($this->curl); + $responseInfo = curl_getinfo($this->curl); + curl_close($this->curl); + + return json_decode($responseBody); + + }catch (\InvalidArgumentException $e) { + if(gettype($this->curl) == 'resource') curl_close($this->curl); + throw $e; + } catch (\Exception $e) { + if(gettype($this->curl) == 'resource') curl_close($this->curl); + throw $e; + } + + } + + protected function _putJson($url, $data){ + try { + + $this->initCurl(); + curl_setopt($this->curl, CURLOPT_HTTPHEADER, array("X-Auth-Token: {$this->token}", "X-Auth-UserId: {$this->userId}", "Accept: application/json", "Content-Type: application/json", "Content-length: " . mb_strlen($data) )); + curl_setopt($this->curl, CURLOPT_URL, $url); + curl_setopt($this->curl, CURLOPT_DNS_USE_GLOBAL_CACHE, false ); + curl_setopt($this->curl, CURLOPT_DNS_CACHE_TIMEOUT, 2 ); + curl_setopt($this->curl, CURLOPT_HEADER, 0); + curl_setopt($this->curl, CURLOPT_POST, true); + curl_setopt($this->curl, CURLOPT_CUSTOMREQUEST, "PUT"); + curl_setopt($this->curl, CURLOPT_POSTFIELDS, $data); + + $content = curl_exec($this->curl); + $response = curl_getinfo($this->curl); + $curlError = curl_error($this->curl); + $httpCode = (int)$response['http_code']; + curl_close($this->curl); + + if ($httpCode != 200 && $httpCode != 201 ) + { + throw new exception(__METHOD__ . "url:$url - response:" . + json_encode($response) . ' - content: ' . json_encode($content) ); + } + + $rr = array('content' => $content,'response' => $response,'curlError' => $curlError); + return $rr; + + }catch (\InvalidArgumentException $e) { + if(gettype($this->curl) == 'resource') curl_close($this->curl); + throw $e; + } catch (\Exception $e) { + if(gettype($this->curl) == 'resource') curl_close($this->curl); + throw $e; + } + } + + public function addTrackerArtifactMessage($id, $noteText){ + $data = array(); + // values is required (but may be empty) + $data['values'] = array(); + $data['comment'] = array("body"=>$noteText, "format"=>"text"); + + $items = $this->_putJson($this->url."/artifacts/{$id}", json_encode($data)); + return $items; + } + + + public function createIssue($id, $summary, $description){ + $values_by_field = array(); + if($summary!= null && $summary!=""){ + $values_by_field['summary'] = array("value"=>$summary,"type"=>"string"); + } + + if($description!= null && $description!=""){ + $values_by_field['details'] = array("value"=>$description, "type"=>"text"); + } + $data = array(); + $data["tracker"] = array("id"=>$id); + $data["values_by_field"] = $values_by_field; + + $item = $this->_postJson($this->url."/artifacts", json_encode($data)); + + $ret = is_object($item) ? $item : null; + return $ret; + } + + protected function _postJson($url, $data){ + try{ + $this->initCurl(); + $header = array(); + if( trim($this->token) != '') + { + $header[] = "X-Auth-Token: {$this->token}"; + $header[] = "X-Auth-UserId: {$this->userId}"; + } + $header[] = "Content-Type: application/json"; + $header[] = "Accept: application/json"; + $header[] = "Content-length: " . mb_strlen($data); + + curl_setopt($this->curl, CURLOPT_URL, $url); + curl_setopt($this->curl, CURLOPT_HTTPHEADER, $header); + curl_setopt($this->curl, CURLOPT_POST, true); + + if (!empty($data)){ + curl_setopt($this->curl, CURLOPT_POSTFIELDS, $data); + } + + $content = curl_exec($this->curl); + $response = curl_getinfo($this->curl); + $httpCode = (int)$response['http_code']; + + curl_close($this->curl); + + if ($httpCode != 200 && $httpCode != 201 ) + { + throw new exception(__METHOD__ . "url:$url - response:" . + json_encode($response) . ' - content: ' . json_encode($content) ); + + return null; + }else{ + return json_decode($content); + } + + }catch (\InvalidArgumentException $e) { + if(gettype($this->curl) == 'resource') curl_close($this->curl); + throw $e; + } catch (\Exception $e) { + if(gettype($this->curl) == 'resource') curl_close($this->curl); + throw $e; + } + + + } +} // Class end +?>