git-codereview: rewrite issue references

We have a single repo against which all issues are filed.
GitHub supports closing issues in other repos,
but it is easy to forget to use the magic syntax.
To fix that, rewrite issue references in the commit msg hook.

To that end, introduce a repo-level config file,
located at REPOROOT/codereview.cfg.
The config is always read from origin/master.
Config lines are of the form "key: value".
Lines beginning with # are comments.

To designate a repo as the issues repo, add

issuerepo: USERNAME/REPO

to the config.

Fixes golang/go#9273.

Change-Id: I31ec98883641bbf0c149f3619769231f6a452512
Reviewed-on: https://go-review.googlesource.com/4131
Reviewed-by: Andrew Gerrand <adg@golang.org>
Reviewed-by: Russ Cox <rsc@golang.org>
diff --git a/codereview.cfg b/codereview.cfg
new file mode 100644
index 0000000..3f8b14b
--- /dev/null
+++ b/codereview.cfg
@@ -0,0 +1 @@
+issuerepo: golang/go
diff --git a/git-codereview/config.go b/git-codereview/config.go
new file mode 100644
index 0000000..1adf71f
--- /dev/null
+++ b/git-codereview/config.go
@@ -0,0 +1,54 @@
+// Copyright 2015 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 main
+
+import (
+	"fmt"
+	"strings"
+)
+
+var (
+	configRef    = "refs/remotes/origin/master:codereview.cfg"
+	cachedConfig map[string]string
+)
+
+// Config returns the code review config.
+// Configs consist of lines of the form "key: value".
+// Lines beginning with # are comments.
+// If there is no config, it returns an empty map.
+// If the config is malformed, it dies.
+func config() map[string]string {
+	if cachedConfig != nil {
+		return cachedConfig
+	}
+	raw, err := cmdOutputErr("git", "show", configRef)
+	if err != nil {
+		verbosef("%sfailed to load config from %q: %v", raw, configRef, err)
+		cachedConfig = make(map[string]string)
+		return cachedConfig
+	}
+	cachedConfig, err = parseConfig(raw)
+	if err != nil {
+		dief("%v", err)
+	}
+	return cachedConfig
+}
+
+func parseConfig(raw string) (map[string]string, error) {
+	cfg := make(map[string]string)
+	for _, line := range nonBlankLines(raw) {
+		line = strings.TrimSpace(line)
+		if strings.HasPrefix(line, "#") {
+			// comment line
+			continue
+		}
+		fields := strings.SplitN(line, ":", 2)
+		if len(fields) != 2 {
+			return nil, fmt.Errorf("bad config line, expected 'key: value': %q", line)
+		}
+		cfg[strings.TrimSpace(fields[0])] = strings.TrimSpace(fields[1])
+	}
+	return cfg, nil
+}
diff --git a/git-codereview/config_test.go b/git-codereview/config_test.go
new file mode 100644
index 0000000..249b7df
--- /dev/null
+++ b/git-codereview/config_test.go
@@ -0,0 +1,34 @@
+// Copyright 2015 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 main
+
+import (
+	"reflect"
+	"testing"
+)
+
+func TestParseConfig(t *testing.T) {
+	cases := []struct {
+		raw     string
+		want    map[string]string
+		wanterr bool
+	}{
+		{raw: "", want: map[string]string{}},
+		{raw: "issuerepo: golang/go", want: map[string]string{"issuerepo": "golang/go"}},
+		{raw: "# comment", want: map[string]string{}},
+		{raw: "# comment\n  k  :   v   \n# comment 2\n\n k2:v2\n", want: map[string]string{"k": "v", "k2": "v2"}},
+	}
+
+	for _, tt := range cases {
+		cfg, err := parseConfig(tt.raw)
+		if err != nil != tt.wanterr {
+			t.Errorf("parse(%q) error: %v", tt.raw, err)
+			continue
+		}
+		if !reflect.DeepEqual(cfg, tt.want) {
+			t.Errorf("parse(%q)=%v want %v", tt.raw, cfg, tt.want)
+		}
+	}
+}
diff --git a/git-codereview/hook.go b/git-codereview/hook.go
index 4746b61..3d4ff9c 100644
--- a/git-codereview/hook.go
+++ b/git-codereview/hook.go
@@ -98,6 +98,8 @@
 `
 
 func cmdHookInvoke(args []string) {
+	flags.Parse(args)
+	args = flags.Args()
 	if len(args) == 0 {
 		dief("usage: git-codereview hook-invoke <hook-name> [args...]")
 	}
@@ -109,6 +111,8 @@
 	}
 }
 
+var issueRefRE = regexp.MustCompile(`(?P<space>\s)(?P<ref>#\d+\w)`)
+
 // hookCommitMsg is installed as the git commit-msg hook.
 // It adds a Change-Id line to the bottom of the commit message
 // if there is not one already.
@@ -144,6 +148,11 @@
 		data[eol+1] = '\n'
 	}
 
