cmd/gopherbot: extend gopherbot's general tasks to the vscode-go repo

GopherBot has a few tasks, such as automatically applying labels and
closing stale issues, which are applicable to all golang.org/x repos,
not just golang/go. This change makes these work for golang/go and
golang/vscode-go, with the eventual intention of making them work for
more repos.

Updates golang/go#39008

Change-Id: I4aad54822422747aeac25590de3069562bebb3a5
Reviewed-on: https://go-review.googlesource.com/c/build/+/233377
Run-TryBot: Rebecca Stambler <rstambler@golang.org>
TryBot-Result: Gobot Gobot <gobot@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/cmd/gopherbot/gopherbot.go b/cmd/gopherbot/gopherbot.go
index 2c4d6ef..e1113df 100644
--- a/cmd/gopherbot/gopherbot.go
+++ b/cmd/gopherbot/gopherbot.go
@@ -293,30 +293,44 @@
 	name string
 	fn   func(*gopherbot, context.Context) error
 }{
+	// Tasks that are specific to the golang/go repo.
 	{"kicktrain", (*gopherbot).getOffKickTrain},
 	{"unwait-release", (*gopherbot).unwaitRelease},
-	{"freeze old issues", (*gopherbot).freezeOldIssues},
-	{"label proposals", (*gopherbot).labelProposals},
-	{"set subrepo milestones", (*gopherbot).setSubrepoMilestones},
-	{"set misc milestones", (*gopherbot).setMiscMilestones},
 	{"label build issues", (*gopherbot).labelBuildIssues},
 	{"label mobile issues", (*gopherbot).labelMobileIssues},
-	{"label documentation issues", (*gopherbot).labelDocumentationIssues},
 	{"label tools issues", (*gopherbot).labelToolsIssues},
 	{"label go.dev issues", (*gopherbot).labelGoDevIssues},
+	{"label proposals", (*gopherbot).labelProposals},
 	{"handle gopls issues", (*gopherbot).handleGoplsIssues},
-	{"close stale WaitingForInfo", (*gopherbot).closeStaleWaitingForInfo},
-	{"cl2issue", (*gopherbot).cl2issue},
+	{"open cherry pick issues", (*gopherbot).openCherryPickIssues},
+	{"close cherry pick issues", (*gopherbot).closeCherryPickIssues},
+	{"set subrepo milestones", (*gopherbot).setSubrepoMilestones},
+	{"set misc milestones", (*gopherbot).setMiscMilestones},
+	{"apply minor release milestones", (*gopherbot).setMinorMilestones},
 	{"update needs", (*gopherbot).updateNeeds},
+
+	// Tasks that can be applied to many repos.
+	{"freeze old issues", (*gopherbot).freezeOldIssues},
+	{"label documentation issues", (*gopherbot).labelDocumentationIssues},
+	{"close stale WaitingForInfo", (*gopherbot).closeStaleWaitingForInfo},
+	{"apply labels from comments", (*gopherbot).applyLabelsFromComments},
+
+	// Gerrit tasks are applied to all projects by default.
+	{"abandon scratch reviews", (*gopherbot).abandonScratchReviews},
+	{"assign reviewers to CLs", (*gopherbot).assignReviewersToCLs},
+	{"access", (*gopherbot).whoNeedsAccess},
+	{"cl2issue", (*gopherbot).cl2issue},
 	{"congratulate new contributors", (*gopherbot).congratulateNewContributors},
 	{"un-wait CLs", (*gopherbot).unwaitCLs},
-	{"open cherry pick issues", (*gopherbot).openCherryPickIssues},
-	{"apply minor release milestones", (*gopherbot).setMinorMilestones},
-	{"close cherry pick issues", (*gopherbot).closeCherryPickIssues},
-	{"apply labels from comments", (*gopherbot).applyLabelsFromComments},
-	{"assign reviewers to CLs", (*gopherbot).assignReviewersToCLs},
-	{"abandon scratch reviews", (*gopherbot).abandonScratchReviews},
-	{"access", (*gopherbot).whoNeedsAccess},
+}
+
+// gardenIssues reports whether GopherBot should perform general issue
+// gardening tasks for the repo.
+func gardenIssues(repo *maintner.GitHubRepo) bool {
+	if repo.ID().Owner != "golang" {
+		return false
+	}
+	return repo.ID().Repo == "go" || repo.ID().Repo == "vscode-go"
 }
 
 func (b *gopherbot) initCorpus() {
@@ -358,18 +372,18 @@
 	RemoveLabelForIssue(ctx context.Context, owner string, repo string, number int, label string) (*github.Response, error)
 }
 
