cmd/gopherbot: normalize and restore dry-run mode

Make sure dry-run does not require credentials, logs the skipped action,
and is applied uniformly.

Also, add instructions on how to use it from Docker.

Change-Id: I05ed681f7dd070e68a85638bf08fc7ee4a7705b6
Reviewed-on: https://go-review.googlesource.com/108235
Reviewed-by: Brad Fitzpatrick <bradfitz@golang.org>
diff --git a/cmd/gopherbot/README.md b/cmd/gopherbot/README.md
index 43c858e..73ef8e1 100644
--- a/cmd/gopherbot/README.md
+++ b/cmd/gopherbot/README.md
@@ -1,7 +1,14 @@
-<!-- Auto-generated by x/build/update-readmes.go -->
-
 [![GoDoc](https://godoc.org/golang.org/x/build/cmd/gopherbot?status.svg)](https://godoc.org/golang.org/x/build/cmd/gopherbot)
 
 # golang.org/x/build/cmd/gopherbot
 
 The gopherbot command runs Go's gopherbot role account on GitHub and Gerrit.
+
+## Development with Docker
+
+```
+make docker-staging
+docker volume create golang-maintner
+docker run -v golang-maintner:/.cache/golang-maintner \
+    -it --rm gcr.io/go-dashboard-dev/gopherbot --dry-run
+```
diff --git a/cmd/gopherbot/gopherbot.go b/cmd/gopherbot/gopherbot.go
index d5b5a31..56fe140 100644
--- a/cmd/gopherbot/gopherbot.go
+++ b/cmd/gopherbot/gopherbot.go
@@ -52,6 +52,19 @@
 	frozenDueToAge = "FrozenDueToAge"
 )
 
+// GitHub Milestone IDs for the golang/go repo.
+var (
+	proposal   = milestone{30, "Proposal"}
+	unreleased = milestone{22, "Unreleased"}
+	gccgo      = milestone{23, "Gccgo"}
+	vgo        = milestone{71, "vgo"}
+)
+
+type milestone struct {
+	ID   int
+	Name string
+}
+
 func getGithubToken() (string, error) {
 	if metadata.OnGCE() {
 		for _, key := range []string{"gopherbot-github-token", "maintner-github-token"} {
@@ -117,6 +130,10 @@
 func getGerritClient() (*gerrit.Client, error) {
 	username, token, err := getGerritAuth()
 	if err != nil {
+		if *dryRun {
+			c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth)
+			return c, nil
+		}
 		return nil, err
 	}
 	c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.BasicAuth(username, token))
@@ -239,6 +256,7 @@
 
 func (b *gopherbot) addLabel(ctx context.Context, gi *maintner.GitHubIssue, label string) error {
 	if *dryRun {
+		printIssue("label-"+label, gi)
 		return nil
 	}
 	_, _, err := b.ghc.Issues.AddLabelsToIssue(ctx, "golang", "go", int(gi.Number), []string{label})
@@ -251,6 +269,7 @@
 // exist), removeLabel returns nil.
 func (b *gopherbot) removeLabel(ctx context.Context, gi *maintner.GitHubIssue, label string) error {
 	if *dryRun {
+		printIssue("unlabel-"+label, gi)
 		return nil
 	}
 	_, err := b.ghc.Issues.RemoveLabelForIssue(ctx, "golang", "go", int(gi.Number), label)
@@ -260,6 +279,17 @@
 	return err
 }
 
+func (b *gopherbot) setMilestone(ctx context.Context, gi *maintner.GitHubIssue, m milestone) error {
+	if *dryRun {
+		printIssue("milestone-"+m.Name, gi)
+		return nil
+	}
+	_, _, err := b.ghc.Issues.Edit(ctx, "golang", "go", int(gi.Number), &github.IssueRequest{
+		Milestone: github.Int(m.ID),
+	})
+	return err
+}
+
 func (b *gopherbot) addGitHubComment(ctx context.Context, org, repo string, issueNum int32, msg string) error {
 	gr := b.corpus.GitHub().Repo(org, repo)
 	if gr == nil {
@@ -299,7 +329,7 @@
 		}
 	}
 	if *dryRun {
-		log.Printf("[dry run] would add comment to github.com/%s/%s/issues/%d: %v", org, repo, issueNum, msg)
+		log.Printf("[dry-run] would add comment to github.com/%s/%s/issues/%d: %v", org, repo, issueNum, msg)
 		return nil
 	}
 	_, _, err = b.ghc.Issues.CreateComment(ctx, org, repo, int(issueNum), &github.IssueComment{
@@ -325,6 +355,10 @@
 	if b == nil {
 		panic("nil gopherbot")
 	}
+	if *dryRun {
+		log.Printf("[dry-run] would add comment to golang.org/cl/%s: %v", changeID, comment)
+		return nil
+	}
 	if opts == nil {
 		opts = &emptyGerritCommentOpts
 	}
@@ -399,23 +433,14 @@
 		}
 		// Add Milestone if missing:
 		if gi.Milestone.IsNone() && !gi.HasEvent("milestoned") && !gi.HasEvent("demilestoned") {
-			printIssue("proposal-milestone", gi)
-			if !*dryRun {
-				_, _, err := b.ghc.Issues.Edit(ctx, "golang", "go", int(gi.Number), &github.IssueRequest{
-					Milestone: github.Int(30), // "Proposal"
-				})
-				if err != nil {
-					return err
-				}
+			if err := b.setMilestone(ctx, gi, proposal); err != nil {
+				return err
 			}
 		}
 		// Add Proposal label if missing:
 		if !gi.HasLabel("Proposal") && !gi.HasEvent("unlabeled") {
-			printIssue("proposal-label", gi)
-			if !*dryRun {
-				if err := b.addLabel(ctx, gi, "Proposal"); err != nil {
-					return err
-				}
+			if err := b.addLabel(ctx, gi, "Proposal"); err != nil {
+				return err
 			}
 		}
 		return nil
@@ -456,14 +481,7 @@
 			// Handled by setMiscMilestones
 			return nil
 		}
-		printIssue("subrepo-unreleased", gi)
-		if *dryRun {
-			return nil
-		}
-		_, _, err := b.ghc.Issues.Edit(ctx, "golang", "go", int(gi.Number), &github.IssueRequest{
-			Milestone: github.Int(22), // "Unreleased"
-		})
-		return err
+		return b.setMilestone(ctx, gi, unreleased)
 	})
 }
 
@@ -473,24 +491,10 @@
 			return nil
 		}
 		if strings.Contains(gi.Title, "gccgo") { // TODO: better gccgo bug report heuristic?
-			printIssue("misc-milestone-gccgo", gi)
-			if *dryRun {
-				return nil
-			}
-			_, _, err := b.ghc.Issues.Edit(ctx, "golang", "go", int(gi.Number), &github.IssueRequest{
-				Milestone: github.Int(23), // "Gccgo"
-			})
-			return err
+			return b.setMilestone(ctx, gi, gccgo)
 		}
 		if strings.HasPrefix(gi.Title, "x/vgo") {
-			printIssue("misc-milestone-vgo", gi)
-			if *dryRun {
-				return nil
-			}
-			_, _, err := b.ghc.Issues.Edit(ctx, "golang", "go", int(gi.Number), &github.IssueRequest{
-				Milestone: github.Int(71), // "vgo"
-			})
-			return err
+			return b.setMilestone(ctx, gi, vgo)
 		}
 		return nil
 	})
