blob: d39a4f42a03da73b99122185f195e5cf061cccb0 [file] [log] [blame]
#!/usr/bin/env bash
################################################################################
# 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.
################################################################################
#
# Community review GitHub Action - sets the community review labels on PRs to show case
# community review activity. See Flip-518 for details
# This github action also sets the target version as a label on PRs.
#
# If more capabilities are added to this script we should consider renaming it to have a
# more generic name.
set -e
# =============================================================================
# Global variables
# =============================================================================
REPO_OWNER=apache
REPO_NAME=flink
LGTM_LABEL="community-reviewed-LGTM"
COMMUNITY_REVIEW_LABEL="community-reviewed"
USER_CACHE_FILENAME="user_cache.txt"
PR_CACHE_FILENAME="pr_number_cache.txt"
# =============================================================================
# Community review script - is passed a github token that it uses for authentication
# - gets all of the open PRs from the Flink Github repo. As the API calls have a limit,
# extra calls will be made to get the rest of the PR pages, and review as required.
# - For each PR the reviews state and whether the reviewers is the committer decides whether
# or not a label is applied to PR. The 2 labels that this script can add are:
# - community-reviewed-LGTM - set if there have been 2 approves by non-committers, no committer reviews and
# no changes requested
# - community-reviewed - set if a non-committer has reviewed the PR.
# Note only one of the above labels should be present on a PR.
#
# Additionally this script adds the target branch as a label to each PR.
# =============================================================================
main() {
local token="${1?missing token}"
local GET_PRS_TEMPLATE='{
"query":
"query {
repository(owner: \"<<REPO_OWNER>>\" name: \"<<REPO_NAME>>\") {
pullRequests(first:100, <<AFTER_CURSOR>> states: [OPEN]) {
edges {
node {
number
isDraft
baseRef {
name
}
timelineItems(first: 100, itemTypes: [PULL_REQUEST_REVIEW]) {
nodes {
... on PullRequestReview {
author {
login
}
state
createdAt
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
cursor
}
pageInfo {
endCursor
hasNextPage
}
}
}
}"
}'
# prepare payload template
local payloadTemplate
payloadTemplate="$(echo "$GET_PRS_TEMPLATE" | tr -d '\n')" # the query should be a one-liner, without newlines
payloadTemplate="$(replace_template_value "$payloadTemplate" "REPO_OWNER" "${REPO_OWNER}")"
payloadTemplate="$(replace_template_value "$payloadTemplate" "REPO_NAME" "${REPO_NAME}")"
local pullRequests="[]"
local hasNextPage=true
local cursor=""
local payload
echo "=== Community review GitHub Action ==="
while [[ "$hasNextPage" == "true" ]]; do
if [[ -n $cursor ]]; then
# we have a cursor - so need to page
payload="$(replace_template_value "$payloadTemplate" 'AFTER_CURSOR' "after: \\\\\"$cursor\\\\\",")"
else
# no cursor so no need to page
payload="$(replace_template_value "$payloadTemplate" 'AFTER_CURSOR' '')"
fi
restResponse="$(call_github_graphql_api "$token" "$payload")"
check_github_graphql_response "$restResponse"
local receivedPullRequests="$(jq '.data.repository.pullRequests.edges' <<< "$restResponse")"
printf "Filtering %4s received pull requests... " "$(JSONArrayLength "$receivedPullRequests")"
receivedPullRequests=$(jq '[.[] | select((.node.isDraft = false))]' <<< "$receivedPullRequests")
printf " %2s PR retained" "$(JSONArrayLength "$receivedPullRequests")"
# the below line is coded so as to avoid command-line argument limits.
pullRequests=$(jq -s '.[0] + .[1]' <(echo "$pullRequests") <(echo "$receivedPullRequests"))
hasNextPage=$(jq '.data.repository.pullRequests.pageInfo.hasNextPage' <<< "$restResponse")
cursor=$(jq -r '.data.repository.pullRequests.pageInfo.endCursor' <<< "$restResponse")
printf " | hasNextPage: %-5s | cursor: %s\n" "${hasNextPage}" "${cursor}"
done
process_each_pr "${token}" "${pullRequests}" || exit
echo "Completed."
}
# =============================================================================
# Take the supplied information about the PRs and process each one.
#
# Arguments:
# $1 - GitHub API token for authentication
# $2 - pull Requests - a variable containing a json structure for each PR.
#
# Usage:
# process_each_pr "${token}" "${pullRequests}"
#
# =============================================================================
process_each_pr() {
local token="${1?missing token}"
local pullRequests="${2?missing pull requests}"
local prNumbersAndPaging
local prCount
local token="${1?missing token}"
# get pr numbers list
prNumbersAndPaging="$(jq -jr '.[] | .node.number, "-", .node.timelineItems.pageInfo.hasNextPage,"\n"' <<< "$pullRequests")"
prCount=$(wc -l <<< "$prNumbersAndPaging" | xargs)
local counter=1
# Process each pr separately in a loop
while IFS= read -r line; do
local pr_number=${line%-*}
local hasNextPage=${line#*-}
printf "\n(%s/%s) PR %s - " "$counter" "$prCount" "$pr_number"
# Add target branch as label
local target_branch=$(jq --argjson number "$pr_number" -r '.[] | select(.node.number==$number) | .node.baseRef.name' <<< "$pullRequests")
process_target_branch_label "$token" "$pr_number" "$target_branch"
# Add review orientated labels
local has_timeline_items=$(jq --argjson number "$pr_number" -r '.[] | select(.node.number==$number) | (.node.timelineItems.nodes | type != "array" or length > 0)' <<< "$pullRequests")
# Only process reviews if there are timeline items
if [[ "$has_timeline_items" == "true" ]]; then
local all_reviews
if [[ "$hasNextPage" == "false" ]]; then
all_reviews="$(jq --argjson number "$pr_number" -r '.[] | select(.node.number==$number) | .node.timelineItems.nodes' <<< "$pullRequests")"
else
all_reviews="$(get_all_reviews_for_pr "$token" "$pr_number")"
fi
# leave only the latest reviews per reviewer in a comma separated form
pr_reviewers="$(jq '. | sort_by([.author.login, .createdAt]) | reverse | unique_by(.author.login) | .[] | [.author.login, .state, .createdAt] | join(",")' <<< "$all_reviews")"
printf "Reviews %s Reviewers %s\n" "$(JSONArrayLength "$all_reviews")" "$(wc -l <<< "$pr_reviewers" | xargs)"
process_pr_reviews "$token" "$pr_number" "$pr_reviewers" || exit
fi
((counter++))
done <<< "$prNumbersAndPaging" || exit
}
# =============================================================================
# Process target branch label for a PR
# Arguments:
# $1 - GitHub API token for authentication
# $2 - PR number
# $3 - Target branch name
# =============================================================================
process_target_branch_label() {
local token="${1?missing token}"
local pr_number="${2?missing pr number}"
local target_branch="${3?missing target branch}"
local file_name=$PR_CACHE_FILENAME
local pr_found=false
if [[ -e "$file_name" ]]; then
while IFS=, read -r pr_number_from_file; do
if [[ "$pr_number_from_file" == "$pr_number" ]]; then
pr_found=true
break
fi
done < $file_name
fi
# Only process labels for release branches.
if [[ "$target_branch" == release-* && "$pr_found" == "false" ]]; then
local target_branch_label="target:${target_branch}"
# Ensure the target branch label exists before trying to add it to the PR
ensure_label_exists "$token" "$target_branch_label"
# Get existing labels
local existing_labels=$(call_github_get_labels_api "$pr_number")
# Add target branch label if it doesn't exist on the PR
if [[ ! "$existing_labels" =~ (^|[[:space:]])"$target_branch_label"($|[[:space:]]) ]]; then
call_github_mutate_label_api "$token" "$target_branch_label" "POST" "$pr_number" || exit
printf "Added target branch label %s to PR %s\n" "$target_branch_label" "$pr_number"
local line="$pr_number"
echo "$line">>$file_name
fi
fi
}
# =============================================================================
# Process pr reviews for a pr
# The pr reviews a line for each review, with the user, creation time and review state
# This function processes the pr reviews for the pr to obtain
# community approves
# request for changes
# committer approves
# community reviews
#
# If there are 2 or more community approves, no request for changes and no committer approves we set
# label 'community-reviewed-LGTM' on the PR. If not and there has been a community review, label
# 'community-reviewed' is set.
# If one of the labels is set the other is unset.
#
# Arguments:
# $1 - GitHub API token for authentication
# $2 - PR number
# $3 - PR reviews
# =============================================================================
process_pr_reviews() {
local token="${1?missing token}"
local pr_number="${2?missing pr number}"
local pr_reviews="${3?missing pr reviews}"
local communityApproves=0
local requestForChanges=0
local committerApproves=0
local communityReviews=0
local push_permission
# replace spaces with new lines so the loop will work
pr_reviews=$(echo "$pr_reviews" | tr ' ' '\n')
# remove unnecessary double quotes
pr_reviews="${pr_reviews//\"/}"
while IFS=, read -r user state time
do
printf "%-15s | %-20s | %-20s - checking user permissions..." "$user" "$state" "$time"
push_permission=$(call_github_get_user_push_permission "$token" "$user") || exit
printf "%s\n" "$push_permission"
#see if the user has read role
if [[ "$push_permission" == "true" ]]; then
if [[ "$state" == "APPROVED" ]]; then
((++committerApproves))
fi
else
((++communityReviews))
if [[ "$state" == "APPROVED" ]]; then
((++communityApproves))
fi
fi
if [[ "$state" == "CHANGES_REQUESTED" ]]; then
((++requestForChanges))
fi
done <<< "$pr_reviews"
echo "communityApproves $communityApproves requestForChanges $requestForChanges committerApproves $committerApproves communityReviews $communityReviews"
local label_to_post=
local label_to_delete=
local existing_labels
if [[ $communityApproves -ge 2 && $requestForChanges -eq 0 && $committerApproves -eq 0 ]]; then
label_to_post=$LGTM_LABEL
label_to_delete=$COMMUNITY_REVIEW_LABEL
elif [[ $communityReviews -gt 0 ]]; then
label_to_post=$COMMUNITY_REVIEW_LABEL
label_to_delete=$LGTM_LABEL
fi
if [[ -n "$label_to_post" ]]; then
existing_labels=$(call_github_get_labels_api "$pr_number")
if [[ ! "$existing_labels" =~ (^|[[:space:]])"$label_to_post"($|[[:space:]]) ]]; then
call_github_mutate_label_api "$token" "$label_to_post" "POST" "$pr_number" || exit
fi
if [[ "$existing_labels" =~ (^|[[:space:]])"$label_to_delete"($|[[:space:]]) ]]; then
call_github_mutate_label_api "$token" "$label_to_delete" "DELETE" "$pr_number" || exit
fi
fi
}
# =============================================================================
# Get all the reviews for a pr
# The pr reviews a line for each review, with the user, creation time and review state
# This function processes the pr reviews for the pr to obtain
# community approves
# request for changes
# committer approves
# community reviews
#
# If there are 2 or more community approves, no request for changes and no committer approves we set
# label 'community-reviewed-LGTM' on the PR. If not and there has been a community review, label
# 'community-reviewed' is set.
# If one of the labels is set the other is unset.
#
# Arguments:
# $1 - GitHub API token for authentication
# $2 - PR number
# =============================================================================
get_all_reviews_for_pr() {
local token="${1?missing token}"
local pr_number="${2?missing pr number}"
local cursor=""
local hasNextPage="true"
local payloadTemplate
local cutdownRestResponse
local GET_REVIEWS_TEMPLATE='{
"query":
"query {
repository(owner: \"<<REPO_OWNER>>\" name: \"<<REPO_NAME>>\") {
pullRequest(number: <<PR_NUMBER>>) {
id
number
timelineItems(first: 100 <<AFTER_CURSOR>> itemTypes: [PULL_REQUEST_REVIEW] ) {
nodes {
... on PullRequestReview {
author {
login
}
state
createdAt
}
}
pageInfo {
endCursor
hasNextPage
}
}
}
}
}"
}'
payloadTemplate=$(echo "$GET_REVIEWS_TEMPLATE" | tr -d '\n') # the query should be a one-liner, without newlines
payloadTemplate="$(replace_template_value "$payloadTemplate" "REPO_OWNER" "${REPO_OWNER}")"
payloadTemplate="$(replace_template_value "$payloadTemplate" "REPO_NAME" "${REPO_NAME}")"
payloadTemplate="$(replace_template_value "$payloadTemplate" "PR_NUMBER" "${pr_number}")"
local all_reviews_for_pr=""
while [[ "$hasNextPage" == "true" ]]; do
if [[ -n $cursor ]]; then
payload="$(replace_template_value "$payloadTemplate" 'AFTER_CURSOR' "after: \\\\\"$cursor\\\\\", ")"
else
payload="$(replace_template_value "$payloadTemplate" 'AFTER_CURSOR' '')"
fi
restResponse="$(call_github_graphql_api "$token" "$payload")"
check_github_graphql_response "$restResponse"
local cutdownRestResponse="$(jq '.data.repository.pullRequest.timelineItems.nodes' <<< "$restResponse")"
hasNextPage=$(jq '.data.repository.pullRequest.timelineItems.pageInfo.hasNextPage' <<< "$restResponse")
cursor=$(jq '.data.repository.pullRequest.timelineItems.pageInfo.endCursor' <<< "$restResponse")
# remove quotes from cursor
cursor="${cursor//\"/}"
# append to all reviews into a single json array
all_reviews_for_pr=$(echo -e "${all_reviews_for_pr}" "$cutdownRestResponse" | jq '.[]' | jq -s)
done
echo "$all_reviews_for_pr"
}
# ======================================
# Utility functions
# ======================================
# =============================================================================
# Send a POST request to the GitHub GraphQL API and output the response.
# Use in combination with the check_github_graphql_response function.
# Arguments:
# $1 - GitHub API token for authentication
# $2 - JSON payload to be sent with the request
#
# Usage:
# restResponse="$(call_github_graphql_api "$token" "$payload")"
# check_github_graphql_response "$restResponse"
# =============================================================================
call_github_graphql_api() {
local token="${1?missing token}"
local payload="${2?missing payload}"
curl --fail --no-progress-meter \
-H "Content-Type: application/json" \
-X POST \
-H "Authorization: Bearer ${token}" \
-d "${payload}" \
"https://api.github.com/graphql"
}
# =============================================================================
# Check if a label exists in the repository and create it if it doesn't
# Arguments:
# $1 - GitHub API token for authentication
# $2 - Label name to check/create
# =============================================================================
ensure_label_exists() {
local token="${1?missing token}"
local label_name="${2?missing label name}"
local color="5d069e"
# Check if the label exists
local label_exists=$(curl --fail --no-progress-meter -s \
-H "Accept: application/json" \
-H "Authorization: Bearer $token" \
"https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/labels/$label_name" \
-w "%{http_code}" -o /dev/null || echo "404")
# If label doesn't exist (404), create it
if [[ "$label_exists" == "404" ]]; then
sprintf "Creating label %s" "$label_name"
curl --fail --no-progress-meter -s \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $token" \
-X POST \
-d "{\"name\":\"$label_name\",\"color\":\"$color\"}" \
"https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/labels"
fi
}
# =============================================================================
# Check the response of the cURL request. Exit in case of error.
# Arguments:
# $1 - response from callAPI
# =============================================================================
check_github_graphql_response() {
local response="${1?missing response}"
# check if response contains a valid JSON
if jq -e . >/dev/null 2>&1 <<<"$response"; then
# The cURL request can be successful, but still return an error if the data it receives is
# incorrect, such as a malformed payload.
if [[ "$(jq 'has("errors") and .errors != null' <<< "$response")" == "true" ]]; then
# display the error and terminate
echo "ERROR received: $response"; exit 1;
fi
else
# The cURL request failed and received no response. cURL already displayed the error.
exit 1
fi
}
# =============================================================================
# Replaces a placeholder string <<PLACEHOLDER>> in a template with a specified value in a template.
# Expected arguments
# $1 - The template string.
# $2 - The placeholder string to replace.
# $3 - The value to substitute in place of the template string.
# =============================================================================
replace_template_value() {
local text=${1?missing text to update}
local templateName=${2?missing template name}
local value=${3?missing value}
echo "$text" | sed -r "s/<<${templateName}>>/${value}/"
}
# =============================================================================
# Count number of item of a JSON array
# Expected arguments
# =============================================================================
JSONArrayLength() {
local jsonArray=${1?missing array}
echo "${jsonArray}" | jq 'length'
}
# =============================================================================
# Retrieves existing label names on a Flink PR
# Arguments:
# $1 - pr number to fetch labels from
# =============================================================================
call_github_get_labels_api() {
local prNumber="${1?missing pr number}"
local labels
labels=$(curl --fail --no-progress-meter -s \
-H 'Content-Type: application/json' \
"https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/issues/$prNumber/labels")
# extract label names as a space separated string
jq -r '.[].name' <<< "$labels" | tr '\n' ' '
}
# =============================================================================
# Add or delete a label on a Flink PR
# Expected arguments
# $1 - GitHub API token for authentication
# $2 - labelName - name of the label add or delete on the PR
# $3 - operation POST to add label, DELETE to remove label
# $4 - pr number on which the label will be added or removed
#
# Usage:
# restResponse="$(call_github_mutate_label_api "$token" "$labelName" "POST" "$pr_number")"
# check_github_graphql_response "$restResponse"
# =============================================================================
call_github_mutate_label_api() {
local token="${1?missing token}"
local labelName="${2?missing label}"
local operation="${3?missing operation}"
local prNumber="${4?missing pr number}"
echo "${operation} label: ${labelName}"
curl --fail --no-progress-meter -s \
-H 'Content-Type: application/json' \
-H "Authorization: bearer $token" \
-X "$operation" -d "{ \"labels\":[\"${labelName}\"]}" "https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/issues/${prNumber}/labels"
}
# =============================================================================
# Get the push permission of the user - committers have push permission.
# The user permissions are cached in a file.
# So we only issue the rest call if we have not seen the userName before.
# Expected arguments
# $1 - GitHub API token for authentication
# $2 - Name of the user to be checked
#
# Usage:
# pushPermission="$(call_github_get_user_push_permission "$token" "$userName")"
# =============================================================================
call_github_get_user_push_permission() {
local token="${1?missing token}"
local user_name="${2?missing user}"
local file_name=$USER_CACHE_FILENAME
local permissions
local push_permission
if [[ -e "$file_name" ]]; then
while IFS=, read -r user_from_file pushperm_from_file; do
if [[ "$user_from_file" == "$user_name" ]]; then
push_permission=$pushperm_from_file
break
fi
done < $file_name
fi
if [[ -z "$push_permission" ]]; then
# not in the cache so get it from github
permissions=$(curl --fail --no-progress-meter \
-H "Accept: application/json" \
-H "Authorization: Bearer $token" \
"https://api.github.com/repos/$REPO_OWNER/$REPO_NAME/collaborators/$user_name/permission") || exit
push_permission=$(jq -r '.user.permissions.push' <<< "$permissions")
# write line to file
local line="$user_name,$push_permission"
echo "$line">>$file_name
fi
# echo out the permissions
echo "$push_permission"
}
main "$@"