зеркало из https://github.com/golang/build.git
devapp: add development dashboard
Resurrect the old go-dev.appspot.com, now backed by Gerrit and GitHub. Everything works except the state-mutating functions (permanently muting directories, and changing the assigned reviewer from the dashboard). The latter will likely never work with Gerrit. Change-Id: Iad712ef2995f21083dbc57b399504d9da6f0f2c6 Reviewed-on: https://go-review.googlesource.com/23600 Reviewed-by: Andrew Gerrand <adg@golang.org>
This commit is contained in:
Родитель
bd6b12a04d
Коммит
03b0f5e524
|
@ -14,7 +14,14 @@ handlers:
|
|||
static_files: static/index.html
|
||||
upload: static/index.html
|
||||
secure: always
|
||||
- url: /(|release|cl)
|
||||
- url: /favicon.ico
|
||||
static_files: static/golang.ico
|
||||
upload: static/golang.ico
|
||||
secure: always
|
||||
- url: /static
|
||||
static_dir: static
|
||||
secure: always
|
||||
- url: /(|dash|release|cl)
|
||||
script: _go_app
|
||||
secure: always
|
||||
- url: /update
|
||||
|
|
|
@ -0,0 +1,229 @@
|
|||
// Copyright 2016 The Go Authors. All rights reserved.
|
||||
// Use of this source code is governed by a BSD-style
|
||||
// license that can be found in the LICENSE file.
|
||||
|
||||
package devapp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"golang.org/x/build/godash"
|
||||
"golang.org/x/net/context"
|
||||
"google.golang.org/appengine"
|
||||
"google.golang.org/appengine/datastore"
|
||||
"google.golang.org/appengine/log"
|
||||
"google.golang.org/appengine/user"
|
||||
)
|
||||
|
||||
func findEmail(ctx context.Context, data *godash.Data) string {
|
||||
u := user.Current(ctx)
|
||||
|
||||
if u != nil {
|
||||
return data.Reviewers.Preferred(u.Email)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func showDash(w http.ResponseWriter, req *http.Request) {
|
||||
ctx := appengine.NewContext(req)
|
||||
req.ParseForm()
|
||||
|
||||
var cache Cache
|
||||
if err := datastore.Get(ctx, datastore.NewKey(ctx, entityPrefix+"Cache", "data", 0, nil), &cache); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
data := &godash.Data{Reviewers: &godash.Reviewers{}}
|
||||
if err := gob.NewDecoder(bytes.NewBuffer(cache.Value)).Decode(data); err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
return
|
||||
}
|
||||
|
||||
// Load information about logged-in user.
|
||||
var d display
|
||||
d.email = findEmail(ctx, data)
|
||||
d.data = data
|
||||
d.activeMilestones = data.GetActiveMilestones()
|
||||
// TODO(quentin): Load the user's preferences into d.pref.
|
||||
|
||||
tmpl, err := ioutil.ReadFile("template/dash.html")
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "reading template: %v", err)
|
||||
return
|
||||
}
|
||||
t, err := template.New("main").Funcs(template.FuncMap{
|
||||
"css": d.css,
|
||||
"join": d.join,
|
||||
"mine": d.mine,
|
||||
"muted": d.muted,
|
||||
"old": d.old,
|
||||
"replace": strings.Replace,
|
||||
"second": d.second,
|
||||
"short": d.short,
|
||||
"since": d.since,
|
||||
"ghemail": d.ghemail,
|
||||
"release": d.release,
|
||||
}).Parse(string(tmpl))
|
||||
if err != nil {
|
||||
log.Errorf(ctx, "parsing template: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
groups := data.GroupData(true, true)
|
||||
|
||||
var filtered []*godash.Group
|
||||
for _, group := range groups {
|
||||
if group.Dir == "closed" || group.Dir == "proposal" {
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, group)
|
||||
}
|
||||
|
||||
login, err := user.LoginURL(ctx, "/dash")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
}
|
||||
logout, err := user.LogoutURL(ctx, "/dash")
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), 500)
|
||||
}
|
||||
|
||||
tData := struct {
|
||||
User string
|
||||
Login, Logout string
|
||||
Dirs []*godash.Group
|
||||
}{
|
||||
d.email,
|
||||
login, logout,
|
||||
filtered,
|
||||
}
|
||||
|
||||
if err := t.Execute(w, tData); err != nil {
|
||||
log.Errorf(ctx, "execute: %v", err)
|
||||
http.Error(w, "error executing template", 500)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// display holds state needed to compute the displayed HTML.
|
||||
// The methods here are turned into functions for the template to call.
|
||||
// Not all methods need the display state; being methods just keeps
|
||||
// them all in one place.
|
||||
type display struct {
|
||||
email string
|
||||
data *godash.Data
|
||||
activeMilestones []string
|
||||
pref UserPref
|
||||
}
|
||||
|
||||
// short returns a shortened email address by removing @domain.
|
||||
// Input can be string or []string; output is same.
|
||||
func (d *display) short(s interface{}) interface{} {
|
||||
switch s := s.(type) {
|
||||
case string:
|
||||
return d.data.Reviewers.Shorten(s)
|
||||
case []string:
|
||||
v := make([]string, len(s))
|
||||
for i, t := range s {
|
||||
v[i] = d.short(t).(string)
|
||||
}
|
||||
return v
|
||||
default:
|
||||
return s
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// css returns name if cond is true; otherwise it returns the empty string.
|
||||
// It is intended for use in generating css class names (or not).
|
||||
func (d *display) css(name string, cond bool) string {
|
||||
if cond {
|
||||
return name
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// old returns css class "old" t is too long ago.
|
||||
func (d *display) old(t time.Time) string {
|
||||
return d.css("old", time.Since(t) > 7*24*time.Hour)
|
||||
}
|
||||
|
||||
// join is like strings.Join but takes arguments in the reverse order,
|
||||
// enabling {{list | join ","}}.
|
||||
func (d *display) join(sep string, list []string) string {
|
||||
return strings.Join(list, sep)
|
||||
}
|
||||
|
||||
// since returns the elapsed time since t as a number of days.
|
||||
func (d *display) since(t time.Time) string {
|
||||
// NOTE(rsc): Considered changing the unit (hours, days, weeks)
|
||||
// but that made it harder to scan through the table.
|
||||
// If it's always days, that's one less thing you have to read.
|
||||
// Otherwise 1 week might be misread as worse than 6 hours.
|
||||
dt := time.Since(t)
|
||||
return fmt.Sprintf("%.1f days ago", float64(dt)/float64(24*time.Hour))
|
||||
}
|
||||
|
||||
// second returns the css class "second" if the index is non-zero
|
||||
// (so really "second" here means "not first").
|
||||
func (d *display) second(index int) string {
|
||||
return d.css("second", index > 0)
|
||||
}
|
||||
|
||||
// mine returns the css class "mine" if the email address is the logged-in user.
|
||||
// It also returns "unassigned" for the unassigned reviewer "golang-dev"
|
||||
// (see reviewer above).
|
||||
func (d *display) mine(email string) string {
|
||||
if d.data.Reviewers.Preferred(email) == d.email {
|
||||
return "mine"
|
||||
}
|
||||
if email == "golang-dev" {
|
||||
return "unassigned"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ghemail converts a GitHub login name into an e-mail address, or
|
||||
// "@username" if the e-mail address is unknown.
|
||||
func (d *display) ghemail(login string) string {
|
||||
if login == "" {
|
||||
return login
|
||||
}
|
||||
if addr := d.data.Reviewers.ResolveGitHub(login); addr != "" {
|
||||
return addr
|
||||
}
|
||||
return "@" + login
|
||||
}
|
||||
|
||||
// muted returns the css class "muted" if the directory is muted.
|
||||
func (d *display) muted(dir string) string {
|
||||
for _, m := range d.pref.Muted {
|
||||
if m == dir {
|
||||
return "muted"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// release returns the css class "release" if the issue is related to the release.
|
||||
func (d *display) release(milestone string) string {
|
||||
for _, m := range d.activeMilestones {
|
||||
if m == milestone {
|
||||
return "release"
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// UserPref holds user preferences; stored in the datastore under email address.
|
||||
type UserPref struct {
|
||||
Muted []string
|
||||
}
|
|
@ -9,7 +9,7 @@ package devapp
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/gob"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
@ -31,6 +31,7 @@ func init() {
|
|||
page := page
|
||||
http.Handle("/"+page, hstsHandler(func(w http.ResponseWriter, r *http.Request) { servePage(w, r, page) }))
|
||||
}
|
||||
http.Handle("/dash", hstsHandler(showDash))
|
||||
http.HandleFunc("/update", updateHandler)
|
||||
http.HandleFunc("/setToken", setTokenHandler)
|
||||
}
|
||||
|
@ -82,7 +83,7 @@ func update(ctx context.Context) error {
|
|||
var token, cache Cache
|
||||
var keys []*datastore.Key
|
||||
keys = append(keys, datastore.NewKey(ctx, entityPrefix+"Cache", "github-token", 0, nil))
|
||||
keys = append(keys, datastore.NewKey(ctx, entityPrefix+"Cache", "reviewers", 0, nil))
|
||||
keys = append(keys, datastore.NewKey(ctx, entityPrefix+"Cache", "data", 0, nil))
|
||||
datastore.GetMulti(ctx, keys, []*Cache{&token, &cache}) // Ignore errors since they might not exist.
|
||||
// Without a deadline, urlfetch will use a 5s timeout which is too slow for Gerrit.
|
||||
ctx, cancel := context.WithTimeout(ctx, 9*time.Minute)
|
||||
|
@ -93,7 +94,8 @@ func update(ctx context.Context) error {
|
|||
ger.HTTPClient = urlfetch.Client(ctx)
|
||||
data := &godash.Data{Reviewers: &godash.Reviewers{}}
|
||||
if len(cache.Value) > 0 {
|
||||
if err := json.Unmarshal(cache.Value, data.Reviewers); err != nil {
|
||||
d := gob.NewDecoder(bytes.NewBuffer(cache.Value))
|
||||
if err := d.Decode(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
@ -128,14 +130,13 @@ func update(ctx context.Context) error {
|
|||
}
|
||||
}
|
||||
|
||||
// N.B. We can't serialize the whole data because a) it's too
|
||||
// big and b) it can only be serialized by Go >=1.7.
|
||||
js, err := json.MarshalIndent(data.Reviewers, "", " ")
|
||||
if err != nil {
|
||||
var cacheout bytes.Buffer
|
||||
e := gob.NewEncoder(&cacheout)
|
||||
if err := e.Encode(&data); err != nil {
|
||||
return err
|
||||
}
|
||||
cache.Value = js
|
||||
if _, err = datastore.Put(ctx, datastore.NewKey(ctx, entityPrefix+"Cache", "reviewers", 0, nil), &cache); err != nil {
|
||||
cache.Value = cacheout.Bytes()
|
||||
if _, err := datastore.Put(ctx, datastore.NewKey(ctx, entityPrefix+"Cache", "data", 0, nil), &cache); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
|
|
|
@ -0,0 +1,102 @@
|
|||
h1 {
|
||||
font-size: 1.5em;
|
||||
margin-top: 0.1em;
|
||||
margin-bottom: 0.1em;
|
||||
}
|
||||
.loginbar {
|
||||
float: right;
|
||||
}
|
||||
a.showing {
|
||||
color: black;
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
table {
|
||||
border-spacing: 0;
|
||||
width: 100%;
|
||||
}
|
||||
tbody.hidden tr.dir {
|
||||
display: none;
|
||||
}
|
||||
tr.hidden {
|
||||
display: none;
|
||||
}
|
||||
tr.item td {
|
||||
border-top: 1px solid #ccc;
|
||||
}
|
||||
td {
|
||||
vertical-align: top;
|
||||
padding: 2px;
|
||||
}
|
||||
td.highlight {
|
||||
width: 1em;
|
||||
}
|
||||
tr.dir {
|
||||
background-color: #ffc;
|
||||
}
|
||||
tr.dir td {
|
||||
border-top: 1px solid black;
|
||||
}
|
||||
td.id {
|
||||
width: 7em;
|
||||
}
|
||||
tr.todo td.highlight {
|
||||
border-right: 5px solid blue;
|
||||
}
|
||||
td.author {
|
||||
width: 9em;
|
||||
}
|
||||
td.reviewer {
|
||||
width: 9em;
|
||||
}
|
||||
div.loginbar, span.howto, span.lgtmornot, span.summary, span.files {
|
||||
font-family: sans-serif;
|
||||
font-size: 80%;
|
||||
}
|
||||
div.loginbar {
|
||||
padding-bottom: 1em;
|
||||
}
|
||||
span.lgtmornot, span.summary, span.files {
|
||||
color: #777;
|
||||
}
|
||||
span.lgtm {
|
||||
color: #0c8;
|
||||
font-weight: bold;
|
||||
}
|
||||
span.notlgtm {
|
||||
color: #e00;
|
||||
font-weight: bold;
|
||||
}
|
||||
span.milestone {
|
||||
font-size: 60%;
|
||||
}
|
||||
span.milestone.release {
|
||||
background-color: #f99;
|
||||
}
|
||||
tr.old span.age {
|
||||
font-weight: bold;
|
||||
font-style: italic;
|
||||
color: #e00;
|
||||
}
|
||||
span.assignreviewer {
|
||||
font-weight: normal;
|
||||
font-size: 60%;
|
||||
font-family: sans-serif;
|
||||
}
|
||||
a.big {
|
||||
font-size: 150%;
|
||||
border: 1px solid blue;
|
||||
}
|
||||
td.mine {
|
||||
font-weight: bold;
|
||||
}
|
||||
tr.nest td.id {
|
||||
padding-left: 1em;
|
||||
}
|
||||
div.extra {
|
||||
margin-left: 1em;
|
||||
}
|
||||
span.verb {
|
||||
font-size: 60%;
|
||||
font-family: sans-serif;
|
||||
}
|
|
@ -0,0 +1,194 @@
|
|||
var mode = "all"
|
||||
|
||||
function readURL() {
|
||||
mode = window.location.hash.substr(1)
|
||||
if(mode.match(/\+muted/)) {
|
||||
$("#showmute").attr("checked","checked");
|
||||
mode = mode.replace(/\+muted/, "");
|
||||
}
|
||||
if(mode.match(/\+hidecl/)) {
|
||||
$("#showcl").attr("checked","");
|
||||
mode = mode.replace(/\+hidecl/, "");
|
||||
}
|
||||
if(mode.match(/\+hideissue/)) {
|
||||
$("#showissue").attr("checked","");
|
||||
mode = mode.replace(/\+hideissue/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function show(newmode) {
|
||||
mode = newmode
|
||||
redraw()
|
||||
}
|
||||
|
||||
function redraw() {
|
||||
// Invariant: a tr containing a td with mine and todo classes itself has class todo.
|
||||
$("tr.todo").removeClass("todo");
|
||||
$("td.mine.todo").parent().addClass("todo");
|
||||
|
||||
// Start with all items hidden.
|
||||
$("tr.item").addClass("hidden");
|
||||
$("tbody.dir").addClass("hidden");
|
||||
|
||||
$("tr.item").removeClass("unhide");
|
||||
|
||||
// Unhide the rows we want to show.
|
||||
var show;
|
||||
var showmute = $("#showmute").prop('checked');
|
||||
var showcl = $("#showcl").prop('checked');
|
||||
var showissue = $("#showissue").prop('checked');
|
||||
if(mode == "mine") {
|
||||
$("td.mine").parent().addClass("unhide");
|
||||
} else if(mode == "release") {
|
||||
$("td.release").parent().addClass("unhide");
|
||||
} else if(mode == "todo") {
|
||||
$("td.mine.todo").parent().addClass("unhide");
|
||||
} else if(mode == "unassigned") {
|
||||
var show = $("td.unassigned").parent();
|
||||
if(!showmute)
|
||||
show = show.not("tbody.muted tr.item");
|
||||
show.addClass("unhide");
|
||||
} else {
|
||||
mode = "all"
|
||||
if(showmute) {
|
||||
$("tr.item").addClass("unhide");
|
||||
} else {
|
||||
$("tbody:not(.muted) tr.item").addClass("unhide");
|
||||
$("td.mine").parent().addClass("unhide");
|
||||
}
|
||||
}
|
||||
|
||||
// But keep issues and/or CLs hidden.
|
||||
if(!showissue)
|
||||
$("td.issue").parent().removeClass("unhide");
|
||||
if(!showcl)
|
||||
$("td.codereview").parent().removeClass("unhide");
|
||||
|
||||
$("tr.unhide").removeClass("hidden");
|
||||
|
||||
// Unhide the tbody containing the items we want to show.
|
||||
// Unhiding a tbody will unhide its directory row.
|
||||
$("tr.item:not(.hidden)").parent().removeClass("hidden");
|
||||
|
||||
// Make the current mode look less like a link.
|
||||
$("a.showbar").removeClass("showing");
|
||||
$("#show-"+mode).addClass("showing");
|
||||
|
||||
// Update window hash for bookmarking.
|
||||
var hash = mode
|
||||
if(showmute) {
|
||||
hash += "+muted"
|
||||
}
|
||||
if(!showcl) {
|
||||
hash += "+hidecl"
|
||||
}
|
||||
if(!showissue) {
|
||||
hash += "+hideissue"
|
||||
}
|
||||
window.location.hash = hash
|
||||
}
|
||||
|
||||
function mute(ev, dir) {
|
||||
var dirclass = "dir-" + dir.replace(/\//g, "\\/").replace(/\./g, "\\.");
|
||||
|
||||
var outer = $(ev.delegateTarget);
|
||||
var muting = outer.text() == "mute";
|
||||
var op = "";
|
||||
if(muting) {
|
||||
outer.text("muting...");
|
||||
op = "mute";
|
||||
} else {
|
||||
outer.text("unmuting...");
|
||||
op = "unmute";
|
||||
}
|
||||
console.log("Mute: " + dir)
|
||||
$.ajax({
|
||||
"type": "POST",
|
||||
"url": "/uiop",
|
||||
"data": {
|
||||
"dir": dir,
|
||||
"op": op
|
||||
},
|
||||
"success": function() {
|
||||
if(op == "mute") {
|
||||
$("tbody." + dirclass).addClass("muted");
|
||||
outer.text("unmute");
|
||||
} else {
|
||||
$("tbody." + dirclass).removeClass("muted");
|
||||
outer.text("mute");
|
||||
}
|
||||
redraw();
|
||||
},
|
||||
"error": function(xhr, status) {
|
||||
outer.text("failed: " + status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
function setreviewer(a, rev) {
|
||||
var clnumber = a.attr("id").replace("assign-", "");
|
||||
var who = rev.text();
|
||||
$.ajax({
|
||||
"type": "POST",
|
||||
"url": "/uiop",
|
||||
"data": {
|
||||
"cl": clnumber,
|
||||
"reviewer": who,
|
||||
"op": "reviewer"
|
||||
},
|
||||
"dataType": "text",
|
||||
"success": function(data) {
|
||||
a.text("edit");
|
||||
if(data.match(/^ERROR/)) {
|
||||
$("#err-" + clnumber).text(data);
|
||||
return;
|
||||
}
|
||||
rev.text(data);
|
||||
},
|
||||
"error": function(xhr, status) {
|
||||
a.text("failed: " + status)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
$(document).ready(function() {
|
||||
// Define handler for mute links.
|
||||
$("a.mute").click(function(ev) {
|
||||
ev.preventDefault();
|
||||
var classes = $(ev.delegateTarget).attr("class").split(/\s+/);
|
||||
for(var i in classes) {
|
||||
var cl = classes[i];
|
||||
if(cl.substr(0,4) == "dir-") {
|
||||
mute(ev, cl.substr(4))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// Define handler for edit-reviewer links.
|
||||
$("a.assignreviewer").click(function(ev) {
|
||||
ev.preventDefault();
|
||||
var a = $(ev.delegateTarget);
|
||||
var revid = a.attr("id").replace("assign-", "reviewer-");
|
||||
var rev = $("#" + revid);
|
||||
if(a.text() == "edit") {
|
||||
rev.attr("contenteditable", "true");
|
||||
rev.focus();
|
||||
a.addClass("big");
|
||||
a.text("save");
|
||||
} else if(a.text() == "save") {
|
||||
a.text("saving...");
|
||||
a.removeClass("big");
|
||||
rev.attr("contenteditable", "false");
|
||||
setreviewer(a, rev);
|
||||
}
|
||||
})
|
||||
|
||||
// Update mode from URL in browser and redraw.
|
||||
readURL();
|
||||
redraw();
|
||||
|
||||
// Redraw any time the muting checkbox changes.
|
||||
$("#showmute").change(redraw);
|
||||
$("#showcl").change(redraw);
|
||||
$("#showissue").change(redraw);
|
||||
})
|
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 412 B |
Двоичный файл не отображается.
После Ширина: | Высота: | Размер: 1.1 KiB |
|
@ -2,6 +2,7 @@
|
|||
<pre>
|
||||
<a href="release">Go release dashboard</a>
|
||||
<a href="cl">Go CL dashboard</a>
|
||||
<a href="dash">Go development dashboard</a>
|
||||
|
||||
<b>About the Dashboards</b>
|
||||
|
||||
|
|
|
@ -0,0 +1,74 @@
|
|||
<html>
|
||||
<head>
|
||||
<title>Go development dashboard</title>
|
||||
<link rel="stylesheet" href="/static/dash.css" />
|
||||
<script src="//ajax.googleapis.com/ajax/libs/jquery/1.8.2/jquery.min.js"></script>
|
||||
<script src="/static/dash.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="loginbar">
|
||||
{{if .User}}
|
||||
logged in as {{.User}} <a href="{{.Logout}}">log out</a><br>
|
||||
show
|
||||
<a href="javascript:show('all')" class="showbar" id="show-all">all</a> |
|
||||
<a href="javascript:show('release')" class="showbar" id="show-release">release</a> |
|
||||
<a href="javascript:show('mine')" class="showbar" id="show-mine">mine</a> |
|
||||
<a href="javascript:show('todo')" class="showbar" id="show-todo">my TODOs</a> |
|
||||
<a href="javascript:show('unassigned')" class="showbar" id="show-unassigned">unassigned</a>
|
||||
<br>
|
||||
<span id="showmutetext">include muted directories</span> <input type=checkbox id="showmute"></input>
|
||||
{{else}}
|
||||
<a href="{{.Login}}">log in for personalization</a><br>
|
||||
show
|
||||
<a href="javascript:show('all')" class="showbar" id="show-all">all</a> |
|
||||
<a href="javascript:show('release')" class="showbar" id="show-release">release</a> |
|
||||
<a href="javascript:show('unassigned')" class="showbar" id="show-unassigned">unassigned</a>
|
||||
{{end}}
|
||||
| <span id="showcltext">show CLs</span> <input type=checkbox id="showcl" checked=checked></input>
|
||||
| <span id="showissuetext">show issues</span> <input type=checkbox id="showissue" checked=checked></input>
|
||||
</div>
|
||||
|
||||
<h1>Go development dashboard</h1>
|
||||
<span class="howto"><a target="_blank" href="http://golang.org/s/go-dev-howto">how to use</a><br></span>
|
||||
<br>
|
||||
|
||||
<table>
|
||||
{{range .Dirs}}
|
||||
{{$dir := .Dir}}
|
||||
|
||||
<tbody class="dir dir-{{$dir}} {{muted $dir}}">
|
||||
<tr class="dir dir-{{$dir}}">
|
||||
<td colspan=5>
|
||||
<b>{{.Dir}}</b> <span class="verb"><a class="dir-{{$dir}} mute" href="#">{{if muted $dir}}un{{end}}mute</a></span>
|
||||
|
||||
{{range $ItemIndex, $Item := .Items}}
|
||||
{{with .Issue}}
|
||||
<tr class="item {{second $ItemIndex}}">
|
||||
<td class="highlight">
|
||||
<td class="issue id {{.Milestone | release}} "><a target="_blank" href="https://golang.org/issue/{{.Number}}">#{{.Number}}</a>
|
||||
<td class="author {{.Reporter | ghemail | mine}}">{{.Reporter | ghemail | short}}
|
||||
<td class="reviewer {{.Assignee | ghemail | mine}}">{{.Assignee | ghemail | short}}
|
||||
<td class="summary">{{.Title}}
|
||||
{{with .Milestone}}<span class="milestone {{. | release}}">{{.}}</span>{{end}}
|
||||
{{end}}
|
||||
{{range .CLs}}
|
||||
<tr class="item {{if $Item.Issue}}nest{{end}} {{.NeedsReviewChanged | old}}">
|
||||
<td class="highlight">
|
||||
<td class="codereview id"><a target="_blank" href="https://golang.org/cl/{{.Number}}">CL {{.Number}}</a>
|
||||
<td class="author {{.Author | mine}} {{css "todo" (not .NeedsReview)}}">{{.Author | short}}
|
||||
<td class="reviewer {{.Reviewer | mine}} {{css "todo" .NeedsReview}}">
|
||||
<span id="reviewer-{{.Number}}">{{.Reviewer | short}}</span>
|
||||
<td class="summary">{{.Subject}}
|
||||
<span class="lgtmornot">{{range $r, $score := .Scores}}<span class="{{css "lgtm" (ge $score 0)}}{{css "notlgtm" (le $score 0)}}">{{$r}} {{printf "%+d" $score}}</span> {{end}}</span><br>
|
||||
<div class="extra">
|
||||
<span class="summary"><span class="age">last updated {{.NeedsReviewChanged | since}}</span>, {{if .NeedsReview}}<span class="needsreview">waiting for reviewer</span>{{else}}<span class="needswork">waiting for author</span>{{end}}</span><br>
|
||||
<span class="files">{{.Files | join " "}}</span>
|
||||
</div>
|
||||
{{end}}
|
||||
{{end}}
|
||||
</tbody>
|
||||
{{end}}
|
||||
</table>
|
||||
</body>
|
||||
</html>
|
|
@ -113,7 +113,12 @@ func (d *Data) FetchData(gh *github.Client, ger *gerrit.Client, days int, clOnly
|
|||
return nil
|
||||
}
|
||||
|
||||
func (d *Data) groupData(includeIssues, allCLs bool) []*Group {
|
||||
// GroupData returns information about all the issues and CLs,
|
||||
// grouping related issues and CLs together and then grouping those
|
||||
// items by directory affected. includeIssues specifies whether to
|
||||
// include both CLs and issues or just CLs. allCLs specifies whether
|
||||
// to include CLs from non-go projects (i.e. x/ repos).
|
||||
func (d *Data) GroupData(includeIssues, allCLs bool) []*Group {
|
||||
groupsByDir := make(map[string]*Group)
|
||||
addGroup := func(item *Item) {
|
||||
dir := item.Dir()
|
||||
|
@ -332,7 +337,7 @@ type milestone struct {
|
|||
due time.Time
|
||||
}
|
||||
|
||||
func (d *Data) getActiveMilestones() []string {
|
||||
func (d *Data) GetActiveMilestones() []string {
|
||||
var all []milestone
|
||||
for _, dm := range d.Milestones {
|
||||
if m := milestoneRE.FindStringSubmatch(*dm.Title); m != nil {
|
||||
|
@ -376,9 +381,9 @@ type section struct {
|
|||
}
|
||||
|
||||
func (d *Data) PrintIssues(w io.Writer) {
|
||||
groups := d.groupData(true, false)
|
||||
groups := d.GroupData(true, false)
|
||||
|
||||
milestones := d.getActiveMilestones()
|
||||
milestones := d.GetActiveMilestones()
|
||||
|
||||
sections := []*section{}
|
||||
|
||||
|
@ -392,7 +397,7 @@ func (d *Data) PrintIssues(w io.Writer) {
|
|||
// Pending CLs
|
||||
// This uses a different grouping (by CL, not by issue) since
|
||||
// otherwise we might print a CL twice.
|
||||
count, body := d.printGroups(d.groupData(false, false), false, func(item *Item) bool { return len(item.CLs) > 0 })
|
||||
count, body := d.printGroups(d.GroupData(false, false), false, func(item *Item) bool { return len(item.CLs) > 0 })
|
||||
sections = append(sections, §ion{
|
||||
"Pending CLs",
|
||||
count, body,
|
||||
|
@ -433,7 +438,7 @@ func (d *Data) PrintIssues(w io.Writer) {
|
|||
}
|
||||
|
||||
func (d *Data) PrintCLs(w io.Writer) {
|
||||
count, body := d.printGroups(d.groupData(false, true), true, func(item *Item) bool { return len(item.CLs) > 0 })
|
||||
count, body := d.printGroups(d.GroupData(false, true), true, func(item *Item) bool { return len(item.CLs) > 0 })
|
||||
fmt.Fprintf(w, "%d Pending CLs\n", count)
|
||||
fmt.Fprintf(w, "\n%s\n%s", "Pending CLs", body)
|
||||
}
|
||||
|
|
|
@ -5,6 +5,8 @@
|
|||
package godash
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/gob"
|
||||
"encoding/json"
|
||||
"sort"
|
||||
"strings"
|
||||
|
@ -45,11 +47,16 @@ type Reviewers struct {
|
|||
// CountByAddr maps full e-mail address to a score of
|
||||
// the number of CLs authored and reviewed.
|
||||
CountByAddr map[string]int64
|
||||
// GitHubByAddr maps full e-mail address to GitHub username.
|
||||
GitHubByAddr map[string]string
|
||||
// LastSHA and LastTime track the SHA and time of the
|
||||
// last commit included in these stats.
|
||||
LastSHA string
|
||||
LastTime time.Time
|
||||
}
|
||||
// addrByGitHub maps GitHub usernames to preferred address
|
||||
// (the address with the highest review count).
|
||||
addrByGitHub map[string]string
|
||||
// mailLookup maps short names to full e-mail addresses.
|
||||
mailLookup map[string]string // rsc -> rsc@golang.org
|
||||
}
|
||||
|
@ -76,6 +83,23 @@ func (r *Reviewers) Resolve(short string) string {
|
|||
return r.mailLookup[short]
|
||||
}
|
||||
|
||||
// Preferred takes an address and returns the preferred e-mail address
|
||||
// for that user, which may be the same. It does this by resolving the
|
||||
// GitHub username and then returning the address most-used for
|
||||
// commits on that username.
|
||||
func (r *Reviewers) Preferred(addr string) string {
|
||||
if out := r.addrByGitHub[r.data.GitHubByAddr[addr]]; out != "" {
|
||||
return out
|
||||
}
|
||||
return addr
|
||||
}
|
||||
|
||||
// ResolveGitHub takes a GitHub login name and returns the matching
|
||||
// full e-mail address, or "" if the name could not be resolved.
|
||||
func (r *Reviewers) ResolveGitHub(login string) string {
|
||||
return r.addrByGitHub[login]
|
||||
}
|
||||
|
||||
// add increments a reviewer's count. recalculate must be called to
|
||||
// regenerate the mail lookup table.
|
||||
func (r *Reviewers) add(addr string, isReviewer bool) {
|
||||
|
@ -88,6 +112,9 @@ func (r *Reviewers) add(addr string, isReviewer bool) {
|
|||
if r.data.CountByAddr == nil {
|
||||
r.data.CountByAddr = make(map[string]int64)
|
||||
}
|
||||
if r.data.GitHubByAddr == nil {
|
||||
r.data.GitHubByAddr = make(map[string]string)
|
||||
}
|
||||
if isReviewer {
|
||||
r.data.IsReviewer[addr] = true
|
||||
r.data.CountByAddr[addr] += reviewScale
|
||||
|
@ -112,6 +139,12 @@ func (r *Reviewers) recalculate() {
|
|||
r.mailLookup[short] = rev.addr
|
||||
}
|
||||
}
|
||||
r.addrByGitHub = map[string]string{}
|
||||
for addr, user := range r.data.GitHubByAddr {
|
||||
if r.addrByGitHub[user] == "" || r.data.CountByAddr[r.addrByGitHub[user]] < r.data.CountByAddr[addr] {
|
||||
r.addrByGitHub[user] = addr
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Reviewers) MarshalJSON() ([]byte, error) {
|
||||
|
@ -125,3 +158,21 @@ func (r *Reviewers) UnmarshalJSON(b []byte) error {
|
|||
r.recalculate()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Reviewers) GobEncode() ([]byte, error) {
|
||||
var out bytes.Buffer
|
||||
e := gob.NewEncoder(&out)
|
||||
if err := e.Encode(&r.data); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return out.Bytes(), nil
|
||||
}
|
||||
|
||||
func (r *Reviewers) GobDecode(b []byte) error {
|
||||
d := gob.NewDecoder(bytes.NewBuffer(b))
|
||||
if err := d.Decode(&r.data); err != nil {
|
||||
return err
|
||||
}
|
||||
r.recalculate()
|
||||
return nil
|
||||
}
|
||||
|
|
|
@ -39,6 +39,9 @@ pages:
|
|||
}
|
||||
if commit.Commit != nil && commit.Commit.Author != nil && commit.Commit.Author.Email != nil {
|
||||
r.add(*commit.Commit.Author.Email, false)
|
||||
if commit.Author != nil && commit.Author.Login != nil {
|
||||
r.data.GitHubByAddr[*commit.Commit.Author.Email] = *commit.Author.Login
|
||||
}
|
||||
}
|
||||
if commit.Commit != nil && commit.Commit.Message != nil {
|
||||
for _, line := range strings.Split(string(*commit.Commit.Message), "\n") {
|
||||
|
|
Загрузка…
Ссылка в новой задаче