BugChomper utility - rewrite in Go

BUG=skia:
R=jcgregorio@google.com

Author: borenet@google.com

Review URL: https://codereview.chromium.org/274693002

git-svn-id: http://skia.googlecode.com/svn/trunk@14715 2bbb7eff-a529-9590-31e7-b0007b416f81
diff --git a/tools/bug_chomper/res/favicon.ico b/tools/bug_chomper/res/favicon.ico
new file mode 100644
index 0000000..e7440c7
--- /dev/null
+++ b/tools/bug_chomper/res/favicon.ico
Binary files differ
diff --git a/tools/bug_chomper/res/style.css b/tools/bug_chomper/res/style.css
new file mode 100644
index 0000000..726e5ae
--- /dev/null
+++ b/tools/bug_chomper/res/style.css
@@ -0,0 +1,72 @@
+table#buglist {
+  border-collapse: collapse;
+  border-style: solid;
+  border-color: rgba(0, 0, 0, 1.0);
+  border-width: 3px;
+  width: 80%;
+  margin-left: 10%;
+  margin-right: 10%;
+}
+
+tr {
+  border-color: rgba(0, 0, 0, 1.0);
+  border-style: dashed;
+  border-width: 1px 3px;
+}
+
+tr.priority_Critical {
+  background-color: rgba(255, 0, 0, 0.3);
+}
+
+tr.priority_High {
+  background-color: rgba(255, 165, 0, 0.3);
+}
+
+tr.priority_Medium {
+  background-color: rgba(255, 255, 0, 0.3);
+}
+
+tr.priority_Low {
+  background-color: rgba(0, 255, 0, 0.3);
+}
+
+tr.priority_Never {
+  background-color: rgba(190, 190, 190, 0.3);
+}
+
+tbody {
+  background-color: rgba(190, 190, 190, 0.1);
+}
+
+tr.priority_row {
+  background-color: rgba(190, 190, 190, 0.1);
+  border-style: solid;
+}
+
+tr.tr_head {
+  background-color: rgba(190, 190, 190, 0.5);
+}
+
+#table_header {
+  text-align: center;
+}
+
+td {
+  padding: 5px;
+}
+
+td.priority_td {
+  text-align: center;
+}
+
+a {
+  color: black;
+}
+
+a:visited {
+  color: black;
+}
+
+a:hover {
+  text-decoration: none;
+}
\ No newline at end of file
diff --git a/tools/bug_chomper/res/third_party/jquery.tablednd.js b/tools/bug_chomper/res/third_party/jquery.tablednd.js
new file mode 100644
index 0000000..869f94e
--- /dev/null
+++ b/tools/bug_chomper/res/third_party/jquery.tablednd.js
@@ -0,0 +1,314 @@
+/**
+ * TableDnD plug-in for JQuery, allows you to drag and drop table rows
+ * You can set up various options to control how the system will work
+ * Copyright © Denis Howlett <denish@isocra.com>
+ * Licensed like jQuery, see http://docs.jquery.com/License.
+ *
+ * Configuration options:
+ * 
+ * onDragStyle
+ *     This is the style that is assigned to the row during drag. There are limitations to the styles that can be
+ *     associated with a row (such as you can't assign a border—well you can, but it won't be
+ *     displayed). (So instead consider using onDragClass.) The CSS style to apply is specified as
+ *     a map (as used in the jQuery css(...) function).
+ * onDropStyle
+ *     This is the style that is assigned to the row when it is dropped. As for onDragStyle, there are limitations
+ *     to what you can do. Also this replaces the original style, so again consider using onDragClass which
+ *     is simply added and then removed on drop.
+ * onDragClass
+ *     This class is added for the duration of the drag and then removed when the row is dropped. It is more
+ *     flexible than using onDragStyle since it can be inherited by the row cells and other content. The default
+ *     is class is tDnD_whileDrag. So to use the default, simply customise this CSS class in your
+ *     stylesheet.
+ * onDrop
+ *     Pass a function that will be called when the row is dropped. The function takes 2 parameters: the table
+ *     and the row that was dropped. You can work out the new order of the rows by using
+ *     table.rows.
+ * onDragStart
+ *     Pass a function that will be called when the user starts dragging. The function takes 2 parameters: the
+ *     table and the row which the user has started to drag.
+ * onAllowDrop
+ *     Pass a function that will be called as a row is over another row. If the function returns true, allow 
+ *     dropping on that row, otherwise not. The function takes 2 parameters: the dragged row and the row under
+ *     the cursor. It returns a boolean: true allows the drop, false doesn't allow it.
+ * scrollAmount
+ *     This is the number of pixels to scroll if the user moves the mouse cursor to the top or bottom of the
+ *     window. The page should automatically scroll up or down as appropriate (tested in IE6, IE7, Safari, FF2,
+ *     FF3 beta)
+ * 
+ * Other ways to control behaviour:
+ *
+ * Add class="nodrop" to any rows for which you don't want to allow dropping, and class="nodrag" to any rows
+ * that you don't want to be draggable.
+ *
+ * Inside the onDrop method you can also call $.tableDnD.serialize() this returns a string of the form
+ * <tableID>[]=<rowID1>&<tableID>[]=<rowID2> so that you can send this back to the server. The table must have
+ * an ID as must all the rows.
+ *
+ * Known problems:
+ * - Auto-scoll has some problems with IE7  (it scrolls even when it shouldn't), work-around: set scrollAmount to 0
+ * 
+ * Version 0.2: 2008-02-20 First public version
+ * Version 0.3: 2008-02-07 Added onDragStart option
+ *                         Made the scroll amount configurable (default is 5 as before)
+ * Version 0.4: 2008-03-15 Changed the noDrag/noDrop attributes to nodrag/nodrop classes
+ *                         Added onAllowDrop to control dropping
+ *                         Fixed a bug which meant that you couldn't set the scroll amount in both directions
+ *                         Added serialise method
+ */
+jQuery.tableDnD = {
+    /** Keep hold of the current table being dragged */
+    currentTable : null,
+    /** Keep hold of the current drag object if any */
+    dragObject: null,
+    /** The current mouse offset */
+    mouseOffset: null,
+    /** Remember the old value of Y so that we don't do too much processing */
+    oldY: 0,
+
+    /** Actually build the structure */
+    build: function(options) {
+        // Make sure options exists
+        options = options || {};
+        // Set up the defaults if any
+
+        this.each(function() {
+            // Remember the options
+            this.tableDnDConfig = {
+                onDragStyle: options.onDragStyle,
+                onDropStyle: options.onDropStyle,
+				// Add in the default class for whileDragging
+				onDragClass: options.onDragClass ? options.onDragClass : "tDnD_whileDrag",
+                onDrop: options.onDrop,
+                onDragStart: options.onDragStart,
+                scrollAmount: options.scrollAmount ? options.scrollAmount : 5
+            };
+            // Now make the rows draggable
+            jQuery.tableDnD.makeDraggable(this);
+        });
+
+        // Now we need to capture the mouse up and mouse move event
+        // We can use bind so that we don't interfere with other event handlers
+        jQuery(document)
+            .bind('mousemove', jQuery.tableDnD.mousemove)
+            .bind('mouseup', jQuery.tableDnD.mouseup);
+
+        // Don't break the chain
+        return this;
+    },
+
+    /** This function makes all the rows on the table draggable apart from those marked as "NoDrag" */
+    makeDraggable: function(table) {
+        // Now initialise the rows
+        var rows = table.rows; //getElementsByTagName("tr")
+        var config = table.tableDnDConfig;
+        for (var i=0; i<rows.length; i++) {
+            // To make non-draggable rows, add the nodrag class (eg for Category and Header rows) 
+			// inspired by John Tarr and Famic
+            var nodrag = $(rows[i]).hasClass("nodrag");
+            if (! nodrag) { //There is no NoDnD attribute on rows I want to drag
+                jQuery(rows[i]).mousedown(function(ev) {
+                    if (ev.target.tagName == "TD") {
+                        jQuery.tableDnD.dragObject = this;
+                        jQuery.tableDnD.currentTable = table;
+                        jQuery.tableDnD.mouseOffset = jQuery.tableDnD.getMouseOffset(this, ev);
+                        if (config.onDragStart) {
+                            // Call the onDrop method if there is one
+                            config.onDragStart(table, this);
+                        }
+                        return false;
+                    }
+                }).css("cursor", "move"); // Store the tableDnD object
+            }
+        }
+    },
+
+    /** Get the mouse coordinates from the event (allowing for browser differences) */
+    mouseCoords: function(ev){
+        if(ev.pageX || ev.pageY){
+            return {x:ev.pageX, y:ev.pageY};
+        }
+        return {
+            x:ev.clientX + document.body.scrollLeft - document.body.clientLeft,
+            y:ev.clientY + document.body.scrollTop  - document.body.clientTop
+        };
+    },
+
+    /** Given a target element and a mouse event, get the mouse offset from that element.
+        To do this we need the element's position and the mouse position */
+    getMouseOffset: function(target, ev) {
+        ev = ev || window.event;
+
+        var docPos    = this.getPosition(target);
+        var mousePos  = this.mouseCoords(ev);
+        return {x:mousePos.x - docPos.x, y:mousePos.y - docPos.y};
+    },
+
+    /** Get the position of an element by going up the DOM tree and adding up all the offsets */
+    getPosition: function(e){
+        var left = 0;
+        var top  = 0;
+        /** Safari fix -- thanks to Luis Chato for this! */
+        if (e.offsetHeight == 0) {
+            /** Safari 2 doesn't correctly grab the offsetTop of a table row
+            this is detailed here:
+            http://jacob.peargrove.com/blog/2006/technical/table-row-offsettop-bug-in-safari/
+            the solution is likewise noted there, grab the offset of a table cell in the row - the firstChild.
+            note that firefox will return a text node as a first child, so designing a more thorough
+            solution may need to take that into account, for now this seems to work in firefox, safari, ie */
+            e = e.firstChild; // a table cell
+        }
+
+        while (e.offsetParent){
+            left += e.offsetLeft;
+            top  += e.offsetTop;
+            e     = e.offsetParent;
+        }
+
+        left += e.offsetLeft;
+        top  += e.offsetTop;
+
+        return {x:left, y:top};
+    },
+
+    mousemove: function(ev) {
+        if (jQuery.tableDnD.dragObject == null) {
+            return;
+        }
+
+        var dragObj = jQuery(jQuery.tableDnD.dragObject);
+        var config = jQuery.tableDnD.currentTable.tableDnDConfig;
+        var mousePos = jQuery.tableDnD.mouseCoords(ev);
+        var y = mousePos.y - jQuery.tableDnD.mouseOffset.y;
+        //auto scroll the window
+	    var yOffset = window.pageYOffset;
+	 	if (document.all) {
+	        // Windows version
+	        //yOffset=document.body.scrollTop;
+	        if (typeof document.compatMode != 'undefined' &&
+	             document.compatMode != 'BackCompat') {
+	           yOffset = document.documentElement.scrollTop;
+	        }
+	        else if (typeof document.body != 'undefined') {
+	           yOffset=document.body.scrollTop;
+	        }
+
+	    }
+		    
+		if (mousePos.y-yOffset < config.scrollAmount) {
+	    	window.scrollBy(0, -config.scrollAmount);
+	    } else {
+            var windowHeight = window.innerHeight ? window.innerHeight
+                    : document.documentElement.clientHeight ? document.documentElement.clientHeight : document.body.clientHeight;
+            if (windowHeight-(mousePos.y-yOffset) < config.scrollAmount) {
+                window.scrollBy(0, config.scrollAmount);
+            }
+        }
+
+
+        if (y != jQuery.tableDnD.oldY) {
+            // work out if we're going up or down...
+            var movingDown = y > jQuery.tableDnD.oldY;
+            // update the old value
+            jQuery.tableDnD.oldY = y;
+            // update the style to show we're dragging
+			if (config.onDragClass) {
+				dragObj.addClass(config.onDragClass);
+			} else {
+	            dragObj.css(config.onDragStyle);
+			}
+            // If we're over a row then move the dragged row to there so that the user sees the
+            // effect dynamically
+            var currentRow = jQuery.tableDnD.findDropTargetRow(dragObj, y);
+            if (currentRow) {
+                // TODO worry about what happens when there are multiple TBODIES
+                if (movingDown && jQuery.tableDnD.dragObject != currentRow) {
+                    jQuery.tableDnD.dragObject.parentNode.insertBefore(jQuery.tableDnD.dragObject, currentRow.nextSibling);
+                } else if (! movingDown && jQuery.tableDnD.dragObject != currentRow) {
+                    jQuery.tableDnD.dragObject.parentNode.insertBefore(jQuery.tableDnD.dragObject, currentRow);
+                }
+            }
+        }
+
+        return false;
+    },
+
+    /** We're only worried about the y position really, because we can only move rows up and down */
+    findDropTargetRow: function(draggedRow, y) {
+        var rows = jQuery.tableDnD.currentTable.rows;
+        for (var i=0; i<rows.length; i++) {
+            var row = rows[i];
+            var rowY    = this.getPosition(row).y;
+            var rowHeight = parseInt(row.offsetHeight)/2;
+            if (row.offsetHeight == 0) {
+                rowY = this.getPosition(row.firstChild).y;
+                rowHeight = parseInt(row.firstChild.offsetHeight)/2;
+            }
+            // Because we always have to insert before, we need to offset the height a bit
+            if ((y > rowY - rowHeight) && (y < (rowY + rowHeight))) {
+                // that's the row we're over
+				// If it's the same as the current row, ignore it
+				if (row == draggedRow) {return null;}
+                var config = jQuery.tableDnD.currentTable.tableDnDConfig;
+                if (config.onAllowDrop) {
+                    if (config.onAllowDrop(draggedRow, row)) {
+                        return row;
+                    } else {
+                        return null;
+                    }
+                } else {
+					// If a row has nodrop class, then don't allow dropping (inspired by John Tarr and Famic)
+                    var nodrop = $(row).hasClass("nodrop");
+                    if (! nodrop) {
+                        return row;
+                    } else {
+                        return null;
+                    }
+                }
+                return row;
+            }
+        }
+        return null;
+    },
+
+    mouseup: function(e) {
+        if (jQuery.tableDnD.currentTable && jQuery.tableDnD.dragObject) {
+            var droppedRow = jQuery.tableDnD.dragObject;
+            var config = jQuery.tableDnD.currentTable.tableDnDConfig;
+            // If we have a dragObject, then we need to release it,
+            // The row will already have been moved to the right place so we just reset stuff
+			if (config.onDragClass) {
+	            jQuery(droppedRow).removeClass(config.onDragClass);
+			} else {
+	            jQuery(droppedRow).css(config.onDropStyle);
+			}
+            jQuery.tableDnD.dragObject   = null;
+            if (config.onDrop) {
+                // Call the onDrop method if there is one
+                config.onDrop(jQuery.tableDnD.currentTable, droppedRow);
+            }
+            jQuery.tableDnD.currentTable = null; // let go of the table too
+        }
+    },
+
+    serialize: function() {
+        if (jQuery.tableDnD.currentTable) {
+            var result = "";
+            var tableId = jQuery.tableDnD.currentTable.id;
+            var rows = jQuery.tableDnD.currentTable.rows;
+            for (var i=0; i<rows.length; i++) {
+                if (result.length > 0) result += "&";
+                result += tableId + '[]=' + rows[i].id;
+            }
+            return result;
+        } else {
+            return "Error: No Table id set, you need to set an id on your table and every row";
+        }
+    }
+}
+
+jQuery.fn.extend(
+	{
+		tableDnD : jQuery.tableDnD.build
+	}
+);
\ No newline at end of file
diff --git a/tools/bug_chomper/run_server.sh b/tools/bug_chomper/run_server.sh
new file mode 100755
index 0000000..5bedb4f
--- /dev/null
+++ b/tools/bug_chomper/run_server.sh
@@ -0,0 +1,11 @@
+if [[ -z `which go` ]]; then
+  echo "Please install Go before running the server."
+  exit 1
+fi
+
+go get github.com/gorilla/securecookie
+
+DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+cd $DIR
+GOPATH="$GOPATH:$DIR" go run $DIR/src/server/server.go $@
+
diff --git a/tools/bug_chomper/src/issue_tracker/issue_tracker.go b/tools/bug_chomper/src/issue_tracker/issue_tracker.go
new file mode 100644
index 0000000..e4854f5
--- /dev/null
+++ b/tools/bug_chomper/src/issue_tracker/issue_tracker.go
@@ -0,0 +1,303 @@
+// Copyright (c) 2014 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/*
+	Utilities for interacting with the GoogleCode issue tracker.
+
+	Example usage:
+		issueTracker := issue_tracker.MakeIssueTraker(myOAuthConfigFile)
+		authURL := issueTracker.MakeAuthRequestURL()
+		// Visit the authURL to obtain an authorization code.
+		issueTracker.UpgradeCode(code)
+		// Now issueTracker can be used to retrieve and edit issues.
+*/
+package issue_tracker
+
+import (
+	"bytes"
+	"code.google.com/p/goauth2/oauth"
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/url"
+	"strconv"
+	"strings"
+)
+
+// BugPriorities are the possible values for "Priority-*" labels for issues.
+var BugPriorities = []string{"Critical", "High", "Medium", "Low", "Never"}
+
+var apiScope = []string{
+	"https://www.googleapis.com/auth/projecthosting",
+	"https://www.googleapis.com/auth/userinfo.email",
+}
+
+const issueApiURL = "https://www.googleapis.com/projecthosting/v2/projects/"
+const issueURL = "https://code.google.com/p/skia/issues/detail?id="
+const personApiURL = "https://www.googleapis.com/userinfo/v2/me"
+
+// Enum for determining whether a label has been added, removed, or is
+// unchanged.
+const (
+	labelAdded = iota
+	labelRemoved
+	labelUnchanged
+)
+
+// loadOAuthConfig reads the OAuth given config file path and returns an
+// appropriate oauth.Config.
+func loadOAuthConfig(oauthConfigFile string) (*oauth.Config, error) {
+	errFmt := "failed to read OAuth config file: %s"
+	fileContents, err := ioutil.ReadFile(oauthConfigFile)
+	if err != nil {
+		return nil, fmt.Errorf(errFmt, err)
+	}
+	var decodedJson map[string]struct {
+		AuthURL      string `json:"auth_uri"`
+		ClientId     string `json:"client_id"`
+		ClientSecret string `json:"client_secret"`
+		TokenURL     string `json:"token_uri"`
+	}
+	if err := json.Unmarshal(fileContents, &decodedJson); err != nil {
+		return nil, fmt.Errorf(errFmt, err)
+	}
+	config, ok := decodedJson["web"]
+	if !ok {
+		return nil, fmt.Errorf(errFmt, err)
+	}
+	return &oauth.Config{
+		ClientId:     config.ClientId,
+		ClientSecret: config.ClientSecret,
+		Scope:        strings.Join(apiScope, " "),
+		AuthURL:      config.AuthURL,
+		TokenURL:     config.TokenURL,
+	}, nil
+}
+
+// Issue contains information about an issue.
+type Issue struct {
+	Id      int      `json:"id"`
+	Project string   `json:"projectId"`
+	Title   string   `json:"title"`
+	Labels  []string `json:"labels"`
+}
+
+// URL returns the URL of a given issue.
+func (i Issue) URL() string {
+	return issueURL + strconv.Itoa(i.Id)
+}
+
+// IssueList represents a list of issues from the IssueTracker.
+type IssueList struct {
+	TotalResults int      `json:"totalResults"`
+	Items        []*Issue `json:"items"`
+}
+
+// IssueTracker is the primary point of contact with the issue tracker,
+// providing methods for authenticating to and interacting with it.
+type IssueTracker struct {
+	OAuthConfig    *oauth.Config
+	OAuthTransport *oauth.Transport
+}
+
+// MakeIssueTracker creates and returns an IssueTracker with authentication
+// configuration from the given authConfigFile.
+func MakeIssueTracker(authConfigFile string, redirectURL string) (*IssueTracker, error) {
+	oauthConfig, err := loadOAuthConfig(authConfigFile)
+	if err != nil {
+		return nil, fmt.Errorf(
+			"failed to create IssueTracker: %s", err)
+	}
+	oauthConfig.RedirectURL = redirectURL
+	return &IssueTracker{
+		OAuthConfig:    oauthConfig,
+		OAuthTransport: &oauth.Transport{Config: oauthConfig},
+	}, nil
+}
+
+// MakeAuthRequestURL returns an authentication request URL which can be used
+// to obtain an authorization code via user sign-in.
+func (it IssueTracker) MakeAuthRequestURL() string {
+	// NOTE: Need to add XSRF protection if we ever want to run this on a public
+	// server.
+	return it.OAuthConfig.AuthCodeURL(it.OAuthConfig.RedirectURL)
+}
+
+// IsAuthenticated determines whether the IssueTracker has sufficient
+// permissions to retrieve and edit Issues.
+func (it IssueTracker) IsAuthenticated() bool {
+	return it.OAuthTransport.Token != nil
+}
+
+// UpgradeCode exchanges the single-use authorization code, obtained by
+// following the URL obtained from IssueTracker.MakeAuthRequestURL, for a
+// multi-use, session token. This is required before IssueTracker can retrieve
+// and edit issues.
+func (it *IssueTracker) UpgradeCode(code string) error {
+	token, err := it.OAuthTransport.Exchange(code)
+	if err == nil {
+		it.OAuthTransport.Token = token
+		return nil
+	} else {
+		return fmt.Errorf(
+			"failed to exchange single-user auth code: %s", err)
+	}
+}
+
+// GetLoggedInUser retrieves the email address of the authenticated user.
+func (it IssueTracker) GetLoggedInUser() (string, error) {
+	errFmt := "error retrieving user email: %s"
+	if !it.IsAuthenticated() {
+		return "", fmt.Errorf(errFmt, "User is not authenticated!")
+	}
+	resp, err := it.OAuthTransport.Client().Get(personApiURL)
+	if err != nil {
+		return "", fmt.Errorf(errFmt, err)
+	}
+	defer resp.Body.Close()
+	body, _ := ioutil.ReadAll(resp.Body)
+	if resp.StatusCode != http.StatusOK {
+		return "", fmt.Errorf(errFmt, fmt.Sprintf(
+			"user data API returned code %d: %v", resp.StatusCode, string(body)))
+	}
+	userInfo := struct {
+		Email string `json:"email"`
+	}{}
+	if err := json.Unmarshal(body, &userInfo); err != nil {
+		return "", fmt.Errorf(errFmt, err)
+	}
+	return userInfo.Email, nil
+}
+
+// GetBug retrieves the Issue with the given ID from the IssueTracker.
+func (it IssueTracker) GetBug(project string, id int) (*Issue, error) {
+	errFmt := fmt.Sprintf("error retrieving issue %d: %s", id, "%s")
+	if !it.IsAuthenticated() {
+		return nil, fmt.Errorf(errFmt, "user is not authenticated!")
+	}
+	requestURL := issueApiURL + project + "/issues/" + strconv.Itoa(id)
+	resp, err := it.OAuthTransport.Client().Get(requestURL)
+	if err != nil {
+		return nil, fmt.Errorf(errFmt, err)
+	}
+	defer resp.Body.Close()
+	body, _ := ioutil.ReadAll(resp.Body)
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf(errFmt, fmt.Sprintf(
+			"issue tracker returned code %d:%v", resp.StatusCode, string(body)))
+	}
+	var issue Issue
+	if err := json.Unmarshal(body, &issue); err != nil {
+		return nil, fmt.Errorf(errFmt, err)
+	}
+	return &issue, nil
+}
+
+// GetBugs retrieves all Issues with the given owner from the IssueTracker,
+// returning an IssueList.
+func (it IssueTracker) GetBugs(project string, owner string) (*IssueList, error) {
+	errFmt := "error retrieving issues: %s"
+	if !it.IsAuthenticated() {
+		return nil, fmt.Errorf(errFmt, "user is not authenticated!")
+	}
+	params := map[string]string{
+		"owner":      url.QueryEscape(owner),
+		"can":        "open",
+		"maxResults": "9999",
+	}
+	requestURL := issueApiURL + project + "/issues?"
+	first := true
+	for k, v := range params {
+		if first {
+			first = false
+		} else {
+			requestURL += "&"
+		}
+		requestURL += k + "=" + v
+	}
+	resp, err := it.OAuthTransport.Client().Get(requestURL)
+	if err != nil {
+		return nil, fmt.Errorf(errFmt, err)
+	}
+	defer resp.Body.Close()
+	body, _ := ioutil.ReadAll(resp.Body)
+	if resp.StatusCode != http.StatusOK {
+		return nil, fmt.Errorf(errFmt, fmt.Sprintf(
+			"issue tracker returned code %d:%v", resp.StatusCode, string(body)))
+	}
+
+	var bugList IssueList
+	if err := json.Unmarshal(body, &bugList); err != nil {
+		return nil, fmt.Errorf(errFmt, err)
+	}
+	return &bugList, nil
+}
+
+// SubmitIssueChanges creates a comment on the given Issue which modifies it
+// according to the contents of the passed-in Issue struct.
+func (it IssueTracker) SubmitIssueChanges(issue *Issue, comment string) error {
+	errFmt := "Error updating issue " + strconv.Itoa(issue.Id) + ": %s"
+	if !it.IsAuthenticated() {
+		return fmt.Errorf(errFmt, "user is not authenticated!")
+	}
+	oldIssue, err := it.GetBug(issue.Project, issue.Id)
+	if err != nil {
+		return fmt.Errorf(errFmt, err)
+	}
+	postData := struct {
+		Content string `json:"content"`
+		Updates struct {
+			Title  *string  `json:"summary"`
+			Labels []string `json:"labels"`
+		} `json:"updates"`
+	}{
+		Content: comment,
+	}
+	if issue.Title != oldIssue.Title {
+		postData.Updates.Title = &issue.Title
+	}
+	// TODO(borenet): Add other issue attributes, eg. Owner.
+	labels := make(map[string]int)
+	for _, label := range issue.Labels {
+		labels[label] = labelAdded
+	}
+	for _, label := range oldIssue.Labels {
+		if _, ok := labels[label]; ok {
+			labels[label] = labelUnchanged
+		} else {
+			labels[label] = labelRemoved
+		}
+	}
+	labelChanges := make([]string, 0)
+	for labelName, present := range labels {
+		if present == labelRemoved {
+			labelChanges = append(labelChanges, "-"+labelName)
+		} else if present == labelAdded {
+			labelChanges = append(labelChanges, labelName)
+		}
+	}
+	if len(labelChanges) > 0 {
+		postData.Updates.Labels = labelChanges
+	}
+
+	postBytes, err := json.Marshal(&postData)
+	if err != nil {
+		return fmt.Errorf(errFmt, err)
+	}
+	requestURL := issueApiURL + issue.Project + "/issues/" +
+		strconv.Itoa(issue.Id) + "/comments"
+	resp, err := it.OAuthTransport.Client().Post(
+		requestURL, "application/json", bytes.NewReader(postBytes))
+	if err != nil {
+		return fmt.Errorf(errFmt, err)
+	}
+	defer resp.Body.Close()
+	body, _ := ioutil.ReadAll(resp.Body)
+	if resp.StatusCode != http.StatusOK {
+		return fmt.Errorf(errFmt, fmt.Sprintf(
+			"Issue tracker returned code %d:%v", resp.StatusCode, string(body)))
+	}
+	return nil
+}
diff --git a/tools/bug_chomper/src/server/server.go b/tools/bug_chomper/src/server/server.go
new file mode 100644
index 0000000..9fb21ed
--- /dev/null
+++ b/tools/bug_chomper/src/server/server.go
@@ -0,0 +1,376 @@
+// Copyright (c) 2014 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+/*
+	Serves a webpage for easy management of Skia bugs.
+
+	WARNING: This server is NOT secure and should not be made publicly
+	accessible.
+*/
+
+package main
+
+import (
+	"encoding/json"
+	"flag"
+	"fmt"
+	"html/template"
+	"issue_tracker"
+	"log"
+	"net/http"
+	"net/url"
+	"path"
+	"path/filepath"
+	"strconv"
+	"strings"
+	"time"
+)
+
+import "github.com/gorilla/securecookie"
+
+const certFile = "certs/cert.pem"
+const keyFile = "certs/key.pem"
+const issueComment = "Edited by BugChomper"
+const oauthCallbackPath = "/oauth2callback"
+const oauthConfigFile = "oauth_client_secret.json"
+const defaultPort = 8000
+const localHost = "127.0.0.1"
+const maxSessionLen = time.Duration(3600 * time.Second)
+const priorityPrefix = "Priority-"
+const project = "skia"
+const cookieName = "BugChomperCookie"
+
+var scheme = "http"
+
+var curdir, _ = filepath.Abs(".")
+var templatePath, _ = filepath.Abs("templates")
+var templates = template.Must(template.ParseFiles(
+	path.Join(templatePath, "bug_chomper.html"),
+	path.Join(templatePath, "submitted.html"),
+	path.Join(templatePath, "error.html")))
+
+var hashKey = securecookie.GenerateRandomKey(32)
+var blockKey = securecookie.GenerateRandomKey(32)
+var secureCookie = securecookie.New(hashKey, blockKey)
+
+// SessionState contains data for a given session.
+type SessionState struct {
+	IssueTracker   *issue_tracker.IssueTracker
+	OrigRequestURL string
+	SessionStart   time.Time
+}
+
+// getAbsoluteURL returns the absolute URL of the given Request.
+func getAbsoluteURL(r *http.Request) string {
+	return scheme + "://" + r.Host + r.URL.Path
+}
+
+// getOAuth2CallbackURL returns a callback URL to be used by the OAuth2 login
+// page.
+func getOAuth2CallbackURL(r *http.Request) string {
+	return scheme + "://" + r.Host + oauthCallbackPath
+}
+
+func saveSession(session *SessionState, w http.ResponseWriter, r *http.Request) error {
+	encodedSession, err := secureCookie.Encode(cookieName, session)
+	if err != nil {
+		return fmt.Errorf("unable to encode session state: %s", err)
+	}
+	cookie := &http.Cookie{
+		Name:     cookieName,
+		Value:    encodedSession,
+		Domain:   strings.Split(r.Host, ":")[0],
+		Path:     "/",
+		HttpOnly: true,
+	}
+	http.SetCookie(w, cookie)
+	return nil
+}
+
+// makeSession creates a new session for the Request.
+func makeSession(w http.ResponseWriter, r *http.Request) (*SessionState, error) {
+	log.Println("Creating new session.")
+	// Create the session state.
+	issueTracker, err := issue_tracker.MakeIssueTracker(
+		oauthConfigFile, getOAuth2CallbackURL(r))
+	if err != nil {
+		return nil, fmt.Errorf("unable to create IssueTracker for session: %s", err)
+	}
+	session := SessionState{
+		IssueTracker:   issueTracker,
+		OrigRequestURL: getAbsoluteURL(r),
+		SessionStart:   time.Now(),
+	}
+
+	// Encode and store the session state.
+	if err := saveSession(&session, w, r); err != nil {
+		return nil, err
+	}
+
+	return &session, nil
+}
+
+// getSession retrieves the active SessionState or creates and returns a new
+// SessionState.
+func getSession(w http.ResponseWriter, r *http.Request) (*SessionState, error) {
+	cookie, err := r.Cookie(cookieName)
+	if err != nil {
+		log.Println("No cookie found! Starting new session.")
+		return makeSession(w, r)
+	}
+	var session SessionState
+	if err := secureCookie.Decode(cookieName, cookie.Value, &session); err != nil {
+		log.Printf("Invalid or corrupted session. Starting another: %s", err.Error())
+		return makeSession(w, r)
+	}
+
+	currentTime := time.Now()
+	if currentTime.Sub(session.SessionStart) > maxSessionLen {
+		log.Printf("Session starting at %s is expired. Starting another.",
+			session.SessionStart.Format(time.RFC822))
+		return makeSession(w, r)
+	}
+	saveSession(&session, w, r)
+	return &session, nil
+}
+
+// reportError serves the error page with the given message.
+func reportError(w http.ResponseWriter, msg string, code int) {
+	errData := struct {
+		Code       int
+		CodeString string
+		Message    string
+	}{
+		Code:       code,
+		CodeString: http.StatusText(code),
+		Message:    msg,
+	}
+	w.WriteHeader(code)
+	err := templates.ExecuteTemplate(w, "error.html", errData)
+	if err != nil {
+		log.Println("Failed to display error.html!!")
+	}
+}
+
+// makeBugChomperPage builds and serves the BugChomper page.
+func makeBugChomperPage(w http.ResponseWriter, r *http.Request) {
+	session, err := getSession(w, r)
+	if err != nil {
+		reportError(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	issueTracker := session.IssueTracker
+	user, err := issueTracker.GetLoggedInUser()
+	if err != nil {
+		reportError(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	log.Println("Loading bugs for " + user)
+	bugList, err := issueTracker.GetBugs(project, user)
+	if err != nil {
+		reportError(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	bugsById := make(map[string]*issue_tracker.Issue)
+	bugsByPriority := make(map[string][]*issue_tracker.Issue)
+	for _, bug := range bugList.Items {
+		bugsById[strconv.Itoa(bug.Id)] = bug
+		var bugPriority string
+		for _, label := range bug.Labels {
+			if strings.HasPrefix(label, priorityPrefix) {
+				bugPriority = label[len(priorityPrefix):]
+			}
+		}
+		if _, ok := bugsByPriority[bugPriority]; !ok {
+			bugsByPriority[bugPriority] = make(
+				[]*issue_tracker.Issue, 0)
+		}
+		bugsByPriority[bugPriority] = append(
+			bugsByPriority[bugPriority], bug)
+	}
+	bugsJson, err := json.Marshal(bugsById)
+	if err != nil {
+		reportError(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	data := struct {
+		Title          string
+		User           string
+		BugsJson       template.JS
+		BugsByPriority *map[string][]*issue_tracker.Issue
+		Priorities     []string
+		PriorityPrefix string
+	}{
+		Title:          "BugChomper",
+		User:           user,
+		BugsJson:       template.JS(string(bugsJson)),
+		BugsByPriority: &bugsByPriority,
+		Priorities:     issue_tracker.BugPriorities,
+		PriorityPrefix: priorityPrefix,
+	}
+
+	if err := templates.ExecuteTemplate(w, "bug_chomper.html", data); err != nil {
+		reportError(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+}
+
+// authIfNeeded determines whether the current user is logged in. If not, it
+// redirects to a login page. Returns true if the user is redirected and false
+// otherwise.
+func authIfNeeded(w http.ResponseWriter, r *http.Request) bool {
+	session, err := getSession(w, r)
+	if err != nil {
+		reportError(w, err.Error(), http.StatusInternalServerError)
+		return false
+	}
+	issueTracker := session.IssueTracker
+	if !issueTracker.IsAuthenticated() {
+		loginURL := issueTracker.MakeAuthRequestURL()
+		log.Println("Redirecting for login:", loginURL)
+		http.Redirect(w, r, loginURL, http.StatusTemporaryRedirect)
+		return true
+	}
+	return false
+}
+
+// submitData attempts to submit data from a POST request to the IssueTracker.
+func submitData(w http.ResponseWriter, r *http.Request) {
+	session, err := getSession(w, r)
+	if err != nil {
+		reportError(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	issueTracker := session.IssueTracker
+	edits := r.FormValue("all_edits")
+	var editsMap map[string]*issue_tracker.Issue
+	if err := json.Unmarshal([]byte(edits), &editsMap); err != nil {
+		errMsg := "Could not parse edits from form response: " + err.Error()
+		reportError(w, errMsg, http.StatusInternalServerError)
+		return
+	}
+	data := struct {
+		Title    string
+		Message  string
+		BackLink string
+	}{}
+	if len(editsMap) == 0 {
+		data.Title = "No Changes Submitted"
+		data.Message = "You didn't change anything!"
+		data.BackLink = ""
+		if err := templates.ExecuteTemplate(w, "submitted.html", data); err != nil {
+			reportError(w, err.Error(), http.StatusInternalServerError)
+			return
+		}
+		return
+	}
+	errorList := make([]error, 0)
+	for issueId, newIssue := range editsMap {
+		log.Println("Editing issue " + issueId)
+		if err := issueTracker.SubmitIssueChanges(newIssue, issueComment); err != nil {
+			errorList = append(errorList, err)
+		}
+	}
+	if len(errorList) > 0 {
+		errorStrings := ""
+		for _, err := range errorList {
+			errorStrings += err.Error() + "\n"
+		}
+		errMsg := "Not all changes could be submitted: \n" + errorStrings
+		reportError(w, errMsg, http.StatusInternalServerError)
+		return
+	}
+	data.Title = "Submitted Changes"
+	data.Message = "Your changes were submitted to the issue tracker."
+	data.BackLink = ""
+	if err := templates.ExecuteTemplate(w, "submitted.html", data); err != nil {
+		reportError(w, err.Error(), http.StatusInternalServerError)
+		return
+	}
+	return
+}
+
+// handleBugChomper handles HTTP requests for the bug_chomper page.
+func handleBugChomper(w http.ResponseWriter, r *http.Request) {
+	if authIfNeeded(w, r) {
+		return
+	}
+	switch r.Method {
+	case "GET":
+		makeBugChomperPage(w, r)
+	case "POST":
+		submitData(w, r)
+	}
+}
+
+// handleOAuth2Callback handles callbacks from the OAuth2 sign-in.
+func handleOAuth2Callback(w http.ResponseWriter, r *http.Request) {
+	session, err := getSession(w, r)
+	if err != nil {
+		reportError(w, err.Error(), http.StatusInternalServerError)
+	}
+	issueTracker := session.IssueTracker
+	invalidLogin := "Invalid login credentials"
+	params, err := url.ParseQuery(r.URL.RawQuery)
+	if err != nil {
+		reportError(w, invalidLogin+": "+err.Error(), http.StatusForbidden)
+		return
+	}
+	code, ok := params["code"]
+	if !ok {
+		reportError(w, invalidLogin+": redirect did not include auth code.",
+			http.StatusForbidden)
+		return
+	}
+	log.Println("Upgrading auth token:", code[0])
+	if err := issueTracker.UpgradeCode(code[0]); err != nil {
+		errMsg := "failed to upgrade token: " + err.Error()
+		reportError(w, errMsg, http.StatusForbidden)
+		return
+	}
+	if err := saveSession(session, w, r); err != nil {
+		reportError(w, "failed to save session: "+err.Error(),
+			http.StatusInternalServerError)
+		return
+	}
+	http.Redirect(w, r, session.OrigRequestURL, http.StatusTemporaryRedirect)
+	return
+}
+
+// handleRoot is the handler function for all HTTP requests at the root level.
+func handleRoot(w http.ResponseWriter, r *http.Request) {
+	log.Println("Fetching " + r.URL.Path)
+	if r.URL.Path == "/" || r.URL.Path == "/index.html" {
+		handleBugChomper(w, r)
+		return
+	}
+	http.NotFound(w, r)
+}
+
+// Run the BugChomper server.
+func main() {
+	var public bool
+	flag.BoolVar(
+		&public, "public", false, "Make this server publicly accessible.")
+	flag.Parse()
+
+	http.HandleFunc("/", handleRoot)
+	http.HandleFunc(oauthCallbackPath, handleOAuth2Callback)
+	http.Handle("/res/", http.FileServer(http.Dir(curdir)))
+	port := ":" + strconv.Itoa(defaultPort)
+	log.Println("Server is running at " + scheme + "://" + localHost + port)
+	var err error
+	if public {
+		log.Println("WARNING: This server is not secure and should not be made " +
+			"publicly accessible.")
+		scheme = "https"
+		err = http.ListenAndServeTLS(port, certFile, keyFile, nil)
+	} else {
+		scheme = "http"
+		err = http.ListenAndServe(localHost+port, nil)
+	}
+	if err != nil {
+		log.Println(err.Error())
+	}
+}
diff --git a/tools/bug_chomper/templates/bug_chomper.html b/tools/bug_chomper/templates/bug_chomper.html
new file mode 100644
index 0000000..df08570
--- /dev/null
+++ b/tools/bug_chomper/templates/bug_chomper.html
@@ -0,0 +1,118 @@
+<html>
+<head>
+<title>{{.Title}}</title>
+<link rel="stylesheet" type="text/css" href="res/style.css" />
+<link rel="icon" type="image/ico" href="res/favicon.ico" />
+<script type="text/javascript" src="//ajax.googleapis.com/ajax/libs/jquery/2.1.0/jquery.min.js"></script>
+<script type="text/javascript" src="res/third_party/jquery.tablednd.js"></script>
+<script type="text/javascript">
+"use strict";
+
+var issues = {{.BugsJson}};
+var edited = {};
+
+function edit_label(bug_id, old_value, new_value) {
+    console.log("issue[" + bug_id + "]: " + old_value + " -> " + new_value);
+    if (!edited[bug_id]) {
+        edited[bug_id] = JSON.parse(JSON.stringify(issues[bug_id]));
+    }
+    var old_index = edited[bug_id]["labels"].indexOf(old_value);
+    if (old_index > -1) {
+        edited[bug_id]["labels"][old_index] = new_value;
+    } else {
+        edited[bug_id]["labels"].push(new_value)
+    }
+    if (JSON.stringify(issues[bug_id]) == JSON.stringify(edited[bug_id])) {
+        console.log("Not changing " + bug_id);
+        delete edited[bug_id]
+    }
+    document.getElementById("all_edits").value = JSON.stringify(edited);
+}
+
+</script>
+</head>
+<body>
+<h1>BugChomper</h1>
+
+<form method="post">
+<input type="hidden" name="all_edits" id="all_edits" value="{}" />
+<input type="submit" value="Submit changes to issue tracker" />
+</form>
+<table id="buglist">
+  <thead>
+    <tr id="table_header" class="nodrag tr_head">
+      <td colspan=3><h2>Open bugs for {{.User}}</h2></td>
+    </tr>
+    <tr id="table_subheader" class="nodrag tr_head">
+      <td>ID</td>
+      <td>Priority</td>
+      <td>Title</td>
+    </tr>
+  </thead>
+  <tbody>
+    {{with $all_data := .}}
+      {{range $index, $priority := index $all_data.Priorities}}
+        <tr id="priority_{{$priority}}"
+            class="{{if eq $index 0}}nodrop{{else}}{{end}} nodrag priority_row priority_{{$priority}}"
+            >
+          <td colspan=3 class="priority_td">Priority {{$priority}}</td>
+        </tr>
+        {{range $index, $bug := index $all_data.BugsByPriority $priority}}
+          <tr id="{{$bug.Id}}" class="priority_{{$priority}}">
+            <td id="id_{{$bug.Id}}">
+              <a href="{{$bug.URL}}" target="_blank">{{$bug.Id}}</a>
+            </td>
+            <td id="priority_{{$bug.Id}}">{{$priority}}</td>
+            <td id="title_{{$bug.Id}}">{{$bug.Title}}</td>
+          </tr>
+        {{end}}
+      {{end}}
+    {{end}}
+  </tbody>
+</table>
+
+<script type="text/javascript">
+$(document).ready(function() {
+    $("#buglist").tableDnD({
+        onDrop: function(table, dropped_row) {
+            var id = dropped_row.id;
+            var css_priority_prefix = "priority_"
+            var new_priority = null;
+            var dropped_index = null;
+            var thead_rows = table.tHead.rows;
+            var tbody_rows = table.tBodies[0].rows;
+            var all_rows = [];
+            for (var i = 0; i < thead_rows.length; i++) {
+                all_rows.push(thead_rows[i]);
+            }
+            for (var i = 0; i < tbody_rows.length; i++) {
+                all_rows.push(tbody_rows[i]);
+            }
+            for (var i = 0; i < all_rows.length; i++) {
+                if (all_rows[i].id) {
+                    if (all_rows[i].id.indexOf(css_priority_prefix) == 0) {
+                        new_priority = all_rows[i].id.substring(css_priority_prefix.length);
+                    }
+                    if (all_rows[i].id == id) {
+                        break;
+                    }
+                } else {
+                  console.warn("No id for:");
+                  console.warn(all_rows[i]);
+                }
+            }
+            if (new_priority) {
+                priority_td = document.getElementById(css_priority_prefix + id);
+                old_priority = priority_td.innerHTML;
+                if (priority_td && new_priority != old_priority) {
+                    priority_td.innerHTML = new_priority;
+                    document.getElementById(id).className = css_priority_prefix + new_priority;
+                    edit_label(id, "{{.PriorityPrefix}}" + old_priority, "{{.PriorityPrefix}}" + new_priority);
+                }
+            }
+        }
+    });
+});
+</script>
+</body>
+</html>
diff --git a/tools/bug_chomper/templates/error.html b/tools/bug_chomper/templates/error.html
new file mode 100644
index 0000000..1e8fcda
--- /dev/null
+++ b/tools/bug_chomper/templates/error.html
@@ -0,0 +1,12 @@
+<html>
+<head>
+<title>Error {{.Code}}: {{.CodeString}}</title>
+<link rel="stylesheet" type="text/css" href="res/style.css" />
+<link rel="icon" type="image/ico" href="res/favicon.ico" />
+</head>
+<body>
+<h1>Error {{.Code}}: {{.CodeString}}</h1>
+{{.Message}}
+<br/>
+</body>
+</html>
diff --git a/tools/bug_chomper/templates/submitted.html b/tools/bug_chomper/templates/submitted.html
new file mode 100644
index 0000000..2b09c23
--- /dev/null
+++ b/tools/bug_chomper/templates/submitted.html
@@ -0,0 +1,13 @@
+<html>
+<head>
+<title>{{.Title}}</title>
+<link rel="stylesheet" type="text/css" href="res/style.css" />
+<link rel="icon" type="image/ico" href="res/favicon.ico" />
+</head>
+<body>
+<h1>{{.Title}}</h1>
+{{.Message}}
+<br/>
+<a href="{{.BackLink}}">Go back</a>
+</body>
+</html>