You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@mxnet.apache.org by zh...@apache.org on 2020/10/12 01:45:40 UTC

[incubator-mxnet-ci] branch master updated: Serverless based Lambda function for Labelling PRs based on CI & PR Review status (#27)

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

zhasheng pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/incubator-mxnet-ci.git


The following commit(s) were added to refs/heads/master by this push:
     new 0fb0e75  Serverless based Lambda function for Labelling PRs based on CI & PR Review status (#27)
0fb0e75 is described below

commit 0fb0e75b83a371130caf43098ec1e5b12326cb25
Author: Chaitanya Prakash Bapat <ch...@gmail.com>
AuthorDate: Sun Oct 11 18:42:50 2020 -0700

    Serverless based Lambda function for Labelling PRs based on CI & PR Review status (#27)
    
    * add redacted working code for lambda
    
    * drop jenkins-related code
    
    * rename the folder, update logic to handle SQS, custom domain
    
    * log context & state of PR
    
    * update status retrieval & labeling logic
    
    * comment out actual label addition/deletion for dev purposes and replace by logging
    
    * define constants, str to int, improve logging
    
    * test user
    
    * constants to uppercase
    
    * add logic for filtering reviews by MXNet committer
    
    * log non mxnet committer
    
    * add simple test, remove old tests
    
    * add tests, remove json, improve logging
    
    * check for commit with no review & non-MX committer review
    
    * remove unnecessary method to get reviewer
    
    * factor out review parse
    
    * move github_obj to constructor
    
    * create a class for github, add a minimal barebones test for mocking github obj
    
    * fix lint for handler
    
    * previous test file
    
    * update logic for counting reviews, filter out reviews on stale commits
    
    * add mock test file
    
    * move requirements so that serverless deployment script can use it
    
    * update license header in files
    
    * enable actual Bot action to add & remove label
    
    * replace mock classes with actual PyGithub class
    
    * Revert "enable actual Bot action to add & remove label"
    
    This reverts commit 24fb126aec7e1e04f76f5c32767d104c983e46a4.
    
    * add logic to handle staggred build statuses since combined_status_state doesnt capture it
---
 .gitignore                                         |   4 +-
 services/lambda-pr-status-labeler/README.md        |  19 +
 .../pr_status_bot/PRStatusBot.py                   | 407 +++++++++++++++++++++
 .../pr_status_bot/__init__.py                      |   0
 .../pr_status_bot/deploy_lambda.sh                 |  17 +
 .../pr_status_bot/environment.yml                  |  25 ++
 .../pr_status_bot/handler.py                       |  69 ++++
 .../pr_status_bot/requirements.txt                 |  16 +
 .../pr_status_bot/secret_manager.py                |  56 +++
 .../pr_status_bot/serverless.yml                   |  80 ++++
 .../lambda-pr-status-labeler/pr_status_bot/test.py | 112 ++++++
 .../pr_status_bot/test_mock.py                     | 115 ++++++
 12 files changed, 919 insertions(+), 1 deletion(-)

diff --git a/.gitignore b/.gitignore
index a0420e1..342ec9a 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,13 @@
 *.DS_STORE
 *~
+
+# IDE
 .vscode/
 .idea
 
 # Terraform scripts
 .terraform/
-terraform
+terraform/
 
 # Serverless scripts
 node_modules
diff --git a/services/lambda-pr-status-labeler/README.md b/services/lambda-pr-status-labeler/README.md
new file mode 100644
index 0000000..d2241a1
--- /dev/null
+++ b/services/lambda-pr-status-labeler/README.md
@@ -0,0 +1,19 @@
+# Deploy the lambda function
+Deploy this lambda function for labeling PRs based on the CI status using _serverless framework_
+- Configure AWS profile [mxnet-ci or mxnet-ci-dev] based on the stage **[dev/prod]**
+- Deploy
+```
+./deploy_lambda.sh
+```
+
+# Manual Steps
+1. Creation of Domain name
+- In `serverless.yml` within the customDomain section specify the domain name you would like to use.
+- Similarly, specify the `basePath` and the `stage` (this correlates to your API Gateway function) i.e. dev and dev stage.
+- You will need to request a Certificate for your new domain, so under AWS Certificate Manager add your domain name and validate using DNS service.
+# Note
+Make sure to create the certificate in us-east-1 N. Virginia
+- To install the plugin, run `npm install serverless-domain-manager --save-dev`.
+- After this run `serverless create_domain` (process may take some time and is meant to only run once)
+- Afterwards run `serverless deploy -v`
+- Specify this domain name (and the specific endpoint where your function points to in the API Gateway Console)
\ No newline at end of file
diff --git a/services/lambda-pr-status-labeler/pr_status_bot/PRStatusBot.py b/services/lambda-pr-status-labeler/pr_status_bot/PRStatusBot.py
new file mode 100644
index 0000000..4bfa163
--- /dev/null
+++ b/services/lambda-pr-status-labeler/pr_status_bot/PRStatusBot.py
@@ -0,0 +1,407 @@
+#!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# -*- coding: utf-8 -*-
+import ast
+import json
+import hmac
+import hashlib
+import os
+import logging
+import re
+import secret_manager
+
+from github import Github
+
+# Define the constants
+# Github labels
+PR_WORK_IN_PROGRESS_LABEL = 'pr-work-in-progress'
+PR_AWAITING_TESTING_LABEL = 'pr-awaiting-testing'
+PR_AWAITING_MERGE_LABEL = 'pr-awaiting-merge'
+PR_AWAITING_REVIEW_LABEL = 'pr-awaiting-review'
+PR_AWAITING_RESPONSE_LABEL = 'pr-awaiting-response'
+
+WORK_IN_PROGRESS_TITLE_SUBSTRING = 'WIP'
+
+# CI state
+FAILURE_STATE = 'failure'
+PENDING_STATE = 'pending'
+SUCCESS_STATE = 'success'
+
+# Review state
+APPROVED_STATE = 'APPROVED'
+CHANGES_REQUESTED_STATE = 'CHANGES_REQUESTED'
+COMMENTED_STATE = 'COMMENTED'
+DISMISSED_STATE = 'DISMISSED'
+
+
+class GithubObj:
+    def __init__(self,
+                 github_personal_access_token=None,
+                 apply_secret=True):
+        """
+        Initializes the Github Object
+        :param github_personal_access_token: GitHub authentication token (Personal access token)
+        :param apply_secret: GitHub secret credential (Secret credential that is unique to a GitHub developer)
+        """
+        self.github_personal_access_token = github_personal_access_token
+        if apply_secret:
+            self._get_secret()
+        self.github_object = self._get_github_object()
+
+    def _get_github_object(self):
+        """
+        This method returns github object initialized with Github personal access token
+        """
+        github_obj = Github(self.github_personal_access_token)
+        return github_obj
+
+    def _get_secret(self):
+        """
+        This method is to get secret value from Secrets Manager
+        """
+        secret = json.loads(secret_manager.get_secret())
+        self.github_personal_access_token = secret["github_personal_access_token"]
+
+
+class PRStatusBot:
+    def __init__(self,
+                 repo=os.environ.get("repo"),
+                 github_obj=None,
+                 apply_secret=True):
+        """
+        Initializes the PR Status Bot
+        :param repo: GitHub repository that is being referenced
+        :param apply_secret: GitHub secret credential (Secret credential that is unique to a GitHub developer)
+        """
+        self.repo = repo
+        self.github_obj = github_obj
+        self.latest_commit_sha = None
+        if apply_secret:
+            self._get_secret()
+
+    def _get_secret(self):
+        """
+        This method is to get secret value from Secrets Manager
+        """
+        secret = json.loads(secret_manager.get_secret())
+        self.webhook_secret = secret["webhook_secret"]
+
+    def _secure_webhook(self, event):
+        """
+        This method will validate the security of the webhook, it confirms that the secret
+        of the webhook is matched and that each github event is signed appropriately
+        :param event: The github event we want to validate
+        :return Response denoting success or failure of security
+        """
+
+        # Validating github event is signed
+        try:
+            git_signed = ast.literal_eval(event["Records"][0]['body'])['headers']["X-Hub-Signature"]
+        except KeyError:
+            raise Exception("WebHook from GitHub is not signed")
+        git_signed = git_signed.replace('sha1=', '')
+
+        # Signing our event with the same secret as what we assigned to github event
+        secret = self.webhook_secret
+        body = ast.literal_eval(event["Records"][0]['body'])['body']
+        secret_sign = hmac.new(key=secret.encode('utf-8'), msg=body.encode('utf-8'), digestmod=hashlib.sha1).hexdigest()
+
+        # Validating signatures match
+        return hmac.compare_digest(git_signed, secret_sign)
+
+    def _get_pull_request_object(self, pr_number):
+        """
+        This method returns a PullRequest object based on the PR number
+        :param pr_number
+        """
+        repo = self.github_obj.get_repo(self.repo)
+        pr_obj = repo.get_pull(int(pr_number))
+        return pr_obj
+
+    def _get_commit_object(self, commit_sha):
+        """
+        This method returns a Commit object based on the SHA of the commit
+        :param commit_sha
+        """
+        repo = self.github_obj.get_repo(self.repo)
+        commit_obj = repo.get_commit(commit_sha)
+        return commit_obj
+
+    def _is_mxnet_committer(self, reviewer):
+        """
+        This method checks if the Pull Request reviewer is a member of MXNet committers
+        It uses the Github API for fetching team members of a repo
+        Only a Committer can access [read/write] to Apache MXNet Committer team on Github
+        Retrieved the Team ID of the Apache MXNet Committer team on Github using a Committer's credentials
+        """
+        team = self.github_obj.get_organization('apache').get_team(2413476)
+        return team.has_in_members(reviewer)
+
+    def _drop_other_pr_labels(self, pr, desired_label):
+        labels = pr.get_labels()
+        if not labels:
+            logging.info('No labels found')
+            return
+
+        for label in labels:
+            logging.info(f'Label:{label}')
+            if label.name.startswith('pr-') and label.name != desired_label:
+                try:
+                    logging.info(f'Removing {label}')
+                    # pr.remove_from_labels(label)
+                except Exception:
+                    logging.error(f'Error while removing the label {label}')
+
+    def _add_label(self, pr, label):
+        # drop other PR labels
+        self._drop_other_pr_labels(pr, label)
+
+        # check if the PR already has the desired label
+        if(self._has_desired_label(pr, label)):
+            logging.info(f'PR {pr.number} already contains the label {label}')
+            return
+
+        logging.info(f'BOT Labels: {label}')
+        # try:
+        #     pr.add_to_labels(label)
+        # except Exception:
+        #     logging.error(f'Unable to add label {label}')
+
+        # verify that label has been correctly added
+        # if(self._has_desired_label(pr, label)):
+        #     logging.info(f'Successfully labeled {label} for PR-{pr.number}')
+        return
+
+    def _has_desired_label(self, pr, desired_label):
+        """
+        This method returns True if desired label found in PR labels
+        """
+        labels = pr.get_labels()
+        for label in labels:
+            if desired_label == label.name:
+                return True
+        return False
+
+    def get_review_counts(self, review, approvers, change_requesters, commenters, dismissed):
+        if review.state == APPROVED_STATE:
+            approvers.append(review.user.login)
+        elif review.state == CHANGES_REQUESTED_STATE:
+            change_requesters.append(review.user.login)
+        elif review.state == COMMENTED_STATE:
+            commenters.append(review.user.login)
+        elif review.state == DISMISSED_STATE:
+            dismissed.append(review.user.login)
+        else:
+            logging.error(f'Unknown review state {review.state}')
+        return approvers, change_requesters, commenters, dismissed
+
+    def _parse_reviews(self, pr):
+        """
+        This method parses through the reviews of the PR and returns count of
+        3 states: Approved reviews, Comment reviews, Requested Changes reviews
+
+        All these 3 states take into account if there are dismissed reviews.
+        Approved review / Requested changes review can be dismissed.
+        If dismissed, then the review doesn't count.
+        Note: Only reviews by MXNet Committers are considered.
+        :param pr
+        """
+        approvers = []
+        change_requesters = []
+        commenters = []
+        dismissed = []
+        for review in pr.get_reviews():
+            # continue if the review is for a stale commit
+            if(review.commit_id != self.latest_commit_sha):
+                continue
+            # continue if the review is by non-committer
+            reviewer = review.user
+            if not self._is_mxnet_committer(reviewer):
+                logging.info(f'Review is by non-MXNet Committer: {reviewer}. Ignore.')
+                continue
+            approvers, change_requesters, commenters, dismissed = self.get_review_counts(review, approvers, change_requesters, commenters, dismissed)
+        approvers = list(set(approvers) - set(dismissed))
+        change_requesters = list(set(change_requesters) - set(dismissed))
+        commenters = list(set(commenters))
+        approved_count = len(approvers) if len(approvers) else 0
+        requested_changes_count = len(change_requesters) if len(change_requesters) else 0
+        comment_count = len(commenters) if len(commenters) else 0
+        return approved_count, requested_changes_count, comment_count
+
+    def _label_pr_based_on_status(self, full_build_status_state, pull_request_obj):
+        """
+        This method checks the CI status of the specific commit of the PR
+        and it labels the PR accordingly
+        :param full_build_status_state
+        :param pull_request_obj
+        """
+        # pseudo-code
+        # if WIP in title or PR is draft or CI failed:
+        #   pr-work-in-progress
+        # elif CI has not started yet or CI is in progress:
+        #   pr-awaiting-testing
+        # else: # CI passed checks
+        #   if pr has at least one approval and no request changes:
+        #       pr-awaiting-merge
+        #   elif pr has no review or all reviews have been dismissed/re-requested:
+        #       pr-awaiting-review
+        #   else: # pr has a review that hasn't been dismissed yet no approval
+        #       pr-awaiting-response
+
+        # combined status of PR can be 1 of the 3 potential states
+        # https://developer.github.com/v3/repos/statuses/#get-the-combined-status-for-a-specific-reference
+        wip_in_title, ci_failed, ci_pending = False, False, False
+        if full_build_status_state == FAILURE_STATE:
+            ci_failed = True
+        elif full_build_status_state == PENDING_STATE:
+            ci_pending = True
+
+        if WORK_IN_PROGRESS_TITLE_SUBSTRING in pull_request_obj.title:
+            logging.info('WIP in PR Title')
+            wip_in_title = True
+        work_in_progress_conditions = wip_in_title or pull_request_obj.draft or ci_failed
+        if work_in_progress_conditions:
+            self._add_label(pull_request_obj, PR_WORK_IN_PROGRESS_LABEL)
+        elif ci_pending:
+            self._add_label(pull_request_obj, PR_AWAITING_TESTING_LABEL)
+        else:  # CI passed since status=successful
+            # parse reviews to assess count of approved/requested changes/commented reviews
+            # make sure you take into account dismissed reviews
+            approves, request_changes, comments = self._parse_reviews(pull_request_obj)
+            if approves > 0 and request_changes == 0:
+                self._add_label(pull_request_obj, PR_AWAITING_MERGE_LABEL)
+            else:
+                # decisive review means approve/request change
+                # comment is a non-decisive review
+                has_no_decisive_reviews = approves + request_changes == 0
+                if has_no_decisive_reviews:
+                    self._add_label(pull_request_obj, PR_AWAITING_REVIEW_LABEL)
+                else:
+                    self._add_label(pull_request_obj, PR_AWAITING_RESPONSE_LABEL)
+        return
+
+    def _get_latest_commit(self, pull_request_obj):
+        """
+        This method returns the latest commit of the Pull Request
+        :param pull_request_obj
+        :returns latest_commit
+        """
+        latest_commit = pull_request_obj.get_commits()[pull_request_obj.commits - 1]
+        return latest_commit
+
+    def _is_stale_commit(self, commit_sha, pull_request_obj):
+        """
+        This method checks if the given commit is stale or not
+        :param commit_sha
+        :param pull_request_obj
+        :returns boolean
+        """
+        latest_commit = self._get_latest_commit(pull_request_obj)
+        self.latest_commit_sha = latest_commit.sha
+        if commit_sha == self.latest_commit_sha:
+            logging.info(f'Current commit {commit_sha} is latest commit of PR {pull_request_obj.number}')
+            return False
+        else:
+            logging.info(f'Latest commit of PR {pull_request_obj.number}: {self.latest_commit_sha}')
+            logging.info(f'Current status belongs to stale commit {commit_sha}')
+            return True
+
+    def _get_full_build_status_from_combined_status(self, commit_obj, combined_status_state):
+        """
+        Due to staggered build pipelines, combined status isn't reflective of full build status
+        i.e. When sanity passes, combined_status_state = Success
+        It should be pending since other pipelines aren't successful yet.
+        However, combined_status_state only takes into account the pipelines that have been triggered until that point.
+        Thus, manually check if combined_status_state and length of combined_statuses
+        """
+        combined_status_list = commit_obj.get_combined_status().statuses
+        combined_status_length = len(combined_status_list)
+        logging.info(f'Combined Status Length: {combined_status_length}')
+        if combined_status_state == SUCCESS_STATE and combined_status_length == 1:
+            # Only sanity build has passed; rest of the builds haven't been triggered
+            return PENDING_STATE
+        # Full build has been triggered
+        return combined_status_state
+
+    def parse_payload(self, payload):
+        """
+        This method parses the payload and process it according to the event status
+        """
+        # CI is run for non-PR commits as well
+        # for instance, after PR is merged into the master/v1.x branch
+        # we exit in such a case
+        # to detect if the status update is for a PR commit or a merged commit
+        # we rely on Target_URL in the event payload
+        # e.g. http//jenkins.mxnet-ci.amazon-ml.com/job/mxnet-validation/job/sanity/job/PR-18899/1/display/redirect
+        target_url = payload['target_url']
+        if 'PR' not in target_url:
+            logging.info('Status update doesnt belong to a PR commit')
+            return 1
+        # strip PR number from the target URL
+        # use raw string instead of normal string to make regex check pep8 compliant
+        pr_number = re.search(r"PR-(\d+)", target_url, re.IGNORECASE).group(1)
+        logging.info(f'--------- PR : {pr_number} ----------')
+        pull_request_obj = self._get_pull_request_object(pr_number)
+
+        # verify PR is open
+        # return if PR is closed
+        if pull_request_obj.state == 'closed':
+            logging.info('PR is closed. No point in labeling')
+            return 2
+
+        # CI runs for stale commits
+        # return if its status update of a stale commit
+        commit_sha = payload['commit']['sha']
+        if self._is_stale_commit(commit_sha, pull_request_obj):
+            return
+
+        context = payload['context']
+        state = payload['state']
+
+        logging.info(f'PR Context: {context}')
+        logging.info(f'Context State: {state}')
+
+        commit_obj = self._get_commit_object(commit_sha)
+        combined_status_state = commit_obj.get_combined_status().state
+        logging.info(f'PR Combined Status State: {combined_status_state}')
+        full_build_status_state = self._get_full_build_status_from_combined_status(commit_obj, combined_status_state)
+        logging.info(f'PR Full Build Status State: {full_build_status_state}')
+        self._label_pr_based_on_status(full_build_status_state, pull_request_obj)
+
+    def parse_webhook_data(self, event):
+        """
+        This method handles the processing for each PR depending on the appropriate Github event
+        information provided by the Github Webhook.
+        """
+        try:
+            github_event = ast.literal_eval(event["Records"][0]['body'])['headers']["X-GitHub-Event"]
+            logging.info(f"github event {github_event}")
+        except KeyError:
+            raise Exception("Not a GitHub Event")
+
+        if not self._secure_webhook(event):
+            raise Exception("Failed to validate WebHook security")
+
+        try:
+            payload = json.loads(ast.literal_eval(event["Records"][0]['body'])['body'])
+        except ValueError:
+            raise Exception("Decoding JSON for payload failed")
+
+        self.parse_payload(payload)
diff --git a/services/lambda-pr-status-labeler/pr_status_bot/__init__.py b/services/lambda-pr-status-labeler/pr_status_bot/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/services/lambda-pr-status-labeler/pr_status_bot/deploy_lambda.sh b/services/lambda-pr-status-labeler/pr_status_bot/deploy_lambda.sh
new file mode 100755
index 0000000..4786b75
--- /dev/null
+++ b/services/lambda-pr-status-labeler/pr_status_bot/deploy_lambda.sh
@@ -0,0 +1,17 @@
+#!/bin/bash
+set -e
+
+echo "Deployment stage (test, prod)"
+read config_dir
+
+if [ "$config_dir" == "test" ]; then
+    echo "Deploying to test"
+    export AWS_PROFILE=mxnet-ci-dev
+    sls deploy --stage test
+elif [ "$config_dir" == "prod" ]; then
+    echo "Deploying to prod"
+    export AWS_PROFILE=mxnet-ci
+    sls deploy --stage prod
+else
+    echo "Unrecognized stage: ${config_dir}"
+fi
diff --git a/services/lambda-pr-status-labeler/pr_status_bot/environment.yml b/services/lambda-pr-status-labeler/pr_status_bot/environment.yml
new file mode 100644
index 0000000..bfa304a
--- /dev/null
+++ b/services/lambda-pr-status-labeler/pr_status_bot/environment.yml
@@ -0,0 +1,25 @@
+test:
+    SECRET_NAME: REDACTED
+    DOMAIN_NAME: REDACTED
+    SECRET_ENDPOINT_URL: https://secretsmanager.us-west-2.amazonaws.com
+    SECRET_ENDPOINT_REGION: us-west-2
+    SECRET_ARN: REDACTED
+    LOGGING_LEVEL: DEBUG
+    LOGGING_LEVEL_BOTOCORE: INFO
+    LOGGING_LEVEL_BOTO3: INFO
+    LOGGING_LEVEL_URLLIB3: INFO
+    LOGGING_LEVEL_REQUESTS: ERROR
+    REPO_NAME: ChaiBapchya/incubator-mxnet
+
+prod:
+    SECRET_NAME: REDACTED
+    DOMAIN_NAME: REDACTED
+    SECRET_ENDPOINT_URL: https://secretsmanager.us-west-2.amazonaws.com
+    SECRET_ENDPOINT_REGION: us-west-2
+    SECRET_ARN:  REDACTED
+    LOGGING_LEVEL: DEBUG
+    LOGGING_LEVEL_BOTOCORE: INFO
+    LOGGING_LEVEL_BOTO3: INFO
+    LOGGING_LEVEL_URLLIB3: INFO
+    LOGGING_LEVEL_REQUESTS: ERROR
+    REPO_NAME: apache/incubator-mxnet
diff --git a/services/lambda-pr-status-labeler/pr_status_bot/handler.py b/services/lambda-pr-status-labeler/pr_status_bot/handler.py
new file mode 100644
index 0000000..7b9978e
--- /dev/null
+++ b/services/lambda-pr-status-labeler/pr_status_bot/handler.py
@@ -0,0 +1,69 @@
+#!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# -*- coding: utf-8 -*-
+
+import os
+import boto3
+import logging
+
+from PRStatusBot import PRStatusBot, GithubObj
+
+logging.getLogger().setLevel(logging.INFO)
+logging.getLogger('boto3').setLevel(logging.CRITICAL)
+logging.getLogger('botocore').setLevel(logging.CRITICAL)
+
+SQS_CLIENT = boto3.client('sqs')
+
+
+def send_to_sqs(event, context):
+
+    response = (SQS_CLIENT.send_message(
+        QueueUrl=os.getenv('SQS_URL'),
+        MessageBody=str(event)
+    ))
+
+    logging.info('Response: {}'.format(response))
+    status = response['ResponseMetadata']['HTTPStatusCode']
+    if status == 200:
+        logging.info('Enqueued to SQS')
+    else:
+        logging.error('Unable to enqueue to SQS')
+
+    return {
+        "statusCode": status,
+        "headers": {"Content-Type": "application/json"}
+    }
+
+
+def run_lambda(event, context):
+    logging.info(f'event {event}')
+    github_obj = GithubObj(apply_secret=True)
+    pr_status_bot = PRStatusBot(github_obj=github_obj.github_object, apply_secret=True)
+
+    try:
+        pr_status_bot.parse_webhook_data(event)
+    except Exception as e:
+        logging.error("CI bot raised an exception! %s", exc_info=e)
+    logging.info("Lambda triggered successfully")
+
+
+if __name__ == "__main__":
+    logging.basicConfig(level=logging.DEBUG)
+    run_lambda(None, None)
diff --git a/services/lambda-pr-status-labeler/pr_status_bot/requirements.txt b/services/lambda-pr-status-labeler/pr_status_bot/requirements.txt
new file mode 100644
index 0000000..77067d4
--- /dev/null
+++ b/services/lambda-pr-status-labeler/pr_status_bot/requirements.txt
@@ -0,0 +1,16 @@
+boto3==1.14.33
+botocore==1.17.33
+certifi==2020.6.20
+chardet==3.0.4
+Deprecated==1.2.10
+docutils==0.15.2
+idna==2.10
+jmespath==0.10.0
+PyGithub==1.51
+PyJWT==1.7.1
+python-dateutil==2.8.1
+requests==2.24.0
+s3transfer==0.3.3
+six==1.15.0
+urllib3==1.25.10
+wrapt==1.12.1
diff --git a/services/lambda-pr-status-labeler/pr_status_bot/secret_manager.py b/services/lambda-pr-status-labeler/pr_status_bot/secret_manager.py
new file mode 100644
index 0000000..7866591
--- /dev/null
+++ b/services/lambda-pr-status-labeler/pr_status_bot/secret_manager.py
@@ -0,0 +1,56 @@
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# This file is served to fetch secret from Secrets Manager
+import boto3
+from botocore.exceptions import ClientError
+import os
+import logging
+logging.basicConfig(level=logging.INFO)
+
+
+def get_secret():
+    """
+    This method is to fetch secret values
+    Please configure corresponding secret_name and region_name in environment.yml
+    """
+    secret_name = os.environ.get("secret_name")
+    region_name = os.environ.get("region_name")
+    endpoint_url = "https://secretsmanager.{}.amazonaws.com".format(region_name)
+    session = boto3.session.Session()
+
+    client = session.client(
+        service_name='secretsmanager',
+        region_name=region_name,
+        endpoint_url=endpoint_url
+    )
+
+    try:
+        # Decrypted secret using the associated KMS CMK
+        # Depending on whether the secret was a string or binary, one of these fields will be populated
+        get_secret_value_response = client.get_secret_value(
+            SecretId=secret_name
+        )
+        if 'SecretString' in get_secret_value_response:
+            secret = get_secret_value_response['SecretString']
+            return secret
+        else:
+            binary_secret_data = get_secret_value_response['SecretBinary']
+            return binary_secret_data
+    except ClientError as e:
+        logging.exception(e.response['Error']['Code'])
diff --git a/services/lambda-pr-status-labeler/pr_status_bot/serverless.yml b/services/lambda-pr-status-labeler/pr_status_bot/serverless.yml
new file mode 100644
index 0000000..e813227
--- /dev/null
+++ b/services/lambda-pr-status-labeler/pr_status_bot/serverless.yml
@@ -0,0 +1,80 @@
+service: pr-status-labeler-bot
+
+frameworkVersion: ">=1.2.0 <2.0.0"
+
+provider:
+  name: aws
+  runtime: python3.7
+  region: us-west-2
+  stage: ${opt:stage}
+  environment: ${file(environment.yml):${self:provider.stage}}
+  iamRoleStatements:
+    -  Effect: "Allow"
+       Action:
+         - "secretsmanager:GetSecretValue"
+         - "secretsmanager:DescribeSecret"
+       Resource: ${self:provider.environment.SECRET_ARN}
+    -  Effect: "Allow"
+       Action:
+         - "secretsmanager:ListSecrets"
+       Resource: "*"
+    -  Effect: "Allow"
+       Action:
+         - "sqs:SendMessage"
+         - "sqs:ReceiveMessage"
+         - "sqs:DeleteMessage"
+         - "sqs:GetQueueAttributes"
+       Resource:
+         Fn::GetAtt: [ SQSQueue, Arn ]
+
+functions:
+  pr_status_sqs_function:
+    handler: handler.send_to_sqs
+    environment:
+      SQS_URL:
+        Ref: SQSQueue
+    timeout: 30
+    events:
+      - http: POST send_to_sqs
+
+  pr_status_label_function:
+    handler: handler.run_lambda
+    events:
+      - sqs:
+          arn:
+            Fn::GetAtt: [ SQSQueue, Arn ]
+    environment:
+      region_name: ${self:provider.environment.SECRET_ENDPOINT_REGION}
+      secret_name: ${self:provider.environment.SECRET_NAME}
+      repo: ${self:provider.environment.REPO_NAME}
+    timeout: 900
+
+resources:
+  Resources:
+    SQSQueue:
+      Type: AWS::SQS::Queue
+      Properties:
+        QueueName: ${self:custom.queueName}
+        VisibilityTimeout: 900
+
+plugins:
+  - serverless-python-requirements
+  - serverless-domain-manager
+
+custom:
+  queueName: pr_status_sqs
+  customDomain:
+    domainName: ${self:provider.environment.DOMAIN_NAME}
+    basePath: ${opt:stage}
+    stage: ${opt:stage} 
+    createRoute53Record: true
+  pythonRequirements:
+    dockerizePip: true
+
+package:
+  exclude:
+    - ./**
+  include:
+    - handler.py
+    - secret_manager.py
+    - PRStatusBot.py
\ No newline at end of file
diff --git a/services/lambda-pr-status-labeler/pr_status_bot/test.py b/services/lambda-pr-status-labeler/pr_status_bot/test.py
new file mode 100644
index 0000000..30f5977
--- /dev/null
+++ b/services/lambda-pr-status-labeler/pr_status_bot/test.py
@@ -0,0 +1,112 @@
+#!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# -*- coding: utf-8 -*-
+import os
+import json
+from PRStatusBot import PRStatusBot, GithubObj
+
+def load_and_test(data):
+    payload_json = json.loads(data)
+    os.environ["AWS_PROFILE"] = "mxnet-ci"
+    # set secret_name [commented since it is to be redacted]
+    # os.environ["secret_name"] = REDACTED
+    os.environ["region_name"] = "us-west-2"
+    github_obj = GithubObj(apply_secret=True)
+    pr_status_bot = PRStatusBot(repo="apache/incubator-mxnet", github_obj=github_obj.github_object, apply_secret=True)
+    pr_status_bot.parse_payload(payload_json)
+
+
+def prepare_input(pr_num, context, state, sha):
+    data = {
+        "target_url": "PR-" + str(pr_num),
+        "context": "ci/jenkins/mxnet-validation/" + context,
+        "state": state,
+        "commit": {
+            "sha": sha
+        }
+    }
+    # return serialized data dictionary
+    return json.dumps(data)
+
+
+def check_ci_failure():
+    data = prepare_input(18984, "website", "failed", "6fbfa3c020e566c0d54825cbfb67abca1d70b4fa")
+    load_and_test(data)
+
+
+def check_ci_pending():
+    data = prepare_input(18921, "sanity", "pending", "19fa075dc0cc76678750b6c691208aa7aa1f45ff")
+    load_and_test(data)
+
+
+def check_ci_success():
+    data = prepare_input(18983, "unix-gpu", "success", "26fb1921b6e09226146b4b90d2d995b7a018347d")
+    load_and_test(data)
+
+
+def check_pr_awaiting_merge():
+    # https://github.com/apache/incubator-mxnet/pull/17468
+    # PR satisfies all criterion for pr-awaiting-merge label
+    # - passed all CI tests;
+    # - PR contains atleast 1 Committers' approvers; no requested changes
+    # It does have a merge conflict though
+    data = prepare_input(17468, "unix-gpu", "success", "68c19d7b08d04df1d4ac9dd3fca7ad58f925ec51")
+    load_and_test(data)
+
+
+def check_commit_with_non_committer_review():
+    # https://github.com/apache/incubator-mxnet/pull/16025
+    # PR passes CI but has 1 non-MX Committer review
+    data = prepare_input(16025, "unix-gpu", "success", "fb343b55ec4721c9cce4422224c246eb3a188bb2")
+    load_and_test(data)
+
+
+def check_commit_with_no_review():
+    # https://github.com/apache/incubator-mxnet/pull/18983
+    # PR has no review but CI passes
+    data = prepare_input(18983, "unix-gpu", "success", "26fb1921b6e09226146b4b90d2d995b7a018347d")
+    load_and_test(data)
+
+
+def check_wip_title_pr():
+    data = prepare_input(18715, "unix-gpu", "success", "d638d3c51c176208e2909134306fb62d1df99b6c")
+    load_and_test(data)
+
+
+def check_draft_pr():
+    data = prepare_input(18835, "unix-gpu", "success", "12dd397f6886a4014ef5f81c1cbcae4ca68e3f5b")
+    load_and_test(data)
+
+
+def check_pr_with_requested_changes():
+    data = prepare_input(13735, "unix-gpu", "success", "32a3b6eb2b53f27a5bddbfd130ac2e357877475d")
+    load_and_test(data)
+
+
+check_ci_failure()
+check_ci_success()
+check_ci_pending()
+check_pr_awaiting_merge()
+check_commit_with_non_committer_review()
+check_commit_with_no_review()
+check_wip_title_pr()
+check_draft_pr()
+check_pr_with_requested_changes()
+# check_pr_awaiting_response()
diff --git a/services/lambda-pr-status-labeler/pr_status_bot/test_mock.py b/services/lambda-pr-status-labeler/pr_status_bot/test_mock.py
new file mode 100644
index 0000000..ff653af
--- /dev/null
+++ b/services/lambda-pr-status-labeler/pr_status_bot/test_mock.py
@@ -0,0 +1,115 @@
+#!/usr/bin/env python3
+
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#   http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing,
+# software distributed under the License is distributed on an
+# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+# KIND, either express or implied.  See the License for the
+# specific language governing permissions and limitations
+# under the License.
+
+# -*- coding: utf-8 -*-
+import github
+import json
+import os
+from PRStatusBot import PRStatusBot, GithubObj, FAILURE_STATE, PENDING_STATE, SUCCESS_STATE, PR_WORK_IN_PROGRESS_LABEL, PR_AWAITING_TESTING_LABEL, PR_AWAITING_REVIEW_LABEL, PR_AWAITING_RESPONSE_LABEL, PR_AWAITING_MERGE_LABEL, WORK_IN_PROGRESS_TITLE_SUBSTRING
+import handler
+
+
+def test_if_payload_is_non_PR(mocker):
+    payload = {'target_url': 'master'}
+    prsb = PRStatusBot(None, None, False)
+    actual = prsb.parse_payload(payload)
+    expected = 1
+    assert actual == expected
+
+
+def test_if_pr_closed(mocker):
+    payload = {'target_url': 'PR-1'}
+    prsb = PRStatusBot(None, None, False)
+    mocker.patch.object(PRStatusBot, '_get_pull_request_object',
+                        return_value=get_mock_pr(state='closed'))
+    actual = prsb.parse_payload(payload)
+    expected = 2
+    assert actual == expected
+
+
+def test_if_pr_wip_title(mocker):
+    mockpr = get_mock_pr(title=WORK_IN_PROGRESS_TITLE_SUBSTRING)
+    prsb = PRStatusBot(None, None, False)
+    mocker.patch.object(PRStatusBot, '_add_label')
+    prsb._label_pr_based_on_status(FAILURE_STATE, mockpr)
+    PRStatusBot._add_label.assert_called_with(
+        mockpr, PR_WORK_IN_PROGRESS_LABEL)
+
+
+def test_if_pr_draft(mocker):
+    mockpr = get_mock_pr(draft=True)
+    prsb = PRStatusBot(None, None, False)
+    mocker.patch.object(PRStatusBot, '_add_label')
+    prsb._label_pr_based_on_status(SUCCESS_STATE, mockpr)
+    PRStatusBot._add_label.assert_called_with(
+        mockpr, PR_WORK_IN_PROGRESS_LABEL)
+
+
+def test_if_ci_status_failure(mocker):
+    mockpr = get_mock_pr()
+    prsb = PRStatusBot(None, None, False)
+    mocker.patch.object(PRStatusBot, '_add_label')
+    prsb._label_pr_based_on_status(FAILURE_STATE, mockpr)
+    PRStatusBot._add_label.assert_called_with(
+        mockpr, PR_WORK_IN_PROGRESS_LABEL)
+
+
+def test_if_ci_status_pending(mocker):
+    mockpr = get_mock_pr()
+    prsb = PRStatusBot(None, None, False)
+    mocker.patch.object(PRStatusBot, '_add_label')
+    prsb._label_pr_based_on_status(PENDING_STATE, mockpr)
+    PRStatusBot._add_label.assert_called_with(
+        mockpr, PR_AWAITING_TESTING_LABEL)
+
+
+def test_if_pr_no_reviews(mocker):
+    def mock_no_review_counts(self, pr):
+        # approves, request_changes, comments
+        return 0, 0, 0
+    mockpr = get_mock_pr()
+    prsb = PRStatusBot(None, None, False)
+    mocker.patch.object(PRStatusBot, '_add_label')
+    mocker.patch.object(PRStatusBot, '_parse_reviews', mock_no_review_counts)
+    prsb._label_pr_based_on_status(SUCCESS_STATE, mockpr)
+    PRStatusBot._add_label.assert_called_with(mockpr, PR_AWAITING_REVIEW_LABEL)
+
+
+def test_if_pr_reviews_requested_changes(mocker):
+    def mock_no_review_counts(self, pr):
+        # approves, request_changes, comments
+        return 0, 1, 0
+    mockpr = get_mock_pr()
+    prsb = PRStatusBot(None, None, False)
+    mocker.patch.object(PRStatusBot, '_add_label')
+    mocker.patch.object(PRStatusBot, '_parse_reviews', mock_no_review_counts)
+    prsb._label_pr_based_on_status(SUCCESS_STATE, mockpr)
+    PRStatusBot._add_label.assert_called_with(
+        mockpr, PR_AWAITING_RESPONSE_LABEL)
+
+
+def get_mock_pr(number=1, state='open', title='abc', draft=False):
+    mock_pr = github.PullRequest.PullRequest(requester='None',
+                                             headers='None',
+                                             attributes={'number': number,
+                                                         'state': state,
+                                                         'title': title,
+                                                         'draft': draft},
+                                             completed='None')
+    return mock_pr