-func (b *gopherbot) addLabel(ctx context.Context, gi *maintner.GitHubIssue, label string) error {
-	return b.addLabels(ctx, gi, []string{label})
+func (b *gopherbot) addLabel(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, label string) error {
+	return b.addLabels(ctx, repoID, gi, []string{label})
 }
 
-func (b *gopherbot) addLabels(ctx context.Context, gi *maintner.GitHubIssue, labels []string) error {
+func (b *gopherbot) addLabels(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, labels []string) error {
 	var toAdd []string
 	for _, label := range labels {
 		if gi.HasLabel(label) {
 			log.Printf("Issue %d already has label %q; no need to send request to add it", gi.Number, label)
 			continue
 		}
-		printIssue("label-"+label, gi)
+		printIssue("label-"+label, repoID, gi)
 		toAdd = append(toAdd, label)
 	}
 
@@ -377,23 +391,23 @@
 		return nil
 	}
 
-	_, _, err := b.is.AddLabelsToIssue(ctx, "golang", "go", int(gi.Number), toAdd)
+	_, _, err := b.is.AddLabelsToIssue(ctx, repoID.Owner, repoID.Repo, int(gi.Number), toAdd)
 	return err
 }
 
-// removeLabel removes the label from the given issue in golang/go.
-func (b *gopherbot) removeLabel(ctx context.Context, gi *maintner.GitHubIssue, label string) error {
-	return b.removeLabels(ctx, gi, []string{label})
+// removeLabel removes the label from the given issue in the given repo.
+func (b *gopherbot) removeLabel(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, label string) error {
+	return b.removeLabels(ctx, repoID, gi, []string{label})
 }
 
-func (b *gopherbot) removeLabels(ctx context.Context, gi *maintner.GitHubIssue, labels []string) error {
+func (b *gopherbot) removeLabels(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, labels []string) error {
 	var removeLabels bool
 	for _, l := range labels {
 		if !gi.HasLabel(l) {
 			log.Printf("Issue %d (in maintner) does not have label %q; no need to send request to remove it", gi.Number, l)
 			continue
 		}
-		printIssue("label-"+l, gi)
+		printIssue("label-"+l, repoID, gi)
 		removeLabels = true
 	}
 
@@ -401,7 +415,7 @@
 		return nil
 	}
 
-	ghLabels, err := labelsForIssue(ctx, b.is, int(gi.Number))
+	ghLabels, err := labelsForIssue(ctx, repoID, b.is, int(gi.Number))
 	if err != nil {
 		return err
 	}
@@ -412,7 +426,7 @@
 
 	for _, l := range ghLabels {
 		if toRemove[l] {
-			if err := removeLabelFromIssue(ctx, b.is, int(gi.Number), l); err != nil {
+			if err := removeLabelFromIssue(ctx, repoID, b.is, int(gi.Number), l); err != nil {
 				log.Printf("Could not remove label %q from issue %d: %v", l, gi.Number, err)
 				continue
 			}
@@ -421,11 +435,11 @@
 	return nil
 }
 
-// labelsForIssue returns all labels for the given issue in the golang/go repo.
-func labelsForIssue(ctx context.Context, issues issuesService, issueNum int) ([]string, error) {
-	ghLabels, _, err := issues.ListLabelsByIssue(ctx, "golang", "go", issueNum, &github.ListOptions{PerPage: 100})
+// labelsForIssue returns all labels for the given issue in the given repo.
+func labelsForIssue(ctx context.Context, repoID maintner.GitHubRepoID, issues issuesService, issueNum int) ([]string, error) {
+	ghLabels, _, err := issues.ListLabelsByIssue(ctx, repoID.Owner, repoID.Repo, issueNum, &github.ListOptions{PerPage: 100})
 	if err != nil {
-		return nil, fmt.Errorf("could not list labels for golang/go#%d: %v", issueNum, err)
+		return nil, fmt.Errorf("could not list labels for %s#%d: %v", repoID, issueNum, err)
 	}
 	var labels []string
 	for _, l := range ghLabels {
@@ -434,10 +448,11 @@
 	return labels, nil
 }
 
-// removeLabelForIssue removes the given label from golang/go with the given issueNum.
-// If the issue did not have the label already (or the label didn't exist), return nil.
-func removeLabelFromIssue(ctx context.Context, issues issuesService, issueNum int, label string) error {
-	_, err := issues.RemoveLabelForIssue(ctx, "golang", "go", issueNum, label)
+// removeLabelForIssue removes the given label from the given repo with the
+// given issueNum. If the issue did not have the label already (or the label
+// didn't exist), return nil.
+func removeLabelFromIssue(ctx context.Context, repoID maintner.GitHubRepoID, issues issuesService, issueNum int, label string) error {
+	_, err := issues.RemoveLabelForIssue(ctx, repoID.Owner, repoID.Repo, issueNum, label)
 	if ge, ok := err.(*github.ErrorResponse); ok && ge.Response != nil && ge.Response.StatusCode == http.StatusNotFound {
 		return nil
 	}
@@ -445,7 +460,7 @@
 }
 
 func (b *gopherbot) setMilestone(ctx context.Context, gi *maintner.GitHubIssue, m milestone) error {
-	printIssue("milestone-"+m.Name, gi)
+	printIssue("milestone-"+m.Name, b.gorepo.ID(), gi)
 	if *dryRun {
 		return nil
 	}
@@ -455,13 +470,9 @@
 	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 {
-		return fmt.Errorf("unknown github repo %s/%s", org, repo)
-	}
+func (b *gopherbot) addGitHubComment(ctx context.Context, repo *maintner.GitHubRepo, issueNum int32, msg string) error {
 	var since time.Time
-	if gi := gr.Issue(issueNum); gi != nil {
+	if gi := repo.Issue(issueNum); gi != nil {
 		dup := false
 		gi.ForeachComment(func(c *maintner.GitHubComment) error {
 			since = c.Updated
@@ -480,7 +491,7 @@
 	}
 	// See if there is a dup comment from when gopherbot last got
 	// its data from maintner.
-	ics, _, err := b.ghc.Issues.ListComments(ctx, org, repo, int(issueNum), &github.IssueListCommentsOptions{
+	ics, _, err := b.ghc.Issues.ListComments(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum), &github.IssueListCommentsOptions{
 		Since:       since,
 		ListOptions: github.ListOptions{PerPage: 1000},
 	})
@@ -494,10 +505,10 @@
 		}
 	}
 	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/issues/%d: %v", repo.ID(), issueNum, msg)
 		return nil
 	}
-	_, _, err = b.ghc.Issues.CreateComment(ctx, org, repo, int(issueNum), &github.IssueComment{
+	_, _, err = b.ghc.Issues.CreateComment(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum), &github.IssueComment{
 		Body: github.String(msg),
 	})
 	return err
@@ -547,12 +558,12 @@
 	return i.GetNumber(), err
 }
 
-func (b *gopherbot) closeGitHubIssue(ctx context.Context, number int32) error {
+func (b *gopherbot) closeGitHubIssue(ctx context.Context, repoID maintner.GitHubRepoID, number int32) error {
 	if *dryRun {
 		log.Printf("[dry-run] would close golang.org/issue/%v", number)
 		return nil
 	}
-	_, _, err := b.ghc.Issues.Edit(ctx, "golang", "go", int(number), &github.IssueRequest{State: github.String("closed")})
+	_, _, err := b.ghc.Issues.Edit(ctx, repoID.Owner, repoID.Repo, int(number), &github.IssueRequest{State: github.String("closed")})
 	return err
 }
 
@@ -727,26 +738,31 @@
 // again for another year.
 func (b *gopherbot) freezeOldIssues(ctx context.Context) error {
 	tooOld := time.Now().Add(-365 * 24 * time.Hour)
-	return b.gorepo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
-		if gi.NotExist || !gi.Closed || gi.PullRequest || gi.Locked {
+	return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error {
+		if !gardenIssues(repo) {
 			return nil
 		}
-		if gi.Updated.After(tooOld) {
-			return nil
-		}
-		printIssue("freeze", gi)
-		if *dryRun {
-			return nil
-		}
-		_, err := b.ghc.Issues.Lock(ctx, "golang", "go", int(gi.Number), nil)
-		if ge, ok := err.(*github.ErrorResponse); ok && ge.Response.StatusCode == http.StatusNotFound {
-			// It's rare, but an issue can become 404 on GitHub. See golang.org/issue/30182.
-			// Nothing to do since the issue is gone.
-			return nil
-		} else if err != nil {
-			return err
-		}
-		return b.addLabel(ctx, gi, frozenDueToAge)
+		return repo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
+			if gi.NotExist || !gi.Closed || gi.PullRequest || gi.Locked {
+				return nil
+			}
+			if gi.Updated.After(tooOld) {
+				return nil
+			}
+			printIssue("freeze", repo.ID(), gi)
+			if *dryRun {
+				return nil
+			}
+			_, err := b.ghc.Issues.Lock(ctx, repo.ID().Owner, repo.ID().Repo, int(gi.Number), nil)
+			if ge, ok := err.(*github.ErrorResponse); ok && ge.Response.StatusCode == http.StatusNotFound {
+				// It's rare, but an issue can become 404 on GitHub. See golang.org/issue/30182.
+				// Nothing to do since the issue is gone.
+				return nil
+			} else if err != nil {
+				return err
+			}
+			return b.addLabel(ctx, repo.ID(), gi, frozenDueToAge)
+		})
 	})
 }
 
@@ -769,14 +785,14 @@
 		}
 		// Add Proposal label if missing:
 		if !gi.HasLabel("Proposal") && !gi.HasEvent("unlabeled") {
-			if err := b.addLabel(ctx, gi, "Proposal"); err != nil {
+			if err := b.addLabel(ctx, b.gorepo.ID(), gi, "Proposal"); err != nil {
 				return err
 			}
 		}
 
 		// Remove NeedsDecision label if exists, but not for Go 2 issues:
 		if !isGo2Issue(gi) && gi.HasLabel("NeedsDecision") && !gopherbotRemovedLabel(gi, "NeedsDecision") {
-			if err := b.removeLabel(ctx, gi, "NeedsDecision"); err != nil {
+			if err := b.removeLabel(ctx, b.gorepo.ID(), gi, "NeedsDecision"); err != nil {
 				return err
 			}
 		}
@@ -877,7 +893,7 @@
 		if gi.Closed || gi.PullRequest || !strings.HasPrefix(gi.Title, "x/build") || gi.HasLabel("Builders") || gi.HasEvent("unlabeled") {
 			return nil
 		}
-		return b.addLabel(ctx, gi, "Builders")
+		return b.addLabel(ctx, b.gorepo.ID(), gi, "Builders")
 	})
 }
 
@@ -886,16 +902,21 @@
 		if gi.Closed || gi.PullRequest || !strings.HasPrefix(gi.Title, "x/mobile") || gi.HasLabel("mobile") || gi.HasEvent("unlabeled") {
 			return nil
 		}
-		return b.addLabel(ctx, gi, "mobile")
+		return b.addLabel(ctx, b.gorepo.ID(), gi, "mobile")
 	})
 }
 
 func (b *gopherbot) labelDocumentationIssues(ctx context.Context) error {
-	return b.gorepo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
-		if gi.Closed || gi.PullRequest || !isDocumentationTitle(gi.Title) || gi.HasLabel("Documentation") || gi.HasEvent("unlabeled") {
+	return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error {
+		if !gardenIssues(repo) {
 			return nil
 		}
-		return b.addLabel(ctx, gi, "Documentation")
+		return repo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
+			if gi.Closed || gi.PullRequest || !isDocumentationTitle(gi.Title) || gi.HasLabel("Documentation") || gi.HasEvent("unlabeled") {
+				return nil
+			}
+			return b.addLabel(ctx, repo.ID(), gi, "Documentation")
+		})
 	})
 }
 
@@ -904,7 +925,7 @@
 		if gi.Closed || gi.PullRequest || !strings.HasPrefix(gi.Title, "x/tools") || gi.HasLabel("Tools") || gi.HasEvent("unlabeled") {
 			return nil
 		}
-		return b.addLabel(ctx, gi, "Tools")
+		return b.addLabel(ctx, b.gorepo.ID(), gi, "Tools")
 	})
 }
 
