Skip to content

Commit 290b615

Browse files
Merge pull request #98 from jeefy/mrbobbylabels
mrbobbylabels (github label action POC)
2 parents 3ee1752 + 0b5ed46 commit 290b615

File tree

5 files changed

+351
-0
lines changed

5 files changed

+351
-0
lines changed

.github/workflows/labeler.yml

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
name: Auto Label Issues and PRs
2+
3+
on:
4+
issue_comment:
5+
types: [created, edited]
6+
7+
jobs:
8+
label:
9+
runs-on: ubuntu-latest
10+
steps:
11+
- name: Checkout code
12+
uses: actions/checkout@v4
13+
14+
- name: Set up Go
15+
uses: actions/setup-go@v5
16+
with:
17+
go-version: '1.22'
18+
19+
- name: Build labeler
20+
run: |
21+
cd utilities/labeler
22+
go build -o labeler
23+
24+
- name: Run labeler on comment
25+
env:
26+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
27+
run: |
28+
cd utilities/labeler
29+
# Extract command and args from comment body
30+
COMMENT_BODY="${{ github.event.comment.body }}"
31+
OWNER="${{ github.repository_owner }}"
32+
REPO="${{ github.event.repository.name }}"
33+
NUMBER="${{ github.event.issue.number }}"
34+
# Always run the labeler; all logic is handled in Go
35+
./labeler "$OWNER" "$REPO" "$NUMBER" "$COMMENT_BODY"

utilities/labeler/go.mod

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
module labeler
2+
3+
go 1.24.5
4+
5+
require (
6+
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect
7+
github.com/cloudflare/circl v1.3.3 // indirect
8+
github.com/google/go-github/v55 v55.0.0 // indirect
9+
github.com/google/go-querystring v1.1.0 // indirect
10+
golang.org/x/crypto v0.12.0 // indirect
11+
golang.org/x/oauth2 v0.30.0 // indirect
12+
golang.org/x/sys v0.11.0 // indirect
13+
gopkg.in/yaml.v3 v3.0.1 // indirect
14+
)

utilities/labeler/go.sum

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwFc0ih8puEVAOFGELsn1zoIIYdxvML7mDxA=
2+
github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
3+
github.com/bwesterb/go-ristretto v1.2.0/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
4+
github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtMxxK7fi4I=
5+
github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs=
6+
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
7+
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
8+
github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg=
9+
github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA=
10+
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
11+
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
12+
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
13+
golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
14+
golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
15+
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
16+
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
17+
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
18+
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
19+
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
20+
golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
21+
golang.org/x/sys v0.11.0 h1:eG7RXZHdqOJ1i+0lgLgCpSXAp6M3LYlAo6osgSi0xOM=
22+
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
23+
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
24+
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
25+
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
26+
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
27+
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
28+
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
29+
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

