internal/task, cmd/releasebot: add mail-dl-cl task to releasebot CLI

This change adds a release task that exercises the ability to create
a Gerrit CL via the Gerrit API, which will play a part in release
automation.

The task will eventually be exposed via relui's web UI when it is ready,
and it has a signature we expect to be able to use. To be able to use it
sooner, it's exposed via releasebot's CLI.

For golang.org/issue/38075.

Change-Id: I05ff078d7fcc25b1c2ac7001b094f3a3fee6b3a1
Reviewed-on: https://go-review.googlesource.com/c/build/+/350631
Run-TryBot: Dmitri Shuralyov <dmitshur@golang.org>
TryBot-Result: Go Bot <gobot@golang.org>
Reviewed-by: Heschi Kreinick <heschi@google.com>
Reviewed-by: Alexander Rakoczy <alex@golang.org>
Trust: Dmitri Shuralyov <dmitshur@golang.org>
diff --git a/cmd/releasebot/main.go b/cmd/releasebot/main.go
index 647e4f9..e0718f4 100644
--- a/cmd/releasebot/main.go
+++ b/cmd/releasebot/main.go
@@ -8,6 +8,7 @@
 
 import (
 	"bytes"
+	"context"
 	"crypto/sha1"
 	"crypto/sha256"
 	"errors"
@@ -27,6 +28,7 @@
 	"time"
 
 	"golang.org/x/build/buildenv"
+	"golang.org/x/build/internal/task"
 	"golang.org/x/build/maintner"
 )
 
@@ -66,12 +68,13 @@
 }
 
 var releaseModes = map[string]bool{
-	"prepare": true,
-	"release": true,
+	"prepare":    true,
+	"release":    true,
+	"mail-dl-cl": true,
 }
 
 func usage() {
-	fmt.Fprintln(os.Stderr, "usage: releasebot -mode {prepare|release} [-security] [-dry-run] {go1.8.5|go1.10beta2|go1.11rc1}")
+	fmt.Fprintln(os.Stderr, "usage: releasebot -mode {prepare|release|mail-dl-cl} [-security] [-dry-run] {go1.8.5|go1.10beta2|go1.11rc1}")
 	flag.PrintDefaults()
 	os.Exit(2)
 }
@@ -91,8 +94,14 @@
 	security := flag.Bool("security", false, "cut a security release from the internal Gerrit")
 	flag.Usage = usage
 	flag.Parse()
-	if *modeFlag == "" || !releaseModes[*modeFlag] || flag.NArg() != 1 {
-		fmt.Fprintln(os.Stderr, "need to provide a valid mode and a release name")
+	if !releaseModes[*modeFlag] {
+		fmt.Fprintln(os.Stderr, "need to provide a valid mode")
+		usage()
+	} else if *modeFlag == "mail-dl-cl" {
+		mailDLCL()
+		return
+	} else if flag.NArg() != 1 {
+		fmt.Fprintln(os.Stderr, "need to provide a release name")
 		usage()
 	}
 	releaseVersion := flag.Arg(0)
@@ -184,6 +193,46 @@
 	w.doRelease()
 }
 
