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