dmitri.shuralyov.com/go/prefixtitle/...

create package for parsing prefixed issue/change titles

This functionality is factored out of the gido website
so that it can be reused across a wider set of projects.

Follows https://dmitri.shuralyov.com/website/gido$commit/9ec8098d1ae7dcc4e4bcc249640bb5dec2a42b2c.
dmitshur committed 2 weeks ago commit 985f29f600087e42c2d4d98c76a207527ea686bc
LICENSE
@@ -0,0 +1,27 @@
+Copyright (c) 2019 The Go Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+   * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+   * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+   * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
parse.go
@@ -0,0 +1,116 @@
+// Package prefixtitle parses prefixed issue and change titles
+// for Go packages.
+//
+// It uses the conventions set by the Go project.
+// These conventions are documented on
+// https://golang.org/wiki/HandlingIssues,
+// https://golang.org/wiki/Gardening,
+// https://golang.org/wiki/CommitMessage, and
+// https://golang.org/wiki/MinorReleases wiki pages.
+package prefixtitle
+
+import (
+	"path"
+	"strings"
+)
+
+// ParseIssue parses a prefixed issue title.
+// It returns 1 or more paths from the prefix joined with module,
+// and the remaining issue title.
+// It does not try to verify whether each path is an existing Go package.
+//
+// The value of module is expected to be the empty string
+// for issues in the "github.com/golang/go" repository.
+// It should be the module path for all other repositories.
+//
+// Supported forms include:
+//
+// 	"",    "import/path: issue title"    -> ["import/path"],            "issue title"
+// 	"",    "proposal: path: issue title" -> ["path"],                   "issue title"  # Proposal.
+// 	"",    "Proposal: path: issue title" -> ["path"],                   "issue title"  # Proposal.
+// 	"",    "x/path: issue title"         -> ["golang.org/x/path"],      "issue title"  # "x/..." refers to "golang.org/x/...".
+// 	"",    "path1, path2: issue title"   -> ["path1", "path2"],         "issue title"  # Multiple comma-separated paths.
+// 	"mod", "import/path: issue title"    -> ["mod/import/path"],        "issue title"
+// 	"mod", "path1, path2: issue title"   -> ["mod/path1", "mod/path2"], "issue title"  # Multiple comma-separated paths.
+//
+// If there's no path prefix (preceded by ": "), prefixedTitle is returned unmodified:
+//
+// 	"",    "issue title"                 -> [""],    "issue title"
+// 	"mod", "issue title"                 -> ["mod"], "issue title"
+//
+func ParseIssue(module, prefixedTitle string) (paths []string, title string) {
+	prefixedTitle = strings.TrimPrefix(prefixedTitle, "proposal: ") // TODO: Consider preserving the "proposal: " prefix?
+	prefixedTitle = strings.TrimPrefix(prefixedTitle, "Proposal: ")
+	idx := strings.Index(prefixedTitle, ": ")
+	if idx == -1 {
+		return []string{module}, prefixedTitle
+	}
+	prefix, title := prefixedTitle[:idx], prefixedTitle[idx+len(": "):]
+	if strings.ContainsAny(prefix, "{}") {
+		// TODO: Parse "image/{png,jpeg}" as ["image/png", "image/jpeg"], maybe?
+		return []string{path.Join(module, strings.TrimSpace(prefix))}, title
+	}
+	paths = strings.Split(prefix, ",")
+	for i := range paths {
+		paths[i] = path.Join(module, strings.TrimSpace(paths[i]))
+		if strings.HasPrefix(paths[i], "x/") || paths[i] == "x" { // Map "x/..." to "golang.org/x/...".
+			paths[i] = "golang.org/" + paths[i]
+		}
+	}
+	return paths, title
+}
+
+// ParseChange parses a prefixed change title.
+// It returns 1 or more paths from the prefix joined with module,
+// and the remaining change title.
+// It does not try to verify whether each path is an existing Go package.
+//
+// The value of module is expected to be the empty string
+// for CLs in the "go.googlesource.com/go" repository
+// and PRs in its "github.com/golang/go" GitHub mirror.
+// It should be the module path for all other repositories.
+//
+// Supported forms include:
+//
+// 	"",    "import/path: change title"   -> ["import/path"],            "change title"
+// 	"",    "path1, path2: change title"  -> ["path1", "path2"],         "change title"  # Multiple comma-separated paths.
+// 	"mod", "import/path: change title"   -> ["mod/import/path"],        "change title"
+// 	"mod", "path1, path2: change title"  -> ["mod/path1", "mod/path2"], "change title"  # Multiple comma-separated paths.
+//
+// If there's no path prefix (preceded by ": "), prefixedTitle is returned unmodified:
+//
+// 	"",    "change title"                -> [""],    "change title"
+// 	"mod", "change title"                -> ["mod"], "change title"
+//
+// If there's a branch prefix in square brackets, title is returned with said prefix:
+//
+// 	"",    "[branch] path: change title" -> ["path"],     "[branch] change title"
+// 	"mod", "[branch] path: change title" -> ["mod/path"], "[branch] change title"
+//
+func ParseChange(module, prefixedTitle string) (paths []string, title string) {
+	// Parse branch prefix in square brackets, if any.
+	// E.g., "[branch] path: change title" -> "[branch] ", "path: change title".
+	var branch string // "[branch] " or empty string.
+	if strings.HasPrefix(prefixedTitle, "[") {
+		if idx := strings.Index(prefixedTitle, "] "); idx != -1 {
+			branch, prefixedTitle = prefixedTitle[:idx+len("] ")], prefixedTitle[idx+len("] "):]
+		}
+	}
+
+	// Parse the rest of the prefixed change title.
+	// E.g., "path1, path2: change title" -> ["path1", "path2"], "change title".
+	idx := strings.Index(prefixedTitle, ": ")
+	if idx == -1 {
+		return []string{module}, branch + prefixedTitle
+	}
+	prefix, title := prefixedTitle[:idx], prefixedTitle[idx+len(": "):]
+	if strings.ContainsAny(prefix, "{}") {
+		// TODO: Parse "image/{png,jpeg}" as ["image/png", "image/jpeg"], maybe?
+		return []string{path.Join(module, strings.TrimSpace(prefix))}, branch + title
+	}
+	paths = strings.Split(prefix, ",")
+	for i := range paths {
+		paths[i] = path.Join(module, strings.TrimSpace(paths[i]))
+	}
+	return paths, branch + title
+}
parse_test.go
@@ -0,0 +1,138 @@
+package prefixtitle_test
+
+import (
+	"reflect"
+	"testing"
+
+	"dmitri.shuralyov.com/go/prefixtitle"
+)
+
+func TestParseIssue(t *testing.T) {
+	tests := []struct {
+		inRoot    string
+		in        string
+		wantPaths []string
+		wantTitle string
+	}{
+		// Go standard library and subrepo packages.
+		{
+			in:        "import/path: issue title",
+			wantPaths: []string{"import/path"}, wantTitle: "issue title",
+		},
+		{ // Proposal.
+			in:        "proposal: path: issue title",
+			wantPaths: []string{"path"}, wantTitle: "issue title",
+		},
+		{ // Proposal.
+			in:        "Proposal: path: issue title",
+			wantPaths: []string{"path"}, wantTitle: "issue title",
+		},
+		{ // "x/..." refers to "golang.org/x/...".
+			in:        "x/path: issue title",
+			wantPaths: []string{"golang.org/x/path"}, wantTitle: "issue title",
+		},
+		{ // "x" refers to "golang.org/x".
+			in:        "x: issue title",
+			wantPaths: []string{"golang.org/x"}, wantTitle: "issue title",
+		},
+		{ // Multiple comma-separated paths.
+			in:        "path1, path2: issue title",
+			wantPaths: []string{"path1", "path2"}, wantTitle: "issue title",
+		},
+		{ // No path prefix.
+			in:        "issue title",
+			wantPaths: []string{""}, wantTitle: "issue title",
+		},
+
+		// Third-party packages.
+		{
+			inRoot:    "example.org",
+			in:        "import/path: issue title",
+			wantPaths: []string{"example.org/import/path"}, wantTitle: "issue title",
+		},
+		{ // Multiple comma-separated paths.
+			inRoot:    "example.org",
+			in:        "path1, path2: issue title",
+			wantPaths: []string{"example.org/path1", "example.org/path2"}, wantTitle: "issue title",
+		},
+		{ // No path prefix.
+			inRoot:    "example.org",
+			in:        "issue title",
+			wantPaths: []string{"example.org"}, wantTitle: "issue title",
+		},
+	}
+	for i, tc := range tests {
+		gotPaths, gotTitle := prefixtitle.ParseIssue(tc.inRoot, tc.in)
+		if !reflect.DeepEqual(gotPaths, tc.wantPaths) {
+			t.Errorf("%d: got paths: %q, want: %q", i, gotPaths, tc.wantPaths)
+		}
+		if gotTitle != tc.wantTitle {
+			t.Errorf("%d: got title: %q, want: %q", i, gotTitle, tc.wantTitle)
+		}
+	}
+}
+
+func TestParseChange(t *testing.T) {
+	tests := []struct {
+		inRoot    string
+		in        string
+		wantPaths []string
+		wantTitle string
+	}{
+		{
+			in:        "import/path: change title",
+			wantPaths: []string{"import/path"}, wantTitle: "change title",
+		},
+		{
+			inRoot:    "golang.org/x/image",
+			in:        "import/path: change title",
+			wantPaths: []string{"golang.org/x/image/import/path"}, wantTitle: "change title",
+		},
+		{
+			inRoot:    "golang.org/x/image",
+			in:        "[release-branch.go1.11] import/path: change title",
+			wantPaths: []string{"golang.org/x/image/import/path"}, wantTitle: "[release-branch.go1.11] change title",
+		},
+
+		// Multiple comma-separated paths.
+		{
+			in:        "path1, path2: change title",
+			wantPaths: []string{"path1", "path2"}, wantTitle: "change title",
+		},
+		{
+			inRoot:    "golang.org/x/image",
+			in:        "path1, path2: change title",
+			wantPaths: []string{"golang.org/x/image/path1", "golang.org/x/image/path2"}, wantTitle: "change title",
+		},
+		{
+			inRoot:    "golang.org/x/image",
+			in:        "[release-branch.go1.11] path1, path2: change title",
+			wantPaths: []string{"golang.org/x/image/path1", "golang.org/x/image/path2"}, wantTitle: "[release-branch.go1.11] change title",
+		},
+
+		// No path prefix.
+		{
+			in:        "change title",
+			wantPaths: []string{""}, wantTitle: "change title",
+		},
+		{
+			inRoot:    "golang.org/x/image",
+			in:        "change title",
+			wantPaths: []string{"golang.org/x/image"}, wantTitle: "change title",
+		},
+		{
+			inRoot:    "golang.org/x/image",
+			in:        "[release-branch.go1.11] change title",
+			wantPaths: []string{"golang.org/x/image"}, wantTitle: "[release-branch.go1.11] change title",
+		},
+	}
+	for i, tc := range tests {
+		gotPaths, gotTitle := prefixtitle.ParseChange(tc.inRoot, tc.in)
+		if !reflect.DeepEqual(gotPaths, tc.wantPaths) {
+			t.Errorf("%d: got paths: %q, want: %q", i, gotPaths, tc.wantPaths)
+		}
+		if gotTitle != tc.wantTitle {
+			t.Errorf("%d: got title: %q, want: %q", i, gotTitle, tc.wantTitle)
+		}
+	}
+}