diff --git a/internal/labels/labeler.go b/internal/labels/labeler.go index a18a315..eba0c07 100644 --- a/internal/labels/labeler.go +++ b/internal/labels/labeler.go @@ -88,6 +88,102 @@ func (l *Labeler) RequireApproval() { l.requireApproval = true } +// Run runs a single round of labeling to GitHub. +// It scans all open issues that have been created since the last call to [Labeler.Run] +// using a Labeler with the same name (see [New]). +// TODO(jba): more doc +func (l *Labeler) Run(ctx context.Context) error { + l.slog.Info("labels.Labeler start", "name", l.name, "label", l.label, "latest", l.watcher.Latest()) + defer func() { + l.slog.Info("labels.Labeler end", "name", l.name, "latest", l.watcher.Latest()) + }() + + // Ensure that labels in GH match our config. + for p := range l.projects { + if err := l.syncLabels(ctx, p); err != nil { + return err + } + } + // TODO(jba): finish implementation. + return nil +} + +func (l *Labeler) syncLabels(ctx context.Context, project string) error { + // TODO(jba): generalize to other projects. + if project != "golang/go" { + return errors.New("labeling only supported for golang/go") + } + l.slog.Info("syncing labels", "name", l.name, "project", project) + return syncLabels(ctx, l.slog, config.Categories, ghLabels{l.github, project}) +} + +// trackerLabels manipulates the set of labels on an issue tracker. +// TODO: remove dependence on GitHub. +type trackerLabels interface { + CreateLabel(ctx context.Context, lab github.Label) error + EditLabel(ctx context.Context, name string, changes github.LabelChanges) error + ListLabels(ctx context.Context) ([]github.Label, error) +} + +type ghLabels struct { + gh *github.Client + project string +} + +func (g ghLabels) CreateLabel(ctx context.Context, lab github.Label) error { + return g.gh.CreateLabel(ctx, g.project, lab) +} + +func (g ghLabels) EditLabel(ctx context.Context, name string, changes github.LabelChanges) error { + return g.gh.EditLabel(ctx, g.project, name, changes) +} + +func (g ghLabels) ListLabels(ctx context.Context) ([]github.Label, error) { + return g.gh.ListLabels(ctx, g.project) +} + +// labelColor is the color of labels created by syncLabels. +const labelColor = "4d0070" + +// syncLabels attempts to reconcile the labels in cats with the labels on the issue tracker, +// modifying the issue tracker's labels to match. +// If a label in cats is not on the issue tracker, it is created. +// Otherwise, if the label description on the issue tracker is empty, it is set to the description in the Category. +// Otherwise, if the descriptions don't agree, a warning is logged and nothing is done on the issue tracker. +// This function makes no other changes. In particular, it never deletes labels. +func syncLabels(ctx context.Context, lg *slog.Logger, cats []Category, tl trackerLabels) error { + tlabList, err := tl.ListLabels(ctx) + if err != nil { + return err + } + tlabs := map[string]github.Label{} + for _, lab := range tlabList { + tlabs[lab.Name] = lab + } + + for _, cat := range cats { + lab, ok := tlabs[cat.Label] + if !ok { + lg.Info("creating label", "label", lab.Name) + if err := tl.CreateLabel(ctx, github.Label{ + Name: cat.Label, + Description: cat.Description, + Color: labelColor, + }); err != nil { + return err + } + } else if lab.Description == "" { + lg.Info("setting empty label description", "label", lab.Name) + if err := tl.EditLabel(ctx, lab.Name, github.LabelChanges{Description: cat.Description}); err != nil { + return err + } + } else if lab.Description != cat.Description { + lg.Warn("descriptions disagree", "label", lab.Name) + } + } + return nil +} + type actioner struct { l *Labeler } diff --git a/internal/labels/labeler_test.go b/internal/labels/labeler_test.go new file mode 100644 index 0000000..a0f3f06 --- /dev/null +++ b/internal/labels/labeler_test.go @@ -0,0 +1,77 @@ +// Copyright 2024 The Go Authors. All rights reserved. +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. + +package labels + +import ( + "context" + "errors" + "maps" + "slices" + "testing" + + "golang.org/x/oscar/internal/github" + "golang.org/x/oscar/internal/testutil" +) + +func TestSyncLabels(t *testing.T) { + m := map[string]github.Label{ + "A": {Name: "A", Description: "a", Color: "a"}, + "B": {Name: "B", Description: "", Color: "b"}, + "C": {Name: "C", Description: "c", Color: "c"}, + "D": {Name: "D", Description: "d", Color: "d"}, + } + cats := []Category{ + {Label: "A", Description: "a"}, // same as tracker + {Label: "B", Description: "b"}, // set empty tracker description + {Label: "C", Description: "other"}, // different descriptions + // D in tracker but not in cats + {Label: "E", Description: "e"}, // create + } + tl := &testTrackerLabels{m} + + if err := syncLabels(context.Background(), testutil.Slogger(t), cats, tl); err != nil { + t.Fatal(err) + } + + want := map[string]github.Label{ + "A": {Name: "A", Description: "a", Color: "a"}, + "B": {Name: "B", Description: "b", Color: "b"}, // added B description + "C": {Name: "C", Description: "c", Color: "c"}, + "D": {Name: "D", Description: "d", Color: "d"}, + "E": {Name: "E", Description: "e", Color: labelColor}, // added E + } + + if got := tl.m; !maps.Equal(got, want) { + t.Errorf("\ngot %v\nwant %v", got, want) + } +} + +type testTrackerLabels struct { + m map[string]github.Label +} + +func (t *testTrackerLabels) CreateLabel(ctx context.Context, lab github.Label) error { + if _, ok := t.m[lab.Name]; ok { + return errors.New("label exists") + } + t.m[lab.Name] = lab + return nil +} + +func (t *testTrackerLabels) ListLabels(ctx context.Context) ([]github.Label, error) { + return slices.Collect(maps.Values(t.m)), nil +} + +func (t *testTrackerLabels) EditLabel(ctx context.Context, name string, changes github.LabelChanges) error { + if changes.NewName != "" || changes.Color != "" { + return errors.New("unsupported edit") + } + if lab, ok := t.m[name]; ok { + lab.Description = changes.Description + t.m[name] = lab + return nil + } + return errors.New("not found") +} diff --git a/internal/labels/static/categories.yaml b/internal/labels/static/categories.yaml index 7ca595d..a2b3155 100644 --- a/internal/labels/static/categories.yaml +++ b/internal/labels/static/categories.yaml @@ -7,11 +7,11 @@ # with the description categories: - name: bug - label: BUGLABEL + label: Bug description: "Issues describing a bug in the Go implementation." - name: languageProposal - label: LANGPROPLABEL + label: LanguageProposal description: Issues describing a requested change to the Go language specification. extra: | This should be used for any notable change or addition to the language. @@ -20,7 +20,7 @@ categories: changes in existing functionality. - name: libraryProposal - label: LIBPROPLABEL + label: LibraryProposal description: Issues describing a requested change to the Go standard library or x/ libraries, but not to a tool extra: | This should be used for any notable change or addition to the libraries. @@ -33,7 +33,7 @@ categories: and probably a GODEBUG setting. - name: toolProposal - label: TOOLPROPLABEL + label: ToolProposal description: Issues describing a requested change to a Go tool or command-line program. extra: | This should be used for any notable change or addition to the tools. @@ -47,11 +47,11 @@ categories: This does NOT includ changes to tools in x repos, like gopls, or third-party tools. - name: implementation - label: IMPLABEL + label: Implementation description: Issues describing a semantics-preserving change to the Go implementation. - name: accessRequest - label: accessRequestLABEL + label: AccessRequest description: Issues requesting builder or gomote access. - name: pkgsiteRemovalRequest @@ -60,11 +60,11 @@ categories: # We don't label issues posted by gopherbot, so this label is probably unnecessary. - name: automation - label: automationLABEL + label: Automation description: Issues created by gopherbot or watchflakes automation. - name: backport - label: backportLABEL + label: Backport description: Issues created for requesting a backport of a change to a previous Go version. - name: builders @@ -72,7 +72,7 @@ categories: description: x/build issues (builders, bots, dashboards) - name: question - label: questionLABEL + label: Question description: Issues that are questions about using Go. # It may be too challenging for the LLM to decide is something is WAI. Consider removing this. @@ -81,7 +81,7 @@ categories: description: Issues describing something that is working as it is supposed to. - name: featureRequest - label: FeatureRequestLABEL + label: FeatureRequest description: Issues asking for a new feature that does not need a proposal. - name: documentation @@ -90,10 +90,10 @@ categories: # The LLM never seems to pick invalid. - name: invalid - label: invalidLABEL + label: Invalid description: Issues that are empty, incomplete, or spam. # The LLM never seems to pick other. - name: other - label: otherLABEL + label: Other description: None of the above.