WIP
diff --git a/classes/BugzillaClient.class.inc b/classes/BugzillaClient.class.inc
index ddc5d95..d3934da 100644
--- a/classes/BugzillaClient.class.inc
+++ b/classes/BugzillaClient.class.inc
@@ -1,5 +1,6 @@
 <?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
@@ -7,304 +8,658 @@
  * http://www.eclipse.org/legal/epl-v10.html
  *
  * Contributors:
- *    Wayne Beaton (Eclipse Foundation)- initial API and implementation
- *******************************************************************************/
+ * 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 = '/Bug (\d+)+ Submitted/';
-	var $attachmentCreatedPattern = '/Attachment (\d+) added to Bug (\d+)/';
-	
-	function __construct($host) {
-	    $this->host = $host;
-	    $this->cookieFile = tempnam(null, 'bugzilla');
-	}
-	
-	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', '1')
-			->execute();
+  var $host;
+  var $cookieFile;
+  var $loggedIn = false;
 
-		/*
-		 * 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;
-	}
-	
-	function startBug($product, $component, $summary) {
-		return new BugBuilder($this, $product, $component, $summary);
-	}
+  /**
+   * 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 {}
+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 $cc = array();
-	
-	function __construct($client, $product, $component, $summary) {
-		$this->client = $client;
-		$this->product = $product;
-		$this->component = $component;
-		$this->summary = $summary;
-	}
-	
-	function addParagraph($paragraph) {
-		$this->body[] = $paragraph;
-		return $this;
-	}
-		
-	function addCC($email) {
-		$this->cc[] = $email;
-		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->execute();
-		
-		$query = new BugzillaPostQuery($this->client, 'post_bug.cgi');
-		$result = $query
-			->setData('form_name', 'enter_bug')
-            ->setData('token', $queryFormResult->getToken())
-			->setData('product', $this->product)
-			->setData('component', $this->component)
-			->setData('cc', join(',', $this->cc))
-			->setData('short_desc', $this->summary)
-			->setData('comment', join('\n\n', $this->body))
-			->setData('op_sys', 'All')
-			->setData('rep_platform', 'All')
-			->setData('bug_severity', 'normal')
-			->setData('priority', 'Normal')
-			->setData('version', 'unspecified')
-            ->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);
-	}
+  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';
-    
-    function __construct($bug) {
-        $this->bug = $bug;  
-    }
-    
-    function setFile($file) {
-        $this->file = $file;
-        return $this;
-    }
-    
-    function setDescription($description) {
-        $this->description = $description;
-        return $this;
+  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]);
     }
 
-    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('contenttypemethod', 'autodetect')
-			->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);
-	}
+    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);
-	}
+  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;
-    }
+  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();
+  var $client;
+  var $resource;
+  var $parameters = array();
 
-	function __construct($client, $resource) {
-		$this->client = $client;
-		$this->resource = $resource;
-	}
+  function __construct($client, $resource) {
+    $this->client = $client;
+    $this->resource = $resource;
+  }
 
-	function setData($key, $value) {
-		$this->parameters[$key] = $value;
-		return $this;
-	}
+  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_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);
-	}
-	
+  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;
-    }
+  var $contents;
 
-    function getToken() {
-        if (preg_match('/<input type="hidden" name="token" value="(\w+)">/', $this->contents, $matches))
-            return $matches[1];
-        return null;
+  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 getUrl() {
+    return parent::getUrl() . '?' . $this->getParameters();
+  }
 
-	function getParameters() {
-	    $parameters = array();
-	    foreach($this->parameters as $key => $value) $parameters[] = "$key=$value";
-		return join('&', $parameters);
-	}
-}
+  function getOptions() {
+    $options = $this->getDefaultOptions();
+    $options[CURLOPT_HTTPGET] = TRUE;
+    return $options;
+  }
 
-?>
\ No newline at end of file
+  function getParameters() {
+    $parameters = array();
+    foreach ($this->parameters as $key => $value)
+      $parameters[] = "$key=" . urlencode($value);
+    return join('&', $parameters);
+  }
+
+}
\ No newline at end of file
diff --git a/classes/Release.class.inc b/classes/Release.class.inc
index 5aef9ff..ed69e8b 100755
--- a/classes/Release.class.inc
+++ b/classes/Release.class.inc
@@ -1,7 +1,7 @@
 <?php
 /**
  * *****************************************************************************
- * Copyright (c) 2017 Eclipse Foundation and others.
+ * Copyright (c) 2018 Eclipse Foundation and others.
  *
  * This program and the accompanying materials are made available under the
  * terms of the Eclipse Public License v. 2.0 which is available at
@@ -10,7 +10,11 @@
  * SPDX-License-Identifier: EPL-2.0
  ********************************************************************************/
 
+/*
+ * FIXME Experimental.
+ */
 require_once dirname ( __FILE__ ) . '/../classes/Project.class.php';
+require_once dirname ( __FILE__ ) . '/../classes/BugzillaClient.class.inc';
 require_once dirname ( __FILE__ ) . '/../classes/debug.php';
 require_once dirname ( __FILE__ ) . '/../classes/database.inc';
 