@@ -913,7 +934,7 @@
 		if gi.Closed || gi.PullRequest || !strings.HasPrefix(gi.Title, "go.dev:") || gi.HasLabel("go.dev") || gi.HasEvent("unlabeled") {
 			return nil
 		}
-		return b.addLabel(ctx, gi, "go.dev")
+		return b.addLabel(ctx, b.gorepo.ID(), gi, "go.dev")
 	})
 }
 
@@ -926,7 +947,7 @@
 		if gi.Closed || gi.PullRequest || !isGoplsTitle(gi.Title) || gi.HasLabel("gopls") || gi.HasEvent("unlabeled") {
 			return nil
 		}
-		if err := b.addLabel(ctx, gi, "gopls"); err != nil {
+		if err := b.addLabel(ctx, b.gorepo.ID(), gi, "gopls"); err != nil {
 			return err
 		}
 		// Check if the person filing the issue is known through Gerrit.
@@ -937,64 +958,68 @@
 		const comment = "Thank you for filing a gopls issue! Please take a look at the " +
 			"[Troubleshooting guide](https://github.com/golang/tools/blob/master/gopls/doc/troubleshooting.md#troubleshooting), " +
 			"and make sure that you have provided all of the relevant information here."
-		return b.addGitHubComment(ctx, "golang", "go", gi.Number, comment)
+		return b.addGitHubComment(ctx, b.gorepo, gi.Number, comment)
 	})
 }
 
 func (b *gopherbot) closeStaleWaitingForInfo(ctx context.Context) error {
 	const waitingForInfo = "WaitingForInfo"
 	now := time.Now()
-	return b.gorepo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
-		if gi.Closed || gi.PullRequest || !gi.HasLabel("WaitingForInfo") {
+	return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error {
+		if !gardenIssues(repo) {
 			return nil
 		}
-		var waitStart time.Time
-		gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error {
-			if e.Type == "reopened" {
-				// Ignore any previous WaitingForInfo label if it's reopend.
-				waitStart = time.Time{}
+		return repo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
+			if gi.Closed || gi.PullRequest || !gi.HasLabel("WaitingForInfo") {
 				return nil
 			}
-			if e.Label == waitingForInfo {
-				switch e.Type {
-				case "unlabeled":
+			var waitStart time.Time
+			gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error {
+				if e.Type == "reopened" {
+					// Ignore any previous WaitingForInfo label if it's reopend.
 					waitStart = time.Time{}
-				case "labeled":
-					waitStart = e.Created
+					return nil
+				}
+				if e.Label == waitingForInfo {
+					switch e.Type {
+					case "unlabeled":
+						waitStart = time.Time{}
+					case "labeled":
+						waitStart = e.Created
+					}
+					return nil
 				}
 				return nil
+			})
+			if waitStart.IsZero() {
+				return nil
 			}
-			return nil
-		})
-		if waitStart.IsZero() {
-			return nil
-		}
 
-		deadline := waitStart.AddDate(0, 1, 0) // 1 month
-		if now.Before(deadline) {
-			return nil
-		}
-
-		var lastOPComment time.Time
-		gi.ForeachComment(func(c *maintner.GitHubComment) error {
-			if c.User.ID == gi.User.ID {
-				lastOPComment = c.Created
+			deadline := waitStart.AddDate(0, 1, 0) // 1 month
+			if now.Before(deadline) {
+				return nil
 			}
-			return nil
-		})
-		if lastOPComment.After(waitStart) {
-			return nil
-		}
 
-		printIssue("close-stale-waiting-for-info", gi)
-		// TODO: write a task that reopens issues if the OP speaks up.
-		if err := b.addGitHubComment(ctx, "golang", "go", gi.Number,
-			"Timed out in state WaitingForInfo. Closing.\n\n(I am just a bot, though. Please speak up if this is a mistake or you have the requested information.)"); err != nil {
-			return err
-		}
-		return b.closeGitHubIssue(ctx, gi.Number)
+			var lastOPComment time.Time
+			gi.ForeachComment(func(c *maintner.GitHubComment) error {
+				if c.User.ID == gi.User.ID {
+					lastOPComment = c.Created
+				}
+				return nil
+			})
+			if lastOPComment.After(waitStart) {
+				return nil
+			}
+
+			printIssue("close-stale-waiting-for-info", repo.ID(), gi)
+			// TODO: write a task that reopens issues if the OP speaks up.
+			if err := b.addGitHubComment(ctx, repo, gi.Number,
+				"Timed out in state WaitingForInfo. Closing.\n\n(I am just a bot, though. Please speak up if this is a mistake or you have the requested information.)"); err != nil {
+				return err
+			}
+			return b.closeGitHubIssue(ctx, repo.ID(), gi.Number)
+		})
 	})
-
 }
 
 // cl2issue writes "Change https://golang.org/cl/NNNN mentions this issue"
