tools: Add snippets tool

Gathers information about changes merged and reviewed for team weekly reports.

Change-Id: I53e3acc45679b4822c506d16980393fbaf337b3b
Reviewed-on: https://dawn-review.googlesource.com/c/tint/+/59022
Auto-Submit: Ben Clayton <bclayton@google.com>
Reviewed-by: James Price <jrprice@google.com>
Reviewed-by: Antonio Maiorano <amaiorano@google.com>
Kokoro: Kokoro <noreply+kokoro@google.com>
diff --git a/tools/gerrit-stats b/tools/gerrit-stats
new file mode 100755
index 0000000..3fcfb32
--- /dev/null
+++ b/tools/gerrit-stats
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Copyright 2021 The Tint Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -e # Fail on any error.
+
+if [ ! -x "$(which go)" ] ; then
+    echo "error: go needs to be on \$PATH to use $0"
+    exit 1
+fi
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd )"
+ROOT_DIR="$( cd "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd )"
+BINARY="${SCRIPT_DIR}/bin/gerrit-stats"
+
+# Rebuild the binary.
+# Note, go caches build artifacts, so this is quick for repeat calls
+pushd "${SCRIPT_DIR}/src/cmd/gerrit-stats" > /dev/null
+    go build -o "${BINARY}" main.go
+popd > /dev/null
+
+"${BINARY}" "$@"
diff --git a/tools/snippets b/tools/snippets
new file mode 100755
index 0000000..d58232d
--- /dev/null
+++ b/tools/snippets
@@ -0,0 +1,33 @@
+#!/usr/bin/env bash
+# Copyright 2021 The Tint Authors
+#
+# Licensed under the Apache License, Version 2.0 (the "License");
+# you may not use this file except in compliance with the License.
+# You may obtain a copy of the License at
+#
+#     http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+set -e # Fail on any error.
+
+if [ ! -x "$(which go)" ] ; then
+    echo "error: go needs to be on \$PATH to use $0"
+    exit 1
+fi
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd )"
+ROOT_DIR="$( cd "${SCRIPT_DIR}/.." >/dev/null 2>&1 && pwd )"
+BINARY="${SCRIPT_DIR}/bin/snippets"
+
+# Rebuild the binary.
+# Note, go caches build artifacts, so this is quick for repeat calls
+pushd "${SCRIPT_DIR}/src/cmd/snippets" > /dev/null
+    go build -o "${BINARY}" main.go
+popd > /dev/null
+
+"${BINARY}" "$@"
diff --git a/tools/src/cmd/gerrit-stats/main.go b/tools/src/cmd/gerrit-stats/main.go
index dd27fc3..33e30fd 100644
--- a/tools/src/cmd/gerrit-stats/main.go
+++ b/tools/src/cmd/gerrit-stats/main.go
@@ -18,20 +18,15 @@
 import (
 	"flag"
 	"fmt"
-	"io/ioutil"
 	"net/url"
 	"os"
 	"regexp"
-	"strings"
 	"time"
 
-	"github.com/andygrunwald/go-gerrit"
+	"dawn.googlesource.com/tint/tools/src/gerrit"
 )
 
-const (
-	yyyymmdd  = "2006-01-02"
-	gerritURL = "https://dawn-review.googlesource.com/"
-)
+const yyyymmdd = "2006-01-02"
 
 var (
 	// See https://dawn-review.googlesource.com/new-password for obtaining
@@ -54,26 +49,6 @@
 	}
 }
 
-func queryChanges(client *gerrit.Client, queryParts ...string) ([]gerrit.ChangeInfo, string, error) {
-	query := strings.Join(queryParts, "+")
-	out := []gerrit.ChangeInfo{}
-	for {
-		changes, _, err := client.Changes.QueryChanges(&gerrit.QueryChangeOptions{
-			QueryOptions: gerrit.QueryOptions{Query: []string{query}},
-			Skip:         len(out),
-		})
-		if err != nil {
-			return nil, "", err
-		}
-
-		out = append(out, *changes...)
-		if len(*changes) == 0 || !(*changes)[len(*changes)-1].MoreChanges {
-			break
-		}
-	}
-	return out, query, nil
-}
-
 func run() error {
 	var after, before time.Time
 	var err error
@@ -98,43 +73,22 @@
 		after = before.Add(-time.Hour * time.Duration(24**daysFlag))
 	}
 
