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:
Quentin Smith 2016-05-27 18:38:52 -04:00
Родитель bd6b12a04d
Коммит 03b0f5e524
12 изменённых файлов: 683 добавлений и 16 удалений

Просмотреть файл

@ -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

229
devapp/dash.go Normal file
Просмотреть файл

@ -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

102
devapp/static/dash.css Normal file
Просмотреть файл

@ -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;
}

194
devapp/static/dash.js Normal file
Просмотреть файл

@ -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);
})

Двоичные данные
devapp/static/favicon.ico Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 412 B

Двоичные данные
devapp/static/golang.ico Normal file

Двоичный файл не отображается.

После

Ширина:  |  Высота:  |  Размер: 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>

74
devapp/template/dash.html Normal file
Просмотреть файл

@ -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, &section{
"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") {