blob: 1c4433d48b68c8bd4fc4cedbcae0ad8ca4316191 [file] [log] [blame]
// Copyright 2018 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 (
"context"
"flag"
"net/http"
"testing"
"time"
"github.com/google/go-cmp/cmp"
"github.com/google/go-cmp/cmp/cmpopts"
"github.com/google/go-github/v48/github"
"golang.org/x/build/devapp/owners"
"golang.org/x/build/maintner"
)
func TestLabelCommandsFromComments(t *testing.T) {
created := time.Now()
testCases := []struct {
desc string
body string
cmds []labelCommand
}{
{
"basic add/remove",
"We should fix this issue, but we need help\n\n@gopherbot please add help wanted, needsfix and remove needsinvestigation",
[]labelCommand{
{action: "add", label: "help wanted", created: created},
{action: "add", label: "needsfix", created: created},
{action: "remove", label: "needsinvestigation", created: created},
},
},
{
"no please",
"@gopherbot add NeedsFix",
[]labelCommand{
{action: "add", label: "needsfix", created: created},
},
},
{
"with comma",
"@gopherbot, NeedsFix",
[]labelCommand{
{action: "add", label: "needsfix", created: created},
},
},
{
"with semicolons",
"@gopherbot NeedsFix;help wanted; remove needsinvestigation",
[]labelCommand{
{action: "add", label: "needsfix", created: created},
{action: "add", label: "help wanted", created: created},
{action: "remove", label: "needsinvestigation", created: created},
},
},
{
"case insensitive",
"@gopherbot please add HelP WanteD",
[]labelCommand{
{action: "add", label: "help wanted", created: created},
},
},
{
"fun input",
"@gopherbot please add help wanted,;needsfix;",
[]labelCommand{
{action: "add", label: "help wanted", created: created},
{action: "add", label: "needsfix", created: created},
},
},
{
"with hyphen",
"@gopherbot please add label OS-macOS",
[]labelCommand{
{action: "add", label: "os-macos", created: created},
},
},
{
"unlabel keyword",
"@gopherbot please unlabel needsinvestigation, NeedsDecision",
[]labelCommand{
{action: "remove", label: "needsinvestigation", created: created},
{action: "remove", label: "needsdecision", created: created},
},
},
{
"with label[s] keyword",
"@gopherbot please add label help wanted and remove labels needsinvestigation, NeedsDecision",
[]labelCommand{
{action: "add", label: "help wanted", created: created},
{action: "remove", label: "needsinvestigation", created: created},
{action: "remove", label: "needsdecision", created: created},
},
},
{
"no label commands",
"The cake was a lie",
nil,
},
}
for _, tc := range testCases {
cmds := labelCommandsFromBody(tc.body, created)
if diff := cmp.Diff(cmds, tc.cmds, cmp.AllowUnexported(labelCommand{})); diff != "" {
t.Errorf("%s: commands differ: (-got +want)\n%s", tc.desc, diff)
}
}
}
func TestLabelMutations(t *testing.T) {
testCases := []struct {
desc string
cmds []labelCommand
add []string
remove []string
}{
{
"basic",
[]labelCommand{
{action: "add", label: "foo"},
{action: "remove", label: "baz"},
},
[]string{"foo"},
[]string{"baz"},
},
{
"add/remove of same label",
[]labelCommand{
{action: "add", label: "foo"},
{action: "remove", label: "foo"},
{action: "remove", label: "bar"},
{action: "add", label: "bar"},
},
nil,
nil,
},
{
"deduplication of labels",
[]labelCommand{
{action: "add", label: "foo"},
{action: "add", label: "foo"},
{action: "remove", label: "bar"},
{action: "remove", label: "bar"},
},
[]string{"foo"},
[]string{"bar"},
},
{
"forbidden actions",
[]labelCommand{
{action: "add", label: "Proposal-Accepted"},
{action: "add", label: "CherryPickApproved"},
{action: "add", label: "cla: yes"},
{action: "remove", label: "Security"},
},
nil,
nil,
},
{
"can add Security",
[]labelCommand{
{action: "add", label: "Security"},
},
[]string{"Security"},
nil,
},
}
for _, tc := range testCases {
add, remove := mutationsFromCommands(tc.cmds)
if diff := cmp.Diff(add, tc.add); diff != "" {
t.Errorf("%s: label additions differ: (-got, +want)\n%s", tc.desc, diff)
}
if diff := cmp.Diff(remove, tc.remove); diff != "" {
t.Errorf("%s: label removals differ: (-got, +want)\n%s", tc.desc, diff)
}
}
}
type fakeIssuesService struct {
labels map[int][]string
}
func (f *fakeIssuesService) ListLabelsByIssue(ctx context.Context, owner, repo string, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) {
var labels []*github.Label
if ls, ok := f.labels[number]; ok {
for _, l := range ls {
name := l
labels = append(labels, &github.Label{Name: &name})
}
}
return labels, nil, nil
}
func (f *fakeIssuesService) AddLabelsToIssue(ctx context.Context, owner, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) {
if f.labels == nil {
f.labels = map[int][]string{number: labels}
return nil, nil, nil
}
ls, ok := f.labels[number]
if !ok {
f.labels[number] = labels
return nil, nil, nil
}
for _, label := range labels {
var found bool
for _, l := range ls {
if l == label {
found = true
}
}
if found {
continue
}
f.labels[number] = append(f.labels[number], label)
}
return nil, nil, nil
}
func (f *fakeIssuesService) RemoveLabelForIssue(ctx context.Context, owner, repo string, number int, label string) (*github.Response, error) {
if ls, ok := f.labels[number]; ok {
for i, l := range ls {
if l == label {
f.labels[number] = append(f.labels[number][:i], f.labels[number][i+1:]...)
return nil, nil
}
}
}
// The GitHub API returns a NotFound error if the label did not exist.
return nil, &github.ErrorResponse{
Response: &http.Response{
Status: http.StatusText(http.StatusNotFound),
StatusCode: http.StatusNotFound,
},
}
}
func TestAddLabels(t *testing.T) {
testCases := []struct {
desc string
gi *maintner.GitHubIssue
labels []string
added []string
}{
{
"basic add",
&maintner.GitHubIssue{},
[]string{"foo"},
[]string{"foo"},
},
{
"some labels already present in maintner",
&maintner.GitHubIssue{
Labels: map[int64]*maintner.GitHubLabel{
0: {Name: "NeedsDecision"},
},
},
[]string{"foo", "NeedsDecision"},
[]string{"foo"},
},
{
"all labels already present in maintner",
&maintner.GitHubIssue{
Labels: map[int64]*maintner.GitHubLabel{
0: {Name: "NeedsDecision"},
},
},
[]string{"NeedsDecision"},
nil,
},
}
b := &gopherbot{}
for _, tc := range testCases {
// Clear any previous state from fake addLabelsToIssue since some test cases may skip calls to it.
fis := &fakeIssuesService{}
b.is = fis
if err := b.addLabels(context.Background(), maintner.GitHubRepoID{
Owner: "golang",
Repo: "go",
}, tc.gi, tc.labels); err != nil {
t.Errorf("%s: b.addLabels got unexpected error: %v", tc.desc, err)
continue
}
if diff := cmp.Diff(fis.labels[int(tc.gi.ID)], tc.added); diff != "" {
t.Errorf("%s: labels added differ: (-got, +want)\n%s", tc.desc, diff)
}
}
}
func TestRemoveLabels(t *testing.T) {
testCases := []struct {
desc string
gi *maintner.GitHubIssue
ghLabels []string
toRemove []string
want []string
}{
{
"basic remove",
&maintner.GitHubIssue{
Number: 123,
Labels: map[int64]*maintner.GitHubLabel{
0: {Name: "NeedsFix"},
1: {Name: "help wanted"},
},
},
[]string{"NeedsFix", "help wanted"},
[]string{"NeedsFix"},
[]string{"help wanted"},
},
{
"label not present in maintner",
&maintner.GitHubIssue{},
[]string{"NeedsFix"},
[]string{"NeedsFix"},
[]string{"NeedsFix"},
},
{
"label not present in GitHub",
&maintner.GitHubIssue{
Labels: map[int64]*maintner.GitHubLabel{
0: {Name: "foo"},
},
},
[]string{"NeedsFix"},
[]string{"foo"},
[]string{"NeedsFix"},
},
}
b := &gopherbot{}
for _, tc := range testCases {
// Clear any previous state from fakeIssuesService since some test cases may skip calls to it.
fis := &fakeIssuesService{map[int][]string{
int(tc.gi.Number): tc.ghLabels,
}}
b.is = fis
if err := b.removeLabels(context.Background(), maintner.GitHubRepoID{
Owner: "golang",
Repo: "go",
}, tc.gi, tc.toRemove); err != nil {
t.Errorf("%s: b.addLabels got unexpected error: %v", tc.desc, err)
continue
}
if diff := cmp.Diff(fis.labels[int(tc.gi.Number)], tc.want); diff != "" {
t.Errorf("%s: labels differ: (-got, +want)\n%s", tc.desc, diff)
}
}
}
func TestReviewersInMetas(t *testing.T) {
testCases := []struct {
desc string
commitMsg string
wantIDs []string
}{
{
desc: "one human reviewer",
commitMsg: `Patch-set: 6
Reviewer: Andrew Bonventre <22285@62eb7196-b449-3ce5-99f1-c037f21e1705>
`,
wantIDs: []string{"22285"},
},
{
desc: "one human CC",
commitMsg: `Patch-set: 6
CC: Andrew Bonventre <22285@62eb7196-b449-3ce5-99f1-c037f21e1705>
`,
wantIDs: []string{"22285"},
},
{
desc: "gobot reviewer",
commitMsg: `Patch-set: 6
Reviewer: Gobot Gobot <5976@62eb7196-b449-3ce5-99f1-c037f21e1705>
`,
wantIDs: []string{"5976"},
},
{
desc: "gobot reviewer and human CC",
commitMsg: `Patch-set: 6
Reviewer: Gobot Gobot <5976@62eb7196-b449-3ce5-99f1-c037f21e1705>
CC: Andrew Bonventre <22285@62eb7196-b449-3ce5-99f1-c037f21e1705>
`,
wantIDs: []string{"5976", "22285"},
},
{
desc: "gobot reviewer and human reviewer",
commitMsg: `Patch-set: 6
Reviewer: Gobot Gobot <5976@62eb7196-b449-3ce5-99f1-c037f21e1705>
Reviewer: Andrew Bonventre <22285@62eb7196-b449-3ce5-99f1-c037f21e1705>
`,
wantIDs: []string{"5976", "22285"},
},
{
desc: "gobot reviewer and two human reviewers",
commitMsg: `Patch-set: 6
Reviewer: Gobot Gobot <5976@62eb7196-b449-3ce5-99f1-c037f21e1705>
Reviewer: Andrew Bonventre <22285@62eb7196-b449-3ce5-99f1-c037f21e1705>
Reviewer: Rebecca Stambler <16140@62eb7196-b449-3ce5-99f1-c037f21e1705>
`,
wantIDs: []string{"5976", "22285", "16140"},
},
{
desc: "reviewersInMetas should not return duplicate IDs", // Happened in go.dev/cl/534975.
commitMsg: `Reviewer: Gerrit User 5190 <5190@62eb7196-b449-3ce5-99f1-c037f21e1705>
CC: Gerrit User 60063 <60063@62eb7196-b449-3ce5-99f1-c037f21e1705>
Reviewer: Gerrit User 60063 <60063@62eb7196-b449-3ce5-99f1-c037f21e1705>`,
wantIDs: []string{"5190", "60063"},
},
}
cmpFn := func(a, b string) bool {
return a < b
}
for _, tc := range testCases {
t.Run(tc.desc, func(t *testing.T) {
metas := []*maintner.GerritMeta{
{Commit: &maintner.GitCommit{Msg: tc.commitMsg}},
}
ids := reviewersInMetas(metas)
if diff := cmp.Diff(tc.wantIDs, ids, cmpopts.SortSlices(cmpFn)); diff != "" {
t.Fatalf("reviewersInMetas() mismatch (-want +got):\n%s", diff)
}
})
}
}
func TestMergeOwnersEntries(t *testing.T) {
var (
andybons = owners.Owner{GitHubUsername: "andybons", GerritEmail: "andybons@golang.org"}
bradfitz = owners.Owner{GitHubUsername: "bradfitz", GerritEmail: "bradfitz@golang.org"}
filippo = owners.Owner{GitHubUsername: "filippo", GerritEmail: "filippo@golang.org"}
iant = owners.Owner{GitHubUsername: "iant", GerritEmail: "iant@golang.org"}
rsc = owners.Owner{GitHubUsername: "rsc", GerritEmail: "rsc@golang.org"}
)
testCases := []struct {
desc string
entries []*owners.Entry
authorEmail string
result *owners.Entry
}{
{
"no entries",
nil,
"",
&owners.Entry{},
},
{
"primary merge",
[]*owners.Entry{
{Primary: []owners.Owner{andybons}},
{Primary: []owners.Owner{bradfitz}},
},
"",
&owners.Entry{
Primary: []owners.Owner{andybons, bradfitz},
},
},
{
"secondary merge",
[]*owners.Entry{
{Secondary: []owners.Owner{andybons}},
{Secondary: []owners.Owner{filippo}},
},
"",
&owners.Entry{
Secondary: []owners.Owner{andybons, filippo},
},
},
{
"promote from secondary to primary",
[]*owners.Entry{
{Primary: []owners.Owner{andybons, filippo}},
{Secondary: []owners.Owner{filippo}},
},
"",
&owners.Entry{
Primary: []owners.Owner{andybons, filippo},
},
},
{
"primary filter",
[]*owners.Entry{
{Primary: []owners.Owner{filippo, andybons}},
},
filippo.GerritEmail,
&owners.Entry{
Primary: []owners.Owner{andybons},
},
},
{
"secondary filter",
[]*owners.Entry{
{Secondary: []owners.Owner{filippo, andybons}},
},
filippo.GerritEmail,
&owners.Entry{
Secondary: []owners.Owner{andybons},
},
},
{
"too many reviewers",
[]*owners.Entry{
{Primary: []owners.Owner{iant, bradfitz}, Secondary: []owners.Owner{andybons}},
{Primary: []owners.Owner{andybons}, Secondary: []owners.Owner{iant, bradfitz}},
{Primary: []owners.Owner{iant, filippo}, Secondary: []owners.Owner{bradfitz, andybons, rsc}},
},
"",
&owners.Entry{
Primary: []owners.Owner{andybons, bradfitz, iant},
},
},
}
cmpFn := func(a, b owners.Owner) bool {
return a.GitHubUsername < b.GitHubUsername
}
for _, tc := range testCases {
got := mergeOwnersEntries(tc.entries, tc.authorEmail)
if diff := cmp.Diff(got, tc.result, cmpopts.SortSlices(cmpFn)); diff != "" {
t.Errorf("%s: final entry results differ: (-got, +want)\n%s", tc.desc, diff)
}
}
}
func TestFilterGerritOwners(t *testing.T) {
var (
andybons = owners.Owner{GitHubUsername: "andybons", GerritEmail: "andybons@golang.org"}
bradfitz = owners.Owner{GitHubUsername: "bradfitz", GerritEmail: "bradfitz@golang.org"}
toolsTeam = owners.Owner{GitHubUsername: "golang/tools-team"}
)
testCases := []struct {
name string
entries []*owners.Entry
want []*owners.Entry
}{
{
name: "no entries",
entries: nil,
want: []*owners.Entry{},
},
{
name: "all valid",
entries: []*owners.Entry{
{Primary: []owners.Owner{andybons}},
{Primary: []owners.Owner{bradfitz}},
},
want: []*owners.Entry{
{Primary: []owners.Owner{andybons}},
{Primary: []owners.Owner{bradfitz}},
},
},
{
name: "drop primary",
entries: []*owners.Entry{
{Primary: []owners.Owner{andybons, toolsTeam}},
{Primary: []owners.Owner{toolsTeam, bradfitz}},
},
want: []*owners.Entry{
{Primary: []owners.Owner{andybons}},
{Primary: []owners.Owner{bradfitz}},
},
},
{
name: "drop secondary",
entries: []*owners.Entry{
{
Primary: []owners.Owner{andybons},
Secondary: []owners.Owner{bradfitz, toolsTeam},
},
{
Primary: []owners.Owner{bradfitz},
Secondary: []owners.Owner{toolsTeam, andybons},
},
},
want: []*owners.Entry{
{
Primary: []owners.Owner{andybons},
Secondary: []owners.Owner{bradfitz},
},
{
Primary: []owners.Owner{bradfitz},
Secondary: []owners.Owner{andybons},
},
},
},
{
name: "upgrade secondary",
entries: []*owners.Entry{
{
Primary: []owners.Owner{toolsTeam},
Secondary: []owners.Owner{bradfitz},
},
},
want: []*owners.Entry{
{
Primary: []owners.Owner{bradfitz},
},
},
},
{
name: "no primary",
entries: []*owners.Entry{
{
Secondary: []owners.Owner{bradfitz},
},
},
want: []*owners.Entry{
{
Primary: []owners.Owner{bradfitz},
},
},
},
}
cmpFn := func(a, b owners.Owner) bool {
return a.GitHubUsername < b.GitHubUsername
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
got := filterGerritOwners(tc.entries)
if diff := cmp.Diff(got, tc.want, cmpopts.SortSlices(cmpFn)); diff != "" {
t.Errorf("final entry results differ: (-got, +want)\n%s", diff)
}
})
}
}
func TestForeachIssue(t *testing.T) {
if testing.Short() || flag.Lookup("test.run").Value.String() != "^TestForeachIssue$" {
t.Skip("not running test requiring large Go corpus download in short mode and if not explicitly requested with go test -run=^TestForeachIssue$")
}
b := &gopherbot{}
b.initCorpus()
var num int
err := b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
if gi.Closed || gi.PullRequest || gi.NotExist {
t.Errorf("issue %d should be skipped but isn't: %#v", gi.Number, gi)
}
num++
return nil
})
if err != nil {
t.Errorf("gopherbot.foreachIssue: got %v error, want nil", err)
}
t.Logf("gopherbot.foreachIssue walked over %d open issues (not including PRs and deleted/transferred/converted issues)", num)
var got struct {
Open, Closed, PR bool
}
err = b.foreachIssue(b.gorepo, open|closed|includePRs, func(gi *maintner.GitHubIssue) error {
if gi.NotExist {
t.Errorf("issue %d should be skipped but isn't: %#v", gi.Number, gi)
}
got.Open = got.Open || !gi.Closed
got.Closed = got.Closed || gi.Closed
got.PR = got.PR || gi.PullRequest
return nil
})
if err != nil {
t.Errorf("gopherbot.foreachIssue: got %v error, want nil", err)
}
if !got.Open || !got.Closed || !got.PR {
t.Errorf("got %+v, want all true", got)
}
}