+// mailDLCL parses command-line arguments for the mail-dl-cl mode,
+// and runs it.
+func mailDLCL() {
+	if flag.NArg() != 1 && flag.NArg() != 2 {
+		fmt.Fprintln(os.Stderr, "need to provide 1 or 2 versions")
+		usage()
+	}
+	versions := flag.Args()
+
+	fmt.Printf("About to create a golang.org/dl CL for the following Go versions:\n\n\t• %s\n\nOk? (Y/n) ", strings.Join(versions, "\n\t• "))
+	if dryRun {
+		fmt.Println("dry-run")
+		return
+	}
+	var response string
+	_, err := fmt.Scanln(&response)
+	if err != nil {
+		log.Fatalln(err)
+	}
+	if response != "Y" && response != "y" {
+		log.Fatalln("stopped as requested")
+	}
+	changeURL, err := task.MailDLCL(context.Background(), versions)
+	if err != nil {
+		log.Fatalf(`task.MailDLCL(ctx, %#v) failed: %v
+
+If it's neccessary to perform it manually as a workaround,
+consider the following steps:
+
+	git clone https://go.googlesource.com/dl && cd dl
+	go run ./internal/genv goX.Y.Z goX.A.B
+	git add .
+	git commit -m "dl: add goX.Y.Z and goX.A.B"
+	git codereview mail -trybot -trust
+
+Discuss with the secondary release coordinator as needed.`, versions, err)
+	}
+	fmt.Printf("\nPlease review and submit %s\nand then refer to the playbook for the next steps.\n\n", changeURL)
+}
+
 // checkForGitCodereview exits the program if git-codereview is not installed
 // in the user's path.
 func checkForGitCodereview() {
diff --git a/internal/task/dlcl.go b/internal/task/dlcl.go
new file mode 100644
index 0000000..2b50d42
--- /dev/null
+++ b/internal/task/dlcl.go
@@ -0,0 +1,164 @@
+// Copyright 2021 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.
+
+// Package task implements tasks involved in making a Go release.
+package task
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"go/format"
+	"path"
+	"regexp"
+	"strings"
+	"text/template"
+	"time"
+
+	"golang.org/x/build/buildenv"
+	"golang.org/x/build/gerrit"
+	"golang.org/x/build/internal/secret"
+)
+
+// MailDLCL mails a golang.org/dl CL that adds commands for the
+// specified Go versions. It accepts one or two versions only.
+//
+// The versions must use the same format as Go tags. For example:
+// 	• "go1.17.2" and "go1.16.9" for a minor Go release
+// 	• "go1.18" for a major Go release
+// 	• "go1.18beta1" or "go1.18rc1" for a pre-release
+//
+// Credentials are fetched from Secret Manager.
+// On success, the URL of the change is returned, like "https://golang.org/cl/123".
+func MailDLCL(ctx context.Context, versions []string) (changeURL string, _ error) {
+	if len(versions) < 1 || len(versions) > 2 {
+		return "", fmt.Errorf("got %d Go versions, want 1 or 2", len(versions))
+	}
+	for _, ver := range versions {
+		if ver != strings.ToLower(ver) {
+			return "", fmt.Errorf("version %q is not lowercase", ver)
+		} else if strings.Contains(ver, " ") {
+			return "", fmt.Errorf("version %q contains a space", ver)
+		} else if !strings.HasPrefix(ver, "go") {
+			return "", fmt.Errorf("version %q doesn't have the 'go' prefix", ver)
+		}
+	}
+
+	var files = make(map[string]string) // Map key is relative path, and map value is file content.
+
+	// Generate main.go files for versions from the template.
+	for _, ver := range versions {
+		var buf bytes.Buffer
+		versionNoPatch, err := versionNoPatch(ver)
+		if err != nil {
+			return "", err
+		}
+		if err := dlTmpl.Execute(&buf, struct {
+			Year                int
+			Version             string // "go1.5.3rc2"
+			VersionNoPatch      string // "go1.5"
+			CapitalSpaceVersion string // "Go 1.5"
+			DocHost             string // "golang.org" or "tip.golang.org" for rc/beta
+		}{
+			Year:                time.Now().UTC().Year(),
+			Version:             ver,
+			VersionNoPatch:      versionNoPatch,
+			DocHost:             docHost(ver),
+			CapitalSpaceVersion: strings.Replace(ver, "go", "Go ", 1),
+		}); err != nil {
+			return "", fmt.Errorf("dlTmpl.Execute: %v", err)
+		}
+		gofmted, err := format.Source(buf.Bytes())
+		if err != nil {
+			return "", fmt.Errorf("could not gofmt: %v", err)
+		}
+		files[path.Join(ver, "main.go")] = string(gofmted)
+	}
+
+	// Create a Gerrit CL using the Gerrit API.
+	gobot, err := gobot()
+	if err != nil {
+		return "", err
+	}
+	ci, err := gobot.CreateChange(ctx, gerrit.ChangeInput{
+		Project: "dl",
+		Subject: "dl: add " + strings.Join(versions, " and "),
+		Branch:  "master",
+	})
+	if err != nil {
+		return "", err
+	}
+	changeID := fmt.Sprintf("%s~%d", ci.Project, ci.ChangeNumber)
+	for path, content := range files {
+		err := gobot.ChangeFileContentInChangeEdit(ctx, changeID, path, content)
+		if err != nil {
+			return "", err
+		}
+	}
+	err = gobot.PublishChangeEdit(ctx, changeID)
+	if err != nil {
+		return "", err
+	}
+	return fmt.Sprintf("https://golang.org/cl/%d", ci.ChangeNumber), nil
+}
+
+func docHost(ver string) string {
+	if strings.Contains(ver, "rc") || strings.Contains(ver, "beta") {
+		return "tip.golang.org"
+	}
+	return "golang.org"
+}
+
+func versionNoPatch(ver string) (string, error) {
+	rx := regexp.MustCompile(`^(go\d+\.\d+)($|[\.]?\d*)($|rc|beta|\.)`)
+	m := rx.FindStringSubmatch(ver)
+	if m == nil {
+		return "", fmt.Errorf("unrecognized version %q", ver)
+	}
+	if m[2] != "" {
+		return "devel/release.html#" + m[1] + ".minor", nil
+	}
+	return m[1], nil
+}
+
+var dlTmpl = template.Must(template.New("").Parse(`// Copyright {{.Year}} 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 {{.Version}} command runs the go command from {{.CapitalSpaceVersion}}.
+//
+// To install, run:
+//
+//     $ go install golang.org/dl/{{.Version}}@latest
+//     $ {{.Version}} download
+//
+// And then use the {{.Version}} command as if it were your normal go
+// command.
+//
+// See the release notes at https://{{.DocHost}}/doc/{{.VersionNoPatch}}
+//
+// File bugs at https://golang.org/issues/new
+package main
+
+import "golang.org/dl/internal/version"
+
+func main() {
+	version.Run("{{.Version}}")
+}
+`))
+
+// gobot creates an authenticated Gerrit API client
+// that uses the gobot@golang.org Gerrit account.
+func gobot() (*gerrit.Client, error) {
+	sc, err := secret.NewClientInProject(buildenv.Production.ProjectName)
+	if err != nil {
+		return nil, err
+	}
+	defer sc.Close()
+	token, err := sc.Retrieve(context.Background(), secret.NameGobotPassword)
+	if err != nil {
+		return nil, err
+	}
+	return gerrit.NewClient("https://go-review.googlesource.com", gerrit.BasicAuth("git-gobot.golang.org", token)), nil
+}