internal/task: update x/tools dependency and create a CL for review

updateXToolsDependency will drop the replace directive in gopls sub
module and pin the x/tools version to the head of the current gopls
release branch.

A local relui screenshot is at https://go.dev/issue/57643#issuecomment-2263725465

For golang/go#57643

Change-Id: I0d9f4462b71b394ff432bc65142cf35e70977e6a
Reviewed-on: https://go-review.googlesource.com/c/build/+/602516
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
Auto-Submit: Hongxiang Jiang <hxjiang@golang.org>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
diff --git a/internal/task/releasegopls.go b/internal/task/releasegopls.go
index 243d527..e763299 100644
--- a/internal/task/releasegopls.go
+++ b/internal/task/releasegopls.go
@@ -8,6 +8,7 @@
 	"errors"
 	"fmt"
 	"slices"
+	"strconv"
 	"strings"
 	"time"
 
@@ -34,7 +35,9 @@
 	semversion := wf.Task1(wd, "validating input version", r.isValidVersion, version)
 	branchCreated := wf.Action1(wd, "creating new branch if minor release", r.createBranchIfMinor, semversion)
 	changeID := wf.Task2(wd, "updating branch's codereview.cfg", r.updateCodeReviewConfig, semversion, reviewers, wf.After(branchCreated))
-	_ = wf.Action1(wd, "await config CL submission", r.AwaitSubmission, changeID)
+	submitted := wf.Action1(wd, "await config CL submission", r.AwaitSubmission, changeID)
+	changeID = wf.Task2(wd, "updating gopls' x/tools dependency", r.updateXToolsDependency, semversion, reviewers, wf.After(submitted))
+	_ = wf.Action1(wd, "await gopls' x/tools dependency CL submission", r.AwaitSubmission, changeID)
 	return wd
 }
 
@@ -73,10 +76,29 @@
 	return err
 }
 
-// updateCodeReviewConfig ensures codereview.cfg contains the expected
-// configuration.
+// openCL checks if an open CL with the given title exists in the specified
+// branch.
 //