@@ -1014,7 +1039,7 @@
 				return nil
 			}
 			for _, ref := range cl.GitHubIssueRefs {
-				if id := ref.Repo.ID(); id.Owner != "golang" || id.Repo != "go" {
+				if !gardenIssues(ref.Repo) {
 					continue
 				}
 				gi := ref.Repo.Issue(ref.Number)
@@ -1031,9 +1056,9 @@
 					return nil
 				})
 				if !hasComment {
-					printIssue("cl2issue", gi)
+					printIssue("cl2issue", ref.Repo.ID(), gi)
 					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 {
+					if err := b.addGitHubComment(ctx, ref.Repo, gi.Number, msg); err != nil {
 						return err
 					}
 				}
@@ -1106,9 +1131,9 @@
 			if !strings.HasPrefix(key, "needs") || labels[key] == maxPos {
 				continue
 			}
-			printIssue("updateneeds", gi)
+			printIssue("updateneeds", b.gorepo.ID(), gi)
 			fmt.Printf("\t... removing label %q\n", lab.Name)
-			if err := b.removeLabel(ctx, gi, lab.Name); err != nil {
+			if err := b.removeLabel(ctx, b.gorepo.ID(), gi, lab.Name); err != nil {
 				return err
 			}
 		}
@@ -1357,7 +1382,7 @@
 func (b *gopherbot) openCherryPickIssues(ctx context.Context) error {
 	return b.gorepo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
 		if gi.HasLabel("CherryPickApproved") && gi.HasLabel("CherryPickCandidate") {
-			if err := b.removeLabel(ctx, gi, "CherryPickCandidate"); err != nil {
+			if err := b.removeLabel(ctx, b.gorepo.ID(), gi, "CherryPickCandidate"); err != nil {
 				return err
 			}
 		}
@@ -1400,7 +1425,7 @@
 		}
 		var openedIssues []string
 		for _, rel := range selectedReleases {
-			printIssue("open-backport-issue-"+rel, gi)
+			printIssue("open-backport-issue-"+rel, b.gorepo.ID(), gi)
 			id, err := b.createGitHubIssue(ctx,
 				fmt.Sprintf("%s [%s backport]", gi.Title, rel),
 				fmt.Sprintf("@%s requested issue #%d to be considered for backport to the next %s minor release.\n\n%s\n",
@@ -1411,7 +1436,7 @@
 			}
 			openedIssues = append(openedIssues, fmt.Sprintf("#%d (for %s)", id, rel))
 		}
-		return b.addGitHubComment(ctx, "golang", "go", gi.Number, fmt.Sprintf("Backport issue(s) opened: %s.\n\nRemember to create the cherry-pick CL(s) as soon as the patch is submitted to master, according to https://golang.org/wiki/MinorReleases.", strings.Join(openedIssues, ", ")))
+		return b.addGitHubComment(ctx, b.gorepo, gi.Number, fmt.Sprintf("Backport issue(s) opened: %s.\n\nRemember to create the cherry-pick CL(s) as soon as the patch is submitted to master, according to https://golang.org/wiki/MinorReleases.", strings.Join(openedIssues, ", ")))
 	})
 }
 
