Google Code-Issues mit PHPUnit-Tests schließen und öffnen

Update 25.01.2010: Sebastian Bergmann hat meine Klasse dem 3.5-Branch hinzugefügt. Sie wird also ab PHPUnit 3.5.0 standardmässig verfügbar sein.

Raphael Stolt beschreibt in seinem Artikel Closing and reopening GitHub issues via PHPUnit tests wie man mit PHPUnit-Tests GitHub-Issues schließen und wieder öffnen kann. Kurz gesagt geht es dabei um test-driven bug fixing, wobei man für jeden gemeldeten Bug einen Test erstellt der verifiziert, dass dieser gefixt ist. Ausführlicher kann man das in seinem Blogpost nachlesen.

Dadurch inspiriert, habe ich einen entsprechende TicketListener für Google Code erstellt:

<?php
class PHPUnit_Extensions_TicketListener_GoogleCode extends PHPUnit_Extensions_TicketListener
{
    private $email;
    private $password;
    private $project;
 
    private $statusClosed;
    private $statusReopened;
 
    private $printTicketStateChanges;
 
    private $authUrl    = 'https://www.google.com/accounts/ClientLogin';
    private $apiBaseUrl = 'http://code.google.com/feeds/issues/p/%s/issues';
    private $authToken;
 
    /**
     * @param string $email          The email associated with the Google account.
     * @param string $password       The password associated with the Google account.
     * @param string $project        The project name of the system under test (SUT) on Google Code.
     * @param string $printTicketChanges Boolean flag to print the ticket state changes in the test result.
     * @param string $statusClosed   The status name of the closed state.
     * @param string $statusReopened The status name of the reopened state.
     * @throws RuntimeException
     */
    public function __construct($email, $password, $project, $printTicketStateChanges = FALSE,
                                $statusClosed = 'Fixed', $statusReopened = 'Started')
    {
        if (!extension_loaded('curl')) {
            throw new RuntimeException('ext/curl is not available');
        }
 
        $this->email          = $email;
        $this->password       = $password;
        $this->project        = $project;
 
        $this->statusClosed   = $statusClosed;
        $this->statusReopened = $statusReopened;
 
        $this->printTicketStateChanges = $printTicketStateChanges;
 
        $this->apiBaseUrl     = sprintf($this->apiBaseUrl, $project);
    }
 
