Skip to content

Commit 2e2900e

Browse files
committed
refactor labeler to allow for testing and... test
Signed-off-by: Jeffrey Sica <[email protected]>
1 parent 1a35010 commit 2e2900e

File tree

9 files changed

+2005
-585
lines changed

9 files changed

+2005
-585
lines changed

utilities/labeler/README.md

Lines changed: 89 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,102 @@
1-
## Label Config / General Notes
1+
# GitHub Labeler
22

3-
When a label configuration is supplied, they should be the only labels that exist unless we set auto-delete to FALSE
3+
A Go program that automatically labels GitHub issues and pull requests based on configurable rules defined in a `labels.yaml` file.
44

5-
When the label configuration is updated (name, color, description etc), it should update the label and all items the label was previously tagged with.
5+
## Rule Types Supported
66

7-
When a label that isn't defined is attempted to be applied, it should not create the label and prompt the user UNLESS we set auto-create to TRUE
7+
### 1. Match Rules (`kind: match`)
8+
Process slash commands in comments:
9+
```yaml
10+
- name: apply-triage
11+
kind: match
12+
spec:
13+
command: "/triage"
14+
matchList: ["valid", "duplicate", "needs-information", "not-planned"]
15+
actions:
16+
- kind: remove-label
17+
spec:
18+
match: needs-triage
19+
- kind: apply-label
20+
spec:
21+
label: "triage/{{ argv.0 }}"
22+
```
823
9-
When there are multiple matching commands, it should it process all of them.
24+
### 2. Label Rules (`kind: label`)
25+
Apply labels based on existing label presence:
26+
```yaml
27+
- name: needs-triage
28+
kind: label
29+
spec:
30+
match: "triage/*"
31+
matchCondition: NOT
32+
actions:
33+
- kind: apply-label
34+
spec:
35+
label: "needs-triage"
36+
```
1037

11-
When the labeler executes, it should only attempt to modify the label state IF the end state is different from the current state e.g. it should not remove and re-add a label if the end condition is the same.
38+
### 3. File Path Rules (`kind: filePath`)
39+
Apply labels based on changed file paths:
40+
```yaml
41+
- name: charter
42+
kind: filePath
43+
spec:
44+
matchPath: "tags/*/charter.md"
45+
actions:
46+
- kind: apply-label
47+
spec:
48+
label: toc
49+
```
1250

13-
A label should be able to be removed by some method e.g. /remove-<foo> <bar> would remove the label foo/bar or /foo -bar.
14-
No preference, just a method of removing a label needs to exist
15-
16-
## kind/match
17-
18-
When the matchList rule is used, it should ONLY execute the actions if the text supplied by the user matches one of the items in the list
51+
## Action Types
1952

20-
When the unique rule is used, only ONE of the defined labels should be present
53+
### Apply Label
54+
```yaml
55+
- kind: apply-label
56+
spec:
57+
label: "label-name"
58+
```
2159

22-
- This can be renamed / adjusted - essentially need to restrict a set of labels to a 'namespace' and only one can be present in the final state. Maybe this should be processed as soemthing different? definine an end state condition vs matching whats there initially?
60+
### Remove Label
61+
```yaml
62+
- kind: remove-label
63+
spec:
64+
match: "label-pattern" # Supports wildcards like "triage/*"
65+
```
2366

67+
## Testing
2468

25-
## kind/label
69+
Run tests:
70+
```bash
71+
go test -v
72+
```
2673

27-
When the kind/label rule is used, it should ignore issue/PR bodies and check label state only for taking action.
74+
## Usage
2875

29-
## kind/filePath
30-
31-
Only applies to PRs
32-
33-
When a commit changes anything that matches the filepath, the rules defined should execute
34-
35-
36-
## rules / actions
37-
38-
When the remove-label action is present, it should remove the matching label if present
76+
### CLI
77+
```bash
78+
./labeler <labels_url> <owner> <repo> <issue_number> <comment_body> <changed_files>
79+
```
3980