@@ -501,10 +505,6 @@
 		if gi.Closed || gi.PullRequest || !strings.HasPrefix(gi.Title, "x/build") || gi.HasLabel("Builders") || gi.HasEvent("unlabeled") {
 			return nil
 		}
-		printIssue("label-builders", gi)
-		if *dryRun {
-			return nil
-		}
 		return b.addLabel(ctx, gi, "Builders")
 	})
 }
@@ -514,10 +514,6 @@
 		if gi.Closed || gi.PullRequest || !strings.HasPrefix(gi.Title, "x/mobile") || gi.HasLabel("mobile") || gi.HasEvent("unlabeled") {
 			return nil
 		}
-		printIssue("label-mobile", gi)
-		if *dryRun {
-			return nil
-		}
 		return b.addLabel(ctx, gi, "mobile")
 	})
 }
@@ -527,10 +523,6 @@
 		if gi.Closed || gi.PullRequest || !isDocumentationTitle(gi.Title) || gi.HasLabel("Documentation") || gi.HasEvent("unlabeled") {
 			return nil
 		}
-		printIssue("label-documentation", gi)
-		if *dryRun {
-			return nil
-		}
 		return b.addLabel(ctx, gi, "Documentation")
 	})
 }
@@ -659,9 +651,6 @@
 				})
 				if !hasComment {
 					printIssue("cl2issue", gi)
-					if *dryRun {
-						return nil
-					}
 					msg := fmt.Sprintf("Change https://golang.org/cl/%d mentions this issue: `%s`", cl.Number, cl.Commit.Summary())
 					if err := b.addGitHubComment(ctx, "golang", "go", gi.Number, msg); err != nil {
 						return err
@@ -850,11 +839,6 @@
 			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,
 		}