-
Notifications
You must be signed in to change notification settings - Fork 173
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* implement assign bot in go and trigger workflow Signed-off-by: mikeee <[email protected]> * remove debug line Signed-off-by: mikeee <[email protected]> * prevent an issue from being assigned twice Signed-off-by: mikeee <[email protected]> * add event json annotations Signed-off-by: mikeee <[email protected]> * add makefile and test workflow Signed-off-by: mikeee <[email protected]> * rename workflow and job Signed-off-by: mikeee <[email protected]> * handle multiline comments Signed-off-by: mikeee <[email protected]> --------- Signed-off-by: mikeee <[email protected]>
- Loading branch information
Showing
10 changed files
with
669 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,34 @@ | ||
name: dapr-bot | ||
|
||
on: | ||
issue_comment: | ||
types: [created] | ||
|
||
jobs: | ||
bot-run: | ||
name: bot-processor | ||
runs-on: ubuntu-latest | ||
permissions: | ||
issues: write | ||
contents: read | ||
env: | ||
GITHUB_TOKEN: ${{ github.token }} | ||
|
||
steps: | ||
- name: Checkout code | ||
uses: actions/checkout@v4 | ||
|
||
- name: Setup Golang | ||
uses: actions/setup-go@v4 | ||
with: | ||
go-version: ~1.21 | ||
cache-dependency-path: | | ||
./.github/workflows/dapr-bot/ | ||
- name: go-bot-mod | ||
working-directory: ./.github/workflows/dapr-bot/ | ||
run: go get ./... | ||
- name: go-bot-run | ||
|
||
working-directory: ./.github/workflows/dapr-bot/ | ||
run: go run . |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
GO_COMPAT_VERSION=1.21 | ||
|
||
.PHONY: cover | ||
cover: | ||
go test -coverprofile=cover.out ./ && go tool cover -html=cover.out | ||
|
||
.PHONY: tidy | ||
tidy: ## Updates the go modules | ||
go mod tidy -compat=$(GO_COMPAT_VERSION) | ||
|
||
.PHONY: test | ||
test: | ||
go test -count=1 \ | ||
-race \ | ||
-coverprofile=coverage.txt \ | ||
-covermode=atomic \ | ||
./... |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,95 @@ | ||
package main | ||
|
||
import ( | ||
"context" | ||
"errors" | ||
"fmt" | ||
"net/http" | ||
"strings" | ||
|
||
"github.com/google/go-github/v55/github" | ||
) | ||
|
||
var ( | ||
errCommentBodyEmpty = errors.New("comment body is empty") | ||
errIssueClosed = errors.New("issue is closed") | ||
errIssueAlreadyAssigned = errors.New("issue is already assigned") | ||
errUnauthorizedClient = errors.New("possibly unauthorized client issue") | ||
) | ||
|
||
type issueInterface interface { | ||
CreateComment(ctx context.Context, owner string, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) | ||
AddAssignees(ctx context.Context, owner string, repo string, number int, assignees []string) (*github.Issue, *github.Response, error) | ||
} | ||
|
||
type Bot struct { | ||
ctx context.Context | ||
issueClient issueInterface | ||
} | ||
|
||
func NewBot(ghClient *github.Client) *Bot { | ||
return &Bot{ | ||
ctx: context.Background(), | ||
issueClient: ghClient.Issues, | ||
} | ||
} | ||
|
||
func (b *Bot) HandleEvent(ctx context.Context, event Event) (res string, err error) { | ||
commentBody := event.IssueCommentEvent.Comment.GetBody() | ||
|
||
// split the comment after any potential new lines | ||
newline := strings.Split(strings.ReplaceAll(commentBody, "\r\n", "\n"), "\n")[0] | ||
|
||
command := strings.Split(newline, " ")[0] | ||
|
||
if command[0] != '/' { | ||
return "no command found", err | ||
} | ||
|
||
switch command { | ||
case "/assign": | ||
assignee, err := b.AssignIssueToCommenter(event) | ||
res = fmt.Sprintf("👍 Issue assigned to %s", assignee) | ||
if err == nil { | ||
err = b.CreateIssueComment(fmt.Sprintf("🚀 Issue assigned to you @%s", assignee), event) | ||
} else { | ||
err = b.CreateIssueComment("⚠️ Unable to assign issue", event) | ||
} | ||
if err != nil { | ||
return fmt.Sprintf("failed to comment on issue: %v", err), err | ||
} | ||
} | ||
return | ||
} | ||
|
||
func (b *Bot) CreateIssueComment(body string, event Event) error { | ||
if body == "" { | ||
return errCommentBodyEmpty | ||
} | ||
ctx := context.Background() | ||
comment := &github.IssueComment{ | ||
Body: github.String(body), | ||
} | ||
_, response, err := b.issueClient.CreateComment(ctx, event.GetIssueOrg(), event.GetIssueRepo(), event.GetIssueNumber(), comment) | ||
if err != nil || response.StatusCode == http.StatusNotFound { | ||
return fmt.Errorf("failed to create comment: %v%v", err, response.StatusCode) | ||
} | ||
return nil | ||
} | ||
|
||
func (b *Bot) AssignIssueToCommenter(event Event) (string, error) { | ||
if event.GetIssueState() == "closed" { | ||
return "", errIssueClosed | ||
} | ||
|
||
if len(event.GetIssueAssignees()) > 0 { | ||
return "", errIssueAlreadyAssigned | ||
} | ||
|
||
ctx := context.Background() | ||
_, response, err := b.issueClient.AddAssignees(ctx, event.GetIssueOrg(), event.GetIssueRepo(), event.GetIssueNumber(), []string{event.GetIssueUser()}) | ||
if response.StatusCode == http.StatusNotFound { | ||
return "", errUnauthorizedClient | ||
} | ||
return event.GetIssueUser(), err | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,206 @@ | ||
package main | ||
|
||
import ( | ||
"context" | ||
"net/http" | ||
"testing" | ||
|
||
"github.com/google/go-github/v55/github" | ||
"github.com/jinzhu/copier" | ||
"github.com/stretchr/testify/assert" | ||
) | ||
|
||
var testBot *Bot = &Bot{ | ||
ctx: context.Background(), | ||
issueClient: &testClient{}, | ||
} | ||
|
||
type testClient struct { | ||
issue *github.Issue | ||
issueComment *github.IssueComment | ||
resp *github.Response | ||
} | ||
|
||
func (tc *testClient) CreateComment(ctx context.Context, org, repo string, number int, comment *github.IssueComment) (*github.IssueComment, *github.Response, error) { | ||
return tc.issueComment, tc.resp, nil | ||
} | ||
|
||
func (tc *testClient) AddAssignees(ctx context.Context, org, repo string, number int, assignees []string) (*github.Issue, *github.Response, error) { | ||
return tc.issue, tc.resp, nil | ||
} | ||
|
||
func TestNewBot(t *testing.T) { | ||
t.Run("create a bot test", func(t *testing.T) { | ||
bot := NewBot(github.NewClient(nil)) | ||
assert.NotNil(t, bot) | ||
}) | ||
} | ||
|
||
func TestHandleEvent(t *testing.T) { | ||
t.Run("handle valid event", func(t *testing.T) { | ||
tc := testClient{ | ||
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusOK}}, | ||
} | ||
testBot.issueClient = &tc | ||
ctx := context.Background() | ||
var testEventCopy Event | ||
errC := copier.CopyWithOption(&testEventCopy, &testEvent, copier.Option{DeepCopy: true}) | ||
if errC != nil { | ||
t.Error(errC) | ||
} | ||
testEventCopy.IssueCommentEvent.Comment.Body = github.String("/assign") | ||
res, err := testBot.HandleEvent(ctx, testEventCopy) | ||
assert.NoError(t, err) | ||
assert.NotEmpty(t, res) | ||
}) | ||
|
||
t.Run("handle valid (longer body) event", func(t *testing.T) { | ||
tc := testClient{ | ||
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusOK}}, | ||
} | ||
testBot.issueClient = &tc | ||
ctx := context.Background() | ||
var testEventCopy Event | ||
errC := copier.CopyWithOption(&testEventCopy, &testEvent, copier.Option{DeepCopy: true}) | ||
if errC != nil { | ||
t.Error(errC) | ||
} | ||
testEventCopy.IssueCommentEvent.Comment.Body = github.String("/assign \r \ntest body") | ||
res, err := testBot.HandleEvent(ctx, testEventCopy) | ||
assert.NoError(t, err) | ||
assert.NotEmpty(t, res) | ||
}) | ||
|
||
t.Run("handle unable to assign", func(t *testing.T) { | ||
tc := testClient{ | ||
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusNotFound}}, | ||
} | ||
testBot.issueClient = &tc | ||
ctx := context.Background() | ||
var testEventCopy Event | ||
errC := copier.CopyWithOption(&testEventCopy, &testEvent, copier.Option{DeepCopy: true}) | ||
if errC != nil { | ||
t.Error(errC) | ||
} | ||
testEventCopy.IssueCommentEvent.Comment.Body = github.String("/assign") | ||
res, err := testBot.HandleEvent(ctx, testEventCopy) | ||
assert.Error(t, err) | ||
assert.NotEmpty(t, res) | ||
}) | ||
|
||
t.Run("handle no event", func(t *testing.T) { | ||
tc := testClient{} | ||
testBot.issueClient = &tc | ||
ctx := context.Background() | ||
var testEventCopy Event | ||
errC := copier.CopyWithOption(&testEventCopy, &testEvent, copier.Option{DeepCopy: true}) | ||
if errC != nil { | ||
t.Error(errC) | ||
} | ||
testEventCopy.IssueCommentEvent.Comment.Body = github.String("assign") | ||
res, err := testBot.HandleEvent(ctx, testEventCopy) | ||
assert.NoError(t, err) | ||
assert.Equal(t, "no command found", res) | ||
}) | ||
} | ||
|
||
func TestCreateIssueComment(t *testing.T) { | ||
t.Run("failure to create issue comment", func(t *testing.T) { | ||
tc := testClient{ | ||
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusNotFound}}, | ||
} | ||
testBot.issueClient = &tc | ||
err := testBot.CreateIssueComment("test", testEvent) | ||
assert.Error(t, err) | ||
}) | ||
|
||
t.Run("create issue comment", func(t *testing.T) { | ||
tc := testClient{ | ||
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusOK}}, | ||
} | ||
testBot.issueClient = &tc | ||
err := testBot.CreateIssueComment("test", testEvent) | ||
assert.NoError(t, err) | ||
}) | ||
|
||
t.Run("create issue comment with empty body", func(t *testing.T) { | ||
tc := testClient{ | ||
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusOK}}, | ||
} | ||
testBot.issueClient = &tc | ||
err := testBot.CreateIssueComment("", testEvent) | ||
assert.Error(t, err) | ||
}) | ||
} | ||
|
||
func TestAssignIssueToCommenter(t *testing.T) { | ||
t.Run("failure to assign issue to commenter", func(t *testing.T) { | ||
tc := testClient{ | ||
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusNotFound}}, | ||
} | ||
testBot.issueClient = &tc | ||
assignee, err := testBot.AssignIssueToCommenter(testEvent) | ||
assert.Error(t, err) | ||
assert.Empty(t, assignee) | ||
}) | ||
|
||
t.Run("successfully assign issue to commenter", func(t *testing.T) { | ||
tc := testClient{ | ||
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusOK}}, | ||
} | ||
testBot.issueClient = &tc | ||
var testEventCopy Event | ||
errC := copier.CopyWithOption(&testEventCopy, &testEvent, copier.Option{DeepCopy: true}) | ||
if errC != nil { | ||
t.Error(errC) | ||
} | ||
testEventCopy.IssueCommentEvent.Issue.Assignees = []*github.User{} | ||
assignee, err := testBot.AssignIssueToCommenter(testEventCopy) | ||
assert.NoError(t, err) | ||
assert.Equal(t, "testCommentLogin", assignee) | ||
}) | ||
|
||
t.Run("attempt to assign a closed issue", func(t *testing.T) { | ||
tc := testClient{} | ||
testBot.issueClient = &tc | ||
var testEventCopy Event | ||
errC := copier.CopyWithOption(&testEventCopy, &testEvent, copier.Option{DeepCopy: true}) | ||
if errC != nil { | ||
t.Error(errC) | ||
} | ||
testEventCopy.IssueCommentEvent.Issue.State = github.String("closed") | ||
assignee, err := testBot.AssignIssueToCommenter(testEventCopy) | ||
assert.Error(t, err) | ||
assert.Empty(t, assignee) | ||
}) | ||
|
||
t.Run("issue already assigned to user", func(t *testing.T) { | ||
tc := testClient{} | ||
testBot.issueClient = &tc | ||
var testEventCopy Event | ||
errC := copier.CopyWithOption(&testEventCopy, &testEvent, copier.Option{DeepCopy: true}) | ||
if errC != nil { | ||
t.Error(errC) | ||
} | ||
testEventCopy.IssueCommentEvent.Issue.Assignees = []*github.User{{Login: github.String("testCommentLogin")}} | ||
assignee, err := testBot.AssignIssueToCommenter(testEventCopy) | ||
assert.Error(t, err) | ||
assert.Empty(t, assignee) | ||
}) | ||
|
||
t.Run("issue already assigned to another user", func(t *testing.T) { | ||
tc := testClient{ | ||
resp: &github.Response{Response: &http.Response{StatusCode: http.StatusOK}}, | ||
} | ||
testBot.issueClient = &tc | ||
var testEventCopy Event | ||
errC := copier.CopyWithOption(&testEventCopy, &testEvent, copier.Option{DeepCopy: true}) | ||
if errC != nil { | ||
t.Error(errC) | ||
} | ||
testEventCopy.IssueCommentEvent.Issue.Assignees = []*github.User{{Login: github.String("testCommentLogin2")}} | ||
assignee, err := testBot.AssignIssueToCommenter(testEventCopy) | ||
assert.Error(t, err) | ||
assert.Empty(t, assignee) | ||
}) | ||
} |
Oops, something went wrong.