[tools] Add 'add-gerrit-hashtags' tool

Parses the CL descriptions and adds missing hashtags to Gerrit changes.

Also add  ./tools/push-to-gerrit which runs this after pushing the local branch's changes to 'main'.

Use this for the VSCode 'push' task.

Change-Id: I4c3f5982f6fdc7c1c6ebe770fc7811b1b38795d1
Reviewed-on: https://dawn-review.googlesource.com/c/dawn/+/133061
Commit-Queue: Ben Clayton <bclayton@google.com>
Kokoro: Ben Clayton <bclayton@google.com>
Reviewed-by: Dan Sinclair <dsinclair@chromium.org>
diff --git a/.vscode/tasks.json b/.vscode/tasks.json
index c0c1554..acd0666 100644
--- a/.vscode/tasks.json
+++ b/.vscode/tasks.json
@@ -125,17 +125,12 @@
         {
             "label": "push",
             "type": "shell",
-            "command": "git",
-            "args": [
-                "push",
-                "origin",
-                "HEAD:refs/for/main"
-            ],
+            "command": "./tools/push-to-gerrit",
             "options": {
                 "cwd": "${workspaceRoot}"
             },
             "problemMatcher": [],
-        }
+        },
     ],
     "inputs": [
         {
diff --git a/go.mod b/go.mod
index 7eb200f..a5d09ed 100644
--- a/go.mod
+++ b/go.mod
@@ -3,7 +3,7 @@
 go 1.18
 
 require (
-	github.com/andygrunwald/go-gerrit v0.0.0-20220427111355-d3e91fbf2db5
+	github.com/andygrunwald/go-gerrit v0.0.0-20230508072829-423d372345aa
 	github.com/ben-clayton/webidlparser v0.0.0-20210923100217-8ba896ded094
 	github.com/fatih/color v1.13.0
 	github.com/google/go-cmp v0.5.9
diff --git a/go.sum b/go.sum
index a648592..785efdb 100644
--- a/go.sum
+++ b/go.sum
@@ -8,6 +8,8 @@
 github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
 github.com/andygrunwald/go-gerrit v0.0.0-20220427111355-d3e91fbf2db5 h1:HBlTlvyq4siv4ZK41DebGIX11/9gFBqUF8G64AePjyQ=
 github.com/andygrunwald/go-gerrit v0.0.0-20220427111355-d3e91fbf2db5/go.mod h1:aqcjwEnmLLSalFNYR0p2ttnEXOVVRctIzsUMHbEcruU=
+github.com/andygrunwald/go-gerrit v0.0.0-20230508072829-423d372345aa h1:bGSzPoUh/2eduqGEk54TCoB4v81MVi6Hr3+fYQwFrBM=
+github.com/andygrunwald/go-gerrit v0.0.0-20230508072829-423d372345aa/go.mod h1:SeP12EkHZxEVjuJ2HZET304NBtHGG2X6w2Gzd0QXAZw=
 github.com/ben-clayton/webidlparser v0.0.0-20210923100217-8ba896ded094 h1:CTVJdI6oUCRNucMEmoh3c2U88DesoPtefsxKhoZ1WuQ=
 github.com/ben-clayton/webidlparser v0.0.0-20210923100217-8ba896ded094/go.mod h1:bV550SPlMos7UhMprxlm14XTBTpKHSUZ8Q4Id5qQuyw=
 github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
diff --git a/tools/push-to-gerrit b/tools/push-to-gerrit
new file mode 100755
index 0000000..ed11cc8
--- /dev/null
+++ b/tools/push-to-gerrit
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+# Copyright 2023 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.
+
+SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}")" >/dev/null 2>&1 && pwd )"
+
+git push origin HEAD:refs/for/main
+
+${SCRIPT_DIR}/run add-gerrit-hashtags
diff --git a/tools/src/cmd/add-gerrit-hashtags/main.go b/tools/src/cmd/add-gerrit-hashtags/main.go
new file mode 100644
index 0000000..e0ae0b2
--- /dev/null
+++ b/tools/src/cmd/add-gerrit-hashtags/main.go
@@ -0,0 +1,168 @@
+// Copyright 2023 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.
+
+// add-gerrit-hashtags adds any missing hashtags parsed from the CL description to the Gerrit change.
+package main
+
+import (
+	"flag"
+	"fmt"
+	"os"
+	"os/exec"
+	"regexp"
+	"strings"
+	"time"
+
+	"dawn.googlesource.com/dawn/tools/src/container"
+	"dawn.googlesource.com/dawn/tools/src/dawn"
+	"dawn.googlesource.com/dawn/tools/src/gerrit"
+	"dawn.googlesource.com/dawn/tools/src/git"
+)
+
+const (
+	toolName = "add-gerrit-hashtags"
+	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")
+	repoFlag    = flag.String("repo", "dawn", "the project (tint or dawn)")
+	userFlag    = flag.String("user", defaultUser(), "user name / email")
+	afterFlag   = flag.String("after", "", "start date")
+	beforeFlag  = flag.String("before", "", "end date")
+	daysFlag    = flag.Int("days", 30, "interval in days (used if --after is not specified)")
+	verboseFlag = flag.Bool("v", false, "verbose mode - lists all the changes")
+	dryrunFlag  = flag.Bool("dry", false, "dry mode. Don't apply any changes")
+)
+
+func defaultUser() string {
+	if gitExe, err := exec.LookPath("git"); err == nil {
+		if g, err := git.New(gitExe); err == nil {
+			if cwd, err := os.Getwd(); err == nil {
+				if r, err := g.Open(cwd); err == nil {
+					if cfg, err := r.Config(nil); err == nil {
+						return cfg["user.email"]
+					}
+				}
+			}
+		}
+	}
+	return ""
+}
+
+func main() {
+	flag.Usage = func() {
+		out := flag.CommandLine.Output()
+		fmt.Fprintf(out, "%v adds any missing hashtags parsed from the CL description to the Gerrit change.\n", toolName)
+		fmt.Fprintf(out, "\n")
+		flag.PrintDefaults()
+	}
+	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().Add(24 * time.Hour)
+	}
+	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(dawn.GerritURL, gerrit.Credentials{
+		Username: *gerritUser, Password: *gerritPass,
+	})
+	if err != nil {
+		return err
+	}
+
+	submitted, _, err := g.QueryChanges(
+		"owner:"+user,
+		"after:"+date(after),
+		"before:"+date(before),
+		"repo:"+*repoFlag)
+	if err != nil {
+		return fmt.Errorf("Query failed: %w", err)
+	}
+
+	numUpdated := 0
+	for _, cl := range submitted {
+		expected := parseHashtags(cl.Subject)
+		got := container.NewSet(cl.Hashtags...)
+		if !got.ContainsAll(expected) {
+			toAdd := expected.Clone()
+			toAdd.RemoveAll(got)
+			fmt.Printf("%v: %v missing hashtags: %v\n", cl.Number, cl.Subject, strings.Join(toAdd.List(), ", "))
+			if !*dryrunFlag {
+				if err := g.AddHashtags(cl.ChangeID, toAdd); err != nil {
+					return err
+				}
+				numUpdated++
+			}
+		}
+	}
+
+	if numUpdated > 0 {
+		fmt.Println()
+		fmt.Println(numUpdated, "changes updated with new hashtags")
+	} else {
+		fmt.Println("no changes updated")
+	}
+
+	return nil
+}
+
+var reBracketHashtag = regexp.MustCompile(`\[(\w+)\]`)
+var reColonHashtag = regexp.MustCompile(`^(\w+):`)
+
+func parseHashtags(subject string) container.Set[string] {
+	out := container.NewSet[string]()
+	for _, match := range reBracketHashtag.FindAllStringSubmatch(subject, -1) {
+		out.Add(match[1])
+	}
+	if match := reColonHashtag.FindStringSubmatch(subject); len(match) > 1 {
+		out.Add(match[1])
+	}
+	return out
+}
+
+func today() time.Time {
+	return time.Now()
+}
+
+func date(t time.Time) string {
+	return t.Format(yyyymmdd)
+}
diff --git a/tools/src/cmd/benchdiff/main.go b/tools/src/cmd/benchdiff/main.go
index ad9b9bd..66503aa 100644
--- a/tools/src/cmd/benchdiff/main.go
+++ b/tools/src/cmd/benchdiff/main.go
@@ -35,11 +35,11 @@
 
 func main() {
 	flag.ErrHelp = errors.New("benchdiff is a tool to compare two benchmark results")
-	flag.Parse()
 	flag.Usage = func() {
 		fmt.Fprintln(os.Stderr, "benchdiff <benchmark-a> <benchmark-b>")
 		flag.PrintDefaults()
 	}
+	flag.Parse()
 
 	args := flag.Args()
 	if len(args) < 2 {
diff --git a/tools/src/cmd/cts/common/results.go b/tools/src/cmd/cts/common/results.go
index a1647f3..00d829a 100644
--- a/tools/src/cmd/cts/common/results.go
+++ b/tools/src/cmd/cts/common/results.go
@@ -116,7 +116,7 @@
 		if err != nil {
 			return nil, err
 		}
-		*ps, err = gerrit.LatestPatchest(strconv.Itoa(ps.Change))
+		*ps, err = gerrit.LatestPatchset(strconv.Itoa(ps.Change))
 		if err != nil {
 			err := fmt.Errorf("failed to find latest patchset of change %v: %w",
 				ps.Change, err)
@@ -288,7 +288,7 @@
 
 // LatestPatchset returns the most recent patchset for the given change.
 func LatestPatchset(g *gerrit.Gerrit, change int) (gerrit.Patchset, error) {
-	ps, err := g.LatestPatchest(strconv.Itoa(change))
+	ps, err := g.LatestPatchset(strconv.Itoa(change))
 	if err != nil {
 		err := fmt.Errorf("failed to find latest patchset of change %v: %w",
 			ps.Change, err)
diff --git a/tools/src/gerrit/gerrit.go b/tools/src/gerrit/gerrit.go
index 8633afb..228f824 100644
--- a/tools/src/gerrit/gerrit.go
+++ b/tools/src/gerrit/gerrit.go
@@ -25,6 +25,7 @@
 	"strconv"
 	"strings"
 
+	"dawn.googlesource.com/dawn/tools/src/container"
 	"github.com/andygrunwald/go-gerrit"
 )
 
@@ -55,8 +56,8 @@
 // ChangeInfo is an alias to gerrit.ChangeInfo
 type ChangeInfo = gerrit.ChangeInfo
 
-// LatestPatchest returns the latest Patchset from the ChangeInfo
-func LatestPatchest(change *ChangeInfo) Patchset {
+// LatestPatchset returns the latest Patchset from the ChangeInfo
+func LatestPatchset(change *ChangeInfo) Patchset {
 	u, _ := url.Parse(change.URL)
 	ps := Patchset{
 		Host:     u.Host,
@@ -198,11 +199,11 @@
 		return Patchset{}, g.maybeWrapError(err)
 	}
 
-	return g.LatestPatchest(changeID)
+	return g.LatestPatchset(changeID)
 }
 
-// LatestPatchest returns the latest patchset for the change.
-func (g *Gerrit) LatestPatchest(changeID string) (Patchset, error) {
+// LatestPatchset returns the latest patchset for the change.
+func (g *Gerrit) LatestPatchset(changeID string) (Patchset, error) {
 	change, _, err := g.client.Changes.GetChange(changeID, &gerrit.ChangeOptions{
 		AdditionalFields: []string{"CURRENT_REVISION"},
 	})
@@ -218,6 +219,17 @@
 	return ps, nil
 }
 
+// AddHashtags adds the given hashtags to the change
+func (g *Gerrit) AddHashtags(changeID string, tags container.Set[string]) error {
+	_, resp, err := g.client.Changes.SetHashtags(changeID, &gerrit.HashtagsInput{
+		Add: tags.List(),
+	})
+	if err != nil && resp.StatusCode != 409 { // 409: already ready
+		return g.maybeWrapError(err)
+	}
+	return nil
+}
+
 // CommentSide is an enumerator for specifying which side code-comments should
 // be shown.
 type CommentSide int