Skip to content

Commit

Permalink
feat(assign): adds a new assign action (#191)
Browse files Browse the repository at this point in the history
Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com>
  • Loading branch information
nobe4 and github-advanced-security[bot] authored Oct 2, 2024
1 parent a16cb10 commit 090e4b5
Show file tree
Hide file tree
Showing 7 changed files with 271 additions and 10 deletions.
7 changes: 4 additions & 3 deletions cmd/gen-docs/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down
16 changes: 9 additions & 7 deletions internal/actions/actions.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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},
}
}

Expand Down
86 changes: 86 additions & 0 deletions internal/actions/assign/assign.go
Original file line number Diff line number Diff line change
@@ -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
}
169 changes: 169 additions & 0 deletions internal/actions/assign/assign_test.go
Original file line number Diff line number Diff line change
@@ -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 := &notifications.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 := &notifications.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 := &notifications.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 := &notifications.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 := &notifications.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)
}
})
}
}
1 change: 1 addition & 0 deletions internal/actions/done/done.go
Original file line number Diff line number Diff line change
@@ -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
Expand Down
1 change: 1 addition & 0 deletions internal/actions/hide/hide.go
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/*
Package hide implements an [actions.Runner] that hides a notification.
It hides the notifications completely.
*/
package hide
Expand Down
1 change: 1 addition & 0 deletions internal/actions/read/read.go
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down

0 comments on commit 090e4b5

Please sign in to comment.