diff --git a/internal/labels/labeler.go b/internal/labels/labeler.go index 45e7d5c..6fbb9cc 100644 --- a/internal/labels/labeler.go +++ b/internal/labels/labeler.go @@ -13,8 +13,10 @@ import ( "golang.org/x/oscar/internal/actions" "golang.org/x/oscar/internal/github" + "golang.org/x/oscar/internal/llm" "golang.org/x/oscar/internal/storage" "golang.org/x/oscar/internal/storage/timed" + "rsc.io/ordered" ) // A Labeler labels GitHub issues. @@ -22,6 +24,7 @@ type Labeler struct { slog *slog.Logger db storage.DB github *github.Client + cgen llm.ContentGenerator projects map[string]bool watcher *timed.Watcher[*github.Event] name string @@ -35,8 +38,7 @@ type Labeler struct { } // New creates and returns a new Labeler. It logs to lg, stores state in db, -// -// and watches for new GitHub issues using gh. +// manipulates GitHub issues using gh, and classifies issues using cgen. // // For the purposes of storing its own state, it uses the given name. // Future calls to New with the same name will use the same state. @@ -44,11 +46,12 @@ type Labeler struct { // Use the [Labeler] methods to configure the posting parameters // (especially [Labeler.EnableProject] and [Labeler.EnableLabels]) // before calling [Labeler.Run]. -func New(lg *slog.Logger, db storage.DB, gh *github.Client, name string) *Labeler { +func New(lg *slog.Logger, db storage.DB, gh *github.Client, cgen llm.ContentGenerator, name string) *Labeler { l := &Labeler{ slog: lg, db: db, github: gh, + cgen: cgen, projects: make(map[string]bool), watcher: gh.EventWatcher("labels.Labeler:" + name), name: name, @@ -159,7 +162,36 @@ func (l *Labeler) logLabelIssue(ctx context.Context, e *github.Event) (advance b e.Project, "issue", e.Issue, "reason", reason, "event", e) return false, nil } - return false, errors.New("unimplemented") + // If an action has already been logged for this event, do nothing. + // we don't need a lock. [actions.before] will lock to avoid multiple log entries. + if _, ok := actions.Get(l.db, l.actionKind, logKey(e)); ok { + l.slog.Info("labels.Labeler already logged", "name", l.name, "project", e.Project, "issue", e.Issue, "event", e) + // If labeling is enabled, we can advance the watcher because + // a comment has already been logged for this issue. + return l.label, nil + } + // If we didn't skip, it's definitely an issue. + issue := e.Typed.(*github.Issue) + l.slog.Debug("labels.Labeler consider", "url", issue.HTMLURL) + + cat, explanation, err := IssueCategory(ctx, l.cgen, issue) + if err != nil { + return false, fmt.Errorf("IssueCategory(%s): %w", issue.HTMLURL, err) + } + l.slog.Info("labels.Labeler chose label", "name", l.name, "project", e.Project, "issue", e.Issue, + "label", cat.Label, "explanation", explanation) + + if !l.label { + // Labeling is disabled so we did not handle this issue. + return false, nil + } + + act := &action{ + Issue: issue, + NewLabels: []string{cat.Label}, + } + l.logAction(l.db, logKey(e), storage.JSON(act), l.requireApproval) + return true, nil } func (l *Labeler) skip(e *github.Event) (bool, string) { @@ -206,7 +238,7 @@ func (l *Labeler) syncLabels(ctx context.Context, project string, cats []Categor for _, cat := range cats { lab, ok := tlabs[cat.Label] if !ok { - l.slog.Info("creating label", "label", lab.Name) + l.slog.Info("creating label", "label", cat.Label) if err := l.github.CreateLabel(ctx, project, github.Label{ Name: cat.Label, Description: cat.Description, @@ -242,3 +274,9 @@ func (ar *actioner) ForDisplay(data []byte) string { // TODO: implement return "" } + +// logKey returns the key for the event in the action log. This is only a portion +// of the database key; it is prefixed by the Labelers's action kind. +func logKey(e *github.Event) []byte { + return ordered.Encode(e.Project, e.Issue) +} diff --git a/internal/labels/labeler_test.go b/internal/labels/labeler_test.go index a6b0735..cc35677 100644 --- a/internal/labels/labeler_test.go +++ b/internal/labels/labeler_test.go @@ -6,11 +6,15 @@ package labels import ( "context" + "encoding/json" "maps" + "slices" "testing" "github.com/google/go-cmp/cmp" + "golang.org/x/oscar/internal/actions" "golang.org/x/oscar/internal/github" + "golang.org/x/oscar/internal/llm" "golang.org/x/oscar/internal/storage" "golang.org/x/oscar/internal/testutil" ) @@ -20,7 +24,7 @@ func TestSyncLabels(t *testing.T) { lg := testutil.Slogger(t) db := storage.MemDB() gh := github.New(lg, db, nil, nil) - labeler := New(lg, nil, gh, "") + labeler := New(lg, nil, gh, nil, "") m := map[string]github.Label{ "A": {Name: "A", Description: "a", Color: "a"}, "B": {Name: "B", Description: "", Color: "b"}, @@ -60,3 +64,47 @@ func TestSyncLabels(t *testing.T) { t.Errorf("mismatch (-want, got):\n%s", diff) } } + +func TestRun(t *testing.T) { + const project = "golang/go" + ctx := context.Background() + check := testutil.Checker(t) + lg := testutil.Slogger(t) + db := storage.MemDB() + gh := github.New(lg, db, nil, nil) + gh.Testing().AddLabel(project, github.Label{Name: "bug"}) + gh.Testing().AddIssue(project, &github.Issue{ + Number: 1, + Title: "title", + Body: "body", + }) + gh.Testing().AddIssue("other/project", &github.Issue{ + Number: 2, + Title: "title", + Body: "body", + }) + cgen := llm.TestContentGenerator("test", func(context.Context, *llm.Schema, []llm.Part) (string, error) { + return `{"CategoryName": "bug", "Explanation": "exp"}`, nil + }) + l := New(lg, db, gh, cgen, "test") + l.EnableProject(project) + l.EnableLabels() + + check(l.Run(ctx)) + entries := slices.Collect(actions.ScanAfterDBTime(lg, db, 0, nil)) + if g := len(entries); g != 1 { + t.Fatalf("got %d actions, want 1", g) + } + var got action + check(json.Unmarshal(entries[0].Action, &got)) + if got.Issue.Number != 1 || !slices.Equal(got.NewLabels, []string{"Bug"}) { + t.Errorf("got (%d, %v), want (1, [Bug])", got.Issue.Number, got.NewLabels) + } + + // Second time, nothing should happen. + check(l.Run(ctx)) + entries = slices.Collect(actions.ScanAfterDBTime(lg, db, entries[0].ModTime, nil)) + if g := len(entries); g != 0 { + t.Fatalf("got %d actions, want 0", g) + } +}