Skip to content

Commit 30aaa3f

Browse files
authored
feat: allow user to choose title and/or body for evaluation (#14)
* feat: allow user to choose title and/or description for evaluation * feat: description as alternate for body
1 parent 22ba78c commit 30aaa3f

File tree

8 files changed

+197
-8
lines changed

8 files changed

+197
-8
lines changed

cmd/labeler/root.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ func newRootCmd() *cobra.Command {
2929
ID int
3030
Data string
3131
ConfigPath string
32+
Fields []string
3233
}
3334
o := options{
3435
Owner: os.Getenv("GITHUB_ACTOR"),
@@ -53,6 +54,11 @@ func newRootCmd() *cobra.Command {
5354
if o.ConfigPath != "" {
5455
labelOpts = append(labelOpts, labeler.WithConfigPath(o.ConfigPath))
5556
}
57+
if len(o.Fields) > 0 {
58+
fieldFlags := labeler.ParseFieldFlags(o.Fields)
59+
labelOpts = append(labelOpts, labeler.WithFields(fieldFlags))
60+
}
61+
5662
l, err := labeler.NewWithOptions(labelOpts...)
5763
if err != nil {
5864
return fmt.Errorf("could not initialize labeler: %w", err)
@@ -68,6 +74,7 @@ func newRootCmd() *cobra.Command {
6874
c.Flags().StringVarP(&o.Owner, "owner", "o", o.Owner, "GitHub Owner/Org name [GITHUB_ACTOR]")
6975
c.Flags().StringVarP(&o.Repo, "repo", "r", o.Repo, "GitHub Repo name [GITHUB_REPO]")
7076
c.Flags().StringVarP(&o.Type, "type", "t", o.Type, "The target event type to label (issues or pull_request) [GITHUB_EVENT_NAME]")
77+
c.Flags().StringSliceVar(&o.Fields, "fields", []string{"title", "body"}, "Fields to evaluate for labeling (title, body)")
7178
c.Flags().IntVarP(&o.ID, "id", "", o.ID, "The integer id of the issue or pull request")
7279
c.Flags().StringVarP(&o.Data, "data", "", o.Data, "A JSON string of the 'event' type (issue event or pull request event)")
7380
c.Flags().StringVarP(&o.ConfigPath, "config-path", "", o.ConfigPath, "A custom config path, relative to the repository root")

construct.go

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Opt struct {
2424
id int
2525
data string
2626
configPath string
27+
fieldFlags FieldFlag
2728
}
2829

2930
type OptFn func(o *Opt)
@@ -95,15 +96,23 @@ func WithConfigPath(value string) OptFn {
9596
}
9697
}
9798

99+
// WithFields allows for configuring the fields to evaluate for labeling
100+
func WithFields(fieldFlag FieldFlag) OptFn {
101+
return func(o *Opt) {
102+
o.fieldFlags = fieldFlag.OrDefault()
103+
}
104+
}
105+
98106
// NewWithOptions constructs a new Labeler with functional arguments of type OptFn
99107
func NewWithOptions(opts ...OptFn) (*Labeler, error) {
100108
l := Labeler{}
101109
options := Opt{
102-
token: os.Getenv("GITHUB_TOKEN"),
103-
owner: os.Getenv("GITHUB_ACTOR"),
104-
repo: os.Getenv("GITHUB_REPO"),
105-
event: os.Getenv("GITHUB_EVENT_NAME"),
106-
id: -1,
110+
token: os.Getenv("GITHUB_TOKEN"),
111+
owner: os.Getenv("GITHUB_ACTOR"),
112+
repo: os.Getenv("GITHUB_REPO"),
113+
event: os.Getenv("GITHUB_EVENT_NAME"),
114+
id: -1,
115+
fieldFlags: AllFieldFlags,
107116
}
108117

109118
for _, opt := range opts {
@@ -157,6 +166,7 @@ func NewWithOptions(opts ...OptFn) (*Labeler, error) {
157166
l.Repo = &options.repo
158167
l.Event = &options.event
159168
l.ID = &options.id
169+
l.fieldFlag = options.fieldFlags
160170
if options.data != "" {
161171
l.Data = &options.data
162172
}

construct_test.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ func TestNew(t *testing.T) {
6868
assert.Equal(t, tt.args.repo, *got.Repo)
6969
assert.Equal(t, tt.args.event, *got.Event)
7070
assert.Equal(t, tt.args.id, *got.ID)
71+
assert.Equal(t, AllFieldFlags, got.fieldFlag)
7172
assert.Equal(t, ".github/labeler.yml", got.configPath)
7273
}
7374
})
@@ -141,6 +142,7 @@ func TestNewWithOptions(t *testing.T) {
141142
assert.Equal(t, ptr("jimschubert"), l.Owner)
142143
assert.Equal(t, ptr("example"), l.Repo)
143144
assert.Equal(t, ptr(1000), l.ID)
145+
assert.Equal(t, AllFieldFlags, l.fieldFlag)
144146

145147
assert.NotNil(t, l.context, "Should have created a default context")
146148
assert.NotNil(t, l.client, "Should have created a default github client")
@@ -155,14 +157,15 @@ func TestNewWithOptions(t *testing.T) {
155157
WithOwner("jimschubert"), WithRepo("example"), WithID(1000),
156158

157159
// optional fields
158-
WithContext(childContext), WithConfigPath(".github/labeler-custom.yml"), WithData("{}"), WithToken("irrelevant"),
160+
WithContext(childContext), WithConfigPath(".github/labeler-custom.yml"), WithData("{}"), WithToken("irrelevant"), WithFields(AllFieldFlags),
159161
},
160162
},
161163
validate: func(l *Labeler) {
162164
assert.Equal(t, ptr("jimschubert"), l.Owner)
163165
assert.Equal(t, ptr("example"), l.Repo)
164166
assert.Equal(t, ptr("{}"), l.Data)
165167
assert.Equal(t, ptr(1000), l.ID)
168+
assert.Equal(t, AllFieldFlags, l.fieldFlag)
166169

167170
assert.NotNil(t, l.context, "Should have created a default context")
168171
assert.NotNil(t, l.client, "Should have created a default github client")

doc.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Package labeler provides a utility for labeling GitHub issues and pull requests based on configurable rules and field evaluation.
2+
package labeler

fields.go

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package labeler
2+
3+
// FieldFlag represents a bitmask for fields that can be evaluated for labeling.
4+
// Use bitwise operations or helper methods to combine and check flags.
5+
type FieldFlag uint
6+
7+
// Has returns true if the FieldFlag contains the provided flag.
8+
func (f FieldFlag) Has(flag FieldFlag) bool {
9+
return f&flag != 0
10+
}
11+
12+
// OrDefault returns AllFieldFlags if no flags are set (f == 0), otherwise returns f.
13+
func (f FieldFlag) OrDefault() FieldFlag {
14+
if f == 0 {
15+
return AllFieldFlags
16+
}
17+
return f
18+
}
19+
20+
const (
21+
// FieldTitle indicates the title field should be evaluated for labeling.
22+
FieldTitle FieldFlag = 1 << iota
23+
// FieldBody indicates the body field should be evaluated for labeling.
24+
FieldBody
25+
26+
// AllFieldFlags is a convenience constant representing all available fields.
27+
AllFieldFlags = FieldTitle | FieldBody
28+
)
29+
30+
// ParseFieldFlags converts a slice of string field names to a FieldFlag bitmask.
31+
// Unrecognized field names are ignored.
32+
func ParseFieldFlags(fields []string) FieldFlag {
33+
var flags FieldFlag
34+
for _, f := range fields {
35+
switch f {
36+
case "title":
37+
flags |= FieldTitle
38+
case "body", "description":
39+
flags |= FieldBody
40+
}
41+
}
42+
return flags
43+
}

fields_test.go

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
package labeler
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestFieldFlag_Has(t *testing.T) {
8+
tests := []struct {
9+
name string
10+
flag FieldFlag
11+
check FieldFlag
12+
expected bool
13+
}{
14+
{"Has title", FieldTitle, FieldTitle, true},
15+
{"Has body", FieldBody, FieldBody, true},
16+
{"Has both", AllFieldFlags, FieldTitle, true},
17+
{"Has both (body)", AllFieldFlags, FieldBody, true},
18+
{"Does not have", FieldTitle, FieldBody, false},
19+
{"Zero flag", 0, FieldTitle, false},
20+
}
21+
22+
for _, tt := range tests {
23+
t.Run(tt.name, func(t *testing.T) {
24+
got := tt.flag.Has(tt.check)
25+
if got != tt.expected {
26+
t.Errorf("FieldFlag(%d).Has(%d) = %v; want %v", tt.flag, tt.check, got, tt.expected)
27+
}
28+
})
29+
}
30+
}
31+
32+
func TestFieldFlag_OrDefault(t *testing.T) {
33+
tests := []struct {
34+
name string
35+
flag FieldFlag
36+
expected FieldFlag
37+
}{
38+
{"Non-zero returns self", FieldTitle, FieldTitle},
39+
{"AllFieldFlags returns self", AllFieldFlags, AllFieldFlags},
40+
{"Zero returns AllFieldFlags", 0, AllFieldFlags},
41+
}
42+
43+
for _, tt := range tests {
44+
t.Run(tt.name, func(t *testing.T) {
45+
got := tt.flag.OrDefault()
46+
if got != tt.expected {
47+
t.Errorf("FieldFlag(%d).OrDefault() = %d; want %d", tt.flag, got, tt.expected)
48+
}
49+
})
50+
}
51+
}
52+
53+
func TestParseFieldFlags(t *testing.T) {
54+
tests := []struct {
55+
name string
56+
input []string
57+
expected FieldFlag
58+
}{
59+
{"Empty slice", []string{}, 0},
60+
{"Single title", []string{"title"}, FieldTitle},
61+
{"Single body", []string{"body"}, FieldBody},
62+
{"Single description (alternate)", []string{"description"}, FieldBody},
63+
{"Both fields", []string{"title", "body"}, AllFieldFlags},
64+
{"Duplicate fields", []string{"title", "title"}, FieldTitle},
65+
{"Unknown field", []string{"unknown"}, 0},
66+
{"Mixed known and unknown", []string{"title", "unknown"}, FieldTitle},
67+
}
68+
69+
for _, tt := range tests {
70+
t.Run(tt.name, func(t *testing.T) {
71+
got := ParseFieldFlags(tt.input)
72+
if got != tt.expected {
73+
t.Errorf("ParseFieldFlags(%v) = %d; want %d", tt.input, got, tt.expected)
74+
}
75+
})
76+
}
77+
}
78+
79+
func TestAllFieldFlagsValue(t *testing.T) {
80+
expected := FieldTitle | FieldBody
81+
// Ensure AllFieldFlags is set to the correct value of OR'd flags.
82+
if AllFieldFlags != expected {
83+
t.Errorf("AllFieldFlags = %d; want %d", AllFieldFlags, expected)
84+
}
85+
}

labeler.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ type Labeler struct {
3737
client model.Client
3838
config model.Config
3939
configPath string
40+
fieldFlag FieldFlag
4041
}
4142

4243
// Execute performs the labeler logic
@@ -201,7 +202,18 @@ func (l *Labeler) applyLabels(i githubEvent, existingLabels []*github.Label) int
201202
targetBranch = *pr.Base.Ref
202203
}
203204

204-
labels := l.config.LabelsFor(i.GetTitle(), i.GetBody())
205+
flags := l.fieldFlag.OrDefault()
206+
fields := make([]string, 0)
207+
208+
if flags.Has(FieldTitle) {
209+
fields = append(fields, i.GetTitle())
210+
}
211+
212+
if flags.Has(FieldBody) {
213+
fields = append(fields, i.GetBody())
214+
}
215+
216+
labels := l.config.LabelsFor(fields...)
205217
filteredLabels := make(map[string]model.Label)
206218
for name, label := range labels {
207219
if len(label.Branches) > 0 && targetBranch != "" {
@@ -229,7 +241,7 @@ func (l *Labeler) applyLabels(i githubEvent, existingLabels []*github.Label) int
229241
defer cancel()
230242
added, _, err := l.client.AddLabelsToIssue(ctx, *l.Owner, *l.Repo, *l.ID, newLabels)
231243
if err != nil {
232-
log.WithFields(log.Fields{"err": err}).Debug("Unable to add labels to issue.")
244+
log.WithFields(log.Fields{"err": err}).Error("Unable to add labels to issue.")
233245
return 0
234246
}
235247
log.Debugf("Found %d new labels to apply", len(added))

labeler_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -155,6 +155,33 @@ func TestLabeler_applyLabels(t *testing.T) {
155155
mockClient.AssertExpectations(t)
156156
}
157157

158+
func TestLabeler_applyLabelsCustomizedFields(t *testing.T) {
159+
ctx := context.Background()
160+
mockClient := new(mockRichClient)
161+
162+
mockCfg := new(mockConfig)
163+
l := &Labeler{
164+
Owner: ptr("owner"),
165+
Repo: ptr("repo"),
166+
ID: ptr(1),
167+
fieldFlag: FieldTitle,
168+
context: &ctx,
169+
client: mockClient,
170+
config: mockCfg,
171+
}
172+
mockCfg.On("LabelsFor", "title").Return(map[string]model.Label{
173+
"bug": {},
174+
})
175+
mockClient.On("AddLabelsToIssue", mock.Anything, "owner", "repo", 1, []string{"bug"}).
176+
Return([]*github.Label{{Name: ptr("bug")}}, nil, nil)
177+
ev := &testEvent{title: "title", body: "body"}
178+
count := l.applyLabels(ev, []*github.Label{})
179+
assert.Equal(t, 1, count)
180+
181+
mockClient.AssertNumberOfCalls(t, "AddLabelsToIssue", 1)
182+
mockClient.AssertExpectations(t)
183+
}
184+
158185
func TestLabeler_addComment(t *testing.T) {
159186
ctx := context.Background()
160187
mockClient := new(mockRichClient)

0 commit comments

Comments
 (0)