golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/gopherbot/gopherbot.go (about)

     1  // Copyright 2017 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  // The gopherbot command runs Go's gopherbot role account on
     6  // GitHub and Gerrit.
     7  //
     8  // General documentation is at https://go.dev/wiki/gopherbot.
     9  // Consult the tasks slice in gopherbot.go for an up-to-date
    10  // list of all gopherbot tasks.
    11  package main
    12  
    13  import (
    14  	"bufio"
    15  	"bytes"
    16  	"context"
    17  	"crypto/tls"
    18  	"encoding/json"
    19  	"errors"
    20  	"flag"
    21  	"fmt"
    22  	"log"
    23  	"net/http"
    24  	"os"
    25  	"path/filepath"
    26  	"regexp"
    27  	"sort"
    28  	"strconv"
    29  	"strings"
    30  	"sync"
    31  	"time"
    32  	"unicode"
    33  
    34  	"cloud.google.com/go/compute/metadata"
    35  	"github.com/google/go-github/v48/github"
    36  	"github.com/shurcooL/githubv4"
    37  	"go4.org/strutil"
    38  	"golang.org/x/build/devapp/owners"
    39  	"golang.org/x/build/gerrit"
    40  	"golang.org/x/build/internal/foreach"
    41  	"golang.org/x/build/internal/gophers"
    42  	"golang.org/x/build/internal/secret"
    43  	"golang.org/x/build/maintner"
    44  	"golang.org/x/build/maintner/godata"
    45  	"golang.org/x/build/maintner/maintnerd/apipb"
    46  	"golang.org/x/exp/slices"
    47  	"golang.org/x/oauth2"
    48  	"google.golang.org/grpc"
    49  	"google.golang.org/grpc/credentials"
    50  )
    51  
    52  var (
    53  	dryRun          = flag.Bool("dry-run", false, "just report what would've been done, without changing anything")
    54  	daemon          = flag.Bool("daemon", false, "run in daemon mode")
    55  	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>`)
    56  	// go here: https://go-review.googlesource.com/settings#HTTPCredentials
    57  	// click "Obtain Password"
    58  	// The next page will have a .gitcookies file - look for the part that has
    59  	// "git-youremail@yourcompany.com=password". Copy and paste that to the
    60  	// token file with a colon in between the email and password.
    61  	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>`)
    62  
    63  	onlyRun = flag.String("only-run", "", "if non-empty, the name of a task to run. Mostly for debugging, but tasks (like 'kicktrain') may choose to only run in explicit mode")
    64  )
    65  
    66  func init() {
    67  	flag.Usage = func() {
    68  		output := flag.CommandLine.Output()
    69  		fmt.Fprintf(output, "gopherbot runs Go's gopherbot role account on GitHub and Gerrit.\n\n")
    70  		flag.PrintDefaults()
    71  		fmt.Fprintln(output, "")
    72  		fmt.Fprintln(output, "Tasks (can be used for the --only-run flag):")
    73  		for _, t := range tasks {
    74  			fmt.Fprintf(output, "  %q\n", t.name)
    75  		}
    76  	}
    77  }
    78  
    79  const (
    80  	gopherbotGitHubID = 8566911
    81  )
    82  
    83  const (
    84  	gobotGerritID     = "5976"
    85  	gerritbotGerritID = "12446"
    86  	kokoroGerritID    = "37747"
    87  	goLUCIGerritID    = "60063"
    88  	triciumGerritID   = "62045"
    89  )
    90  
    91  // GitHub Label IDs for the golang/go repo.
    92  const (
    93  	needsDecisionID      = 373401956
    94  	needsFixID           = 373399998
    95  	needsInvestigationID = 373402289
    96  	earlyInCycleID       = 626114143
    97  )
    98  
    99  // Label names (that are used in multiple places).
   100  const (
   101  	frozenDueToAge = "FrozenDueToAge"
   102  )
   103  
   104  // GitHub Milestone numbers for the golang/go repo.
   105  var (
   106  	proposal      = milestone{30, "Proposal"}
   107  	unreleased    = milestone{22, "Unreleased"}
   108  	unplanned     = milestone{6, "Unplanned"}
   109  	gccgo         = milestone{23, "Gccgo"}
   110  	vgo           = milestone{71, "vgo"}
   111  	vulnUnplanned = milestone{288, "vuln/unplanned"}
   112  )
   113  
   114  // GitHub Milestone numbers for the golang/vscode-go repo.
   115  var vscodeUntriaged = milestone{26, "Untriaged"}
   116  
   117  type milestone struct {
   118  	Number int
   119  	Name   string
   120  }
   121  
   122  func getGitHubToken(ctx context.Context, sc *secret.Client) (string, error) {
   123  	if metadata.OnGCE() && sc != nil {
   124  		ctxSc, cancel := context.WithTimeout(ctx, 10*time.Second)
   125  		defer cancel()
   126  
   127  		token, err := sc.Retrieve(ctxSc, secret.NameMaintnerGitHubToken)
   128  		if err == nil && token != "" {
   129  			return token, nil
   130  		}
   131  	}
   132  	slurp, err := os.ReadFile(*githubTokenFile)
   133  	if err != nil {
   134  		return "", err
   135  	}
   136  	f := strings.SplitN(strings.TrimSpace(string(slurp)), ":", 2)
   137  	if len(f) != 2 || f[0] == "" || f[1] == "" {
   138  		return "", fmt.Errorf("expected token %q to be of form <username>:<token>", slurp)
   139  	}
   140  	return f[1], nil
   141  }
   142  
   143  func getGerritAuth(ctx context.Context, sc *secret.Client) (username string, password string, err error) {
   144  	if metadata.OnGCE() && sc != nil {
   145  		ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
   146  		defer cancel()
   147  
   148  		token, err := sc.Retrieve(ctx, secret.NameGobotPassword)
   149  		if err != nil {
   150  			return "", "", err
   151  		}
   152  		return "git-gobot.golang.org", token, nil
   153  	}
   154  
   155  	var slurpBytes []byte
   156  	slurpBytes, err = os.ReadFile(*gerritTokenFile)
   157  	if err != nil {
   158  		return "", "", err
   159  	}
   160  	slurp := string(slurpBytes)
   161  
   162  	f := strings.SplitN(strings.TrimSpace(slurp), ":", 2)
   163  	if len(f) == 1 {
   164  		// assume the whole thing is the token
   165  		return "git-gobot.golang.org", f[0], nil
   166  	}
   167  	if len(f) != 2 || f[0] == "" || f[1] == "" {
   168  		return "", "", fmt.Errorf("expected Gerrit token %q to be of form <git-email>:<token>", slurp)
   169  	}
   170  	return f[0], f[1], nil
   171  }
   172  
   173  func getGitHubClients(ctx context.Context, sc *secret.Client) (*github.Client, *githubv4.Client, error) {
   174  	token, err := getGitHubToken(ctx, sc)
   175  	if err != nil {
   176  		if *dryRun {
   177  			// Note: GitHub API v4 requires requests to be authenticated, which isn't implemented here.
   178  			return github.NewClient(http.DefaultClient), githubv4.NewClient(http.DefaultClient), nil
   179  		}
   180  		return nil, nil, err
   181  	}
   182  	ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
   183  	tc := oauth2.NewClient(context.Background(), ts)
   184  	return github.NewClient(tc), githubv4.NewClient(tc), nil
   185  }
   186  
   187  func getGerritClient(ctx context.Context, sc *secret.Client) (*gerrit.Client, error) {
   188  	username, token, err := getGerritAuth(ctx, sc)
   189  	if err != nil {
   190  		if *dryRun {
   191  			c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth)
   192  			return c, nil
   193  		}
   194  		return nil, err
   195  	}
   196  	c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.BasicAuth(username, token))
   197  	return c, nil
   198  }
   199  
   200  func getMaintnerClient(ctx context.Context) (apipb.MaintnerServiceClient, error) {
   201  	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
   202  	defer cancel()
   203  	mServer := "maintner.golang.org:443"
   204  	cc, err := grpc.DialContext(ctx, mServer,
   205  		grpc.WithBlock(),
   206  		grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{NextProtos: []string{"h2"}})))
   207  	if err != nil {
   208  		return nil, err
   209  	}
   210  	return apipb.NewMaintnerServiceClient(cc), nil
   211  }
   212  
   213  type gerritChange struct {
   214  	project string
   215  	num     int32
   216  }
   217  
   218  func (c gerritChange) ID() string {
   219  	// https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id
   220  	return fmt.Sprintf("%s~%d", c.project, c.num)
   221  }
   222  
   223  func (c gerritChange) String() string {
   224  	return c.ID()
   225  }
   226  
   227  type githubIssue struct {
   228  	repo maintner.GitHubRepoID
   229  	num  int32
   230  }
   231  
   232  func main() {
   233  	flag.Parse()
   234  
   235  	var sc *secret.Client
   236  	if metadata.OnGCE() {
   237  		sc = secret.MustNewClient()
   238  	}
   239  	ctx := context.Background()
   240  
   241  	ghV3, ghV4, err := getGitHubClients(ctx, sc)
   242  	if err != nil {
   243  		log.Fatal(err)
   244  	}
   245  	gerrit, err := getGerritClient(ctx, sc)
   246  	if err != nil {
   247  		log.Fatal(err)
   248  	}
   249  	mc, err := getMaintnerClient(ctx)
   250  	if err != nil {
   251  		log.Fatal(err)
   252  	}
   253  
   254  	var goRepo = maintner.GitHubRepoID{Owner: "golang", Repo: "go"}
   255  	var vscode = maintner.GitHubRepoID{Owner: "golang", Repo: "vscode-go"}
   256  	bot := &gopherbot{
   257  		ghc:    ghV3,
   258  		ghV4:   ghV4,
   259  		gerrit: gerrit,
   260  		mc:     mc,
   261  		is:     ghV3.Issues,
   262  		deletedChanges: map[gerritChange]bool{
   263  			{"crypto", 35958}:  true,
   264  			{"scratch", 71730}: true,
   265  			{"scratch", 71850}: true,
   266  			{"scratch", 72090}: true,
   267  			{"scratch", 72091}: true,
   268  			{"scratch", 72110}: true,
   269  			{"scratch", 72131}: true,
   270  		},
   271  		deletedIssues: map[githubIssue]bool{
   272  			{goRepo, 13084}: true,
   273  			{goRepo, 23772}: true,
   274  			{goRepo, 27223}: true,
   275  			{goRepo, 28522}: true,
   276  			{goRepo, 29309}: true,
   277  			{goRepo, 32047}: true,
   278  			{goRepo, 32048}: true,
   279  			{goRepo, 32469}: true,
   280  			{goRepo, 32706}: true,
   281  			{goRepo, 32737}: true,
   282  			{goRepo, 33315}: true,
   283  			{goRepo, 33316}: true,
   284  			{goRepo, 33592}: true,
   285  			{goRepo, 33593}: true,
   286  			{goRepo, 33697}: true,
   287  			{goRepo, 33785}: true,
   288  			{goRepo, 34296}: true,
   289  			{goRepo, 34476}: true,
   290  			{goRepo, 34766}: true,
   291  			{goRepo, 34780}: true,
   292  			{goRepo, 34786}: true,
   293  			{goRepo, 34821}: true,
   294  			{goRepo, 35493}: true,
   295  			{goRepo, 35649}: true,
   296  			{goRepo, 36322}: true,
   297  			{goRepo, 36323}: true,
   298  			{goRepo, 36324}: true,
   299  			{goRepo, 36342}: true,
   300  			{goRepo, 36343}: true,
   301  			{goRepo, 36406}: true,
   302  			{goRepo, 36517}: true,
   303  			{goRepo, 36829}: true,
   304  			{goRepo, 36885}: true,
   305  			{goRepo, 36933}: true,
   306  			{goRepo, 36939}: true,
   307  			{goRepo, 36941}: true,
   308  			{goRepo, 36947}: true,
   309  			{goRepo, 36962}: true,
   310  			{goRepo, 36963}: true,
   311  			{goRepo, 37516}: true,
   312  			{goRepo, 37522}: true,
   313  			{goRepo, 37582}: true,
   314  			{goRepo, 37896}: true,
   315  			{goRepo, 38132}: true,
   316  			{goRepo, 38241}: true,
   317  			{goRepo, 38483}: true,
   318  			{goRepo, 38560}: true,
   319  			{goRepo, 38840}: true,
   320  			{goRepo, 39112}: true,
   321  			{goRepo, 39141}: true,
   322  			{goRepo, 39229}: true,
   323  			{goRepo, 39234}: true,
   324  			{goRepo, 39335}: true,
   325  			{goRepo, 39401}: true,
   326  			{goRepo, 39453}: true,
   327  			{goRepo, 39522}: true,
   328  			{goRepo, 39718}: true,
   329  			{goRepo, 40400}: true,
   330  			{goRepo, 40593}: true,
   331  			{goRepo, 40600}: true,
   332  			{goRepo, 41211}: true,
   333  			{goRepo, 41336}: true,
   334  			{goRepo, 41649}: true,
   335  			{goRepo, 41650}: true,
   336  			{goRepo, 41655}: true,
   337  			{goRepo, 41675}: true,
   338  			{goRepo, 41676}: true,
   339  			{goRepo, 41678}: true,
   340  			{goRepo, 41679}: true,
   341  			{goRepo, 41714}: true,
   342  			{goRepo, 42309}: true,
   343  			{goRepo, 43102}: true,
   344  			{goRepo, 43169}: true,
   345  			{goRepo, 43231}: true,
   346  			{goRepo, 43330}: true,
   347  			{goRepo, 43409}: true,
   348  			{goRepo, 43410}: true,
   349  			{goRepo, 43411}: true,
   350  			{goRepo, 43433}: true,
   351  			{goRepo, 43613}: true,
   352  			{goRepo, 43751}: true,
   353  			{goRepo, 44124}: true,
   354  			{goRepo, 44185}: true,
   355  			{goRepo, 44566}: true,
   356  			{goRepo, 44652}: true,
   357  			{goRepo, 44711}: true,
   358  			{goRepo, 44768}: true,
   359  			{goRepo, 44769}: true,
   360  			{goRepo, 44771}: true,
   361  			{goRepo, 44773}: true,
   362  			{goRepo, 44871}: true,
   363  			{goRepo, 45018}: true,
   364  			{goRepo, 45082}: true,
   365  			{goRepo, 45201}: true,
   366  			{goRepo, 45202}: true,
   367  			{goRepo, 47140}: true,
   368  
   369  			{vscode, 298}:  true,
   370  			{vscode, 524}:  true,
   371  			{vscode, 650}:  true,
   372  			{vscode, 741}:  true,
   373  			{vscode, 773}:  true,
   374  			{vscode, 959}:  true,
   375  			{vscode, 1402}: true,
   376  		},
   377  	}
   378  	for n := int32(55359); n <= 55828; n++ {
   379  		bot.deletedIssues[githubIssue{goRepo, n}] = true
   380  	}
   381  	bot.initCorpus()
   382  
   383  	for {
   384  		t0 := time.Now()
   385  		taskErrors := bot.doTasks(ctx)
   386  		for _, err := range taskErrors {
   387  			log.Print(err)
   388  		}
   389  		botDur := time.Since(t0)
   390  		log.Printf("gopherbot ran in %v", botDur)
   391  		if !*daemon {
   392  			if len(taskErrors) > 0 {
   393  				os.Exit(1)
   394  			}
   395  			return
   396  		}
   397  		if len(taskErrors) > 0 {
   398  			log.Printf("sleeping 30s after previous error.")
   399  			time.Sleep(30 * time.Second)
   400  		}
   401  		for {
   402  			t0 := time.Now()
   403  			err := bot.corpus.Update(ctx)
   404  			if err != nil {
   405  				if err == maintner.ErrSplit {
   406  					log.Print("Corpus out of sync. Re-fetching corpus.")
   407  					bot.initCorpus()
   408  				} else {
   409  					log.Printf("corpus.Update: %v; sleeping 15s", err)
   410  					time.Sleep(15 * time.Second)
   411  					continue
   412  				}
   413  			}
   414  			log.Printf("got corpus update after %v", time.Since(t0))
   415  			break
   416  		}
   417  		lastTask = ""
   418  	}
   419  }
   420  
   421  type gopherbot struct {
   422  	ghc    *github.Client
   423  	ghV4   *githubv4.Client
   424  	gerrit *gerrit.Client
   425  	mc     apipb.MaintnerServiceClient
   426  	corpus *maintner.Corpus
   427  	gorepo *maintner.GitHubRepo
   428  	is     issuesService
   429  
   430  	knownContributors map[string]bool
   431  
   432  	// Until golang.org/issue/22635 is fixed, keep a map of changes and issues
   433  	// that were deleted to prevent calls to Gerrit or GitHub that will always 404.
   434  	deletedChanges map[gerritChange]bool
   435  	deletedIssues  map[githubIssue]bool
   436  
   437  	releases struct {
   438  		sync.Mutex
   439  		lastUpdate time.Time
   440  		major      []string          // Last two releases and the next upcoming release, like: "1.9", "1.10", "1.11".
   441  		nextMinor  map[string]string // Key is a major release like "1.9", value is its next minor release like "1.9.7".
   442  	}
   443  }
   444  
   445  var tasks = []struct {
   446  	name string
   447  	fn   func(*gopherbot, context.Context) error
   448  }{
   449  	// Tasks that are specific to the golang/go repo.
   450  	{"kicktrain", (*gopherbot).getOffKickTrain},
   451  	{"label build issues", (*gopherbot).labelBuildIssues},
   452  	{"label compiler/runtime issues", (*gopherbot).labelCompilerRuntimeIssues},
   453  	{"label mobile issues", (*gopherbot).labelMobileIssues},
   454  	{"label tools issues", (*gopherbot).labelToolsIssues},
   455  	{"label website issues", (*gopherbot).labelWebsiteIssues},
   456  	{"label pkgsite issues", (*gopherbot).labelPkgsiteIssues},
   457  	{"label proxy.golang.org issues", (*gopherbot).labelProxyIssues},
   458  	{"label vulncheck or vulndb issues", (*gopherbot).labelVulnIssues},
   459  	{"label proposals", (*gopherbot).labelProposals},
   460  	{"handle gopls issues", (*gopherbot).handleGoplsIssues},
   461  	{"handle telemetry issues", (*gopherbot).handleTelemetryIssues},
   462  	{"open cherry pick issues", (*gopherbot).openCherryPickIssues},
   463  	{"close cherry pick issues", (*gopherbot).closeCherryPickIssues},
   464  	{"set subrepo milestones", (*gopherbot).setSubrepoMilestones},
   465  	{"set misc milestones", (*gopherbot).setMiscMilestones},
   466  	{"apply minor release milestones", (*gopherbot).setMinorMilestones},
   467  	{"update needs", (*gopherbot).updateNeeds},
   468  
   469  	// Tasks that can be applied to many repos.
   470  	{"freeze old issues", (*gopherbot).freezeOldIssues},
   471  	{"label documentation issues", (*gopherbot).labelDocumentationIssues},
   472  	{"close stale WaitingForInfo", (*gopherbot).closeStaleWaitingForInfo},
   473  	{"apply labels from comments", (*gopherbot).applyLabelsFromComments},
   474  
   475  	// Gerrit tasks are applied to all projects by default.
   476  	{"abandon scratch reviews", (*gopherbot).abandonScratchReviews},
   477  	{"assign reviewers to CLs", (*gopherbot).assignReviewersToCLs},
   478  	{"auto-submit CLs", (*gopherbot).autoSubmitCLs},
   479  
   480  	// Tasks that are specific to the golang/vscode-go repo.
   481  	{"set vscode-go milestones", (*gopherbot).setVSCodeGoMilestones},
   482  
   483  	{"access", (*gopherbot).whoNeedsAccess},
   484  	{"cl2issue", (*gopherbot).cl2issue},
   485  	{"congratulate new contributors", (*gopherbot).congratulateNewContributors},
   486  	{"un-wait CLs", (*gopherbot).unwaitCLs},
   487  }
   488  
   489  // gardenIssues reports whether GopherBot should perform general issue
   490  // gardening tasks for the repo.
   491  func gardenIssues(repo *maintner.GitHubRepo) bool {
   492  	if repo.ID().Owner != "golang" {
   493  		return false
   494  	}
   495  	switch repo.ID().Repo {
   496  	case "go", "vscode-go", "vulndb":
   497  		return true
   498  	}
   499  	return false
   500  }
   501  
   502  func (b *gopherbot) initCorpus() {
   503  	ctx := context.Background()
   504  	corpus, err := godata.Get(ctx)
   505  	if err != nil {
   506  		log.Fatalf("godata.Get: %v", err)
   507  	}
   508  
   509  	repo := corpus.GitHub().Repo("golang", "go")
   510  	if repo == nil {
   511  		log.Fatal("Failed to find Go repo in Corpus.")
   512  	}
   513  
   514  	b.corpus = corpus
   515  	b.gorepo = repo
   516  }
   517  
   518  // doTasks performs tasks in sequence. It doesn't stop if
   519  // if encounters an error, but reports errors at the end.
   520  func (b *gopherbot) doTasks(ctx context.Context) []error {
   521  	var errs []error
   522  	for _, task := range tasks {
   523  		if *onlyRun != "" && task.name != *onlyRun {
   524  			continue
   525  		}
   526  		err := task.fn(b, ctx)
   527  		if err != nil {
   528  			errs = append(errs, fmt.Errorf("%s: %v", task.name, err))
   529  		}
   530  	}
   531  	return errs
   532  }
   533  
   534  // issuesService represents portions of github.IssuesService that we want to override in tests.
   535  type issuesService interface {
   536  	ListLabelsByIssue(ctx context.Context, owner string, repo string, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error)
   537  	AddLabelsToIssue(ctx context.Context, owner string, repo string, number int, labels []string) ([]*github.Label, *github.Response, error)
   538  	RemoveLabelForIssue(ctx context.Context, owner string, repo string, number int, label string) (*github.Response, error)
   539  }
   540  
   541  func (b *gopherbot) addLabel(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, label string) error {
   542  	return b.addLabels(ctx, repoID, gi, []string{label})
   543  }
   544  
   545  func (b *gopherbot) addLabels(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, labels []string) error {
   546  	var toAdd []string
   547  	for _, label := range labels {
   548  		if gi.HasLabel(label) {
   549  			log.Printf("Issue %d already has label %q; no need to send request to add it", gi.Number, label)
   550  			continue
   551  		}
   552  		printIssue("label-"+label, repoID, gi)
   553  		toAdd = append(toAdd, label)
   554  	}
   555  
   556  	if *dryRun || len(toAdd) == 0 {
   557  		return nil
   558  	}
   559  
   560  	_, resp, err := b.is.AddLabelsToIssue(ctx, repoID.Owner, repoID.Repo, int(gi.Number), toAdd)
   561  	if err != nil && resp != nil && (resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone) {
   562  		// TODO(golang/go#40640) - This issue was transferred or otherwise is gone. We should permanently skip it. This
   563  		// is a temporary fix to keep gopherbot working.
   564  		log.Printf("addLabels: Issue %v#%v returned %s when trying to add labels. Skipping. See golang/go#40640.", repoID, gi.Number, resp.Status)
   565  		b.deletedIssues[githubIssue{repoID, gi.Number}] = true
   566  		return nil
   567  	}
   568  	return err
   569  }
   570  
   571  // removeLabel removes the label from the given issue in the given repo.
   572  func (b *gopherbot) removeLabel(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, label string) error {
   573  	return b.removeLabels(ctx, repoID, gi, []string{label})
   574  }
   575  
   576  func (b *gopherbot) removeLabels(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, labels []string) error {
   577  	var removeLabels bool
   578  	for _, l := range labels {
   579  		if !gi.HasLabel(l) {
   580  			log.Printf("Issue %d (in maintner) does not have label %q; no need to send request to remove it", gi.Number, l)
   581  			continue
   582  		}
   583  		printIssue("label-"+l, repoID, gi)
   584  		removeLabels = true
   585  	}
   586  
   587  	if *dryRun || !removeLabels {
   588  		return nil
   589  	}
   590  
   591  	ghLabels, err := labelsForIssue(ctx, repoID, b.is, int(gi.Number))
   592  	if err != nil {
   593  		return err
   594  	}
   595  	toRemove := make(map[string]bool)
   596  	for _, l := range labels {
   597  		toRemove[l] = true
   598  	}
   599  
   600  	for _, l := range ghLabels {
   601  		if toRemove[l] {
   602  			if err := removeLabelFromIssue(ctx, repoID, b.is, int(gi.Number), l); err != nil {
   603  				log.Printf("Could not remove label %q from issue %d: %v", l, gi.Number, err)
   604  				continue
   605  			}
   606  		}
   607  	}
   608  	return nil
   609  }
   610  
   611  // labelsForIssue returns all labels for the given issue in the given repo.
   612  func labelsForIssue(ctx context.Context, repoID maintner.GitHubRepoID, issues issuesService, issueNum int) ([]string, error) {
   613  	ghLabels, _, err := issues.ListLabelsByIssue(ctx, repoID.Owner, repoID.Repo, issueNum, &github.ListOptions{PerPage: 100})
   614  	if err != nil {
   615  		return nil, fmt.Errorf("could not list labels for %s#%d: %v", repoID, issueNum, err)
   616  	}
   617  	var labels []string
   618  	for _, l := range ghLabels {
   619  		labels = append(labels, l.GetName())
   620  	}
   621  	return labels, nil
   622  }
   623  
   624  // removeLabelFromIssue removes the given label from the given repo with the
   625  // given issueNum. If the issue did not have the label already (or the label
   626  // didn't exist), return nil.
   627  func removeLabelFromIssue(ctx context.Context, repoID maintner.GitHubRepoID, issues issuesService, issueNum int, label string) error {
   628  	_, err := issues.RemoveLabelForIssue(ctx, repoID.Owner, repoID.Repo, issueNum, label)
   629  	if ge, ok := err.(*github.ErrorResponse); ok && ge.Response != nil && ge.Response.StatusCode == http.StatusNotFound {
   630  		return nil
   631  	}
   632  	return err
   633  }
   634  
   635  func (b *gopherbot) setMilestone(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, m milestone) error {
   636  	printIssue("milestone-"+m.Name, repoID, gi)
   637  	if *dryRun {
   638  		return nil
   639  	}
   640  	_, _, err := b.ghc.Issues.Edit(ctx, repoID.Owner, repoID.Repo, int(gi.Number), &github.IssueRequest{
   641  		Milestone: github.Int(m.Number),
   642  	})
   643  	return err
   644  }
   645  
   646  func (b *gopherbot) addGitHubComment(ctx context.Context, repo *maintner.GitHubRepo, issueNum int32, msg string) error {
   647  	var since time.Time
   648  	if gi := repo.Issue(issueNum); gi != nil {
   649  		dup := false
   650  		gi.ForeachComment(func(c *maintner.GitHubComment) error {
   651  			since = c.Updated
   652  			// TODO: check for gopherbot as author? check for exact match?
   653  			// This seems fine for now.
   654  			if strings.Contains(c.Body, msg) {
   655  				dup = true
   656  				return errStopIteration
   657  			}
   658  			return nil
   659  		})
   660  		if dup {
   661  			// Comment's already been posted. Nothing to do.
   662  			return nil
   663  		}
   664  	}
   665  	// See if there is a dup comment from when gopherbot last got
   666  	// its data from maintner.
   667  	opt := &github.IssueListCommentsOptions{ListOptions: github.ListOptions{PerPage: 1000}}
   668  	if !since.IsZero() {
   669  		opt.Since = &since
   670  	}
   671  	ics, resp, err := b.ghc.Issues.ListComments(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum), opt)
   672  	if err != nil {
   673  		// TODO(golang/go#40640) - This issue was transferred or otherwise is gone. We should permanently skip it. This
   674  		// is a temporary fix to keep gopherbot working.
   675  		if resp != nil && resp.StatusCode == http.StatusNotFound {
   676  			log.Printf("addGitHubComment: Issue %v#%v returned a 404 when trying to load comments. Skipping. See golang/go#40640.", repo.ID(), issueNum)
   677  			b.deletedIssues[githubIssue{repo.ID(), issueNum}] = true
   678  			return nil
   679  		}
   680  		return err
   681  	}
   682  	for _, ic := range ics {
   683  		if strings.Contains(ic.GetBody(), msg) {
   684  			// Dup.
   685  			return nil
   686  		}
   687  	}
   688  	if *dryRun {
   689  		log.Printf("[dry-run] would add comment to github.com/%s/issues/%d: %v", repo.ID(), issueNum, msg)
   690  		return nil
   691  	}
   692  	_, resp, createError := b.ghc.Issues.CreateComment(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum), &github.IssueComment{
   693  		Body: github.String(msg),
   694  	})
   695  	if createError != nil && resp != nil && resp.StatusCode == http.StatusUnprocessableEntity {
   696  		// While maintner's tracking of deleted issues is incomplete (see go.dev/issue/30184),
   697  		// we sometimes see a deleted issue whose /comments endpoint returns 200 OK with an
   698  		// empty list, so the error check from ListComments doesn't catch it. (The deleted
   699  		// issue 55403 is an example of such a case.) So check again with the Get endpoint,
   700  		// which seems to return 404 more reliably in such cases at least as of 2022-10-11.
   701  		if _, resp, err := b.ghc.Issues.Get(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum)); err != nil &&
   702  			resp != nil && resp.StatusCode == http.StatusNotFound {
   703  			log.Printf("addGitHubComment: Issue %v#%v returned a 404 after posting comment failed with 422. Skipping. See go.dev/issue/30184.", repo.ID(), issueNum)
   704  			b.deletedIssues[githubIssue{repo.ID(), issueNum}] = true
   705  			return nil
   706  		}
   707  	}
   708  	return createError
   709  }
   710  
   711  // createGitHubIssue returns the number of the created issue, or 4242 in dry-run mode.
   712  // baseEvent is the timestamp of the event causing this action, and is used for de-duplication.
   713  func (b *gopherbot) createGitHubIssue(ctx context.Context, title, msg string, labels []string, baseEvent time.Time) (int, error) {
   714  	var dup int
   715  	b.gorepo.ForeachIssue(func(gi *maintner.GitHubIssue) error {
   716  		// TODO: check for gopherbot as author? check for exact match?
   717  		// This seems fine for now.
   718  		if gi.Title == title {
   719  			dup = int(gi.Number)
   720  			return errStopIteration
   721  		}
   722  		return nil
   723  	})
   724  	if dup != 0 {
   725  		// Issue's already been posted. Nothing to do.
   726  		return dup, nil
   727  	}
   728  	// See if there is a dup issue from when gopherbot last got its data from maintner.
   729  	is, _, err := b.ghc.Issues.ListByRepo(ctx, "golang", "go", &github.IssueListByRepoOptions{
   730  		State:       "all",
   731  		ListOptions: github.ListOptions{PerPage: 100},
   732  		Since:       baseEvent,
   733  	})
   734  	if err != nil {
   735  		return 0, err
   736  	}
   737  	for _, i := range is {
   738  		if i.GetTitle() == title {
   739  			// Dup.
   740  			return i.GetNumber(), nil
   741  		}
   742  	}
   743  	if *dryRun {
   744  		log.Printf("[dry-run] would create issue with title %s and labels %v\n%s", title, labels, msg)
   745  		return 4242, nil
   746  	}
   747  	i, _, err := b.ghc.Issues.Create(ctx, "golang", "go", &github.IssueRequest{
   748  		Title:  github.String(title),
   749  		Body:   github.String(msg),
   750  		Labels: &labels,
   751  	})
   752  	return i.GetNumber(), err
   753  }
   754  
   755  // issueCloseReason is a reason given when closing an issue on GitHub.
   756  // See https://docs.github.com/en/issues/tracking-your-work-with-issues/closing-an-issue.
   757  type issueCloseReason *string
   758  
   759  var (
   760  	completed  issueCloseReason = github.String("completed")   // Done, closed, fixed, resolved.
   761  	notPlanned issueCloseReason = github.String("not_planned") // Won't fix, can't repro, duplicate, stale.
   762  )
   763  
   764  // closeGitHubIssue closes a GitHub issue.
   765  // reason specifies why it's being closed. (GitHub's default reason on 2023-06-12 is "completed".)
   766  func (b *gopherbot) closeGitHubIssue(ctx context.Context, repoID maintner.GitHubRepoID, number int32, reason issueCloseReason) error {
   767  	if *dryRun {
   768  		var suffix string
   769  		if reason != nil {
   770  			suffix = " as " + *reason
   771  		}
   772  		log.Printf("[dry-run] would close go.dev/issue/%v%s", number, suffix)
   773  		return nil
   774  	}
   775  	_, _, err := b.ghc.Issues.Edit(ctx, repoID.Owner, repoID.Repo, int(number), &github.IssueRequest{
   776  		State:       github.String("closed"),
   777  		StateReason: reason,
   778  	})
   779  	return err
   780  }
   781  
   782  type gerritCommentOpts struct {
   783  	OldPhrases []string
   784  	Version    string // if empty, latest version is used
   785  }
   786  
   787  var emptyGerritCommentOpts gerritCommentOpts
   788  
   789  // addGerritComment adds the given comment to the CL specified by the changeID
   790  // and the patch set identified by the version.
   791  //
   792  // As an idempotence check, before adding the comment and the list
   793  // of oldPhrases are checked against the CL to ensure that no phrase in the list
   794  // has already been added to the list as a comment.
   795  func (b *gopherbot) addGerritComment(ctx context.Context, changeID, comment string, opts *gerritCommentOpts) error {
   796  	if b == nil {
   797  		panic("nil gopherbot")
   798  	}
   799  	if *dryRun {
   800  		log.Printf("[dry-run] would add comment to golang.org/cl/%s: %v", changeID, comment)
   801  		return nil
   802  	}
   803  	if opts == nil {
   804  		opts = &emptyGerritCommentOpts
   805  	}
   806  	// One final staleness check before sending a message: get the list
   807  	// of comments from the API and check whether any of them match.
   808  	info, err := b.gerrit.GetChange(ctx, changeID, gerrit.QueryChangesOpt{
   809  		Fields: []string{"MESSAGES", "CURRENT_REVISION"},
   810  	})
   811  	if err != nil {
   812  		return err
   813  	}
   814  	for _, msg := range info.Messages {
   815  		if strings.Contains(msg.Message, comment) {
   816  			return nil // Our comment is already there
   817  		}
   818  		for j := range opts.OldPhrases {
   819  			// Message looks something like "Patch set X:\n\n(our text)"
   820  			if strings.Contains(msg.Message, opts.OldPhrases[j]) {
   821  				return nil // Our comment is already there
   822  			}
   823  		}
   824  	}
   825  	var rev string
   826  	if opts.Version != "" {
   827  		rev = opts.Version
   828  	} else {
   829  		rev = info.CurrentRevision
   830  	}
   831  	return b.gerrit.SetReview(ctx, changeID, rev, gerrit.ReviewInput{
   832  		Message: comment,
   833  	})
   834  }
   835  
   836  // Move any issue to "Unplanned" if it looks like it keeps getting kicked along between releases.
   837  func (b *gopherbot) getOffKickTrain(ctx context.Context) error {
   838  	// We only run this task if it was explicitly requested via
   839  	// the --only-run flag.
   840  	if *onlyRun == "" {
   841  		return nil
   842  	}
   843  	type match struct {
   844  		url   string
   845  		title string
   846  		gi    *maintner.GitHubIssue
   847  	}
   848  	var matches []match
   849  	b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
   850  		curMilestone := gi.Milestone.Title
   851  		if !strings.HasPrefix(curMilestone, "Go1.") || strings.Count(curMilestone, ".") != 1 {
   852  			return nil
   853  		}
   854  		if gi.HasLabel("release-blocker") || gi.HasLabel("Security") {
   855  			return nil
   856  		}
   857  		if len(gi.Assignees) > 0 {
   858  			return nil
   859  		}
   860  		was := map[string]bool{}
   861  		gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error {
   862  			if e.Type == "milestoned" {
   863  				switch e.Milestone {
   864  				case "Unreleased", "Unplanned", "Proposal":
   865  					return nil
   866  				}
   867  				if strings.Count(e.Milestone, ".") > 1 {
   868  					return nil
   869  				}
   870  				ms := strings.TrimSuffix(e.Milestone, "Maybe")
   871  				ms = strings.TrimSuffix(ms, "Early")
   872  				was[ms] = true
   873  			}
   874  			return nil
   875  		})
   876  		if len(was) > 2 {
   877  			var mss []string
   878  			for ms := range was {
   879  				mss = append(mss, ms)
   880  			}
   881  			sort.Slice(mss, func(i, j int) bool {
   882  				if len(mss[i]) == len(mss[j]) {
   883  					return mss[i] < mss[j]
   884  				}
   885  				return len(mss[i]) < len(mss[j])
   886  			})
   887  			matches = append(matches, match{
   888  				url:   fmt.Sprintf("https://go.dev/issue/%d", gi.Number),
   889  				title: fmt.Sprintf("%s - %v", gi.Title, mss),
   890  				gi:    gi,
   891  			})
   892  		}
   893  		return nil
   894  	})
   895  	sort.Slice(matches, func(i, j int) bool {
   896  		return matches[i].title < matches[j].title
   897  	})
   898  	fmt.Printf("%d issues:\n", len(matches))
   899  	for _, m := range matches {
   900  		fmt.Printf("%-30s - %s\n", m.url, m.title)
   901  		if !*dryRun {
   902  			if err := b.setMilestone(ctx, b.gorepo.ID(), m.gi, unplanned); err != nil {
   903  				return err
   904  			}
   905  		}
   906  	}
   907  	return nil
   908  }
   909  
   910  // freezeOldIssues locks any issue that's old and closed.
   911  // (Otherwise people find ancient bugs via searches and start asking questions
   912  // into a void and it's sad for everybody.)
   913  // This method doesn't need to explicitly avoid edit wars with humans because
   914  // it bails out if the issue was edited recently. A human unlocking an issue
   915  // causes the updated time to bump, which means the bot wouldn't try to lock it
   916  // again for another year.
   917  func (b *gopherbot) freezeOldIssues(ctx context.Context) error {
   918  	tooOld := time.Now().Add(-365 * 24 * time.Hour)
   919  	return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error {
   920  		if !gardenIssues(repo) {
   921  			return nil
   922  		}
   923  		if !repoHasLabel(repo, frozenDueToAge) {
   924  			return nil
   925  		}
   926  		return b.foreachIssue(repo, closed, func(gi *maintner.GitHubIssue) error {
   927  			if gi.Locked || gi.Updated.After(tooOld) {
   928  				return nil
   929  			}
   930  			printIssue("freeze", repo.ID(), gi)
   931  			if *dryRun {
   932  				return nil
   933  			}
   934  			_, err := b.ghc.Issues.Lock(ctx, repo.ID().Owner, repo.ID().Repo, int(gi.Number), nil)
   935  			if ge, ok := err.(*github.ErrorResponse); ok && ge.Response.StatusCode == http.StatusNotFound {
   936  				// An issue can become 404 on GitHub due to being deleted or transferred. See go.dev/issue/30182.
   937  				b.deletedIssues[githubIssue{repo.ID(), gi.Number}] = true
   938  				return nil
   939  			} else if err != nil {
   940  				return err
   941  			}
   942  			return b.addLabel(ctx, repo.ID(), gi, frozenDueToAge)
   943  		})
   944  	})
   945  }
   946  
   947  // labelProposals adds the "Proposal" label and "Proposal" milestone
   948  // to open issues with title beginning with "Proposal:". It tries not
   949  // to get into an edit war with a human.
   950  func (b *gopherbot) labelProposals(ctx context.Context) error {
   951  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
   952  		if !strings.HasPrefix(gi.Title, "proposal:") && !strings.HasPrefix(gi.Title, "Proposal:") {
   953  			return nil
   954  		}
   955  		// Add Milestone if missing:
   956  		if gi.Milestone.IsNone() && !gi.HasEvent("milestoned") && !gi.HasEvent("demilestoned") {
   957  			if err := b.setMilestone(ctx, b.gorepo.ID(), gi, proposal); err != nil {
   958  				return err
   959  			}
   960  		}
   961  		// Add Proposal label if missing:
   962  		if !gi.HasLabel("Proposal") && !gi.HasEvent("unlabeled") {
   963  			if err := b.addLabel(ctx, b.gorepo.ID(), gi, "Proposal"); err != nil {
   964  				return err
   965  			}
   966  		}
   967  
   968  		// Remove NeedsDecision label if exists, but not for Go 2 issues:
   969  		if !isGo2Issue(gi) && gi.HasLabel("NeedsDecision") && !gopherbotRemovedLabel(gi, "NeedsDecision") {
   970  			if err := b.removeLabel(ctx, b.gorepo.ID(), gi, "NeedsDecision"); err != nil {
   971  				return err
   972  			}
   973  		}
   974  		return nil
   975  	})
   976  }
   977  
   978  // gopherbotRemovedLabel reports whether gopherbot has
   979  // previously removed label in the GitHub issue gi.
   980  //
   981  // Note that until golang.org/issue/28226 is resolved,
   982  // there's a brief delay before maintner catches up on
   983  // GitHub issue events and learns that it has happened.
   984  func gopherbotRemovedLabel(gi *maintner.GitHubIssue, label string) bool {
   985  	var hasRemoved bool
   986  	gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error {
   987  		if e.Actor != nil && e.Actor.ID == gopherbotGitHubID &&
   988  			e.Type == "unlabeled" &&
   989  			e.Label == label {
   990  			hasRemoved = true
   991  			return errStopIteration
   992  		}
   993  		return nil
   994  	})
   995  	return hasRemoved
   996  }
   997  
   998  // isGo2Issue reports whether gi seems like it's about Go 2, based on either labels or its title.
   999  func isGo2Issue(gi *maintner.GitHubIssue) bool {
  1000  	if gi.HasLabel("Go2") {
  1001  		return true
  1002  	}
  1003  	if !strings.Contains(gi.Title, "2") {
  1004  		// Common case.
  1005  		return false
  1006  	}
  1007  	return strings.Contains(gi.Title, "Go 2") || strings.Contains(gi.Title, "go2") || strings.Contains(gi.Title, "Go2")
  1008  }
  1009  
  1010  func (b *gopherbot) setSubrepoMilestones(ctx context.Context) error {
  1011  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1012  		if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") {
  1013  			return nil
  1014  		}
  1015  		if !strings.HasPrefix(gi.Title, "x/") {
  1016  			return nil
  1017  		}
  1018  		pkg := gi.Title
  1019  		if colon := strings.IndexByte(pkg, ':'); colon >= 0 {
  1020  			pkg = pkg[:colon]
  1021  		}
  1022  		if sp := strings.IndexByte(pkg, ' '); sp >= 0 {
  1023  			pkg = pkg[:sp]
  1024  		}
  1025  		switch pkg {
  1026  		case "",
  1027  			"x/arch",
  1028  			"x/crypto/chacha20poly1305",
  1029  			"x/crypto/curve25519",
  1030  			"x/crypto/poly1305",
  1031  			"x/net/http2",
  1032  			"x/net/idna",
  1033  			"x/net/lif",
  1034  			"x/net/proxy",
  1035  			"x/net/route",
  1036  			"x/text/unicode/norm",
  1037  			"x/text/width":
  1038  			// These get vendored in. Don't mess with them.
  1039  			return nil
  1040  		case "x/vgo":
  1041  			// Handled by setMiscMilestones
  1042  			return nil
  1043  		}
  1044  		return b.setMilestone(ctx, b.gorepo.ID(), gi, unreleased)
  1045  	})
  1046  }
  1047  
  1048  func (b *gopherbot) setMiscMilestones(ctx context.Context) error {
  1049  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1050  		if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") {
  1051  			return nil
  1052  		}
  1053  		if strings.Contains(gi.Title, "gccgo") { // TODO: better gccgo bug report heuristic?
  1054  			return b.setMilestone(ctx, b.gorepo.ID(), gi, gccgo)
  1055  		}
  1056  		if strings.HasPrefix(gi.Title, "x/vgo") {
  1057  			return b.setMilestone(ctx, b.gorepo.ID(), gi, vgo)
  1058  		}
  1059  		if strings.HasPrefix(gi.Title, "x/vuln") {
  1060  			return b.setMilestone(ctx, b.gorepo.ID(), gi, vulnUnplanned)
  1061  		}
  1062  		return nil
  1063  	})
  1064  }
  1065  
  1066  func (b *gopherbot) setVSCodeGoMilestones(ctx context.Context) error {
  1067  	vscode := b.corpus.GitHub().Repo("golang", "vscode-go")
  1068  	if vscode == nil {
  1069  		return nil
  1070  	}
  1071  	return b.foreachIssue(vscode, open, func(gi *maintner.GitHubIssue) error {
  1072  		if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") {
  1073  			return nil
  1074  		}
  1075  		// Work-around golang/go#40640 by only milestoning new issues.
  1076  		if time.Since(gi.Created) > 24*time.Hour {
  1077  			return nil
  1078  		}
  1079  		return b.setMilestone(ctx, vscode.ID(), gi, vscodeUntriaged)
  1080  	})
  1081  }
  1082  
  1083  func (b *gopherbot) labelBuildIssues(ctx context.Context) error {
  1084  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1085  		if !strings.HasPrefix(gi.Title, "x/build") || gi.HasLabel("Builders") || gi.HasEvent("unlabeled") {
  1086  			return nil
  1087  		}
  1088  		return b.addLabel(ctx, b.gorepo.ID(), gi, "Builders")
  1089  	})
  1090  }
  1091  
  1092  func (b *gopherbot) labelCompilerRuntimeIssues(ctx context.Context) error {
  1093  	entries, err := getAllCodeOwners(ctx)
  1094  	if err != nil {
  1095  		return err
  1096  	}
  1097  	// Filter out any entries that don't contain compiler/runtime owners into
  1098  	// a set of compiler/runtime-owned packages whose names match the names
  1099  	// used in the issue tracker.
  1100  	crtPackages := make(map[string]struct{}) // Key is issue title prefix, like "cmd/compile" or "x/sys/unix."
  1101  	for pkg, entry := range entries {
  1102  		for _, owner := range entry.Primary {
  1103  			name := owner.GitHubUsername
  1104  			if name == "golang/compiler" || name == "golang/runtime" {
  1105  				crtPackages[owners.TranslatePathForIssues(pkg)] = struct{}{}
  1106  				break
  1107  			}
  1108  		}
  1109  	}
  1110  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1111  		if gi.HasLabel("compiler/runtime") || gi.HasEvent("unlabeled") {
  1112  			return nil
  1113  		}
  1114  		components := strings.SplitN(gi.Title, ":", 2)
  1115  		if len(components) != 2 {
  1116  			return nil
  1117  		}
  1118  		for _, p := range strings.Split(strings.TrimSpace(components[0]), ",") {
  1119  			if _, ok := crtPackages[strings.TrimSpace(p)]; !ok {
  1120  				continue
  1121  			}
  1122  			// TODO(mknyszek): Add this issue to the GitHub project as well.
  1123  			return b.addLabel(ctx, b.gorepo.ID(), gi, "compiler/runtime")
  1124  		}
  1125  		return nil
  1126  	})
  1127  }
  1128  
  1129  func (b *gopherbot) labelMobileIssues(ctx context.Context) error {
  1130  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1131  		if !strings.HasPrefix(gi.Title, "x/mobile") || gi.HasLabel("mobile") || gi.HasEvent("unlabeled") {
  1132  			return nil
  1133  		}
  1134  		return b.addLabel(ctx, b.gorepo.ID(), gi, "mobile")
  1135  	})
  1136  }
  1137  
  1138  func (b *gopherbot) labelDocumentationIssues(ctx context.Context) error {
  1139  	const documentation = "Documentation"
  1140  	return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error {
  1141  		if !gardenIssues(repo) {
  1142  			return nil
  1143  		}
  1144  		if !repoHasLabel(repo, documentation) {
  1145  			return nil
  1146  		}
  1147  		return b.foreachIssue(repo, open, func(gi *maintner.GitHubIssue) error {
  1148  			if !isDocumentationTitle(gi.Title) || gi.HasLabel("Documentation") || gi.HasEvent("unlabeled") {
  1149  				return nil
  1150  			}
  1151  			return b.addLabel(ctx, repo.ID(), gi, documentation)
  1152  		})
  1153  	})
  1154  }
  1155  
  1156  func (b *gopherbot) labelToolsIssues(ctx context.Context) error {
  1157  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1158  		if !strings.HasPrefix(gi.Title, "x/tools") || gi.HasLabel("Tools") || gi.HasEvent("unlabeled") {
  1159  			return nil
  1160  		}
  1161  		return b.addLabel(ctx, b.gorepo.ID(), gi, "Tools")
  1162  	})
  1163  }
  1164  
  1165  func (b *gopherbot) labelWebsiteIssues(ctx context.Context) error {
  1166  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1167  		hasWebsiteTitle := strings.HasPrefix(gi.Title, "x/website:")
  1168  		if !hasWebsiteTitle || gi.HasLabel("website") || gi.HasEvent("unlabeled") {
  1169  			return nil
  1170  		}
  1171  		return b.addLabel(ctx, b.gorepo.ID(), gi, "website")
  1172  	})
  1173  }
  1174  
  1175  func (b *gopherbot) labelPkgsiteIssues(ctx context.Context) error {
  1176  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1177  		hasPkgsiteTitle := strings.HasPrefix(gi.Title, "x/pkgsite:")
  1178  		if !hasPkgsiteTitle || gi.HasLabel("pkgsite") || gi.HasEvent("unlabeled") {
  1179  			return nil
  1180  		}
  1181  		return b.addLabel(ctx, b.gorepo.ID(), gi, "pkgsite")
  1182  	})
  1183  }
  1184  
  1185  func (b *gopherbot) labelProxyIssues(ctx context.Context) error {
  1186  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1187  		hasProxyTitle := strings.Contains(gi.Title, "proxy.golang.org") || strings.Contains(gi.Title, "sum.golang.org") || strings.Contains(gi.Title, "index.golang.org")
  1188  		if !hasProxyTitle || gi.HasLabel("proxy.golang.org") || gi.HasEvent("unlabeled") {
  1189  			return nil
  1190  		}
  1191  		return b.addLabel(ctx, b.gorepo.ID(), gi, "proxy.golang.org")
  1192  	})
  1193  }
  1194  
  1195  func (b *gopherbot) labelVulnIssues(ctx context.Context) error {
  1196  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1197  		hasVulnTitle := strings.HasPrefix(gi.Title, "x/vuln:") || strings.HasPrefix(gi.Title, "x/vuln/") ||
  1198  			strings.HasPrefix(gi.Title, "x/vulndb:") || strings.HasPrefix(gi.Title, "x/vulndb/")
  1199  		if !hasVulnTitle || gi.HasLabel("vulncheck or vulndb") || gi.HasEvent("unlabeled") {
  1200  			return nil
  1201  		}
  1202  		return b.addLabel(ctx, b.gorepo.ID(), gi, "vulncheck or vulndb")
  1203  	})
  1204  }
  1205  
  1206  // handleGoplsIssues labels and asks for additional information on gopls issues.
  1207  //
  1208  // This is necessary because gopls issues often require additional information to diagnose,
  1209  // and we don't ask for this information in the Go issue template.
  1210  func (b *gopherbot) handleGoplsIssues(ctx context.Context) error {
  1211  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1212  		if !isGoplsTitle(gi.Title) || gi.HasLabel("gopls") || gi.HasEvent("unlabeled") {
  1213  			return nil
  1214  		}
  1215  		return b.addLabel(ctx, b.gorepo.ID(), gi, "gopls")
  1216  	})
  1217  }
  1218  
  1219  func (b *gopherbot) handleTelemetryIssues(ctx context.Context) error {
  1220  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1221  		if !strings.HasPrefix(gi.Title, "x/telemetry") || gi.HasLabel("telemetry") || gi.HasEvent("unlabeled") {
  1222  			return nil
  1223  		}
  1224  		return b.addLabel(ctx, b.gorepo.ID(), gi, "telemetry")
  1225  	})
  1226  }
  1227  
  1228  func (b *gopherbot) closeStaleWaitingForInfo(ctx context.Context) error {
  1229  	const waitingForInfo = "WaitingForInfo"
  1230  	now := time.Now()
  1231  	return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error {
  1232  		if !gardenIssues(repo) {
  1233  			return nil
  1234  		}
  1235  		if !repoHasLabel(repo, waitingForInfo) {
  1236  			return nil
  1237  		}
  1238  		return b.foreachIssue(repo, open, func(gi *maintner.GitHubIssue) error {
  1239  			if !gi.HasLabel(waitingForInfo) {
  1240  				return nil
  1241  			}
  1242  			var waitStart time.Time
  1243  			gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error {
  1244  				if e.Type == "reopened" {
  1245  					// Ignore any previous WaitingForInfo label if it's reopend.
  1246  					waitStart = time.Time{}
  1247  					return nil
  1248  				}
  1249  				if e.Label == waitingForInfo {
  1250  					switch e.Type {
  1251  					case "unlabeled":
  1252  						waitStart = time.Time{}
  1253  					case "labeled":
  1254  						waitStart = e.Created
  1255  					}
  1256  					return nil
  1257  				}
  1258  				return nil
  1259  			})
  1260  			if waitStart.IsZero() {
  1261  				return nil
  1262  			}
  1263  
  1264  			deadline := waitStart.AddDate(0, 1, 0) // 1 month
  1265  			if gi.HasLabel("CherryPickCandidate") || gi.HasLabel("CherryPickApproved") {
  1266  				// Cherry-pick issues may sometimes need to wait while
  1267  				// fixes get prepared and soak, so give them more time.
  1268  				deadline = waitStart.AddDate(0, 6, 0)
  1269  			}
  1270  			if repo.ID().Repo == "vscode-go" && gi.HasLabel("automatedReport") {
  1271  				// Automated issue reports have low response rates.
  1272  				// Apply shorter timeout.
  1273  				deadline = waitStart.AddDate(0, 0, 7)
  1274  			}
  1275  			if now.Before(deadline) {
  1276  				return nil
  1277  			}
  1278  
  1279  			var lastOPComment time.Time
  1280  			gi.ForeachComment(func(c *maintner.GitHubComment) error {
  1281  				if c.User.ID == gi.User.ID {
  1282  					lastOPComment = c.Created
  1283  				}
  1284  				return nil
  1285  			})
  1286  			if lastOPComment.After(waitStart) {
  1287  				return nil
  1288  			}
  1289  
  1290  			printIssue("close-stale-waiting-for-info", repo.ID(), gi)
  1291  			// TODO: write a task that reopens issues if the OP speaks up.
  1292  			if err := b.addGitHubComment(ctx, repo, gi.Number,
  1293  				"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 {
  1294  				return fmt.Errorf("b.addGitHubComment(_, %v, %v) = %w", repo.ID(), gi.Number, err)
  1295  			}
  1296  			return b.closeGitHubIssue(ctx, repo.ID(), gi.Number, notPlanned)
  1297  		})
  1298  	})
  1299  }
  1300  
  1301  // cl2issue writes "Change https://go.dev/cl/NNNN mentions this issue"
  1302  // and the change summary on GitHub when a new Gerrit change references a GitHub issue.
  1303  func (b *gopherbot) cl2issue(ctx context.Context) error {
  1304  	monthAgo := time.Now().Add(-30 * 24 * time.Hour)
  1305  	return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
  1306  		if gp.Server() != "go.googlesource.com" {
  1307  			return nil
  1308  		}
  1309  		return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
  1310  			if cl.Meta.Commit.AuthorTime.Before(monthAgo) {
  1311  				// If the CL was last updated over a
  1312  				// month ago, assume (as an
  1313  				// optimization) that gopherbot
  1314  				// already processed this issue.
  1315  				return nil
  1316  			}
  1317  			for _, ref := range cl.GitHubIssueRefs {
  1318  				if !gardenIssues(ref.Repo) {
  1319  					continue
  1320  				}
  1321  				gi := ref.Repo.Issue(ref.Number)
  1322  				if gi == nil || gi.NotExist || gi.PullRequest || gi.Locked || b.deletedIssues[githubIssue{ref.Repo.ID(), gi.Number}] {
  1323  					continue
  1324  				}
  1325  				hasComment := false
  1326  				substr := fmt.Sprintf("%d mentions this issue", cl.Number)
  1327  				gi.ForeachComment(func(c *maintner.GitHubComment) error {
  1328  					if strings.Contains(c.Body, substr) {
  1329  						hasComment = true
  1330  						return errStopIteration
  1331  					}
  1332  					return nil
  1333  				})
  1334  				if hasComment {
  1335  					continue
  1336  				}
  1337  				printIssue("cl2issue", ref.Repo.ID(), gi)
  1338  				msg := fmt.Sprintf("Change https://go.dev/cl/%d mentions this issue: `%s`", cl.Number, cl.Commit.Summary())
  1339  				if err := b.addGitHubComment(ctx, ref.Repo, gi.Number, msg); err != nil {
  1340  					return err
  1341  				}
  1342  			}
  1343  			return nil
  1344  		})
  1345  	})
  1346  }
  1347  
  1348  // canonicalLabelName returns "needsfix" for "needs-fix" or "NeedsFix"
  1349  // in prep for future label renaming.
  1350  func canonicalLabelName(s string) string {
  1351  	return strings.Replace(strings.ToLower(s), "-", "", -1)
  1352  }
  1353  
  1354  // If an issue has multiple "needs" labels, remove all but the most recent.
  1355  // These were originally called NeedsFix, NeedsDecision, and NeedsInvestigation,
  1356  // but are being renamed to "needs-foo".
  1357  func (b *gopherbot) updateNeeds(ctx context.Context) error {
  1358  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1359  		var numNeeds int
  1360  		if gi.Labels[needsDecisionID] != nil {
  1361  			numNeeds++
  1362  		}
  1363  		if gi.Labels[needsFixID] != nil {
  1364  			numNeeds++
  1365  		}
  1366  		if gi.Labels[needsInvestigationID] != nil {
  1367  			numNeeds++
  1368  		}
  1369  		if numNeeds <= 1 {
  1370  			return nil
  1371  		}
  1372  
  1373  		labels := map[string]int{} // lowercase no-hyphen "needsfix" -> position
  1374  		var pos, maxPos int
  1375  		gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error {
  1376  			var add bool
  1377  			switch e.Type {
  1378  			case "labeled":
  1379  				add = true
  1380  			case "unlabeled":
  1381  			default:
  1382  				return nil
  1383  			}
  1384  			if !strings.HasPrefix(e.Label, "Needs") && !strings.HasPrefix(e.Label, "needs-") {
  1385  				return nil
  1386  			}
  1387  			key := canonicalLabelName(e.Label)
  1388  			pos++
  1389  			if add {
  1390  				labels[key] = pos
  1391  				maxPos = pos
  1392  			} else {
  1393  				delete(labels, key)
  1394  			}
  1395  			return nil
  1396  		})
  1397  		if len(labels) <= 1 {
  1398  			return nil
  1399  		}
  1400  
  1401  		// Remove any label that's not the newest (added in
  1402  		// last position).
  1403  		for _, lab := range gi.Labels {
  1404  			key := canonicalLabelName(lab.Name)
  1405  			if !strings.HasPrefix(key, "needs") || labels[key] == maxPos {
  1406  				continue
  1407  			}
  1408  			printIssue("updateneeds", b.gorepo.ID(), gi)
  1409  			fmt.Printf("\t... removing label %q\n", lab.Name)
  1410  			if err := b.removeLabel(ctx, b.gorepo.ID(), gi, lab.Name); err != nil {
  1411  				return err
  1412  			}
  1413  		}
  1414  		return nil
  1415  	})
  1416  }
  1417  
  1418  // TODO: Improve this message. Some ideas:
  1419  //
  1420  // Provide more helpful info? Amend, don't add 2nd commit, link to a review guide?
  1421  // Make this a template? May want to provide more dynamic information in the future.
  1422  // Only show freeze message during freeze.
  1423  const (
  1424  	congratsSentence = `Congratulations on opening your first change. Thank you for your contribution!`
  1425  
  1426  	defaultCongratsMsg = congratsSentence + `
  1427  
  1428  Next steps:
  1429  A maintainer will review your change and provide feedback. See
  1430  https://go.dev/doc/contribute#review for more info and tips to get your
  1431  patch through code review.
  1432  
  1433  Most changes in the Go project go through a few rounds of revision. This can be
  1434  surprising to people new to the project. The careful, iterative review process
  1435  is our way of helping mentor contributors and ensuring that their contributions
  1436  have a lasting impact.`
  1437  
  1438  	// Not all x/ repos are subject to the freeze, and so shouldn't get the
  1439  	// warning about it. See isSubjectToFreeze for the complete list.
  1440  	freezeCongratsMsg = defaultCongratsMsg + `
  1441  
  1442  During May-July and Nov-Jan the Go project is in a code freeze, during which
  1443  little code gets reviewed or merged. If a reviewer responds with a comment like
  1444  R=go1.11 or adds a tag like "wait-release", it means that this CL will be
  1445  reviewed as part of the next development cycle. See https://go.dev/s/release
  1446  for more details.`
  1447  )
  1448  
  1449  // If messages containing any of the sentences in this array have been posted
  1450  // on a CL, don't post again. If you amend the message even slightly, please
  1451  // prepend the new message to this list, to avoid re-spamming people.
  1452  //
  1453  // The first message is the "current" message.
  1454  var oldCongratsMsgs = []string{
  1455  	congratsSentence,
  1456  	`It's your first ever CL! Congrats, and thanks for sending!`,
  1457  }
  1458  
  1459  // isSubjectToFreeze returns true if a repository is subject to the release
  1460  // freeze. x/ repos can be subject if they are vendored into golang/go.
  1461  func isSubjectToFreeze(repo string) bool {
  1462  	switch repo {
  1463  	case "go": // main repo
  1464  		return true
  1465  	case "crypto", "net", "sys", "text": // vendored x/ repos
  1466  		return true
  1467  	}
  1468  	return false
  1469  }
  1470  
  1471  // Don't want to congratulate people on CL's they submitted a year ago.
  1472  var congratsEpoch = time.Date(2017, 6, 17, 0, 0, 0, 0, time.UTC)
  1473  
  1474  func (b *gopherbot) congratulateNewContributors(ctx context.Context) error {
  1475  	cls := make(map[string]*maintner.GerritCL)
  1476  	b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
  1477  		if gp.Server() != "go.googlesource.com" {
  1478  			return nil
  1479  		}
  1480  		return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
  1481  			// CLs can be returned by maintner in any order. Note also that
  1482  			// Gerrit CL numbers are sparse (CL N does not guarantee that CL N-1
  1483  			// exists) and Gerrit issues CL's out of order - it may issue CL N,
  1484  			// then CL (N - 18), then CL (N - 40).
  1485  			if b.knownContributors == nil {
  1486  				b.knownContributors = make(map[string]bool)
  1487  			}
  1488  			if cl.Commit == nil {
  1489  				return nil
  1490  			}
  1491  			email := cl.Commit.Author.Email()
  1492  			if email == "" {
  1493  				email = cl.Commit.Author.Str
  1494  			}
  1495  			if b.knownContributors[email] {
  1496  				return nil
  1497  			}
  1498  			if cls[email] != nil {
  1499  				// this person has multiple CLs; not a new contributor.
  1500  				b.knownContributors[email] = true
  1501  				delete(cls, email)
  1502  				return nil
  1503  			}
  1504  			cls[email] = cl
  1505  			return nil
  1506  		})
  1507  	})
  1508  	for email, cl := range cls {
  1509  		// See golang.org/issue/23865
  1510  		if cl.Branch() == "refs/meta/config" {
  1511  			b.knownContributors[email] = true
  1512  			continue
  1513  		}
  1514  		if cl.Commit == nil || cl.Commit.CommitTime.Before(congratsEpoch) {
  1515  			b.knownContributors[email] = true
  1516  			continue
  1517  		}
  1518  		if cl.Status == "merged" {
  1519  			b.knownContributors[email] = true
  1520  			continue
  1521  		}
  1522  		foundMessage := false
  1523  		congratulatoryMessage := defaultCongratsMsg
  1524  		if isSubjectToFreeze(cl.Project.Project()) {
  1525  			congratulatoryMessage = freezeCongratsMsg
  1526  		}
  1527  		for i := range cl.Messages {
  1528  			// TODO: once gopherbot starts posting these messages and we
  1529  			// have the author's name for Gopherbot, check the author name
  1530  			// matches as well.
  1531  			for j := range oldCongratsMsgs {
  1532  				// Message looks something like "Patch set X:\n\n(our text)"
  1533  				if strings.Contains(cl.Messages[i].Message, oldCongratsMsgs[j]) {
  1534  					foundMessage = true
  1535  					break
  1536  				}
  1537  			}
  1538  			if foundMessage {
  1539  				break
  1540  			}
  1541  		}
  1542  
  1543  		if foundMessage {
  1544  			b.knownContributors[email] = true
  1545  			continue
  1546  		}
  1547  		// Don't add all of the old congratulatory messages here, since we've
  1548  		// already checked for them above.
  1549  		opts := &gerritCommentOpts{
  1550  			OldPhrases: []string{congratulatoryMessage},
  1551  		}
  1552  		err := b.addGerritComment(ctx, cl.ChangeID(), congratulatoryMessage, opts)
  1553  		if err != nil {
  1554  			return fmt.Errorf("could not add comment to golang.org/cl/%d: %v", cl.Number, err)
  1555  		}
  1556  		b.knownContributors[email] = true
  1557  	}
  1558  	return nil
  1559  }
  1560  
  1561  // unwaitCLs removes wait-* hashtags from CLs.
  1562  func (b *gopherbot) unwaitCLs(ctx context.Context) error {
  1563  	return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
  1564  		if gp.Server() != "go.googlesource.com" {
  1565  			return nil
  1566  		}
  1567  		return gp.ForeachOpenCL(func(cl *maintner.GerritCL) error {
  1568  			tags := cl.Meta.Hashtags()
  1569  			if tags.Len() == 0 {
  1570  				return nil
  1571  			}
  1572  			// If the CL is tagged "wait-author", remove
  1573  			// that tag if the author has replied since
  1574  			// the last time the "wait-author" tag was
  1575  			// added.
  1576  			if tags.Contains("wait-author") {
  1577  				// Figure out the last index at which "wait-author" was added.
  1578  				waitAuthorIndex := -1
  1579  				for i := len(cl.Metas) - 1; i >= 0; i-- {
  1580  					if cl.Metas[i].HashtagsAdded().Contains("wait-author") {
  1581  						waitAuthorIndex = i
  1582  						break
  1583  					}
  1584  				}
  1585  
  1586  				// Find out whether the author has replied since.
  1587  				authorEmail := cl.Metas[0].Commit.Author.Email() // Equivalent to "{{cl.OwnerID}}@62eb7196-b449-3ce5-99f1-c037f21e1705".
  1588  				hasReplied := false
  1589  				for _, m := range cl.Metas[waitAuthorIndex+1:] {
  1590  					if m.Commit.Author.Email() == authorEmail {
  1591  						hasReplied = true
  1592  						break
  1593  					}
  1594  				}
  1595  				if hasReplied {
  1596  					log.Printf("https://go.dev/cl/%d -- remove wait-author; reply from %s", cl.Number, cl.Owner())
  1597  					err := b.onLatestCL(ctx, cl, func() error {
  1598  						if *dryRun {
  1599  							log.Printf("[dry run] would remove hashtag 'wait-author' from CL %d", cl.Number)
  1600  							return nil
  1601  						}
  1602  						_, err := b.gerrit.RemoveHashtags(ctx, fmt.Sprint(cl.Number), "wait-author")
  1603  						if err != nil {
  1604  							log.Printf("https://go.dev/cl/%d: error removing wait-author: %v", cl.Number, err)
  1605  							return err
  1606  						}
  1607  						log.Printf("https://go.dev/cl/%d: removed wait-author", cl.Number)
  1608  						return nil
  1609  					})
  1610  					if err != nil {
  1611  						return err
  1612  					}
  1613  				}
  1614  			}
  1615  			return nil
  1616  		})
  1617  	})
  1618  }
  1619  
  1620  // onLatestCL checks whether cl's metadata is up to date with Gerrit's
  1621  // upstream data and, if so, returns f(). If it's out of date, it does
  1622  // nothing more and returns nil.
  1623  func (b *gopherbot) onLatestCL(ctx context.Context, cl *maintner.GerritCL, f func() error) error {
  1624  	ci, err := b.gerrit.GetChangeDetail(ctx, fmt.Sprint(cl.Number), gerrit.QueryChangesOpt{Fields: []string{"MESSAGES"}})
  1625  	if err != nil {
  1626  		return err
  1627  	}
  1628  	if len(ci.Messages) == 0 {
  1629  		log.Printf("onLatestCL: CL %d has no messages. Odd. Ignoring.", cl.Number)
  1630  		return nil
  1631  	}
  1632  	latestGerritID := ci.Messages[len(ci.Messages)-1].ID
  1633  	// Check all metas and not just the latest, because there are some meta commits
  1634  	// that don't have a corresponding message in the Gerrit REST API response.
  1635  	for i := len(cl.Metas) - 1; i >= 0; i-- {
  1636  		metaHash := cl.Metas[i].Commit.Hash.String()
  1637  		if metaHash == latestGerritID {
  1638  			// latestGerritID is contained by maintner metadata for this CL, so run f().
  1639  			return f()
  1640  		}
  1641  	}
  1642  	log.Printf("onLatestCL: maintner metadata for CL %d is behind; skipping action for now.", cl.Number)
  1643  	return nil
  1644  }
  1645  
  1646  // fetchReleases returns the two most recent major Go 1.x releases, and
  1647  // the next upcoming release, sorted and formatted like []string{"1.9", "1.10", "1.11"}.
  1648  // It also returns the next minor release for each major release,
  1649  // like map[string]string{"1.9": "1.9.7", "1.10": "1.10.4", "1.11": "1.11.1"}.
  1650  //
  1651  // The data returned is fetched from Maintner Service occasionally
  1652  // and cached for some time.
  1653  func (b *gopherbot) fetchReleases(ctx context.Context) (major []string, nextMinor map[string]string, _ error) {
  1654  	b.releases.Lock()
  1655  	defer b.releases.Unlock()
  1656  
  1657  	if expiry := b.releases.lastUpdate.Add(10 * time.Minute); time.Now().Before(expiry) {
  1658  		return b.releases.major, b.releases.nextMinor, nil
  1659  	}
  1660  
  1661  	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
  1662  	defer cancel()
  1663  	resp, err := b.mc.ListGoReleases(ctx, &apipb.ListGoReleasesRequest{})
  1664  	if err != nil {
  1665  		return nil, nil, err
  1666  	}
  1667  	rs := resp.Releases // Supported Go releases, sorted with latest first.
  1668  
  1669  	nextMinor = make(map[string]string)
  1670  	for i := len(rs) - 1; i >= 0; i-- {
  1671  		x, y, z := rs[i].Major, rs[i].Minor, rs[i].Patch
  1672  		major = append(major, fmt.Sprintf("%d.%d", x, y))
  1673  		nextMinor[fmt.Sprintf("%d.%d", x, y)] = fmt.Sprintf("%d.%d.%d", x, y, z+1)
  1674  	}
  1675  	// Include the next release in the list of major releases.
  1676  	if len(rs) > 0 {
  1677  		// Assume the next major release after Go X.Y is Go X.(Y+1). This is true more often than not.
  1678  		nextX, nextY := rs[0].Major, rs[0].Minor+1
  1679  		major = append(major, fmt.Sprintf("%d.%d", nextX, nextY))
  1680  		nextMinor[fmt.Sprintf("%d.%d", nextX, nextY)] = fmt.Sprintf("%d.%d.1", nextX, nextY)
  1681  	}
  1682  
  1683  	b.releases.major = major
  1684  	b.releases.nextMinor = nextMinor
  1685  	b.releases.lastUpdate = time.Now()
  1686  
  1687  	return major, nextMinor, nil
  1688  }
  1689  
  1690  // openCherryPickIssues opens CherryPickCandidate issues for backport when
  1691  // asked on the main issue.
  1692  func (b *gopherbot) openCherryPickIssues(ctx context.Context) error {
  1693  	return b.foreachIssue(b.gorepo, open|closed|includePRs, func(gi *maintner.GitHubIssue) error {
  1694  		if gi.HasLabel("CherryPickApproved") && gi.HasLabel("CherryPickCandidate") {
  1695  			if err := b.removeLabel(ctx, b.gorepo.ID(), gi, "CherryPickCandidate"); err != nil {
  1696  				return err
  1697  			}
  1698  		}
  1699  		if gi.Locked || gi.PullRequest {
  1700  			return nil
  1701  		}
  1702  		var backportComment *maintner.GitHubComment
  1703  		if err := gi.ForeachComment(func(c *maintner.GitHubComment) error {
  1704  			if strings.HasPrefix(c.Body, "Backport issue(s) opened") {
  1705  				backportComment = nil
  1706  				return errStopIteration
  1707  			}
  1708  			body := strings.ToLower(c.Body)
  1709  			if strings.Contains(body, "@gopherbot") &&
  1710  				strings.Contains(body, "please") &&
  1711  				strings.Contains(body, "backport") {
  1712  				backportComment = c
  1713  			}
  1714  			return nil
  1715  		}); err != nil && err != errStopIteration {
  1716  			return err
  1717  		}
  1718  		if backportComment == nil {
  1719  			return nil
  1720  		}
  1721  
  1722  		// Figure out releases to open backport issues for.
  1723  		var selectedReleases []string
  1724  		majorReleases, _, err := b.fetchReleases(ctx)
  1725  		if err != nil {
  1726  			return err
  1727  		}
  1728  		for _, r := range majorReleases {
  1729  			if strings.Contains(backportComment.Body, r) {
  1730  				selectedReleases = append(selectedReleases, r)
  1731  			}
  1732  		}
  1733  		if len(selectedReleases) == 0 {
  1734  			// Only backport to major releases unless explicitly
  1735  			// asked to backport to the upcoming release.
  1736  			selectedReleases = majorReleases[:len(majorReleases)-1]
  1737  		}
  1738  
  1739  		// Figure out extra labels to include from the main issue.
  1740  		// Only copy a subset that's relevant to backport issue management.
  1741  		var extraLabels []string
  1742  		for _, l := range [...]string{
  1743  			"Security",
  1744  			"GoCommand",
  1745  			"Testing",
  1746  		} {
  1747  			if gi.HasLabel(l) {
  1748  				extraLabels = append(extraLabels, l)
  1749  			}
  1750  		}
  1751  
  1752  		// Open backport issues.
  1753  		var openedIssues []string
  1754  		for _, rel := range selectedReleases {
  1755  			printIssue("open-backport-issue-"+rel, b.gorepo.ID(), gi)
  1756  			id, err := b.createGitHubIssue(ctx,
  1757  				fmt.Sprintf("%s [%s backport]", gi.Title, rel),
  1758  				fmt.Sprintf("@%s requested issue #%d to be considered for backport to the next %s minor release.\n\n%s\n",
  1759  					backportComment.User.Login, gi.Number, rel, blockqoute(backportComment.Body)),
  1760  				append([]string{"CherryPickCandidate"}, extraLabels...), backportComment.Created)
  1761  			if err != nil {
  1762  				return err
  1763  			}
  1764  			openedIssues = append(openedIssues, fmt.Sprintf("#%d (for %s)", id, rel))
  1765  		}
  1766  		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://go.dev/wiki/MinorReleases.", strings.Join(openedIssues, ", ")))
  1767  	})
  1768  }
  1769  
  1770  // setMinorMilestones applies the next minor release milestone
  1771  // to issues with [1.X backport] in the title.
  1772  func (b *gopherbot) setMinorMilestones(ctx context.Context) error {
  1773  	majorReleases, nextMinor, err := b.fetchReleases(ctx)
  1774  	if err != nil {
  1775  		return err
  1776  	}
  1777  	return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1778  		if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") {
  1779  			return nil
  1780  		}
  1781  		var majorRel string
  1782  		for _, r := range majorReleases {
  1783  			if strings.Contains(gi.Title, "backport") && strings.HasSuffix(gi.Title, "["+r+" backport]") {
  1784  				majorRel = r
  1785  			}
  1786  		}
  1787  		if majorRel == "" {
  1788  			return nil
  1789  		}
  1790  		if _, ok := nextMinor[majorRel]; !ok {
  1791  			return fmt.Errorf("internal error: fetchReleases returned majorReleases=%q nextMinor=%q, and nextMinor doesn't have %q", majorReleases, nextMinor, majorRel)
  1792  		}
  1793  		lowerTitle := "go" + nextMinor[majorRel]
  1794  		var nextMinorMilestone milestone
  1795  		if b.gorepo.ForeachMilestone(func(m *maintner.GitHubMilestone) error {
  1796  			if m.Closed || strings.ToLower(m.Title) != lowerTitle {
  1797  				return nil
  1798  			}
  1799  			nextMinorMilestone = milestone{
  1800  				Number: int(m.Number),
  1801  				Name:   m.Title,
  1802  			}
  1803  			return errStopIteration
  1804  		}); nextMinorMilestone == (milestone{}) {
  1805  			// Fail silently, the milestone might not exist yet.
  1806  			log.Printf("Failed to apply minor release milestone to issue %d", gi.Number)
  1807  			return nil
  1808  		}
  1809  		return b.setMilestone(ctx, b.gorepo.ID(), gi, nextMinorMilestone)
  1810  	})
  1811  }
  1812  
  1813  // closeCherryPickIssues closes cherry-pick issues when CLs are merged to
  1814  // release branches, as GitHub only does that on merge to master.
  1815  func (b *gopherbot) closeCherryPickIssues(ctx context.Context) error {
  1816  	cherryPickIssues := make(map[int32]*maintner.GitHubIssue) // by GitHub Issue Number
  1817  	b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
  1818  		if gi.Milestone.IsNone() || gi.HasEvent("reopened") {
  1819  			return nil
  1820  		}
  1821  		if !strings.HasPrefix(gi.Milestone.Title, "Go") {
  1822  			return nil
  1823  		}
  1824  		cherryPickIssues[gi.Number] = gi
  1825  		return nil
  1826  	})
  1827  	monthAgo := time.Now().Add(-30 * 24 * time.Hour)
  1828  	return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
  1829  		if gp.Server() != "go.googlesource.com" {
  1830  			return nil
  1831  		}
  1832  		return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
  1833  			if cl.Commit.CommitTime.Before(monthAgo) {
  1834  				// If the CL was last updated over a month ago, assume (as an
  1835  				// optimization) that gopherbot already processed this CL.
  1836  				return nil
  1837  			}
  1838  			if cl.Status != "merged" || cl.Private || !strings.HasPrefix(cl.Branch(), "release-branch.") {
  1839  				return nil
  1840  			}
  1841  			clBranchVersion := cl.Branch()[len("release-branch."):] // "go1.11" or "go1.12".
  1842  			for _, ref := range cl.GitHubIssueRefs {
  1843  				if ref.Repo != b.gorepo {
  1844  					continue
  1845  				}
  1846  				gi, ok := cherryPickIssues[ref.Number]
  1847  				if !ok {
  1848  					continue
  1849  				}
  1850  				if !strutil.HasPrefixFold(gi.Milestone.Title, clBranchVersion) {
  1851  					// This issue's milestone (e.g., "Go1.11.6", "Go1.12", "Go1.12.1", etc.)
  1852  					// doesn't match the CL branch goX.Y version, so skip it.
  1853  					continue
  1854  				}
  1855  				printIssue("close-cherry-pick", ref.Repo.ID(), gi)
  1856  				if err := b.addGitHubComment(ctx, ref.Repo, gi.Number, fmt.Sprintf(
  1857  					"Closed by merging %s to %s.", cl.Commit.Hash, cl.Branch())); err != nil {
  1858  					return err
  1859  				}
  1860  				return b.closeGitHubIssue(ctx, ref.Repo.ID(), gi.Number, completed)
  1861  			}
  1862  			return nil
  1863  		})
  1864  	})
  1865  }
  1866  
  1867  type labelCommand struct {
  1868  	action  string    // "add" or "remove"
  1869  	label   string    // the label name
  1870  	created time.Time // creation time of the comment containing the command
  1871  	noop    bool      // whether to apply the command or not
  1872  }
  1873  
  1874  // applyLabelsFromComments looks within open GitHub issues for commands to add or
  1875  // remove labels. Anyone can use the /label <label> or /unlabel <label> commands.
  1876  func (b *gopherbot) applyLabelsFromComments(ctx context.Context) error {
  1877  	return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error {
  1878  		if !gardenIssues(repo) {
  1879  			return nil
  1880  		}
  1881  
  1882  		allLabels := make(map[string]string) // lowercase label name -> proper casing
  1883  		repo.ForeachLabel(func(gl *maintner.GitHubLabel) error {
  1884  			allLabels[strings.ToLower(gl.Name)] = gl.Name
  1885  			return nil
  1886  		})
  1887  
  1888  		return b.foreachIssue(repo, open|includePRs, func(gi *maintner.GitHubIssue) error {
  1889  			var cmds []labelCommand
  1890  
  1891  			cmds = append(cmds, labelCommandsFromBody(gi.Body, gi.Created)...)
  1892  			gi.ForeachComment(func(gc *maintner.GitHubComment) error {
  1893  				cmds = append(cmds, labelCommandsFromBody(gc.Body, gc.Created)...)
  1894  				return nil
  1895  			})
  1896  
  1897  			for i, c := range cmds {
  1898  				// Does the label even exist? If so, use the proper capitalization.
  1899  				// If it doesn't exist, the command is a no-op.
  1900  				if l, ok := allLabels[c.label]; ok {
  1901  					cmds[i].label = l
  1902  				} else {
  1903  					cmds[i].noop = true
  1904  					continue
  1905  				}
  1906  
  1907  				// If any action has been taken on the label since the comment containing
  1908  				// the command to add or remove it, then it should be a no-op.
  1909  				gi.ForeachEvent(func(ge *maintner.GitHubIssueEvent) error {
  1910  					if (ge.Type == "unlabeled" || ge.Type == "labeled") &&
  1911  						strings.ToLower(ge.Label) == c.label &&
  1912  						ge.Created.After(c.created) {
  1913  						cmds[i].noop = true
  1914  						return errStopIteration
  1915  					}
  1916  					return nil
  1917  				})
  1918  			}
  1919  
  1920  			toAdd, toRemove := mutationsFromCommands(cmds)
  1921  			if err := b.addLabels(ctx, repo.ID(), gi, toAdd); err != nil {
  1922  				log.Printf("Unable to add labels (%v) to issue %d: %v", toAdd, gi.Number, err)
  1923  			}
  1924  			if err := b.removeLabels(ctx, repo.ID(), gi, toRemove); err != nil {
  1925  				log.Printf("Unable to remove labels (%v) from issue %d: %v", toRemove, gi.Number, err)
  1926  			}
  1927  
  1928  			return nil
  1929  		})
  1930  	})
  1931  }
  1932  
  1933  // labelCommandsFromBody returns a slice of commands inferred by the given body text.
  1934  // The format of commands is:
  1935  // @gopherbot[,] [please] [add|remove] <label>[{,|;} label... and remove <label>...]
  1936  // Omission of add or remove will default to adding a label.
  1937  func labelCommandsFromBody(body string, created time.Time) []labelCommand {
  1938  	if !strutil.ContainsFold(body, "@gopherbot") {
  1939  		return nil
  1940  	}
  1941  	var cmds []labelCommand
  1942  	lines := strings.Split(body, "\n")
  1943  	for _, l := range lines {
  1944  		if !strutil.ContainsFold(l, "@gopherbot") {
  1945  			continue
  1946  		}
  1947  		l = strings.ToLower(l)
  1948  		scanner := bufio.NewScanner(strings.NewReader(l))
  1949  		scanner.Split(bufio.ScanWords)
  1950  		var (
  1951  			add      strings.Builder
  1952  			remove   strings.Builder
  1953  			inRemove bool
  1954  		)
  1955  		for scanner.Scan() {
  1956  			switch scanner.Text() {
  1957  			case "@gopherbot", "@gopherbot,", "@gopherbot:", "please", "and", "label", "labels":
  1958  				continue
  1959  			case "add":
  1960  				inRemove = false
  1961  				continue
  1962  			case "remove", "unlabel":
  1963  				inRemove = true
  1964  				continue
  1965  			}
  1966  
  1967  			if inRemove {
  1968  				remove.WriteString(scanner.Text())
  1969  				remove.WriteString(" ") // preserve whitespace within labels
  1970  			} else {
  1971  				add.WriteString(scanner.Text())
  1972  				add.WriteString(" ") // preserve whitespace within labels
  1973  			}
  1974  		}
  1975  		if add.Len() > 0 {
  1976  			cmds = append(cmds, labelCommands(add.String(), "add", created)...)
  1977  		}
  1978  		if remove.Len() > 0 {
  1979  			cmds = append(cmds, labelCommands(remove.String(), "remove", created)...)
  1980  		}
  1981  	}
  1982  	return cmds
  1983  }
  1984  
  1985  // labelCommands returns a slice of commands for the given action and string of
  1986  // text following commands like @gopherbot add/remove.
  1987  func labelCommands(s, action string, created time.Time) []labelCommand {
  1988  	var cmds []labelCommand
  1989  	f := func(c rune) bool {
  1990  		return c != '-' && !unicode.IsLetter(c) && !unicode.IsNumber(c) && !unicode.IsSpace(c)
  1991  	}
  1992  	for _, label := range strings.FieldsFunc(s, f) {
  1993  		label = strings.TrimSpace(label)
  1994  		if label == "" {
  1995  			continue
  1996  		}
  1997  		cmds = append(cmds, labelCommand{action: action, label: label, created: created})
  1998  	}
  1999  	return cmds
  2000  }
  2001  
  2002  // mutationsFromCommands returns two sets of labels to add and remove based on
  2003  // the given cmds.
  2004  func mutationsFromCommands(cmds []labelCommand) (add, remove []string) {
  2005  	// Split the labels into what to add and what to remove.
  2006  	// Account for two opposing commands that have yet to be applied canceling
  2007  	// each other out.
  2008  	var (
  2009  		toAdd    map[string]bool
  2010  		toRemove map[string]bool
  2011  	)
  2012  	for _, c := range cmds {
  2013  		if c.noop {
  2014  			continue
  2015  		}
  2016  		switch c.action {
  2017  		case "add":
  2018  			if toRemove[c.label] {
  2019  				delete(toRemove, c.label)
  2020  				continue
  2021  			}
  2022  			if toAdd == nil {
  2023  				toAdd = make(map[string]bool)
  2024  			}
  2025  			toAdd[c.label] = true
  2026  		case "remove":
  2027  			if toAdd[c.label] {
  2028  				delete(toAdd, c.label)
  2029  				continue
  2030  			}
  2031  			if toRemove == nil {
  2032  				toRemove = make(map[string]bool)
  2033  			}
  2034  			toRemove[c.label] = true
  2035  		default:
  2036  			log.Printf("Invalid label action type: %q", c.action)
  2037  		}
  2038  	}
  2039  
  2040  	for l := range toAdd {
  2041  		if toAdd[l] && !labelChangeDisallowed(l, "add") {
  2042  			add = append(add, l)
  2043  		}
  2044  	}
  2045  
  2046  	for l := range toRemove {
  2047  		if toRemove[l] && !labelChangeDisallowed(l, "remove") {
  2048  			remove = append(remove, l)
  2049  		}
  2050  	}
  2051  	return add, remove
  2052  }
  2053  
  2054  // labelChangeDisallowed reports whether an action on the given label is
  2055  // forbidden via gopherbot.
  2056  func labelChangeDisallowed(label, action string) bool {
  2057  	if action == "remove" && label == "Security" {
  2058  		return true
  2059  	}
  2060  	for _, prefix := range []string{
  2061  		"CherryPick",
  2062  		"cla:",
  2063  		"Proposal-",
  2064  	} {
  2065  		if strings.HasPrefix(label, prefix) {
  2066  			return true
  2067  		}
  2068  	}
  2069  	return false
  2070  }
  2071  
  2072  // assignReviewersOptOut lists contributors who have opted out from
  2073  // having reviewers automatically added to their CLs.
  2074  var assignReviewersOptOut = map[string]bool{
  2075  	"mdempsky@google.com": true,
  2076  }
  2077  
  2078  // assignReviewersToCLs looks for CLs with no humans in the reviewer or CC fields
  2079  // that have been open for a short amount of time (enough of a signal that the
  2080  // author does not intend to add anyone to the review), then assigns reviewers/CCs
  2081  // using the go.dev/s/owners API.
  2082  func (b *gopherbot) assignReviewersToCLs(ctx context.Context) error {
  2083  	const tagNoOwners = "no-owners"
  2084  	b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
  2085  		if gp.Project() == "scratch" || gp.Server() != "go.googlesource.com" {
  2086  			return nil
  2087  		}
  2088  		gp.ForeachOpenCL(func(cl *maintner.GerritCL) error {
  2089  			if cl.Private || cl.WorkInProgress() || time.Since(cl.Created) < 10*time.Minute {
  2090  				return nil
  2091  			}
  2092  			if assignReviewersOptOut[cl.Owner().Email()] {
  2093  				return nil
  2094  			}
  2095  
  2096  			// Don't auto-assign reviewers to CLs on shared branches;
  2097  			// the presumption is that developers there will know which
  2098  			// reviewers to assign.
  2099  			if strings.HasPrefix(cl.Branch(), "dev.") {
  2100  				return nil
  2101  			}
  2102  
  2103  			tags := cl.Meta.Hashtags()
  2104  			if tags.Contains(tagNoOwners) {
  2105  				return nil
  2106  			}
  2107  
  2108  			gc := gerritChange{gp.Project(), cl.Number}
  2109  			if b.deletedChanges[gc] {
  2110  				return nil
  2111  			}
  2112  			if strutil.ContainsFold(cl.Commit.Msg, "do not submit") || strutil.ContainsFold(cl.Commit.Msg, "do not review") {
  2113  				return nil
  2114  			}
  2115  
  2116  			currentReviewers, ok := b.humanReviewersOnChange(ctx, gc, cl)
  2117  			if ok {
  2118  				return nil
  2119  			}
  2120  			log.Printf("humanReviewersOnChange reported insufficient reviewers or CC on CL %d, attempting to add some", cl.Number)
  2121  
  2122  			changeURL := fmt.Sprintf("https://go-review.googlesource.com/c/%s/+/%d", gp.Project(), cl.Number)
  2123  			files, err := b.gerrit.ListFiles(ctx, gc.ID(), cl.Commit.Hash.String())
  2124  			if err != nil {
  2125  				log.Printf("Could not get change %+v: %v", gc, err)
  2126  				if httpErr, ok := err.(*gerrit.HTTPError); ok && httpErr.Res.StatusCode == http.StatusNotFound {
  2127  					b.deletedChanges[gc] = true
  2128  				}
  2129  				return nil
  2130  			}
  2131  
  2132  			var paths []string
  2133  			for f := range files {
  2134  				if f == "/COMMIT_MSG" {
  2135  					continue
  2136  				}
  2137  				paths = append(paths, gp.Project()+"/"+f)
  2138  			}
  2139  
  2140  			entries, err := getCodeOwners(ctx, paths)
  2141  			if err != nil {
  2142  				log.Printf("Could not get owners for change %s: %v", changeURL, err)
  2143  				return nil
  2144  			}
  2145  
  2146  			// Remove owners that can't be reviewers.
  2147  			entries = filterGerritOwners(entries)
  2148  
  2149  			authorEmail := cl.Commit.Author.Email()
  2150  			merged := mergeOwnersEntries(entries, authorEmail)
  2151  			if len(merged.Primary) == 0 && len(merged.Secondary) == 0 {
  2152  				// No owners found for the change. Add the #no-owners tag.
  2153  				log.Printf("Adding no-owners tag to change %s...", changeURL)
  2154  				if *dryRun {
  2155  					return nil
  2156  				}
  2157  				if _, err := b.gerrit.AddHashtags(ctx, gc.ID(), tagNoOwners); err != nil {
  2158  					log.Printf("Could not add hashtag to change %q: %v", gc.ID(), err)
  2159  					return nil
  2160  				}
  2161  				return nil
  2162  			}
  2163  
  2164  			// Assign reviewers.
  2165  			var review gerrit.ReviewInput
  2166  			for _, owner := range merged.Primary {
  2167  				review.Reviewers = append(review.Reviewers, gerrit.ReviewerInput{Reviewer: owner.GerritEmail})
  2168  			}
  2169  			for _, owner := range merged.Secondary {
  2170  				review.Reviewers = append(review.Reviewers, gerrit.ReviewerInput{Reviewer: owner.GerritEmail, State: "CC"})
  2171  			}
  2172  
  2173  			// If the reviewers that would be set are the same as the existing
  2174  			// reviewers (minus the bots), there is no work to be done.
  2175  			if sameReviewers(currentReviewers, review) {
  2176  				log.Printf("Setting review %+v on %s would have no effect, continuing", review, changeURL)
  2177  				return nil
  2178  			}
  2179  			if *dryRun {
  2180  				log.Printf("[dry run] Would set review on %s: %+v", changeURL, review)
  2181  				return nil
  2182  			}
  2183  			log.Printf("Setting review on %s: %+v", changeURL, review)
  2184  			if err := b.gerrit.SetReview(ctx, gc.ID(), "current", review); err != nil {
  2185  				log.Printf("Could not set review for change %q: %v", gc.ID(), err)
  2186  				return nil
  2187  			}
  2188  			return nil
  2189  		})
  2190  		return nil
  2191  	})
  2192  	return nil
  2193  }
  2194  
  2195  func sameReviewers(reviewers []string, review gerrit.ReviewInput) bool {
  2196  	if len(reviewers) != len(review.Reviewers) {
  2197  		return false
  2198  	}
  2199  	sort.Strings(reviewers)
  2200  	var people []*gophers.Person
  2201  	for _, id := range reviewers {
  2202  		p := gophers.GetPerson(fmt.Sprintf("%s%s", id, gerritInstanceID))
  2203  		// If an existing reviewer is not known to us, we have no way of
  2204  		// checking if these reviewer lists are identical.
  2205  		if p == nil {
  2206  			return false
  2207  		}
  2208  		people = append(people, p)
  2209  	}
  2210  	sort.Slice(review.Reviewers, func(i, j int) bool {
  2211  		return review.Reviewers[i].Reviewer < review.Reviewers[j].Reviewer
  2212  	})
  2213  	// Check if any of the person's emails match the expected reviewer email.
  2214  outer:
  2215  	for i, p := range people {
  2216  		reviewerEmail := review.Reviewers[i].Reviewer
  2217  		for _, email := range p.Emails {
  2218  			if email == reviewerEmail {
  2219  				continue outer
  2220  			}
  2221  		}
  2222  		return false
  2223  	}
  2224  	return true
  2225  }
  2226  
  2227  // abandonScratchReviews abandons Gerrit CLs in the "scratch" project if they've been open for over a week.
  2228  func (b *gopherbot) abandonScratchReviews(ctx context.Context) error {
  2229  	tooOld := time.Now().Add(-24 * time.Hour * 7)
  2230  	return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
  2231  		if gp.Project() != "scratch" || gp.Server() != "go.googlesource.com" {
  2232  			return nil
  2233  		}
  2234  		return gp.ForeachOpenCL(func(cl *maintner.GerritCL) error {
  2235  			if b.deletedChanges[gerritChange{gp.Project(), cl.Number}] || !cl.Meta.Commit.CommitTime.Before(tooOld) {
  2236  				return nil
  2237  			}
  2238  			if *dryRun {
  2239  				log.Printf("[dry-run] would've closed scratch CL https://go.dev/cl/%d ...", cl.Number)
  2240  				return nil
  2241  			}
  2242  			log.Printf("closing scratch CL https://go.dev/cl/%d ...", cl.Number)
  2243  			err := b.gerrit.AbandonChange(ctx, fmt.Sprint(cl.Number), "Auto-abandoning old scratch review.")
  2244  			if err != nil && strings.Contains(err.Error(), "404 Not Found") {
  2245  				return nil
  2246  			}
  2247  			return err
  2248  		})
  2249  	})
  2250  }
  2251  
  2252  func (b *gopherbot) whoNeedsAccess(ctx context.Context) error {
  2253  	// We only run this task if it was explicitly requested via
  2254  	// the --only-run flag.
  2255  	if *onlyRun == "" {
  2256  		return nil
  2257  	}
  2258  	level := map[int64]int{} // gerrit id -> 1 for try, 2 for submit
  2259  	ais, err := b.gerrit.GetGroupMembers(ctx, "may-start-trybots")
  2260  	if err != nil {
  2261  		return err
  2262  	}
  2263  	for _, ai := range ais {
  2264  		level[ai.NumericID] = 1
  2265  	}
  2266  	ais, err = b.gerrit.GetGroupMembers(ctx, "approvers")
  2267  	if err != nil {
  2268  		return err
  2269  	}
  2270  	for _, ai := range ais {
  2271  		level[ai.NumericID] = 2
  2272  	}
  2273  
  2274  	quarterAgo := time.Now().Add(-90 * 24 * time.Hour)
  2275  	missing := map[string]int{} // "only level N: $WHO" -> number of CLs for that user
  2276  	err = b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
  2277  		if gp.Server() != "go.googlesource.com" {
  2278  			return nil
  2279  		}
  2280  		return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error {
  2281  			if cl.Meta.Commit.AuthorTime.Before(quarterAgo) {
  2282  				return nil
  2283  			}
  2284  			authorID := int64(cl.OwnerID())
  2285  			if authorID == -1 {
  2286  				return nil
  2287  			}
  2288  			if level[authorID] == 2 {
  2289  				return nil
  2290  			}
  2291  			missing[fmt.Sprintf("only level %d: %v", level[authorID], cl.Commit.Author)]++
  2292  			return nil
  2293  		})
  2294  	})
  2295  	if err != nil {
  2296  		return err
  2297  	}
  2298  	var people []string
  2299  	for who := range missing {
  2300  		people = append(people, who)
  2301  	}
  2302  	sort.Slice(people, func(i, j int) bool { return missing[people[j]] < missing[people[i]] })
  2303  	fmt.Println("Number of CLs created in last 90 days | Access (0=none, 1=trybots) | Author")
  2304  	for i, who := range people {
  2305  		num := missing[who]
  2306  		if num < 3 {
  2307  			break
  2308  		}
  2309  		fmt.Printf("%3d: %s\n", num, who)
  2310  		if i == 20 {
  2311  			break
  2312  		}
  2313  	}
  2314  	return nil
  2315  }
  2316  
  2317  // humanReviewersOnChange reports whether there is (or was) a sufficient
  2318  // number of human reviewers in the given change, and returns the IDs of
  2319  // the current human reviewers. It includes reviewers in REVIEWER and CC
  2320  // states.
  2321  //
  2322  // The given gerritChange works as a key for deletedChanges.
  2323  func (b *gopherbot) humanReviewersOnChange(ctx context.Context, change gerritChange, cl *maintner.GerritCL) ([]string, bool) {
  2324  	// The CL's owner will be GerritBot if it is imported from a PR.
  2325  	// In that case, if the CL's author has a Gerrit account, they will be
  2326  	// added as a reviewer (go.dev/issue/30265). Otherwise, no reviewers
  2327  	// will be added. Work around this by requiring 2 human reviewers on PRs.
  2328  	ownerID := strconv.Itoa(cl.OwnerID())
  2329  	isPR := ownerID == gerritbotGerritID
  2330  	minHumans := 1
  2331  	if isPR {
  2332  		minHumans = 2
  2333  	}
  2334  	reject := []string{gobotGerritID, gerritbotGerritID, kokoroGerritID, goLUCIGerritID, triciumGerritID, ownerID}
  2335  	ownerOrRobot := func(gerritID string) bool {
  2336  		for _, r := range reject {
  2337  			if gerritID == r {
  2338  				return true
  2339  			}
  2340  		}
  2341  		return false
  2342  	}
  2343  
  2344  	ids := slices.DeleteFunc(reviewersInMetas(cl.Metas), ownerOrRobot)
  2345  	if len(ids) >= minHumans {
  2346  		return ids, true
  2347  	}
  2348  
  2349  	reviewers, err := b.gerrit.ListReviewers(ctx, change.ID())
  2350  	if err != nil {
  2351  		if httpErr, ok := err.(*gerrit.HTTPError); ok && httpErr.Res.StatusCode == http.StatusNotFound {
  2352  			b.deletedChanges[change] = true
  2353  		}
  2354  		log.Printf("Could not list reviewers on change %q: %v", change.ID(), err)
  2355  		return nil, true
  2356  	}
  2357  	ids = []string{}
  2358  	for _, r := range reviewers {
  2359  		id := strconv.FormatInt(r.NumericID, 10)
  2360  		if hasServiceUserTag(r.AccountInfo) || ownerOrRobot(id) {
  2361  			// Skip bots and owner.
  2362  			continue
  2363  		}
  2364  		ids = append(ids, id)
  2365  	}
  2366  	return ids, len(ids) >= minHumans
  2367  }
  2368  
  2369  // hasServiceUserTag reports whether the account has a SERVICE_USER tag.
  2370  func hasServiceUserTag(a gerrit.AccountInfo) bool {
  2371  	for _, t := range a.Tags {
  2372  		if t == "SERVICE_USER" {
  2373  			return true
  2374  		}
  2375  	}
  2376  	return false
  2377  }
  2378  
  2379  // autoSubmitCLs submits CLs which are labelled "Auto-Submit",
  2380  // have all submit requirements satisfied according to Gerrit, and
  2381  // aren't waiting for a parent CL in the stack to be handled.
  2382  //
  2383  // See go.dev/issue/48021.
  2384  func (b *gopherbot) autoSubmitCLs(ctx context.Context) error {
  2385  	return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error {
  2386  		if gp.Server() != "go.googlesource.com" {
  2387  			return nil
  2388  		}
  2389  		return gp.ForeachOpenCL(func(cl *maintner.GerritCL) error {
  2390  			gc := gerritChange{gp.Project(), cl.Number}
  2391  			if b.deletedChanges[gc] {
  2392  				return nil
  2393  			}
  2394  
  2395  			// Break out early (before making Gerrit API calls) if the Auto-Submit label
  2396  			// hasn't been used at all in this CL.
  2397  			var autosubmitPresent bool
  2398  			for _, meta := range cl.Metas {
  2399  				if strings.Contains(meta.Commit.Msg, "\nLabel: Auto-Submit") {
  2400  					autosubmitPresent = true
  2401  					break
  2402  				}
  2403  			}
  2404  			if !autosubmitPresent {
  2405  				return nil
  2406  			}
  2407  
  2408  			// Skip this CL if Auto-Submit+1 isn't actively set on it.
  2409  			changeInfo, err := b.gerrit.GetChange(ctx, fmt.Sprint(cl.Number), gerrit.QueryChangesOpt{Fields: []string{"LABELS", "SUBMITTABLE"}})
  2410  			if err != nil {
  2411  				if httpErr, ok := err.(*gerrit.HTTPError); ok && httpErr.Res.StatusCode == http.StatusNotFound {
  2412  					b.deletedChanges[gc] = true
  2413  				}
  2414  				log.Printf("Could not retrieve change %q: %v", gc.ID(), err)
  2415  				return nil
  2416  			}
  2417  			if autosubmitActive := changeInfo.Labels["Auto-Submit"].Approved != nil; !autosubmitActive {
  2418  				return nil
  2419  			}
  2420  			// NOTE: we might be able to skip this as well, since the revision action
  2421  			// check will also cover this...
  2422  			if !changeInfo.Submittable {
  2423  				return nil
  2424  			}
  2425  
  2426  			// We need to check the mergeability, as well as the submitability,
  2427  			// as the latter doesn't take into account merge conflicts, just
  2428  			// if the change satisfies the project submit rules.
  2429  			//
  2430  			// NOTE: this may now be redundant, since the revision action check
  2431  			// below will also inherently checks mergeability, since the change
  2432  			// cannot actually be submitted if there is a merge conflict. We
  2433  			// may be able to just skip this entirely.
  2434  			mi, err := b.gerrit.GetMergeable(ctx, fmt.Sprint(cl.Number), "current")
  2435  			if err != nil {
  2436  				return err
  2437  			}
  2438  			if !mi.Mergeable || mi.CommitMerged {
  2439  				return nil
  2440  			}
  2441  
  2442  			ra, err := b.gerrit.GetRevisionActions(ctx, fmt.Sprint(cl.Number), "current")
  2443  			if err != nil {
  2444  				return err
  2445  			}
  2446  			if ra["submit"] == nil || !ra["submit"].Enabled {
  2447  				return nil
  2448  			}
  2449  
  2450  			// If this change is part of a stack, we'd like to merge the stack
  2451  			// in the correct order (i.e. from the bottom of the stack to the
  2452  			// top), so we'll only merge the current change if every change
  2453  			// below it in the stack is either merged, or abandoned.
  2454  			// GetRelatedChanges gives us the stack from top to bottom (the
  2455  			// order of the git commits, from newest to oldest, see Gerrit
  2456  			// documentation for RelatedChangesInfo), so first we find our
  2457  			// change in the stack, then  check everything below it.
  2458  			relatedChanges, err := b.gerrit.GetRelatedChanges(ctx, fmt.Sprint(cl.Number), "current")
  2459  			if err != nil {
  2460  				return err
  2461  			}
  2462  			if len(relatedChanges.Changes) > 0 {
  2463  				var parentChanges bool
  2464  				for _, ci := range relatedChanges.Changes {
  2465  					if !parentChanges {
  2466  						// Skip everything before the change we are checking, as
  2467  						// they are the children of this change, and we only care
  2468  						// about the parents.
  2469  						parentChanges = ci.ChangeNumber == cl.Number
  2470  						continue
  2471  					}
  2472  					if ci.Status != gerrit.ChangeStatusAbandoned &&
  2473  						ci.Status != gerrit.ChangeStatusMerged {
  2474  						return nil
  2475  					}
  2476  					// We do not check the revision number of merged/abandoned
  2477  					// parents since, even if they are not current according to
  2478  					// gerrit, if there were any merge conflicts, caused by the
  2479  					// diffs between the revision this change was based on and
  2480  					// the current revision, the change would not be considered
  2481  					// submittable anyway.
  2482  				}
  2483  			}
  2484  
  2485  			if *dryRun {
  2486  				log.Printf("[dry-run] would've submitted CL https://golang.org/cl/%d ...", cl.Number)
  2487  				return nil
  2488  			}
  2489  			log.Printf("submitting CL https://golang.org/cl/%d ...", cl.Number)
  2490  
  2491  			// TODO: if maintner isn't fast enough (or is too fast) and it re-runs this
  2492  			// before the submission is noticed, we may run this more than once. This
  2493  			// could be handled with a local cache of "recently submitted" changes to
  2494  			// be ignored.
  2495  			_, err = b.gerrit.SubmitChange(ctx, fmt.Sprint(cl.Number))
  2496  			return err
  2497  		})
  2498  	})
  2499  }
  2500  
  2501  type issueFlags uint8
  2502  
  2503  const (
  2504  	open        issueFlags = 1 << iota // Include open issues.
  2505  	closed                             // Include closed issues.
  2506  	includePRs                         // Include issues that are Pull Requests.
  2507  	includeGone                        // Include issues that are gone (e.g., deleted or transferred).
  2508  )
  2509  
  2510  // foreachIssue calls fn for each issue in repo gr as controlled by flags.
  2511  //
  2512  // If fn returns an error, iteration ends and foreachIssue returns
  2513  // with that error.
  2514  //
  2515  // The fn function is called serially, with increasingly numbered
  2516  // issues.
  2517  func (b *gopherbot) foreachIssue(gr *maintner.GitHubRepo, flags issueFlags, fn func(*maintner.GitHubIssue) error) error {
  2518  	return gr.ForeachIssue(func(gi *maintner.GitHubIssue) error {
  2519  		switch {
  2520  		case (flags&open == 0) && !gi.Closed,
  2521  			(flags&closed == 0) && gi.Closed,
  2522  			(flags&includePRs == 0) && gi.PullRequest,
  2523  			(flags&includeGone == 0) && (gi.NotExist || b.deletedIssues[githubIssue{gr.ID(), gi.Number}]):
  2524  			// Skip issue.
  2525  			return nil
  2526  		default:
  2527  			return fn(gi)
  2528  		}
  2529  	})
  2530  }
  2531  
  2532  // reviewerRe extracts the reviewer's Gerrit ID from a line that looks like:
  2533  //
  2534  //	Reviewer: Rebecca Stambler <16140@62eb7196-b449-3ce5-99f1-c037f21e1705>
  2535  var reviewerRe = regexp.MustCompile(`.* <(?P<id>\d+)@.*>`)
  2536  
  2537  const gerritInstanceID = "@62eb7196-b449-3ce5-99f1-c037f21e1705"
  2538  
  2539  // reviewersInMetas returns the unique Gerrit IDs of reviewers
  2540  // (in REVIEWER and CC states) that were at some point added
  2541  // to the given Gerrit CL, even if they've been since removed.
  2542  func reviewersInMetas(metas []*maintner.GerritMeta) []string {
  2543  	var ids []string
  2544  	for _, m := range metas {
  2545  		if !strings.Contains(m.Commit.Msg, "Reviewer:") && !strings.Contains(m.Commit.Msg, "CC:") {
  2546  			continue
  2547  		}
  2548  
  2549  		err := foreach.LineStr(m.Commit.Msg, func(ln string) error {
  2550  			if !strings.HasPrefix(ln, "Reviewer:") && !strings.HasPrefix(ln, "CC:") {
  2551  				return nil
  2552  			}
  2553  			match := reviewerRe.FindStringSubmatch(ln)
  2554  			if match == nil {
  2555  				return nil
  2556  			}
  2557  			// Extract the reviewer's Gerrit ID.
  2558  			for i, name := range reviewerRe.SubexpNames() {
  2559  				if name != "id" {
  2560  					continue
  2561  				}
  2562  				if i < 0 || i > len(match) {
  2563  					continue
  2564  				}
  2565  				ids = append(ids, match[i])
  2566  			}
  2567  			return nil
  2568  		})
  2569  		if err != nil {
  2570  			log.Printf("reviewersInMetas: got unexpected error from foreach.LineStr: %v", err)
  2571  		}
  2572  	}
  2573  	// Remove duplicates.
  2574  	slices.Sort(ids)
  2575  	ids = slices.Compact(ids)
  2576  	return ids
  2577  }
  2578  
  2579  func getCodeOwners(ctx context.Context, paths []string) ([]*owners.Entry, error) {
  2580  	oReq := owners.Request{Version: 1}
  2581  	oReq.Payload.Paths = paths
  2582  
  2583  	oResp, err := fetchCodeOwners(ctx, &oReq)
  2584  	if err != nil {
  2585  		return nil, err
  2586  	}
  2587  
  2588  	var entries []*owners.Entry
  2589  	for _, entry := range oResp.Payload.Entries {
  2590  		if entry == nil {
  2591  			continue
  2592  		}
  2593  		entries = append(entries, entry)
  2594  	}
  2595  	return entries, nil
  2596  }
  2597  
  2598  func getAllCodeOwners(ctx context.Context) (map[string]*owners.Entry, error) {
  2599  	oReq := owners.Request{Version: 1}
  2600  	oReq.Payload.All = true
  2601  	oResp, err := fetchCodeOwners(ctx, &oReq)
  2602  	if err != nil {
  2603  		return nil, err
  2604  	}
  2605  	return oResp.Payload.Entries, nil
  2606  }
  2607  
  2608  func fetchCodeOwners(ctx context.Context, oReq *owners.Request) (*owners.Response, error) {
  2609  	b, err := json.Marshal(oReq)
  2610  	if err != nil {
  2611  		return nil, err
  2612  	}
  2613  	req, err := http.NewRequest("POST", "https://dev.golang.org/owners/", bytes.NewReader(b))
  2614  	if err != nil {
  2615  		return nil, err
  2616  	}
  2617  	req.Header.Set("Content-Type", "application/json")
  2618  	ctx, cancel := context.WithTimeout(ctx, 10*time.Second)
  2619  	defer cancel()
  2620  	resp, err := http.DefaultClient.Do(req.WithContext(ctx))
  2621  	if err != nil {
  2622  		return nil, err
  2623  	}
  2624  	defer resp.Body.Close()
  2625  	var oResp owners.Response
  2626  	if err := json.NewDecoder(resp.Body).Decode(&oResp); err != nil {
  2627  		return nil, fmt.Errorf("could not decode owners response: %v", err)
  2628  	}
  2629  	if oResp.Error != "" {
  2630  		return nil, fmt.Errorf("error from dev.golang.org/owners endpoint: %v", oResp.Error)
  2631  	}
  2632  	return &oResp, nil
  2633  }
  2634  
  2635  // mergeOwnersEntries takes multiple owners.Entry structs and aggregates all
  2636  // primary and secondary users into a single entry.
  2637  // If a user is a primary in one entry but secondary on another, they are
  2638  // primary in the returned entry.
  2639  // If a users email matches the authorEmail, the user is omitted from the
  2640  // result.
  2641  // The resulting order of the entries is non-deterministic.
  2642  func mergeOwnersEntries(entries []*owners.Entry, authorEmail string) *owners.Entry {
  2643  	var result owners.Entry
  2644  	pm := make(map[owners.Owner]int)
  2645  	for _, e := range entries {
  2646  		for _, o := range e.Primary {
  2647  			pm[o]++
  2648  		}
  2649  	}
  2650  	sm := make(map[owners.Owner]int)
  2651  	for _, e := range entries {
  2652  		for _, o := range e.Secondary {
  2653  			if pm[o] > 0 {
  2654  				pm[o]++
  2655  			} else {
  2656  				sm[o]++
  2657  			}
  2658  		}
  2659  	}
  2660  
  2661  	const maxReviewers = 3
  2662  	if len(pm) > maxReviewers {
  2663  		// Spamming many reviewers.
  2664  		// Cut to three most common reviewers
  2665  		// and drop all the secondaries.
  2666  		var keep []owners.Owner
  2667  		for o := range pm {
  2668  			keep = append(keep, o)
  2669  		}
  2670  		sort.Slice(keep, func(i, j int) bool {
  2671  			return pm[keep[i]] > pm[keep[j]]
  2672  		})
  2673  		keep = keep[:maxReviewers]
  2674  		sort.Slice(keep, func(i, j int) bool {
  2675  			return keep[i].GerritEmail < keep[j].GerritEmail
  2676  		})
  2677  		return &owners.Entry{Primary: keep}
  2678  	}
  2679  
  2680  	for o := range pm {
  2681  		if o.GerritEmail != authorEmail {
  2682  			result.Primary = append(result.Primary, o)
  2683  		}
  2684  	}
  2685  	for o := range sm {
  2686  		if o.GerritEmail != authorEmail {
  2687  			result.Secondary = append(result.Secondary, o)
  2688  		}
  2689  	}
  2690  	return &result
  2691  }
  2692  
  2693  // filterGerritOwners removes all primary and secondary owners from entries
  2694  // that are missing GerritEmail, and thus cannot be Gerrit reviewers (e.g.,
  2695  // GitHub Teams).
  2696  //
  2697  // If an Entry's primary reviewers is empty after this process, the secondary
  2698  // owners are upgraded to primary.
  2699  func filterGerritOwners(entries []*owners.Entry) []*owners.Entry {
  2700  	result := make([]*owners.Entry, 0, len(entries))
  2701  	for _, e := range entries {
  2702  		var clean owners.Entry
  2703  		for _, owner := range e.Primary {
  2704  			if owner.GerritEmail != "" {
  2705  				clean.Primary = append(clean.Primary, owner)
  2706  			}
  2707  		}
  2708  		for _, owner := range e.Secondary {
  2709  			if owner.GerritEmail != "" {
  2710  				clean.Secondary = append(clean.Secondary, owner)
  2711  			}
  2712  		}
  2713  		if len(clean.Primary) == 0 {
  2714  			clean.Primary = clean.Secondary
  2715  			clean.Secondary = nil
  2716  		}
  2717  		result = append(result, &clean)
  2718  	}
  2719  	return result
  2720  }
  2721  
  2722  func blockqoute(s string) string {
  2723  	s = strings.TrimSpace(s)
  2724  	s = "> " + s
  2725  	s = strings.Replace(s, "\n", "\n> ", -1)
  2726  	return s
  2727  }
  2728  
  2729  // errStopIteration is used to stop iteration over issues or comments.
  2730  // It has no special meaning.
  2731  var errStopIteration = errors.New("stop iteration")
  2732  
  2733  func isDocumentationTitle(t string) bool {
  2734  	if !strings.Contains(t, "doc") && !strings.Contains(t, "Doc") {
  2735  		return false
  2736  	}
  2737  	t = strings.ToLower(t)
  2738  	if strings.HasPrefix(t, "x/pkgsite:") {
  2739  		// Don't label pkgsite issues with the Documentation label.
  2740  		return false
  2741  	}
  2742  	if strings.HasPrefix(t, "doc:") {
  2743  		return true
  2744  	}
  2745  	if strings.HasPrefix(t, "docs:") {
  2746  		return true
  2747  	}
  2748  	if strings.HasPrefix(t, "cmd/doc:") {
  2749  		return false
  2750  	}
  2751  	if strings.HasPrefix(t, "go/doc:") {
  2752  		return false
  2753  	}
  2754  	if strings.Contains(t, "godoc:") { // in x/tools, or the dozen places people file it as
  2755  		return false
  2756  	}
  2757  	return strings.Contains(t, "document") ||
  2758  		strings.Contains(t, "docs ")
  2759  }
  2760  
  2761  func isGoplsTitle(t string) bool {
  2762  	// If the prefix doesn't contain "gopls" or "lsp",
  2763  	// then it may not be a gopls issue.
  2764  	i := strings.Index(t, ":")
  2765  	if i > -1 {
  2766  		t = t[:i]
  2767  	}
  2768  	return strings.Contains(t, "gopls") || strings.Contains(t, "lsp")
  2769  }
  2770  
  2771  var lastTask string
  2772  
  2773  func printIssue(task string, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue) {
  2774  	if *dryRun {
  2775  		task = task + " [dry-run]"
  2776  	}
  2777  	if task != lastTask {
  2778  		fmt.Println(task)
  2779  		lastTask = task
  2780  	}
  2781  	if repoID.Owner != "golang" || repoID.Repo != "go" {
  2782  		fmt.Printf("\thttps://github.com/%s/issues/%v  %s\n", repoID, gi.Number, gi.Title)
  2783  	} else {
  2784  		fmt.Printf("\thttps://go.dev/issue/%v  %s\n", gi.Number, gi.Title)
  2785  	}
  2786  }
  2787  
  2788  func repoHasLabel(repo *maintner.GitHubRepo, name string) bool {
  2789  	has := false
  2790  	repo.ForeachLabel(func(label *maintner.GitHubLabel) error {
  2791  		if label.Name == name {
  2792  			has = true
  2793  		}
  2794  		return nil
  2795  	})
  2796  	return has
  2797  }