40-
When the apply-label action is used, it should ONLY apply a label if the label exists.
81+
### GitHub Actions Workflow
82+
The included workflow automatically runs the labeler on issue comments.
83+
84+
## Configuration
85+
86+
The labeler reads configuration from a `labels.yaml` file that defines:
87+
88+
- **Label definitions** with colors and descriptions
89+
- **Rule sets** for automated labeling
90+
- **Global settings** for auto-creation/deletion
91+
92+
## Development
93+
94+
### Adding New Rule Types
95+
96+
1. Add new rule processing function in `labeler.go`
97+
2. Update `processRule()` to handle the new rule type
98+
3. Add corresponding tests in test files
99+
100+
### Mock Testing
101+
102+
The `MockGitHubClient` provides comprehensive mocking for testing complex scenarios without hitting the GitHub API.
Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/google/go-github/v55/github"
8+
)
9+
10+
// Additional comprehensive tests for all rule types
11+
12+
func TestLabeler_ProcessMatchRule_RemoveCommand(t *testing.T) {
13+
client := NewMockGitHubClient()
14+
config := createTestConfigWithRemoveRules()
15+
labeler := NewLabeler(client, config)
16+
17+
// Add some existing labels
18+
triageLabel := &github.Label{Name: stringPtr("triage/valid")}
19+
tagLabel := &github.Label{Name: stringPtr("tag/infrastructure")}
20+
client.IssueLabels[1] = []*github.Label{triageLabel, tagLabel}
21+
22+
req := &LabelRequest{
23+
Owner: "test-owner",
24+
Repo: "test-repo",
25+
IssueNumber: 1,
26+
CommentBody: "/remove-triage valid",
27+
ChangedFiles: []string{},
28+
}
29+
30+
ctx := context.Background()
31+
err := labeler.ProcessRequest(ctx, req)
32+
if err != nil {
33+
t.Fatalf("ProcessRequest failed: %v", err)
34+
}
35+
36+
// Check that triage/valid was removed
37+
removedLabels := client.RemovedLabels[1]
38+
if !sliceContains(removedLabels, "triage/valid") {
39+
t.Errorf("Expected 'triage/valid' to be removed, got: %v", removedLabels)
40+
}
41+
}
42+
43+
func TestLabeler_ProcessFilePathRule_MultipleFiles(t *testing.T) {
44+
client := NewMockGitHubClient()
45+
config := createTestConfigWithFilePathRules()
46+
labeler := NewLabeler(client, config)
47+
48+
req := &LabelRequest{
49+
Owner: "test-owner",
50+
Repo: "test-repo",
51+
IssueNumber: 1,
52+
CommentBody: "",
53+
ChangedFiles: []string{
54+
"tags/tag-infrastructure/charter.md",
55+
"tags/tag-developer-experience/README.md",
56+
},
57+
}
58+
59+
ctx := context.Background()
60+
err := labeler.ProcessRequest(ctx, req)
61+
if err != nil {
62+
t.Fatalf("ProcessRequest failed: %v", err)
63+
}
64+
65+
// Check that toc label was applied (for charter.md)
66+
appliedLabels := client.AppliedLabels[1]
67+
if !sliceContains(appliedLabels, "toc") {
68+
t.Errorf("Expected 'toc' label to be applied, got: %v", appliedLabels)
69+
}
70+
if !sliceContains(appliedLabels, "tag/developer-experience") {
71+
t.Errorf("Expected 'tag/developer-experience' label to be applied, got: %v", appliedLabels)
72+
}
73+
}
74+
75+
func TestLabeler_ProcessMatchRule_WildcardRemoval(t *testing.T) {
76+
client := NewMockGitHubClient()
77+
config := createTestConfigWithWildcardRules()
78+
labeler := NewLabeler(client, config)
79+
80+
// Add multiple triage labels
81+
triageValid := &github.Label{Name: stringPtr("triage/valid")}
82+
triageDuplicate := &github.Label{Name: stringPtr("triage/duplicate")}
83+
client.IssueLabels[1] = []*github.Label{triageValid, triageDuplicate}
84+
85+
req := &LabelRequest{
86+
Owner: "test-owner",
87+
Repo: "test-repo",
88+
IssueNumber: 1,
89+
CommentBody: "/clear-triage",
90+
ChangedFiles: []string{},
91+
}
92+
93+
ctx := context.Background()
94+
err := labeler.ProcessRequest(ctx, req)
95+
if err != nil {
96+
t.Fatalf("ProcessRequest failed: %v", err)
97+
}
98+
99+
// Check that all triage/* labels were removed
100+
removedLabels := client.RemovedLabels[1]
101+
if !sliceContains(removedLabels, "triage/valid") {
102+
t.Errorf("Expected 'triage/valid' to be removed, got: %v", removedLabels)
103+
}
104+
if !sliceContains(removedLabels, "triage/duplicate") {
105+
t.Errorf("Expected 'triage/duplicate' to be removed, got: %v", removedLabels)
106+
}
107+
}
108+
109+
func TestLabeler_ProcessComplexScenario(t *testing.T) {
110+
client := NewMockGitHubClient()
111+
config := createTestConfig()
112+
labeler := NewLabeler(client, config)
113+
114+
// Start with needs-triage label
115+
needsTriageLabel := &github.Label{Name: stringPtr("needs-triage")}
116+
client.IssueLabels[1] = []*github.Label{needsTriageLabel}
117+
118+
req := &LabelRequest{
119+
Owner: "test-owner",
120+
Repo: "test-repo",
121+
IssueNumber: 1,
122+
CommentBody: `This is a complex scenario.
123+
/triage valid
124+
/tag developer-experience
125+
Some more comments here.`,
126+
ChangedFiles: []string{"tags/tag-developer-experience/some-file.md"},
127+
}
128+
129+
ctx := context.Background()
130+
err := labeler.ProcessRequest(ctx, req)
131+
if err != nil {
132+
t.Fatalf("ProcessRequest failed: %v", err)
133+
}
134+
135+
appliedLabels := client.AppliedLabels[1]
136+
removedLabels := client.RemovedLabels[1]
137+
138+
// Should remove needs-triage
139+
if !sliceContains(removedLabels, "needs-triage") {
140+
t.Errorf("Expected 'needs-triage' to be removed, got: %v", removedLabels)
141+
}
142+
143+
// Should apply triage/valid and tag/developer-experience
144+
if !sliceContains(appliedLabels, "triage/valid") {
145+
t.Errorf("Expected 'triage/valid' to be applied, got: %v", appliedLabels)
146+
}
147+
if !sliceContains(appliedLabels, "tag/developer-experience") {
148+
t.Errorf("Expected 'tag/developer-experience' to be applied, got: %v", appliedLabels)
149+
}
150+
}
151+
152+
// Helper functions for additional test configs
153+
154+
func createTestConfigWithRemoveRules() *LabelsYAML {
155+
config := createTestConfig()
156+
config.Ruleset = append(config.Ruleset, Rule{
157+
Name: "remove-triage",
158+
Kind: "match",
159+
Spec: RuleSpec{
160+
Command: "/remove-triage",
161+
},
162+
Actions: []Action{
163+
{
164+
Kind: "remove-label",
165+
Spec: ActionSpec{Match: "triage/{{ argv.0 }}"},
166+
},
167+
},
168+
})
169+
return config
170+
}
171+
172+
func createTestConfigWithFilePathRules() *LabelsYAML {
173+
config := createTestConfig()
174+
config.Ruleset = append(config.Ruleset, Rule{
175+
Name: "tag-developer-experience-dir",
176+
Kind: "filePath",
177+
Spec: RuleSpec{
178+
MatchPath: "tags/tag-developer-experience/*",
179+
},
180+
Actions: []Action{
181+
{
182+
Kind: "apply-label",
183+
Spec: ActionSpec{Label: "tag/developer-experience"},
184+
},
185+
},
186+
})
187+
return config
188+
}
189+
190+
func createTestConfigWithWildcardRules() *LabelsYAML {
191+
config := createTestConfig()
192+
config.Ruleset = append(config.Ruleset, Rule{
193+
Name: "clear-triage",
194+
Kind: "match",
195+
Spec: RuleSpec{
196+
Command: "/clear-triage",
197+
},
198+
Actions: []Action{
199+
{
200+
Kind: "remove-label",
201+
Spec: ActionSpec{Match: "triage/*"},
202+
},
203+
},
204+
})
205+
return config
206+
}

utilities/labeler/go.mod

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,16 @@ module labeler
22

33
go 1.24.5
44

5+
require (
6+
github.com/google/go-github/v55 v55.0.0
7+
golang.org/x/oauth2 v0.30.0
8+
gopkg.in/yaml.v3 v3.0.1
9+
)
10+
511
require (
612
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
713
github.com/cloudflare/circl v1.3.3 // indirect
8-
github.com/google/go-github/v55 v55.0.0 // indirect
914
github.com/google/go-querystring v1.1.0 // indirect
1015
golang.org/x/crypto v0.12.0 // indirect
11-
golang.org/x/oauth2 v0.30.0 // indirect
1216
golang.org/x/sys v0.11.0 // indirect
13-
gopkg.in/yaml.v3 v3.0.1 // indirect
1417
)

utilities/labeler/go.sum

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtM
55
github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
66
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
77
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
8+
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
9+
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
810
github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg=
911
github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA=
1012
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
@@ -24,6 +26,7 @@ golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9sn
2426
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
2527
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
2628
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
29+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
2730
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
2831
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
2932
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

0 commit comments

Comments
 (0)