From 090e4b5783736f694dbe4381e2f64383c88d341b Mon Sep 17 00:00:00 2001 From: nobe4 Date: Wed, 2 Oct 2024 21:09:34 +0200 Subject: [PATCH] feat(assign): adds a new assign action (#191) Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- cmd/gen-docs/main.go | 7 +- internal/actions/actions.go | 16 ++- internal/actions/assign/assign.go | 86 +++++++++++++ internal/actions/assign/assign_test.go | 169 +++++++++++++++++++++++++ internal/actions/done/done.go | 1 + internal/actions/hide/hide.go | 1 + internal/actions/read/read.go | 1 + 7 files changed, 271 insertions(+), 10 deletions(-) create mode 100644 internal/actions/assign/assign.go create mode 100644 internal/actions/assign/assign_test.go diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go index 2f36fb9..e0856c5 100644 --- a/cmd/gen-docs/main.go +++ b/cmd/gen-docs/main.go @@ -22,6 +22,7 @@ func main() { if info.IsDir() || filepath.Ext(path) != ".go" || + strings.HasSuffix(path, "_test.go") || filepath.Base(path) == "actions.go" { return nil } @@ -64,9 +65,9 @@ func format(content string) (string, error) { } header := strings.Trim(parts[0], "\n") - parts = strings.SplitN(header, "\n", 2) + parts = strings.SplitN(header, "\n\n", 2) - re := regexp.MustCompile(`Package (\w+) implements an \[actions.Runner\] that (.*)\.`) + re := regexp.MustCompile(`Package (\w+) implements an \[actions.Runner\] that (.*\n?.*)\.`) matches := re.FindStringSubmatch(parts[0]) if len(matches) < 3 { @@ -83,7 +84,7 @@ func format(content string) (string, error) { outParts = append(outParts, tail) } - return strings.Join(outParts, "\n"), nil + return strings.Join(outParts, "\n\n"), nil } func indent(s string) string { diff --git a/internal/actions/actions.go b/internal/actions/actions.go index d2c2638..3301427 100644 --- a/internal/actions/actions.go +++ b/internal/actions/actions.go @@ -3,6 +3,7 @@ package actions import ( "io" + "github.com/nobe4/gh-not/internal/actions/assign" "github.com/nobe4/gh-not/internal/actions/debug" "github.com/nobe4/gh-not/internal/actions/done" "github.com/nobe4/gh-not/internal/actions/hide" @@ -18,13 +19,14 @@ type ActionsMap map[string]Runner func Map(client *gh.Client) ActionsMap { return map[string]Runner{ - "pass": &pass.Runner{}, - "debug": &debug.Runner{}, - "print": &print.Runner{}, - "hide": &hide.Runner{}, - "read": &read.Runner{Client: client}, - "done": &done.Runner{Client: client}, - "open": &open.Runner{Client: client}, + "pass": &pass.Runner{}, + "debug": &debug.Runner{}, + "print": &print.Runner{}, + "hide": &hide.Runner{}, + "read": &read.Runner{Client: client}, + "done": &done.Runner{Client: client}, + "open": &open.Runner{Client: client}, + "assign": &assign.Runner{Client: client}, } } diff --git a/internal/actions/assign/assign.go b/internal/actions/assign/assign.go new file mode 100644 index 0000000..b4d0478 --- /dev/null +++ b/internal/actions/assign/assign.go @@ -0,0 +1,86 @@ +/* +Package assign implements an [actions.Runner] that assigns the subject of a +notification. + +It only works when the notifications has an issue or pull request for subject. + +It takes as arguments the usernames to assign. + +Usage in the config: + + rules: + - action: assign + args: ["user0", "user1"] + +Usage in the REPL: + + :assign user0 user1 + +Refs: https://docs.github.com/en/rest/issues/assignees?apiVersion=2022-11-28#add-assignees-to-an-issue +*/ +package assign + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "net/http" + "regexp" + "strings" + + "github.com/nobe4/gh-not/internal/colors" + "github.com/nobe4/gh-not/internal/gh" + "github.com/nobe4/gh-not/internal/notifications" +) + +type Runner struct { + Client *gh.Client +} + +type Body struct { + Assignees []string `json:"assignees"` +} + +func (a *Runner) Run(n *notifications.Notification, assignees []string, w io.Writer) error { + slog.Debug("assigning notification", "notification", n, "assignees", assignees) + + if len(assignees) == 0 { + return errors.New("no assignees provided") + } + + url, ok := issueURL(n.Subject.URL) + if !ok { + slog.Warn("not an issue or pull", "notification", n) + return nil + } + + assigneesUrl := url + "/assignees" + + body, err := json.Marshal(Body{Assignees: assignees}) + if err != nil { + return err + } + + _, err = a.Client.API.Request(http.MethodPost, assigneesUrl, bytes.NewReader(body)) + if err != nil { + return err + } + + fmt.Fprint(w, colors.Red("ASSIGN ")+n.String()+" to "+strings.Join(assignees, ", ")) + + return nil +} + +func issueURL(url string) (string, bool) { + re := regexp.MustCompile(`^(https://api\.github\.com/repos/.+/.+/)(issues|pulls)(/\d+)$`) + matches := re.FindStringSubmatch(url) + + if len(matches) == 0 { + return "", false + } + + return fmt.Sprintf("%sissues%s", matches[1], matches[3]), true +} diff --git a/internal/actions/assign/assign_test.go b/internal/actions/assign/assign_test.go new file mode 100644 index 0000000..f8ddb55 --- /dev/null +++ b/internal/actions/assign/assign_test.go @@ -0,0 +1,169 @@ +package assign + +import ( + "bytes" + "errors" + "net/http" + "testing" + + "github.com/nobe4/gh-not/internal/api/mock" + "github.com/nobe4/gh-not/internal/gh" + "github.com/nobe4/gh-not/internal/notifications" +) + +func TestRun(t *testing.T) { + w := &bytes.Buffer{} + + api := &mock.Mock{} + client := gh.Client{API: api} + + runner := Runner{Client: &client} + + t.Run("not assignees", func(t *testing.T) { + n := ¬ifications.Notification{URL: "http://example.com"} + + if err := runner.Run(n, []string{}, w); err == nil { + t.Fatal("expected error", err) + } + + if err := api.Done(); err != nil { + t.Fatal("unexpected error", err) + } + }) + + t.Run("not an issue or pull", func(t *testing.T) { + n := ¬ifications.Notification{URL: "http://example.com"} + + if err := runner.Run(n, []string{"user"}, w); err != nil { + t.Fatal("unexpected error", err) + } + + if err := api.Done(); err != nil { + t.Fatal("unexpected error", err) + } + }) + + t.Run("return an API failure", func(t *testing.T) { + expectedError := errors.New("expected error") + + api.Calls = append(api.Calls, mock.Call{ + Verb: "POST", + Url: "https://api.github.com/repos/owner/repo/issues/123/assignees", + Data: `{"assignees":["user"]}`, + Error: expectedError, + }) + n := ¬ifications.Notification{ + Subject: notifications.Subject{ + URL: "https://api.github.com/repos/owner/repo/pulls/123", + }, + } + + if err := runner.Run(n, []string{"user"}, w); err != expectedError { + t.Fatalf("expected %#v but got %#v", expectedError, err) + } + + if err := api.Done(); err != nil { + t.Fatal("unexpected error", err) + } + }) + + t.Run("works for an issue", func(t *testing.T) { + api.Calls = append(api.Calls, mock.Call{ + Verb: "POST", + Url: "https://api.github.com/repos/owner/repo/issues/123/assignees", + Data: `{"assignees":["user"]}`, + Response: &http.Response{StatusCode: http.StatusCreated}, + }) + n := ¬ifications.Notification{ + Subject: notifications.Subject{ + URL: "https://api.github.com/repos/owner/repo/issues/123", + }, + } + + if err := runner.Run(n, []string{"user"}, w); err != nil { + t.Fatal("unexpected error", err) + } + + if err := api.Done(); err != nil { + t.Fatal("unexpected error", err) + } + }) + + t.Run("works for a pull", func(t *testing.T) { + api.Calls = append(api.Calls, mock.Call{ + Verb: "POST", + Url: "https://api.github.com/repos/owner/repo/issues/123/assignees", + Data: `{"assignees":["user"]}`, + Response: &http.Response{StatusCode: http.StatusCreated}, + }) + n := ¬ifications.Notification{ + Subject: notifications.Subject{ + URL: "https://api.github.com/repos/owner/repo/pulls/123", + }, + } + + if err := runner.Run(n, []string{"user"}, w); err != nil { + t.Fatal("unexpected error", err) + } + + if err := api.Done(); err != nil { + t.Fatal("unexpected error", err) + } + }) +} + +func TestIsIssueOrPull(t *testing.T) { + tests := []struct { + url string + want string + match bool + }{ + { + url: "http://example.com", + match: false, + }, + { + url: "https://github.com", + match: false, + }, + { + url: "https://api.github.com", + match: false, + }, + { + url: "https://api.github.com/repos/owner/repo", + match: false, + }, + { + url: "https://api.github.com/repos/owner/repo/pulls", + match: false, + }, + { + url: "https://api.github.com/repos/owner/repo/issues", + match: false, + }, + { + url: "https://api.github.com/repos/owner/repo/pulls/123", + want: "https://api.github.com/repos/owner/repo/issues/123", + match: true, + }, + { + url: "https://api.github.com/repos/owner/repo/issues/123", + want: "https://api.github.com/repos/owner/repo/issues/123", + match: true, + }, + } + + for _, test := range tests { + t.Run(test.url, func(t *testing.T) { + got, match := issueURL(test.url) + if match != test.match { + t.Errorf("want %v but got %v", test.match, match) + } + + if got != test.want { + t.Errorf("want %v but got %v", test.want, got) + } + }) + } +} diff --git a/internal/actions/done/done.go b/internal/actions/done/done.go index 7e6a240..c86c97c 100644 --- a/internal/actions/done/done.go +++ b/internal/actions/done/done.go @@ -1,5 +1,6 @@ /* Package done implements an [actions.Runner] that marks a notification as done. + It updates Meta.Done and marks the notification's thread as done on GitHub. The notification will be hidden until the thread is updated. Ref: https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#mark-a-thread-as-done diff --git a/internal/actions/hide/hide.go b/internal/actions/hide/hide.go index 1982647..8e49577 100644 --- a/internal/actions/hide/hide.go +++ b/internal/actions/hide/hide.go @@ -1,5 +1,6 @@ /* Package hide implements an [actions.Runner] that hides a notification. + It hides the notifications completely. */ package hide diff --git a/internal/actions/read/read.go b/internal/actions/read/read.go index e88f425..95265fd 100644 --- a/internal/actions/read/read.go +++ b/internal/actions/read/read.go @@ -1,5 +1,6 @@ /* Package read implements an [actions.Runner] that marks a notification as read. + It updates Unread and marks the notification's thread as read on GitHub. Ref: https://docs.github.com/en/rest/activity/notifications?apiVersion=2022-11-28#mark-a-thread-as-read */