diff --git a/.golangci.yml b/.golangci.yml index a2fea56937c..e3537546387 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -228,6 +228,7 @@ linters: - HookResponse.RawPayload - Issue.PullRequestLinks # TODO: PullRequest - IssueImportRequest.IssueImport # TODO: Issue + - IssueListByRepoOptions.ExcludeLabels # url:"-" skips query param; no matching tag - IssuesSearchResult.Issues # TODO: Items - IssuesSearchResult.Total - LabelsSearchResult.Labels # TODO: Items diff --git a/github/github-accessors.go b/github/github-accessors.go index bc3194bc1b6..29c68fc8518 100644 --- a/github/github-accessors.go +++ b/github/github-accessors.go @@ -20078,6 +20078,14 @@ func (i *IssueListByRepoOptions) GetDirection() string { return i.Direction } +// GetExcludeLabels returns the ExcludeLabels slice if it's non-nil, nil otherwise. +func (i *IssueListByRepoOptions) GetExcludeLabels() []string { + if i == nil || i.ExcludeLabels == nil { + return nil + } + return i.ExcludeLabels +} + // GetLabels returns the Labels slice if it's non-nil, nil otherwise. func (i *IssueListByRepoOptions) GetLabels() []string { if i == nil || i.Labels == nil { diff --git a/github/github-accessors_test.go b/github/github-accessors_test.go index c9ced173fe0..b944266f2b7 100644 --- a/github/github-accessors_test.go +++ b/github/github-accessors_test.go @@ -25392,6 +25392,17 @@ func TestIssueListByRepoOptions_GetDirection(tt *testing.T) { i.GetDirection() } +func TestIssueListByRepoOptions_GetExcludeLabels(tt *testing.T) { + tt.Parallel() + zeroValue := []string{} + i := &IssueListByRepoOptions{ExcludeLabels: zeroValue} + i.GetExcludeLabels() + i = &IssueListByRepoOptions{} + i.GetExcludeLabels() + i = nil + i.GetExcludeLabels() +} + func TestIssueListByRepoOptions_GetLabels(tt *testing.T) { tt.Parallel() zeroValue := []string{} diff --git a/github/issues.go b/github/issues.go index bea89912b5a..28fe4c01e84 100644 --- a/github/issues.go +++ b/github/issues.go @@ -8,6 +8,7 @@ package github import ( "context" "fmt" + "strings" "time" ) @@ -364,6 +365,11 @@ type IssueListByRepoOptions struct { // Labels filters issues based on their label. Labels []string `url:"labels,omitempty,comma"` + // ExcludeLabels filters issues to exclude those with the specified labels. + // Filtering is done client-side after fetching, since GitHub's Issues REST API + // does not support server-side label exclusion. + ExcludeLabels []string `url:"-"` + // Sort specifies how to sort issues. Possible values are: created, updated, // and comments. Default value is "created". Sort string `url:"sort,omitempty"` @@ -408,6 +414,30 @@ func (s *IssuesService) ListByRepo(ctx context.Context, owner, repo string, opts return nil, resp, err } + // Filter out issues with excluded labels client-side. + // The GitHub Issues REST API does not support server-side label exclusion, + // so we apply the filter in-memory after fetching results. + if len(opts.ExcludeLabels) > 0 { + exclude := make(map[string]bool, len(opts.ExcludeLabels)) + for _, l := range opts.ExcludeLabels { + exclude[strings.ToLower(l)] = true + } + filtered := make([]*Issue, 0, len(issues)) + for _, issue := range issues { + shouldExclude := false + for _, label := range issue.Labels { + if exclude[strings.ToLower(label.GetName())] { + shouldExclude = true + break + } + } + if !shouldExclude { + filtered = append(filtered, issue) + } + } + issues = filtered + } + return issues, resp, nil } diff --git a/github/issues_test.go b/github/issues_test.go index e4da6c72c49..fe386db42ec 100644 --- a/github/issues_test.go +++ b/github/issues_test.go @@ -192,8 +192,8 @@ func TestIssuesService_ListByRepo(t *testing.T) { "assignee": "a", "creator": "c", "mentioned": "m", - "labels": "a,b", - "sort": "updated", + "labels": "a,b", + "sort": "updated", "direction": "asc", "since": referenceTime.Format(time.RFC3339), "per_page": "1", @@ -209,6 +209,7 @@ func TestIssuesService_ListByRepo(t *testing.T) { Creator: "c", Mentioned: "m", Labels: []string{"a", "b"}, + ExcludeLabels: []string{"c", "d"}, Sort: "updated", Direction: "asc", Since: referenceTime, @@ -241,6 +242,47 @@ func TestIssuesService_ListByRepo(t *testing.T) { }) } +func TestIssuesService_ListByRepo_ExcludeLabels(t *testing.T) { + t.Parallel() + client, mux, _ := setup(t) + + mux.HandleFunc("/repos/o/r/issues", func(w http.ResponseWriter, r *http.Request) { + testMethod(t, r, "GET") + testHeader(t, r, "Accept", mediaTypeReactionsPreview) + // ExcludeLabels should NOT be sent as a query parameter. + testFormValues(t, r, values{ + "labels": "bug", + }) + // Server returns all bug-labeled issues; client-side filtering handles exclusion. + fmt.Fprint(w, `[ + {"number":1, "labels":[{"name":"bug"}]}, + {"number":2, "labels":[{"name":"bug"},{"name":"wontfix"}]}, + {"number":3, "labels":[{"name":"bug"},{"name":"enhancement"}]}, + {"number":4, "labels":[{"name":"bug"},{"name":"wontfix"},{"name":"duplicate"}]} + ]`) + }) + + opt := &IssueListByRepoOptions{ + Labels: []string{"bug"}, + ExcludeLabels: []string{"wontfix", "duplicate"}, + } + + ctx := t.Context() + issues, _, err := client.Issues.ListByRepo(ctx, "o", "r", opt) + if err != nil { + t.Errorf("Issues.ListByRepo returned error: %v", err) + } + + // Issues #1 and #3 should remain; #2 and #4 should be excluded by client-side filter. + want := []*Issue{ + {Number: Ptr(1), Labels: []*Label{{Name: Ptr("bug")}}}, + {Number: Ptr(3), Labels: []*Label{{Name: Ptr("bug")}, {Name: Ptr("enhancement")}}}, + } + if !cmp.Equal(issues, want) { + t.Errorf("Issues.ListByRepo returned %+v, want %+v", issues, want) + } +} + func TestIssuesService_Get(t *testing.T) { t.Parallel() client, mux, _ := setup(t)