Skip to content

Commit

Permalink
Implement issue assign bot (#460)
Browse files Browse the repository at this point in the history
* 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
mikeee authored Oct 10, 2023
1 parent c73a542 commit 1757eaa
Show file tree
Hide file tree
Showing 10 changed files with 669 additions and 0 deletions.
34 changes: 34 additions & 0 deletions .github/workflows/dapr-bot.yml
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 .
17 changes: 17 additions & 0 deletions .github/workflows/dapr-bot/Makefile
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 \
./...
95 changes: 95 additions & 0 deletions .github/workflows/dapr-bot/bot.go
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
}
206 changes: 206 additions & 0 deletions .github/workflows/dapr-bot/bot_test.go
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)
})
}
Loading

0 comments on commit 1757eaa

Please sign in to comment.