#!/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 "$@"