-	client, err := gerrit.NewClient(gerritURL, nil)
+	g, err := gerrit.New(gerrit.Config{Username: *gerritUser, Password: *gerritPass})
 	if err != nil {
-		return fmt.Errorf("Couldn't create gerrit client: %w", err)
+		return err
 	}
 
-	if *gerritUser == "" {
-		cookiesFile := os.Getenv("HOME") + "/.gitcookies"
-		if cookies, err := ioutil.ReadFile(cookiesFile); err == nil {
-			re := regexp.MustCompile(`dawn-review.googlesource.com\s+(?:FALSE|TRUE)[\s/]+(?:FALSE|TRUE)\s+[0-9]+\s+.\s+(.*)=(.*)`)
-			match := re.FindStringSubmatch(string(cookies))
-			if len(match) == 3 {
-				*gerritUser, *gerritPass = match[1], match[2]
-			}
-		}
-	}
-
-	if *gerritUser != "" {
-		client.Authentication.SetBasicAuth(*gerritUser, *gerritPass)
-	}
-
-	submitted, submittedQuery, err := queryChanges(client,
+	submitted, submittedQuery, err := g.QueryChanges(
 		"status:merged",
 		"owner:"+user,
 		"after:"+date(after),
 		"before:"+date(before),
 		"repo:"+*repoFlag)
 	if err != nil {
-		if *gerritUser == "" {
-			return fmt.Errorf(`Query failed, possibly because of authentication.
-See https://dawn-review.googlesource.com/new-password for obtaining a username
-and password which can be provided with --gerrit-user and --gerrit-pass.
-%w`, err)
-		}
 		return fmt.Errorf("Query failed: %w", err)
 	}
 
-	reviewed, reviewQuery, err := queryChanges(client,
+	reviewed, reviewQuery, err := g.QueryChanges(
 		"commentby:"+user,
 		"-owner:"+user,
 		"after:"+date(after),
@@ -183,8 +137,8 @@
 	}
 
 	fmt.Printf("\n")
-	fmt.Printf("Submitted query: %vq/%v\n", gerritURL, url.QueryEscape(submittedQuery))
-	fmt.Printf("Review query: %vq/%v\n", gerritURL, url.QueryEscape(reviewQuery))
+	fmt.Printf("Submitted query: %vq/%v\n", gerrit.URL, url.QueryEscape(submittedQuery))
+	fmt.Printf("Review query: %vq/%v\n", gerrit.URL, url.QueryEscape(reviewQuery))
 
 	return nil
 }
diff --git a/tools/src/cmd/snippets/main.go b/tools/src/cmd/snippets/main.go
new file mode 100644
index 0000000..a8b94fc
--- /dev/null
+++ b/tools/src/cmd/snippets/main.go
@@ -0,0 +1,112 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// snippets gathers information about changes merged for weekly reports (snippets).
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+	"strings"
+	"time"
+
+	"dawn.googlesource.com/tint/tools/src/gerrit"
+)
+
+const yyyymmdd = "2006-01-02"
+
+var (
+	// See https://dawn-review.googlesource.com/new-password for obtaining
+	// username and password for gerrit.
+	gerritUser = flag.String("gerrit-user", "", "gerrit authentication username")
+	gerritPass = flag.String("gerrit-pass", "", "gerrit authentication password")
+	userFlag   = flag.String("user", "", "user name / email")
+	afterFlag  = flag.String("after", "", "start date")
+	beforeFlag = flag.String("before", "", "end date")
+	daysFlag   = flag.Int("days", 7, "interval in days (used if --after is not specified)")
+)
+
+func main() {
+	flag.Parse()
+	if err := run(); err != nil {
+		fmt.Fprintln(os.Stderr, err)
+		os.Exit(1)
+	}
+}
+
+func run() error {
+	var after, before time.Time
+	var err error
+	user := *userFlag
+	if user == "" {
+		return fmt.Errorf("Missing required 'user' flag")
+	}
+	if *beforeFlag != "" {
+		before, err = time.Parse(yyyymmdd, *beforeFlag)
+		if err != nil {
+			return fmt.Errorf("Couldn't parse before date: %w", err)
+		}
+	} else {
+		before = time.Now()
+	}
+	if *afterFlag != "" {
+		after, err = time.Parse(yyyymmdd, *afterFlag)
+		if err != nil {
+			return fmt.Errorf("Couldn't parse after date: %w", err)
+		}
+	} else {
+		after = before.Add(-time.Hour * time.Duration(24**daysFlag))
+	}
+
+	g, err := gerrit.New(gerrit.Config{Username: *gerritUser, Password: *gerritPass})
+	if err != nil {
+		return err
+	}
+
+	submitted, _, err := g.QueryChanges(
+		"status:merged",
+		"owner:"+user,
+		"after:"+date(after),
+		"before:"+date(before))
+	if err != nil {
+		return fmt.Errorf("Query failed: %w", err)
+	}
+
+	changesByProject := map[string][]string{}
+	for _, change := range submitted {
+		str := fmt.Sprintf(`* [%s](%sc/%s/+/%d)`, change.Subject, gerrit.URL, change.Project, change.Number)
+		changesByProject[change.Project] = append(changesByProject[change.Project], str)
+	}
+
+	for _, project := range []string{"tint", "dawn"} {
+		if changes := changesByProject[project]; len(changes) > 0 {
+			fmt.Println("##", strings.Title(project))
+			for _, change := range changes {
+				fmt.Println(change)
+			}
+			fmt.Println()
+		}
+	}
+
+	return nil
+}
+
+func today() time.Time {
+	return time.Now()
+}
+
+func date(t time.Time) string {
+	return t.Format(yyyymmdd)
+}
diff --git a/tools/src/gerrit/gerrit.go b/tools/src/gerrit/gerrit.go
new file mode 100644
index 0000000..0485bbd
--- /dev/null
+++ b/tools/src/gerrit/gerrit.go
@@ -0,0 +1,90 @@
+// Copyright 2021 The Tint Authors.
+//
+// Licensed under the Apache License, Version 2.0 (the "License");
+// you may not use this file except in compliance with the License.
+// You may obtain a copy of the License at
+//
+//     http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing, software
+// distributed under the License is distributed on an "AS IS" BASIS,
+// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+// See the License for the specific language governing permissions and
+// limitations under the License.
+
+// gerrit provides helpers for obtaining information from Tint's gerrit instance
+package gerrit
+
+import (
+	"fmt"
+	"io/ioutil"
+	"os"
+	"regexp"
+	"strings"
+
+	"github.com/andygrunwald/go-gerrit"
+)
+
+const URL = "https://dawn-review.googlesource.com/"
+
+// G is the interface to gerrit
+type G struct {
+	client        *gerrit.Client
+	authenticated bool
+}
+
+type Config struct {
+	Username string
+	Password string
+}
+
+func New(cfg Config) (*G, error) {
+	client, err := gerrit.NewClient(URL, nil)
+	if err != nil {
+		return nil, fmt.Errorf("Couldn't create gerrit client: %w", err)
+	}
+
+	user, pass := cfg.Username, cfg.Password
+	if user == "" {
+		cookiesFile := os.Getenv("HOME") + "/.gitcookies"
+		if cookies, err := ioutil.ReadFile(cookiesFile); err == nil {
+			re := regexp.MustCompile(`dawn-review.googlesource.com\s+(?:FALSE|TRUE)[\s/]+(?:FALSE|TRUE)\s+[0-9]+\s+.\s+(.*)=(.*)`)
+			match := re.FindStringSubmatch(string(cookies))
+			if len(match) == 3 {
+				user, pass = match[1], match[2]
+			}
+		}
+	}
+
+	if user != "" {
+		client.Authentication.SetBasicAuth(user, pass)
+	}
+
+	return &G{client, user != ""}, nil
+}
+
+func (g *G) QueryChanges(queryParts ...string) (changes []gerrit.ChangeInfo, query string, err error) {
+	changes = []gerrit.ChangeInfo{}
+	query = strings.Join(queryParts, "+")
+	for {
+		batch, _, err := g.client.Changes.QueryChanges(&gerrit.QueryChangeOptions{
+			QueryOptions: gerrit.QueryOptions{Query: []string{query}},
+			Skip:         len(changes),
+		})
+		if err != nil {
+			if !g.authenticated {
+				err = fmt.Errorf(`Query failed, possibly because of authentication.
+	See https://dawn-review.googlesource.com/new-password for obtaining a username
+	and password which can be provided with --gerrit-user and --gerrit-pass.
+	%w`, err)
+			}
+			return nil, "", err
+		}
+
+		changes = append(changes, *batch...)
+		if len(*batch) == 0 || !(*batch)[len(*batch)-1].MoreChanges {
+			break
+		}
+	}
+	return changes, query, nil
+}