diff options
author | 2014-05-13 18:03:45 +0000 | |
---|---|---|
committer | 2014-05-13 18:03:45 +0000 | |
commit | 513246162767bca05d2ceb8f2cf9b28e6f67f6fa (patch) | |
tree | 621cc50026ff16100e8151003d80c2edd03c1e10 | |
parent | 90bebbb9ed0a9bb8bef8d85f0396208c0c2896fb (diff) |
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
-rw-r--r-- | tools/bug_chomper/res/favicon.ico | bin | 0 -> 318 bytes | |||
-rw-r--r-- | tools/bug_chomper/res/style.css | 72 | ||||
-rw-r--r-- | tools/bug_chomper/res/third_party/jquery.tablednd.js | 314 | ||||
-rwxr-xr-x | tools/bug_chomper/run_server.sh | 11 | ||||
-rw-r--r-- | tools/bug_chomper/src/issue_tracker/issue_tracker.go | 303 | ||||
-rw-r--r-- | tools/bug_chomper/src/server/server.go | 376 | ||||
-rw-r--r-- | tools/bug_chomper/templates/bug_chomper.html | 118 | ||||
-rw-r--r-- | tools/bug_chomper/templates/error.html | 12 | ||||
-rw-r--r-- | tools/bug_chomper/templates/submitted.html | 13 |
9 files changed, 1219 insertions, 0 deletions
diff --git a/tools/bug_chomper/res/favicon.ico b/tools/bug_chomper/res/favicon.ico Binary files differnew file mode 100644 index 0000000000..e7440c7512 --- /dev/null +++ b/tools/bug_chomper/res/favicon.ico 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> |