aboutsummaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--tools/bug_chomper/res/favicon.icobin0 -> 318 bytes
-rw-r--r--tools/bug_chomper/res/style.css72
-rw-r--r--tools/bug_chomper/res/third_party/jquery.tablednd.js314
-rwxr-xr-xtools/bug_chomper/run_server.sh11
-rw-r--r--tools/bug_chomper/src/issue_tracker/issue_tracker.go303
-rw-r--r--tools/bug_chomper/src/server/server.go376
-rw-r--r--tools/bug_chomper/templates/bug_chomper.html118
-rw-r--r--tools/bug_chomper/templates/error.html12
-rw-r--r--tools/bug_chomper/templates/submitted.html13
9 files changed, 1219 insertions, 0 deletions
diff --git a/tools/bug_chomper/res/favicon.ico b/tools/bug_chomper/res/favicon.ico
new file mode 100644
index 0000000000..e7440c7512
--- /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 0000000000..726e5aecd5
--- /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 0000000000..869f94effc
--- /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 0000000000..5bedb4f9ae
--- /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 0000000000..e4854f5ee6
--- /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 0000000000..9fb21ed594
--- /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 0000000000..df08570b86
--- /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 0000000000..1e8fcda491
--- /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 0000000000..2b09c238a8
--- /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>