blob: d3934da6c958ddde3ca9842b991102375304d412 [file] [log] [blame]
<?php
/**
* *****************************************************************************
* Copyright (c) 2013, 2014 Eclipse Foundation and others.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Wayne Beaton (Eclipse Foundation)- initial API and implementation
* *****************************************************************************
*/
class BugzillaClient {
var $host;
var $cookieFile;
var $loggedIn = false;
/**
* This is the pattern that we look for to verify that the
* attempt to login as succeeded.
* Since we're parsing web pages,
* we look for an occurance of "Log out" (assuming that we can
* only log out if we have previously logged in).
*
* @var string
*/
var $loggedInPattern = '/<a href="index.cgi\?logout=1">Log(&nbsp;| )out<\/a>/';
var $bugCreatedPattern = '/(\d+)+ Submitted/';
var $attachmentCreatedPattern = '/Attachment (\d+) added/';
function __construct($host) {
$this->host = $host;
$this->cookieFile = tempnam(sys_get_temp_dir(), 'bugzilla');
if (!$this->cookieFile)
throw new Exception("The BugzillaClient cannot allocate a cookie file. Check temp directory settings.");
}
function isLoggedIn() {
return $this->loggedIn;
}
function login($user, $password) {
if ($this->isLoggedIn())
return;
$query = new BugzillaPostQuery($this, "index.cgi");
$result = $query->setData('Bugzilla_login', $user)->setData('Bugzilla_password', $password)->setData('GoAheadAndLogIn', 'Login')->execute();
/*
* If the value that we get back from the server contains the
* "loggedInPattern", then we were successful. Otherwise, obviously,
* we were not successful. In this event, extract the error message
* from the title and pass it back as the message in an exception.
*/
if (!preg_match($this->loggedInPattern, $result->contents)) {
if (preg_match('/<title>(.*)<\/title>/', $result->contents, $matches)) {
$message = $matches[1];
}
else
$message = 'Unknown error';
throw new BugzillaInvalidCredentialsException($message);
}
$this->loggedIn = true;
return $this;
}
function logout() {
// TODO Should we throw an exception?
if (!$this->isLoggedIn())
return;
$query = new BugzillaPostQuery($this, "index.cgi");
$query->setData('logout', '1')->execute();
$this->loggedIn = false;
}
/**
*
* @param string $product
* @param string $component
* @param string $summary
* @return BugBuilder
*/
function startBug($product, $component, $summary) {
return new BugBuilder($this, $product, $component, $summary);
}
function findBugs() {
return new BugFinder($this);
}
/**
* Answers a representation of the bug.
* Note that no check is done
* to ensure that this bug actually exists.
*
* @param int $id
* The bug id.
* @return Bug
*/
function getBug($id) {
return new Bug($this, $id);
}
}
class BugzillaInvalidCredentialsException extends Exception {
}
/**
* Instances of BugBuilder are used--oddly enough--to build bugs.
* You
* should never directly create an instance of this class. Instead, send
* the startAttachment() message to a Bug instance.
*
* All of the methods implemented by this class return the receiver.
* This lets you chain multiple calls against the instance using relatively
* compact and easy-to-read code (that's the intention, anyway).
*
* The final message sent is generally create() which does the actual work
* based on the information that's been provided.
*
* @internal
*
* @see Bug::startAttachment
*/
class BugBuilder {
var $client;
var $product;
var $component;
var $summary;
var $body = array();
var $keywords = array();
var $assignee;
var $cc = array();
var $operatingSystem = 'All';
var $platform = 'All';
var $severity = 'normal';
var $priority = 'P3';
var $version = 'unspecified';
var $url;
var $otherFields = array();
function __construct($client, $product, $component, $summary) {
$this->client = $client;
$this->product = $product;
$this->component = $component;
$this->summary = $summary;
}
/**
* Add a paragraph to comment #0 of the new bug.
* Each paragraph
* is separated from the previous paragraph by a blank line.
* The second (optional) parameter is a boolean value, the paragraph
* is only added if that parameter's value is true. This provides
* a relatively convenient way to build up the content without
* having to include a whole bunch of if statements to chop up
* an otherwise very tidy code flow.
*
* Like every other method on this class, the return value is the
* receiver, so that additional messages can be sent.
*
* @param string $paragraph
* @param boolean $include
* @return AttachmentBuilder
*/
function addParagraph($paragraph, $include = true) {
if ($include)
$this->body[] = $paragraph;
return $this;
}
function addKeyword($keyword) {
$this->keywords[] = $keyword;
return $this;
}
function setAssignee($email) {
$this->assignee = $email;
return $this;
}
function addCC($email) {
$this->cc[] = $email;
return $this;
}
function setOperatingSystem($os) {
$this->operatingSystem = $os;
return $this;
}
function setPlatform($platform) {
$this->platform = $platform;
return $this;
}
function setSeverity($severity) {
$this->severity = $severity;
return $this;
}
function setPriority($priority) {
$this->priority = $priority;
return $this;
}
function setVersion($version) {
$this->version = $version;
return $this;
}
function setUrl($url) {
$this->url = $url;
return $this;
}
function set($field, $value) {
$this->otherFields[$field] = $value;
return $this;
}
function create() {
// Recent versions of Bugzilla require a token when creating
// a bug. Attempt to get the token.
// FIXME Only do this if the Bugzilla version requires it.
$query = new BugzillaGetQuery($this->client, 'enter_bug.cgi');
$queryFormResult = $query->setData('product', $this->product)->setData('component', $this->component)->execute();
$query = new BugzillaPostQuery($this->client, 'post_bug.cgi');
$query->setData('form_name', 'enter_bug')->setData('token', $queryFormResult->getToken())->setData('product', $this->product)->setData('component', $this->component)->setData('assigned_to', $this->assignee)->setData('cc', join(',', $this->cc))->setData('short_desc', $this->summary)->setData('comment', join("\n\n", $this->body))->setData('op_sys', $this->operatingSystem)->setData('rep_platform', $this->platform)->setData('bug_severity', $this->severity)->setData('priority', $this->priority)->setData('version', $this->version)->setData('keywords', join(',', $this->keywords))->setData('bug_file_loc', $this->url);
foreach ($this->otherFields as $key => $value) {
$query->setData($key, $value);
}
$result = $query->execute();
if (preg_match($this->client->bugCreatedPattern, $result->contents, $matches)) {
return new Bug($this->client, $matches[1]);
}
if (preg_match('/<title>(.*)<\/title>/ims', $result->contents, $matches)) {
$message = preg_replace('/\s+/', ' ', $matches[1]);
}
else
$message = 'Unknown error';
// TODO Make custom exception
throw new Exception($message);
}
/**
* Answer a URL that will open a browser with a pre-populated
* create page.
*/
function getUrl() {
$parameters = array(
'product' => $this->product,
'component' => $this->component,
'assigned_to' => $this->assignee,
'cc' => join(',', $this->cc),
'short_desc' => $this->summary,
'comment' => join("\n\n", $this->body),
'op_sys' => $this->operatingSystem,
'rep_platform' => $this->platform,
'bug_severity' => $this->severity,
'priority' => $this->priority,
'version' => $this->version,
'keywords' => join(',', $this->keywords),
'bug_file_loc' => $this->url
);
$url = $this->client->host . '/enter_bug.cgi?';
foreach ($parameters as $key => $value) {
if ($value)
$url .= "$key=" . urlencode($value) . '&';
}
return $url;
}
}
class BugFinder {
var $client;
var $parameters = array(
'columnlist' => array(
'bug_id'
)
);
function __construct(BugzillaClient $client) {
$this->client = $client;
}
function setProduct($product) {
$this->parameters['product'] = array(
$product
);
return $this;
}
function setComponent($component) {
$this->parameters['component'] = array(
$component
);
return $this;
}
function addColumn($column) {
$this->parameters['columnlist'][] = $column;
return $this;
}
function setSummary($summary) {
$this->parameters['short_desc'] = array(
$summary
);
$this->parameters['short_desc_type'] = array(
'substring'
);
return $this;
}
function addKeyword($keyword) {
$this->parameters['keywords'][] = $keyword;
return $this;
}
function addStatus($status) {
$this->parameters['bug_status'][] = $status;
return $this;
}
/**
* Execute the search.
* The returned value is an array of
* arrays.
*
* e.g. array(array('bug_id' => 1234, 'short_desc' => 'Something', ...), ...)
*
* @see searchBugs
* @return mixed[][]
*/
function search() {
$query = new BugzillaGetQuery($this->client, 'buglist.cgi');
foreach ($this->parameters as $key => $values) {
$query->setData($key, join(',', $values));
}
$result = $query->setData('ctype', 'csv')->execute();
return $result->getCsv();
}
/**
* Execute the search but return a collection of instances of the
* bug class.
*
* @return Bug[]
*/
function searchBugs() {
$bugs = array();
foreach ($this->search() as $row) {
$bugs[] = new Bug($this->client, $row['bug_id']);
}
return $bugs;
}
}
/**
* Instances of AttachmentBuilder are used--oddly enough--to build attachments.
* You
* should never directly create an instance of this class. Instead, send
* the startBug() message to a BugzillaClient instance.
*
* All of the methods implemented by this class return the receiver.
* This lets you chain multiple calls against the instance using relatively
* compact and easy-to-read code (that's the intention, anyway).
*
* The final message sent is generally create() which does the actual work
* based on the information that's been provided.
*
* @internal
*
* @see BugzillaClient::startBug
*/
class AttachmentBuilder {
var $bug;
var $file;
var $description = 'A file';
var $body = array();
function __construct($bug) {
$this->bug = $bug;
}
function setFile($file) {
$this->file = $file;
return $this;
}
function setDescription($description) {
$this->description = $description;
return $this;
}
/**
* Add a paragraph to the comment for the attachment.
* Each paragraph
* is separated from the previous paragraph by a blank line.
* The second (optional) parameter is a boolean value, the paragraph
* is only added if that parameter's value is true. This provides
* a relatively convenient way to build up the content without
* having to include a whole bunch of if statements to chop up
* an otherwise very tidy code flow.
*
* Like every other method on this class, the return value is the
* receiver, so that additional messages can be sent.
*
* @param string $paragraph
* @param boolean $include
* @return AttachmentBuilder
*/
function addParagraph($paragraph, $include = true) {
if ($include)
$this->body[] = $paragraph;
return $this;
}
function create() {
$query = new BugzillaGetQuery($this->bug->client, 'attachment.cgi');
$queryFormResult = $query->setData('bugid', $this->bug->id)->setData('action', 'enter')->execute();
$query = new BugzillaPostQuery($this->bug->client, 'attachment.cgi');
$result = $query->setData('form_name', 'enter_bug')->setData('token', $queryFormResult->getToken())->setData('bugid', $this->bug->id)->setData('action', 'insert')->setData('data', "@$this->file")->setData('description', $this->description)->setData('comment', join("\n\n", $this->body))->setData('contenttypemethod', 'list')->setData('contenttypeselection', 'text/html')->execute();
if (preg_match($this->bug->client->attachmentCreatedPattern, $result->contents, $matches)) {
return new Attachment($this->bug, $matches[1]);
}
if (preg_match('/<title>(.*)<\/title>/ims', $result->contents, $matches)) {
$message = preg_replace('/\s+/', ' ', $matches[1]);
}
else
$message = 'Unknown error';
// TODO Make custom exception
throw new Exception($message);
}
}
/**
* Instances of this class represent a bug instance from the client.
* Instances
* should not be directly created, but should instead be created by sending
* a message to an instance of the BugzillaClient class.
*
* @internal
*
* @see BugzillaClient::getBug
* @see BugzillaClient::startBug
*/
class Bug {
var $client;
var $id;
function __construct($client, $id) {
$this->client = $client;
$this->id = $id;
}
function getId() {
return $this->id;
}
function startAttachment() {
return new AttachmentBuilder($this);
}
function getUrl() {
return $this->client->host . '/show_bug.cgi?id=' . $this->id;
}
}
/**
* Instances of this class represent an attachment instance from the client.
* Instances should not be directly created, but should instead be created by
* sending a message to an instance of the Bug class.
*
* @internal
*
* @see Bug::startAttachment
*/
class Attachment {
var $bug;
var $id;
function __construct($bug, $id) {
$this->bug = $bug;
$this->id = $id;
}
}
/**
* Instances of this class are used to build and execute a POST query
* against Bugzilla.
*
* @internal
*
*/
class BugzillaPostQuery {
var $client;
var $resource;
var $parameters = array();
function __construct($client, $resource) {
$this->client = $client;
$this->resource = $resource;
}
function setData($key, $value) {
$this->parameters[$key] = $value;
return $this;
}
function getUrl() {
return $this->client->host . '/' . $this->resource;
}
function getDefaultOptions() {
return array(
CURLOPT_USERAGENT => "Eclipse",
CURLOPT_COOKIEFILE => $this->client->cookieFile,
CURLOPT_COOKIEJAR => $this->client->cookieFile,
// CURLOPT_VERBOSE => 1,
// CURLOPT_FOLLOWLOCATION => true,
CURLOPT_RETURNTRANSFER => 1,
CURLOPT_URL => $this->getUrl()
);
}
function getOptions() {
$options = $this->getDefaultOptions();
$options[CURLOPT_POST] = TRUE;
$options[CURLOPT_POSTFIELDS] = $this->parameters;
return $options;
}
function execute() {
$curl = curl_init($this->client->host);
curl_setopt_array($curl, $this->getOptions());
$contents = curl_exec($curl);
curl_close($curl);
return new QueryResult($contents);
}
}
class QueryResult {
var $contents;
function __construct($contents) {
$this->contents = $contents;
}
function getToken() {
if (preg_match('/<input type="hidden" name="token" value="(\w+)">/', $this->contents, $matches))
return $matches[1];
return null;
}
public function getCsv() {
// FIXME switch to str_getcsv when we upgrade to PHP 5.3+
// TODO Make sure that the data is indeed CSV data.
// We need to support pre-5.3 versions of PHP which do not include
// the str_getcsv function. Use some string-to-stream cleverness in
// its place.
//
// If fopen fails because, for example, PHP is configured for it
// to fail (via php.ini), then we brute force it with a regular
// expression.
if ($stream = fopen('data://text/plain,' . $this->contents, 'r')) {
$header = fgetcsv($stream);
$rows = array();
while ($values = fgetcsv($stream)) {
$row = array();
for ($index = 0; $index < count($header); $index++)
$row[$header[$index]] = $values[$index];
$rows[] = $row;
}
fclose($stream);
return $rows;
}
else {
$lines = explode("\n", $this->contents);
$header = $this->sgetcsv(current($lines));
$rows = array();
while ($line = next($lines)) {
$values = $this->sgetcsv($line);
$row = array();
for ($index = 0; $index < count($header); $index++)
$row[$header[$index]] = $values[$index];
$rows[] = $row;
}
return $rows;
}
}
/**
* This is a quick and dirty implementation of a string CSV parser.
* I'm sure that we can come up with a more clever regex, but this
* one will do.
*
* @internal
*
* @param unknown $input
* @return string[]
*/
private function sgetcsv($input) {
$values = array();
if (preg_match_all('/(?:^|,)((?!")[^,|$]*|"(?:[^"\\\\]|\\\\.)*")/', $input, $matches)) {
foreach ($matches[1] as $match) {
if (preg_match('/^"(.*)"$/', $match, $contents)) {
$match = stripslashes($contents[1]);
}
$values[] = $match;
}
}
return $values;
}
}
class BugzillaGetQuery extends BugzillaPostQuery {
function getUrl() {
return parent::getUrl() . '?' . $this->getParameters();
}
function getOptions() {
$options = $this->getDefaultOptions();
$options[CURLOPT_HTTPGET] = TRUE;
return $options;
}
function getParameters() {
$parameters = array();
foreach ($this->parameters as $key => $value)
$parameters[] = "$key=" . urlencode($value);
return join('&', $parameters);
}
}