diff --git a/utilities/labeler/README.md b/utilities/labeler/README.md index dd18091..92ee52d 100644 --- a/utilities/labeler/README.md +++ b/utilities/labeler/README.md @@ -1,40 +1,102 @@ -## Label Config / General Notes +# GitHub Labeler -When a label configuration is supplied, they should be the only labels that exist unless we set auto-delete to FALSE +A Go program that automatically labels GitHub issues and pull requests based on configurable rules defined in a `labels.yaml` file. -When the label configuration is updated (name, color, description etc), it should update the label and all items the label was previously tagged with. +## Rule Types Supported -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 +### 1. Match Rules (`kind: match`) +Process slash commands in comments: +```yaml +- name: apply-triage + kind: match + spec: + command: "/triage" + matchList: ["valid", "duplicate", "needs-information", "not-planned"] + actions: + - kind: remove-label + spec: + match: needs-triage + - kind: apply-label + spec: + label: "triage/{{ argv.0 }}" +``` -When there are multiple matching commands, it should it process all of them. +### 2. Label Rules (`kind: label`) +Apply labels based on existing label presence: +```yaml +- name: needs-triage + kind: label + spec: + match: "triage/*" + matchCondition: NOT + actions: + - kind: apply-label + spec: + label: "needs-triage" +``` -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. +### 3. File Path Rules (`kind: filePath`) +Apply labels based on changed file paths: +```yaml +- name: charter + kind: filePath + spec: + matchPath: "tags/*/charter.md" + actions: + - kind: apply-label + spec: + label: toc +``` -A label should be able to be removed by some method e.g. /remove- would remove the label foo/bar or /foo -bar. -No preference, just a method of removing a label needs to exist - -## kind/match - -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 +## Action Types -When the unique rule is used, only ONE of the defined labels should be present +### Apply Label +```yaml +- kind: apply-label + spec: + label: "label-name" +``` -- 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? +### Remove Label +```yaml +- kind: remove-label + spec: + match: "label-pattern" # Supports wildcards like "triage/*" +``` +## Testing -## kind/label +Run tests: +```bash +go test -v +``` -When the kind/label rule is used, it should ignore issue/PR bodies and check label state only for taking action. +## Usage -## kind/filePath - -Only applies to PRs - -When a commit changes anything that matches the filepath, the rules defined should execute - - -## rules / actions - -When the remove-label action is present, it should remove the matching label if present +### CLI +```bash +./labeler +``` -When the apply-label action is used, it should ONLY apply a label if the label exists. \ No newline at end of file +### GitHub Actions Workflow +The included workflow automatically runs the labeler on issue comments. + +## Configuration + +The labeler reads configuration from a `labels.yaml` file that defines: + +- **Label definitions** with colors and descriptions +- **Rule sets** for automated labeling +- **Global settings** for auto-creation/deletion + +## Development + +### Adding New Rule Types + +1. Add new rule processing function in `labeler.go` +2. Update `processRule()` to handle the new rule type +3. Add corresponding tests in test files + +### Mock Testing + +The `MockGitHubClient` provides comprehensive mocking for testing complex scenarios without hitting the GitHub API. diff --git a/utilities/labeler/comprehensive_test.go b/utilities/labeler/comprehensive_test.go new file mode 100644 index 0000000..288dfdd --- /dev/null +++ b/utilities/labeler/comprehensive_test.go @@ -0,0 +1,206 @@ +package main + +import ( + "context" + "testing" + + "github.com/google/go-github/v55/github" +) + +// Additional comprehensive tests for all rule types + +func TestLabeler_ProcessMatchRule_RemoveCommand(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfigWithRemoveRules() + labeler := NewLabeler(client, config) + + // Add some existing labels + triageLabel := &github.Label{Name: stringPtr("triage/valid")} + tagLabel := &github.Label{Name: stringPtr("tag/infrastructure")} + client.IssueLabels[1] = []*github.Label{triageLabel, tagLabel} + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: "/remove-triage valid", + ChangedFiles: []string{}, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + // Check that triage/valid was removed + removedLabels := client.RemovedLabels[1] + if !sliceContains(removedLabels, "triage/valid") { + t.Errorf("Expected 'triage/valid' to be removed, got: %v", removedLabels) + } +} + +func TestLabeler_ProcessFilePathRule_MultipleFiles(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfigWithFilePathRules() + labeler := NewLabeler(client, config) + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: "", + ChangedFiles: []string{ + "tags/tag-infrastructure/charter.md", + "tags/tag-developer-experience/README.md", + }, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + // Check that toc label was applied (for charter.md) + appliedLabels := client.AppliedLabels[1] + if !sliceContains(appliedLabels, "toc") { + t.Errorf("Expected 'toc' label to be applied, got: %v", appliedLabels) + } + if !sliceContains(appliedLabels, "tag/developer-experience") { + t.Errorf("Expected 'tag/developer-experience' label to be applied, got: %v", appliedLabels) + } +} + +func TestLabeler_ProcessMatchRule_WildcardRemoval(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfigWithWildcardRules() + labeler := NewLabeler(client, config) + + // Add multiple triage labels + triageValid := &github.Label{Name: stringPtr("triage/valid")} + triageDuplicate := &github.Label{Name: stringPtr("triage/duplicate")} + client.IssueLabels[1] = []*github.Label{triageValid, triageDuplicate} + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: "/clear-triage", + ChangedFiles: []string{}, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + // Check that all triage/* labels were removed + removedLabels := client.RemovedLabels[1] + if !sliceContains(removedLabels, "triage/valid") { + t.Errorf("Expected 'triage/valid' to be removed, got: %v", removedLabels) + } + if !sliceContains(removedLabels, "triage/duplicate") { + t.Errorf("Expected 'triage/duplicate' to be removed, got: %v", removedLabels) + } +} + +func TestLabeler_ProcessComplexScenario(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfig() + labeler := NewLabeler(client, config) + + // Start with needs-triage label + needsTriageLabel := &github.Label{Name: stringPtr("needs-triage")} + client.IssueLabels[1] = []*github.Label{needsTriageLabel} + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: `This is a complex scenario. +/triage valid +/tag developer-experience +Some more comments here.`, + ChangedFiles: []string{"tags/tag-developer-experience/some-file.md"}, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + appliedLabels := client.AppliedLabels[1] + removedLabels := client.RemovedLabels[1] + + // Should remove needs-triage + if !sliceContains(removedLabels, "needs-triage") { + t.Errorf("Expected 'needs-triage' to be removed, got: %v", removedLabels) + } + + // Should apply triage/valid and tag/developer-experience + if !sliceContains(appliedLabels, "triage/valid") { + t.Errorf("Expected 'triage/valid' to be applied, got: %v", appliedLabels) + } + if !sliceContains(appliedLabels, "tag/developer-experience") { + t.Errorf("Expected 'tag/developer-experience' to be applied, got: %v", appliedLabels) + } +} + +// Helper functions for additional test configs + +func createTestConfigWithRemoveRules() *LabelsYAML { + config := createTestConfig() + config.Ruleset = append(config.Ruleset, Rule{ + Name: "remove-triage", + Kind: "match", + Spec: RuleSpec{ + Command: "/remove-triage", + }, + Actions: []Action{ + { + Kind: "remove-label", + Spec: ActionSpec{Match: "triage/{{ argv.0 }}"}, + }, + }, + }) + return config +} + +func createTestConfigWithFilePathRules() *LabelsYAML { + config := createTestConfig() + config.Ruleset = append(config.Ruleset, Rule{ + Name: "tag-developer-experience-dir", + Kind: "filePath", + Spec: RuleSpec{ + MatchPath: "tags/tag-developer-experience/*", + }, + Actions: []Action{ + { + Kind: "apply-label", + Spec: ActionSpec{Label: "tag/developer-experience"}, + }, + }, + }) + return config +} + +func createTestConfigWithWildcardRules() *LabelsYAML { + config := createTestConfig() + config.Ruleset = append(config.Ruleset, Rule{ + Name: "clear-triage", + Kind: "match", + Spec: RuleSpec{ + Command: "/clear-triage", + }, + Actions: []Action{ + { + Kind: "remove-label", + Spec: ActionSpec{Match: "triage/*"}, + }, + }, + }) + return config +} diff --git a/utilities/labeler/go.mod b/utilities/labeler/go.mod index 8484d65..63a616d 100644 --- a/utilities/labeler/go.mod +++ b/utilities/labeler/go.mod @@ -2,13 +2,16 @@ module labeler go 1.24.5 +require ( + github.com/google/go-github/v55 v55.0.0 + golang.org/x/oauth2 v0.30.0 + gopkg.in/yaml.v3 v3.0.1 +) + require ( github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 // indirect github.com/cloudflare/circl v1.3.3 // indirect - github.com/google/go-github/v55 v55.0.0 // indirect github.com/google/go-querystring v1.1.0 // indirect golang.org/x/crypto v0.12.0 // indirect - golang.org/x/oauth2 v0.30.0 // indirect golang.org/x/sys v0.11.0 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/utilities/labeler/go.sum b/utilities/labeler/go.sum index 48f3253..f316b6b 100644 --- a/utilities/labeler/go.sum +++ b/utilities/labeler/go.sum @@ -5,6 +5,8 @@ github.com/cloudflare/circl v1.1.0/go.mod h1:prBCrKB9DV4poKZY1l9zBXg2QJY7mvgRvtM github.com/cloudflare/circl v1.3.3 h1:fE/Qz0QdIGqeWfnwq0RE0R7MI51s0M2E4Ga9kq5AEMs= github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38= +github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v55 v55.0.0 h1:4pp/1tNMB9X/LuAhs5i0KQAE40NmiR/y6prLNb9x9cg= github.com/google/go-github/v55 v55.0.0/go.mod h1:JLahOTA1DnXzhxEymmFF5PP2tSS9JVNj68mSZNDwskA= 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 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/utilities/labeler/labeler.go b/utilities/labeler/labeler.go new file mode 100644 index 0000000..1ab28fa --- /dev/null +++ b/utilities/labeler/labeler.go @@ -0,0 +1,498 @@ +package main + +import ( + "context" + "fmt" + "log" + "net/http" + "path/filepath" + "slices" + "strings" + + "github.com/google/go-github/v55/github" + "golang.org/x/oauth2" + yaml "gopkg.in/yaml.v3" +) + +// GitHubClient interface to allow mocking +type GitHubClient interface { + GetIssue(ctx context.Context, owner, repo string, number int) (*github.Issue, *github.Response, error) + ListLabelsByIssue(ctx context.Context, owner, repo string, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) + AddLabelsToIssue(ctx context.Context, owner, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) + RemoveLabelForIssue(ctx context.Context, owner, repo string, number int, label string) (*github.Response, error) + ListLabels(ctx context.Context, owner, repo string, opts *github.ListOptions) ([]*github.Label, *github.Response, error) + CreateLabel(ctx context.Context, owner, repo string, label *github.Label) (*github.Label, *github.Response, error) + EditLabel(ctx context.Context, owner, repo, name string, label *github.Label) (*github.Label, *github.Response, error) + DeleteLabel(ctx context.Context, owner, repo, name string) (*github.Response, error) + GetLabel(ctx context.Context, owner, repo, name string) (*github.Label, *github.Response, error) +} + +// GitHubClientWrapper wraps the actual GitHub client +type GitHubClientWrapper struct { + client *github.Client +} + +func (g *GitHubClientWrapper) GetIssue(ctx context.Context, owner, repo string, number int) (*github.Issue, *github.Response, error) { + return g.client.Issues.Get(ctx, owner, repo, number) +} + +func (g *GitHubClientWrapper) ListLabelsByIssue(ctx context.Context, owner, repo string, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) { + return g.client.Issues.ListLabelsByIssue(ctx, owner, repo, number, opts) +} + +func (g *GitHubClientWrapper) AddLabelsToIssue(ctx context.Context, owner, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) { + return g.client.Issues.AddLabelsToIssue(ctx, owner, repo, number, labels) +} + +func (g *GitHubClientWrapper) RemoveLabelForIssue(ctx context.Context, owner, repo string, number int, label string) (*github.Response, error) { + return g.client.Issues.RemoveLabelForIssue(ctx, owner, repo, number, label) +} + +func (g *GitHubClientWrapper) ListLabels(ctx context.Context, owner, repo string, opts *github.ListOptions) ([]*github.Label, *github.Response, error) { + return g.client.Issues.ListLabels(ctx, owner, repo, opts) +} + +func (g *GitHubClientWrapper) CreateLabel(ctx context.Context, owner, repo string, label *github.Label) (*github.Label, *github.Response, error) { + return g.client.Issues.CreateLabel(ctx, owner, repo, label) +} + +func (g *GitHubClientWrapper) EditLabel(ctx context.Context, owner, repo, name string, label *github.Label) (*github.Label, *github.Response, error) { + return g.client.Issues.EditLabel(ctx, owner, repo, name, label) +} + +func (g *GitHubClientWrapper) DeleteLabel(ctx context.Context, owner, repo, name string) (*github.Response, error) { + return g.client.Issues.DeleteLabel(ctx, owner, repo, name) +} + +func (g *GitHubClientWrapper) GetLabel(ctx context.Context, owner, repo, name string) (*github.Label, *github.Response, error) { + return g.client.Issues.GetLabel(ctx, owner, repo, name) +} + +// Labeler handles the core labeling logic +type Labeler struct { + client GitHubClient + config *LabelsYAML +} + +// NewLabeler creates a new Labeler instance +func NewLabeler(client GitHubClient, config *LabelsYAML) *Labeler { + return &Labeler{ + client: client, + config: config, + } +} + +// ProcessRequest processes a labeling request +func (l *Labeler) ProcessRequest(ctx context.Context, req *LabelRequest) error { + if l.config.AutoDelete { + if err := l.deleteUndefinedLabels(ctx, req.Owner, req.Repo); err != nil { + log.Printf("failed to delete undefined labels: %v", err) + } + } + + if l.config.AutoCreate { + if err := l.ensureDefinedLabelsExist(ctx, req.Owner, req.Repo); err != nil { + log.Printf("failed to ensure defined labels exist: %v", err) + } + } + + issue, _, err := l.client.GetIssue(ctx, req.Owner, req.Repo, req.IssueNumber) + if err != nil { + return fmt.Errorf("failed to fetch issue: %v", err) + } + + if l.config.Debug { + log.Printf("Processing issue #%d: %s", *issue.Number, *issue.Title) + } + + return l.processRules(ctx, req, issue) +} + +// LabelRequest represents a labeling request +type LabelRequest struct { + Owner string + Repo string + IssueNumber int + CommentBody string + ChangedFiles []string +} + +func (l *Labeler) processRules(ctx context.Context, req *LabelRequest, issue *github.Issue) error { + for _, rule := range l.config.Ruleset { + if err := l.processRule(ctx, req, rule); err != nil { + log.Printf("error processing rule %s: %v", rule.Name, err) + } + } + return nil +} + +func (l *Labeler) processRule(ctx context.Context, req *LabelRequest, rule Rule) error { + switch rule.Kind { + case "filePath": + return l.processFilePathRule(ctx, req, rule) + case "match": + return l.processMatchRule(ctx, req, rule) + case "label": + return l.processLabelRule(ctx, req, rule) + default: + return fmt.Errorf("unknown rule kind: %s", rule.Kind) + } +} + +func (l *Labeler) processFilePathRule(ctx context.Context, req *LabelRequest, rule Rule) error { + if len(req.ChangedFiles) == 0 { + if l.config.Debug { + log.Printf("No changed files to process for rule %s", rule.Name) + } + return nil + } + + for _, file := range req.ChangedFiles { + matched, err := filepath.Match(rule.Spec.MatchPath, file) + if err != nil { + return fmt.Errorf("error matching file path: %v", err) + } + + shouldApply := matched + if rule.Spec.MatchCondition == "NOT" { + shouldApply = !matched + } + + if shouldApply { + for _, action := range rule.Actions { + if err := l.executeAction(ctx, req, action, nil); err != nil { + log.Printf("error executing action: %v", err) + } + } + } + } + return nil +} + +func (l *Labeler) processMatchRule(ctx context.Context, req *LabelRequest, rule Rule) error { + if rule.Spec.Command == "" { + return fmt.Errorf("match rule missing command") + } + + if !strings.HasPrefix(rule.Spec.Command, "/") { + if l.config.Debug { + log.Printf("Command `%s` does not start with a forward slash, skipping", rule.Spec.Command) + } + return nil + } + + lines := strings.Split(req.CommentBody, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, rule.Spec.Command) { + parts := strings.Fields(line) + argv := []string{} + if len(parts) > 1 { + argv = parts[1:] + } + + if len(rule.Spec.MatchList) > 0 && len(argv) > 0 { + if !slices.Contains(rule.Spec.MatchList, argv[0]) { + if l.config.Debug { + log.Printf("Invalid argument `%s` for command %s", argv[0], rule.Spec.Command) + } + continue + } + } + + for _, action := range rule.Actions { + if err := l.executeAction(ctx, req, action, argv); err != nil { + log.Printf("error executing action: %v", err) + } + } + } + } + return nil +} + +func (l *Labeler) processLabelRule(ctx context.Context, req *LabelRequest, rule Rule) error { + existingLabels, _, err := l.client.ListLabelsByIssue(ctx, req.Owner, req.Repo, req.IssueNumber, nil) + if err != nil { + return fmt.Errorf("failed to fetch labels for issue: %v", err) + } + + foundNamespace := false + for _, lbl := range existingLabels { + matched, _ := filepath.Match(rule.Spec.Match, lbl.GetName()) + if matched { + foundNamespace = true + break + } + } + + // Default logic: apply if the namespace is NOT found + // For "NOT" condition: apply if the namespace is NOT found (same as default) + shouldApply := !foundNamespace + + if l.config.Debug { + log.Printf("Label rule %s: foundNamespace=%v, matchCondition=%s, shouldApply=%v", + rule.Name, foundNamespace, rule.Spec.MatchCondition, shouldApply) + } + + if shouldApply { + for _, action := range rule.Actions { + if err := l.executeAction(ctx, req, action, nil); err != nil { + log.Printf("error executing action: %v", err) + } + } + } + return nil +} + +func (l *Labeler) executeAction(ctx context.Context, req *LabelRequest, action Action, argv []string) error { + var label string + if action.Spec.Label != "" { + label = l.renderLabel(action.Spec.Label, argv) + } + if action.Spec.Match != "" { + label = l.renderLabel(action.Spec.Match, argv) + if !l.isValidLabel(label) && !strings.Contains(label, "/*") { + if l.config.Debug { + log.Printf("Label `%s` is not defined in labels.yaml", label) + } + return nil + } + } + + switch action.Kind { + case "apply-label": + return l.applyLabel(ctx, req.Owner, req.Repo, req.IssueNumber, label) + case "remove-label": + if label != "" { + return l.removeLabel(ctx, req.Owner, req.Repo, req.IssueNumber, label) + } + } + return nil +} + +func (l *Labeler) renderLabel(template string, argv []string) string { + label := template + for i, v := range argv { + label = strings.ReplaceAll(label, fmt.Sprintf("{{ argv.%d }}", i), v) + } + return label +} + +func (l *Labeler) isValidLabel(label string) bool { + for _, lbl := range l.config.Labels { + if lbl.Name == label { + return true + } + } + return false +} + +func (l *Labeler) applyLabel(ctx context.Context, owner, repo string, issueNum int, label string) error { + if l.config.Debug { + log.Printf("Applying label: %s", label) + } + + // Get current labels for the issue + existingLabels, _, err := l.client.ListLabelsByIssue(ctx, owner, repo, issueNum, nil) + if err != nil { + return fmt.Errorf("failed to fetch labels for issue: %v", err) + } + + // Check if the label is already applied + for _, lbl := range existingLabels { + if lbl.GetName() == label { + if l.config.Debug { + log.Printf("label %s is already applied, skipping", label) + } + return nil + } + } + + // Get label definition from config + color, description, resolvedLabel := l.getLabelDefinition(label) + if resolvedLabel == "" { + return fmt.Errorf("label %s is not defined in labels.yaml and auto-create is disabled", label) + } + + // Ensure the label exists with the defined color and description + if err := l.ensureLabelExists(ctx, owner, repo, resolvedLabel, color, description); err != nil { + return fmt.Errorf("failed to ensure label exists: %v", err) + } + + _, _, err = l.client.AddLabelsToIssue(ctx, owner, repo, issueNum, []string{resolvedLabel}) + if err != nil { + return fmt.Errorf("failed to apply label %s: %v", resolvedLabel, err) + } + return nil +} + +func (l *Labeler) removeLabel(ctx context.Context, owner, repo string, issueNum int, label string) error { + if l.config.Debug { + log.Printf("Removing label: %s", label) + } + + // Get current labels for the issue + existingLabels, _, err := l.client.ListLabelsByIssue(ctx, owner, repo, issueNum, nil) + if err != nil { + return fmt.Errorf("failed to fetch labels for issue: %v", err) + } + + // Handle wildcard removal + if strings.Contains(label, "/*") { + prefix := strings.TrimSuffix(label, "*") + removed := false + for _, lbl := range existingLabels { + if strings.HasPrefix(lbl.GetName(), prefix) { + if err := l.removeLabel(ctx, owner, repo, issueNum, lbl.GetName()); err != nil { + log.Printf("error removing label %s: %v", lbl.GetName(), err) + } else { + removed = true + } + } + } + if !removed && l.config.Debug { + log.Printf("no labels matching pattern %s found to remove", label) + } + return nil + } + + // Check if the label is applied + labelFound := false + for _, lbl := range existingLabels { + if lbl.GetName() == label { + labelFound = true + break + } + } + + if !labelFound { + if l.config.Debug { + log.Printf("label %s is not applied, skipping removal", label) + } + return nil + } + + _, err = l.client.RemoveLabelForIssue(ctx, owner, repo, issueNum, label) + if err != nil { + return fmt.Errorf("failed to remove label %s: %v", label, err) + } + return nil +} + +func (l *Labeler) getLabelDefinition(labelName string) (string, string, string) { + for _, label := range l.config.Labels { + if label.Name == labelName { + return label.Color, label.Description, label.Name + } + for _, prev := range label.Previously { + if prev.Name == labelName { + return label.Color, label.Description, label.Name + } + } + } + if l.config.DefinitionRequired { + return "", "", "" + } + return "000000", "Automatically applied label", labelName +} + +func (l *Labeler) ensureLabelExists(ctx context.Context, owner, repo, labelName, color, description string) error { + if !l.config.AutoCreate { + return fmt.Errorf("label %s does not exist and auto-create-labels is disabled", labelName) + } + + lbl, _, err := l.client.GetLabel(ctx, owner, repo, labelName) + if err != nil { + // Create the label if it doesn't exist + lbl, _, err = l.client.CreateLabel(ctx, owner, repo, &github.Label{ + Name: &labelName, + Color: &color, + Description: &description, + }) + if err != nil { + return fmt.Errorf("failed to create label %s: %v", labelName, err) + } + } + + // Update label if color or description differs + if lbl.GetColor() != color || lbl.GetDescription() != description { + _, _, err := l.client.EditLabel(ctx, owner, repo, labelName, &github.Label{ + Name: &labelName, + Color: &color, + Description: &description, + }) + if err != nil { + return fmt.Errorf("failed to update label %s: %v", labelName, err) + } + } + return nil +} + +func (l *Labeler) ensureDefinedLabelsExist(ctx context.Context, owner, repo string) error { + for _, label := range l.config.Labels { + color, description, labelName := l.getLabelDefinition(label.Name) + if err := l.ensureLabelExists(ctx, owner, repo, labelName, color, description); err != nil { + log.Printf("skipping label %s due to error: %v", labelName, err) + } + } + return nil +} + +func (l *Labeler) deleteUndefinedLabels(ctx context.Context, owner, repo string) error { + existingLabels, _, err := l.client.ListLabels(ctx, owner, repo, nil) + if err != nil { + return fmt.Errorf("failed to fetch existing labels: %v", err) + } + + definedLabels := map[string]bool{} + for _, label := range l.config.Labels { + definedLabels[label.Name] = true + for _, prev := range label.Previously { + definedLabels[prev.Name] = true + } + } + + for _, lbl := range existingLabels { + if !definedLabels[lbl.GetName()] { + if l.config.Debug { + log.Printf("deleting undefined label: %s", lbl.GetName()) + } + _, err := l.client.DeleteLabel(ctx, owner, repo, lbl.GetName()) + if err != nil { + log.Printf("failed to delete label %s: %v", lbl.GetName(), err) + } + } + } + return nil +} + +// LoadConfigFromURL loads configuration from a URL +func LoadConfigFromURL(url string) (*LabelsYAML, error) { + resp, err := http.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch labels.yaml from URL: %v", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("failed to fetch labels.yaml: HTTP %d", resp.StatusCode) + } + + var cfg LabelsYAML + dec := yaml.NewDecoder(resp.Body) + if err := dec.Decode(&cfg); err != nil { + return nil, fmt.Errorf("failed to decode labels.yaml: %v", err) + } + return &cfg, nil +} + +// CreateGitHubClient creates a new GitHub client +func CreateGitHubClient(token string) (*GitHubClientWrapper, error) { + if token == "" { + return nil, fmt.Errorf("GITHUB_TOKEN not provided") + } + ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) + return &GitHubClientWrapper{ + client: github.NewClient(oauth2.NewClient(context.Background(), ts)), + }, nil +} diff --git a/utilities/labeler/labeler_test.go b/utilities/labeler/labeler_test.go new file mode 100644 index 0000000..c3f3745 --- /dev/null +++ b/utilities/labeler/labeler_test.go @@ -0,0 +1,465 @@ +package main + +import ( + "context" + "strings" + "testing" + + "github.com/google/go-github/v55/github" +) + +// MockGitHubClient implements GitHubClient for testing +type MockGitHubClient struct { + Issues map[int]*github.Issue + Labels []*github.Label + IssueLabels map[int][]*github.Label + CreatedLabels map[string]*github.Label + DeletedLabels []string + AppliedLabels map[int][]string + RemovedLabels map[int][]string +} + +func NewMockGitHubClient() *MockGitHubClient { + return &MockGitHubClient{ + Issues: make(map[int]*github.Issue), + IssueLabels: make(map[int][]*github.Label), + CreatedLabels: make(map[string]*github.Label), + DeletedLabels: []string{}, + AppliedLabels: make(map[int][]string), + RemovedLabels: make(map[int][]string), + } +} + +func (m *MockGitHubClient) GetIssue(ctx context.Context, owner, repo string, number int) (*github.Issue, *github.Response, error) { + if issue, exists := m.Issues[number]; exists { + return issue, nil, nil + } + title := "Test Issue" + return &github.Issue{Number: &number, Title: &title}, nil, nil +} + +func (m *MockGitHubClient) ListLabelsByIssue(ctx context.Context, owner, repo string, number int, opts *github.ListOptions) ([]*github.Label, *github.Response, error) { + return m.IssueLabels[number], nil, nil +} + +func (m *MockGitHubClient) AddLabelsToIssue(ctx context.Context, owner, repo string, number int, labels []string) ([]*github.Label, *github.Response, error) { + if m.AppliedLabels[number] == nil { + m.AppliedLabels[number] = []string{} + } + m.AppliedLabels[number] = append(m.AppliedLabels[number], labels...) + + // Add to issue labels + for _, label := range labels { + labelObj := &github.Label{Name: &label} + m.IssueLabels[number] = append(m.IssueLabels[number], labelObj) + } + return nil, nil, nil +} + +func (m *MockGitHubClient) RemoveLabelForIssue(ctx context.Context, owner, repo string, number int, label string) (*github.Response, error) { + if m.RemovedLabels[number] == nil { + m.RemovedLabels[number] = []string{} + } + m.RemovedLabels[number] = append(m.RemovedLabels[number], label) + + // Remove from issue labels + for i, lbl := range m.IssueLabels[number] { + if lbl.GetName() == label { + m.IssueLabels[number] = append(m.IssueLabels[number][:i], m.IssueLabels[number][i+1:]...) + break + } + } + return nil, nil +} + +func (m *MockGitHubClient) ListLabels(ctx context.Context, owner, repo string, opts *github.ListOptions) ([]*github.Label, *github.Response, error) { + return m.Labels, nil, nil +} + +func (m *MockGitHubClient) CreateLabel(ctx context.Context, owner, repo string, label *github.Label) (*github.Label, *github.Response, error) { + m.CreatedLabels[label.GetName()] = label + m.Labels = append(m.Labels, label) + return label, nil, nil +} + +func (m *MockGitHubClient) EditLabel(ctx context.Context, owner, repo, name string, label *github.Label) (*github.Label, *github.Response, error) { + for _, lbl := range m.Labels { + if lbl.GetName() == name { + *lbl = *label + break + } + } + return label, nil, nil +} + +func (m *MockGitHubClient) DeleteLabel(ctx context.Context, owner, repo, name string) (*github.Response, error) { + m.DeletedLabels = append(m.DeletedLabels, name) + + // Remove from labels + for i, lbl := range m.Labels { + if lbl.GetName() == name { + m.Labels = append(m.Labels[:i], m.Labels[i+1:]...) + break + } + } + return nil, nil +} + +func (m *MockGitHubClient) GetLabel(ctx context.Context, owner, repo, name string) (*github.Label, *github.Response, error) { + for _, lbl := range m.Labels { + if lbl.GetName() == name { + return lbl, nil, nil + } + } + // Return error if label doesn't exist + return nil, nil, &github.ErrorResponse{Message: "Not Found"} +} + +// Helper function to create a test config +func createTestConfig() *LabelsYAML { + return &LabelsYAML{ + AutoCreate: true, + AutoDelete: false, + DefinitionRequired: true, + Debug: true, + Labels: []Label{ + {Name: "needs-triage", Color: "ededed", Description: "Needs triage"}, + {Name: "triage/valid", Color: "0e8a16", Description: "Valid issue"}, + {Name: "triage/duplicate", Color: "ebf84a", Description: "Duplicate issue"}, + {Name: "kind/enhancement", Color: "61D6C3", Description: "Enhancement"}, + {Name: "tag/developer-experience", Color: "c2e0c6", Description: "TAG Developer Experience"}, + {Name: "toc", Color: "CF0CBE", Description: "TOC specific issue"}, + {Name: "help wanted", Color: "159818", Description: "Help wanted"}, + }, + Ruleset: []Rule{ + // needs-triage rule + { + Name: "needs-triage", + Kind: "label", + Spec: RuleSpec{ + Match: "triage/*", + MatchCondition: "NOT", + }, + Actions: []Action{ + { + Kind: "apply-label", + Spec: ActionSpec{Label: "needs-triage"}, + }, + }, + }, + // triage command rule + { + Name: "apply-triage", + Kind: "match", + Spec: RuleSpec{ + Command: "/triage", + MatchList: []string{ + "valid", + "duplicate", + "needs-information", + "not-planned", + }, + }, + Actions: []Action{ + { + Kind: "remove-label", + Spec: ActionSpec{Match: "needs-triage"}, + }, + { + Kind: "remove-label", + Spec: ActionSpec{Match: "triage/*"}, + }, + { + Kind: "apply-label", + Spec: ActionSpec{Label: "triage/{{ argv.0 }}"}, + }, + }, + }, + // tag command rule + { + Name: "apply-tag", + Kind: "match", + Spec: RuleSpec{ + Command: "/tag", + MatchList: []string{ + "developer-experience", + "infrastructure", + "operational-resilience", + "security-compliance", + "workloads-foundation", + }, + }, + Actions: []Action{ + { + Kind: "apply-label", + Spec: ActionSpec{Label: "tag/{{ argv.0 }}"}, + }, + }, + }, + // file path rule + { + Name: "charter", + Kind: "filePath", + Spec: RuleSpec{ + MatchPath: "tags/*/charter.md", + }, + Actions: []Action{ + { + Kind: "apply-label", + Spec: ActionSpec{Label: "toc"}, + }, + }, + }, + }, + } +} + +func TestLabeler_ProcessFilePathRule(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfig() + labeler := NewLabeler(client, config) + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: "", + ChangedFiles: []string{"tags/tag-infrastructure/charter.md"}, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + // Check that toc label was applied (along with needs-triage) + appliedLabels := client.AppliedLabels[1] + if !sliceContains(appliedLabels, "toc") { + t.Errorf("Expected 'toc' label to be applied, got: %v", appliedLabels) + } + if !sliceContains(appliedLabels, "needs-triage") { + t.Errorf("Expected 'needs-triage' label to be applied, got: %v", appliedLabels) + } +} + +func TestLabeler_ProcessMatchRule_TriageCommand(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfig() + labeler := NewLabeler(client, config) + + // Add needs-triage label to the issue initially + needsTriageLabel := &github.Label{Name: stringPtr("needs-triage")} + client.IssueLabels[1] = []*github.Label{needsTriageLabel} + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: "/triage valid", + ChangedFiles: []string{}, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + // Check that needs-triage was removed and triage/valid was applied + removedLabels := client.RemovedLabels[1] + appliedLabels := client.AppliedLabels[1] + + // Should remove needs-triage and apply triage/valid (wildcard removal may not trigger) + if !sliceContains(removedLabels, "needs-triage") { + t.Errorf("Expected 'needs-triage' to be removed, got: %v", removedLabels) + } + + expectedApplied := []string{"triage/valid"} + if !slicesEqual(appliedLabels, expectedApplied) { + t.Errorf("Expected applied labels %v, got: %v", expectedApplied, appliedLabels) + } +} + +func TestLabeler_ProcessMatchRule_TagCommand(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfig() + labeler := NewLabeler(client, config) + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: "/tag developer-experience", + ChangedFiles: []string{}, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + // Check that both needs-triage and tag/developer-experience were applied + appliedLabels := client.AppliedLabels[1] + + if !sliceContains(appliedLabels, "tag/developer-experience") { + t.Errorf("Expected 'tag/developer-experience' label to be applied, got: %v", appliedLabels) + } + if !sliceContains(appliedLabels, "needs-triage") { + t.Errorf("Expected 'needs-triage' label to be applied, got: %v", appliedLabels) + } +} + +func TestLabeler_ProcessLabelRule_NeedsTriage(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfig() + labeler := NewLabeler(client, config) + + // Issue has no triage labels + client.IssueLabels[1] = []*github.Label{} // Explicitly set to empty + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: "", + ChangedFiles: []string{}, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + // Check that needs-triage was applied + appliedLabels := client.AppliedLabels[1] + if !sliceContains(appliedLabels, "needs-triage") { + t.Errorf("Expected 'needs-triage' label to be applied, got: %v", appliedLabels) + } +} + +func TestLabeler_ProcessLabelRule_HasTriageLabel(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfig() + labeler := NewLabeler(client, config) + + // Issue already has a triage label + triageLabel := &github.Label{Name: stringPtr("triage/valid")} + client.IssueLabels[1] = []*github.Label{triageLabel} + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: "", + ChangedFiles: []string{}, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + // Check that needs-triage was NOT applied + appliedLabels := client.AppliedLabels[1] + if sliceContains(appliedLabels, "needs-triage") { + t.Errorf("needs-triage should not be applied when triage label exists, but was applied: %v", appliedLabels) + } +} + +func TestLabeler_ProcessMatchRule_InvalidArgument(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfig() + labeler := NewLabeler(client, config) + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: "/tag invalid-tag", // Not in matchList + ChangedFiles: []string{}, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + // Check that only needs-triage was applied (no tag label due to invalid argument) + appliedLabels := client.AppliedLabels[1] + if !sliceContains(appliedLabels, "needs-triage") { + t.Errorf("Expected 'needs-triage' to be applied, got: %v", appliedLabels) + } + + // Should not contain any tag labels + for _, label := range appliedLabels { + if strings.HasPrefix(label, "tag/") { + t.Errorf("No tag labels should be applied for invalid argument, but found: %s", label) + } + } +} + +func TestLabeler_ProcessMatchRule_MultilineComment(t *testing.T) { + client := NewMockGitHubClient() + config := createTestConfig() + labeler := NewLabeler(client, config) + + req := &LabelRequest{ + Owner: "test-owner", + Repo: "test-repo", + IssueNumber: 1, + CommentBody: `This is a comment +/triage valid +And some more text`, + ChangedFiles: []string{}, + } + + ctx := context.Background() + err := labeler.ProcessRequest(ctx, req) + if err != nil { + t.Fatalf("ProcessRequest failed: %v", err) + } + + // Check that triage/valid was applied from multiline comment + appliedLabels := client.AppliedLabels[1] + found := false + for _, label := range appliedLabels { + if label == "triage/valid" { + found = true + break + } + } + if !found { + t.Errorf("Expected 'triage/valid' label to be applied from multiline comment, got: %v", appliedLabels) + } +} + +// Helper functions +func stringPtr(s string) *string { + return &s +} + +func slicesEqual(a, b []string) bool { + if len(a) != len(b) { + return false + } + for i, v := range a { + if v != b[i] { + return false + } + } + return true +} + +func sliceContains(slice []string, item string) bool { + for _, s := range slice { + if s == item { + return true + } + } + return false +} diff --git a/utilities/labeler/labels.yaml b/utilities/labeler/labels.yaml index 0ef14c1..7e666b6 100644 --- a/utilities/labeler/labels.yaml +++ b/utilities/labeler/labels.yaml @@ -13,123 +13,132 @@ labels: - name: dd/adopters/complete description: DD Adopter Interviews have been completed color: 41cd40 +- name: dd/adopters/in-progress + description: DD Adopter Interviews are in progress + color: 61D6C3 - name: dd/adopters/not-started description: Adopter interviews have not yet been started color: d93f0b -- name: dd/adopters/in-progress - description: DD Adopter Interviews are in progress +- name: dd/gov-review/complete + description: DD Governance Review has been completed + color: 41cd40 +- name: dd/gov-review/in-progress + description: DD Governance Review is in progress color: 61D6C3 -- name: dd/complete +- name: dd/gov-review/not-started + description: Governance Review has not yet been started + color: d93f0b +- name: dd/needs-triage + description: DD application has not been reviewed + color: ededed +- name: dd/sec-review/complete + description: DD Security Review has been completed + color: 41cd40 +- name: dd/sec-review/in-progress + description: DD Security Review is in progress + color: 61D6C3 +- name: dd/sec-review/not-started + description: Security Review has not yet been started + color: d93f0b +- name: dd/status/complete description: DD has been completed color: 41cd40 -- name: dd/in-comment-period +- name: dd/status/in-comment-period description: DD is in the public comment period color: 0052cc -- name: dd/in-progress +- name: dd/status/in-progress description: DD is in progress color: 61D6C3 -- name: dd/in-voting +- name: dd/status/in-voting description: DD is currently in voting color: 74a0f4 -- name: dd/ready-for-assignment +- name: dd/status/ready-for-assignment description: DD Prerequisites complete; ready to be assigned to a TOC member. color: fbca04 -- name: dd/waiting +- name: dd/status/waiting description: DD has been paused and will pick up at a later date color: d93f01 -- name: dd/triage/needs-adopters - description: Additional adopters needed for DD application to be marked ready. - color: b60205 -- name: dd/triage/incomplete-application - description: Application incomplete; must be completed for application to be marked ready - color: b60205 -- name: dd/gov-review/not-started - description: Governance Review has not yet been started - color: d93f0b -- name: dd/gov-review/in-progress - description: DD Governance Review is in progress - color: 61D6C3 -- name: dd/gov-review/complete - description: DD Governance Review has been completed +- name: dd/tech-review/complete + description: DD Tech Review has been completed color: 41cd40 -- name: dd/sec-review/not-started - description: Security Review has not yet been started - color: d93f0b -- name: dd/sec-review/in-progress - description: DD Security Review is in progress +- name: dd/tech-review/in-progress + description: DD Tech Review is in progress color: 61D6C3 -- name: dd/sec-review/complete - description: DD Security Review has been completed - color: 41cd40 - name: dd/tech-review/not-started description: Tech Review has not yet been started color: d93f0b -- name: dd/tech-review/in-progress - description: DD Tech Review is in progress - color: 61D6C3 -- name: dd/tech-review/complete - description: DD Tech Review has been completed - color: 41cd40 -- name: dd/needs-triage - description: DD application has not been reviewed +- name: dd/triage/incomplete-application + description: Application incomplete; must be completed for application to be marked ready + color: b60205 +- name: dd/triage/needs-adopters + description: Additional adopters needed for DD application to be marked ready. + color: b60205 +- name: gitvote + description: '' + color: ededed +- name: gitvote/closed + description: '' + color: ededed +- name: gitvote/passed + description: '' color: ededed +- name: help wanted + description: '' + color: '159818' +- name: init/complete + description: Initiative has been completed + color: 41cd40 +- name: init/in-progress + description: Initiative is in progress and actively being worked on + color: 61D6C3 +- name: init/not-started + description: Initiative has been accepted, but not started (in the backlog) + color: d93f0b +- name: init/stale + description: Initiative is no longer actively being worked on + color: b60205 - name: kind/dd description: Project DD or item related to the DD process color: 61D6C3 -- name: kind/initiative - description: An initiative or an item related to imitative processes - color: 61D6C3 - name: kind/docs description: Docs related changes or updates color: 61D6C3 +- name: kind/enhancement + description: General items related to enhancements or improvements. + color: 61D6C3 +- name: kind/initiative + description: An initiative or an item related to imitative processes + color: 61D6C3 - name: kind/meeting description: Item related to a meeting color: 61D6C3 -- name: kind/enhancement - description: General items related to enhancements or improvements. +- name: kind/review + description: Item related to a governance, tech, or other review color: 61D6C3 - name: kind/subproject description: Item related to a subproject or subproject related process color: 61D6C3 -- name: level/graduation - description: Item related to a graduation level project or the graduation criteria/process itself - color: 0052cc - name: level/archived description: Item related to an archived level project or the archive criteria/process itself color: d93f0b +- name: level/graduation + description: Item related to a graduation level project or the graduation criteria/process itself + color: 0052cc - name: level/incubation description: Item related to an incubation level project or the incubation criteria/process itself color: ed0e81 - name: level/sandbox description: Item related to a sandbox level project or the sandbox criteria/process itself color: e884e1 -- name: init/not-started - description: Initiative has been accepted, but not started (in the backlog) - color: d93f0b -- name: init/in-progress - description: Initiative is in progress and actively being worked on - color: 61D6C3 -- name: init/complete - description: Initiative has been completed - color: 41cd40 -- name: init/stale - description: Initiative is no longer actively being worked on - color: b60205 -- name: triage/needs-information - description: Needs additional information provided before it can be worked on - color: b60205 -- name: triage/duplicate - description: Duplicate issue or PR, can be closed - color: ebf84a -- name: triage/not-planned - description: Out of scope, or not planned to be worked on - color: d93f0b -- name: triage/valid - description: Issue or PR is valid with enough information to be actionable - color: 0e8a16 -- name: help wanted - description: '' - color: '159818' +- name: needs-group + description: Indicates an issue or PR that has not been assigned a group (toc or tag/foo label applied) + color: ededed +- name: needs-kind + description: Indicates an issue or PR that is missing an issue type or kind (a kind/foo label) + color: ededed +- name: needs-triage + description: Indicates an issue or PR that has not been triaged yet (has a 'triage/foo' label applied) + color: ededed - name: review/governance description: Project Governance Review color: '5319e7' @@ -145,39 +154,12 @@ labels: - name: sub/contributor-strategy-and-advocacy description: TOC Contributor Strategy and Advocacy SubProject color: 924F23 -- name: sub/project-review - description: TOC Project Review Subproject - color: '3E4469' - name: sub/mentoring description: TOC Mentoring Subproject color: 6DCC2C -- name: toc/initiative/AI - description: TOC Artificial Intelligence Initiative - color: d4c5f9 -- name: toc - description: toc specific issue - color: CF0CBE -- name: gitvote - description: '' - color: ededed -- name: gitvote/closed - description: '' - color: ededed -- name: gitvote/passed - description: '' - color: ededed -- name: vote open - description: '' - color: ededed -- name: vote/open - description: An election is open - color: 0e8a16 -- name: vote/closed - description: An election that has been completed - color: 5c2908 -- name: vote/nomination - description: A nomination or call for nominations - color: E00EEF +- name: sub/project-reviews + description: TOC Project Review Subproject + color: '3E4469' - name: tag/developer-experience description: TAG Developer Experience color: c2e0c6 @@ -193,101 +175,640 @@ labels: - name: tag/workloads-foundation description: TAG Workloads Foundation color: 6AE2DC +- name: toc + description: toc specific issue + color: CF0CBE +- name: toc/initiative/AI + description: TOC Artificial Intelligence Initiative + color: d4c5f9 +- name: triage/duplicate + description: Duplicate issue or PR, can be closed + color: ebf84a +- name: triage/needs-information + description: Needs additional information provided before it can be worked on + color: b60205 +- name: triage/not-planned + description: Out of scope, or not planned to be worked on + color: d93f0b +- name: triage/valid + description: Issue or PR is valid with enough information to be actionable + color: 0e8a16 +- name: vote open + description: '' + color: ededed +- name: vote/closed + description: An election that has been completed + color: 5c2908 +- name: vote/nomination + description: A nomination or call for nominations + color: E00EEF +- name: vote/open + description: An election is open + color: 0e8a16 -# each rule should be evaluated to determine what the labels should look like -# and ONLY apply them if there is a difference. This will prevent removal of -# labels in between steps (e.g. ensure there is only one namespaced one present) ruleset: + +############################################################################## +# Remove command rules +# +# Rules are executed in order, to be evaluated correctly removals rules need +# to be executed first. +############################################################################## + +- name: remove-dd-triage + kind: match + spec: + command: /remove-dd/triage + actions: + - kind: remove-label + spec: + match: dd/{{ argv.0 }} + +- name: remove-help + kind: match + spec: + command: /remove-help + actions: + - kind: remove-label + spec: + match: "help wanted" + +- name: remove-init + kind: match + spec: + command: /remove-init + actions: + - kind: remove-label + spec: + match: init/{{ argv.0 }} + +- name: remove-kind + kind: match + spec: + command: /remove-kind + actions: + - kind: remove-label + spec: + match: kind/{{ argv.0 }} + +- name: remove-level + kind: match + spec: + command: /remove-level + actions: + - kind: remove-label + spec: + match: level/{{ argv.0 }} + +- name: remove-review + kind: match + spec: + command: /remove-review + actions: + - kind: remove-label + spec: + match: review/{{ argv.0 }} + +- name: remove-sub + kind: match + spec: + command: /remove-sub + actions: + - kind: remove-label + spec: + match: sub/{{ argv.0 }} + +- name: remove-tag + kind: match + spec: + command: /remove-tag + actions: + - kind: remove-label + spec: + match: tag/{{ argv.0 }} + +- name: test + kind: filePath + spec: + matchPath: "utilities/labeler/*" + actions: + - kind: remove-label + spec: + match: needs-group + - kind: apply-label + spec: + label: toc + +# has to come before /toc otherwise it will try and match as "toc/{{argv.0}}" +- name: remove-toc-init + kind: match + spec: + command: /remove-toc/initiative + actions: + - kind: remove-label + spec: + match: toc/initiative/{{ argv.0 }} + +- name: remove-toc + kind: match + spec: + command: /remove-toc + actions: + - kind: remove-label + spec: + match: toc + +- name: remove-triage + kind: match + spec: + command: /remove-triage + actions: + - kind: remove-label + spec: + match: triage/{{ argv.0 }} + +- name: remove-vote + kind: match + spec: + command: /remove-vote + actions: + - kind: remove-label + spec: + match: vote/* + + +############################################################################## +# Label state rules & needs- rules +############################################################################## + +- name: needs-triage + kind: label + spec: + match: triage/* + matchCondition: NOT + actions: + - kind: apply-label + spec: + label: needs-triage + +- name: needs-kind + kind: label + spec: + match: kind/* + matchCondition: NOT + actions: + - kind: apply-label + spec: + label: needs-kind + +- name: needs-group + kind: label + spec: + match: "{toc,tag/*,sub/*}" + matchCondition: NOT + actions: + - kind: apply-label + spec: + label: "help wanted" + +- name: apply-kind + kind: match + spec: + command: /kind + rules: + - matchList: + - needs-kind + - kind/dd + - kind/docs + - kind/enhancement + - kind/initiative + - kind/meeting + - kind/review + - kind/subproject + actions: + - kind: remove-label + spec: + match: needs-kind + - kind: apply-label + spec: + label: kind/{{ argv.0 }} + +- name: apply-triage + kind: match + spec: + command: /triage + rules: + - matchList: + - needs-triage + - triage/valid + - triage/needs-information + - triage/duplicate + - triage/not-planned + actions: + - kind: remove-label + spec: + match: needs-triage + - kind: remove-label + spec: + match: triage/* + - kind: apply-label + spec: + label: triage/{{ argv.0 }} + + +############################################################################## +# Group (toc/tag/sub) apply commands +############################################################################## + - name: apply-tag kind: match spec: - command: "/tag" + command: /tag rules: - - matchList: # allow any items from this list, not unique - values: + - matchList: + - needs-group - tag/developer-experience - tag/infrastructure - tag/operational-resilience - tag/security-compliance - tag/workloads-foundation actions: - - kind: remove-label # removes label if present + - kind: remove-label spec: match: needs-group - kind: apply-label spec: - label: "tag/{{ argv.0 }}" # to match input from string + label: tag/{{ argv.0 }} - name: apply-toc kind: match spec: - command: "/toc" + command: /toc rules: - - match: "" + - match: "toc" actions: - - kind: remove-label # removes label if present + - kind: remove-label spec: match: needs-group - kind: apply-label spec: - label: "toc" + label: toc -# Applies needs-triage label if no triage related label is present -# this will account for conditions where a triage label is removed, the -# needs-triage label will be reapplied -- name: needs-triage - kind: label +- name: apply-sub + kind: match spec: - match: "triage/*" - matchCondition: NOT + command: /sub + rules: + - matchList: + - needs-group + - sub/contributor-strategy-and-advocacy + - sub/mentoring + - sub/project-reviews actions: + - kind: remove-label + spec: + match: needs-group - kind: apply-label spec: - label: "needs-triage" + label: sub/{{ argv.0 }} + +############################################################################## +# DD commands +############################################################################## - # Remove needs-triage label when a triage label is applied and ONLY allow - # a single triage label -- name: triage - kind: match # matches string response in issue/pr +# triage for DD only includes items that are 'blockers'. Any project that has a +# complete app, submitted adopters, and has completed their security assessment +# is ready to be evaluated and dd/status/ready-for-review + +- name: apply-dd-triage + kind: match spec: - command: "/triage" + command: /dd/triage rules: - - unique: # could also be a generic list with additional commands of in / not in or a regex match - ruleCondition: or # this applies to the whole rule, so it could match multiple rules as and/or - values: - - needs-triage - - triage/valid - - triage/needs-information - - triage/duplicate - - triage/not-planned - actions: # only executed if passed the rules - - kind: remove-label # removes label if present + - matchList: + - dd/triage/needs-triage + - dd/triage/needs-adopters + - dd/triage/incomplete-application + - dd/triage/needs-security-assessment + actions: + - kind: remove-label spec: - match: needs-triage - - kind: remove-label # ensures there is only a single triage label applied + match: dd/triage/needs-triage + - kind: apply-label spec: - match: "triage/*" + label: dd/triage/{{ argv.0 }} + +# when ready for assignment, clear previous triage state labels and status +- name: apply-dd-ready + kind: match + spec: + command: /dd/status + rules: + - match: ready-for-assignment + actions: + - kind: remove-label + spec: + match: dd/triage/* + - kind: apply-label + spec: + label: dd/status/ready-for-assignment + +- name: apply-dd-status + kind: match + spec: + command: /dd/status + rules: + - matchList: + - dd/status/ready-for-assignment + - dd/status/in-progress + - dd/status/in-comment-period + - dd/status/in-voting + - dd/status/complete + - dd/status/waiting + actions: + - kind: remove-label + spec: + match: dd/triage/* + - kind: remove-label + spec: + match: dd/status/* + - kind: apply-label + spec: + label: dd/adopters/not-started + - kind: apply-label + spec: + label: dd/gov-review/not-started + - kind: apply-label + spec: + label: dd/tech-review/not-started - kind: apply-label spec: - label: "triage/{{ argv.0 }}" # to match input from string + label: dd/sec-review/not-started + - kind: apply-label + spec: + label: dd/status/{{ argv.0 }} + +- name: apply-dd-adopters + kind: match + spec: + command: /dd/adopters + rules: + - matchList: + - dd/adopters/not-started + - dd/adopters/in-progress + - dd/adopters/complete + actions: + - kind: remove-label + spec: + match: dd/adopters/* + - kind: apply-label + spec: + label: dd/adopters/{{ argv.0 }} + +- name: apply-dd-gov-review + kind: match + spec: + command: /dd/gov-review + rules: + - matchList: + - dd/gov-review/not-started + - dd/gov-review/in-progress + - dd/gov-review/complete + actions: + - kind: remove-label + spec: + match: dd/gov-review/* + - kind: apply-label + spec: + label: dd/gov-review/{{ argv.0 }} + +- name: apply-dd-tech-review + kind: match + spec: + command: /dd/tech-review + rules: + - matchList: + - dd/tech-review/not-started + - dd/tech-review/in-progress + - dd/tech-review/complete + actions: + - kind: remove-label + spec: + match: dd/tech-review/* + - kind: apply-label + spec: + label: dd/tech-review/{{ argv.0 }} + +- name: apply-dd-sec-review + kind: match + spec: + command: /dd/sec-review + rules: + - matchList: + - dd/sec-review/not-started + - dd/sec-review/in-progress + - dd/sec-review/complete + actions: + - kind: remove-label + spec: + match: dd/sec-review/* + - kind: apply-label + spec: + label: dd/sec-review/{{ argv.0 }} + + +############################################################################## +# Initiatives +############################################################################## + +- name: apply-toc-init + kind: match + spec: + command: /toc/initiative + rules: + - matchList: + - toc/initiative/AI + actions: + - kind: apply-label + spec: + label: toc/initiative/{{ argv.0 }} + + +- name: apply-init-lifecycle + kind: match + spec: + command: /init + rules: + - matchList: + - init/not-started + - init/in-progress + - init/complete + - init/stale + actions: + - kind: remove-label + spec: + match: init/* + - kind: apply-label + spec: + label: init/{{ argv.0 }} + + +############################################################################## +# Vote / Election Commands +############################################################################## + +- name: vote-open-closed + kind: match + spec: + command: /vote + rules: + - matchList: + - vote/open + - vote/closed + actions: + - kind: remove-label + spec: + match: vote/open + - kind: remove-label + spec: + match: vote/closed + - kind: apply-label + spec: + label: vote/{{ argv.0 }} + +- name: vote-nomination + kind: match + spec: + command: /vote + rules: + - match: nomination + actions: + - kind: apply-label + spec: + label: vote/nomination + + +############################################################################## +# Misc Commands +############################################################################## + +- name: help + kind: match + spec: + command: /help + rules: + - match: "" + actions: + - kind: apply-label + spec: + label: "help wanted" + + +############################################################################## +# filepath rules +# These need to come first as they are strict on applying labels by path +############################################################################## + - name: charter kind: filePath spec: matchPath: "tags/*/charter.md" actions: + - kind: remove-label + spec: + match: needs-group - kind: apply-label spec: label: toc - -- name: tag-foo +- name: tag-developer-experience-dir + kind: filePath + spec: + matchPath: "tags/tag-developer-experience/*" + actions: + - kind: remove-label + spec: + match: needs-group + - kind: apply-label + spec: + label: tag/developer-experience +- name: tag-infrastructure-dir + kind: filePath + spec: + matchPath: "tags/tag-infrastructure/*" + actions: + - kind: remove-label + spec: + match: needs-group + - kind: apply-label + spec: + label: tag/infrastructure +- name: tag-operational-resilience-dir + kind: filePath + spec: + matchPath: "tags/tag-operational-resilience/*" + actions: + - kind: remove-label + spec: + match: needs-group + - kind: apply-label + spec: + label: tag/operational-resilience +- name: tag-security-and-compliance-dir kind: filePath spec: - matchPath: tags/tag-foo/* + matchPath: "tags/tag-security-and-compliance/*" actions: + - kind: remove-label + spec: + match: needs-group + - kind: apply-label + spec: + label: tag/security-and-compliance +- name: tag-workloads-foundation-dir + kind: filePath + spec: + matchPath: "tags/tag-workloads-foundation/*" + actions: + - kind: remove-label + spec: + match: needs-group + - kind: apply-label + spec: + label: tag/workloads-foundation +- name: sub-contrib-strat-dir + kind: filePath + spec: + matchPath: "toc_subprojects/contributor-strategy-and-advocacy/*" + actions: + - kind: remove-label + spec: + match: needs-group + - kind: apply-label + spec: + label: sub/contributor-strategy-and-advocacy +- name: sub-mentoring-dir + kind: filePath + spec: + matchPath: "toc_subprojects/mentoring/*" + actions: + - kind: remove-label + spec: + match: needs-group + - kind: apply-label + spec: + label: sub/mentoring +- name: sub-project-reviews-dir + kind: filePath + spec: + matchPath: "toc_subprojects/project-reviews/*" + actions: + - kind: remove-label + spec: + match: needs-group - kind: apply-label spec: - label: tag-foo \ No newline at end of file + label: sub/project-reviews \ No newline at end of file diff --git a/utilities/labeler/main.go b/utilities/labeler/main.go index 4824ee3..617dc12 100644 --- a/utilities/labeler/main.go +++ b/utilities/labeler/main.go @@ -5,92 +5,10 @@ import ( "flag" "fmt" "log" - "net/http" "os" - "path/filepath" - "slices" "strings" - - "github.com/google/go-github/v55/github" - "golang.org/x/oauth2" - yaml "gopkg.in/yaml.v3" ) -// LabelConfig represents the structure of labels.yaml -// Only the fields needed for logic are included here - -type Label struct { - Name string `yaml:"name"` - Color string `yaml:"color"` - Description string `yaml:"description"` - Previously []struct { - Name string `yaml:"name"` - } `yaml:"previously"` -} - -type ActionSpec struct { - Match string `yaml:"match,omitempty"` - Label string `yaml:"label,omitempty"` -} - -type Action struct { - Kind string `yaml:"kind"` - Spec ActionSpec `yaml:"spec,omitempty"` -} - -type RuleSpec struct { - Command string `yaml:"command,omitempty"` - Rules []interface{} `yaml:"rules,omitempty"` - Match string `yaml:"match,omitempty"` - MatchCondition string `yaml:"matchCondition,omitempty"` - MatchPath string `yaml:"matchPath,omitempty"` - MatchList []string `yaml:"matchList,omitempty"` -} - -type Rule struct { - Name string `yaml:"name"` - Kind string `yaml:"kind"` - Spec RuleSpec `yaml:"spec"` - Actions []Action `yaml:"actions"` -} - -type LabelsYAML struct { - Labels []Label `yaml:"labels"` - Ruleset []Rule `yaml:"ruleset"` - AutoCreate bool `yaml:"autoCreateLabels"` - AutoDelete bool `yaml:"autoDeleteLabels"` - DefinitionRequired bool `yaml:"definitionRequired"` - Debug bool `yaml:"debug"` -} - -func loadConfigFromURL(url string) (*LabelsYAML, error) { - resp, err := http.Get(url) - if err != nil { - return nil, fmt.Errorf("failed to fetch labels.yaml from URL: %v", err) - } - defer resp.Body.Close() - - if resp.StatusCode != http.StatusOK { - return nil, fmt.Errorf("failed to fetch labels.yaml: HTTP %d", resp.StatusCode) - } - - var cfg LabelsYAML - dec := yaml.NewDecoder(resp.Body) - if err := dec.Decode(&cfg); err != nil { - return nil, fmt.Errorf("failed to decode labels.yaml: %v", err) - } - return &cfg, nil -} - -func githubClient() (*github.Client, error) { - token := os.Getenv("GITHUB_TOKEN") - if token == "" { - return nil, fmt.Errorf("GITHUB_TOKEN environment variable not set") - } - ts := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: token}) - return github.NewClient(oauth2.NewClient(context.Background(), ts)), nil -} - func main() { flag.Parse() @@ -109,355 +27,48 @@ func main() { log.Fatal("labels URL not set") } - cfg, err := loadConfigFromURL(labelsURL) + cfg, err := LoadConfigFromURL(labelsURL) if err != nil { log.Fatalf("failed to load labels.yaml: %v", err) } - client, err := githubClient() + token := os.Getenv("GITHUB_TOKEN") + client, err := CreateGitHubClient(token) if err != nil { log.Fatalf("failed to create GitHub client: %v", err) } - ctx := context.Background() - - if cfg.AutoDelete { - deleteUndefinedLabels(ctx, client, owner, repo, cfg) - } - if cfg.AutoCreate { - for _, label := range cfg.Labels { - color, description, labelName := getLabelDefinition(cfg, label.Name) - if err := ensureLabelExists(ctx, client, owner, repo, labelName, color, description, cfg); err != nil { - log.Printf("skipping label %s due to error: %v", labelName, err) - continue - } - } - } + labeler := NewLabeler(client, cfg) - // Parse changed files if provided - log.Printf("Changed files: %s", changedFiles) var files []string if changedFiles != "" { files = strings.Split(changedFiles, ",") } - log.Printf("Parsed changed files: %v", files) - - issue, _, err := client.Issues.Get(ctx, owner, repo, toInt(issueNum)) - if err != nil { - log.Fatalf("failed to fetch issue: %v", err) - } - fmt.Printf("Issue #%d: %s\n", *issue.Number, *issue.Title) - lines := strings.Split(commentBody, "\n") - - for _, rule := range cfg.Ruleset { - if rule.Kind == "filePath" { - if len(files) == 0 { - if cfg.Debug { - log.Printf("No changed files to process for rule %s", rule.Name) - } - continue - } - for _, file := range files { - matched, err := filepath.Match(rule.Spec.MatchPath, file) - if err != nil { - log.Printf("error matching file path: %v", err) - continue - } - if matched || (rule.Spec.MatchCondition == "NOT" && !matched) { - for _, action := range rule.Actions { - label := renderLabel(action.Spec.Label, nil) - switch action.Kind { - case "apply-label": - applyLabel(ctx, client, owner, repo, toInt(issueNum), label, cfg) - case "remove-label": - if label != "" { - removeLabel(ctx, client, owner, repo, toInt(issueNum), label) - } - } - } - } - } - } - - if rule.Spec.Command != "" { - if !strings.HasPrefix(rule.Spec.Command, "/") { - log.Printf("Command `%s` does not start with a forward slash, skipping", rule.Spec.Command) - continue - } - for _, line := range lines { - line = strings.TrimSpace(line) - if strings.HasPrefix(line, rule.Spec.Command) { - parts := strings.Fields(line) - log.Printf("%v", parts) - argv := []string{} - if len(parts) > 1 { - argv = parts[1:] - } - /* - for key, arg := range argv { - // Handle namespace prefix - slashIndex := strings.Index(arg, "/") - if slashIndex == -1 { - log.Printf("Argument `%s` does not contain a namespace, skipping", arg) - continue - } - namespace := arg[:slashIndex] - fullArg := fmt.Sprintf("%s/%s", namespace, arg[slashIndex+1:]) - log.Printf("Checking full argument `%s` against command %s", fullArg, rule.Spec.Command) - - argv[key] = fullArg // Replace argv[0] with full argument - } - */ - if len(rule.Spec.MatchList) > 0 { - // Validate argv.0 against matchList - valid := slices.Contains(rule.Spec.MatchList, argv[0]) - if !valid { - log.Printf("Invalid argument `%s` for command %s", argv[0], rule.Spec.Command) - continue - } - } - - for _, action := range rule.Actions { - label := "" - if action.Spec.Label != "" { - label = renderLabel(action.Spec.Label, argv) - } - if action.Spec.Match != "" { - // Handle match condition for action - if cfg.Debug { - log.Printf("Checking action label `%s` against Spec.Match `%s` and Argv `%v`", action.Spec.Label, action.Spec.Match, argv) - } - label = renderLabel(action.Spec.Match, argv) - if !cfg.isValidLabel(label) && !strings.Contains(label, "/*") { - log.Printf("Label `%s` is not defined in labels.yaml", label) - continue - } - } - - switch action.Kind { - case "apply-label": - applyLabel(ctx, client, owner, repo, toInt(issueNum), label, cfg) - case "remove-label": - if label != "" { - removeLabel(ctx, client, owner, repo, toInt(issueNum), label) - } - } - } - } - } - } else if rule.Kind == "label" { - // Handle default namespaced label logic dynamically - namespacePattern := rule.Spec.Match - existingLabels, _, err := client.Issues.ListLabelsByIssue(ctx, owner, repo, toInt(issueNum), nil) - if err != nil { - log.Printf("failed to fetch labels for issue: %v", err) - continue - } - - foundNamespace := false - for _, lbl := range existingLabels { - matched, _ := filepath.Match(namespacePattern, lbl.GetName()) - if matched { - foundNamespace = true - break - } - } - - if !foundNamespace || rule.Spec.MatchCondition == "NOT" { - for _, action := range rule.Actions { - label := renderLabel(action.Spec.Label, nil) - switch action.Kind { - case "apply-label": - applyLabel(ctx, client, owner, repo, toInt(issueNum), label, cfg) - case "remove-label": - if label != "" { - removeLabel(ctx, client, owner, repo, toInt(issueNum), label) - } - } - } - } - } - } -} - -func deleteUndefinedLabels(ctx context.Context, client *github.Client, owner, repo string, cfg *LabelsYAML) { - existingLabels, _, err := client.Issues.ListLabels(ctx, owner, repo, nil) - if err != nil { - log.Printf("failed to fetch existing labels: %v", err) - return - } - definedLabels := map[string]bool{} - for _, label := range cfg.Labels { - definedLabels[label.Name] = true - for _, prev := range label.Previously { - definedLabels[prev.Name] = true - } - } - - for _, lbl := range existingLabels { - if !definedLabels[lbl.GetName()] { - log.Printf("deleting undefined label: %s", lbl.GetName()) - _, err := client.Issues.DeleteLabel(ctx, owner, repo, lbl.GetName()) - if err != nil { - log.Printf("failed to delete label %s: %v", lbl.GetName(), err) - } - } - } -} - -// renderLabel replaces {{ argv.0 }} etc. in label templates -func renderLabel(template string, argv []string) string { - label := template - for i, v := range argv { - label = strings.ReplaceAll(label, fmt.Sprintf("{{ argv.%d }}", i), v) - } - return label -} - -func ensureLabelExists(ctx context.Context, client *github.Client, owner, repo, labelName, color, description string, cfg *LabelsYAML) error { - if !cfg.AutoCreate { - return fmt.Errorf("label %s does not exist and auto-create-labels is disabled", labelName) - } - - lbl, _, err := client.Issues.GetLabel(ctx, owner, repo, labelName) - if err != nil { - // Create the label if it doesn't exist - lbl, _, err = client.Issues.CreateLabel(ctx, owner, repo, &github.Label{ - Name: &labelName, - Color: &color, - Description: &description, - }) - if err != nil { - return fmt.Errorf("failed to create label %s: %v", labelName, err) - } - } - - // Update label if color or description differs - if lbl.GetColor() != color || lbl.GetDescription() != description { - _, _, err := client.Issues.EditLabel(ctx, owner, repo, labelName, &github.Label{ - Name: &labelName, - Color: &color, - Description: &description, - }) - if err != nil { - return fmt.Errorf("failed to update label %s: %v", labelName, err) - } - } - if cfg.Debug { - log.Printf("label %s exists", labelName) - } - return nil -} - -func (cfg *LabelsYAML) isValidLabel(label string) bool { - for _, lbl := range cfg.Labels { - if lbl.Name == label { - return true - } - } - return false -} - -func getLabelDefinition(cfg *LabelsYAML, labelName string) (string, string, string) { - for _, label := range cfg.Labels { - if label.Name == labelName { - return label.Color, label.Description, label.Name - } - for _, prev := range label.Previously { - if prev.Name == labelName { - return label.Color, label.Description, label.Name - } - } - } - if cfg.DefinitionRequired { - log.Printf("label %s is not defined in labels.yaml, but auto-create is disabled", labelName) - return "", "", "" // Return empty if not required - } - return "000000", "Automatically applied label", labelName // Default values -} - -func applyLabel(ctx context.Context, client *github.Client, owner, repo string, issueNum int, label string, cfg *LabelsYAML) { - fmt.Printf("Applying label: %s\n", label) - - // Get current labels for the issue - existingLabels, _, err := client.Issues.ListLabelsByIssue(ctx, owner, repo, issueNum, nil) + issueNumber, err := toInt(issueNum) if err != nil { - log.Printf("failed to fetch labels for issue: %v", err) - return - } - - // Check if the label is already applied - for _, lbl := range existingLabels { - if lbl.GetName() == label { - log.Printf("label %s is already applied, skipping", label) - return - } - } - - // Get label definition from config - color, description, resolvedLabel := getLabelDefinition(cfg, label) - - if resolvedLabel == "" { - log.Printf("label %s is not defined in labels.yaml and auto-create is disabled", - label) - return - } - - // Ensure the label exists with the defined color and description - if err := ensureLabelExists(ctx, client, owner, repo, resolvedLabel, color, description, cfg); err != nil { - log.Printf("skipping label %s due to error: %v", resolvedLabel, err) - return + log.Fatalf("invalid issue number: %v", err) } - _, _, err = client.Issues.AddLabelsToIssue(ctx, owner, repo, issueNum, []string{resolvedLabel}) - if err != nil { - log.Printf("failed to apply label %s: %v", resolvedLabel, err) + req := &LabelRequest{ + Owner: owner, + Repo: repo, + IssueNumber: issueNumber, + CommentBody: commentBody, + ChangedFiles: files, } -} - -func removeLabel(ctx context.Context, client *github.Client, owner, repo string, issueNum int, label string) { - fmt.Printf("Removing label: %s\n", label) - // Get current labels for the issue - existingLabels, _, err := client.Issues.ListLabelsByIssue(ctx, owner, repo, issueNum, nil) - if err != nil { - log.Printf("failed to fetch labels for issue: %v", err) - return - } - - // Check if the label is not applied - labelFound := false - for _, lbl := range existingLabels { - if strings.Contains(label, "/*") { - // Handle wildcard removal - if strings.HasPrefix(lbl.GetName(), strings.TrimSuffix(label, "*")) { - removeLabel(ctx, client, owner, repo, issueNum, lbl.GetName()) - } - } - if lbl.GetName() == label { - labelFound = true - break - } - } - - if !labelFound { - log.Printf("label %s is not applied, skipping removal", label) - return - } - - _, err = client.Issues.RemoveLabelForIssue(ctx, owner, repo, issueNum, label) - if err != nil { - log.Printf("failed to remove label %s: %v", label, err) + ctx := context.Background() + if err := labeler.ProcessRequest(ctx, req); err != nil { + log.Fatalf("failed to process request: %v", err) } } -func toInt(s string) int { - n, err := fmt.Sscanf(s, "%d", new(int)) +func toInt(s string) (int, error) { + var i int + n, err := fmt.Sscanf(s, "%d", &i) if err != nil || n != 1 { - log.Fatalf("invalid issue number: %s", s) + return 0, fmt.Errorf("invalid number: %s", s) } - var i int - fmt.Sscanf(s, "%d", &i) - return i + return i, nil } diff --git a/utilities/labeler/types.go b/utilities/labeler/types.go new file mode 100644 index 0000000..415df47 --- /dev/null +++ b/utilities/labeler/types.go @@ -0,0 +1,51 @@ +package main + +// Label represents a label definition +type Label struct { + Name string `yaml:"name"` + Color string `yaml:"color"` + Description string `yaml:"description"` + Previously []struct { + Name string `yaml:"name"` + } `yaml:"previously"` +} + +// ActionSpec represents action specifications +type ActionSpec struct { + Match string `yaml:"match,omitempty"` + Label string `yaml:"label,omitempty"` +} + +// Action represents an action to take +type Action struct { + Kind string `yaml:"kind"` + Spec ActionSpec `yaml:"spec,omitempty"` +} + +// RuleSpec represents rule specifications +type RuleSpec struct { + Command string `yaml:"command,omitempty"` + Rules []interface{} `yaml:"rules,omitempty"` + Match string `yaml:"match,omitempty"` + MatchCondition string `yaml:"matchCondition,omitempty"` + MatchPath string `yaml:"matchPath,omitempty"` + MatchList []string `yaml:"matchList,omitempty"` +} + +// Rule represents a labeling rule +type Rule struct { + Name string `yaml:"name"` + Kind string `yaml:"kind"` + Spec RuleSpec `yaml:"spec"` + Actions []Action `yaml:"actions"` +} + +// LabelsYAML represents the complete configuration +type LabelsYAML struct { + Labels []Label `yaml:"labels"` + Ruleset []Rule `yaml:"ruleset"` + AutoCreate bool `yaml:"autoCreateLabels"` + AutoDelete bool `yaml:"autoDeleteLabels"` + DefinitionRequired bool `yaml:"definitionRequired"` + Debug bool `yaml:"debug"` +}