golang.org/x/build@v0.0.0-20240506185731-218518f32b70/cmd/gopherbot/gopherbot_test.go (about)

     1  // Copyright 2018 The Go Authors. All rights reserved.
     2  // Use of this source code is governed by a BSD-style
     3  // license that can be found in the LICENSE file.
     4  
     5  package main
     6  
     7  import (
     8  	"context"
     9  	"flag"
    10  	"net/http"
    11  	"testing"
    12  	"time"
    13  
    14  	"github.com/google/go-cmp/cmp"
    15  	"github.com/google/go-cmp/cmp/cmpopts"
    16  	"github.com/google/go-github/v48/github"
    17  	"golang.org/x/build/devapp/owners"
    18  	"golang.org/x/build/maintner"
    19  )
    20  
    21  func TestLabelCommandsFromComments(t *testing.T) {
    22  	created := time.Now()
    23  	testCases := []struct {
    24  		desc string
    25  		body string
    26  		cmds []labelCommand
    27  	}{
    28  		{
    29  			"basic add/remove",
    30  			"We should fix this issue, but we need help\n\n@gopherbot please add help wanted, needsfix and remove needsinvestigation",
    31  			[]labelCommand{
    32  				{action: "add", label: "help wanted", created: created},
    33  				{action: "add", label: "needsfix", created: created},
    34  				{action: "remove", label: "needsinvestigation", created: created},
    35  			},
    36  		},
    37  		{
    38  			"no please",
    39  			"@gopherbot add NeedsFix",
    40  			[]labelCommand{
    41  				{action: "add", label: "needsfix", created: created},
    42  			},
    43  		},
    44  		{
    45  			"with comma",
    46  			"@gopherbot, NeedsFix",
    47  			[]labelCommand{
    48  				{action: "add", label: "needsfix", created: created},
    49  			},
    50  		},
    51  		{
    52  			"with semicolons",
    53  			"@gopherbot NeedsFix;help wanted; remove needsinvestigation",
    54  			[]labelCommand{
    55  				{action: "add", label: "needsfix", created: created},
    56  				{action: "add", label: "help wanted", created: created},
    57  				{action: "remove", label: "needsinvestigation", created: created},
    58  			},
    59  		},
    60  		{
    61  			"case insensitive",
    62  			"@gopherbot please add HelP WanteD",
    63  			[]labelCommand{
    64  				{action: "add", label: "help wanted", created: created},
    65  			},
    66  		},
    67  		{
    68  			"fun input",
    69  			"@gopherbot please add help wanted,;needsfix;",
    70  			[]labelCommand{
    71  				{action: "add", label: "help wanted", created: created},
    72  				{action: "add", label: "needsfix", created: created},
    73  			},
    74  		},
    75  		{
    76  			"with hyphen",
    77  			"@gopherbot please add label OS-macOS",
    78  			[]labelCommand{
    79  				{action: "add", label: "os-macos", created: created},
    80  			},
    81  		},
    82  		{
    83  			"unlabel keyword",
    84  			"@gopherbot please unlabel needsinvestigation, NeedsDecision",
    85  			[]labelCommand{
    86  				{action: "remove", label: "needsinvestigation", created: created},
    87  				{action: "remove", label: "needsdecision", created: created},
    88  			},
    89  		},
    90  		{
    91  			"with label[s] keyword",
    92  			"@gopherbot please add label help wanted and remove labels needsinvestigation, NeedsDecision",
    93  			[]labelCommand{
    94  				{action: "add", label: "help wanted", created: created},
    95  				{action: "remove", label: "needsinvestigation", created: created},
    96  				{action: "remove", label: "needsdecision", created: created},
    97  			},
    98  		},
    99  		{
   100  			"no label commands",
   101  			"The cake was a lie",
   102  			nil,
   103  		},
   104  	}
   105  	for _, tc := range testCases {
   106  		cmds := labelCommandsFromBody(tc.body, created)
   107  		if diff := cmp.Diff(cmds, tc.cmds, cmp.AllowUnexported(labelCommand{})); diff != "" {
   108  			t.Errorf("%s: commands differ: (-got +want)\n%s", tc.desc, diff)
   109  		}
   110  	}
   111  }
   112  
   113  func TestLabelMutations(t *testing.T) {
   114  	testCases := []struct {
   115  		desc   string
   116  		cmds   []labelCommand
   117  		add    []string
   118  		remove []string
   119  	}{
   120  		{
   121  			"basic",
   122  			[]labelCommand{
   123  				{action: "add", label: "foo"},
   124  				{action: "remove", label: "baz"},
   125  			},
   126  			[]string{"foo"},
   127  			[]string{"baz"},
   128  		},
   129  		{
   130  			"add/remove of same label",
   131  			[]labelCommand{
   132  				{action: "add", label: "foo"},
   133  				{action: "remove", label: "foo"},
   134  				{action: "remove", label: "bar"},
   135  				{action: "add", label: "bar"},
   136  			},
   137  			nil,
   138  			nil,
   139  		},
   140  		{
   141  			"deduplication of labels",
   142  			[]labelCommand{
   143  				{action: "add", label: "foo"},
   144  				{action: "add", label: "foo"},
   145  				{action: "remove", label: "bar"},
   146  				{action: "remove", label: "bar"},
   147  			},
   148  			[]string{"foo"},
   149  			[]string{"bar"},
   150  		},
   151  		{
   152  			"forbidden actions",
   153  			[]labelCommand{
   154  				{action: "add", label: "Proposal-Accepted"},
   155  				{action: "add", label: "CherryPickApproved"},
   156  				{action: "add", label: "cla: yes"},
   157  				{action: "remove", label: "Security"},
   158  			},
   159  			nil,
   160  			nil,
   161  		},
   162  		{
   163  			"can add Security",
   164  			[]labelCommand{
   165  				{action: "add", label: "Security"},
   166  			},
   167  			[]string{"Security"},
   168  			nil,
   169  		},
   170  	}
   171  	for _, tc := range testCases {
   172  		add, remove := mutationsFromCommands(tc.cmds)
   173  		if diff := cmp.Diff(add, tc.add); diff != "" {
   174  			t.Errorf("%s: label additions differ: (-got, +want)\n%s", tc.desc, diff)
   175  		}
   176  		if diff := cmp.Diff(remove, tc.remove); diff != "" {
   177  			t.Errorf("%s: label removals differ: (-got, +want)\n%s", tc.desc, diff)
   178  		}
   179  	}
   180  }
   181  
   182  type fakeIssuesService struct {
   183  	labels map[int][]string
   184  }
   185  
   186  func (f *fakeIssuesService) ListLabelsByIssue(ctx context.Context, owner string, repo string, number int, opt *github.ListOptions) ([]*github.Label, *github.Response, error) {
   187  	var labels []*github.Label
   188  	if ls, ok := f.labels[number]; ok {
   189  		for _, l := range ls {
   190  			name := l
   191  			labels = append(labels, &github.Label{Name: &name})
   192  		}
   193  	}
   194  	return labels, nil, nil
   195  }
   196  
   197  func (f *fakeIssuesService) AddLabelsToIssue(ctx context.Context, owner string, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) {
   198  	if f.labels == nil {
   199  		f.labels = map[int][]string{number: labels}
   200  		return nil, nil, nil
   201  	}
   202  	ls, ok := f.labels[number]
   203  	if !ok {
   204  		f.labels[number] = labels
   205  		return nil, nil, nil
   206  	}
   207  	for _, label := range labels {
   208  		var found bool
   209  		for _, l := range ls {
   210  			if l == label {
   211  				found = true
   212  			}
   213  		}
   214  		if found {
   215  			continue
   216  		}
   217  		f.labels[number] = append(f.labels[number], label)
   218  	}
   219  	return nil, nil, nil
   220  }
   221  
   222  func (f *fakeIssuesService) RemoveLabelForIssue(ctx context.Context, owner string, repo string, number int, label string) (*github.Response, error) {
   223  	if ls, ok := f.labels[number]; ok {
   224  		for i, l := range ls {
   225  			if l == label {
   226  				f.labels[number] = append(f.labels[number][:i], f.labels[number][i+1:]...)
   227  				return nil, nil
   228  			}
   229  		}
   230  	}
   231  	// The GitHub API returns a NotFound error if the label did not exist.
   232  	return nil, &github.ErrorResponse{
   233  		Response: &http.Response{
   234  			Status:     http.StatusText(http.StatusNotFound),
   235  			StatusCode: http.StatusNotFound,
   236  		},
   237  	}
   238  }
   239  
   240  func TestAddLabels(t *testing.T) {
   241  	testCases := []struct {
   242  		desc   string
   243  		gi     *maintner.GitHubIssue
   244  		labels []string
   245  		added  []string
   246  	}{
   247  		{
   248  			"basic add",
   249  			&maintner.GitHubIssue{},
   250  			[]string{"foo"},
   251  			[]string{"foo"},
   252  		},
   253  		{
   254  			"some labels already present in maintner",
   255  			&maintner.GitHubIssue{
   256  				Labels: map[int64]*maintner.GitHubLabel{
   257  					0: {Name: "NeedsDecision"},
   258  				},
   259  			},
   260  			[]string{"foo", "NeedsDecision"},
   261  			[]string{"foo"},
   262  		},
   263  		{
   264  			"all labels already present in maintner",
   265  			&maintner.GitHubIssue{
   266  				Labels: map[int64]*maintner.GitHubLabel{
   267  					0: {Name: "NeedsDecision"},
   268  				},
   269  			},
   270  			[]string{"NeedsDecision"},
   271  			nil,
   272  		},
   273  	}
   274  
   275  	b := &gopherbot{}
   276  	for _, tc := range testCases {
   277  		// Clear any previous state from fake addLabelsToIssue since some test cases may skip calls to it.
   278  		fis := &fakeIssuesService{}
   279  		b.is = fis
   280  
   281  		if err := b.addLabels(context.Background(), maintner.GitHubRepoID{
   282  			Owner: "golang",
   283  			Repo:  "go",
   284  		}, tc.gi, tc.labels); err != nil {
   285  			t.Errorf("%s: b.addLabels got unexpected error: %v", tc.desc, err)
   286  			continue
   287  		}
   288  		if diff := cmp.Diff(fis.labels[int(tc.gi.ID)], tc.added); diff != "" {
   289  			t.Errorf("%s: labels added differ: (-got, +want)\n%s", tc.desc, diff)
   290  		}
   291  	}
   292  }
   293  
   294  func TestRemoveLabels(t *testing.T) {
   295  	testCases := []struct {
   296  		desc     string
   297  		gi       *maintner.GitHubIssue
   298  		ghLabels []string
   299  		toRemove []string
   300  		want     []string
   301  	}{
   302  		{
   303  			"basic remove",
   304  			&maintner.GitHubIssue{
   305  				Number: 123,
   306  				Labels: map[int64]*maintner.GitHubLabel{
   307  					0: {Name: "NeedsFix"},
   308  					1: {Name: "help wanted"},
   309  				},
   310  			},
   311  			[]string{"NeedsFix", "help wanted"},
   312  			[]string{"NeedsFix"},
   313  			[]string{"help wanted"},
   314  		},
   315  		{
   316  			"label not present in maintner",
   317  			&maintner.GitHubIssue{},
   318  			[]string{"NeedsFix"},
   319  			[]string{"NeedsFix"},
   320  			[]string{"NeedsFix"},
   321  		},
   322  		{
   323  			"label not present in GitHub",
   324  			&maintner.GitHubIssue{
   325  				Labels: map[int64]*maintner.GitHubLabel{
   326  					0: {Name: "foo"},
   327  				},
   328  			},
   329  			[]string{"NeedsFix"},
   330  			[]string{"foo"},
   331  			[]string{"NeedsFix"},
   332  		},
   333  	}
   334  
   335  	b := &gopherbot{}
   336  	for _, tc := range testCases {
   337  		// Clear any previous state from fakeIssuesService since some test cases may skip calls to it.
   338  		fis := &fakeIssuesService{map[int][]string{
   339  			int(tc.gi.Number): tc.ghLabels,
   340  		}}
   341  		b.is = fis
   342  
   343  		if err := b.removeLabels(context.Background(), maintner.GitHubRepoID{
   344  			Owner: "golang",
   345  			Repo:  "go",
   346  		}, tc.gi, tc.toRemove); err != nil {
   347  			t.Errorf("%s: b.addLabels got unexpected error: %v", tc.desc, err)
   348  			continue
   349  		}
   350  		if diff := cmp.Diff(fis.labels[int(tc.gi.Number)], tc.want); diff != "" {
   351  			t.Errorf("%s: labels differ: (-got, +want)\n%s", tc.desc, diff)
   352  		}
   353  	}
   354  }
   355  
   356  func TestReviewersInMetas(t *testing.T) {
   357  	testCases := []struct {
   358  		desc      string
   359  		commitMsg string
   360  		wantIDs   []string
   361  	}{
   362  		{
   363  			desc: "one human reviewer",
   364  			commitMsg: `Patch-set: 6
   365  Reviewer: Andrew Bonventre <22285@62eb7196-b449-3ce5-99f1-c037f21e1705>
   366  `,
   367  			wantIDs: []string{"22285"},
   368  		},
   369  		{
   370  			desc: "one human CC",
   371  			commitMsg: `Patch-set: 6
   372  CC: Andrew Bonventre <22285@62eb7196-b449-3ce5-99f1-c037f21e1705>
   373  `,
   374  			wantIDs: []string{"22285"},
   375  		},
   376  		{
   377  			desc: "gobot reviewer",
   378  			commitMsg: `Patch-set: 6
   379  Reviewer: Gobot Gobot <5976@62eb7196-b449-3ce5-99f1-c037f21e1705>
   380  `,
   381  			wantIDs: []string{"5976"},
   382  		},
   383  		{
   384  			desc: "gobot reviewer and human CC",
   385  			commitMsg: `Patch-set: 6
   386  Reviewer: Gobot Gobot <5976@62eb7196-b449-3ce5-99f1-c037f21e1705>
   387  CC: Andrew Bonventre <22285@62eb7196-b449-3ce5-99f1-c037f21e1705>
   388  `,
   389  			wantIDs: []string{"5976", "22285"},
   390  		},
   391  		{
   392  			desc: "gobot reviewer and human reviewer",
   393  			commitMsg: `Patch-set: 6
   394  Reviewer: Gobot Gobot <5976@62eb7196-b449-3ce5-99f1-c037f21e1705>
   395  Reviewer: Andrew Bonventre <22285@62eb7196-b449-3ce5-99f1-c037f21e1705>
   396  `,
   397  			wantIDs: []string{"5976", "22285"},
   398  		},
   399  		{
   400  			desc: "gobot reviewer and two human reviewers",
   401  			commitMsg: `Patch-set: 6
   402  Reviewer: Gobot Gobot <5976@62eb7196-b449-3ce5-99f1-c037f21e1705>
   403  Reviewer: Andrew Bonventre <22285@62eb7196-b449-3ce5-99f1-c037f21e1705>
   404  Reviewer: Rebecca Stambler <16140@62eb7196-b449-3ce5-99f1-c037f21e1705>
   405  				`,
   406  			wantIDs: []string{"5976", "22285", "16140"},
   407  		},
   408  		{
   409  			desc: "reviewersInMetas should not return duplicate IDs", // Happened in go.dev/cl/534975.
   410  			commitMsg: `Reviewer: Gerrit User 5190 <5190@62eb7196-b449-3ce5-99f1-c037f21e1705>
   411  CC: Gerrit User 60063 <60063@62eb7196-b449-3ce5-99f1-c037f21e1705>
   412  Reviewer: Gerrit User 60063 <60063@62eb7196-b449-3ce5-99f1-c037f21e1705>`,
   413  			wantIDs: []string{"5190", "60063"},
   414  		},
   415  	}
   416  
   417  	cmpFn := func(a, b string) bool {
   418  		return a < b
   419  	}
   420  	for _, tc := range testCases {
   421  		t.Run(tc.desc, func(t *testing.T) {
   422  			metas := []*maintner.GerritMeta{
   423  				{Commit: &maintner.GitCommit{Msg: tc.commitMsg}},
   424  			}
   425  			ids := reviewersInMetas(metas)
   426  			if diff := cmp.Diff(tc.wantIDs, ids, cmpopts.SortSlices(cmpFn)); diff != "" {
   427  				t.Fatalf("reviewersInMetas() mismatch (-want +got):\n%s", diff)
   428  			}
   429  		})
   430  	}
   431  }
   432  
   433  func TestMergeOwnersEntries(t *testing.T) {
   434  	var (
   435  		andybons = owners.Owner{GitHubUsername: "andybons", GerritEmail: "andybons@golang.org"}
   436  		bradfitz = owners.Owner{GitHubUsername: "bradfitz", GerritEmail: "bradfitz@golang.org"}
   437  		filippo  = owners.Owner{GitHubUsername: "filippo", GerritEmail: "filippo@golang.org"}
   438  		iant     = owners.Owner{GitHubUsername: "iant", GerritEmail: "iant@golang.org"}
   439  		rsc      = owners.Owner{GitHubUsername: "rsc", GerritEmail: "rsc@golang.org"}
   440  	)
   441  	testCases := []struct {
   442  		desc        string
   443  		entries     []*owners.Entry
   444  		authorEmail string
   445  		result      *owners.Entry
   446  	}{
   447  		{
   448  			"no entries",
   449  			nil,
   450  			"",
   451  			&owners.Entry{},
   452  		},
   453  		{
   454  			"primary merge",
   455  			[]*owners.Entry{
   456  				{Primary: []owners.Owner{andybons}},
   457  				{Primary: []owners.Owner{bradfitz}},
   458  			},
   459  			"",
   460  			&owners.Entry{
   461  				Primary: []owners.Owner{andybons, bradfitz},
   462  			},
   463  		},
   464  		{
   465  			"secondary merge",
   466  			[]*owners.Entry{
   467  				{Secondary: []owners.Owner{andybons}},
   468  				{Secondary: []owners.Owner{filippo}},
   469  			},
   470  			"",
   471  			&owners.Entry{
   472  				Secondary: []owners.Owner{andybons, filippo},
   473  			},
   474  		},
   475  		{
   476  			"promote from secondary to primary",
   477  			[]*owners.Entry{
   478  				{Primary: []owners.Owner{andybons, filippo}},
   479  				{Secondary: []owners.Owner{filippo}},
   480  			},
   481  			"",
   482  			&owners.Entry{
   483  				Primary: []owners.Owner{andybons, filippo},
   484  			},
   485  		},
   486  		{
   487  			"primary filter",
   488  			[]*owners.Entry{
   489  				{Primary: []owners.Owner{filippo, andybons}},
   490  			},
   491  			filippo.GerritEmail,
   492  			&owners.Entry{
   493  				Primary: []owners.Owner{andybons},
   494  			},
   495  		},
   496  		{
   497  			"secondary filter",
   498  			[]*owners.Entry{
   499  				{Secondary: []owners.Owner{filippo, andybons}},
   500  			},
   501  			filippo.GerritEmail,
   502  			&owners.Entry{
   503  				Secondary: []owners.Owner{andybons},
   504  			},
   505  		},
   506  		{
   507  			"too many reviewers",
   508  			[]*owners.Entry{
   509  				{Primary: []owners.Owner{iant, bradfitz}, Secondary: []owners.Owner{andybons}},
   510  				{Primary: []owners.Owner{andybons}, Secondary: []owners.Owner{iant, bradfitz}},
   511  				{Primary: []owners.Owner{iant, filippo}, Secondary: []owners.Owner{bradfitz, andybons, rsc}},
   512  			},
   513  			"",
   514  			&owners.Entry{
   515  				Primary: []owners.Owner{andybons, bradfitz, iant},
   516  			},
   517  		},
   518  	}
   519  	cmpFn := func(a, b owners.Owner) bool {
   520  		return a.GitHubUsername < b.GitHubUsername
   521  	}
   522  	for _, tc := range testCases {
   523  		got := mergeOwnersEntries(tc.entries, tc.authorEmail)
   524  		if diff := cmp.Diff(got, tc.result, cmpopts.SortSlices(cmpFn)); diff != "" {
   525  			t.Errorf("%s: final entry results differ: (-got, +want)\n%s", tc.desc, diff)
   526  		}
   527  	}
   528  }
   529  
   530  func TestFilterGerritOwners(t *testing.T) {
   531  	var (
   532  		andybons  = owners.Owner{GitHubUsername: "andybons", GerritEmail: "andybons@golang.org"}
   533  		bradfitz  = owners.Owner{GitHubUsername: "bradfitz", GerritEmail: "bradfitz@golang.org"}
   534  		toolsTeam = owners.Owner{GitHubUsername: "golang/tools-team"}
   535  	)
   536  	testCases := []struct {
   537  		name    string
   538  		entries []*owners.Entry
   539  		want    []*owners.Entry
   540  	}{
   541  		{
   542  			name:    "no entries",
   543  			entries: nil,
   544  			want:    []*owners.Entry{},
   545  		},
   546  		{
   547  			name: "all valid",
   548  			entries: []*owners.Entry{
   549  				{Primary: []owners.Owner{andybons}},
   550  				{Primary: []owners.Owner{bradfitz}},
   551  			},
   552  			want: []*owners.Entry{
   553  				{Primary: []owners.Owner{andybons}},
   554  				{Primary: []owners.Owner{bradfitz}},
   555  			},
   556  		},
   557  		{
   558  			name: "drop primary",
   559  			entries: []*owners.Entry{
   560  				{Primary: []owners.Owner{andybons, toolsTeam}},
   561  				{Primary: []owners.Owner{toolsTeam, bradfitz}},
   562  			},
   563  			want: []*owners.Entry{
   564  				{Primary: []owners.Owner{andybons}},
   565  				{Primary: []owners.Owner{bradfitz}},
   566  			},
   567  		},
   568  		{
   569  			name: "drop secondary",
   570  			entries: []*owners.Entry{
   571  				{
   572  					Primary:   []owners.Owner{andybons},
   573  					Secondary: []owners.Owner{bradfitz, toolsTeam},
   574  				},
   575  				{
   576  					Primary:   []owners.Owner{bradfitz},
   577  					Secondary: []owners.Owner{toolsTeam, andybons},
   578  				},
   579  			},
   580  			want: []*owners.Entry{
   581  				{
   582  					Primary:   []owners.Owner{andybons},
   583  					Secondary: []owners.Owner{bradfitz},
   584  				},
   585  				{
   586  					Primary:   []owners.Owner{bradfitz},
   587  					Secondary: []owners.Owner{andybons},
   588  				},
   589  			},
   590  		},
   591  		{
   592  			name: "upgrade secondary",
   593  			entries: []*owners.Entry{
   594  				{
   595  					Primary:   []owners.Owner{toolsTeam},
   596  					Secondary: []owners.Owner{bradfitz},
   597  				},
   598  			},
   599  			want: []*owners.Entry{
   600  				{
   601  					Primary: []owners.Owner{bradfitz},
   602  				},
   603  			},
   604  		},
   605  		{
   606  			name: "no primary",
   607  			entries: []*owners.Entry{
   608  				{
   609  					Secondary: []owners.Owner{bradfitz},
   610  				},
   611  			},
   612  			want: []*owners.Entry{
   613  				{
   614  					Primary: []owners.Owner{bradfitz},
   615  				},
   616  			},
   617  		},
   618  	}
   619  	cmpFn := func(a, b owners.Owner) bool {
   620  		return a.GitHubUsername < b.GitHubUsername
   621  	}
   622  	for _, tc := range testCases {
   623  		t.Run(tc.name, func(t *testing.T) {
   624  			got := filterGerritOwners(tc.entries)
   625  			if diff := cmp.Diff(got, tc.want, cmpopts.SortSlices(cmpFn)); diff != "" {
   626  				t.Errorf("final entry results differ: (-got, +want)\n%s", diff)
   627  			}
   628  		})
   629  	}
   630  }
   631  
   632  func TestForeachIssue(t *testing.T) {
   633  	if testing.Short() || flag.Lookup("test.run").Value.String() != "^TestForeachIssue$" {
   634  		t.Skip("not running test requiring large Go corpus download in short mode and if not explicitly requested with go test -run=^TestForeachIssue$")
   635  	}
   636  
   637  	b := &gopherbot{}
   638  	b.initCorpus()
   639  
   640  	var num int
   641  	err := b.foreachIssue(b.gorepo, open, func(gi *maintner.GitHubIssue) error {
   642  		if gi.Closed || gi.PullRequest || gi.NotExist {
   643  			t.Errorf("issue %d should be skipped but isn't: %#v", gi.Number, gi)
   644  		}
   645  		num++
   646  		return nil
   647  	})
   648  	if err != nil {
   649  		t.Errorf("gopherbot.foreachIssue: got %v error, want nil", err)
   650  	}
   651  	t.Logf("gopherbot.foreachIssue walked over %d open issues (not including PRs and deleted/transferred/converted issues)", num)
   652  
   653  	var got struct {
   654  		Open, Closed, PR bool
   655  	}
   656  	err = b.foreachIssue(b.gorepo, open|closed|includePRs, func(gi *maintner.GitHubIssue) error {
   657  		if gi.NotExist {
   658  			t.Errorf("issue %d should be skipped but isn't: %#v", gi.Number, gi)
   659  		}
   660  		got.Open = got.Open || !gi.Closed
   661  		got.Closed = got.Closed || gi.Closed
   662  		got.PR = got.PR || gi.PullRequest
   663  		return nil
   664  	})
   665  	if err != nil {
   666  		t.Errorf("gopherbot.foreachIssue: got %v error, want nil", err)
   667  	}
   668  	if !got.Open || !got.Closed || !got.PR {
   669  		t.Errorf("got %+v, want all true", got)
   670  	}
   671  }