Tuesday, 19 January 2010

Closing and reopening GitHub issues via PHPUnit tests

PHPUnit GitHub TicketListener Since PHPUnit 3.4.0 a new extension point for interacting with issue tracking systems (TTS) based on the test results has been added to PHP's first choice xUnit framework. The extension point has been introduced by an abstract PHPUnit_Extensions_TicketListener class, which allows developer to add tailor-made ticket listeners supporting their favoured TTS. Currently PHPUnit ships with a single ticket listener for Trac as it's still the used TTS for the framework itself. As I start to become more and more accustomed to use GitHub for some of my exploratory projects and hacks, the following blog post will contain a GitHub_TicketListener implementation and a showcase of it's usage.

Annotating tests with ticket meta data

As you might know, it's considered to be a best practice to write a test for each new ticket representing a bug and drive the system under test (SUT) till the issue is resolved. This extension of test-driven development is also known as test-driven bug fixing. To create a relation between these tests and their associated tickets, PHPUnit provides a new @ticket annotation which will be analyzed before each test is run. The following code listing shows such an annotated test.
<?php
require_once 'PHPUnit/Framework.php';

class ExampleTest extends PHPUnit_Framework_TestCase
{
....

/**
* @ticket 2
* @test
*/
public function shouldGuarantyThatTheSutHandlesTheIssueCorrectly()
{
// test code
}
....

Peeking at the GitHub_TicketListener implementation

The current version (3.4.6) of PHPUnit has a pending issue regarding the abstract TicketListener class, so the first step is to apply an 'exploratory' patch, which might break the functionality of the shipped Trac ticket listener but will enable the use of the one for GitHub's TTS.

The next step en route to a working GitHub_TicketListener is to extend the patched abstract PHPUnit_Extensions_TicketListener class. This abstract class contains two abstract methods named getTicketInfo and updateTicket which have to be implemented by the specific ticket listener class, and will be responsible for the interaction with the TTS.

The implementation of the getTicketInfo method retrieves the ticket status for the annotated ticket, while the updateTicket method is responsible for changing the ticket status based on the test result and the former ticket state. Both implementations make use of the relevant TTS part of the GitHub API by utilizing PHP's curl extension as shown in the next code listing which alternatively is available via this gist.
<?php
require_once('PHPUnit/Extensions/TicketListener.php');
PHPUnit_Util_Filter::addFileToFilter(__FILE__, 'PHPUNIT');

/**
* A ticket listener that interacts with the GitHub issue API.
*/
class PHPUnit_Extensions_TicketListener_GitHub extends
PHPUnit_Extensions_TicketListener
{
const STATUS_CLOSE = 'closed';
const STATUS_REOPEN = 'reopened';

private $_username = null;
private $_apiToken = null;
private $_repository = null;
private $_apiPath = null;
private $_printTicketStateChanges = false;

/**
* @param string $username The username associated with the GitHub account.
* @param string $apiToken The API token associated with the GitHub account.
* @param string $repository The repository of the system under test (SUT) on GitHub.
* @param string $printTicketChanges Boolean flag to print the ticket state
* changes in the test result.
* @throws RuntimeException
*/
public function __construct($username, $apiToken, $repository,
$printTicketStateChanges = false)
{
if ($this->_isCurlAvailable() === false) {
throw new RuntimeException('The dependent curl extension is not available');
}
if ($this->_isJsonAvailable() === false) {
throw new RuntimeException('The dependent json extension is not available');
}
$this->_username = $username;
$this->_apiToken = $apiToken;
$this->_repository = $repository;
$this->_apiPath = 'http://github.com/api/v2/json/issues';
$this->_printTicketStateChanges = $printTicketStateChanges;
}

/**
* @param integer $ticketId
* @return string
* @throws PHPUnit_Framework_Exception
*/
public function getTicketInfo($ticketId = null)
{
if (!ctype_digit($ticketId)) {
return $ticketInfo = array('status' => 'invalid_ticket_id');
}
$ticketInfo = array();

$apiEndpoint = "{$this->_apiPath}/show/{$this->_username}/"
. "{$this->_repository}/{$ticketId}";

$issueProperties = $this->_callGitHubIssueApiWithEndpoint($apiEndpoint, true);

if ($issueProperties['state'] === 'open') {
return $ticketInfo = array('status' => 'new');
} elseif ($issueProperties['state'] === 'closed') {
return $ticketInfo = array('status' => 'closed');
} elseif ($issueProperties['state'] === 'unknown_ticket') {
return $ticketInfo = array('status' => $issueProperties['state']);
}
}

/**
* @param string $ticketId The ticket number of the ticket under test (TUT).
* @param string $statusToBe The status of the TUT after running the associated test.
* @param string $message The additional message for the TUT.
* @param string $resolution The resolution for the TUT.
* @throws PHPUnit_Framework_Exception
*/
protected function updateTicket($ticketId, $statusToBe, $message, $resolution)
{
$apiEndpoint = null;
$acceptedResponseIssueStates = array('open', 'closed');

if ($statusToBe === self::STATUS_CLOSE) {
$apiEndpoint = "{$this->_apiPath}/close/{$this->_username}/"
. "{$this->_repository}/{$ticketId}";
} elseif ($statusToBe === self::STATUS_REOPEN) {
$apiEndpoint = "{$this->_apiPath}/reopen/{$this->_username}/"
. "{$this->_repository}/{$ticketId}";
}
if (!is_null($apiEndpoint)) {
$issueProperties = $this->_callGitHubIssueApiWithEndpoint($apiEndpoint);
if (!in_array($issueProperties['state'], $acceptedResponseIssueStates)) {
throw new PHPUnit_Framework_Exception(
'Recieved an unaccepted issue state from the GitHub Api');
}
if ($this->_printTicketStateChanges) {
printf("\nUpdating GitHub issue #%d, status: %s\n", $ticketId,
$statusToBe);
}
}
}

/**
* @return boolean
*/
private function _isCurlAvailable()
{
return extension_loaded('curl');
}

/**
* @return boolean
*/
private function _isJsonAvailable()
{
return extension_loaded('json');
}

/**
* @param string $apiEndpoint API endpoint to call against the GitHub issue API.
* @param boolean $isShowMethodCall Show method of the GitHub issue API is called?
* @return array
* @throws PHPUnit_Framework_Exception
*/
private function _callGitHubIssueApiWithEndpoint($apiEndpoint,
$isShowMethodCall = false)
{
$curlHandle = curl_init();

curl_setopt($curlHandle, CURLOPT_URL, $apiEndpoint);
curl_setopt($curlHandle, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curlHandle, CURLOPT_FAILONERROR, true);
curl_setopt($curlHandle, CURLOPT_FRESH_CONNECT, true);
curl_setopt($curlHandle, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curlHandle, CURLOPT_HTTPPROXYTUNNEL, true);
curl_setopt($curlHandle, CURLOPT_USERAGENT, __CLASS__);
curl_setopt($curlHandle, CURLOPT_POSTFIELDS,
"login={$this->_username}&token={$this->_apiToken}");

$response = curl_exec($curlHandle);

// Unknown tickets throw a 403 error
if (!$response && $isGetTicketInfoCall) {
return array('state' => 'unknown_ticket');
}

if (!$response) {
$curlErrorMessage = curl_error($curlHandle);
$exceptionMessage = "A failure occured while talking to the "
. "GitHub issue Api. {$curlErrorMessage}.";
throw new PHPUnit_Framework_Exception($exceptionMessage);
}
$issue = (array) json_decode($response);
$issueProperties = (array) $issue['issue'];
curl_close($curlHandle);
return $issueProperties;
}
}

Plugging the GitHub_TicketListener into the PHPUnit test environment

To hook the GitHub ticket listener into the test runtime environment PHPUnit provides several approaches to do so. The chosen approach makes use of a XML configuration file which allows an injection of the ticket listener in a declarative manner. As you will see in the configuration file snippet, the GitHub ticket listener is initialized with four parameters: The first one is the GitHub username, followed by the GitHub API token, the associated GitHub project, and a boolean flag for displaying the ticket status changes in the test result.
<phpunit>
<listeners>
<listener class="PHPUnit_Extensions_TicketListener_GitHub"
file="/path/to/GitHubTicketListener.php">
<arguments>
<string>raphaelstolt</string>
<string>API_TOKEN</string>
<string>PROJECT_NAME</string>
<boolean>true</boolean>
</arguments>
</listener>
</listeners>
</phpunit>
To run the tests against a SUT and see the PHPUnit GitHub TTS interaction at work, all it takes is the forthcoming PHPUnit Cli call.
phpunit --configuration github-ticketlistener.xml ExampleTest.php
The outro screenshot shows the test result for an example SUT along with a GitHub TTS interaction due to a passing test which is associated with a open ticket in the TTS.

A final note: As the interaction with an TTS adds some overhead to the test execution and thereby might cause Slow Tests, ticket listener should only be considered in non time-critical test scenarios (e.g. nightly builds).

PHPUnit closing a GitHub issue

5 comments:

Sebastian Bergmann said...

Hello Raphael,

can you please fork PHPUnit on GitHub, create a topic branch off of the 3.5 branch, apply your changes and add the GitHub listener, and let me know when you are done?

Thanks!
Sebastian

Raphael Stolt said...

Hi Sebastian,

Will do that, expect a back ping this evening.

Cheers,
Raphael

erenon said...

why _callGitHubIssueApiWithEnpoint and not _callGitHubIssueApiWithEndpoint?

Raphael Stolt said...

Because is was a spelling mistake ;D Thanks for pointing it out and the code is fixed now.

erenon said...

Glad to help, thanks for this listener.