+	// Update issue references to point to issue repo, if set.
+	if issueRepo := config()["issuerepo"]; issueRepo != "" {
+		data = issueRefRE.ReplaceAll(data, []byte("${space}"+issueRepo+"${ref}"))
+	}
+
 	// Add Change-Id to commit message if not present.
 	edited := false
 	if !bytes.Contains(data, []byte("\nChange-Id: ")) {
diff --git a/git-codereview/hook_test.go b/git-codereview/hook_test.go
index 645c547..34772d0 100644
--- a/git-codereview/hook_test.go
+++ b/git-codereview/hook_test.go
@@ -14,6 +14,8 @@
 	"testing"
 )
 
+const lenChangeId = len("\n\nChange-Id: I") + 2*20
+
 func TestHookCommitMsg(t *testing.T) {
 	gt := newGitTest(t)
 	defer gt.done()
@@ -73,7 +75,6 @@
 		}
 
 		// pull off the Change-Id that got appended
-		const lenChangeId = len("\n\nChange-Id: I") + 2*20
 		got = got[:len(got)-lenChangeId]
 		want = want[:len(want)-lenChangeId]
 		if !bytes.Equal(got, want) {
@@ -82,6 +83,51 @@
 	}
 }
 
+func TestHookCommitMsgIssueRepoRewrite(t *testing.T) {
+	gt := newGitTest(t)
+	defer gt.done()
+
+	// If there's no config, don't rewrite issue references.
+	const msg = "math/big: catch all the rats\n\nFixes #99999, at least for now\n"
+	write(t, gt.client+"/msg.txt", msg)
+	testMain(t, "hook-invoke", "commit-msg", gt.client+"/msg.txt")
+	got, err := ioutil.ReadFile(gt.client + "/msg.txt")
+	if err != nil {
+		t.Fatal(err)
+	}
+	got = got[:len(got)-lenChangeId]
+	if string(got) != msg {
+		t.Errorf("hook changed %s to %s", msg, got)
+	}
+
+	// Add issuerepo config.
+	write(t, gt.client+"/codereview.cfg", "issuerepo: golang/go")
+	trun(t, gt.client, "git", "add", "codereview.cfg")
+	trun(t, gt.client, "git", "commit", "-m", "add issuerepo codereview config")
+
+	// Look in master rather than origin/master for the config
+	savedConfigRef := configRef
+	configRef = "master:codereview.cfg"
+	cachedConfig = nil
+
+	// Check for the rewrite
+	write(t, gt.client+"/msg.txt", msg)
+	testMain(t, "hook-invoke", "commit-msg", gt.client+"/msg.txt")
+	got, err = ioutil.ReadFile(gt.client + "/msg.txt")
+	if err != nil {
+		t.Fatal(err)
+	}
+	got = got[:len(got)-lenChangeId]
+	const want = "math/big: catch all the rats\n\nFixes golang/go#99999, at least for now\n"
+	if string(got) != want {
+		t.Errorf("issue rewrite failed: got\n\n%s\nwant\n\n%s", got, want)
+	}
+
+	// Reset config state
+	configRef = savedConfigRef
+	cachedConfig = nil
+}
+
 func TestHookCommitMsgBranchPrefix(t *testing.T) {
 	gt := newGitTest(t)
 	defer gt.done()