utilities/labeler/labels.yaml

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,94 @@
1+
# Define labels, description, color
2+
# Support ‘previous’ for label changes over time / changes in ownership
3+
# Apply/remove label based on condition and/or slash command
4+
# Apply labels based on file path
5+
# Treat labels as namespaced based on “path” (slash separated)
6+
7+
8+
labels:
9+
- name: foo
10+
color: 00ff00
11+
description: foo stuff
12+
previously:
13+
- name: bar
14+
- name: baz
15+
color: 0000ff
16+
description: baz stuff
17+
18+
19+
# each rule should be evaluated to determine what the labels should look like
20+
# and ONLY apply them if there is a difference. This will prevent removal of
21+
# labels in between steps (e.g. ensure there is only one namespaced one present)
22+
ruleset:
23+
- name: apply-tag
24+
kind: match
25+
spec:
26+
command: "/tag"
27+
rules:
28+
- matchList: # allow any items from this list, not unique
29+
values:
30+
- tag/developer-experience
31+
- tag/infrastructure
32+
- tag/operational-resilience
33+
- tag/security-compliance
34+
- tag/workloads-foundation
35+
actions:
36+
- kind: remove-label # removes label if present
37+
spec:
38+
match: needs-group
39+
- kind: apply-label
40+
label: "tag/{{ argv.0 }}" # to match input from string
41+
42+
# Applies needs-triage label if no triage related label is present
43+
# this will account for conditions where a triage label is removed, the
44+
# needs-triage label will be reapplied
45+
- name: needs-triage
46+
kind: label
47+
spec:
48+
match: "triage/*"
49+
matchCondition: NOT
50+
actions:
51+
- kind: apply-label
52+
label: "needs-triage"
53+
54+
55+
# Remove needs-triage label when a triage label is applied and ONLY allow
56+
# a single triage label
57+
- name: triage
58+
kind: match # matches string response in issue/pr
59+
spec:
60+
command: "/triage"
61+
rules:
62+
- unique: # could also be a generic list with additional commands of in / not in or a regex match
63+
ruleCondition: or # this applies to the whole rule, so it could match multiple rules as and/or
64+
values:
65+
- needs-triage
66+
- triage/valid
67+
- triage/needs-information
68+
- triage/duplicate
69+
- triage/not-planned
70+
actions: # only executed if passed the rules
71+
- kind: remove-label # removes label if present
72+
spec:
73+
match: needs-triage
74+
- kind: remove-label # ensures there is only a single triage label applied
75+
spec:
76+
match: "triage/*"
77+
- kind: apply-label
78+
label: "triage/{{ argv.0 }}" # to match input from string
79+
80+
- name: charter
81+
kind: filePath
82+
spec:
83+
matchPath: "tags/*/charter.md"
84+
actions:
85+
- kind: apply-label
86+
label: toc
87+
88+
- name: tag-foo
89+
kind: filePath
90+
spec:
91+
matchPath: tags/tag-foo/*
92+
actions:
93+
- kind: apply-label
94+
label: tag-foo

utilities/labeler/main.go

Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
package main
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"log"
7+
"os"
8+
"strings"
9+
10+
"github.com/google/go-github/v55/github"
11+
"golang.org/x/oauth2"
12+
yaml "gopkg.in/yaml.v3"
13+
)
14+
15+
// LabelConfig represents the structure of labels.yaml
16+
// Only the fields needed for logic are included here
17+
18+
type Label struct {
19+
Name string `yaml:"name"`
20+
Color string `yaml:"color"`
21+
Description string `yaml:"description"`
22+
Previously []struct {
23+
Name string `yaml:"name"`
24+
} `yaml:"previously"`
25+
}
26+
27+
type Action struct {
28+
Kind string `yaml:"kind"`
29+
Label string `yaml:"label,omitempty"`
30+
Spec map[string]interface{} `yaml:"spec,omitempty"`
31+
}
32+
33+
type RuleSpec struct {
34+
Command string `yaml:"command,omitempty"`
35+
Rules []interface{} `yaml:"rules,omitempty"`
36+
Match string `yaml:"match,omitempty"`
37+
MatchCondition string `yaml:"matchCondition,omitempty"`
38+
MatchPath string `yaml:"matchPath,omitempty"`
39+
}
40+
41+
type Rule struct {
42+
Name string `yaml:"name"`
43+
Kind string `yaml:"kind"`
44+
Spec RuleSpec `yaml:"spec"`
45+
Actions []Action `yaml:"actions"`
46+
}
47+
48+
type LabelsYAML struct {
49+
Labels []Label `yaml:"labels"`
50+
Ruleset []Rule `yaml:"ruleset"`
51+
}
52+
53+
func loadConfig(path string) (*LabelsYAML, error) {
54+
f, err := os.Open(path)
55+
if err != nil {
56+
return nil, err
57+
}
58+
defer f.Close()
59+
var cfg LabelsYAML
60+
dec := yaml.NewDecoder(f)
61+
if err := dec.Decode(&cfg); err != nil {
62+
return nil, err
63+
}
64+
return &cfg, nil
65+
}
66+
67+
func githubClient() *github.Client {
68+
token := os.Getenv("GITHUB_TOKEN")
69+
if token == "" {
70+
log.Fatal("GITHUB_TOKEN environment variable not set")
71+
}
72+
ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token})
73+
return github.NewClient(oauth2.NewClient(context.Background(), ts))
74+
}
75+
76+
func main() {
77+
if len(os.Args) < 5 {
78+
fmt.Println("Usage: labeler <owner> <repo> <issue_number> <comment_body>")
79+
os.Exit(1)
80+
}
81+
owner := os.Args[1]
82+
repo := os.Args[2]
83+
issueNum := os.Args[3]
84+
commentBody := os.Args[4]
85+
86+
cfg, err := loadConfig("labels.yaml")
87+
if err != nil {
88+
log.Fatalf("failed to load labels.yaml: %v", err)
89+
}
90+
91+
client := githubClient()
92+
ctx := context.Background()
93+
94+
issue, _, err := client.Issues.Get(ctx, owner, repo, toInt(issueNum))
95+
if err != nil {
96+
log.Fatalf("failed to fetch issue: %v", err)
97+
}
98+
fmt.Printf("Issue #%d: %s\n", *issue.Number, *issue.Title)
99+
100+
// Scan for all supported commands anywhere in the comment body
101+
lines := strings.Split(commentBody, "\n")
102+
for _, rule := range cfg.Ruleset {
103+
if rule.Spec.Command != "" {
104+
for _, line := range lines {
105+
line = strings.TrimSpace(line)
106+
if strings.HasPrefix(line, rule.Spec.Command) {
107+
// Split command and args
108+
parts := strings.Fields(line)
109+
argv := []string{}
110+
if len(parts) > 1 {
111+
argv = parts[1:]
112+
}
113+
for _, action := range rule.Actions {
114+
switch action.Kind {
115+
case "apply-label":
116+
label := renderLabel(action.Label, argv)
117+
applyLabel(ctx, client, owner, repo, toInt(issueNum), label)
118+
case "remove-label":
119+
match, _ := action.Spec["match"].(string)
120+
if match != "" {
121+
removeLabel(ctx, client, owner, repo, toInt(issueNum), match)
122+
}
123+
}
124+
}
125+
}
126+
}
127+
}
128+
}
129+
}
130+
131+
// renderLabel replaces {{ argv.0 }} etc. in label templates
132+
func renderLabel(template string, argv []string) string {
133+
label := template
134+
for i, v := range argv {
135+
label = replaceAll(label, fmt.Sprintf("{{ argv.%d }}", i), v)
136+
}
137+
return label
138+
}
139+
140+
func replaceAll(s, old, new string) string {
141+
for {
142+
idx := indexOf(s, old)
143+
if idx == -1 {
144+
break
145+
}
146+
s = s[:idx] + new + s[idx+len(old):]
147+
}
148+
return s
149+
}
150+
151+
func indexOf(s, substr string) int {
152+
return strings.Index(s, substr)
153+
}
154+
155+
func applyLabel(ctx context.Context, client *github.Client, owner, repo string, issueNum int, label string) {
156+
fmt.Printf("Applying label: %s\n", label)
157+
_, _, err := client.Issues.AddLabelsToIssue(ctx, owner, repo, issueNum, []string{label})
158+
if err != nil {
159+
log.Printf("failed to apply label %s: %v", label, err)
160+
}
161+
}
162+
163+
func removeLabel(ctx context.Context, client *github.Client, owner, repo string, issueNum int, label string) {
164+
fmt.Printf("Removing label: %s\n", label)
165+
_, err := client.Issues.RemoveLabelForIssue(ctx, owner, repo, issueNum, label)
166+
if err != nil {
167+
log.Printf("failed to remove label %s: %v", label, err)
168+
}
169+
}
170+
171+
func toInt(s string) int {
172+
n, err := fmt.Sscanf(s, "%d", new(int))
173+
if err != nil || n != 1 {
174+
log.Fatalf("invalid issue number: %s", s)
175+
}
176+
var i int
177+
fmt.Sscanf(s, "%d", &i)
178+
return i
179+
}

0 commit comments

Comments
 (0)