You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@pulsar.apache.org by lh...@apache.org on 2022/09/06 15:21:05 UTC

[pulsar-test-infra] branch master updated: Refactor docbot and add tests (#69)

This is an automated email from the ASF dual-hosted git repository.

lhotari pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/pulsar-test-infra.git


The following commit(s) were added to refs/heads/master by this push:
     new f7f525d  Refactor docbot and add tests (#69)
f7f525d is described below

commit f7f525d26656c2a6dd23f281a8452262e273c3f2
Author: Zixuan Liu <no...@gmail.com>
AuthorDate: Tue Sep 6 23:21:01 2022 +0800

    Refactor docbot and add tests (#69)
---
 docbot/action.go        | 316 +++++++++++++++++++++++++
 docbot/action.yml       |   2 +-
 docbot/action_config.go | 123 ++++++++++
 docbot/action_test.go   | 320 ++++++++++++++++++++++++++
 docbot/go.mod           |   5 +-
 docbot/go.sum           |  15 +-
 docbot/main.go          | 600 +-----------------------------------------------
 7 files changed, 780 insertions(+), 601 deletions(-)

diff --git a/docbot/action.go b/docbot/action.go
new file mode 100644
index 0000000..026a9f7
--- /dev/null
+++ b/docbot/action.go
@@ -0,0 +1,316 @@
+package main
+
+import (
+	"context"
+	"errors"
+	"fmt"
+	"regexp"
+	"strings"
+
+	"github.com/apache/pulsar-test-infra/docbot/pkg/logger"
+	"github.com/google/go-github/v45/github"
+	"golang.org/x/oauth2"
+)
+
+const (
+	MessageLabelMissing = `Please provide a correct documentation label for your PR.
+Instructions see [Pulsar Documentation Label Guide](https://docs.google.com/document/d/1Qw7LHQdXWBW9t2-r-A7QdFDBwmZh6ytB4guwMoXHqc0).`
+	MessageLabelMultiple = `Please select only one documentation label for your PR.
+Instructions see [Pulsar Documentation Label Guide](https://docs.google.com/document/d/1Qw7LHQdXWBW9t2-r-A7QdFDBwmZh6ytB4guwMoXHqc0).`
+)
+
+type Action struct {
+	config *ActionConfig
+
+	globalContext context.Context
+	client        *github.Client
+
+	prNumber int
+}
+
+func NewAction(ac *ActionConfig) *Action {
+	ctx := context.Background()
+	ts := oauth2.StaticTokenSource(
+		&oauth2.Token{AccessToken: ac.GetToken()},
+	)
+
+	tc := oauth2.NewClient(ctx, ts)
+
+	return NewActionWithClient(ctx, ac, github.NewClient(tc))
+}
+
+func NewActionWithClient(ctx context.Context, ac *ActionConfig, client *github.Client) *Action {
+	return &Action{
+		config:        ac,
+		globalContext: ctx,
+		client:        client,
+	}
+}
+
+func (a *Action) Run(prNumber int, actionType string) error {
+	a.prNumber = prNumber
+
+	switch actionType {
+	case "opened", "edited", "labeled", "unlabeled":
+		return a.checkLabels()
+	}
+	return nil
+}
+
+func (a *Action) checkLabels() error {
+	pr, _, err := a.client.PullRequests.Get(a.globalContext, a.config.GetOwner(), a.config.GetRepo(), a.prNumber)
+	if err != nil {
+		return fmt.Errorf("get PR: %v", err)
+	}
+
+	var bodyLabels map[string]bool
+	if pr.Body != nil {
+		bodyLabels = a.extractLabels(*pr.Body)
+	}
+
+	logger.Infoln("@List repo labels")
+	repoLabels, err := a.getRepoLabels()
+	if err != nil {
+		return fmt.Errorf("list repo labels: %v", err)
+	}
+	logger.Infof("Repo labels: %v\n", repoLabels)
+
+	prLabels := a.labelsToMap(pr.Labels)
+	logger.Infof("PR labels: %v\n", prLabels)
+
+	// Get expected labels
+	// Only handle labels already exist in repo
+	expectedLabelsMap := make(map[string]bool)
+	checkedCount := 0
+	for label, checked := range bodyLabels {
+		if _, exist := repoLabels[label]; !exist {
+			logger.Infof("Found label %v not exist int repo\n", label)
+			continue
+		}
+		expectedLabelsMap[label] = checked
+		if checked {
+			checkedCount++
+		}
+	}
+	logger.Infof("Expected labels: %v\n", expectedLabelsMap)
+
+	labelsToRemove := make(map[string]struct{}, 0)
+	labelsToAdd := make(map[string]struct{}, 0)
+
+	if checkedCount == 0 {
+		logger.Infoln("Label missing")
+		for label := range a.config.labelWatchSet {
+			_, found := prLabels[label]
+			if found {
+				labelsToRemove[label] = struct{}{}
+			}
+		}
+		_, found := prLabels[a.config.GetLabelMissing()]
+		if !found {
+			labelsToAdd[a.config.GetLabelMissing()] = struct{}{}
+		} else {
+			logger.Infoln("Already added missing label.")
+			return errors.New(MessageLabelMissing)
+		}
+	} else {
+		if !a.config.GetEnableLabelMultiple() && checkedCount > 1 {
+			logger.Infoln("Multiple labels not enabled")
+			err = a.addAndCleanupHelpComment(pr.User.GetLogin(), MessageLabelMultiple)
+			if err != nil {
+				return err
+			}
+			return errors.New(MessageLabelMultiple)
+		}
+
+		_, found := prLabels[a.config.GetLabelMissing()]
+		if found {
+			labelsToRemove[a.config.GetLabelMissing()] = struct{}{}
+		}
+
+		for label, checked := range expectedLabelsMap {
+			_, found := prLabels[label]
+			if found {
+				continue
+			}
+			if checked {
+				labelsToAdd[label] = struct{}{}
+			}
+		}
+	}
+
+	if len(labelsToAdd) == 0 {
+		logger.Infoln("No labels to add.")
+	} else {
+		labels := a.labelsSetToString(labelsToAdd)
+		logger.Infof("Labels to add: %v\n", labels)
+		err = a.addLabels(labels)
+		if err != nil {
+			logger.Errorf("Failed add labels %v: %v\n", labelsToAdd, err)
+			return err
+		}
+	}
+
+	if len(labelsToRemove) == 0 {
+		logger.Infoln("No labels to remove.")
+	} else {
+		labels := a.labelsSetToString(labelsToRemove)
+		logger.Infof("Labels to remove: %v\n", labels)
+		for _, label := range labels {
+			err = a.removeLabel(label)
+			if err != nil {
+				logger.Errorf("Failed remove labels %v: %v\n", labelsToRemove, err)
+				return err
+			}
+		}
+	}
+
+	if checkedCount == 0 {
+		err := a.addAndCleanupHelpComment(pr.User.GetLogin(), MessageLabelMissing)
+		if err != nil {
+			return err
+		}
+		return errors.New(MessageLabelMissing)
+	}
+
+	return nil
+}
+
+func (a *Action) extractLabels(prBody string) map[string]bool {
+	r := regexp.MustCompile(a.config.GetLabelPattern())
+	targets := r.FindAllStringSubmatch(prBody, -1)
+
+	labels := make(map[string]bool)
+	for _, v := range targets {
+		checked := strings.ToLower(strings.TrimSpace(v[1])) == "x"
+		name := strings.TrimSpace(v[2])
+
+		// Filter uninterested labels
+		if _, exist := a.config.labelWatchSet[name]; !exist {
+			continue
+		}
+
+		labels[name] = checked
+	}
+
+	return labels
+}
+
+func (a *Action) getRepoLabels() (map[string]struct{}, error) {
+	ctx := context.Background()
+	listOptions := &github.ListOptions{PerPage: 100}
+	repoLabels := make(map[string]struct{}, 0)
+	for {
+		rLabels, resp, err := a.client.Issues.ListLabels(ctx, a.config.GetOwner(), a.config.GetRepo(), listOptions)
+		if err != nil {
+			return nil, err
+		}
+
+		for _, label := range rLabels {
+			repoLabels[label.GetName()] = struct{}{}
+		}
+		if resp.NextPage == 0 {
+			break
+		}
+		listOptions.Page = resp.NextPage
+	}
+	return repoLabels, nil
+}
+
+func (a *Action) labelsToMap(labels []*github.Label) map[string]struct{} {
+	result := make(map[string]struct{}, 0)
+	for _, label := range labels {
+		result[label.GetName()] = struct{}{}
+	}
+	return result
+}
+
+func (a *Action) labelsSetToString(labels map[string]struct{}) []string {
+	result := []string{}
+	for label := range labels {
+		result = append(result, label)
+	}
+	return result
+}
+
+func (a *Action) getLabelInvalidCommentIDs(body string) ([]int64, error) {
+	ctx := context.Background()
+	listOptions := &github.IssueListCommentsOptions{}
+	listOptions.PerPage = 100
+	commentIDs := make([]int64, 0)
+	for {
+		comments, resp, err := a.client.Issues.ListComments(ctx, a.config.GetOwner(), a.config.GetRepo(),
+			a.prNumber, listOptions)
+		if err != nil {
+			return nil, err
+		}
+		for _, item := range comments {
+			if strings.Contains(*item.Body, body) {
+				commentIDs = append(commentIDs, *item.ID)
+			}
+		}
+
+		if resp.NextPage == 0 {
+			break
+		}
+		listOptions.Page = resp.NextPage
+	}
+
+	return commentIDs, nil
+}
+
+func (a *Action) createComment(body string) error {
+	_, _, err := a.client.Issues.CreateComment(a.globalContext, a.config.GetOwner(), a.config.GetRepo(),
+		a.prNumber, &github.IssueComment{Body: func(v string) *string { return &v }(body)})
+	return err
+}
+
+func (a *Action) deleteComment(commentID int64) error {
+	_, err := a.client.Issues.DeleteComment(a.globalContext, a.config.GetOwner(), a.config.GetRepo(),
+		commentID)
+	return err
+}
+
+func (a *Action) addLabels(labels []string) error {
+	_, _, err := a.client.Issues.AddLabelsToIssue(a.globalContext, a.config.GetOwner(), a.config.GetRepo(),
+		a.prNumber, labels)
+	return err
+}
+
+func (a *Action) removeLabel(label string) error {
+	_, err := a.client.Issues.RemoveLabelForIssue(a.globalContext, a.config.GetOwner(), a.config.GetRepo(),
+		a.prNumber, label)
+	return err
+}
+
+// addAndCleanupHelpComment adds a help comment when no help comment on the PR.
+func (a *Action) addAndCleanupHelpComment(login, body string) error {
+	commentIDs, err := a.getLabelInvalidCommentIDs(body)
+	if err != nil {
+		logger.Errorf("Failed to get the comment list: %v", err)
+		return err
+	}
+	if len(commentIDs) == 0 {
+		err = a.createComment(fmt.Sprintf("@%s %s", login, body))
+		if err != nil {
+			logger.Errorf("Failed to create %s comment: %v", body, err)
+			return err
+		}
+		return nil
+	} else {
+		// cleanup
+		if len(commentIDs) > 1 {
+			for index, id := range commentIDs {
+				if index == 0 {
+					continue
+				}
+				err := a.deleteComment(id)
+				if err != nil {
+					logger.Errorf("Failed to delete %v comment: %v", id, err)
+					return err
+				}
+			}
+		}
+	}
+
+	return nil
+}
diff --git a/docbot/action.yml b/docbot/action.yml
index 9ecddf7..8a5dce6 100644
--- a/docbot/action.yml
+++ b/docbot/action.yml
@@ -17,6 +17,6 @@ runs:
       with:
         go-version: 1.18
     - name: Execute
-      run: go run main.go
+      run: go run .
       shell: bash
       working-directory: docbot
diff --git a/docbot/action_config.go b/docbot/action_config.go
new file mode 100644
index 0000000..34a810e
--- /dev/null
+++ b/docbot/action_config.go
@@ -0,0 +1,123 @@
+package main
+
+import (
+	"fmt"
+	"os"
+	"strings"
+)
+
+type ActionConfig struct {
+	token *string
+	repo  *string
+	owner *string
+
+	labelPattern        *string
+	labelWatchSet       map[string]struct{}
+	labelMissing        *string
+	enableLabelMissing  *bool
+	enableLabelMultiple *bool
+}
+
+func NewActionConfig() (*ActionConfig, error) {
+	ownerRepoSlug := os.Getenv("GITHUB_REPOSITORY")
+	ownerRepo := strings.Split(ownerRepoSlug, "/")
+	if len(ownerRepo) != 2 {
+		return nil, fmt.Errorf("GITHUB_REPOSITORY is not found")
+	}
+	owner, repo := ownerRepo[0], ownerRepo[1]
+
+	token := os.Getenv("GITHUB_TOKEN")
+
+	labelPattern := os.Getenv("LABEL_PATTERN")
+	if len(labelPattern) == 0 {
+		labelPattern = "- \\[(.*?)\\] ?`(.+?)`"
+	}
+
+	labelWatchListSlug := os.Getenv("LABEL_WATCH_LIST")
+	labelWatchList := strings.Split(labelWatchListSlug, ",")
+	labelWatchSet := make(map[string]struct{})
+	for _, l := range labelWatchList {
+		key := strings.TrimSpace(l)
+		if key == "" {
+			continue
+		}
+		labelWatchSet[key] = struct{}{}
+	}
+
+	enableLabelMissingSlug := os.Getenv("ENABLE_LABEL_MISSING")
+	enableLabelMissing := true
+	if enableLabelMissingSlug == "false" {
+		enableLabelMissing = false
+	}
+
+	labelMissing := os.Getenv("LABEL_MISSING")
+	if len(labelMissing) == 0 {
+		labelMissing = "label-missing"
+	}
+
+	enableLabelMultipleSlug := os.Getenv("ENABLE_LABEL_MULTIPLE")
+	enableLabelMultiple := false
+	if enableLabelMultipleSlug == "true" {
+		enableLabelMultiple = true
+	}
+
+	return &ActionConfig{
+		token:               &token,
+		repo:                &repo,
+		owner:               &owner,
+		labelPattern:        &labelPattern,
+		labelWatchSet:       labelWatchSet,
+		labelMissing:        &labelMissing,
+		enableLabelMissing:  &enableLabelMissing,
+		enableLabelMultiple: &enableLabelMultiple,
+	}, nil
+}
+
+func (ac *ActionConfig) GetToken() string {
+	if ac == nil || ac.token == nil {
+		return ""
+	}
+	return *ac.token
+}
+
+func (ac *ActionConfig) GetOwner() string {
+	if ac == nil || ac.owner == nil {
+		return ""
+	}
+	return *ac.owner
+}
+
+func (ac *ActionConfig) GetRepo() string {
+	if ac == nil || ac.repo == nil {
+		return ""
+	}
+	return *ac.repo
+}
+
+func (ac *ActionConfig) GetLabelPattern() string {
+	if ac == nil || ac.labelPattern == nil {
+		return ""
+	}
+	return *ac.labelPattern
+}
+
+func (ac *ActionConfig) GetLabelMissing() string {
+	if ac == nil || ac.labelMissing == nil {
+		return ""
+	}
+	return *ac.labelMissing
+}
+
+func (ac *ActionConfig) GetEnableLabelMissing() bool {
+	if ac == nil || ac.enableLabelMissing == nil {
+		return false
+	}
+	return *ac.enableLabelMissing
+}
+
+func (ac *ActionConfig) GetEnableLabelMultiple() bool {
+	if ac == nil || ac.enableLabelMultiple == nil {
+		return false
+	}
+	return *ac.enableLabelMultiple
+}
diff --git a/docbot/action_test.go b/docbot/action_test.go
new file mode 100644
index 0000000..a2af4f6
--- /dev/null
+++ b/docbot/action_test.go
@@ -0,0 +1,320 @@
+package main
+
+import (
+	"context"
+	"fmt"
+	"os"
+	"testing"
+
+	"github.com/google/go-github/v45/github"
+	"github.com/migueleliasweb/go-github-mock/src/mock"
+)
+
+func repoLabels() []*github.Label {
+	labels := []string{"doc-required", "doc-not-needed", "doc", "doc-complete", "doc-label-missing"}
+
+	result := make([]*github.Label, 0)
+	for _, label := range labels {
+		name := label
+		result = append(result, &github.Label{Name: &name})
+	}
+
+	return result
+}
+
+func mustNewActionConfig() *ActionConfig {
+	_ = os.Setenv("GITHUB_REPOSITORY", "apache/pulsar")
+	_ = os.Setenv("LABEL_WATCH_LIST", "doc,doc-required,doc-not-needed,doc-complete")
+	_ = os.Setenv("LABEL_MISSING", "doc-label-missing")
+
+	config, err := NewActionConfig()
+	if err != nil {
+		panic(err)
+	}
+
+	return config
+}
+
+func assertMessageLabel(t *testing.T, err error, message string) {
+	t.Helper()
+
+	if err == nil {
+		t.Fatal("Expect err not nil")
+	}
+
+	if err.Error() != message {
+		t.Fatal("Expect err equals " + message)
+	}
+}
+
+func TestSingleChecked(t *testing.T) {
+	id := int64(1)
+	body := fmt.Sprintf(`
+Check the box below or label this PR directly.
+
+Need to update docs?
+
+- [ ] %s
+(Your PR needs to update docs and you will update later)
+
+- [x] %s
+(Please explain why)
+
+- [ ] %s
+(Your PR contains doc changes)
+
+- [ ] %s
+(Docs have been already added)
+`, "`doc-required`", "`doc-not-needed`", "`doc`", "`doc-complete`")
+
+	mockedHTTPClient := mock.NewMockedHTTPClient(
+		mock.WithRequestMatch(
+			mock.GetReposPullsByOwnerByRepoByPullNumber,
+			github.PullRequest{
+				ID:     &id,
+				Body:   &body,
+				Labels: nil,
+			},
+		), mock.WithRequestMatch(
+			mock.GetReposLabelsByOwnerByRepo,
+			repoLabels(),
+		),
+		mock.WithRequestMatch(mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, nil),
+	)
+
+	config := mustNewActionConfig()
+	action := NewActionWithClient(context.Background(), config, github.NewClient(mockedHTTPClient))
+
+	err := action.Run(1, "opened")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestMultipleChecked(t *testing.T) {
+	id := int64(1)
+	body := fmt.Sprintf(`
+Check the box below or label this PR directly.
+
+Need to update docs?
+
+- [ ] %s
+(Your PR needs to update docs and you will update later)
+
+- [x] %s
+(Please explain why)
+
+- [x] %s
+(Your PR contains doc changes)
+
+- [ ] %s
+(Docs have been already added)
+`, "`doc-required`", "`doc-not-needed`", "`doc`", "`doc-complete`")
+
+	mockedHTTPClient := mock.NewMockedHTTPClient(
+		mock.WithRequestMatch(
+			mock.GetReposPullsByOwnerByRepoByPullNumber,
+			github.PullRequest{
+				ID:     &id,
+				Body:   &body,
+				Labels: nil,
+			},
+		), mock.WithRequestMatch(
+			mock.GetReposLabelsByOwnerByRepo,
+			repoLabels(),
+		),
+		mock.WithRequestMatch(mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, nil),
+	)
+
+	const key = "ENABLE_LABEL_MULTIPLE"
+	value := os.Getenv(key)
+	defer func() {
+		// reset
+		_ = os.Setenv(key, value)
+	}()
+	_ = os.Setenv("ENABLE_LABEL_MULTIPLE", "true")
+
+	config := mustNewActionConfig()
+	action := NewActionWithClient(context.Background(), config, github.NewClient(mockedHTTPClient))
+
+	err := action.Run(1, "opened")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestUnchecked(t *testing.T) {
+	id := int64(1)
+	body := fmt.Sprintf(`
+Check the box below or label this PR directly.
+
+Need to update docs?
+
+- [ ] %s
+(Your PR needs to update docs and you will update later)
+
+- [ ] %s
+(Please explain why)
+
+- [ ] %s
+(Your PR contains doc changes)
+
+- [ ] %s
+(Docs have been already added)
+`, "`doc-required`", "`doc-not-needed`", "`doc`", "`doc-complete`")
+
+	mockedHTTPClient := mock.NewMockedHTTPClient(
+		mock.WithRequestMatch(
+			mock.GetReposPullsByOwnerByRepoByPullNumber,
+			github.PullRequest{
+				ID:     &id,
+				Body:   &body,
+				Labels: nil,
+			},
+		), mock.WithRequestMatch(
+			mock.GetReposLabelsByOwnerByRepo,
+			repoLabels(),
+		),
+		mock.WithRequestMatch(mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, nil),
+		mock.WithRequestMatch(mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, nil),
+		mock.WithRequestMatch(mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, nil),
+	)
+
+	config := mustNewActionConfig()
+	action := NewActionWithClient(context.Background(), config, github.NewClient(mockedHTTPClient))
+
+	err := action.Run(1, "opened")
+	assertMessageLabel(t, err, MessageLabelMissing)
+}
+
+func TestMultipleChecked_WhenMultipleLabelsNotEnabled(t *testing.T) {
+	id := int64(1)
+	body := fmt.Sprintf(`
+Check the box below or label this PR directly.
+
+Need to update docs?
+
+- [ ] %s
+(Your PR needs to update docs and you will update later)
+
+- [x] %s
+(Please explain why)
+
+- [x] %s
+(Your PR contains doc changes)
+
+- [ ] %s
+(Docs have been already added)
+`, "`doc-required`", "`doc-not-needed`", "`doc`", "`doc-complete`")
+
+	mockedHTTPClient := mock.NewMockedHTTPClient(
+		mock.WithRequestMatch(
+			mock.GetReposPullsByOwnerByRepoByPullNumber,
+			github.PullRequest{
+				ID:     &id,
+				Body:   &body,
+				Labels: nil,
+			},
+		), mock.WithRequestMatch(
+			mock.GetReposLabelsByOwnerByRepo,
+			repoLabels(),
+		),
+		mock.WithRequestMatch(mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, nil),
+		mock.WithRequestMatch(mock.GetReposIssuesCommentsByOwnerByRepoByIssueNumber, nil),
+		mock.WithRequestMatch(mock.PostReposIssuesCommentsByOwnerByRepoByIssueNumber, nil),
+	)
+
+	config := mustNewActionConfig()
+	action := NewActionWithClient(context.Background(), config, github.NewClient(mockedHTTPClient))
+
+	err := action.Run(1, "opened")
+	assertMessageLabel(t, err, MessageLabelMultiple)
+}
+
+func TestSingleChecked_WhenLabelMissingExist(t *testing.T) {
+	id := int64(1)
+	body := fmt.Sprintf(`
+Check the box below or label this PR directly.
+
+Need to update docs?
+
+- [ ] %s
+(Your PR needs to update docs and you will update later)
+
+- [x] %s
+(Please explain why)
+
+- [ ] %s
+(Your PR contains doc changes)
+
+- [ ] %s
+(Docs have been already added)
+`, "`doc-required`", "`doc-not-needed`", "`doc`", "`doc-complete`")
+
+	labelMissing := "doc-label-missing"
+	mockedHTTPClient := mock.NewMockedHTTPClient(
+		mock.WithRequestMatch(
+			mock.GetReposPullsByOwnerByRepoByPullNumber,
+			github.PullRequest{
+				ID:     &id,
+				Body:   &body,
+				Labels: []*github.Label{{Name: &labelMissing}},
+			},
+		), mock.WithRequestMatch(
+			mock.GetReposLabelsByOwnerByRepo,
+			repoLabels(),
+		),
+		mock.WithRequestMatch(mock.PostReposIssuesLabelsByOwnerByRepoByIssueNumber, nil),
+		mock.WithRequestMatch(mock.DeleteReposIssuesLabelsByOwnerByRepoByIssueNumberByName, nil),
+	)
+
+	config := mustNewActionConfig()
+	action := NewActionWithClient(context.Background(), config, github.NewClient(mockedHTTPClient))
+
+	err := action.Run(1, "opened")
+	if err != nil {
+		t.Fatal(err)
+	}
+}
+
+func TestUnchecked_WhenLabelMissingExist(t *testing.T) {
+	id := int64(1)
+	body := fmt.Sprintf(`
+Check the box below or label this PR directly.
+
+Need to update docs?
+
+- [ ] %s
+(Your PR needs to update docs and you will update later)
+
+- [ ] %s
+(Please explain why)
+
+- [ ] %s
+(Your PR contains doc changes)
+
+- [ ] %s
+(Docs have been already added)
+`, "`doc-required`", "`doc-not-needed`", "`doc`", "`doc-complete`")
+
+	labelMissing := "doc-label-missing"
+	mockedHTTPClient := mock.NewMockedHTTPClient(
+		mock.WithRequestMatch(
+			mock.GetReposPullsByOwnerByRepoByPullNumber,
+			github.PullRequest{
+				ID:     &id,
+				Body:   &body,
+				Labels: []*github.Label{{Name: &labelMissing}},
+			},
+		), mock.WithRequestMatch(
+			mock.GetReposLabelsByOwnerByRepo,
+			repoLabels(),
+		),
+	)
+
+	config := mustNewActionConfig()
+	action := NewActionWithClient(context.Background(), config, github.NewClient(mockedHTTPClient))
+
+	err := action.Run(1, "opened")
+	assertMessageLabel(t, err, MessageLabelMissing)
+}
diff --git a/docbot/go.mod b/docbot/go.mod
index 273fc97..6fa874b 100644
--- a/docbot/go.mod
+++ b/docbot/go.mod
@@ -4,15 +4,18 @@ go 1.18
 
 require (
 	github.com/google/go-github/v45 v45.0.0
+	github.com/migueleliasweb/go-github-mock v0.0.10
 	github.com/sethvargo/go-githubactions v1.0.0
 	golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be
 )
 
 require (
 	github.com/golang/protobuf v1.3.2 // indirect
+	github.com/google/go-github/v41 v41.0.0 // indirect
 	github.com/google/go-querystring v1.1.0 // indirect
+	github.com/gorilla/mux v1.8.0 // indirect
 	github.com/sethvargo/go-envconfig v0.6.0 // indirect
 	golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 // indirect
-	golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 // indirect
+	golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 // indirect
 	google.golang.org/appengine v1.6.7 // indirect
 )
diff --git a/docbot/go.sum b/docbot/go.sum
index cc47aa0..76374c4 100644
--- a/docbot/go.sum
+++ b/docbot/go.sum
@@ -1,12 +1,22 @@
+github.com/buger/jsonparser v1.1.1/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0=
+github.com/go-kit/log v0.2.0/go.mod h1:NwTd00d/i8cPZ3xOwwiv2PO5MOcx78fFErGNcVmBjv0=
+github.com/go-logfmt/logfmt v0.5.1/go.mod h1:WYhtIu8zTZfxdn5+rREduYbwxfcBr/Vr6KEVveWlfTs=
 github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/golang/protobuf v1.3.2 h1:6nsPYzhq5kReh6QImI3k5qWzO4PEbvbIW2cwSfR/6xs=
 github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
 github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
 github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
+github.com/google/go-github/v41 v41.0.0 h1:HseJrM2JFf2vfiZJ8anY2hqBjdfY1Vlj/K27ueww4gg=
+github.com/google/go-github/v41 v41.0.0/go.mod h1:XgmCA5H323A9rtgExdTcnDkcqp6S30AVACCBDOonIxg=
 github.com/google/go-github/v45 v45.0.0 h1:LU0WBjYidxIVyx7PZeWb+FP4JZJ3Wh3FQgdumnGqiLs=
 github.com/google/go-github/v45 v45.0.0/go.mod h1:FObaZJEDSTa/WGCzZ2Z3eoCDXWJKMenWWTrd8jrta28=
 github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
 github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
+github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
+github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/migueleliasweb/go-github-mock v0.0.10 h1:VTaNa4eYzkRpnZ7Fqop7Jldgh7vsUsgnKWXDOnAvm3k=
+github.com/migueleliasweb/go-github-mock v0.0.10/go.mod h1:mD5w+9J3oBBMLr7uD6owEYlYBAL8tZd+BA7iGjI4EU8=
 github.com/sethvargo/go-envconfig v0.6.0 h1:GxxdoeiNpWgGiVEphNFNObgMYRN/ZvI2dN7rBwadyss=
 github.com/sethvargo/go-envconfig v0.6.0/go.mod h1:00S1FAhRUuTNJazWBWcJGvEHOM+NO6DhoRMAOX7FY5o=
 github.com/sethvargo/go-githubactions v1.0.0 h1:5mYGPNxIwIXaS8MLj4uYGWM8QM8giUVqA4FuSYOZjXE=
@@ -15,12 +25,15 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5 h1:HWj/xjIHfjYU5nVXpTM0s39J9CbLn7Cc5a7IC5rwsMQ=
 golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
 golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
-golang.org/x/net v0.0.0-20210226172049-e18ecbb05110 h1:qWPm9rbaAMKs8Bq/9LRpbMqxWRVUAQwMI9fVrssnTfw=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4 h1:4nGaVu0QrbjT/AK2PRLuQfQuh6DJve+pELhqTdAj3x0=
+golang.org/x/net v0.0.0-20210405180319-a5a99cb37ef4/go.mod h1:p54w0d4576C0XHj96bSt6lcn1PtDYWL6XObtHCRCNQM=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
 golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210330210617-4fbd30eecc44/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
+golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
diff --git a/docbot/main.go b/docbot/main.go
index f98bfff..1a98d44 100644
--- a/docbot/main.go
+++ b/docbot/main.go
@@ -1,586 +1,10 @@
 package main
 
 import (
-	"context"
-	"fmt"
-	"os"
-	"regexp"
-	"strings"
-
-	"github.com/google/go-github/v45/github"
-	"github.com/sethvargo/go-githubactions"
-	"golang.org/x/oauth2"
-
 	"github.com/apache/pulsar-test-infra/docbot/pkg/logger"
+	"github.com/sethvargo/go-githubactions"
 )
 
-const (
-	MessageLabelMissing = `Please provide a correct documentation label for your PR.
-Instructions see [Pulsar Documentation Label Guide](https://docs.google.com/document/d/1Qw7LHQdXWBW9t2-r-A7QdFDBwmZh6ytB4guwMoXHqc0).`
-	MessageLabelMultiple = `Please select only one documentation label for your PR.
-Instructions see [Pulsar Documentation Label Guide](https://docs.google.com/document/d/1Qw7LHQdXWBW9t2-r-A7QdFDBwmZh6ytB4guwMoXHqc0).`
-)
-
-type ActionConfig struct {
-	token  *string
-	repo   *string
-	owner  *string
-	number *int
-
-	labelPattern        *string
-	labelWatchSet       map[string]struct{}
-	labelMissing        *string
-	enableLabelMissing  *bool
-	enableLabelMultiple *bool
-
-	// labels extracted from PR body
-	labels map[string]bool
-}
-
-func NewActionConfig() (*ActionConfig, error) {
-	ownerRepoSlug := os.Getenv("GITHUB_REPOSITORY")
-	ownerRepo := strings.Split(ownerRepoSlug, "/")
-	if len(ownerRepo) != 2 {
-		return nil, fmt.Errorf("GITHUB_REPOSITORY is not found")
-	}
-	owner, repo := ownerRepo[0], ownerRepo[1]
-
-	token := os.Getenv("GITHUB_TOKEN")
-
-	labelPattern := os.Getenv("LABEL_PATTERN")
-	if len(labelPattern) == 0 {
-		labelPattern = "- \\[(.*?)\\] ?`(.+?)`"
-	}
-
-	labelWatchListSlug := os.Getenv("LABEL_WATCH_LIST")
-	labelWatchList := strings.Split(strings.TrimSpace(labelWatchListSlug), ",")
-	labelWatchSet := make(map[string]struct{})
-	for _, l := range labelWatchList {
-		labelWatchSet[l] = struct{}{}
-	}
-
-	enableLabelMissingSlug := os.Getenv("ENABLE_LABEL_MISSING")
-	enableLabelMissing := true
-	if enableLabelMissingSlug == "false" {
-		enableLabelMissing = false
-	}
-
-	labelMissing := os.Getenv("LABEL_MISSING")
-	if len(labelMissing) == 0 {
-		labelMissing = "label-missing"
-	}
-
-	enableLabelMultipleSlug := os.Getenv("ENABLE_LABEL_MULTIPLE")
-	enableLabelMultiple := false
-	if enableLabelMultipleSlug == "true" {
-		enableLabelMultiple = true
-	}
-
-	return &ActionConfig{
-		token:               &token,
-		repo:                &repo,
-		owner:               &owner,
-		labelPattern:        &labelPattern,
-		labelWatchSet:       labelWatchSet,
-		labelMissing:        &labelMissing,
-		enableLabelMissing:  &enableLabelMissing,
-		enableLabelMultiple: &enableLabelMultiple,
-	}, nil
-}
-
-func (ac *ActionConfig) GetToken() string {
-	if ac == nil || ac.token == nil {
-		return ""
-	}
-	return *ac.token
-}
-
-func (ac *ActionConfig) GetOwner() string {
-	if ac == nil || ac.owner == nil {
-		return ""
-	}
-	return *ac.owner
-}
-
-func (ac *ActionConfig) GetRepo() string {
-	if ac == nil || ac.repo == nil {
-		return ""
-	}
-	return *ac.repo
-}
-
-func (ac *ActionConfig) GetNumber() int {
-	if ac == nil || ac.number == nil {
-		return 0
-	}
-	return *ac.number
-}
-
-func (ac *ActionConfig) GetLabelPattern() string {
-	if ac == nil || ac.labelPattern == nil {
-		return ""
-	}
-	return *ac.labelPattern
-}
-
-func (ac *ActionConfig) GetLabelMissing() string {
-	if ac == nil || ac.labelMissing == nil {
-		return ""
-	}
-	return *ac.labelMissing
-}
-
-func (ac *ActionConfig) GetEnableLabelMissing() bool {
-	if ac == nil || ac.enableLabelMissing == nil {
-		return false
-	}
-	return *ac.enableLabelMissing
-}
-
-func (ac *ActionConfig) GetEnableLabelMultiple() bool {
-	if ac == nil || ac.enableLabelMultiple == nil {
-		return false
-	}
-	return *ac.enableLabelMultiple
-}
-
-type Action struct {
-	config *ActionConfig
-
-	globalContext context.Context
-	client        *github.Client
-
-	// opened, edited, labeled, unlabeled
-	event string
-}
-
-func NewAction(ac *ActionConfig) *Action {
-	ctx := context.Background()
-	ts := oauth2.StaticTokenSource(
-		&oauth2.Token{AccessToken: ac.GetToken()},
-	)
-
-	tc := oauth2.NewClient(ctx, ts)
-
-	return &Action{
-		config:        ac,
-		globalContext: ctx,
-		client:        github.NewClient(tc),
-	}
-}
-
-func (a *Action) Run(actionType string) error {
-	a.event = actionType
-	switch actionType {
-	case "opened", "edited":
-		return a.onPullRequestOpenedOrEdited()
-	case "labeled", "unlabeled":
-		return a.onPullRequestLabeledOrUnlabeled()
-	}
-	return nil
-}
-
-func (a *Action) onPullRequestOpenedOrEdited() error {
-	pr, _, err := a.client.PullRequests.Get(a.globalContext, a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber())
-	if err != nil {
-		return fmt.Errorf("get PR: %v", err)
-	}
-
-	// Get repo labels
-	logger.Infoln("@List repo labels")
-	repoLabels, err := a.getRepoLabels()
-	if err != nil {
-		return fmt.Errorf("list repo labels: %v", err)
-	}
-	logger.Infof("Repo labels: %v\n", a.labelsToString(repoLabels))
-
-	repoLabelsSet := make(map[string]struct{})
-	for _, label := range repoLabels {
-		repoLabelsSet[label.GetName()] = struct{}{}
-	}
-
-	// Get current labels on this PR
-	logger.Infoln("@List issue labels")
-	issueLabels, err := a.getIssueLabels()
-	if err != nil {
-		return fmt.Errorf("list current issue labels: %v", err)
-	}
-	logger.Infof("Issue labels: %v\n", a.labelsToString(issueLabels))
-
-	// Get the intersection of issueLabels and labelWatchSet, including labelMissing
-	logger.Infoln("@List current labels")
-	currentLabelsSet := make(map[string]struct{})
-	for _, label := range issueLabels {
-		if _, exist := a.config.labelWatchSet[label.GetName()]; !exist && label.GetName() != a.config.GetLabelMissing() {
-			continue
-		}
-		currentLabelsSet[label.GetName()] = struct{}{}
-	}
-	logger.Infof("Current labels: %v\n", a.labelsSetToString(currentLabelsSet))
-
-	// Get expected labels
-	// Only handle labels already exist in repo
-	logger.Infoln("@List expected labels")
-	expectedLabelsMap := make(map[string]bool)
-	for label, checked := range a.config.labels {
-		if _, exist := repoLabelsSet[label]; !exist {
-			logger.Infof("Found label %v not exist int repo\n", label)
-			continue
-		}
-		expectedLabelsMap[label] = checked
-	}
-	logger.Infof("Expected labels: %v\n", expectedLabelsMap)
-
-	// Remove labels
-	logger.Infoln("@Remove labels")
-	labelsToRemove := make(map[string]struct{})
-	if len(expectedLabelsMap) == 0 { // Remove current labels when PR body is empty
-		for l := range a.config.labelWatchSet {
-			if _, exist := currentLabelsSet[l]; exist {
-				labelsToRemove[l] = struct{}{}
-			}
-		}
-	} else {
-		for label := range currentLabelsSet {
-			if label == a.config.GetLabelMissing() {
-				continue
-			}
-			if checked, exist := expectedLabelsMap[label]; exist && checked {
-				continue
-			}
-			labelsToRemove[label] = struct{}{}
-		}
-	}
-
-	// Remove missing label
-	checkedCount := 0
-	for _, checked := range expectedLabelsMap {
-		if checked {
-			checkedCount++
-		}
-	}
-
-	if !a.config.GetEnableLabelMultiple() && checkedCount > 1 {
-		logger.Infoln("Multiple labels detected")
-		_, _, err = a.client.Issues.CreateComment(a.globalContext,
-			a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(),
-			&github.IssueComment{
-				Body: func(v string) *string { return &v }(fmt.Sprintf("@%s %s", pr.User.GetLogin(), MessageLabelMultiple))})
-		if err != nil {
-			return fmt.Errorf("create issue comment: %v", err)
-		}
-		return fmt.Errorf("%s", MessageLabelMultiple)
-	}
-
-	if _, exist := currentLabelsSet[a.config.GetLabelMissing()]; exist && checkedCount > 0 {
-		labelsToRemove[a.config.GetLabelMissing()] = struct{}{}
-	}
-
-	logger.Infof("Labels to remove: %v\n", a.labelsSetToString(labelsToRemove))
-
-	for label := range labelsToRemove {
-		_, err := a.client.Issues.RemoveLabelForIssue(a.globalContext, a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(), label)
-		if err != nil {
-			return fmt.Errorf("remove label %v: %v", label, err)
-		}
-	}
-
-	// Add labels
-	logger.Infoln("@Add labels")
-
-	labelsToAdd := []string{}
-	for label, checked := range expectedLabelsMap {
-		if !checked {
-			continue
-		}
-		if _, exist := currentLabelsSet[label]; !exist {
-			labelsToAdd = append(labelsToAdd, label)
-		}
-	}
-
-	if len(labelsToAdd) == 0 {
-		logger.Infoln("No labels to add.")
-	} else {
-		logger.Infof("Labels to add: %v\n", labelsToAdd)
-
-		_, _, err = a.client.Issues.AddLabelsToIssue(a.globalContext, a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(), labelsToAdd)
-		if err != nil {
-			logger.Infof("Add labels %v: %v\n", labelsToAdd, err)
-		}
-	}
-
-	// Add missing label
-	if a.config.GetEnableLabelMissing() && checkedCount == 0 {
-		if _, exist := currentLabelsSet[a.config.GetLabelMissing()]; exist {
-			logger.Infoln("Already added missing label.")
-			return fmt.Errorf("%s", MessageLabelMissing)
-		}
-
-		logger.Infoln("@Add missing label")
-		_, _, err = a.client.Issues.AddLabelsToIssue(a.globalContext,
-			a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(),
-			[]string{a.config.GetLabelMissing()})
-		if err != nil {
-			return fmt.Errorf("add missing label %v: %v", a.config.GetLabelMissing(), err)
-		}
-
-		_, _, err = a.client.Issues.CreateComment(a.globalContext,
-			a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(),
-			&github.IssueComment{
-				Body: func(v string) *string { return &v }(fmt.Sprintf("@%s %s", pr.User.GetLogin(), MessageLabelMissing))})
-		if err != nil {
-			logger.Infof("Create issue comment: %v\n", err)
-		}
-
-		return fmt.Errorf("%s", MessageLabelMissing)
-	}
-
-	return nil
-}
-
-func (a *Action) onPullRequestLabeledOrUnlabeled() error {
-	pr, _, err := a.client.PullRequests.Get(a.globalContext, a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber())
-	if err != nil {
-		return fmt.Errorf("get PR: %v", err)
-	}
-
-	// Get repo labels
-	logger.Infoln("@List repo labels")
-	repoLabels, err := a.getRepoLabels()
-	if err != nil {
-		return fmt.Errorf("list repo labels: %v", err)
-	}
-	logger.Infof("Repo labels: %v\n", a.labelsToString(repoLabels))
-
-	repoLabelsSet := make(map[string]struct{})
-	for _, label := range repoLabels {
-		repoLabelsSet[label.GetName()] = struct{}{}
-	}
-
-	// Get current labels on this PR
-	logger.Infoln("@List issue labels")
-	issueLabels, err := a.getIssueLabels()
-	if err != nil {
-		return fmt.Errorf("list current issue labels: %v", err)
-	}
-	logger.Infof("Issue labels: %v\n", a.labelsToString(issueLabels))
-
-	// Get the intersection of issueLabels and labelWatchSet, including labelMissing
-	logger.Infoln("@List current labels")
-	currentLabelsSet := make(map[string]struct{})
-	for _, label := range issueLabels {
-		if _, exist := a.config.labelWatchSet[label.GetName()]; !exist && label.GetName() != a.config.GetLabelMissing() {
-			continue
-		}
-		currentLabelsSet[label.GetName()] = struct{}{}
-	}
-	logger.Infof("Current labels: %v\n", a.labelsSetToString(currentLabelsSet))
-
-	// Get expected labels
-	// Only handle labels already exist in repo
-	logger.Infoln("@List expected labels")
-	expectedLabelsMap := make(map[string]bool)
-	for label, checked := range a.config.labels {
-		if _, exist := repoLabelsSet[label]; !exist {
-			logger.Infof("Found label %v not exist int repo\n", label)
-			continue
-		}
-		expectedLabelsMap[label] = checked
-	}
-	logger.Infof("Expected labels: %v\n", expectedLabelsMap)
-
-	// Remove missing label
-	labelsToRemove := make(map[string]struct{})
-	checkedCount := 0
-	for label := range currentLabelsSet {
-		if label != a.config.GetLabelMissing() {
-			checkedCount++
-		}
-	}
-
-	if !a.config.GetEnableLabelMultiple() && checkedCount > 1 {
-		logger.Infoln("Multiple labels detected")
-		_, _, err = a.client.Issues.CreateComment(a.globalContext,
-			a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(),
-			&github.IssueComment{
-				Body: func(v string) *string { return &v }(fmt.Sprintf("@%s %s", pr.User.GetLogin(), MessageLabelMultiple))})
-		if err != nil {
-			return fmt.Errorf("create issue comment: %v", err)
-		}
-		return fmt.Errorf("%s", MessageLabelMultiple)
-	}
-
-	if _, exist := currentLabelsSet[a.config.GetLabelMissing()]; exist && checkedCount > 0 {
-		labelsToRemove[a.config.GetLabelMissing()] = struct{}{}
-	}
-
-	logger.Infof("Labels to remove: %v\n", labelsToRemove)
-
-	for label := range labelsToRemove {
-		_, err := a.client.Issues.RemoveLabelForIssue(a.globalContext, a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(), label)
-		if err != nil {
-			return fmt.Errorf("remove label %v: %v", label, err)
-		}
-	}
-
-	// Add missing label
-	if a.config.GetEnableLabelMissing() && checkedCount == 0 {
-		if _, exist := currentLabelsSet[a.config.GetLabelMissing()]; exist {
-			logger.Infoln("Already added missing label.")
-			return fmt.Errorf("%s", MessageLabelMissing)
-		}
-
-		logger.Infoln("@Add missing label")
-		_, _, err = a.client.Issues.AddLabelsToIssue(a.globalContext,
-			a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(),
-			[]string{a.config.GetLabelMissing()})
-		if err != nil {
-			return fmt.Errorf("add missing label %v: %v", a.config.GetLabelMissing(), err)
-		}
-
-		_, _, err = a.client.Issues.CreateComment(a.globalContext,
-			a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(),
-			&github.IssueComment{
-				Body: func(v string) *string { return &v }(fmt.Sprintf("@%s %s", pr.User.GetLogin(), MessageLabelMissing))})
-		if err != nil {
-			logger.Infof("Create issue comment: %v\n", err)
-		}
-
-		return fmt.Errorf("%s", MessageLabelMissing)
-	}
-
-	// Update PR Body
-	// Compare current labels and expected labels
-	if a.event == "unlabeled" {
-		return nil
-	}
-
-	changeList := make(map[string]bool)
-	for label := range currentLabelsSet {
-		if checked, exist := expectedLabelsMap[label]; exist && checked {
-			continue
-		}
-
-		// If not exist, need to add
-
-		// If exist but not checked, need to update
-
-		changeList[label] = true
-	}
-
-	for label, checked := range expectedLabelsMap {
-		if _, exist := currentLabelsSet[label]; !exist && checked {
-			changeList[label] = false
-		}
-	}
-
-	body := pr.GetBody()
-	for label, checked := range changeList {
-		src := fmt.Sprintf("- [ ] `%s`", label)
-		dst := fmt.Sprintf("- [x] `%s`", label)
-		if !checked {
-			src = fmt.Sprintf("- [x] `%s`", label)
-			dst = fmt.Sprintf("- [ ] `%s`", label)
-		}
-
-		if strings.Contains(body, src) { // Update the label
-			body = strings.Replace(body, src, dst, 1)
-		} else { // Add the label
-			body = fmt.Sprintf("%s\r\n%s\r\n", body, dst)
-		}
-	}
-
-	if len(changeList) > 0 {
-		logger.Infoln("@Update PR body")
-		logger.Infof("ChangeList: %v\n", changeList)
-
-		_, _, err = a.client.PullRequests.Edit(a.globalContext, a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(),
-			&github.PullRequest{Body: &body})
-		if err != nil {
-			return fmt.Errorf("edit PR: %v", err)
-		}
-	}
-
-	return nil
-}
-
-func (a *Action) extractLabels(prBody string) map[string]bool {
-	r := regexp.MustCompile(a.config.GetLabelPattern())
-	targets := r.FindAllStringSubmatch(prBody, -1)
-	labels := make(map[string]bool)
-
-	//// Init labels from watch list
-	//for label := range a.config.labelWatchSet {
-	//	labels[label] = false
-	//}
-
-	for _, v := range targets {
-		checked := strings.ToLower(strings.TrimSpace(v[1])) == "x"
-		name := strings.TrimSpace(v[2])
-
-		// Filter uninterested labels
-		if _, exist := a.config.labelWatchSet[name]; !exist {
-			continue
-		}
-
-		labels[name] = checked
-	}
-
-	return labels
-}
-
-func (a *Action) getRepoLabels() ([]*github.Label, error) {
-	ctx := context.Background()
-	listOptions := &github.ListOptions{PerPage: 100}
-	repoLabels := make([]*github.Label, 0)
-	for {
-		rLabels, resp, err := a.client.Issues.ListLabels(ctx, a.config.GetOwner(), a.config.GetRepo(), listOptions)
-		if err != nil {
-			return nil, err
-		}
-		repoLabels = append(repoLabels, rLabels...)
-		if resp.NextPage == 0 {
-			break
-		}
-		listOptions.Page = resp.NextPage
-	}
-	return repoLabels, nil
-}
-
-func (a *Action) getIssueLabels() ([]*github.Label, error) {
-	ctx := context.Background()
-	listOptions := &github.ListOptions{PerPage: 100}
-	issueLabels := make([]*github.Label, 0)
-	for {
-		iLabels, resp, err := a.client.Issues.ListLabelsByIssue(ctx, a.config.GetOwner(), a.config.GetRepo(), a.config.GetNumber(), listOptions)
-		if err != nil {
-			return nil, err
-		}
-		issueLabels = append(issueLabels, iLabels...)
-		if resp.NextPage == 0 {
-			break
-		}
-		listOptions.Page = resp.NextPage
-	}
-	return issueLabels, nil
-}
-
-func (a *Action) labelsToString(labels []*github.Label) []string {
-	result := []string{}
-	for _, label := range labels {
-		result = append(result, label.GetName())
-	}
-	return result
-}
-
-func (a *Action) labelsSetToString(labels map[string]struct{}) []string {
-	result := []string{}
-	for label := range labels {
-		result = append(result, label)
-	}
-	return result
-}
-
 func main() {
 	logger.Infoln("@Start docbot")
 
@@ -597,8 +21,6 @@ func main() {
 	}
 
 	switch githubContext.EventName {
-	case "issues":
-		logger.Infoln("@EventName is issues")
 	case "pull_request", "pull_request_target":
 		logger.Infoln("@EventName is PR")
 
@@ -607,26 +29,8 @@ func main() {
 			logger.Fatalln("Action type is not string")
 		}
 
-		pr := githubContext.Event["pull_request"]
-		pullRequest, ok := pr.(map[string]interface{})
-		if !ok {
-			logger.Fatalln("PR event is not map")
-		}
-
 		number := int(githubContext.Event["number"].(float64))
-
-		prBody, ok := pullRequest["body"].(string)
-		if !ok {
-			logger.Fatalln("PR body is not string")
-		}
-
-		// Get expected labels
-		labels := action.extractLabels(prBody)
-
-		actionConfig.number = &number
-		actionConfig.labels = labels
-
-		if err := action.Run(actionType); err != nil {
+		if err := action.Run(number, actionType); err != nil {
 			logger.Fatalln(err)
 		}
 	}