Implement a new documentation bot (#43)

diff --git a/docbot/README.md b/docbot/README.md
new file mode 100644
index 0000000..622ee20
--- /dev/null
+++ b/docbot/README.md
@@ -0,0 +1,54 @@
+# Documentation Bot 
+
+Automatically label pull requests based on the checked task list.
+
+## Usage
+
+Create a workflow `.github/workflows/ci-docbot.yml` with below content:
+
+```yaml
+name: Documentation Bot
+
+on:
+  pull_request_target:
+    types:
+      - opened
+      - edited
+      - labeled
+      - unlabeled
+
+jobs:
+  label:
+    permissions:
+      pull-requests: write
+    runs-on: ubuntu-latest
+    steps:
+      - name: Checkout action
+        uses: actions/checkout@v3
+        with:
+          repository: maxsxu/action-labeler
+          ref: master
+
+      - name: Set up Go
+        uses: actions/setup-go@v3
+        with:
+          go-version: 1.18
+
+      - name: Labeling
+        uses: maxsxu/action-labeler@master
+        env:
+          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+          LABEL_WATCH_LIST: 'doc,doc-required,doc-not-needed,doc-complete,doc-label-missing'
+          LABEL_MISSING: 'doc-label-missing'
+```
+
+## Configurations
+
+| Name                    | Description                            | Default                   |
+| ----------------------- |----------------------------------------| ------------------------- |
+| `GITHUB_TOKEN`          | The GitHub Token                       |                     |
+| `LABEL_PATTERN`         | RegExp to extract labels               | `'- \[(.*?)\] ?`(.+?)`' ` |
+| `LABEL_WATCH_LIST`      | Label names to watch, separated by `,` |   |
+| `ENABLE_LABEL_MISSING`  | Add a label missing if none selected   | `true`                    |
+| `LABEL_MISSING`         | The label mssing name                  | `label-missing` |
+| `ENABLE_LABEL_MULTIPLE` | Allow multiple labels selected         | `false`                   |
\ No newline at end of file
diff --git a/docbot/action.yml b/docbot/action.yml
new file mode 100644
index 0000000..0156b25
--- /dev/null
+++ b/docbot/action.yml
@@ -0,0 +1,12 @@
+name: 'Documentation Bot'
+description: 'Automatically label pull requests based on the checked task list'
+author: 'Max Xu <maxs.xu@gmail.com>'
+branding:
+  icon: 'check'
+  color: 'green'
+
+runs:
+  using: composite
+  steps:
+    - run: go run main.go
+      shell: bash
diff --git a/docbot/go.mod b/docbot/go.mod
new file mode 100644
index 0000000..273fc97
--- /dev/null
+++ b/docbot/go.mod
@@ -0,0 +1,18 @@
+module github.com/apache/pulsar-test-infra/docbot
+
+go 1.18
+
+require (
+	github.com/google/go-github/v45 v45.0.0
+	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-querystring v1.1.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
+	google.golang.org/appengine v1.6.7 // indirect
+)
diff --git a/docbot/main.go b/docbot/main.go
new file mode 100644
index 0000000..aceb279
--- /dev/null
+++ b/docbot/main.go
@@ -0,0 +1,623 @@
+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"
+)
+
+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 {
+		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 {
+		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")
+
+	actionConfig, err := NewActionConfig()
+	if err != nil {
+		logger.Fatalf("Get action config: %v\n", err)
+	}
+
+	action := NewAction(actionConfig)
+
+	githubContext, err := githubactions.Context()
+	if err != nil {
+		logger.Fatalf("Get github context: %v\n", err)
+	}
+
+	switch githubContext.EventName {
+	case "issues":
+		logger.Infoln("@EventName is issues")
+	case "pull_request", "pull_request_target":
+		logger.Infoln("@EventName is PR")
+
+		actionType, ok := githubContext.Event["action"].(string)
+		if !ok {
+			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 {
+			logger.Fatalln(err)
+		}
+	}
+}
diff --git a/docbot/pkg/logger/logger.go b/docbot/pkg/logger/logger.go
new file mode 100644
index 0000000..40691a1
--- /dev/null
+++ b/docbot/pkg/logger/logger.go
@@ -0,0 +1,55 @@
+package logger
+
+import (
+	"fmt"
+	"log"
+	"os"
+)
+
+const (
+	// Prefix
+	InfoPrefix  = "[INFO] "
+	ErrorPrefix = "[ERROR] "
+	FatalPrefix = "[FATAL] "
+
+	// Color
+	Reset  = "\033[0m"
+	Red    = "\033[31m"
+	Green  = "\033[32m"
+	Yellow = "\033[33m"
+	Blue   = "\033[34m"
+	Purple = "\033[35m"
+	Cyan   = "\033[36m"
+	Gray   = "\033[37m"
+	White  = "\033[97m"
+
+	// background
+	BgRed      = "\033[41m"
+	BgLightRed = "\033[101m"
+)
+
+func Infoln(v ...interface{}) {
+	log.New(os.Stderr, Cyan+InfoPrefix+Reset, log.LstdFlags).Output(2, fmt.Sprintln(v...))
+}
+
+func Infof(format string, v ...interface{}) {
+	log.New(os.Stderr, Cyan+InfoPrefix+Reset, log.LstdFlags).Output(2, fmt.Sprintf(format, v...))
+}
+
+func Errorln(v ...interface{}) {
+	log.New(os.Stderr, Red+ErrorPrefix+Reset, log.LstdFlags|log.Llongfile).Output(2, fmt.Sprintln(v...))
+}
+
+func Errorf(format string, v ...interface{}) {
+	log.New(os.Stderr, Red+ErrorPrefix+Reset, log.LstdFlags|log.Llongfile).Output(2, fmt.Sprintf(format, v...))
+}
+
+func Fatalf(format string, v ...interface{}) {
+	log.New(os.Stderr, Red+FatalPrefix+Reset, log.LstdFlags|log.Llongfile).Output(2, fmt.Sprintf(format, v...))
+	os.Exit(1)
+}
+
+func Fatalln(v ...interface{}) {
+	log.New(os.Stderr, Red+FatalPrefix+Reset, log.LstdFlags|log.Llongfile).Output(2, fmt.Sprintln(v...))
+	os.Exit(1)
+}