internal/task, cmd/relui: add definition for releasing gopls

1. Param: coordinator need to provide the version string that will be
   released.
2. Task: validate the input version a valid version.
3. Output: returns whether the input version is valid.

For golang/go#57643

Change-Id: Ib6d3f10c9a46ad2f24b0a1fc053265456d152f41
Reviewed-on: https://go-review.googlesource.com/c/build/+/601236
Reviewed-by: Dmitri Shuralyov <dmitshur@golang.org>
LUCI-TryBot-Result: Go LUCI <golang-scoped@luci-project-accounts.iam.gserviceaccount.com>
Reviewed-by: Dmitri Shuralyov <dmitshur@google.com>
diff --git a/cmd/relui/main.go b/cmd/relui/main.go
index e3e6cf9..4d2cac8 100644
--- a/cmd/relui/main.go
+++ b/cmd/relui/main.go
@@ -308,6 +308,11 @@
 	}
 	dh.RegisterDefinition("Tag a new version of x/telemetry/config (if necessary)", tagTelemetryTasks.NewDefinition())
 
+	releaseGoplsTasks := task.ReleaseGoplsTasks{
+		Gerrit: gerritClient,
+	}
+	dh.RegisterDefinition("Release a new version of gopls", releaseGoplsTasks.NewDefinition())
+
 	privateSyncTask := &task.PrivateMasterSyncTask{
 		Git:              gitClient,
 		PrivateGerritURL: "https://go-internal.googlesource.com/golang/go-private",
diff --git a/internal/task/releasegopls.go b/internal/task/releasegopls.go
new file mode 100644
index 0000000..fd956d4
--- /dev/null
+++ b/internal/task/releasegopls.go
@@ -0,0 +1,125 @@
+// Copyright 2020 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
+
+import (
+	"fmt"
+	"slices"
+	"strings"
+
+	wf "golang.org/x/build/internal/workflow"
+	"golang.org/x/mod/semver"
+)
+
+// ReleaseGoplsTasks implements a new workflow definition include all the tasks
+// to release a gopls.
+type ReleaseGoplsTasks struct {
+	Gerrit GerritClient
+}
+
+// NewDefinition create a new workflow definition for releasing gopls.
+func (r *ReleaseGoplsTasks) NewDefinition() *wf.Definition {
+	wd := wf.New()
+
+	// TODO(hxjiang): provide potential release versions in the relui where the
+	// coordinator can choose which version to release instead of manual input.
+	version := wf.Param(wd, wf.ParamDef[string]{Name: "version"})
+	isValid := wf.Task1(wd, "validating input version", r.isValidVersion, version)
+	wf.Output(wd, "valid", isValid)
+	return wd
+}
+
+func (r *ReleaseGoplsTasks) isValidVersion(ctx *wf.TaskContext, ver string) (bool, error) {
+	if !semver.IsValid(ver) {
+		return false, nil
+	}
+
+	versions, err := r.possibleGoplsVersions(ctx)
+	if err != nil {
+		return false, fmt.Errorf("failed to get latest Gopls version tags from x/tool: %w", err)
+	}
+
+	return slices.Contains(versions, ver), nil
+}
+
+// semversion is a parsed semantic version.
+type semversion struct {
+	major, minor, patch int
+	pre                 string
+}
+
+// parseSemver attempts to parse semver components out of the provided semver
+// v. If v is not valid semver in canonical form, parseSemver returns false.
+func parseSemver(v string) (_ semversion, ok bool) {
+	var parsed semversion
+	v, parsed.pre, _ = strings.Cut(v, "-")
+	if _, err := fmt.Sscanf(v, "v%d.%d.%d", &parsed.major, &parsed.minor, &parsed.patch); err == nil {
+		ok = true
+	}
+	return parsed, ok
+}
+
+// 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) {
+	tags, err := r.Gerrit.ListTags(ctx, "tools")
+	if err != nil {
+		return nil, err
+	}
+
+	var semVersions []semversion
+	majorMinorPatch := map[int]map[int]map[int]bool{}
+	for _, tag := range tags {
+		v, ok := strings.CutPrefix(tag, "gopls/")
+		if !ok {
+			continue
+		}
+
+		if !semver.IsValid(v) {
+			continue
+		}
+
+		// Skip for pre-release versions.
+		if semver.Prerelease(v) != "" {
+			continue
+		}
+
+		semv, ok := parseSemver(v)
+		semVersions = append(semVersions, semv)
+
+		if majorMinorPatch[semv.major] == nil {
+			majorMinorPatch[semv.major] = map[int]map[int]bool{}
+		}
+		if majorMinorPatch[semv.major][semv.minor] == nil {
+			majorMinorPatch[semv.major][semv.minor] = map[int]bool{}
+		}
+		majorMinorPatch[semv.major][semv.minor][semv.patch] = true
+	}
+
+	var possible []string
+	seen := map[string]bool{}
+	for _, v := range semVersions {
+		nextMajor := fmt.Sprintf("v%d.%d.%d", v.major+1, 0, 0)
+		if _, ok := majorMinorPatch[v.major+1]; !ok && !seen[nextMajor] {
+			seen[nextMajor] = true
+			possible = append(possible, nextMajor)
+		}
+
+		nextMinor := fmt.Sprintf("v%d.%d.%d", v.major, v.minor+1, 0)
+		if _, ok := majorMinorPatch[v.major][v.minor+1]; !ok && !seen[nextMinor] {
+			seen[nextMinor] = true
+			possible = append(possible, nextMinor)
+		}
+
+		nextPatch := fmt.Sprintf("v%d.%d.%d", v.major, v.minor, v.patch+1)
+		if _, ok := majorMinorPatch[v.major][v.minor][v.patch+1]; !ok && !seen[nextPatch] {
+			seen[nextPatch] = true
+			possible = append(possible, nextPatch)
+		}
+	}
+
+	semver.Sort(possible)
+	return possible, nil
+}
diff --git a/internal/task/releasegopls_test.go b/internal/task/releasegopls_test.go
new file mode 100644
index 0000000..b916190
--- /dev/null
+++ b/internal/task/releasegopls_test.go
@@ -0,0 +1,85 @@
+// Copyright 2020 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
+
+import (
+	"context"
+	"testing"
+
+	"github.com/google/go-cmp/cmp"
+	"golang.org/x/build/internal/workflow"
+)
+
+func TestPossibleGoplsVersions(t *testing.T) {
+	tests := []struct {
+		name string
+		tags []string
+		want []string
+	}{
+		{
+			name: "any one version tag should have three possible next versions",
+			tags: []string{"gopls/v1.2.3"},
+			want: []string{"v1.2.4", "v1.3.0", "v2.0.0"},
+		},
+		{
+			name: "1.2.0 should be skipped because 1.2.3 already exist",
+			tags: []string{"gopls/v1.2.3", "gopls/v1.1.0"},
+			want: []string{"v1.1.1", "v1.2.4", "v1.3.0", "v2.0.0"},
+		},
+		{
+			name: "2.0.0 should be skipped because 2.1.3 already exist",
+			tags: []string{"gopls/v1.2.3", "gopls/v2.1.3"},
+			want: []string{"v1.2.4", "v1.3.0", "v2.1.4", "v2.2.0", "v3.0.0"},
+		},
+		{
+			name: "1.2.0 is still consider valid version because there is no 1.2.X",
+			tags: []string{"gopls/v1.1.3", "gopls/v1.3.2", "gopls/v2.1.2"},
+			want: []string{"v1.1.4", "v1.2.0", "v1.3.3", "v1.4.0", "v2.1.3", "v2.2.0", "v3.0.0"},
+		},
+		{
+			name: "2.0.0 is still consider valid version because there is no 2.X.X",
+			tags: []string{"gopls/v1.2.3", "gopls/v3.1.2"},
+			want: []string{"v1.2.4", "v1.3.0", "v2.0.0", "v3.1.3", "v3.2.0", "v4.0.0"},
+		},
+		{
+			name: "pre-release version tag should not have any effect on the next version",
+			tags: []string{"gopls/v0.16.1-pre.1", "gopls/v0.16.1-pre.2", "gopls/v0.16.0"},
+			want: []string{"v0.16.1", "v0.17.0", "v1.0.0"},
+		},
+		{
+			name: "other unrelated tag should not have any effect on the next version",
+			tags: []string{"v0.9.2", "v0.9.3", "v0.23.0", "gopls/v0.16.0"},
+			want: []string{"v0.16.1", "v0.17.0", "v1.0.0"},
+		},
+	}
+
+	for _, tc := range tests {
+		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,
+			}
+
+			got, err := tasks.possibleGoplsVersions(&workflow.TaskContext{Context: context.Background()})
+			if err != nil {
+				t.Fatalf("possibleGoplsVersions() should not return error, but return %v", err)
+			}
+			if diff := cmp.Diff(tc.want, got); diff != "" {
+				t.Errorf("possibleGoplsVersions() mismatch (-want +got):\n%s", diff)
+			}
+		})
+	}
+}