dmitri.shuralyov.com/website/gido/...

factor out prefixed title parsing code

This functionality is factored out into a separate package
so that it can be reused across a wider set of projects.
dmitshur committed 3 years ago commit 9ec8098d1ae7dcc4e4bcc249640bb5dec2a42b2c
service.go
@@ -8,10 +8,11 @@ import (
	"sort"
	"strings"
	"sync"
	"time"

	"dmitri.shuralyov.com/go/prefixtitle"
	"dmitri.shuralyov.com/service/change"
	"github.com/shurcooL/issues"
	"github.com/shurcooL/users"
	"golang.org/x/build/maintner"
	"golang.org/x/build/maintner/godata"
@@ -159,11 +160,11 @@ func issuesAndChanges(repo *maintner.GitHubRepo, gerrit *maintner.Gerrit) map[st
	err := repo.ForeachIssue(func(i *maintner.GitHubIssue) error {
		if i.NotExist || i.PullRequest {
			return nil
		}

		pkgs, title := ParsePrefixedTitle(i.Title)
		pkgs, title := prefixtitle.ParseIssue("", i.Title)
		var labels []issues.Label
		for _, l := range i.Labels {
			labels = append(labels, issues.Label{
				Name: l.Name,
				// TODO: Can we use label ID to figure out its color?
@@ -236,11 +237,11 @@ func issuesAndChanges(repo *maintner.GitHubRepo, gerrit *maintner.Gerrit) map[st
			if !ok {
				return nil
			}

			prefixedTitle := cl.Subject()
			pkgs, title := ParsePrefixedChangeTitle(root, prefixedTitle)
			pkgs, title := prefixtitle.ParseChange(root, prefixedTitle)
			var labels []issues.Label
			cl.Meta.Hashtags().Foreach(func(name string) {
				labels = append(labels, issues.Label{
					Name:  name,
					Color: issues.RGB{R: 0xed, G: 0xed, B: 0xed}, // Light gray.
@@ -334,80 +335,10 @@ var gerritProjects = map[string]string{
	"go.googlesource.com/vgo":        "golang.org/x/vgo",
}

const otherPackages = "other"

// ParsePrefixedTitle parses a prefixed issue title.
// It returns a list of paths from the prefix, and the remaining issue title.
// It does not try to verify whether each path is an existing Go package.
//
// 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.
//
// If there's no path prefix (preceded by ": "), title is returned unmodified
// with an empty paths list:
//
// 	"Issue title."                 -> [], "Issue title."
//
func ParsePrefixedTitle(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 nil, 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{strings.TrimSpace(prefix)}, title
	}
	paths = strings.Split(prefix, ",")
	for i := range paths {
		paths[i] = 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
}

// ParsePrefixedChangeTitle parses a prefixed change title.
// It returns a list of paths from the prefix joined with root, and the remaining change title.
// It does not try to verify whether each path is an existing Go package.
//
// Supported forms include:
//
// 	"root", "import/path: Change title."  -> ["root/import/path"],         "Change title."
// 	"root", "path1, path2: Change title." -> ["root/path1", "root/path2"], "Change title."  # Multiple comma-separated paths.
//
// If there's no path prefix (preceded by ": "), title is returned unmodified
// with a paths list containing root:
//
// 	"root", "Change title."               -> ["root"], "Change title."
//
func ParsePrefixedChangeTitle(root, prefixedTitle string) (paths []string, title string) {
	idx := strings.Index(prefixedTitle, ": ")
	if idx == -1 {
		return []string{root}, 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(root, strings.TrimSpace(prefix))}, title
	}
	paths = strings.Split(prefix, ",")
	for i := range paths {
		paths[i] = path.Join(root, strings.TrimSpace(paths[i]))
	}
	return paths, title
}

// ImportPathToFullPrefix returns the an issue title prefix (including ": ") for the given import path.
// If path equals to otherPackages, an empty prefix is returned.
func ImportPathToFullPrefix(path string) string {
	switch {
	default:
service_test.go
@@ -1,106 +1,13 @@
package main_test

import (
	"reflect"
	"testing"

	gido "dmitri.shuralyov.com/website/gido"
)

func TestParsePrefixedTitle(t *testing.T) {
	tests := []struct {
		in        string
		wantPaths []string
		wantTitle string
	}{
		{
			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: nil, wantTitle: "Issue title.",
		},
	}
	for i, tc := range tests {
		gotPaths, gotTitle := gido.ParsePrefixedTitle(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 TestParsePrefixedChangeTitle(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:    "root",
			in:        "import/path: Change title.",
			wantPaths: []string{"root/import/path"}, wantTitle: "Change title.",
		},
		{ // Multiple comma-separated paths.
			in:        "path1, path2: Change title.",
			wantPaths: []string{"path1", "path2"}, wantTitle: "Change title.",
		},
		{
			inRoot:    "root",
			in:        "path1, path2: Change title.",
			wantPaths: []string{"root/path1", "root/path2"}, wantTitle: "Change title.",
		},
		{ // No path prefix.
			in:        "Change title.",
			wantPaths: []string{""}, wantTitle: "Change title.",
		},
		{
			inRoot:    "root",
			in:        "Change title.",
			wantPaths: []string{"root"}, wantTitle: "Change title.",
		},
	}
	for i, tc := range tests {
		gotPaths, gotTitle := gido.ParsePrefixedChangeTitle(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 TestImportPathToFullPrefix(t *testing.T) {
	tests := []struct {
		in   string
		want string
	}{