@@ -1509,7 +1534,7 @@
 			}
 			clBranchVersion := cl.Branch()[len("release-branch."):] // "go1.11" or "go1.12".
 			for _, ref := range cl.GitHubIssueRefs {
-				if id := ref.Repo.ID(); id.Owner != "golang" || id.Repo != "go" {
+				if ref.Repo != b.gorepo {
 					continue
 				}
 				gi, ok := cherryPickIssues[ref.Number]
@@ -1521,12 +1546,12 @@
 					// doesn't match the CL branch goX.Y version, so skip it.
 					continue
 				}
-				printIssue("close-cherry-pick", gi)
-				if err := b.addGitHubComment(ctx, "golang", "go", gi.Number, fmt.Sprintf(
+				printIssue("close-cherry-pick", b.gorepo.ID(), gi)
+				if err := b.addGitHubComment(ctx, ref.Repo, gi.Number, fmt.Sprintf(
 					"Closed by merging %s to %s.", cl.Commit.Hash, cl.Branch())); err != nil {
 					return err
 				}
-				return b.closeGitHubIssue(ctx, gi.Number)
+				return b.closeGitHubIssue(ctx, ref.Repo.ID(), gi.Number)
 			}
 			return nil
 		})
@@ -1543,57 +1568,63 @@
 // applyLabelsFromComments looks within open GitHub issues for commands to add or
 // remove labels. Anyone can use the /label <label> or /unlabel <label> commands.
 func (b *gopherbot) applyLabelsFromComments(ctx context.Context) error {
-	allLabels := make(map[string]string) // lowercase label name -> proper casing
-	b.gorepo.ForeachLabel(func(gl *maintner.GitHubLabel) error {
-		allLabels[strings.ToLower(gl.Name)] = gl.Name
-		return nil
-	})
-
-	return b.gorepo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
-		if gi.Closed {
+	return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error {
+		if !gardenIssues(repo) {
 			return nil
 		}
 
-		var cmds []labelCommand
-
-		cmds = append(cmds, labelCommandsFromBody(gi.Body, gi.Created)...)
-		gi.ForeachComment(func(gc *maintner.GitHubComment) error {
-			cmds = append(cmds, labelCommandsFromBody(gc.Body, gc.Created)...)
+		allLabels := make(map[string]string) // lowercase label name -> proper casing
+		repo.ForeachLabel(func(gl *maintner.GitHubLabel) error {
+			allLabels[strings.ToLower(gl.Name)] = gl.Name
 			return nil
 		})
 
-		for i, c := range cmds {
-			// Does the label even exist? If so, use the proper capitalization.
-			// If it doesn't exist, the command is a no-op.
-			if l, ok := allLabels[c.label]; ok {
-				cmds[i].label = l
-			} else {
-				cmds[i].noop = true
-				continue
+		return repo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
+			if gi.Closed {
+				return nil
 			}
 
-			// If any action has been taken on the label since the comment containing
-			// the command to add or remove it, then it should be a no-op.
-			gi.ForeachEvent(func(ge *maintner.GitHubIssueEvent) error {
-				if (ge.Type == "unlabeled" || ge.Type == "labeled") &&
-					strings.ToLower(ge.Label) == c.label &&
-					ge.Created.After(c.created) {
-					cmds[i].noop = true
-					return errStopIteration
-				}
+			var cmds []labelCommand
+
+			cmds = append(cmds, labelCommandsFromBody(gi.Body, gi.Created)...)
+			gi.ForeachComment(func(gc *maintner.GitHubComment) error {
+				cmds = append(cmds, labelCommandsFromBody(gc.Body, gc.Created)...)
 				return nil
 			})
-		}
 
-		toAdd, toRemove := mutationsFromCommands(cmds)
-		if err := b.addLabels(ctx, gi, toAdd); err != nil {
-			log.Printf("Unable to add labels (%v) to issue %d: %v", toAdd, gi.Number, err)
-		}
-		if err := b.removeLabels(ctx, gi, toRemove); err != nil {
-			log.Printf("Unable to remove labels (%v) from issue %d: %v", toRemove, gi.Number, err)
-		}
+			for i, c := range cmds {
+				// Does the label even exist? If so, use the proper capitalization.
+				// If it doesn't exist, the command is a no-op.
+				if l, ok := allLabels[c.label]; ok {
+					cmds[i].label = l
+				} else {
+					cmds[i].noop = true
+					continue
+				}
 
-		return nil
+				// If any action has been taken on the label since the comment containing
+				// the command to add or remove it, then it should be a no-op.
+				gi.ForeachEvent(func(ge *maintner.GitHubIssueEvent) error {
+					if (ge.Type == "unlabeled" || ge.Type == "labeled") &&
+						strings.ToLower(ge.Label) == c.label &&
+						ge.Created.After(c.created) {
+						cmds[i].noop = true
+						return errStopIteration
+					}
+					return nil
+				})
+			}
+
+			toAdd, toRemove := mutationsFromCommands(cmds)
+			if err := b.addLabels(ctx, repo.ID(), gi, toAdd); err != nil {
+				log.Printf("Unable to add labels (%v) to issue %d: %v", toAdd, gi.Number, err)
+			}
+			if err := b.removeLabels(ctx, repo.ID(), gi, toRemove); err != nil {
+				log.Printf("Unable to remove labels (%v) from issue %d: %v", toRemove, gi.Number, err)
+			}
+
+			return nil
+		})
 	})
 }
 
@@ -2119,7 +2150,7 @@
 
 var lastTask string
 
-func printIssue(task string, gi *maintner.GitHubIssue) {
+func printIssue(task string, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue) {
 	if *dryRun {
 		task = task + " [dry-run]"
 	}
@@ -2127,7 +2158,11 @@
 		fmt.Println(task)
 		lastTask = task
 	}
-	fmt.Printf("\thttps://golang.org/issue/%v  %s\n", gi.Number, gi.Title)
+	if repoID.Owner != "golang" || repoID.Repo != "go" {
+		fmt.Printf("\thttps://github.com/%s/issues/%v  %s\n", repoID, gi.Number, gi.Title)
+	} else {
+		fmt.Printf("\thttps://golang.org/issue/%v  %s\n", gi.Number, gi.Title)
+	}
 }
 
 func mustCreateSecretClient() *secret.Client {
diff --git a/cmd/gopherbot/gopherbot_test.go b/cmd/gopherbot/gopherbot_test.go
index cb3075d..642407c 100644
--- a/cmd/gopherbot/gopherbot_test.go
+++ b/cmd/gopherbot/gopherbot_test.go
@@ -277,7 +277,10 @@
 		fis := &fakeIssuesService{}
 		b.is = fis
 
-		if err := b.addLabels(context.Background(), tc.gi, tc.labels); err != nil {
+		if err := b.addLabels(context.Background(), maintner.GitHubRepoID{
+			Owner: "golang",
+			Repo:  "go",
+		}, tc.gi, tc.labels); err != nil {
 			t.Errorf("%s: b.addLabels got unexpected error: %v", tc.desc, err)
 			continue
 		}
@@ -336,7 +339,10 @@
 		}}
 		b.is = fis
 
-		if err := b.removeLabels(context.Background(), tc.gi, tc.toRemove); err != nil {
+		if err := b.removeLabels(context.Background(), maintner.GitHubRepoID{
+			Owner: "golang",
+			Repo:  "go",
+		}, tc.gi, tc.toRemove); err != nil {
 			t.Errorf("%s: b.addLabels got unexpected error: %v", tc.desc, err)
 			continue
 		}