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 4 years ago commit 985f29f600087e42c2d4d98c76a207527ea686bc
Showing partial commit. Full Commit
Collapse all
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)
		}
	}
}