| // Copyright 2017 The Go Authors. All rights reserved. |
| // Use of this source code is governed by a BSD-style |
| // license that can be found in the LICENSE file. |
| |
| // The gopherbot command runs Go's gopherbot role account on |
| // GitHub and Gerrit. |
| // |
| // General documentation is at https://go.dev/wiki/gopherbot. |
| // Consult the tasks slice in gopherbot.go for an up-to-date |
| // list of all gopherbot tasks. |
| package main |
| |
| import ( |
| "bufio" |
| "bytes" |
| "context" |
| "crypto/tls" |
| "encoding/json" |
| "errors" |
| "flag" |
| "fmt" |
| "log" |
| "net/http" |
| "os" |
| "path/filepath" |
| "regexp" |
| "sort" |
| "strconv" |
| "strings" |
| "sync" |
| "time" |
| "unicode" |
| |
| "cloud.google.com/go/compute/metadata" |
| "github.com/google/go-github/v48/github" |
| "github.com/shurcooL/githubv4" |
| "go4.org/strutil" |
| "golang.org/x/build/devapp/owners" |
| "golang.org/x/build/gerrit" |
| "golang.org/x/build/internal/foreach" |
| "golang.org/x/build/internal/gophers" |
| "golang.org/x/build/internal/secret" |
| "golang.org/x/build/maintner" |
| "golang.org/x/build/maintner/godata" |
| "golang.org/x/build/maintner/maintnerd/apipb" |
| "golang.org/x/exp/slices" |
| "golang.org/x/oauth2" |
| "google.golang.org/grpc" |
| "google.golang.org/grpc/credentials" |
| ) |
| |
| var ( |
| dryRun = flag.Bool("dry-run", false, "just report what would've been done, without changing anything") |
| daemon = flag.Bool("daemon", false, "run in daemon mode") |
| githubTokenFile = flag.String("github-token-file", filepath.Join(os.Getenv("HOME"), "keys", "github-gobot"), `File to load GitHub token from. File should be of form <username>:<token>`) |
| // go here: https://go-review.googlesource.com/settings#HTTPCredentials |
| // click "Obtain Password" |
| // The next page will have a .gitcookies file - look for the part that has |
| // "git-youremail@yourcompany.com=password". Copy and paste that to the |
| // token file with a colon in between the email and password. |
| gerritTokenFile = flag.String("gerrit-token-file", filepath.Join(os.Getenv("HOME"), "keys", "gerrit-gobot"), `File to load Gerrit token from. File should be of form <git-email>:<token>`) |
| |
| 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") |
| ) |
| |
| func init() { |
| flag.Usage = func() { |
| output := flag.CommandLine.Output() |
| fmt.Fprintf(output, "gopherbot runs Go's gopherbot role account on GitHub and Gerrit.\n\n") |
| flag.PrintDefaults() |
| fmt.Fprintln(output, "") |
| fmt.Fprintln(output, "Tasks (can be used for the --only-run flag):") |
| for _, t := range tasks { |
| fmt.Fprintf(output, " %q\n", t.name) |
| } |
| } |
| } |
| |
| const ( |
| gopherbotGitHubID = 8566911 |
| ) |
| |
| const ( |
| gobotGerritID = "5976" |
| gerritbotGerritID = "12446" |
| kokoroGerritID = "37747" |
| goLUCIGerritID = "60063" |
| triciumGerritID = "62045" |
| ) |
| |
| // GitHub Label IDs for the golang/go repo. |
| const ( |
| needsDecisionID = 373401956 |
| needsFixID = 373399998 |
| needsInvestigationID = 373402289 |
| earlyInCycleID = 626114143 |
| ) |
| |
| // Label names (that are used in multiple places). |
| const ( |
| frozenDueToAge = "FrozenDueToAge" |
| ) |
| |
| // GitHub Milestone numbers for the golang/go repo. |
| var ( |
| proposal = milestone{30, "Proposal"} |
| unreleased = milestone{22, "Unreleased"} |
| unplanned = milestone{6, "Unplanned"} |
| gccgo = milestone{23, "Gccgo"} |
| vgo = milestone{71, "vgo"} |
| vulnUnplanned = milestone{288, "vuln/unplanned"} |
| ) |
| |
| // GitHub Milestone numbers for the golang/vscode-go repo. |
| var vscodeUntriaged = milestone{26, "Untriaged"} |
| |
| type milestone struct { |
| Number int |
| Name string |
| } |
| |
| func getGitHubToken(ctx context.Context, sc *secret.Client) (string, error) { |
| if metadata.OnGCE() && sc != nil { |
| ctxSc, cancel := context.WithTimeout(ctx, 10*time.Second) |
| defer cancel() |
| |
| token, err := sc.Retrieve(ctxSc, secret.NameMaintnerGitHubToken) |
| if err == nil && token != "" { |
| return token, nil |
| } |
| } |
| slurp, err := os.ReadFile(*githubTokenFile) |
| if err != nil { |
| return "", err |
| } |
| f := strings.SplitN(strings.TrimSpace(string(slurp)), ":", 2) |
| if len(f) != 2 || f[0] == "" || f[1] == "" { |
| return "", fmt.Errorf("expected token %q to be of form <username>:<token>", slurp) |
| } |
| return f[1], nil |
| } |
| |
| func getGerritAuth(ctx context.Context, sc *secret.Client) (username string, password string, err error) { |
| if metadata.OnGCE() && sc != nil { |
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| defer cancel() |
| |
| token, err := sc.Retrieve(ctx, secret.NameGobotPassword) |
| if err != nil { |
| return "", "", err |
| } |
| return "git-gobot.golang.org", token, nil |
| } |
| |
| var slurpBytes []byte |
| slurpBytes, err = os.ReadFile(*gerritTokenFile) |
| if err != nil { |
| return "", "", err |
| } |
| slurp := string(slurpBytes) |
| |
| f := strings.SplitN(strings.TrimSpace(slurp), ":", 2) |
| if len(f) == 1 { |
| // assume the whole thing is the token |
| return "git-gobot.golang.org", f[0], nil |
| } |
| if len(f) != 2 || f[0] == "" || f[1] == "" { |
| return "", "", fmt.Errorf("expected Gerrit token %q to be of form <git-email>:<token>", slurp) |
| } |
| return f[0], f[1], nil |
| } |
| |
| func getGitHubClients(ctx context.Context, sc *secret.Client) (*github.Client, *githubv4.Client, error) { |
| token, err := getGitHubToken(ctx, sc) |
| if err != nil { |
| if *dryRun { |
| // Note: GitHub API v4 requires requests to be authenticated, which isn't implemented here. |
| return github.NewClient(http.DefaultClient), githubv4.NewClient(http.DefaultClient), nil |
| } |
| return nil, nil, err |
| } |
| ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) |
| tc := oauth2.NewClient(context.Background(), ts) |
| return github.NewClient(tc), githubv4.NewClient(tc), nil |
| } |
| |
| func getGerritClient(ctx context.Context, sc *secret.Client) (*gerrit.Client, error) { |
| username, token, err := getGerritAuth(ctx, sc) |
| if err != nil { |
| if *dryRun { |
| c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.NoAuth) |
| return c, nil |
| } |
| return nil, err |
| } |
| c := gerrit.NewClient("https://go-review.googlesource.com", gerrit.BasicAuth(username, token)) |
| return c, nil |
| } |
| |
| func getMaintnerClient(ctx context.Context) (apipb.MaintnerServiceClient, error) { |
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| defer cancel() |
| mServer := "maintner.golang.org:443" |
| cc, err := grpc.DialContext(ctx, mServer, |
| grpc.WithBlock(), |
| grpc.WithTransportCredentials(credentials.NewTLS(&tls.Config{NextProtos: []string{"h2"}}))) |
| if err != nil { |
| return nil, err |
| } |
| return apipb.NewMaintnerServiceClient(cc), nil |
| } |
| |
| type gerritChange struct { |
| project string |
| num int32 |
| } |
| |
| func (c gerritChange) ID() string { |
| // https://gerrit-review.googlesource.com/Documentation/rest-api-changes.html#change-id |
| return fmt.Sprintf("%s~%d", c.project, c.num) |
| } |
| |
| func (c gerritChange) String() string { |
| return c.ID() |
| } |
| |
| type githubIssue struct { |
| repo maintner.GitHubRepoID |
| num int32 |
| } |
| |
| func main() { |
| flag.Parse() |
| |
| var sc *secret.Client |
| if metadata.OnGCE() { |
| sc = secret.MustNewClient() |
| } |
| ctx := context.Background() |
| |
| ghV3, ghV4, err := getGitHubClients(ctx, sc) |
| if err != nil { |
| log.Fatal(err) |
| } |
| gerrit, err := getGerritClient(ctx, sc) |
| if err != nil { |
| log.Fatal(err) |
| } |
| mc, err := getMaintnerClient(ctx) |
| if err != nil { |
| log.Fatal(err) |
| } |
| |
| var goRepo = maintner.GitHubRepoID{Owner: "golang", Repo: "go"} |
| var vscode = maintner.GitHubRepoID{Owner: "golang", Repo: "vscode-go"} |
| bot := &gopherbot{ |
| ghc: ghV3, |
| ghV4: ghV4, |
| gerrit: gerrit, |
| mc: mc, |
| is: ghV3.Issues, |
| deletedChanges: map[gerritChange]bool{ |
| {"crypto", 35958}: true, |
| {"scratch", 71730}: true, |
| {"scratch", 71850}: true, |
| {"scratch", 72090}: true, |
| {"scratch", 72091}: true, |
| {"scratch", 72110}: true, |
| {"scratch", 72131}: true, |
| }, |
| deletedIssues: map[githubIssue]bool{ |
| {goRepo, 13084}: true, |
| {goRepo, 23772}: true, |
| {goRepo, 27223}: true, |
| {goRepo, 28522}: true, |
| {goRepo, 29309}: true, |
| {goRepo, 32047}: true, |
| {goRepo, 32048}: true, |
| {goRepo, 32469}: true, |
| {goRepo, 32706}: true, |
| {goRepo, 32737}: true, |
| {goRepo, 33315}: true, |
| {goRepo, 33316}: true, |
| {goRepo, 33592}: true, |
| {goRepo, 33593}: true, |
| {goRepo, 33697}: true, |
| {goRepo, 33785}: true, |
| {goRepo, 34296}: true, |
| {goRepo, 34476}: true, |
| {goRepo, 34766}: true, |
| {goRepo, 34780}: true, |
| {goRepo, 34786}: true, |
| {goRepo, 34821}: true, |
| {goRepo, 35493}: true, |
| {goRepo, 35649}: true, |
| {goRepo, 36322}: true, |
| {goRepo, 36323}: true, |
| {goRepo, 36324}: true, |
| {goRepo, 36342}: true, |
| {goRepo, 36343}: true, |
| {goRepo, 36406}: true, |
| {goRepo, 36517}: true, |
| {goRepo, 36829}: true, |
| {goRepo, 36885}: true, |
| {goRepo, 36933}: true, |
| {goRepo, 36939}: true, |
| {goRepo, 36941}: true, |
| {goRepo, 36947}: true, |
| {goRepo, 36962}: true, |
| {goRepo, 36963}: true, |
| {goRepo, 37516}: true, |
| {goRepo, 37522}: true, |
| {goRepo, 37582}: true, |
| {goRepo, 37896}: true, |
| {goRepo, 38132}: true, |
| {goRepo, 38241}: true, |
| {goRepo, 38483}: true, |
| {goRepo, 38560}: true, |
| {goRepo, 38840}: true, |
| {goRepo, 39112}: true, |
| {goRepo, 39141}: true, |
| {goRepo, 39229}: true, |
| {goRepo, 39234}: true, |
| {goRepo, 39335}: true, |
| {goRepo, 39401}: true, |
| {goRepo, 39453}: true, |
| {goRepo, 39522}: true, |
| {goRepo, 39718}: true, |
| {goRepo, 40400}: true, |
| {goRepo, 40593}: true, |
| {goRepo, 40600}: true, |
| {goRepo, 41211}: true, |
| {goRepo, 41336}: true, |
| {goRepo, 41649}: true, |
| {goRepo, 41650}: true, |
| {goRepo, 41655}: true, |
| {goRepo, 41675}: true, |
| {goRepo, 41676}: true, |
| {goRepo, 41678}: true, |
| {goRepo, 41679}: true, |
| {goRepo, 41714}: true, |
| {goRepo, 42309}: true, |
| {goRepo, 43102}: true, |
| {goRepo, 43169}: true, |
| {goRepo, 43231}: true, |
| {goRepo, 43330}: true, |
| {goRepo, 43409}: true, |
| {goRepo, 43410}: true, |
| {goRepo, 43411}: true, |
| {goRepo, 43433}: true, |
| {goRepo, 43613}: true, |
| {goRepo, 43751}: true, |
| {goRepo, 44124}: true, |
| {goRepo, 44185}: true, |
| {goRepo, 44566}: true, |
| {goRepo, 44652}: true, |
| {goRepo, 44711}: true, |
| {goRepo, 44768}: true, |
| {goRepo, 44769}: true, |
| {goRepo, 44771}: true, |
| {goRepo, 44773}: true, |
| {goRepo, 44871}: true, |
| {goRepo, 45018}: true, |
| {goRepo, 45082}: true, |
| {goRepo, 45201}: true, |
| {goRepo, 45202}: true, |
| {goRepo, 47140}: true, |
| |
| {vscode, 298}: true, |
| {vscode, 524}: true, |
| {vscode, 650}: true, |
| {vscode, 741}: true, |
| {vscode, 773}: true, |
| {vscode, 959}: true, |
| {vscode, 1402}: true, |
| }, |
| } |
| for n := int32(55359); n <= 55828; n++ { |
| bot.deletedIssues[githubIssue{goRepo, n}] = true |
| } |
| bot.initCorpus() |
| |
| for { |
| t0 := time.Now() |
| taskErrors := bot.doTasks(ctx) |
| for _, err := range taskErrors { |
| log.Print(err) |
| } |
| botDur := time.Since(t0) |
| log.Printf("gopherbot ran in %v", botDur) |
| if !*daemon { |
| if len(taskErrors) > 0 { |
| os.Exit(1) |
| } |
| return |
| } |
| if len(taskErrors) > 0 { |
| log.Printf("sleeping 30s after previous error.") |
| time.Sleep(30 * time.Second) |
| } |
| for { |
| t0 := time.Now() |
| err := bot.corpus.Update(ctx) |
| if err != nil { |
| if err == maintner.ErrSplit { |
| log.Print("Corpus out of sync. Re-fetching corpus.") |
| bot.initCorpus() |
| } else { |
| log.Printf("corpus.Update: %v; sleeping 15s", err) |
| time.Sleep(15 * time.Second) |
| continue |
| } |
| } |
| log.Printf("got corpus update after %v", time.Since(t0)) |
| break |
| } |
| lastTask = "" |
| } |
| } |
| |
| type gopherbot struct { |
| ghc *github.Client |
| ghV4 *githubv4.Client |
| gerrit *gerrit.Client |
| mc apipb.MaintnerServiceClient |
| corpus *maintner.Corpus |
| gorepo *maintner.GitHubRepo |
| is issuesService |
| |
| knownContributors map[string]bool |
| |
| // Until golang.org/issue/22635 is fixed, keep a map of changes and issues |
| // that were deleted to prevent calls to Gerrit or GitHub that will always 404. |
| deletedChanges map[gerritChange]bool |
| deletedIssues map[githubIssue]bool |
| |
| releases struct { |
| sync.Mutex |
| lastUpdate time.Time |
| major []string // Last two releases and the next upcoming release, like: "1.9", "1.10", "1.11". |
| nextMinor map[string]string // Key is a major release like "1.9", value is its next minor release like "1.9.7". |
| } |
| } |
| |
| var tasks = []struct { |
| name string |
| fn func(*gopherbot, context.Context) error |
| }{ |
| // Tasks that are specific to the golang/go repo. |
| {"kicktrain", (*gopherbot).getOffKickTrain}, |
| {"label build issues", (*gopherbot).labelBuildIssues}, |
| {"label compiler/runtime issues", (*gopherbot).labelCompilerRuntimeIssues}, |
| {"label mobile issues", (*gopherbot).labelMobileIssues}, |
| {"label tools issues", (*gopherbot).labelToolsIssues}, |
| {"label website issues", (*gopherbot).labelWebsiteIssues}, |
| {"label pkgsite issues", (*gopherbot).labelPkgsiteIssues}, |
| {"label proxy.golang.org issues", (*gopherbot).labelProxyIssues}, |
| {"label vulncheck or vulndb issues", (*gopherbot).labelVulnIssues}, |
| {"label proposals", (*gopherbot).labelProposals}, |
| {"handle gopls issues", (*gopherbot).handleGoplsIssues}, |
| {"handle telemetry issues", (*gopherbot).handleTelemetryIssues}, |
| {"open cherry pick issues", (*gopherbot).openCherryPickIssues}, |
| {"close cherry pick issues", (*gopherbot).closeCherryPickIssues}, |
| {"set subrepo milestones", (*gopherbot).setSubrepoMilestones}, |
| {"set misc milestones", (*gopherbot).setMiscMilestones}, |
| {"apply minor release milestones", (*gopherbot).setMinorMilestones}, |
| {"update needs", (*gopherbot).updateNeeds}, |
| |
| // Tasks that can be applied to many repos. |
| {"freeze old issues", (*gopherbot).freezeOldIssues}, |
| {"label documentation issues", (*gopherbot).labelDocumentationIssues}, |
| {"close stale WaitingForInfo", (*gopherbot).closeStaleWaitingForInfo}, |
| {"apply labels from comments", (*gopherbot).applyLabelsFromComments}, |
| |
| // Gerrit tasks are applied to all projects by default. |
| {"abandon scratch reviews", (*gopherbot).abandonScratchReviews}, |
| {"assign reviewers to CLs", (*gopherbot).assignReviewersToCLs}, |
| {"auto-submit CLs", (*gopherbot).autoSubmitCLs}, |
| |
| // Tasks that are specific to the golang/vscode-go repo. |
| {"set vscode-go milestones", (*gopherbot).setVSCodeGoMilestones}, |
| |
| {"access", (*gopherbot).whoNeedsAccess}, |
| {"cl2issue", (*gopherbot).cl2issue}, |
| {"congratulate new contributors", (*gopherbot).congratulateNewContributors}, |
| {"un-wait CLs", (*gopherbot).unwaitCLs}, |
| } |
| |
| // gardenIssues reports whether GopherBot should perform general issue |
| // gardening tasks for the repo. |
| func gardenIssues(repo *maintner.GitHubRepo) bool { |
| if repo.ID().Owner != "golang" { |
| return false |
| } |
| switch repo.ID().Repo { |
| case "go", "vscode-go", "vulndb": |
| return true |
| } |
| return false |
| } |
| |
| func (b *gopherbot) initCorpus() { |
| ctx := context.Background() |
| corpus, err := godata.Get(ctx) |
| if err != nil { |
| log.Fatalf("godata.Get: %v", err) |
| } |
| |
| repo := corpus.GitHub().Repo("golang", "go") |
| if repo == nil { |
| log.Fatal("Failed to find Go repo in Corpus.") |
| } |
| |
| b.corpus = corpus |
| b.gorepo = repo |
| } |
| |
| // doTasks performs tasks in sequence. It doesn't stop if |
| // if encounters an error, but reports errors at the end. |
| func (b *gopherbot) doTasks(ctx context.Context) []error { |
| var errs []error |
| for _, task := range tasks { |
| if *onlyRun != "" && task.name != *onlyRun { |
| continue |
| } |
| err := task.fn(b, ctx) |
| if err != nil { |
| errs = append(errs, fmt.Errorf("%s: %v", task.name, err)) |
| } |
| } |
| return errs |
| } |
| |
| // issuesService represents portions of github.IssuesService that we want to override in tests. |
| type issuesService interface { |
| ListLabelsByIssue(ctx context.Context, owner string, repo string, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) |
| AddLabelsToIssue(ctx context.Context, owner string, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) |
| RemoveLabelForIssue(ctx context.Context, owner string, repo string, number int, label string) (*github.Response, error) |
| } |
| |
| func (b *gopherbot) addLabel(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, label string) error { |
| return b.addLabels(ctx, repoID, gi, []string{label}) |
| } |
| |
| func (b *gopherbot) addLabels(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, labels []string) error { |
| var toAdd []string |
| for _, label := range labels { |
| if gi.HasLabel(label) { |
| log.Printf("Issue %d already has label %q; no need to send request to add it", gi.Number, label) |
| continue |
| } |
| printIssue("label-"+label, repoID, gi) |
| toAdd = append(toAdd, label) |
| } |
| |
| if *dryRun || len(toAdd) == 0 { |
| return nil |
| } |
| |
| _, resp, err := b.is.AddLabelsToIssue(ctx, repoID.Owner, repoID.Repo, int(gi.Number), toAdd) |
| if err != nil && resp != nil && (resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone) { |
| // TODO(golang/go#40640) - This issue was transferred or otherwise is gone. We should permanently skip it. This |
| // is a temporary fix to keep gopherbot working. |
| log.Printf("addLabels: Issue %v#%v returned %s when trying to add labels. Skipping. See golang/go#40640.", repoID, gi.Number, resp.Status) |
| b.deletedIssues[githubIssue{repoID, gi.Number}] = true |
| return nil |
| } |
| return err |
| } |
| |
| // removeLabel removes the label from the given issue in the given repo. |
| func (b *gopherbot) removeLabel(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, label string) error { |
| return b.removeLabels(ctx, repoID, gi, []string{label}) |
| } |
| |
| func (b *gopherbot) removeLabels(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, labels []string) error { |
| var removeLabels bool |
| for _, l := range labels { |
| if !gi.HasLabel(l) { |
| log.Printf("Issue %d (in maintner) does not have label %q; no need to send request to remove it", gi.Number, l) |
| continue |
| } |
| printIssue("label-"+l, repoID, gi) |
| removeLabels = true |
| } |
| |
| if *dryRun || !removeLabels { |
| return nil |
| } |
| |
| ghLabels, err := labelsForIssue(ctx, repoID, b.is, int(gi.Number)) |
| if err != nil { |
| return err |
| } |
| toRemove := make(map[string]bool) |
| for _, l := range labels { |
| toRemove[l] = true |
| } |
| |
| for _, l := range ghLabels { |
| if toRemove[l] { |
| if err := removeLabelFromIssue(ctx, repoID, b.is, int(gi.Number), l); err != nil { |
| log.Printf("Could not remove label %q from issue %d: %v", l, gi.Number, err) |
| continue |
| } |
| } |
| } |
| return nil |
| } |
| |
| // labelsForIssue returns all labels for the given issue in the given repo. |
| func labelsForIssue(ctx context.Context, repoID maintner.GitHubRepoID, issues issuesService, issueNum int) ([]string, error) { |
| ghLabels, _, err := issues.ListLabelsByIssue(ctx, repoID.Owner, repoID.Repo, issueNum, &github.ListOptions{PerPage: 100}) |
| if err != nil { |
| return nil, fmt.Errorf("could not list labels for %s#%d: %v", repoID, issueNum, err) |
| } |
| var labels []string |
| for _, l := range ghLabels { |
| labels = append(labels, l.GetName()) |
| } |
| return labels, nil |
| } |
| |
| // removeLabelFromIssue removes the given label from the given repo with the |
| // given issueNum. If the issue did not have the label already (or the label |
| // didn't exist), return nil. |
| func removeLabelFromIssue(ctx context.Context, repoID maintner.GitHubRepoID, issues issuesService, issueNum int, label string) error { |
| _, err := issues.RemoveLabelForIssue(ctx, repoID.Owner, repoID.Repo, issueNum, label) |
| if ge, ok := err.(*github.ErrorResponse); ok && ge.Response != nil && ge.Response.StatusCode == http.StatusNotFound { |
| return nil |
| } |
| return err |
| } |
| |
| func (b *gopherbot) setMilestone(ctx context.Context, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue, m milestone) error { |
| printIssue("milestone-"+m.Name, repoID, gi) |
| if *dryRun { |
| return nil |
| } |
| _, _, err := b.ghc.Issues.Edit(ctx, repoID.Owner, repoID.Repo, int(gi.Number), &github.IssueRequest{ |
| Milestone: github.Int(m.Number), |
| }) |
| return err |
| } |
| |
| func (b *gopherbot) addGitHubComment(ctx context.Context, repo *maintner.GitHubRepo, issueNum int32, msg string) error { |
| var since time.Time |
| if gi := repo.Issue(issueNum); gi != nil { |
| dup := false |
| gi.ForeachComment(func(c *maintner.GitHubComment) error { |
| since = c.Updated |
| // TODO: check for gopherbot as author? check for exact match? |
| // This seems fine for now. |
| if strings.Contains(c.Body, msg) { |
| dup = true |
| return errStopIteration |
| } |
| return nil |
| }) |
| if dup { |
| // Comment's already been posted. Nothing to do. |
| return nil |
| } |
| } |
| // See if there is a dup comment from when gopherbot last got |
| // its data from maintner. |
| opt := &github.IssueListCommentsOptions{ListOptions: github.ListOptions{PerPage: 1000}} |
| if !since.IsZero() { |
| opt.Since = &since |
| } |
| ics, resp, err := b.ghc.Issues.ListComments(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum), opt) |
| if err != nil { |
| // TODO(golang/go#40640) - This issue was transferred or otherwise is gone. We should permanently skip it. This |
| // is a temporary fix to keep gopherbot working. |
| if resp != nil && resp.StatusCode == http.StatusNotFound { |
| log.Printf("addGitHubComment: Issue %v#%v returned a 404 when trying to load comments. Skipping. See golang/go#40640.", repo.ID(), issueNum) |
| b.deletedIssues[githubIssue{repo.ID(), issueNum}] = true |
| return nil |
| } |
| return err |
| } |
| for _, ic := range ics { |
| if strings.Contains(ic.GetBody(), msg) { |
| // Dup. |
| return nil |
| } |
| } |
| if *dryRun { |
| log.Printf("[dry-run] would add comment to github.com/%s/issues/%d: %v", repo.ID(), issueNum, msg) |
| return nil |
| } |
| _, resp, createError := b.ghc.Issues.CreateComment(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum), &github.IssueComment{ |
| Body: github.String(msg), |
| }) |
| if createError != nil && resp != nil && resp.StatusCode == http.StatusUnprocessableEntity { |
| // While maintner's tracking of deleted issues is incomplete (see go.dev/issue/30184), |
| // we sometimes see a deleted issue whose /comments endpoint returns 200 OK with an |
| // empty list, so the error check from ListComments doesn't catch it. (The deleted |
| // issue 55403 is an example of such a case.) So check again with the Get endpoint, |
| // which seems to return 404 more reliably in such cases at least as of 2022-10-11. |
| if _, resp, err := b.ghc.Issues.Get(ctx, repo.ID().Owner, repo.ID().Repo, int(issueNum)); err != nil && |
| resp != nil && resp.StatusCode == http.StatusNotFound { |
| log.Printf("addGitHubComment: Issue %v#%v returned a 404 after posting comment failed with 422. Skipping. See go.dev/issue/30184.", repo.ID(), issueNum) |
| b.deletedIssues[githubIssue{repo.ID(), issueNum}] = true |
| return nil |
| } |
| } |
| return createError |
| } |
| |
| // createGitHubIssue returns the number of the created issue, or 4242 in dry-run mode. |
| // baseEvent is the timestamp of the event causing this action, and is used for de-duplication. |
| func (b *gopherbot) createGitHubIssue(ctx context.Context, title, msg string, labels []string, baseEvent time.Time) (int, error) { |
| var dup int |
| b.gorepo.ForeachIssue(func(gi *maintner.GitHubIssue) error { |
| // TODO: check for gopherbot as author? check for exact match? |
| // This seems fine for now. |
| if gi.Title == title { |
| dup = int(gi.Number) |
| return errStopIteration |
| } |
| return nil |
| }) |
| if dup != 0 { |
| // Issue's already been posted. Nothing to do. |
| return dup, nil |
| } |
| // See if there is a dup issue from when gopherbot last got its data from maintner. |
| is, _, err := b.ghc.Issues.ListByRepo(ctx, "golang", "go", &github.IssueListByRepoOptions{ |
| State: "all", |
| ListOptions: github.ListOptions{PerPage: 100}, |
| Since: baseEvent, |
| }) |
| if err != nil { |
| return 0, err |
| } |
| for _, i := range is { |
| if i.GetTitle() == title { |
| // Dup. |
| return i.GetNumber(), nil |
| } |
| } |
| if *dryRun { |
| log.Printf("[dry-run] would create issue with title %s and labels %v\n%s", title, labels, msg) |
| return 4242, nil |
| } |
| i, _, err := b.ghc.Issues.Create(ctx, "golang", "go", &github.IssueRequest{ |
| Title: github.String(title), |
| Body: github.String(msg), |
| Labels: &labels, |
| }) |
| return i.GetNumber(), err |
| } |
| |
| // issueCloseReason is a reason given when closing an issue on GitHub. |
| // See https://docs.github.com/en/issues/tracking-your-work-with-issues/closing-an-issue. |
| type issueCloseReason *string |
| |
| var ( |
| completed issueCloseReason = github.String("completed") // Done, closed, fixed, resolved. |
| notPlanned issueCloseReason = github.String("not_planned") // Won't fix, can't repro, duplicate, stale. |
| ) |
| |
| // closeGitHubIssue closes a GitHub issue. |
| // reason specifies why it's being closed. (GitHub's default reason on 2023-06-12 is "completed".) |
| func (b *gopherbot) closeGitHubIssue(ctx context.Context, repoID maintner.GitHubRepoID, number int32, reason issueCloseReason) error { |
| if *dryRun { |
| var suffix string |
| if reason != nil { |
| suffix = " as " + *reason |
| } |
| log.Printf("[dry-run] would close go.dev/issue/%v%s", number, suffix) |
| return nil |
| } |
| _, _, err := b.ghc.Issues.Edit(ctx, repoID.Owner, repoID.Repo, int(number), &github.IssueRequest{ |
| State: github.String("closed"), |
| StateReason: reason, |
| }) |
| return err |
| } |
| |
| type gerritCommentOpts struct { |
| OldPhrases []string |
| Version string // if empty, latest version is used |
| } |
| |
| var emptyGerritCommentOpts gerritCommentOpts |
| |
| // addGerritComment adds the given comment to the CL specified by the changeID |
| // and the patch set identified by the version. |
| // |
| // As an idempotence check, before adding the comment and the list |
| // of oldPhrases are checked against the CL to ensure that no phrase in the list |
| // has already been added to the list as a comment. |
| func (b *gopherbot) addGerritComment(ctx context.Context, changeID, comment string, opts *gerritCommentOpts) error { |
| if b == nil { |
| panic("nil gopherbot") |
| } |
| if *dryRun { |
| log.Printf("[dry-run] would add comment to golang.org/cl/%s: %v", changeID, comment) |
| return nil |
| } |
| if opts == nil { |
| opts = &emptyGerritCommentOpts |
| } |
| // One final staleness check before sending a message: get the list |
| // of comments from the API and check whether any of them match. |
| info, err := b.gerrit.GetChange(ctx, changeID, gerrit.QueryChangesOpt{ |
| Fields: []string{"MESSAGES", "CURRENT_REVISION"}, |
| }) |
| if err != nil { |
| return err |
| } |
| for _, msg := range info.Messages { |
| if strings.Contains(msg.Message, comment) { |
| return nil // Our comment is already there |
| } |
| for j := range opts.OldPhrases { |
| // Message looks something like "Patch set X:\n\n(our text)" |
| if strings.Contains(msg.Message, opts.OldPhrases[j]) { |
| return nil // Our comment is already there |
| } |
| } |
| } |
| var rev string |
| if opts.Version != "" { |
| rev = opts.Version |
| } else { |
| rev = info.CurrentRevision |
| } |
| return b.gerrit.SetReview(ctx, changeID, rev, gerrit.ReviewInput{ |
| Message: comment, |
| }) |
| } |
| |
| // Move any issue to "Unplanned" if it looks like it keeps getting kicked along between releases. |
| func (b *gopherbot) getOffKickTrain(ctx context.Context) error { |
| // We only run this task if it was explicitly requested via |
| // the --only-run flag. |
| if *onlyRun == "" { |
| return nil |
| } |
| type match struct { |
| url string |
| title string |
| gi *maintner.GitHubIssue |
| } |
| var matches []match |
| b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| curMilestone := gi.Milestone.Title |
| if !strings.HasPrefix(curMilestone, "Go1.") || strings.Count(curMilestone, ".") != 1 { |
| return nil |
| } |
| if gi.HasLabel("release-blocker") || gi.HasLabel("Security") { |
| return nil |
| } |
| if len(gi.Assignees) > 0 { |
| return nil |
| } |
| was := map[string]bool{} |
| gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { |
| if e.Type == "milestoned" { |
| switch e.Milestone { |
| case "Unreleased", "Unplanned", "Proposal": |
| return nil |
| } |
| if strings.Count(e.Milestone, ".") > 1 { |
| return nil |
| } |
| ms := strings.TrimSuffix(e.Milestone, "Maybe") |
| ms = strings.TrimSuffix(ms, "Early") |
| was[ms] = true |
| } |
| return nil |
| }) |
| if len(was) > 2 { |
| var mss []string |
| for ms := range was { |
| mss = append(mss, ms) |
| } |
| sort.Slice(mss, func(i, j int) bool { |
| if len(mss[i]) == len(mss[j]) { |
| return mss[i] < mss[j] |
| } |
| return len(mss[i]) < len(mss[j]) |
| }) |
| matches = append(matches, match{ |
| url: fmt.Sprintf("https://go.dev/issue/%d", gi.Number), |
| title: fmt.Sprintf("%s - %v", gi.Title, mss), |
| gi: gi, |
| }) |
| } |
| return nil |
| }) |
| sort.Slice(matches, func(i, j int) bool { |
| return matches[i].title < matches[j].title |
| }) |
| fmt.Printf("%d issues:\n", len(matches)) |
| for _, m := range matches { |
| fmt.Printf("%-30s - %s\n", m.url, m.title) |
| if !*dryRun { |
| if err := b.setMilestone(ctx, b.gorepo.ID(), m.gi, unplanned); err != nil { |
| return err |
| } |
| } |
| } |
| return nil |
| } |
| |
| // freezeOldIssues locks any issue that's old and closed. |
| // (Otherwise people find ancient bugs via searches and start asking questions |
| // into a void and it's sad for everybody.) |
| // This method doesn't need to explicitly avoid edit wars with humans because |
| // it bails out if the issue was edited recently. A human unlocking an issue |
| // causes the updated time to bump, which means the bot wouldn't try to lock it |
| // again for another year. |
| func (b *gopherbot) freezeOldIssues(ctx context.Context) error { |
| tooOld := time.Now().Add(-365 * 24 * time.Hour) |
| return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error { |
| if !gardenIssues(repo) { |
| return nil |
| } |
| if !repoHasLabel(repo, frozenDueToAge) { |
| return nil |
| } |
| return b.foreachIssue(repo, closed, func(gi *maintner.GitHubIssue) error { |
| if gi.Locked || gi.Updated.After(tooOld) { |
| return nil |
| } |
| printIssue("freeze", repo.ID(), gi) |
| if *dryRun { |
| return nil |
| } |
| _, err := b.ghc.Issues.Lock(ctx, repo.ID().Owner, repo.ID().Repo, int(gi.Number), nil) |
| if ge, ok := err.(*github.ErrorResponse); ok && ge.Response.StatusCode == http.StatusNotFound { |
| // An issue can become 404 on GitHub due to being deleted or transferred. See go.dev/issue/30182. |
| b.deletedIssues[githubIssue{repo.ID(), gi.Number}] = true |
| return nil |
| } else if err != nil { |
| return err |
| } |
| return b.addLabel(ctx, repo.ID(), gi, frozenDueToAge) |
| }) |
| }) |
| } |
| |
| // labelProposals adds the "Proposal" label and "Proposal" milestone |
| // to open issues with title beginning with "Proposal:". It tries not |
| // to get into an edit war with a human. |
| func (b *gopherbot) labelProposals(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if !strings.HasPrefix(gi.Title, "proposal:") && !strings.HasPrefix(gi.Title, "Proposal:") { |
| return nil |
| } |
| // Add Milestone if missing: |
| if gi.Milestone.IsNone() && !gi.HasEvent("milestoned") && !gi.HasEvent("demilestoned") { |
| if err := b.setMilestone(ctx, b.gorepo.ID(), gi, proposal); err != nil { |
| return err |
| } |
| } |
| // Add Proposal label if missing: |
| if !gi.HasLabel("Proposal") && !gi.HasEvent("unlabeled") { |
| if err := b.addLabel(ctx, b.gorepo.ID(), gi, "Proposal"); err != nil { |
| return err |
| } |
| } |
| |
| // Remove NeedsDecision label if exists, but not for Go 2 issues: |
| if !isGo2Issue(gi) && gi.HasLabel("NeedsDecision") && !gopherbotRemovedLabel(gi, "NeedsDecision") { |
| if err := b.removeLabel(ctx, b.gorepo.ID(), gi, "NeedsDecision"); err != nil { |
| return err |
| } |
| } |
| return nil |
| }) |
| } |
| |
| // gopherbotRemovedLabel reports whether gopherbot has |
| // previously removed label in the GitHub issue gi. |
| // |
| // Note that until golang.org/issue/28226 is resolved, |
| // there's a brief delay before maintner catches up on |
| // GitHub issue events and learns that it has happened. |
| func gopherbotRemovedLabel(gi *maintner.GitHubIssue, label string) bool { |
| var hasRemoved bool |
| gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { |
| if e.Actor != nil && e.Actor.ID == gopherbotGitHubID && |
| e.Type == "unlabeled" && |
| e.Label == label { |
| hasRemoved = true |
| return errStopIteration |
| } |
| return nil |
| }) |
| return hasRemoved |
| } |
| |
| // isGo2Issue reports whether gi seems like it's about Go 2, based on either labels or its title. |
| func isGo2Issue(gi *maintner.GitHubIssue) bool { |
| if gi.HasLabel("Go2") { |
| return true |
| } |
| if !strings.Contains(gi.Title, "2") { |
| // Common case. |
| return false |
| } |
| return strings.Contains(gi.Title, "Go 2") || strings.Contains(gi.Title, "go2") || strings.Contains(gi.Title, "Go2") |
| } |
| |
| func (b *gopherbot) setSubrepoMilestones(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") { |
| return nil |
| } |
| if !strings.HasPrefix(gi.Title, "x/") { |
| return nil |
| } |
| pkg := gi.Title |
| if colon := strings.IndexByte(pkg, ':'); colon >= 0 { |
| pkg = pkg[:colon] |
| } |
| if sp := strings.IndexByte(pkg, ' '); sp >= 0 { |
| pkg = pkg[:sp] |
| } |
| switch pkg { |
| case "", |
| "x/arch", |
| "x/crypto/chacha20poly1305", |
| "x/crypto/curve25519", |
| "x/crypto/poly1305", |
| "x/net/http2", |
| "x/net/idna", |
| "x/net/lif", |
| "x/net/proxy", |
| "x/net/route", |
| "x/text/unicode/norm", |
| "x/text/width": |
| // These get vendored in. Don't mess with them. |
| return nil |
| case "x/vgo": |
| // Handled by setMiscMilestones |
| return nil |
| } |
| return b.setMilestone(ctx, b.gorepo.ID(), gi, unreleased) |
| }) |
| } |
| |
| func (b *gopherbot) setMiscMilestones(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") { |
| return nil |
| } |
| if strings.Contains(gi.Title, "gccgo") { // TODO: better gccgo bug report heuristic? |
| return b.setMilestone(ctx, b.gorepo.ID(), gi, gccgo) |
| } |
| if strings.HasPrefix(gi.Title, "x/vgo") { |
| return b.setMilestone(ctx, b.gorepo.ID(), gi, vgo) |
| } |
| if strings.HasPrefix(gi.Title, "x/vuln") { |
| return b.setMilestone(ctx, b.gorepo.ID(), gi, vulnUnplanned) |
| } |
| return nil |
| }) |
| } |
| |
| func (b *gopherbot) setVSCodeGoMilestones(ctx context.Context) error { |
| vscode := b.corpus.GitHub().Repo("golang", "vscode-go") |
| if vscode == nil { |
| return nil |
| } |
| return b.foreachIssue(vscode, open, func(gi *maintner.GitHubIssue) error { |
| if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") { |
| return nil |
| } |
| // Work-around golang/go#40640 by only milestoning new issues. |
| if time.Since(gi.Created) > 24*time.Hour { |
| return nil |
| } |
| return b.setMilestone(ctx, vscode.ID(), gi, vscodeUntriaged) |
| }) |
| } |
| |
| func (b *gopherbot) labelBuildIssues(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if !strings.HasPrefix(gi.Title, "x/build") || gi.HasLabel("Builders") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| return b.addLabel(ctx, b.gorepo.ID(), gi, "Builders") |
| }) |
| } |
| |
| func (b *gopherbot) labelCompilerRuntimeIssues(ctx context.Context) error { |
| entries, err := getAllCodeOwners(ctx) |
| if err != nil { |
| return err |
| } |
| // Filter out any entries that don't contain compiler/runtime owners into |
| // a set of compiler/runtime-owned packages whose names match the names |
| // used in the issue tracker. |
| crtPackages := make(map[string]struct{}) // Key is issue title prefix, like "cmd/compile" or "x/sys/unix." |
| for pkg, entry := range entries { |
| for _, owner := range entry.Primary { |
| name := owner.GitHubUsername |
| if name == "golang/compiler" || name == "golang/runtime" { |
| crtPackages[owners.TranslatePathForIssues(pkg)] = struct{}{} |
| break |
| } |
| } |
| } |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if gi.HasLabel("compiler/runtime") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| components := strings.SplitN(gi.Title, ":", 2) |
| if len(components) != 2 { |
| return nil |
| } |
| for _, p := range strings.Split(strings.TrimSpace(components[0]), ",") { |
| if _, ok := crtPackages[strings.TrimSpace(p)]; !ok { |
| continue |
| } |
| // TODO(mknyszek): Add this issue to the GitHub project as well. |
| return b.addLabel(ctx, b.gorepo.ID(), gi, "compiler/runtime") |
| } |
| return nil |
| }) |
| } |
| |
| func (b *gopherbot) labelMobileIssues(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if !strings.HasPrefix(gi.Title, "x/mobile") || gi.HasLabel("mobile") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| return b.addLabel(ctx, b.gorepo.ID(), gi, "mobile") |
| }) |
| } |
| |
| func (b *gopherbot) labelDocumentationIssues(ctx context.Context) error { |
| const documentation = "Documentation" |
| return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error { |
| if !gardenIssues(repo) { |
| return nil |
| } |
| if !repoHasLabel(repo, documentation) { |
| return nil |
| } |
| return b.foreachIssue(repo, open, func(gi *maintner.GitHubIssue) error { |
| if !isDocumentationTitle(gi.Title) || gi.HasLabel("Documentation") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| return b.addLabel(ctx, repo.ID(), gi, documentation) |
| }) |
| }) |
| } |
| |
| func (b *gopherbot) labelToolsIssues(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if !strings.HasPrefix(gi.Title, "x/tools") || gi.HasLabel("Tools") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| return b.addLabel(ctx, b.gorepo.ID(), gi, "Tools") |
| }) |
| } |
| |
| func (b *gopherbot) labelWebsiteIssues(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| hasWebsiteTitle := strings.HasPrefix(gi.Title, "x/website:") |
| if !hasWebsiteTitle || gi.HasLabel("website") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| return b.addLabel(ctx, b.gorepo.ID(), gi, "website") |
| }) |
| } |
| |
| func (b *gopherbot) labelPkgsiteIssues(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| hasPkgsiteTitle := strings.HasPrefix(gi.Title, "x/pkgsite:") |
| if !hasPkgsiteTitle || gi.HasLabel("pkgsite") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| return b.addLabel(ctx, b.gorepo.ID(), gi, "pkgsite") |
| }) |
| } |
| |
| func (b *gopherbot) labelProxyIssues(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| hasProxyTitle := strings.Contains(gi.Title, "proxy.golang.org") || strings.Contains(gi.Title, "sum.golang.org") || strings.Contains(gi.Title, "index.golang.org") |
| if !hasProxyTitle || gi.HasLabel("proxy.golang.org") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| return b.addLabel(ctx, b.gorepo.ID(), gi, "proxy.golang.org") |
| }) |
| } |
| |
| func (b *gopherbot) labelVulnIssues(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| hasVulnTitle := strings.HasPrefix(gi.Title, "x/vuln:") || strings.HasPrefix(gi.Title, "x/vuln/") || |
| strings.HasPrefix(gi.Title, "x/vulndb:") || strings.HasPrefix(gi.Title, "x/vulndb/") |
| if !hasVulnTitle || gi.HasLabel("vulncheck or vulndb") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| return b.addLabel(ctx, b.gorepo.ID(), gi, "vulncheck or vulndb") |
| }) |
| } |
| |
| // handleGoplsIssues labels and asks for additional information on gopls issues. |
| // |
| // This is necessary because gopls issues often require additional information to diagnose, |
| // and we don't ask for this information in the Go issue template. |
| func (b *gopherbot) handleGoplsIssues(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if !isGoplsTitle(gi.Title) || gi.HasLabel("gopls") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| return b.addLabel(ctx, b.gorepo.ID(), gi, "gopls") |
| }) |
| } |
| |
| func (b *gopherbot) handleTelemetryIssues(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if !strings.HasPrefix(gi.Title, "x/telemetry") || gi.HasLabel("telemetry") || gi.HasEvent("unlabeled") { |
| return nil |
| } |
| return b.addLabel(ctx, b.gorepo.ID(), gi, "telemetry") |
| }) |
| } |
| |
| func (b *gopherbot) closeStaleWaitingForInfo(ctx context.Context) error { |
| const waitingForInfo = "WaitingForInfo" |
| now := time.Now() |
| return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error { |
| if !gardenIssues(repo) { |
| return nil |
| } |
| if !repoHasLabel(repo, waitingForInfo) { |
| return nil |
| } |
| return b.foreachIssue(repo, open, func(gi *maintner.GitHubIssue) error { |
| if !gi.HasLabel(waitingForInfo) { |
| return nil |
| } |
| var waitStart time.Time |
| gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { |
| if e.Type == "reopened" { |
| // Ignore any previous WaitingForInfo label if it's reopend. |
| waitStart = time.Time{} |
| return nil |
| } |
| if e.Label == waitingForInfo { |
| switch e.Type { |
| case "unlabeled": |
| waitStart = time.Time{} |
| case "labeled": |
| waitStart = e.Created |
| } |
| return nil |
| } |
| return nil |
| }) |
| if waitStart.IsZero() { |
| return nil |
| } |
| |
| deadline := waitStart.AddDate(0, 1, 0) // 1 month |
| if gi.HasLabel("CherryPickCandidate") || gi.HasLabel("CherryPickApproved") { |
| // Cherry-pick issues may sometimes need to wait while |
| // fixes get prepared and soak, so give them more time. |
| deadline = waitStart.AddDate(0, 6, 0) |
| } |
| if repo.ID().Repo == "vscode-go" && gi.HasLabel("automatedReport") { |
| // Automated issue reports have low response rates. |
| // Apply shorter timeout. |
| deadline = waitStart.AddDate(0, 0, 7) |
| } |
| if now.Before(deadline) { |
| return nil |
| } |
| |
| var lastOPComment time.Time |
| gi.ForeachComment(func(c *maintner.GitHubComment) error { |
| if c.User.ID == gi.User.ID { |
| lastOPComment = c.Created |
| } |
| return nil |
| }) |
| if lastOPComment.After(waitStart) { |
| return nil |
| } |
| |
| printIssue("close-stale-waiting-for-info", repo.ID(), gi) |
| // TODO: write a task that reopens issues if the OP speaks up. |
| if err := b.addGitHubComment(ctx, repo, gi.Number, |
| "Timed out in state WaitingForInfo. Closing.\n\n(I am just a bot, though. Please speak up if this is a mistake or you have the requested information.)"); err != nil { |
| return fmt.Errorf("b.addGitHubComment(_, %v, %v) = %w", repo.ID(), gi.Number, err) |
| } |
| return b.closeGitHubIssue(ctx, repo.ID(), gi.Number, notPlanned) |
| }) |
| }) |
| } |
| |
| // cl2issue writes "Change https://go.dev/cl/NNNN mentions this issue" |
| // and the change summary on GitHub when a new Gerrit change references a GitHub issue. |
| func (b *gopherbot) cl2issue(ctx context.Context) error { |
| monthAgo := time.Now().Add(-30 * 24 * time.Hour) |
| return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Server() != "go.googlesource.com" { |
| return nil |
| } |
| return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error { |
| if cl.Meta.Commit.AuthorTime.Before(monthAgo) { |
| // If the CL was last updated over a |
| // month ago, assume (as an |
| // optimization) that gopherbot |
| // already processed this issue. |
| return nil |
| } |
| for _, ref := range cl.GitHubIssueRefs { |
| if !gardenIssues(ref.Repo) { |
| continue |
| } |
| gi := ref.Repo.Issue(ref.Number) |
| if gi == nil || gi.NotExist || gi.PullRequest || gi.Locked || b.deletedIssues[githubIssue{ref.Repo.ID(), gi.Number}] { |
| continue |
| } |
| hasComment := false |
| substr := fmt.Sprintf("%d mentions this issue", cl.Number) |
| gi.ForeachComment(func(c *maintner.GitHubComment) error { |
| if strings.Contains(c.Body, substr) { |
| hasComment = true |
| return errStopIteration |
| } |
| return nil |
| }) |
| if hasComment { |
| continue |
| } |
| printIssue("cl2issue", ref.Repo.ID(), gi) |
| msg := fmt.Sprintf("Change https://go.dev/cl/%d mentions this issue: `%s`", cl.Number, cl.Commit.Summary()) |
| if err := b.addGitHubComment(ctx, ref.Repo, gi.Number, msg); err != nil { |
| return err |
| } |
| } |
| return nil |
| }) |
| }) |
| } |
| |
| // canonicalLabelName returns "needsfix" for "needs-fix" or "NeedsFix" |
| // in prep for future label renaming. |
| func canonicalLabelName(s string) string { |
| return strings.Replace(strings.ToLower(s), "-", "", -1) |
| } |
| |
| // If an issue has multiple "needs" labels, remove all but the most recent. |
| // These were originally called NeedsFix, NeedsDecision, and NeedsInvestigation, |
| // but are being renamed to "needs-foo". |
| func (b *gopherbot) updateNeeds(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| var numNeeds int |
| if gi.Labels[needsDecisionID] != nil { |
| numNeeds++ |
| } |
| if gi.Labels[needsFixID] != nil { |
| numNeeds++ |
| } |
| if gi.Labels[needsInvestigationID] != nil { |
| numNeeds++ |
| } |
| if numNeeds <= 1 { |
| return nil |
| } |
| |
| labels := map[string]int{} // lowercase no-hyphen "needsfix" -> position |
| var pos, maxPos int |
| gi.ForeachEvent(func(e *maintner.GitHubIssueEvent) error { |
| var add bool |
| switch e.Type { |
| case "labeled": |
| add = true |
| case "unlabeled": |
| default: |
| return nil |
| } |
| if !strings.HasPrefix(e.Label, "Needs") && !strings.HasPrefix(e.Label, "needs-") { |
| return nil |
| } |
| key := canonicalLabelName(e.Label) |
| pos++ |
| if add { |
| labels[key] = pos |
| maxPos = pos |
| } else { |
| delete(labels, key) |
| } |
| return nil |
| }) |
| if len(labels) <= 1 { |
| return nil |
| } |
| |
| // Remove any label that's not the newest (added in |
| // last position). |
| for _, lab := range gi.Labels { |
| key := canonicalLabelName(lab.Name) |
| if !strings.HasPrefix(key, "needs") || labels[key] == maxPos { |
| continue |
| } |
| printIssue("updateneeds", b.gorepo.ID(), gi) |
| fmt.Printf("\t... removing label %q\n", lab.Name) |
| if err := b.removeLabel(ctx, b.gorepo.ID(), gi, lab.Name); err != nil { |
| return err |
| } |
| } |
| return nil |
| }) |
| } |
| |
| // TODO: Improve this message. Some ideas: |
| // |
| // Provide more helpful info? Amend, don't add 2nd commit, link to a review guide? |
| // Make this a template? May want to provide more dynamic information in the future. |
| // Only show freeze message during freeze. |
| const ( |
| congratsSentence = `Congratulations on opening your first change. Thank you for your contribution!` |
| |
| defaultCongratsMsg = congratsSentence + ` |
| |
| Next steps: |
| A maintainer will review your change and provide feedback. See |
| https://go.dev/doc/contribute#review for more info and tips to get your |
| patch through code review. |
| |
| Most changes in the Go project go through a few rounds of revision. This can be |
| surprising to people new to the project. The careful, iterative review process |
| is our way of helping mentor contributors and ensuring that their contributions |
| have a lasting impact.` |
| |
| // Not all x/ repos are subject to the freeze, and so shouldn't get the |
| // warning about it. See isSubjectToFreeze for the complete list. |
| freezeCongratsMsg = defaultCongratsMsg + ` |
| |
| During May-July and Nov-Jan the Go project is in a code freeze, during which |
| little code gets reviewed or merged. If a reviewer responds with a comment like |
| R=go1.11 or adds a tag like "wait-release", it means that this CL will be |
| reviewed as part of the next development cycle. See https://go.dev/s/release |
| for more details.` |
| ) |
| |
| // If messages containing any of the sentences in this array have been posted |
| // on a CL, don't post again. If you amend the message even slightly, please |
| // prepend the new message to this list, to avoid re-spamming people. |
| // |
| // The first message is the "current" message. |
| var oldCongratsMsgs = []string{ |
| congratsSentence, |
| `It's your first ever CL! Congrats, and thanks for sending!`, |
| } |
| |
| // isSubjectToFreeze returns true if a repository is subject to the release |
| // freeze. x/ repos can be subject if they are vendored into golang/go. |
| func isSubjectToFreeze(repo string) bool { |
| switch repo { |
| case "go": // main repo |
| return true |
| case "crypto", "net", "sys", "text": // vendored x/ repos |
| return true |
| } |
| return false |
| } |
| |
| // Don't want to congratulate people on CL's they submitted a year ago. |
| var congratsEpoch = time.Date(2017, 6, 17, 0, 0, 0, 0, time.UTC) |
| |
| func (b *gopherbot) congratulateNewContributors(ctx context.Context) error { |
| cls := make(map[string]*maintner.GerritCL) |
| b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Server() != "go.googlesource.com" { |
| return nil |
| } |
| return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error { |
| // CLs can be returned by maintner in any order. Note also that |
| // Gerrit CL numbers are sparse (CL N does not guarantee that CL N-1 |
| // exists) and Gerrit issues CL's out of order - it may issue CL N, |
| // then CL (N - 18), then CL (N - 40). |
| if b.knownContributors == nil { |
| b.knownContributors = make(map[string]bool) |
| } |
| if cl.Commit == nil { |
| return nil |
| } |
| email := cl.Commit.Author.Email() |
| if email == "" { |
| email = cl.Commit.Author.Str |
| } |
| if b.knownContributors[email] { |
| return nil |
| } |
| if cls[email] != nil { |
| // this person has multiple CLs; not a new contributor. |
| b.knownContributors[email] = true |
| delete(cls, email) |
| return nil |
| } |
| cls[email] = cl |
| return nil |
| }) |
| }) |
| for email, cl := range cls { |
| // See golang.org/issue/23865 |
| if cl.Branch() == "refs/meta/config" { |
| b.knownContributors[email] = true |
| continue |
| } |
| if cl.Commit == nil || cl.Commit.CommitTime.Before(congratsEpoch) { |
| b.knownContributors[email] = true |
| continue |
| } |
| if cl.Status == "merged" { |
| b.knownContributors[email] = true |
| continue |
| } |
| foundMessage := false |
| congratulatoryMessage := defaultCongratsMsg |
| if isSubjectToFreeze(cl.Project.Project()) { |
| congratulatoryMessage = freezeCongratsMsg |
| } |
| for i := range cl.Messages { |
| // TODO: once gopherbot starts posting these messages and we |
| // have the author's name for Gopherbot, check the author name |
| // matches as well. |
| for j := range oldCongratsMsgs { |
| // Message looks something like "Patch set X:\n\n(our text)" |
| if strings.Contains(cl.Messages[i].Message, oldCongratsMsgs[j]) { |
| foundMessage = true |
| break |
| } |
| } |
| if foundMessage { |
| break |
| } |
| } |
| |
| if foundMessage { |
| b.knownContributors[email] = true |
| continue |
| } |
| // Don't add all of the old congratulatory messages here, since we've |
| // already checked for them above. |
| opts := &gerritCommentOpts{ |
| OldPhrases: []string{congratulatoryMessage}, |
| } |
| err := b.addGerritComment(ctx, cl.ChangeID(), congratulatoryMessage, opts) |
| if err != nil { |
| return fmt.Errorf("could not add comment to golang.org/cl/%d: %v", cl.Number, err) |
| } |
| b.knownContributors[email] = true |
| } |
| return nil |
| } |
| |
| // unwaitCLs removes wait-* hashtags from CLs. |
| func (b *gopherbot) unwaitCLs(ctx context.Context) error { |
| return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Server() != "go.googlesource.com" { |
| return nil |
| } |
| return gp.ForeachOpenCL(func(cl *maintner.GerritCL) error { |
| tags := cl.Meta.Hashtags() |
| if tags.Len() == 0 { |
| return nil |
| } |
| // If the CL is tagged "wait-author", remove |
| // that tag if the author has replied since |
| // the last time the "wait-author" tag was |
| // added. |
| if tags.Contains("wait-author") { |
| // Figure out the last index at which "wait-author" was added. |
| waitAuthorIndex := -1 |
| for i := len(cl.Metas) - 1; i >= 0; i-- { |
| if cl.Metas[i].HashtagsAdded().Contains("wait-author") { |
| waitAuthorIndex = i |
| break |
| } |
| } |
| |
| // Find out whether the author has replied since. |
| authorEmail := cl.Metas[0].Commit.Author.Email() // Equivalent to "{{cl.OwnerID}}@62eb7196-b449-3ce5-99f1-c037f21e1705". |
| hasReplied := false |
| for _, m := range cl.Metas[waitAuthorIndex+1:] { |
| if m.Commit.Author.Email() == authorEmail { |
| hasReplied = true |
| break |
| } |
| } |
| if hasReplied { |
| log.Printf("https://go.dev/cl/%d -- remove wait-author; reply from %s", cl.Number, cl.Owner()) |
| err := b.onLatestCL(ctx, cl, func() error { |
| if *dryRun { |
| log.Printf("[dry run] would remove hashtag 'wait-author' from CL %d", cl.Number) |
| return nil |
| } |
| _, err := b.gerrit.RemoveHashtags(ctx, fmt.Sprint(cl.Number), "wait-author") |
| if err != nil { |
| log.Printf("https://go.dev/cl/%d: error removing wait-author: %v", cl.Number, err) |
| return err |
| } |
| log.Printf("https://go.dev/cl/%d: removed wait-author", cl.Number) |
| return nil |
| }) |
| if err != nil { |
| return err |
| } |
| } |
| } |
| return nil |
| }) |
| }) |
| } |
| |
| // onLatestCL checks whether cl's metadata is up to date with Gerrit's |
| // upstream data and, if so, returns f(). If it's out of date, it does |
| // nothing more and returns nil. |
| func (b *gopherbot) onLatestCL(ctx context.Context, cl *maintner.GerritCL, f func() error) error { |
| ci, err := b.gerrit.GetChangeDetail(ctx, fmt.Sprint(cl.Number), gerrit.QueryChangesOpt{Fields: []string{"MESSAGES"}}) |
| if err != nil { |
| return err |
| } |
| if len(ci.Messages) == 0 { |
| log.Printf("onLatestCL: CL %d has no messages. Odd. Ignoring.", cl.Number) |
| return nil |
| } |
| latestGerritID := ci.Messages[len(ci.Messages)-1].ID |
| // Check all metas and not just the latest, because there are some meta commits |
| // that don't have a corresponding message in the Gerrit REST API response. |
| for i := len(cl.Metas) - 1; i >= 0; i-- { |
| metaHash := cl.Metas[i].Commit.Hash.String() |
| if metaHash == latestGerritID { |
| // latestGerritID is contained by maintner metadata for this CL, so run f(). |
| return f() |
| } |
| } |
| log.Printf("onLatestCL: maintner metadata for CL %d is behind; skipping action for now.", cl.Number) |
| return nil |
| } |
| |
| // fetchReleases returns the two most recent major Go 1.x releases, and |
| // the next upcoming release, sorted and formatted like []string{"1.9", "1.10", "1.11"}. |
| // It also returns the next minor release for each major release, |
| // like map[string]string{"1.9": "1.9.7", "1.10": "1.10.4", "1.11": "1.11.1"}. |
| // |
| // The data returned is fetched from Maintner Service occasionally |
| // and cached for some time. |
| func (b *gopherbot) fetchReleases(ctx context.Context) (major []string, nextMinor map[string]string, _ error) { |
| b.releases.Lock() |
| defer b.releases.Unlock() |
| |
| if expiry := b.releases.lastUpdate.Add(10 * time.Minute); time.Now().Before(expiry) { |
| return b.releases.major, b.releases.nextMinor, nil |
| } |
| |
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| defer cancel() |
| resp, err := b.mc.ListGoReleases(ctx, &apipb.ListGoReleasesRequest{}) |
| if err != nil { |
| return nil, nil, err |
| } |
| rs := resp.Releases // Supported Go releases, sorted with latest first. |
| |
| nextMinor = make(map[string]string) |
| for i := len(rs) - 1; i >= 0; i-- { |
| x, y, z := rs[i].Major, rs[i].Minor, rs[i].Patch |
| major = append(major, fmt.Sprintf("%d.%d", x, y)) |
| nextMinor[fmt.Sprintf("%d.%d", x, y)] = fmt.Sprintf("%d.%d.%d", x, y, z+1) |
| } |
| // Include the next release in the list of major releases. |
| if len(rs) > 0 { |
| // Assume the next major release after Go X.Y is Go X.(Y+1). This is true more often than not. |
| nextX, nextY := rs[0].Major, rs[0].Minor+1 |
| major = append(major, fmt.Sprintf("%d.%d", nextX, nextY)) |
| nextMinor[fmt.Sprintf("%d.%d", nextX, nextY)] = fmt.Sprintf("%d.%d.1", nextX, nextY) |
| } |
| |
| b.releases.major = major |
| b.releases.nextMinor = nextMinor |
| b.releases.lastUpdate = time.Now() |
| |
| return major, nextMinor, nil |
| } |
| |
| // openCherryPickIssues opens CherryPickCandidate issues for backport when |
| // asked on the main issue. |
| func (b *gopherbot) openCherryPickIssues(ctx context.Context) error { |
| return b.foreachIssue(b.gorepo, open|closed|includePRs, func(gi *maintner.GitHubIssue) error { |
| if gi.HasLabel("CherryPickApproved") && gi.HasLabel("CherryPickCandidate") { |
| if err := b.removeLabel(ctx, b.gorepo.ID(), gi, "CherryPickCandidate"); err != nil { |
| return err |
| } |
| } |
| if gi.Locked || gi.PullRequest { |
| return nil |
| } |
| var backportComment *maintner.GitHubComment |
| if err := gi.ForeachComment(func(c *maintner.GitHubComment) error { |
| if strings.HasPrefix(c.Body, "Backport issue(s) opened") { |
| backportComment = nil |
| return errStopIteration |
| } |
| body := strings.ToLower(c.Body) |
| if strings.Contains(body, "@gopherbot") && |
| strings.Contains(body, "please") && |
| strings.Contains(body, "backport") { |
| backportComment = c |
| } |
| return nil |
| }); err != nil && err != errStopIteration { |
| return err |
| } |
| if backportComment == nil { |
| return nil |
| } |
| |
| // Figure out releases to open backport issues for. |
| var selectedReleases []string |
| majorReleases, _, err := b.fetchReleases(ctx) |
| if err != nil { |
| return err |
| } |
| for _, r := range majorReleases { |
| if strings.Contains(backportComment.Body, r) { |
| selectedReleases = append(selectedReleases, r) |
| } |
| } |
| if len(selectedReleases) == 0 { |
| // Only backport to major releases unless explicitly |
| // asked to backport to the upcoming release. |
| selectedReleases = majorReleases[:len(majorReleases)-1] |
| } |
| |
| // Figure out extra labels to include from the main issue. |
| // Only copy a subset that's relevant to backport issue management. |
| var extraLabels []string |
| for _, l := range [...]string{ |
| "Security", |
| "GoCommand", |
| "Testing", |
| } { |
| if gi.HasLabel(l) { |
| extraLabels = append(extraLabels, l) |
| } |
| } |
| |
| // Open backport issues. |
| var openedIssues []string |
| for _, rel := range selectedReleases { |
| printIssue("open-backport-issue-"+rel, b.gorepo.ID(), gi) |
| id, err := b.createGitHubIssue(ctx, |
| fmt.Sprintf("%s [%s backport]", gi.Title, rel), |
| fmt.Sprintf("@%s requested issue #%d to be considered for backport to the next %s minor release.\n\n%s\n", |
| backportComment.User.Login, gi.Number, rel, blockqoute(backportComment.Body)), |
| append([]string{"CherryPickCandidate"}, extraLabels...), backportComment.Created) |
| if err != nil { |
| return err |
| } |
| openedIssues = append(openedIssues, fmt.Sprintf("#%d (for %s)", id, rel)) |
| } |
| 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, ", "))) |
| }) |
| } |
| |
| // setMinorMilestones applies the next minor release milestone |
| // to issues with [1.X backport] in the title. |
| func (b *gopherbot) setMinorMilestones(ctx context.Context) error { |
| majorReleases, nextMinor, err := b.fetchReleases(ctx) |
| if err != nil { |
| return err |
| } |
| return b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if !gi.Milestone.IsNone() || gi.HasEvent("demilestoned") || gi.HasEvent("milestoned") { |
| return nil |
| } |
| var majorRel string |
| for _, r := range majorReleases { |
| if strings.Contains(gi.Title, "backport") && strings.HasSuffix(gi.Title, "["+r+" backport]") { |
| majorRel = r |
| } |
| } |
| if majorRel == "" { |
| return nil |
| } |
| if _, ok := nextMinor[majorRel]; !ok { |
| return fmt.Errorf("internal error: fetchReleases returned majorReleases=%q nextMinor=%q, and nextMinor doesn't have %q", majorReleases, nextMinor, majorRel) |
| } |
| lowerTitle := "go" + nextMinor[majorRel] |
| var nextMinorMilestone milestone |
| if b.gorepo.ForeachMilestone(func(m *maintner.GitHubMilestone) error { |
| if m.Closed || strings.ToLower(m.Title) != lowerTitle { |
| return nil |
| } |
| nextMinorMilestone = milestone{ |
| Number: int(m.Number), |
| Name: m.Title, |
| } |
| return errStopIteration |
| }); nextMinorMilestone == (milestone{}) { |
| // Fail silently, the milestone might not exist yet. |
| log.Printf("Failed to apply minor release milestone to issue %d", gi.Number) |
| return nil |
| } |
| return b.setMilestone(ctx, b.gorepo.ID(), gi, nextMinorMilestone) |
| }) |
| } |
| |
| // closeCherryPickIssues closes cherry-pick issues when CLs are merged to |
| // release branches, as GitHub only does that on merge to master. |
| func (b *gopherbot) closeCherryPickIssues(ctx context.Context) error { |
| cherryPickIssues := make(map[int32]*maintner.GitHubIssue) // by GitHub Issue Number |
| b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error { |
| if gi.Milestone.IsNone() || gi.HasEvent("reopened") { |
| return nil |
| } |
| if !strings.HasPrefix(gi.Milestone.Title, "Go") { |
| return nil |
| } |
| cherryPickIssues[gi.Number] = gi |
| return nil |
| }) |
| monthAgo := time.Now().Add(-30 * 24 * time.Hour) |
| return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Server() != "go.googlesource.com" { |
| return nil |
| } |
| return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error { |
| if cl.Commit.CommitTime.Before(monthAgo) { |
| // If the CL was last updated over a month ago, assume (as an |
| // optimization) that gopherbot already processed this CL. |
| return nil |
| } |
| if cl.Status != "merged" || cl.Private || !strings.HasPrefix(cl.Branch(), "release-branch.") { |
| return nil |
| } |
| clBranchVersion := cl.Branch()[len("release-branch."):] // "go1.11" or "go1.12". |
| for _, ref := range cl.GitHubIssueRefs { |
| if ref.Repo != b.gorepo { |
| continue |
| } |
| gi, ok := cherryPickIssues[ref.Number] |
| if !ok { |
| continue |
| } |
| if !strutil.HasPrefixFold(gi.Milestone.Title, clBranchVersion) { |
| // This issue's milestone (e.g., "Go1.11.6", "Go1.12", "Go1.12.1", etc.) |
| // doesn't match the CL branch goX.Y version, so skip it. |
| continue |
| } |
| printIssue("close-cherry-pick", ref.Repo.ID(), gi) |
| if err := b.addGitHubComment(ctx, ref.Repo, gi.Number, fmt.Sprintf( |
| "Closed by merging %s to %s.", cl.Commit.Hash, cl.Branch())); err != nil { |
| return err |
| } |
| return b.closeGitHubIssue(ctx, ref.Repo.ID(), gi.Number, completed) |
| } |
| return nil |
| }) |
| }) |
| } |
| |
| type labelCommand struct { |
| action string // "add" or "remove" |
| label string // the label name |
| created time.Time // creation time of the comment containing the command |
| noop bool // whether to apply the command or not |
| } |
| |
| // applyLabelsFromComments looks within open GitHub issues for commands to add or |
| // remove labels. Anyone can use the /label <label> or /unlabel <label> commands. |
| func (b *gopherbot) applyLabelsFromComments(ctx context.Context) error { |
| return b.corpus.GitHub().ForeachRepo(func(repo *maintner.GitHubRepo) error { |
| if !gardenIssues(repo) { |
| return nil |
| } |
| |
| allLabels := make(map[string]string) // lowercase label name -> proper casing |
| repo.ForeachLabel(func(gl *maintner.GitHubLabel) error { |
| allLabels[strings.ToLower(gl.Name)] = gl.Name |
| return nil |
| }) |
| |
| return b.foreachIssue(repo, open|includePRs, func(gi *maintner.GitHubIssue) error { |
| var cmds []labelCommand |
| |
| cmds = append(cmds, labelCommandsFromBody(gi.Body, gi.Created)...) |
| gi.ForeachComment(func(gc *maintner.GitHubComment) error { |
| cmds = append(cmds, labelCommandsFromBody(gc.Body, gc.Created)...) |
| return nil |
| }) |
| |
| for i, c := range cmds { |
| // Does the label even exist? If so, use the proper capitalization. |
| // If it doesn't exist, the command is a no-op. |
| if l, ok := allLabels[c.label]; ok { |
| cmds[i].label = l |
| } else { |
| cmds[i].noop = true |
| continue |
| } |
| |
| // If any action has been taken on the label since the comment containing |
| // the command to add or remove it, then it should be a no-op. |
| gi.ForeachEvent(func(ge *maintner.GitHubIssueEvent) error { |
| if (ge.Type == "unlabeled" || ge.Type == "labeled") && |
| strings.ToLower(ge.Label) == c.label && |
| ge.Created.After(c.created) { |
| cmds[i].noop = true |
| return errStopIteration |
| } |
| return nil |
| }) |
| } |
| |
| toAdd, toRemove := mutationsFromCommands(cmds) |
| if err := b.addLabels(ctx, repo.ID(), gi, toAdd); err != nil { |
| log.Printf("Unable to add labels (%v) to issue %d: %v", toAdd, gi.Number, err) |
| } |
| if err := b.removeLabels(ctx, repo.ID(), gi, toRemove); err != nil { |
| log.Printf("Unable to remove labels (%v) from issue %d: %v", toRemove, gi.Number, err) |
| } |
| |
| return nil |
| }) |
| }) |
| } |
| |
| // labelCommandsFromBody returns a slice of commands inferred by the given body text. |
| // The format of commands is: |
| // @gopherbot[,] [please] [add|remove] <label>[{,|;} label... and remove <label>...] |
| // Omission of add or remove will default to adding a label. |
| func labelCommandsFromBody(body string, created time.Time) []labelCommand { |
| if !strutil.ContainsFold(body, "@gopherbot") { |
| return nil |
| } |
| var cmds []labelCommand |
| lines := strings.Split(body, "\n") |
| for _, l := range lines { |
| if !strutil.ContainsFold(l, "@gopherbot") { |
| continue |
| } |
| l = strings.ToLower(l) |
| scanner := bufio.NewScanner(strings.NewReader(l)) |
| scanner.Split(bufio.ScanWords) |
| var ( |
| add strings.Builder |
| remove strings.Builder |
| inRemove bool |
| ) |
| for scanner.Scan() { |
| switch scanner.Text() { |
| case "@gopherbot", "@gopherbot,", "@gopherbot:", "please", "and", "label", "labels": |
| continue |
| case "add": |
| inRemove = false |
| continue |
| case "remove", "unlabel": |
| inRemove = true |
| continue |
| } |
| |
| if inRemove { |
| remove.WriteString(scanner.Text()) |
| remove.WriteString(" ") // preserve whitespace within labels |
| } else { |
| add.WriteString(scanner.Text()) |
| add.WriteString(" ") // preserve whitespace within labels |
| } |
| } |
| if add.Len() > 0 { |
| cmds = append(cmds, labelCommands(add.String(), "add", created)...) |
| } |
| if remove.Len() > 0 { |
| cmds = append(cmds, labelCommands(remove.String(), "remove", created)...) |
| } |
| } |
| return cmds |
| } |
| |
| // labelCommands returns a slice of commands for the given action and string of |
| // text following commands like @gopherbot add/remove. |
| func labelCommands(s, action string, created time.Time) []labelCommand { |
| var cmds []labelCommand |
| f := func(c rune) bool { |
| return c != '-' && !unicode.IsLetter(c) && !unicode.IsNumber(c) && !unicode.IsSpace(c) |
| } |
| for _, label := range strings.FieldsFunc(s, f) { |
| label = strings.TrimSpace(label) |
| if label == "" { |
| continue |
| } |
| cmds = append(cmds, labelCommand{action: action, label: label, created: created}) |
| } |
| return cmds |
| } |
| |
| // mutationsFromCommands returns two sets of labels to add and remove based on |
| // the given cmds. |
| func mutationsFromCommands(cmds []labelCommand) (add, remove []string) { |
| // Split the labels into what to add and what to remove. |
| // Account for two opposing commands that have yet to be applied canceling |
| // each other out. |
| var ( |
| toAdd map[string]bool |
| toRemove map[string]bool |
| ) |
| for _, c := range cmds { |
| if c.noop { |
| continue |
| } |
| switch c.action { |
| case "add": |
| if toRemove[c.label] { |
| delete(toRemove, c.label) |
| continue |
| } |
| if toAdd == nil { |
| toAdd = make(map[string]bool) |
| } |
| toAdd[c.label] = true |
| case "remove": |
| if toAdd[c.label] { |
| delete(toAdd, c.label) |
| continue |
| } |
| if toRemove == nil { |
| toRemove = make(map[string]bool) |
| } |
| toRemove[c.label] = true |
| default: |
| log.Printf("Invalid label action type: %q", c.action) |
| } |
| } |
| |
| for l := range toAdd { |
| if toAdd[l] && !labelChangeDisallowed(l, "add") { |
| add = append(add, l) |
| } |
| } |
| |
| for l := range toRemove { |
| if toRemove[l] && !labelChangeDisallowed(l, "remove") { |
| remove = append(remove, l) |
| } |
| } |
| return add, remove |
| } |
| |
| // labelChangeDisallowed reports whether an action on the given label is |
| // forbidden via gopherbot. |
| func labelChangeDisallowed(label, action string) bool { |
| if action == "remove" && label == "Security" { |
| return true |
| } |
| for _, prefix := range []string{ |
| "CherryPick", |
| "cla:", |
| "Proposal-", |
| } { |
| if strings.HasPrefix(label, prefix) { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // assignReviewersOptOut lists contributors who have opted out from |
| // having reviewers automatically added to their CLs. |
| var assignReviewersOptOut = map[string]bool{ |
| "mdempsky@google.com": true, |
| } |
| |
| // assignReviewersToCLs looks for CLs with no humans in the reviewer or CC fields |
| // that have been open for a short amount of time (enough of a signal that the |
| // author does not intend to add anyone to the review), then assigns reviewers/CCs |
| // using the go.dev/s/owners API. |
| func (b *gopherbot) assignReviewersToCLs(ctx context.Context) error { |
| const tagNoOwners = "no-owners" |
| b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Project() == "scratch" || gp.Server() != "go.googlesource.com" { |
| return nil |
| } |
| gp.ForeachOpenCL(func(cl *maintner.GerritCL) error { |
| if cl.Private || cl.WorkInProgress() || time.Since(cl.Created) < 10*time.Minute { |
| return nil |
| } |
| if assignReviewersOptOut[cl.Owner().Email()] { |
| return nil |
| } |
| |
| // Don't auto-assign reviewers to CLs on shared branches; |
| // the presumption is that developers there will know which |
| // reviewers to assign. |
| if strings.HasPrefix(cl.Branch(), "dev.") { |
| return nil |
| } |
| |
| tags := cl.Meta.Hashtags() |
| if tags.Contains(tagNoOwners) { |
| return nil |
| } |
| |
| gc := gerritChange{gp.Project(), cl.Number} |
| if b.deletedChanges[gc] { |
| return nil |
| } |
| if strutil.ContainsFold(cl.Commit.Msg, "do not submit") || strutil.ContainsFold(cl.Commit.Msg, "do not review") { |
| return nil |
| } |
| |
| currentReviewers, ok := b.humanReviewersOnChange(ctx, gc, cl) |
| if ok { |
| return nil |
| } |
| log.Printf("humanReviewersOnChange reported insufficient reviewers or CC on CL %d, attempting to add some", cl.Number) |
| |
| changeURL := fmt.Sprintf("https://go-review.googlesource.com/c/%s/+/%d", gp.Project(), cl.Number) |
| files, err := b.gerrit.ListFiles(ctx, gc.ID(), cl.Commit.Hash.String()) |
| if err != nil { |
| log.Printf("Could not get change %+v: %v", gc, err) |
| if httpErr, ok := err.(*gerrit.HTTPError); ok && httpErr.Res.StatusCode == http.StatusNotFound { |
| b.deletedChanges[gc] = true |
| } |
| return nil |
| } |
| |
| var paths []string |
| for f := range files { |
| if f == "/COMMIT_MSG" { |
| continue |
| } |
| paths = append(paths, gp.Project()+"/"+f) |
| } |
| |
| entries, err := getCodeOwners(ctx, paths) |
| if err != nil { |
| log.Printf("Could not get owners for change %s: %v", changeURL, err) |
| return nil |
| } |
| |
| // Remove owners that can't be reviewers. |
| entries = filterGerritOwners(entries) |
| |
| authorEmail := cl.Commit.Author.Email() |
| merged := mergeOwnersEntries(entries, authorEmail) |
| if len(merged.Primary) == 0 && len(merged.Secondary) == 0 { |
| // No owners found for the change. Add the #no-owners tag. |
| log.Printf("Adding no-owners tag to change %s...", changeURL) |
| if *dryRun { |
| return nil |
| } |
| if _, err := b.gerrit.AddHashtags(ctx, gc.ID(), tagNoOwners); err != nil { |
| log.Printf("Could not add hashtag to change %q: %v", gc.ID(), err) |
| return nil |
| } |
| return nil |
| } |
| |
| // Assign reviewers. |
| var review gerrit.ReviewInput |
| for _, owner := range merged.Primary { |
| review.Reviewers = append(review.Reviewers, gerrit.ReviewerInput{Reviewer: owner.GerritEmail}) |
| } |
| for _, owner := range merged.Secondary { |
| review.Reviewers = append(review.Reviewers, gerrit.ReviewerInput{Reviewer: owner.GerritEmail, State: "CC"}) |
| } |
| |
| // If the reviewers that would be set are the same as the existing |
| // reviewers (minus the bots), there is no work to be done. |
| if sameReviewers(currentReviewers, review) { |
| log.Printf("Setting review %+v on %s would have no effect, continuing", review, changeURL) |
| return nil |
| } |
| if *dryRun { |
| log.Printf("[dry run] Would set review on %s: %+v", changeURL, review) |
| return nil |
| } |
| log.Printf("Setting review on %s: %+v", changeURL, review) |
| if err := b.gerrit.SetReview(ctx, gc.ID(), "current", review); err != nil { |
| log.Printf("Could not set review for change %q: %v", gc.ID(), err) |
| return nil |
| } |
| return nil |
| }) |
| return nil |
| }) |
| return nil |
| } |
| |
| func sameReviewers(reviewers []string, review gerrit.ReviewInput) bool { |
| if len(reviewers) != len(review.Reviewers) { |
| return false |
| } |
| sort.Strings(reviewers) |
| var people []*gophers.Person |
| for _, id := range reviewers { |
| p := gophers.GetPerson(fmt.Sprintf("%s%s", id, gerritInstanceID)) |
| // If an existing reviewer is not known to us, we have no way of |
| // checking if these reviewer lists are identical. |
| if p == nil { |
| return false |
| } |
| people = append(people, p) |
| } |
| sort.Slice(review.Reviewers, func(i, j int) bool { |
| return review.Reviewers[i].Reviewer < review.Reviewers[j].Reviewer |
| }) |
| // Check if any of the person's emails match the expected reviewer email. |
| outer: |
| for i, p := range people { |
| reviewerEmail := review.Reviewers[i].Reviewer |
| for _, email := range p.Emails { |
| if email == reviewerEmail { |
| continue outer |
| } |
| } |
| return false |
| } |
| return true |
| } |
| |
| // abandonScratchReviews abandons Gerrit CLs in the "scratch" project if they've been open for over a week. |
| func (b *gopherbot) abandonScratchReviews(ctx context.Context) error { |
| tooOld := time.Now().Add(-24 * time.Hour * 7) |
| return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Project() != "scratch" || gp.Server() != "go.googlesource.com" { |
| return nil |
| } |
| return gp.ForeachOpenCL(func(cl *maintner.GerritCL) error { |
| if b.deletedChanges[gerritChange{gp.Project(), cl.Number}] || !cl.Meta.Commit.CommitTime.Before(tooOld) { |
| return nil |
| } |
| if *dryRun { |
| log.Printf("[dry-run] would've closed scratch CL https://go.dev/cl/%d ...", cl.Number) |
| return nil |
| } |
| log.Printf("closing scratch CL https://go.dev/cl/%d ...", cl.Number) |
| err := b.gerrit.AbandonChange(ctx, fmt.Sprint(cl.Number), "Auto-abandoning old scratch review.") |
| if err != nil && strings.Contains(err.Error(), "404 Not Found") { |
| return nil |
| } |
| return err |
| }) |
| }) |
| } |
| |
| func (b *gopherbot) whoNeedsAccess(ctx context.Context) error { |
| // We only run this task if it was explicitly requested via |
| // the --only-run flag. |
| if *onlyRun == "" { |
| return nil |
| } |
| level := map[int64]int{} // gerrit id -> 1 for try, 2 for submit |
| ais, err := b.gerrit.GetGroupMembers(ctx, "may-start-trybots") |
| if err != nil { |
| return err |
| } |
| for _, ai := range ais { |
| level[ai.NumericID] = 1 |
| } |
| ais, err = b.gerrit.GetGroupMembers(ctx, "approvers") |
| if err != nil { |
| return err |
| } |
| for _, ai := range ais { |
| level[ai.NumericID] = 2 |
| } |
| |
| quarterAgo := time.Now().Add(-90 * 24 * time.Hour) |
| missing := map[string]int{} // "only level N: $WHO" -> number of CLs for that user |
| err = b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Server() != "go.googlesource.com" { |
| return nil |
| } |
| return gp.ForeachCLUnsorted(func(cl *maintner.GerritCL) error { |
| if cl.Meta.Commit.AuthorTime.Before(quarterAgo) { |
| return nil |
| } |
| authorID := int64(cl.OwnerID()) |
| if authorID == -1 { |
| return nil |
| } |
| if level[authorID] == 2 { |
| return nil |
| } |
| missing[fmt.Sprintf("only level %d: %v", level[authorID], cl.Commit.Author)]++ |
| return nil |
| }) |
| }) |
| if err != nil { |
| return err |
| } |
| var people []string |
| for who := range missing { |
| people = append(people, who) |
| } |
| sort.Slice(people, func(i, j int) bool { return missing[people[j]] < missing[people[i]] }) |
| fmt.Println("Number of CLs created in last 90 days | Access (0=none, 1=trybots) | Author") |
| for i, who := range people { |
| num := missing[who] |
| if num < 3 { |
| break |
| } |
| fmt.Printf("%3d: %s\n", num, who) |
| if i == 20 { |
| break |
| } |
| } |
| return nil |
| } |
| |
| // humanReviewersOnChange reports whether there is (or was) a sufficient |
| // number of human reviewers in the given change, and returns the IDs of |
| // the current human reviewers. It includes reviewers in REVIEWER and CC |
| // states. |
| // |
| // The given gerritChange works as a key for deletedChanges. |
| func (b *gopherbot) humanReviewersOnChange(ctx context.Context, change gerritChange, cl *maintner.GerritCL) ([]string, bool) { |
| // The CL's owner will be GerritBot if it is imported from a PR. |
| // In that case, if the CL's author has a Gerrit account, they will be |
| // added as a reviewer (go.dev/issue/30265). Otherwise, no reviewers |
| // will be added. Work around this by requiring 2 human reviewers on PRs. |
| ownerID := strconv.Itoa(cl.OwnerID()) |
| isPR := ownerID == gerritbotGerritID |
| minHumans := 1 |
| if isPR { |
| minHumans = 2 |
| } |
| reject := []string{gobotGerritID, gerritbotGerritID, kokoroGerritID, goLUCIGerritID, triciumGerritID, ownerID} |
| ownerOrRobot := func(gerritID string) bool { |
| for _, r := range reject { |
| if gerritID == r { |
| return true |
| } |
| } |
| return false |
| } |
| |
| ids := slices.DeleteFunc(reviewersInMetas(cl.Metas), ownerOrRobot) |
| if len(ids) >= minHumans { |
| return ids, true |
| } |
| |
| reviewers, err := b.gerrit.ListReviewers(ctx, change.ID()) |
| if err != nil { |
| if httpErr, ok := err.(*gerrit.HTTPError); ok && httpErr.Res.StatusCode == http.StatusNotFound { |
| b.deletedChanges[change] = true |
| } |
| log.Printf("Could not list reviewers on change %q: %v", change.ID(), err) |
| return nil, true |
| } |
| ids = []string{} |
| for _, r := range reviewers { |
| id := strconv.FormatInt(r.NumericID, 10) |
| if hasServiceUserTag(r.AccountInfo) || ownerOrRobot(id) { |
| // Skip bots and owner. |
| continue |
| } |
| ids = append(ids, id) |
| } |
| return ids, len(ids) >= minHumans |
| } |
| |
| // hasServiceUserTag reports whether the account has a SERVICE_USER tag. |
| func hasServiceUserTag(a gerrit.AccountInfo) bool { |
| for _, t := range a.Tags { |
| if t == "SERVICE_USER" { |
| return true |
| } |
| } |
| return false |
| } |
| |
| // autoSubmitCLs submits CLs which are labelled "Auto-Submit", |
| // have all submit requirements satisfied according to Gerrit, and |
| // aren't waiting for a parent CL in the stack to be handled. |
| // |
| // See go.dev/issue/48021. |
| func (b *gopherbot) autoSubmitCLs(ctx context.Context) error { |
| return b.corpus.Gerrit().ForeachProjectUnsorted(func(gp *maintner.GerritProject) error { |
| if gp.Server() != "go.googlesource.com" { |
| return nil |
| } |
| return gp.ForeachOpenCL(func(cl *maintner.GerritCL) error { |
| gc := gerritChange{gp.Project(), cl.Number} |
| if b.deletedChanges[gc] { |
| return nil |
| } |
| |
| // Break out early (before making Gerrit API calls) if the Auto-Submit label |
| // hasn't been used at all in this CL. |
| var autosubmitPresent bool |
| for _, meta := range cl.Metas { |
| if strings.Contains(meta.Commit.Msg, "\nLabel: Auto-Submit") { |
| autosubmitPresent = true |
| break |
| } |
| } |
| if !autosubmitPresent { |
| return nil |
| } |
| |
| // Skip this CL if Auto-Submit+1 isn't actively set on it. |
| changeInfo, err := b.gerrit.GetChange(ctx, fmt.Sprint(cl.Number), gerrit.QueryChangesOpt{Fields: []string{"LABELS", "SUBMITTABLE"}}) |
| if err != nil { |
| if httpErr, ok := err.(*gerrit.HTTPError); ok && httpErr.Res.StatusCode == http.StatusNotFound { |
| b.deletedChanges[gc] = true |
| } |
| log.Printf("Could not retrieve change %q: %v", gc.ID(), err) |
| return nil |
| } |
| if autosubmitActive := changeInfo.Labels["Auto-Submit"].Approved != nil; !autosubmitActive { |
| return nil |
| } |
| // NOTE: we might be able to skip this as well, since the revision action |
| // check will also cover this... |
| if !changeInfo.Submittable { |
| return nil |
| } |
| |
| // We need to check the mergeability, as well as the submitability, |
| // as the latter doesn't take into account merge conflicts, just |
| // if the change satisfies the project submit rules. |
| // |
| // NOTE: this may now be redundant, since the revision action check |
| // below will also inherently checks mergeability, since the change |
| // cannot actually be submitted if there is a merge conflict. We |
| // may be able to just skip this entirely. |
| mi, err := b.gerrit.GetMergeable(ctx, fmt.Sprint(cl.Number), "current") |
| if err != nil { |
| return err |
| } |
| if !mi.Mergeable || mi.CommitMerged { |
| return nil |
| } |
| |
| ra, err := b.gerrit.GetRevisionActions(ctx, fmt.Sprint(cl.Number), "current") |
| if err != nil { |
| return err |
| } |
| if ra["submit"] == nil || !ra["submit"].Enabled { |
| return nil |
| } |
| |
| // If this change is part of a stack, we'd like to merge the stack |
| // in the correct order (i.e. from the bottom of the stack to the |
| // top), so we'll only merge the current change if every change |
| // below it in the stack is either merged, or abandoned. |
| // GetRelatedChanges gives us the stack from top to bottom (the |
| // order of the git commits, from newest to oldest, see Gerrit |
| // documentation for RelatedChangesInfo), so first we find our |
| // change in the stack, then check everything below it. |
| relatedChanges, err := b.gerrit.GetRelatedChanges(ctx, fmt.Sprint(cl.Number), "current") |
| if err != nil { |
| return err |
| } |
| if len(relatedChanges.Changes) > 0 { |
| var parentChanges bool |
| for _, ci := range relatedChanges.Changes { |
| if !parentChanges { |
| // Skip everything before the change we are checking, as |
| // they are the children of this change, and we only care |
| // about the parents. |
| parentChanges = ci.ChangeNumber == cl.Number |
| continue |
| } |
| if ci.Status != gerrit.ChangeStatusAbandoned && |
| ci.Status != gerrit.ChangeStatusMerged { |
| return nil |
| } |
| // We do not check the revision number of merged/abandoned |
| // parents since, even if they are not current according to |
| // gerrit, if there were any merge conflicts, caused by the |
| // diffs between the revision this change was based on and |
| // the current revision, the change would not be considered |
| // submittable anyway. |
| } |
| } |
| |
| if *dryRun { |
| log.Printf("[dry-run] would've submitted CL https://golang.org/cl/%d ...", cl.Number) |
| return nil |
| } |
| log.Printf("submitting CL https://golang.org/cl/%d ...", cl.Number) |
| |
| // TODO: if maintner isn't fast enough (or is too fast) and it re-runs this |
| // before the submission is noticed, we may run this more than once. This |
| // could be handled with a local cache of "recently submitted" changes to |
| // be ignored. |
| _, err = b.gerrit.SubmitChange(ctx, fmt.Sprint(cl.Number)) |
| return err |
| }) |
| }) |
| } |
| |
| type issueFlags uint8 |
| |
| const ( |
| open issueFlags = 1 << iota // Include open issues. |
| closed // Include closed issues. |
| includePRs // Include issues that are Pull Requests. |
| includeGone // Include issues that are gone (e.g., deleted or transferred). |
| ) |
| |
| // foreachIssue calls fn for each issue in repo gr as controlled by flags. |
| // |
| // If fn returns an error, iteration ends and foreachIssue returns |
| // with that error. |
| // |
| // The fn function is called serially, with increasingly numbered |
| // issues. |
| func (b *gopherbot) foreachIssue(gr *maintner.GitHubRepo, flags issueFlags, fn func(*maintner.GitHubIssue) error) error { |
| return gr.ForeachIssue(func(gi *maintner.GitHubIssue) error { |
| switch { |
| case (flags&open == 0) && !gi.Closed, |
| (flags&closed == 0) && gi.Closed, |
| (flags&includePRs == 0) && gi.PullRequest, |
| (flags&includeGone == 0) && (gi.NotExist || b.deletedIssues[githubIssue{gr.ID(), gi.Number}]): |
| // Skip issue. |
| return nil |
| default: |
| return fn(gi) |
| } |
| }) |
| } |
| |
| // reviewerRe extracts the reviewer's Gerrit ID from a line that looks like: |
| // |
| // Reviewer: Rebecca Stambler <16140@62eb7196-b449-3ce5-99f1-c037f21e1705> |
| var reviewerRe = regexp.MustCompile(`.* <(?P<id>\d+)@.*>`) |
| |
| const gerritInstanceID = "@62eb7196-b449-3ce5-99f1-c037f21e1705" |
| |
| // reviewersInMetas returns the unique Gerrit IDs of reviewers |
| // (in REVIEWER and CC states) that were at some point added |
| // to the given Gerrit CL, even if they've been since removed. |
| func reviewersInMetas(metas []*maintner.GerritMeta) []string { |
| var ids []string |
| for _, m := range metas { |
| if !strings.Contains(m.Commit.Msg, "Reviewer:") && !strings.Contains(m.Commit.Msg, "CC:") { |
| continue |
| } |
| |
| err := foreach.LineStr(m.Commit.Msg, func(ln string) error { |
| if !strings.HasPrefix(ln, "Reviewer:") && !strings.HasPrefix(ln, "CC:") { |
| return nil |
| } |
| match := reviewerRe.FindStringSubmatch(ln) |
| if match == nil { |
| return nil |
| } |
| // Extract the reviewer's Gerrit ID. |
| for i, name := range reviewerRe.SubexpNames() { |
| if name != "id" { |
| continue |
| } |
| if i < 0 || i > len(match) { |
| continue |
| } |
| ids = append(ids, match[i]) |
| } |
| return nil |
| }) |
| if err != nil { |
| log.Printf("reviewersInMetas: got unexpected error from foreach.LineStr: %v", err) |
| } |
| } |
| // Remove duplicates. |
| slices.Sort(ids) |
| ids = slices.Compact(ids) |
| return ids |
| } |
| |
| func getCodeOwners(ctx context.Context, paths []string) ([]*owners.Entry, error) { |
| oReq := owners.Request{Version: 1} |
| oReq.Payload.Paths = paths |
| |
| oResp, err := fetchCodeOwners(ctx, &oReq) |
| if err != nil { |
| return nil, err |
| } |
| |
| var entries []*owners.Entry |
| for _, entry := range oResp.Payload.Entries { |
| if entry == nil { |
| continue |
| } |
| entries = append(entries, entry) |
| } |
| return entries, nil |
| } |
| |
| func getAllCodeOwners(ctx context.Context) (map[string]*owners.Entry, error) { |
| oReq := owners.Request{Version: 1} |
| oReq.Payload.All = true |
| oResp, err := fetchCodeOwners(ctx, &oReq) |
| if err != nil { |
| return nil, err |
| } |
| return oResp.Payload.Entries, nil |
| } |
| |
| func fetchCodeOwners(ctx context.Context, oReq *owners.Request) (*owners.Response, error) { |
| b, err := json.Marshal(oReq) |
| if err != nil { |
| return nil, err |
| } |
| req, err := http.NewRequest("POST", "https://dev.golang.org/owners/", bytes.NewReader(b)) |
| if err != nil { |
| return nil, err |
| } |
| req.Header.Set("Content-Type", "application/json") |
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) |
| defer cancel() |
| resp, err := http.DefaultClient.Do(req.WithContext(ctx)) |
| if err != nil { |
| return nil, err |
| } |
| defer resp.Body.Close() |
| var oResp owners.Response |
| if err := json.NewDecoder(resp.Body).Decode(&oResp); err != nil { |
| return nil, fmt.Errorf("could not decode owners response: %v", err) |
| } |
| if oResp.Error != "" { |
| return nil, fmt.Errorf("error from dev.golang.org/owners endpoint: %v", oResp.Error) |
| } |
| return &oResp, nil |
| } |
| |
| // mergeOwnersEntries takes multiple owners.Entry structs and aggregates all |
| // primary and secondary users into a single entry. |
| // If a user is a primary in one entry but secondary on another, they are |
| // primary in the returned entry. |
| // If a users email matches the authorEmail, the user is omitted from the |
| // result. |
| // The resulting order of the entries is non-deterministic. |
| func mergeOwnersEntries(entries []*owners.Entry, authorEmail string) *owners.Entry { |
| var result owners.Entry |
| pm := make(map[owners.Owner]int) |
| for _, e := range entries { |
| for _, o := range e.Primary { |
| pm[o]++ |
| } |
| } |
| sm := make(map[owners.Owner]int) |
| for _, e := range entries { |
| for _, o := range e.Secondary { |
| if pm[o] > 0 { |
| pm[o]++ |
| } else { |
| sm[o]++ |
| } |
| } |
| } |
| |
| const maxReviewers = 3 |
| if len(pm) > maxReviewers { |
| // Spamming many reviewers. |
| // Cut to three most common reviewers |
| // and drop all the secondaries. |
| var keep []owners.Owner |
| for o := range pm { |
| keep = append(keep, o) |
| } |
| sort.Slice(keep, func(i, j int) bool { |
| return pm[keep[i]] > pm[keep[j]] |
| }) |
| keep = keep[:maxReviewers] |
| sort.Slice(keep, func(i, j int) bool { |
| return keep[i].GerritEmail < keep[j].GerritEmail |
| }) |
| return &owners.Entry{Primary: keep} |
| } |
| |
| for o := range pm { |
| if o.GerritEmail != authorEmail { |
| result.Primary = append(result.Primary, o) |
| } |
| } |
| for o := range sm { |
| if o.GerritEmail != authorEmail { |
| result.Secondary = append(result.Secondary, o) |
| } |
| } |
| return &result |
| } |
| |
| // filterGerritOwners removes all primary and secondary owners from entries |
| // that are missing GerritEmail, and thus cannot be Gerrit reviewers (e.g., |
| // GitHub Teams). |
| // |
| // If an Entry's primary reviewers is empty after this process, the secondary |
| // owners are upgraded to primary. |
| func filterGerritOwners(entries []*owners.Entry) []*owners.Entry { |
| result := make([]*owners.Entry, 0, len(entries)) |
| for _, e := range entries { |
| var clean owners.Entry |
| for _, owner := range e.Primary { |
| if owner.GerritEmail != "" { |
| clean.Primary = append(clean.Primary, owner) |
| } |
| } |
| for _, owner := range e.Secondary { |
| if owner.GerritEmail != "" { |
| clean.Secondary = append(clean.Secondary, owner) |
| } |
| } |
| if len(clean.Primary) == 0 { |
| clean.Primary = clean.Secondary |
| clean.Secondary = nil |
| } |
| result = append(result, &clean) |
| } |
| return result |
| } |
| |
| func blockqoute(s string) string { |
| s = strings.TrimSpace(s) |
| s = "> " + s |
| s = strings.Replace(s, "\n", "\n> ", -1) |
| return s |
| } |
| |
| // errStopIteration is used to stop iteration over issues or comments. |
| // It has no special meaning. |
| var errStopIteration = errors.New("stop iteration") |
| |
| func isDocumentationTitle(t string) bool { |
| if !strings.Contains(t, "doc") && !strings.Contains(t, "Doc") { |
| return false |
| } |
| t = strings.ToLower(t) |
| if strings.HasPrefix(t, "x/pkgsite:") { |
| // Don't label pkgsite issues with the Documentation label. |
| return false |
| } |
| if strings.HasPrefix(t, "doc:") { |
| return true |
| } |
| if strings.HasPrefix(t, "docs:") { |
| return true |
| } |
| if strings.HasPrefix(t, "cmd/doc:") { |
| return false |
| } |
| if strings.HasPrefix(t, "go/doc:") { |
| return false |
| } |
| if strings.Contains(t, "godoc:") { // in x/tools, or the dozen places people file it as |
| return false |
| } |
| return strings.Contains(t, "document") || |
| strings.Contains(t, "docs ") |
| } |
| |
| func isGoplsTitle(t string) bool { |
| // If the prefix doesn't contain "gopls" or "lsp", |
| // then it may not be a gopls issue. |
| i := strings.Index(t, ":") |
| if i > -1 { |
| t = t[:i] |
| } |
| return strings.Contains(t, "gopls") || strings.Contains(t, "lsp") |
| } |
| |
| var lastTask string |
| |
| func printIssue(task string, repoID maintner.GitHubRepoID, gi *maintner.GitHubIssue) { |
| if *dryRun { |
| task = task + " [dry-run]" |
| } |
| if task != lastTask { |
| fmt.Println(task) |
| lastTask = task |
| } |
| if repoID.Owner != "golang" || repoID.Repo != "go" { |
| fmt.Printf("\thttps://github.com/%s/issues/%v %s\n", repoID, gi.Number, gi.Title) |
| } else { |
| fmt.Printf("\thttps://go.dev/issue/%v %s\n", gi.Number, gi.Title) |
| } |
| } |
| |
| func repoHasLabel(repo *maintner.GitHubRepo, name string) bool { |
| has := false |
| repo.ForeachLabel(func(label *maintner.GitHubLabel) error { |
| if label.Name == name { |
| has = true |
| } |
| return nil |
| }) |
| return has |
| } |