cmd/gopherbot: Congratulate users when they add their first CL

It's nice to encourage people who submit their first CL, and I figured
we could provide them useful info - the things Josh usually emails to
new contributors.

Parse Author information from Gerrit commit messages, so we can check
whether Gopherbot sent a given message (instead of another user).

Also fix a TODO involving the ordering of messages appended to
a GerritCL.

Change-Id: Ifde950ac08d76a6b4c50c356697068c6d6beb0d7
Reviewed-on: https://go-review.googlesource.com/46390
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
This commit is contained in:
Kevin Burke 2017-04-20 22:13:13 -07:00
Родитель 947af569fa
Коммит 9306cbc43a
2 изменённых файлов: 260 добавлений и 17 удалений

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

@ -17,19 +17,28 @@ import (
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"cloud.google.com/go/compute/metadata"
"github.com/google/go-github/github"
"golang.org/x/build/gerrit"
"golang.org/x/build/maintner"
"golang.org/x/build/maintner/godata"
"golang.org/x/oauth2"
)
var (
dryRun = flag.Bool("dry-run", false, "just report what would've been done, without changing anything")
daemon = flag.Bool("daemon", false, "run in daemon mode")
dryRun = flag.Bool("dry-run", false, "just report what would've been done, without changing anything")
daemon = flag.Bool("daemon", false, "run in daemon mode")
githubTokenFile = flag.String("github-token-file", filepath.Join(os.Getenv("HOME"), "keys", "github-gobot"), `File to load Github token from. File should be of form <username>:<token>`)
// go here: https://go-review.googlesource.com/settings#HTTPCredentials
// click "Obtain Password"
// The next page will have a .gitcookies file - look for the part that has
// "git-youremail@yourcompany.com=password". Copy and paste that to the
// token file with a colon in between the email and password.
gerritTokenFile = flag.String("gerrit-token-file", filepath.Join(os.Getenv("HOME"), "keys", "gerrit-gobot"), `File to load Gerrit token from. File should be of form <git-email>:<token>`)
)
// GitHub Label IDs for the golang/go repo.
@ -48,18 +57,46 @@ func getGithubToken() (string, error) {
}
}
}
tokenFile := filepath.Join(os.Getenv("HOME"), "keys", "github-gobot")
slurp, err := ioutil.ReadFile(tokenFile)
slurp, err := ioutil.ReadFile(*githubTokenFile)
if err != nil {
return "", err
}
f := strings.SplitN(strings.TrimSpace(string(slurp)), ":", 2)
if len(f) != 2 || f[0] == "" || f[1] == "" {
return "", fmt.Errorf("Expected token file %s to be of form <username>:<token>", tokenFile)
return "", fmt.Errorf("Expected token %q to be of form <username>:<token>", slurp)
}
return f[1], nil
}
func getGerritAuth() (username string, password string, err error) {
var slurp string
if metadata.OnGCE() {
for _, key := range []string{"gopherbot-gerrit-token", "maintner-gerrit-token", "gobot-password"} {
slurp, err = metadata.ProjectAttributeValue(key)
if len(slurp) != 0 {
continue
}
}
}
if len(slurp) == 0 {
var slurpBytes []byte
slurpBytes, err = ioutil.ReadFile(*gerritTokenFile)
if err != nil {
return "", "", err
}
slurp = string(slurpBytes)
}
f := strings.SplitN(strings.TrimSpace(slurp), ":", 2)
if len(f) == 1 {
// assume the whole thing is the token
return "git-gobot.golang.org", f[0], nil
}
if len(f) != 2 || f[0] == "" || f[1] == "" {
return "", "", fmt.Errorf("Expected Gerrit token %q to be of form <git-email>:<token>", slurp)
}
return f[0], f[1], nil
}
func getGithubClient() (*github.Client, error) {
token, err := getGithubToken()
if err != nil {
@ -73,6 +110,22 @@ func getGithubClient() (*github.Client, error) {
return github.NewClient(tc), nil
}
func getGerritClient() (*gerrit.Client, error) {
username, token, err := getGerritAuth()
if err != nil {
return nil, err
}
c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.BasicAuth(username, token))
return c, nil
}
func init() {
flag.Usage = func() {
os.Stderr.WriteString("gopherbot runs Go's gopherbot role account on GitHub and Gerrit.\n\n")
flag.PrintDefaults()
}
}
func main() {
flag.Parse()
@ -80,8 +133,12 @@ func main() {
if err != nil {
log.Fatal(err)
}
gerritc, err := getGerritClient()
if err != nil {
log.Fatal(err)
}
bot := &gopherbot{ghc: ghc}
bot := &gopherbot{ghc: ghc, gerrit: gerritc}
bot.initCorpus()
ctx := context.Background()
@ -124,12 +181,16 @@ func main() {
type gopherbot struct {
ghc *github.Client
gerrit *gerrit.Client
corpus *maintner.Corpus
gorepo *maintner.GitHubRepo
// maxIssueMod is the latest modification time of all Go
// github issues. It's updated at the end of the run of tasks.
maxIssueMod time.Time
maxIssueMod time.Time
knownContributors map[string]bool
// Most recent CL processed by the contributor congratulation script
mostRecentCL int32
}
var tasks = []struct {
@ -146,6 +207,7 @@ var tasks = []struct {
{"cl2issue", (*gopherbot).cl2issue},
{"check cherry picks", (*gopherbot).checkCherryPicks},
{"update needs", (*gopherbot).updateNeeds},
{"congratulate new contributors", (*gopherbot).congratulateNewContributors},
}
func (b *gopherbot) initCorpus() {
@ -254,6 +316,56 @@ func (b *gopherbot) addGitHubComment(ctx context.Context, org, repo string, issu
return err
}
type gerritCommentOpts struct {
OldPhrases []string
Version string // if empty, latest version is used
}
var emptyGerritCommentOpts gerritCommentOpts
// addGerritComment adds the given comment to the CL specified by the changeID
// and the patch set identified by the version.
//
// As an idempotence check, before adding the comment the comment and the list
// of oldPhrases are checked against the CL to ensure that no phrase in the list
// has already been added to the list as a comment.
func (b *gopherbot) addGerritComment(ctx context.Context, changeID, comment string, opts *gerritCommentOpts) error {
if b == nil {
panic("nil gopherbot")
}
if opts == nil {
opts = &emptyGerritCommentOpts
}
// One final staleness check before sending a message: get the list
// of comments from the API and check whether any of them match.
info, err := b.gerrit.GetChange(ctx, changeID, gerrit.QueryChangesOpt{
Fields: []string{"MESSAGES", "CURRENT_REVISION"},
})
if err != nil {
return err
}
for _, msg := range info.Messages {
if strings.Contains(msg.Message, comment) {
return nil // Our comment is already there
}
for j := range opts.OldPhrases {
// Message looks something like "Patch set X:\n\n(our text)"
if strings.Contains(msg.Message, opts.OldPhrases[j]) {
return nil // Our comment is already there
}
}
}
var rev string
if opts.Version != "" {
rev = opts.Version
} else {
rev = info.CurrentRevision
}
return b.gerrit.SetReview(ctx, changeID, rev, gerrit.ReviewInput{
Message: comment,
})
}
// freezeOldIssues locks any issue that's old and closed.
// (Otherwise people find ancient bugs via searches and start asking questions
// into a void and it's sad for everybody.)
@ -615,6 +727,122 @@ func (b *gopherbot) updateNeeds(ctx context.Context) error {
})
}
// If any of the messages in this array have been posted on a CL, don't post
// again. If you amend the message even slightly, please prepend the new message
// to this list, to avoid re-spamming people.
//
// The first message is the "current" message.
var congratulatoryMessages = []string{
// TODO: provide more helpful info? Amend, don't add 2nd commit, link to a
// review guide?
//
// also TODO: make this a template? May want to provide more dynamic
// information in the future. Would make it tougher to search and see if
// a comment has been previously posted.
`Congratulations on opening your first change. Thank you for your contribution!
Next steps:
Within the next week or so, a maintainer will review your change and provide
feedback. See https://golang.org/doc/contribute.html#review for more info and
tips to get your patch through code review.
Most changes in the Go project go through a few rounds of revision. This can be
surprising to people new to the project. The careful, iterative review process
is our way of helping mentor contributors and ensuring that their contributions
have a lasting impact.
During May-July and Nov-Jan the Go project is in a code freeze, during which
little code gets reviewed or merged. If a reviewer responds with a comment like
R=go1.11, it means that this CL will be reviewed as part of the next development
cycle. See https://golang.org/s/release for more details.`, // TODO only show freeze message during freeze
"It's your first ever CL! Congrats, and thanks for sending!",
}
// Don't want to congratulate people on CL's they submitted a year ago.
var congratsEpoch = time.Date(2017, 6, 17, 0, 0, 0, 0, time.UTC)
func (b *gopherbot) congratulateNewContributors(ctx context.Context) error {
cls := make(map[string]*maintner.GerritCL)
newHighestCL := b.mostRecentCL
b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
if gp.Server() != "go.googlesource.com" {
return nil
}
return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
if b.knownContributors == nil {
b.knownContributors = make(map[string]bool)
}
if cl.Commit == nil {
return nil
}
if cl.Number <= b.mostRecentCL {
return nil
}
if cl.Number > newHighestCL {
newHighestCL = cl.Number
}
email := cl.Commit.Author.Str
if b.knownContributors[email] {
return nil
}
if cls[email] != nil {
// this person has multiple CLs; not a new contributor.
b.knownContributors[email] = true
delete(cls, email)
return nil
}
cls[email] = cl
return nil
})
})
for email, cl := range cls {
if cl.Commit == nil || cl.Commit.CommitTime.Before(congratsEpoch) {
b.knownContributors[email] = true
continue
}
if cl.Status == "merged" {
b.knownContributors[email] = true
continue
}
foundMessage := false
for i := range cl.Messages {
// TODO: once gopherbot starts posting these messages and we
// have the author's name for Gopherbot, check the author name
// matches as well.
for j := range congratulatoryMessages {
// Message looks something like "Patch set X:\n\n(our text)"
if strings.Contains(cl.Messages[i].Message, congratulatoryMessages[j]) {
foundMessage = true
break
}
}
if foundMessage {
break
}
}
if foundMessage {
b.knownContributors[email] = true
continue
}
if *dryRun {
log.Printf("[dry run] would add comment to golang.org/cl/%d, congratulating %s on their first commit (committed on %v)", cl.Number, cl.Commit.Author.Str, cl.Commit.CommitTime)
b.knownContributors[email] = true
continue
}
opts := &gerritCommentOpts{
OldPhrases: congratulatoryMessages,
}
err := b.addGerritComment(ctx, strconv.Itoa(int(cl.Number)), congratulatoryMessages[0], opts)
if err != nil {
return err
}
b.knownContributors[email] = true
}
b.mostRecentCL = newHighestCL
return nil
}
// errStopIteration is used to stop iteration over issues or comments.
// It has no special meaning.
var errStopIteration = errors.New("stop iteration")

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