@@ -20,30 +24,33 @@
 	var $date;
 
 	/**
-	 * This method answers an array containing all of
-	 * @param unknown $root
-	 * @param unknown $date
-	 * @return Release
+	 * This method answers an array containing all of releases that
+	 * either have occurred or are planned to occur after a particular
+	 * date. The method has two optional parameters. If specified, only
+	 * those projects that descend from a particular root are included.
+	 * If no date (UNIX time) is specified, then the current date is used.
+	 *
+	 * @param string $root the root project
+	 * @param int $date the data in UNIX time
+	 * @return Release[]
 	 */
-	public static function releasesSince($root = null, $date = null) {
-		$releases = array ();
+	public static function releasesAfter($root = null, $date = null) {
 		$sql = "
 			select
 				project as id, name, date
 			from ProjectReleases
-			where id regexp :pattern
-				and date > date(:date)
+			where project regexp ':pattern'
+				and date > date(':date')
 			order by date";
 		$args = array(
 			':pattern' => $root ? "^${root}(?:\..*)?$" : '',
 			':date' => date('Y-m-d', $date ? $date : time())
 		);
 
-		return new DatabaseQuery('dashboard', $sql, $args, function ($row) {
+		return new DatabaseQuery('dashboard', $sql, $args, function ($row) use (&$releases) {
 			$project = Project::getProject ( $row ['id'] );
 			$name = $row ['name'];
 			$date = strtotime ( $row ['date'] );
-			$reviewDate = self::getProbableReviewDate ( $date );
 
 			return new Release ( $project, $name, $date );
 		} );
@@ -55,22 +62,61 @@
 		$this->date = $date;
 	}
 
-	function getId() {
+	public function getId() {
 		return $this->project->getId ();
 	}
 
-	function getUrl() {
+	public function getUrl() {
 		return $this->project->getUrl ();
 	}
 
-	function getName() {
+	public function getName() {
 		return $this->project->getName () . ' ' . $this->name;
 	}
 
-	function getDate() {
+	public function getDate() {
 		return $this->date;
 	}
 
+	public function getVersion() {
+		return $this->makeThreePartVersionNumber($this->name);
+	}
+
+	public function getTrackingBugUrl() {
+		$bugzilla = new BugzillaClient('https://bugs.eclipse.org/bugs');
+		$bugs = $bugzilla->findBugs()
+			->setProduct('Community')
+			->setComponent('Proposals and Reviews')
+			->setSummary($this->getTrackingBugSummary())
+			->searchBugs();
+
+		foreach ($bugs as $bug) {
+			return $bug->getUrl();
+		}
+
+		return null;
+	}
+
+	private function getTrackingBugSummary() {
+		return "[release] {$this->project->getId()} {$this->getVersion()}";
+	}
+
+	public static function makeThreePartVersionNumber($value) {
+		if (preg_match('/(\d+)(\.\d+)?(\.\d+)?/', $value, $matches)) {
+			$name = $matches[1];
+			$name .= isset($matches[2]) && $matches[2] ? $matches[2] : '.0';
+			$name .= isset($matches[3]) && $matches[3] ? $matches[3] : '.0';
+			return $name;
+		}
+		else
+			if (preg_match('/(\d+(\.\d+){1,2})/', $value)) {
+				return $match[1];
+			}
+		else {
+			return null;
+		}
+	}
+
 	private static function getProbableReviewDate($date) {
 		$diff = date ( 'w', $date ) - 3; // '3' means Wednesday
 		if ($diff < 0)
@@ -105,40 +151,4 @@
 			return true; // third week
 		return false;
 	}
-}
-
-
-
-function dumpFutureReleases() {
- foreach(getFutureReleases() as $date => $releases) {
-  echo "<h3>" . ($date == 0 ? "It's already too late" : ("For review on " . date('Y-m-d', $date))) . "</h3>";
-  echo "<ul>";
-  foreach($releases as $release) {
-   echo "<li><a href=\"{$release->getUrl()}\">{$release->getName()}</a> ";
-   echo date('Y-m-d', $release->getDate());
-   dumpOpenCQs($release);
-   echo "</li>";
-  }
-  echo "</ul>";
- }
-}
-
-function dumpOpenCQs(Release $release) {
- echo "<ul>";
- $sql = '
-  select
-   b.bug_id as id, b.short_desc as title
-  from bugs as b
-   join components as c on b.component_id=c.id
-  where c.name=\'$id\'
-   and bug_status in (\'NEW\', \'REOPENED\')';
-
- query ('ipzilla', $sql, array('$id' => $release->getId()), function ($row) {
-  $id = $row ['id'];
-  $title = $row ['title'];
-  echo "<li><a target=_blank href=\"https://dev.eclipse.org/ipzilla/show_bug.cgi?id=$id\">$id</a> $title</li>";
- });
- echo "</ul>";
-}
-
-
+}
\ No newline at end of file
diff --git a/tools/pmc.php b/tools/pmc.php
index d1ae379..098f4b7 100644
--- a/tools/pmc.php
+++ b/tools/pmc.php
@@ -8,12 +8,13 @@
  *******************************************************************************/
 
 /**
-
+ * FIXME Experimental. Refactoring required.
  */
 require_once($_SERVER['DOCUMENT_ROOT'] . "/eclipse.org-common/system/app.class.php");
 require_once($_SERVER['DOCUMENT_ROOT'] . "/eclipse.org-common/system/nav.class.php");
 require_once($_SERVER['DOCUMENT_ROOT'] . "/eclipse.org-common/system/menu.class.php");
 require_once($_SERVER['DOCUMENT_ROOT'] . "/projects/classes/Project.class.php");
+require_once($_SERVER['DOCUMENT_ROOT'] . "/projects/classes/Release.class.inc");
 require_once($_SERVER['DOCUMENT_ROOT'] . "/projects/classes/common.php");
 require_once($_SERVER['DOCUMENT_ROOT'] . "/projects/classes/database.inc");
 
@@ -22,13 +23,49 @@
 $Menu = new Menu();
 include($App->getProjectCommon());
 
+callIfNotCommitter('exit');
+
 $pageTitle = "Eclipse Foundation PMC Tasks";
 $pageKeywords = "";
 $pageAuthor = "Wayne Beaton";
 
-$root = Project::getProject($_GET['id']);
-if ($root->isTopLevel()) {
+/**
+ * This function renders a project selection drop-down.
+ * When the
+ * user selects a value, the page is reloaded with the id of the
+ * selected project in the <code>id</code> parameter. All other
+ * parameters that had been passed to the page are preserved via
+ * hidden fields.
+ *
+ * @param string $id
+ *        	The id of the selected project
+ */
+function dumpProjectSelectionForm($id) {
+	echo "<form method=\"get\">";
+	foreach ( $_GET as $key => $value ) {
+		if ($key == 'id')
+			continue;
+			echo "<input type=\"hidden\" name=\"$key\" value=\"$value\"";
+	}
+	echo "<p>Select a project: ";
+	echo "<select name=\"id\" onchange=\"this.form.submit()\">";
 
+	foreach ( Project::getTopLevelProjects() as $project ) {
+		$selected = $id == $project->getId () ? ' selected="true"' : '';
+		echo "<option value=\"{$project->getId()}\"$selected>{$project->getFormalName()}</option>";
+	}
+
+	echo "</select>";
+	echo "<input type=\"submit\" value=\"Go!\"/>";
+	echo "</p>";
+	echo "</form>";
+}
+
+$id = $_GET['id'];
+$root = Project::getProject($id);
+if ($root == null || !$root->isTopLevel()) {
+	$id = null;
+	$root = null;
 }
 
 function withCqsAwaitingPMC($id, $callable) {
@@ -45,15 +82,47 @@
 		from bugs as b
 			join components as c on b.component_id = c.id
 			join products as p on b.product_id = p.id
-		where p.name='{$id}'
+		where p.name=':id'
 			and bug_status='NEW'
 			and bug_severity='awaiting_pmc'
 		order by c.name
 	";
-	query('ipzilla', $sql, array(), $callable);
+	query('ipzilla', $sql, array(':id' => $id), $callable);
 }
 
 
+function dumpFutureReleases($id) {
+	echo "<ul>";
+	Release::releasesAfter($id)->withEach(function(Release $release) {
+		echo "<li><a href=\"{$release->getUrl()}\">{$release->getName()}</a> ";
+		echo date('Y-m-d', $release->getDate());
+		if ($url = $release->getTrackingBugUrl()) {
+			echo "<a href=\"{$url}\">[track]</a>";
+		}
+		// dumpOpenCQs($release);
+		echo "</li>";
+	});
+	echo "</ul>";
+}
+
+function dumpOpenCQs(Release $release) {
+	echo "<ul>";
+	$sql = '
+		select
+			b.bug_id as id, b.short_desc as title
+		from bugs as b
+			join components as c on b.component_id=c.id
+		where c.name=\'$id\'
+			and bug_status in (\'NEW\', \'REOPENED\')';
+
+	query ('ipzilla', $sql, array('$id' => $release->getId()), function ($row) {
+		$id = $row ['id'];
+		$title = $row ['title'];
+		echo "<li><a target=_blank href=\"https://dev.eclipse.org/ipzilla/show_bug.cgi?id=$id\">$id</a> $title</li>";
+	});
+	echo "</ul>";
+}
+
 ob_start();
 ?>
 <div id="maincontent">
@@ -62,6 +131,10 @@
 
 <p><strong>Experimental</strong> Provide a list of links to tasks that require the PMC's attention.</p>
 
+<?php dumpProjectSelectionForm($id) ?>
+
+<?php if ($id) { ?>
+<h2><?php print $root->getFormalName(); ?></h2>
 <h3>Intellectual Property</h3>
 
 <?php
@@ -83,8 +156,20 @@
 		}
 		print "</ul>";
 	}
+} else {
+	print "<p>There are no CQs that require PMC attention at this time.</p>";
 }
+
 ?>
+<h3>Upcoming Releases</h3>
+
+<?php
+
+dumpFutureReleases($id);
+
+?>
+
+<?php } ?>
 
 </div>
 </div>