-// It returns the change ID, or "" if the CL was not created.
+// It returns an empty string if no such CL is found, otherwise it returns the
+// CL's change ID.
+func (r *ReleaseGoplsTasks) openCL(ctx *wf.TaskContext, branch, title string) (string, error) {
+	// Query for an existing pending config CL, to avoid duplication.
+	query := fmt.Sprintf(`message:%q status:open owner:gobot@golang.org repo:tools branch:%q -age:7d`, title, branch)
+	changes, err := r.Gerrit.QueryChanges(ctx, query)
+	if err != nil {
+		return "", err
+	}
+	if len(changes) == 0 {
+		return "", nil
+	}
+
+	return changes[0].ChangeID, nil
+}
+
+// updateCodeReviewConfig checks if codereview.cfg has the desired configuration.
+//
+// It returns the change ID required to update the config if changes are needed,
+// otherwise it returns an empty string indicating no update is necessary.
 func (r *ReleaseGoplsTasks) updateCodeReviewConfig(ctx *wf.TaskContext, semv semversion, reviewers []string) (string, error) {
 	const configFile = "codereview.cfg"
 	const configFmt = `issuerepo: golang/go
@@ -87,15 +109,13 @@
 	branch := goplsReleaseBranchName(semv)
 	clTitle := fmt.Sprintf("all: update %s for %s", configFile, branch)
 
-	// Query for an existing pending config CL, to avoid duplication.
-	query := fmt.Sprintf(`message:%q status:open owner:gobot@golang.org repo:tools branch:%q -age:7d`, clTitle, branch)
-	changes, err := r.Gerrit.QueryChanges(ctx, query)
+	openCL, err := r.openCL(ctx, branch, clTitle)
 	if err != nil {
-		return "", err
+		return "", fmt.Errorf("failed to find the open CL of title %q in branch %q: %w", clTitle, branch, err)
 	}
-	if len(changes) > 0 {
-		ctx.Printf("not creating CL: found existing CL %d", changes[0].ChangeNumber)
-		return changes[0].ChangeID, nil
+	if openCL != "" {
+		ctx.Printf("not creating CL: found existing CL %s", openCL)
+		return openCL, nil
 	}
 
 	head, err := r.Gerrit.ReadBranchHead(ctx, "tools", branch)
@@ -128,6 +148,110 @@
 	return r.Gerrit.CreateAutoSubmitChange(ctx, changeInput, reviewers, files)
 }
 
+// nextPrerelease go through the tags in tools repo that matches with the given
+// version and find the next pre-release version.
+func (r *ReleaseGoplsTasks) nextPrerelease(ctx *wf.TaskContext, semv semversion) (int, error) {
+	tags, err := r.Gerrit.ListTags(ctx, "tools")
+	if err != nil {
+		return -1, fmt.Errorf("failed to list tags for tools repo: %w", err)
+	}
+
+	max := 0
+	for _, tag := range tags {
+		v, ok := strings.CutPrefix(tag, "gopls/")
+		if !ok {
+			continue
+		}
+		cur, ok := parseSemver(v)
+		if !ok {
+			continue
+		}
+		if cur.Major != semv.Major || cur.Minor != semv.Minor || cur.Patch != semv.Patch {
+			continue
+		}
+		pre, err := cur.prereleaseVersion()
+		if err != nil {
+			continue
+		}
+
+		if pre > max {
+			max = pre
+		}
+	}
+
+	return max + 1, nil
+}
+
+// updateXToolsDependency ensures gopls sub module have the correct x/tools
+// version as dependency.
+//
+// It returns the change ID, or "" if the CL was not created.
+func (r *ReleaseGoplsTasks) updateXToolsDependency(ctx *wf.TaskContext, semv semversion, reviewers []string) (string, error) {
+	const scriptFmt = `cp gopls/go.mod gopls/go.mod.before
+cp gopls/go.sum gopls/go.sum.before
+cd gopls
+go mod edit -dropreplace=golang.org/x/tools
+go get -u golang.org/x/tools@%s
+go mod tidy -compat=1.19
+`
+
+	pre, err := r.nextPrerelease(ctx, semv)
+	if err != nil {
+		return "", fmt.Errorf("failed to find the next prerelease version: %w", err)
+	}
+
+	branch := goplsReleaseBranchName(semv)
+	clTitle := fmt.Sprintf("gopls: update go.mod for v%v.%v.%v-pre.%v", semv.Major, semv.Minor, semv.Patch, pre)
+	openCL, err := r.openCL(ctx, branch, clTitle)
+	if err != nil {
+		return "", fmt.Errorf("failed to find the open CL of title %q in branch %q: %w", clTitle, branch, err)
+	}
+	if openCL != "" {
+		ctx.Printf("not creating CL: found existing CL %s", openCL)
+		return openCL, nil
+	}
+
+	outputFiles := []string{"gopls/go.mod.before", "gopls/go.mod", "gopls/go.sum.before", "gopls/go.sum"}
+	// TODO(hxjiang): Replacing branch with the latest non-pinned commit in the
+	// release branch. Rationale:
+	// 1. Module proxy might return an outdated commit when using the branch name
+	// (to be confirmed with samthanawalla@).
+	// 2. Pinning x/tools using the latest commit from a branch isn't idempotent.
+	// It's best to avoid pinning x/tools to a version that's effectively another
+	// pin.
+	build, err := r.CloudBuild.RunScript(ctx, fmt.Sprintf(scriptFmt, branch), "tools", outputFiles)
+	if err != nil {
+		return "", err
+	}
+
+	outputs, err := buildToOutputs(ctx, r.CloudBuild, build)
+	if err != nil {
+		return "", err
+	}
+
+	changedFiles := map[string]string{}
+	for i := 0; i < len(outputFiles); i += 2 {
+		before, after := outputs[outputFiles[i]], outputs[outputFiles[i+1]]
+		if before != after {
+			changedFiles[outputFiles[i+1]] = after
+		}
+	}
+
+	// Skip CL creation as nothing changed.
+	if len(changedFiles) == 0 {
+		return "", nil
+	}
+
+	changeInput := gerrit.ChangeInput{
+		Project: "tools",
+		Branch:  branch,
+		Subject: fmt.Sprintf("%s\n\nThis is an automated CL which updates the go.mod go.sum.", clTitle),
+	}
+
+	ctx.Printf("creating auto-submit change under branch %q in x/tools repo.", branch)
+	return r.Gerrit.CreateAutoSubmitChange(ctx, changeInput, reviewers, changedFiles)
+}
+
 // AwaitSubmission waits for the CL with the given change ID to be submitted.
 //
 // The return value is the submitted commit hash, or "" if changeID is "".
@@ -179,6 +303,24 @@
 	return parsed, ok
 }
 
+func (s *semversion) prereleaseVersion() (int, error) {
+	parts := strings.Split(s.Pre, ".")
+	if len(parts) == 1 {
+		return -1, fmt.Errorf(`prerelease version does not contain any "."`)
+	}
+
+	if len(parts) > 2 {
+		return -1, fmt.Errorf(`prerelease version contains %v "."`, len(parts)-1)
+	}
+
+	pre, err := strconv.Atoi(parts[1])
+	if err != nil {
+		return -1, fmt.Errorf("failed to convert prerelease version to int %q: %w", pre, err)
+	}
+
+	return pre, nil
+}
+
 // possibleGoplsVersions identifies suitable versions for the upcoming release
 // based on the current tags in the repo.
 func (r *ReleaseGoplsTasks) possibleGoplsVersions(ctx *wf.TaskContext) ([]string, error) {
diff --git a/internal/task/releasegopls_test.go b/internal/task/releasegopls_test.go
index ebe33a6..6c4d6aa 100644
--- a/internal/task/releasegopls_test.go
+++ b/internal/task/releasegopls_test.go
@@ -312,3 +312,65 @@
 		})
 	}
 }
+
+func TestNextPrerelease(t *testing.T) {
+	ctx := context.Background()
+	testcases := []struct {
+		name    string
+		tags    []string
+		version string
+		want    int
+	}{
+		{
+			name:    "next pre-release is 2",
+			tags:    []string{"gopls/v0.16.0-pre.0", "gopls/v0.16.0-pre.1"},
+			version: "v0.16.0",
+			want:    2,
+		},
+		{
+			name:    "next pre-release is 2 regardless of other minor or patch version",
+			tags:    []string{"gopls/v0.16.0-pre.0", "gopls/v0.16.0-pre.1", "gopls/v0.16.1-pre.1", "gopls/v0.2.0-pre.3"},
+			version: "v0.16.0",
+			want:    2,
+		},
+		{
+			name:    "next pre-release is 2 regardless of non-int prerelease version",
+			tags:    []string{"gopls/v0.16.0-pre.0", "gopls/v0.16.0-pre.1", "gopls/v0.16.0-pre.foo", "gopls/v0.16.0-pre.bar"},
+			version: "v0.16.0",
+			want:    2,
+		},
+	}
+
+	for _, tc := range testcases {
+		t.Run(tc.name, func(t *testing.T) {
+			tools := NewFakeRepo(t, "tools")
+			commit := tools.Commit(map[string]string{
+				"go.mod": "module golang.org/x/tools\n",
+				"go.sum": "\n",
+			})
+
+			for _, tag := range tc.tags {
+				tools.Tag(tag, commit)
+			}
+
+			gerrit := NewFakeGerrit(t, tools)
+
+			tasks := &ReleaseGoplsTasks{
+				Gerrit: gerrit,
+			}
+
+			semv, ok := parseSemver(tc.version)
+			if !ok {
+				t.Fatalf("parseSemver(%q) should success", tc.version)
+			}
+			got, err := tasks.nextPrerelease(&workflow.TaskContext{Context: ctx, Logger: &testLogger{t, ""}}, semv)
+			if err != nil {
+				t.Fatalf("nextPrerelease(%q) should not return error: %v", tc.version, err)
+			}
+
+			if tc.want != got {
+				t.Errorf("nextPrerelease(%q) = %v want %v", tc.version, got, tc.want)
+			}
+		})
+	}
+}