@ -257,7 +257,16 @@ type GerritMessage struct {
// the git commit).
Date time.Time
// TODO author id etc.
// Author returns the author of the commit. This takes the form "Kevin Burke
// <13437@62eb7196-b449-3ce5-99f1-c037f21e1705>", where the number before
// the '@' sign is your Gerrit user ID, and the UUID after the '@' sign
// seems to be the same for all commits for the same Gerrit server, across
// projects.
//
// TODO: Merge the *GitPerson object here and for a person's Git commits
// (which use their real email) via the user ID, so they point to the same
// object.
Author *GitPerson
}
// References reports whether cl includes a commit message reference
@ -487,12 +496,20 @@ func (gp *GerritProject) getGerritMessage(commit *GitCommit) *GerritMessage {
l = l + i + 1
}
return &GerritMessage{
Author: commit.Author,
Date: commit.CommitTime,
Message: v,
Version: int32(version),
}
}
func reverseGerritMessages(ss []*GerritMessage) {
for i := len(ss)/2 - 1; i >= 0; i-- {
opp := len(ss) - 1 - i
ss[i], ss[opp] = ss[opp], ss[i]
}
}
// called with c.mu Locked
func (gp *GerritProject) processMutation(gm *maintpb.GerritMutation) {
c := gp.gerrit.c
@ -533,19 +550,16 @@ func (gp *GerritProject) processMutation(gm *maintpb.GerritMutation) {
oldMeta := cl.Meta
cl.Meta = gc
foundStatus := ""
var messages []*GerritMessage
// Walk from the newest commit backwards, so we store the messages
// in reverse order and then flip the array before setting on the
// GerritCL object.
var backwardMessages []*GerritMessage
gp.foreachCommitParent(cl.Meta.Hash, func(gc *GitCommit) error {
if status := getGerritStatus(gc); status != "" && foundStatus == "" {
foundStatus = status
}
if message := gp.getGerritMessage(gc); message != nil {
// Walk from the newest commit backwards, so we need to
// insert all messages at the beginning of the array.
// TODO: store these in reverse order instead and avoid
// the quadratic inserting at the beginning.
messages = append(messages, nil)
copy(messages[1:], messages[:])
messages[0] = message
backwardMessages = append(backwardMessages, message)
}
if oldMeta != nil && gc.Hash == oldMeta.Hash {
return errStopIteration
@ -560,7 +574,8 @@ func (gp *GerritProject) processMutation(gm *maintpb.GerritMutation) {
if cl.Created.IsZero() || gc.CommitTime.Before(cl.Created) {
cl.Created = gc.CommitTime
}
cl.Messages = append(cl.Messages, messages...)
reverseGerritMessages(backwardMessages)
cl.Messages = append(cl.Messages, backwardMessages...)
} else {
cl.Commit = gc
cl.Version = clv.Version