From 75a47109eeb1dac3a7a23a3d36656979e6f93583 Mon Sep 17 00:00:00 2001 From: Yuri Astrakhan Date: Mon, 27 Apr 2020 02:37:57 -0400 Subject: [PATCH] Implement PR performance auto-update (#833) A cron-based approach to find pull requests, possibly from forks, that finished profiling, and post their results as comments. See in-depth explanation of how this works at https://github.com/nyurik/auto_pr_comments_from_forks --- .github/workflows/pr-updater.yml | 187 +++++++++++++++++++++++++++++++ .github/workflows/tests.yml | 49 ++++---- 2 files changed, 214 insertions(+), 22 deletions(-) create mode 100644 .github/workflows/pr-updater.yml diff --git a/.github/workflows/pr-updater.yml b/.github/workflows/pr-updater.yml new file mode 100644 index 00000000..08b141b8 --- /dev/null +++ b/.github/workflows/pr-updater.yml @@ -0,0 +1,187 @@ +name: Update PR comments + +on: + # This number should correspond to the IGNORE_OLDER_THAN value below. + # When setting up for the first time, use "on: push" instead of "on: schedule" + # and set IGNORE_OLDER_THAN to a very high number until it runs once. + schedule: + - cron: '*/5 * * * *' + +jobs: + update_PRs: + runs-on: ubuntu-latest + + steps: + - name: main + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + WORKFLOW_NAME: "OpenMapTiles CI" + # the name of the artifact whose content comment published by PR. Must have a single markdown file inside. + MSG_ARTIFACT_NAME: "pr_message" + # How far back to look for finished runs, in minutes. + # Set to 10-20 minutes higher than cron's job frequency set above. + IGNORE_OLDER_THAN: 20 + run: | + # + # Strategy: + # * get all open pull requests + # * get all recent workflow runs + # * match pull requests and their current SHA with the last workflow run for the same SHA + # * for each found match of and : + # * download artifact from the workflow run -- expects a single file with markdown content + # * look through existing PR comments to see if we have posted a comment before + # (uses a hidden magical header to identify our comment) + # * either create or update the comment with the new text (if changed) + # + + # Recompute time frame to be in seconds, and set a few more useful constants + export MAX_AGE_SECONDS="$(expr "$IGNORE_OLDER_THAN" "*" 60)" + export GITHUB_API="https://api.github.com/repos/$GITHUB_REPOSITORY" + export COMMENT_MAGIC_HEADER='" + + # A useful wrapper around CURL + crl() { + curl --silent --show-error --location --retry 1 "${@:2}" \ + -H "Accept: application/vnd.github.antiope-preview+json, application/vnd.github.v3+json" \ + -H "authorization: Bearer $GITHUB_TOKEN" \ + "$1" + } + + # Resolve workflow name into workflow ID + WORKFLOW_ID="$(crl "$GITHUB_API/actions/workflows" \ + | jq ".workflows[] | select(.name == \"$WORKFLOW_NAME\") | .id")" + echo "WORKFLOW_NAME='$WORKFLOW_NAME' ==> WORKFLOW_ID=${WORKFLOW_ID}" + + # Get all open pull requests, most recently updated first + # (this way we don't need to page through all of them) + OPEN_PULL_REQUESTS="$(crl "$GITHUB_API/pulls?state=open&sort=updated&direction=desc")" + + # Get all workflow runs that were triggered by pull requests + WORKFLOW_PR_RUNS="$(crl "$GITHUB_API/actions/workflows/${WORKFLOW_ID}/runs?event=pull_request")" + + # Create an object mapping a "key" to the pull request number + # { + # "nyurik/openmaptiles/nyurik-patch-1/4953dd2370b9988a7832d090b5e47b3cd867f594": 6, + # ... + # } + PULL_REQUEST_MAP="$(jq --arg MAX_AGE_SECONDS "$MAX_AGE_SECONDS" ' + map( + # Only select open unlocked pull requests updated within last $MAX_AGE_SECONDS seconds + select(.state=="open" and .locked==false + and (now - (.updated_at|fromdate)) < $MAX_AGE_SECONDS) + # Prepare for "from_entries" by creating a key/value object + # The key is a combination of repository name, branch name, and latest SHA + | { key: (.head.repo.full_name + "/" + .head.ref + "/" + .head.sha), value: .number } + ) + | from_entries + ' <( echo "$OPEN_PULL_REQUESTS" ))" + + # For each workflow run, match it with the open pull request to get the PR number + # A match is based on "source repository + branch + SHA" key + # In rare cases (e.g. force push to an older revision), there could be more than one match + # for a given PR number, so just use the most recent one. + # Result is a bash style list (one per line) of pairs + PR_JOB_MAP="$(jq -r ' + # second input is the pull request map - use it to lookup PR numbers + input as $PULL_REQUEST_MAP + | .workflow_runs + | map( + # Create a new object with the relevant values + { + id, updated_at, + # lookup PR number from $PULL_REQUEST_MAP based on the "key": + # source repository + branch + SHA ==> PR number + pr_number: $PULL_REQUEST_MAP[.head_repository.full_name + "/" + .head_branch + "/" + .head_sha], + # was this a sucessful run? + # do not include .conclusion=="success" because errors could also post messages + success: (.status=="completed") + } + # Remove runs that were not in the list of the PRs + | select(.pr_number) + ) + # Keep just the most recent run per pull request + | group_by(.pr_number) + | map( + sort_by(.updated_at) + | last + # If the most recent run did not succeed, ignore it + | select(.success) + # Keep just the pull request number mapping to run ID + | [ .pr_number, .id ] + ) + | .[] + | @sh + ' <( echo "$WORKFLOW_PR_RUNS" ) <( echo "$PULL_REQUEST_MAP" ) )" + + # Iterate over the found pairs of PR number + run ID + echo "$PR_JOB_MAP" | \ + while read PR_NUMBER RUN_ID; do + + echo "Processing workflow run #$RUN_ID for pull request #$PR_NUMBER ..." + ARTIFACTS="$(crl "$GITHUB_API/actions/runs/$RUN_ID/artifacts")" + + # Find the artifact download URL for the artifact with the expected name + ARTIFACT_URL="$(jq -r --arg MSG_ARTIFACT_NAME "$MSG_ARTIFACT_NAME" ' + .artifacts + | map(select(.name == $MSG_ARTIFACT_NAME and .expired == false)) + | first + | .archive_download_url + | select(.!=null) + ' <( echo "$ARTIFACTS" ) )" + + if [ -z "$ARTIFACT_URL" ]; then + echo "Unable to find an artifact named '$MSG_ARTIFACT_NAME' in workflow $RUN_ID (PR #$PR_NUMBER), skipping..." + continue + fi + + echo "Downloading artifact $ARTIFACT_URL (assuming single text file per artifact)..." + MESSAGE="$(crl "$ARTIFACT_URL" | gunzip)" + if [ $? -ne 0 ] || [ -z "$MESSAGE" ]; then + echo "Unable to download or parse message from artifact '$MSG_ARTIFACT_NAME' in workflow $RUN_ID (PR #$PR_NUMBER), skipping..." + continue + fi + + # Create a message body by appending a magic header + # and stripping any starting and ending whitespace from the original message + MESSAGE_BODY="$(jq -n \ + --arg COMMENT_MAGIC_HEADER "$COMMENT_MAGIC_HEADER" \ + --arg MESSAGE "$MESSAGE" \ + '{ body: ($COMMENT_MAGIC_HEADER + "\n" + ($MESSAGE | sub( "^[\\s\\p{Cc}]+"; "" ) | sub( "[\\s\\p{Cc}]+$"; "" ))) }' \ + )" + + EXISTING_PR_COMMENTS="$(crl "$GITHUB_API/issues/$PR_NUMBER/comments")" + + # Get the comment URL for the first comment that begins with the magic header, or empty string + OLD_COMMENT="$(jq --arg COMMENT_MAGIC_HEADER "$COMMENT_MAGIC_HEADER" ' + map(select(.body | startswith($COMMENT_MAGIC_HEADER))) + | first + | select(.!=null) + ' <( echo "$EXISTING_PR_COMMENTS" ) )" + + if [ -z "$OLD_COMMENT" ]; then + COMMENT_URL="$(crl "$GITHUB_API/issues/$PR_NUMBER/comments" \ + -X POST -H "Content-Type: application/json" --data "$MESSAGE_BODY" \ + | jq -r '.html_url' )" + COMMENT_INFO="New comment $COMMENT_URL was created" + else + # Make sure the content of the message has changed + COMMENT_URL="$(jq -r ' + (input | .body) as $body + | select(.body | . != $body) + | .url + ' <( echo "$OLD_COMMENT" ) <( echo "$MESSAGE_BODY" ) )" + + if [ -z "$COMMENT_URL" ]; then + echo "The message has already been posted from artifact '$MSG_ARTIFACT_NAME' in workflow $RUN_ID (PR #$PR_NUMBER), skipping..." + continue + fi + + crl "$COMMENT_URL" \ + -X PATCH -H "Content-Type: application/json" --data "$MESSAGE_BODY" \ + | jq -r '("Updated existing comment " + .html_url)' + + COMMENT_INFO="Existing comment $COMMENT_URL was updated" + fi + + echo "$COMMENT_INFO from workflow $WORKFLOW_NAME #$RUN_ID" + done diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 37f86b6c..98f0c7ad 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -115,6 +115,7 @@ jobs: mkdir -p perf_cache mkdir -p artifacts + mkdir -p pr_message cd code CURRENT_SHA=$(git log -1 --format="%H") @@ -169,33 +170,37 @@ jobs: OUTPUT="${OUTPUT//$'\r'/'%0D'}" # Split into two parts -- before and after the ===== SUMMARY ===== - echo "::set-output name=summary::${OUTPUT##*========}" - echo "::set-output name=details::${OUTPUT%%========*}" + OUT_SUMMARY="${OUTPUT##*========}" + OUT_DETAILS="${OUTPUT%%========*}" + + cat > ../pr_message/message.md < + expand for details... + + \`\`\` + $OUT_DETAILS + \`\`\` + + + EOF + fi - - name: Save artifacts + - name: Save performance artifacts uses: actions/upload-artifact@v1 with: name: performance_results path: artifacts - - name: Post a comment on Pull Request - if: "github.event_name == 'pull_request'" - uses: marocchino/sticky-pull-request-comment@v1 - timeout-minutes: 1 - continue-on-error: true + - name: Save PR message artifact + if: github.event_name == 'pull_request' + uses: actions/upload-artifact@v1 with: - message: |- - ``` - ${{ steps.main.outputs.summary }} - ``` - -
- expand for details... - - ``` - ${{ steps.main.outputs.details }} - ``` - -
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + name: pr_message + path: pr_message