    /**
     * @param  integer $ticketId
     * @return array
     * @throws RuntimeException
     */
    public function getTicketInfo($ticketId = null)
    {
        if (!is_numeric($ticketId)) {
            return array('status' => 'invalid_ticket_id');
        }
 
        $url = $this->apiBaseUrl . '/full/' . $ticketId;
 
        $header = array(
            'Authorization: GoogleLogin auth=' . $this->getAuthToken(),
        );
 
        list($status, $response) = $this->callGoogleCode($url, $header);
 
        if ($status != 200 || !$response) {
            return array('state' => 'unknown_ticket');
        }
 
        $ticket = new SimpleXMLElement(str_replace("xmlns=", "ns=", $response));
 
        $result = $ticket->xpath('//issues:state');
        $state  = (string) $result[0];
 
        if ($state === 'open') {
            return array('status' => 'new');
        }
 
        if ($state === 'closed') {
            return array('status' => 'closed');
        }
 
        return array('status' => $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 RuntimeException
     */
    protected function updateTicket($ticketId, $statusToBe, $message, $resolution)
    {
        $url = $this->apiBaseUrl . '/' . $ticketId . '/comments/full';
 
        $header = array(
            'Authorization: GoogleLogin auth=' . $this->getAuthToken(),
            'Content-Type: application/atom+xml',
        );
 
        $ticketStatus = $statusToBe == 'closed' ? $this->statusClosed : $this->statusReopened;
 
        list($author,) = explode('@', $this->email);
 
        $post = '<?xml version="1.0" encoding="UTF-8"?>' .
                '<entry xmlns="http://www.w3.org/2005/Atom" ' .
                '       xmlns:issues="http://schemas.google.com/projecthosting/issues/2009">' .
                '  <content type="html">' . htmlspecialchars($message, ENT_COMPAT, 'UTF-8') . '</content>' .
                '  <author>' .
                '    <name>' . htmlspecialchars($author, ENT_COMPAT, 'UTF-8') . '</name>' .
                '  </author>' .
                '  <issues:updates>' .
                '    <issues:status>' . htmlspecialchars($ticketStatus, ENT_COMPAT, 'UTF-8') . '</issues:status>' .
                '  </issues:updates>' .
                '</entry>';
 
        list($status, $response) = $this->callGoogleCode($url, $header, $post);
 
        if ($status != 201) {
            throw new RuntimeException('Updating GoogleCode issue failed with status code ' . $status);
        }
 
        if ($this->printTicketStateChanges) {
            printf(
                "\nUpdating GoogleCode issue #%d, status: %s\n",
                $ticketId,
                $statusToBe
            );
        }
    }
 
    /**
     * @return string The auth token
     * @throws RuntimeException
     */
    private function getAuthToken()
    {
        if (null !== $this->authToken) {
            return $this->authToken;
        }
 
        $header = array(
            'Content-Type: application/x-www-form-urlencoded',
        );
 
        $post = array(
            'accountType' => 'GOOGLE',
            'Email'       => $this->email,
            'Passwd'      => $this->password,
            'service'     => 'code',
            'source'      => 'PHPUnit-TicketListener_GoogleCode-' . PHPUnit_Runner_Version::id(),
        );
 
        list($status, $response) = $this->callGoogleCode(
            $this->authUrl,
            $header,
            http_build_query($post, null, '&amp;')
        );
 
        if ($status != 200) {
            throw new RuntimeException('Google account authentication failed');
        }
 
        foreach (explode("\n", $response) as $line) {
            if (strpos(trim($line), 'Auth') === 0) {
                list($name, $token) = explode('=', $line);
                $this->authToken = trim($token);
                break;
            }
        }
 
        if (null === $this->authToken) {
            throw new RuntimeException('Could not detect auth token in response');
        }
 
        return $this->authToken;
    }
 
    /**
     * @param string  $url URL to call
     * @param array   $header Header
     * @param string  $post Post data
     * @return array
     */
    private function callGoogleCode($url, array $header = null, $post = null)
    {
        $curlHandle = curl_init();
 
        curl_setopt($curlHandle, CURLOPT_URL, $url);
        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_SSL_VERIFYPEER, FALSE);
 
        if (null !== $header) {
            curl_setopt($curlHandle, CURLOPT_HTTPHEADER, $header);
        }
 
        if (null !== $post) {
            curl_setopt($curlHandle, CURLOPT_POST, TRUE);
            curl_setopt($curlHandle, CURLOPT_POSTFIELDS, $post);
        }
 
        $response = curl_exec($curlHandle);
        $status   = curl_getinfo($curlHandle, CURLINFO_HTTP_CODE);
 
        if (!$response) {
            throw new RuntimeException(curl_error($curlHandle));
        }
 
        curl_close($curlHandle);
 
        return array($status, $response);
    }
}
?>

Den TicketListener kann man z. B. in der XML-Konfigurationsdatei von PHPUnit so initialisieren:

<phpunit>
  <listeners>
    <listener class="PHPUnit_Extensions_TicketListener_GoogleCode" file="PHPUnit/Extensions/TicketListener/GoogleCode.php">
      <arguments>
        <string>GOOGLE_ACCOUNT_EMAIL</string>
        <string>GOOGLE_ACCOUNT_PASSWORD</string>
        <string>GOOGLE_CODE_PROJECT_NAME</string>
        <boolean>true</boolean>
        <string>Fixed</string>
        <string>Started</string>
      </arguments>
    </listener>
  </listeners>
</phpunit>

Die ersten 2 Argumente sind die E-Mail und das Passwort des Google-Accounts, das 3. Argument der Projektname auf Google Code, das 4. Argument ein Flag, das angibt, ob ausgegeben werden soll wenn ein sich Ticket-Status ändert und die letzten beiden Argumente die Status für den Issue-Tracker.

Den ganzen Code plus ein einfaches Demo gibts im zugehörigen Google Code-Projekt.

Dabei ist zu beachten, dass die TicketListener auf Grund eines Bugs in der abstrakten TicketListener-Klasse in der aktuellen Version von PHPUnit (3.4.6) nicht funktionieren. Deshalb sollte man vorher diesen Patch anwenden.

Links zum Thema