diff --git a/.circleci/config.yml b/.circleci/config.yml index e5f79c3..058f632 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -23,6 +23,8 @@ jobs: - ghpr/build-prospective-branch - ghpr/slack-pr-author: message: ":tada: Tests passed!" + get_slack_user_by: meseeks + color: "#f2c744" publish: machine: true diff --git a/README.md b/README.md index f5ddf82..cd83769 100644 --- a/README.md +++ b/README.md @@ -3,9 +3,7 @@ [![CircleCI Orb Version](https://img.shields.io/badge/endpoint.svg?url=https://badges.circleci.io/orb/narrativescience/ghpr)](https://circleci.com/orbs/registry/orb/narrativescience/ghpr) [![License](https://img.shields.io/badge/License-BSD%203--Clause-blue.svg)](https://opensource.org/licenses/BSD-3-Clause) -Set of git utilities to manage GitHub Pull Requests in CI. -This orb was created to address the need to simulate the result of merging the head branch -into a PR's target base branch. +Set of git utilities to manage GitHub Pull Requests in CI. This orb was created to address the need to simulate the result of merging the head branch into a PR's target base branch. Additional features: @@ -16,6 +14,14 @@ The commands in the orb will expose the following environment variables: * `GITHUB_PR_BASE_BRANCH` - The base branch for the PR. * `GITHUB_PR_NUMBER` - The number of the PR. +* `GITHUB_PR_TITLE` - The title of the PR. +* `GITHUB_PR_COMMIT_MESSAGE` - The current commit's message. +* `GITHUB_PR_AUTHOR_USERNAME` - The PR author's username. +* `GITHUB_PR_AUTHOR_NAME` - The PR author's name. +* `GITHUB_PR_AUTHOR_EMAIL` - The PR author's email address. + +All these commands will work out-of-the-box in jobs using the +[`machine` executor](https://circleci.com/docs/2.0/executor-types/#using-machine). ## Getting Started @@ -41,9 +47,15 @@ and set the following environment variables: * `SLACK_OAUTH_TOKEN` - [OAuth token](https://api.slack.com/docs/token-types#bot) for the Slack bot that will be used to send Slack messages. -This orb uses the [email associated with commits](https://help.github.com/en/github/setting-up-and-managing-your-github-user-account/setting-your-commit-email-address) -to look up the user in a Slack workspace. Therefore, to receive notifications, committers -should set their `user.email` in their `git config` to be the same email associated with their Slack account. +The bot user will need at least the following bot token scopes: + +* [`chat:write`](https://api.slack.com/scopes/chat:write) +* [`chat:write.public`](https://api.slack.com/scopes/chat:write.public) +* [`users:read`](https://api.slack.com/scopes/users:read) +* [`users.profile:read`](https://api.slack.com/scopes/users.profile:read) +* [`users:read.email`](https://api.slack.com/scopes/users:read.email) +* [`im:write`](https://api.slack.com/scopes/im:write) + Messages show as being sent by the user associated with the `SLACK_OAUTH_TOKEN`. #### Example notification diff --git a/src/@orb.yml b/src/@orb.yml index 0430379..2819114 100644 --- a/src/@orb.yml +++ b/src/@orb.yml @@ -1,5 +1,6 @@ version: 2.1 -description: Bundle of utils for GitHub pull requests +description: | + Set of git utilities to manage GitHub Pull Requests in CI. This orb was created to address the need to simulate the result of merging the head branch into a PR's target base branch examples: test-pull-request: @@ -20,3 +21,5 @@ examples: when: on_fail - ghpr/slack-pr-author: message: ":tada: Tests passed!" + get_slack_user_by: meseeks + color: "#1CBF43" diff --git a/src/commands/build-prospective-branch.yml b/src/commands/build-prospective-branch.yml index 2c9f881..2cf690c 100644 --- a/src/commands/build-prospective-branch.yml +++ b/src/commands/build-prospective-branch.yml @@ -1,6 +1,6 @@ description: | Builds the prospective merge branch by merging the head branch into the pull request's base branch. - Requires `GITHUB_EMAIL` and `GITHUB_USERNAME` to be set as environment variables. + Requires `GITHUB_EMAIL`, `GITHUB_USERNAME`, and `GITHUB_PASSWORD` to be set as environment variables. parameters: force: description: | @@ -10,7 +10,7 @@ parameters: default: false steps: - checkout - - get-base-branch + - get-pr-info - run: name: Build prospective merge branch command: | @@ -18,7 +18,7 @@ steps: git config --global user.email "$GITHUB_EMAIL" git config --global user.name "$GITHUB_USERNAME" git fetch && git merge "origin/$GITHUB_PR_BASE_BRANCH" --no-edit - if [[ $? -ne 0 && "<< parameters.force >>" == "false" ]]; then + if [[ $? -ne 0 && << parameters.force >> == false ]]; then echo "Failed to merge $GITHUB_PR_BASE_BRANCH into $CIRCLE_BRANCH" exit 1 fi diff --git a/src/commands/get-base-branch.yml b/src/commands/get-base-branch.yml deleted file mode 100644 index 60d01a9..0000000 --- a/src/commands/get-base-branch.yml +++ /dev/null @@ -1,18 +0,0 @@ -description: | - Get the base branch for the pull request. - Requires `GITHUB_USERNAME` and `GITHUB_PASSWORD` to be set as environment variables. -steps: - - run: - name: Get the base branch - command: | - # Check `jq` dependency - if ! (command -v jq >/dev/null 2>&1); then - echo "This command requires `jq` to be installed" - exit 1 - fi - - GITHUB_PR_NUMBER=$(echo "$CIRCLE_PULL_REQUEST" | sed "s/.*\/pull\///") - url="https://api.github.com/repos/${CIRCLE_PROJECT_USERNAME}/${CIRCLE_PROJECT_REPONAME}/pulls/${GITHUB_PR_NUMBER}" - base_branch=$(curl --user "${GITHUB_USERNAME}:${GITHUB_PASSWORD}" "$url" | jq -e '.base.ref' | tr -d '"') - echo "$base_branch" - echo "export GITHUB_PR_BASE_BRANCH=$base_branch" >> $BASH_ENV diff --git a/src/commands/get-pr-info.yml b/src/commands/get-pr-info.yml new file mode 100644 index 0000000..7a0b5c3 --- /dev/null +++ b/src/commands/get-pr-info.yml @@ -0,0 +1,87 @@ +description: | + Gets and sets the following environment variables: + * `GITHUB_PR_BASE_BRANCH` - The base branch for the PR. + * `GITHUB_PR_NUMBER` - The number of the PR. + * `GITHUB_PR_TITLE` - The title of the PR. + * `GITHUB_PR_COMMIT_MESSAGE` - The current commit's message. (Optional) + * `GITHUB_PR_AUTHOR_USERNAME` - The PR author's username. + * `GITHUB_PR_AUTHOR_NAME` - The PR author's name. (Optional) + * `GITHUB_PR_AUTHOR_EMAIL` - The PR author's email address. (Optional) + Requires `GITHUB_EMAIL` and `GITHUB_PASSWORD` to be set as environment variables. +parameters: + get_pr_author_email: + description: | + If true, also sets GITHUB_PR_AUTHOR_EMAIL. This requires an additional API call. + type: boolean + default: false + get_pr_author_name: + description: | + If true, also sets GITHUB_PR_AUTHOR_NAME. This requires an additional API call. + type: boolean + default: false + get_commit_message: + description: | + If true, also sets GITHUB_PR_COMMIT_MESSAGE. This requires an additional API call. + type: boolean + default: false +steps: + - run: + when: always + name: Get PR information + command: | + # Check `jq` dependency + if ! (command -v jq >/dev/null 2>&1); then + echo "This command requires jq to be installed" + exit 1 + fi + + PR_NUMBER=$(echo "$CIRCLE_PULL_REQUEST" | sed "s/.*\/pull\///") + echo "PR_NUMBER: $PR_NUMBER" + echo "export GITHUB_PR_NUMBER=$PR_NUMBER" >> $BASH_ENV + + API_GITHUB="https://api.github.com/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME" + PR_REQUEST_URL="$API_GITHUB/pulls/$PR_NUMBER" + PR_RESPONSE=$(curl --user "${GITHUB_USERNAME}:${GITHUB_PASSWORD}" "$PR_REQUEST_URL") + + PR_TITLE=$(echo $PR_RESPONSE | jq -e '.title' | tr -d '"') + echo "PR_TITLE: $PR_TITLE" + echo "export GITHUB_PR_TITLE='$PR_TITLE'" >> $BASH_ENV + + PR_BASE_BRANCH=$(echo $PR_RESPONSE | jq -e '.base.ref' | tr -d '"') + echo "PR_BASE_BRANCH: $PR_BASE_BRANCH" + echo "export GITHUB_PR_BASE_BRANCH='$PR_BASE_BRANCH'" >> $BASH_ENV + + PR_AUTHOR_USERNAME=$(echo $PR_RESPONSE | jq -e '.user.login' | tr -d '"') + echo "PR_AUTHOR_USERNAME: $PR_AUTHOR_USERNAME" + echo "export GITHUB_PR_AUTHOR_USERNAME='$PR_AUTHOR_USERNAME'" >> $BASH_ENV + + if [[ << parameters.get_pr_author_email >> == true || << parameters.get_pr_author_name >> ]]; then + # We need to use the email address associated with the merge_commit_sha since + # CIRCLE_SHA1 may have been authored by someone who is not the PR author. + # Sadly, PR_RESPONSE doesn't include the email associated with the merge_commit_sha. + # So we have to get that from the commit information. + + PR_MERGE_COMMIT_SHA=$(echo $PR_RESPONSE | jq -e '.merge_commit_sha' | tr -d '"') + COMMIT_REQUEST_URL="$API_GITHUB/commits/$PR_MERGE_COMMIT_SHA" + COMMIT_RESPONSE=$(curl --user "${GITHUB_USERNAME}:${GITHUB_PASSWORD}" "$COMMIT_REQUEST_URL") + fi + + <<# parameters.get_pr_author_email >> + PR_AUTHOR_EMAIL=$(echo $COMMIT_RESPONSE | jq -e '.commit.author.email' | tr -d '"') + echo "PR_AUTHOR_EMAIL: $PR_AUTHOR_EMAIL" + echo "export GITHUB_PR_AUTHOR_EMAIL='$PR_AUTHOR_EMAIL'" >> $BASH_ENV + <> + + <<# parameters.get_pr_author_name >> + PR_AUTHOR_NAME=$(echo $COMMIT_RESPONSE | jq -e '.commit.author.name' | tr -d '"') + echo "PR_AUTHOR_NAME: $PR_AUTHOR_NAME" + echo "export GITHUB_PR_AUTHOR_NAME='$PR_AUTHOR_NAME'" >> $BASH_ENV + <> + + <<# parameters.get_commit_message >> + COMMIT_REQUEST_URL="$API_GITHUB/commits/$CIRCLE_SHA1" + COMMIT_RESPONSE=$(curl --user "${GITHUB_USERNAME}:${GITHUB_PASSWORD}" "$COMMIT_REQUEST_URL") + PR_COMMIT_MESSAGE=$(echo $COMMIT_RESPONSE | jq -e '.commit.message' | tr -d '"') + echo "PR_COMMIT_MESSAGE: $PR_COMMIT_MESSAGE" + echo "export GITHUB_PR_COMMIT_MESSAGE='$PR_COMMIT_MESSAGE'" >> $BASH_ENV + <> diff --git a/src/commands/get-pr-number.yml b/src/commands/get-pr-number.yml deleted file mode 100644 index 45e52b0..0000000 --- a/src/commands/get-pr-number.yml +++ /dev/null @@ -1,8 +0,0 @@ -description: Get the number of the pull request -steps: - - run: - name: Get PR number - command: | - pr_number=$(echo "$CIRCLE_PULL_REQUEST" | sed "s/.*\/pull\///") - echo "$pr_number" - echo "export GITHUB_PR_NUMBER=$pr_number" >> $BASH_ENV diff --git a/src/commands/slack-pr-author.yml b/src/commands/slack-pr-author.yml index 061a9e3..d49db80 100644 --- a/src/commands/slack-pr-author.yml +++ b/src/commands/slack-pr-author.yml @@ -1,16 +1,27 @@ description: | Send a Slack direct message to the PR author, or to a channel, @mentioning the PR author. - Requires `SLACK_OAUTH_TOKEN` to be set as an environment variable. + Requires `SLACK_OAUTH_TOKEN` to be set as an environment variable. If you'd like to have the workflow name be displayed as well, `CIRCLECI_API_TOKEN` should be set. For more details, see https://github.com/NarrativeScience/circleci-orb-ghpr#enabling-slack-notifications. - For more details, see https://github.com/NarrativeScience/circleci-orb-ghpr#enabling-slack-notifactions + The following are the mechanisms (see `get_slack_user_by`) by which a Slack user can be found for the PR author: + * `email` - Find a Slack user with the same email address as GITHUB_PR_AUTHOR_EMAIL. + * `display_name` - Find a Slack user with their Slack profile name, i.e. "Display name", exactly equal to GITHUB_PR_AUTHOR_USERNAME. First match only. + * `real_name` - Find a Slack user with their Slack profile real_name field, i.e. "Full name", exactly equal to GITHUB_PR_AUTHOR_NAME. Fist match only. + * `title_tag` - Find a Slack user with a Slack profile title field, i.e. "What I do", containing the string "[gh:GITHUB_PR_AUTHOR_USERNAME]". First match only. + * `meseeks` - Try to employ all the mechanisms above, in order, to find a Slack user. parameters: message: description: | The message to send. Supports Slack mrkdown syntax - https://api.slack.com/reference/surfaces/formatting#basics type: string + color: + description: | + The color to format the message with. + Should be a hex value wrapped in quotes because we're in YAML land. + type: string + default: "#DDDDDD" when: - description: Condition for when the DM should be sent + description: Condition for when the message should be sent. type: enum enum: - on_success @@ -24,61 +35,150 @@ parameters: Otherwise, the message is sent to the PR author directly. type: string default: "" + get_slack_user_by: + description: The mechanism by which to find the PR author's associated Slack user ID. + type: enum + enum: + - email + - display_name + - real_name + - title_tag + - meseeks + default: email + pass_on_fail: + description: | + Whether or not the command should exit if anything fails. Ultimately, sending a Slack message is a nice-to-have, but setting this to `fail` is a way to enforce enabling Slack messages. An example scenario where it would be useful is if messages are sent to a channel and the PR author should be tagged to get their attention. + type: boolean + default: true steps: + - get-pr-info: + get_pr_author_email: true + get_pr_author_name: true - run: name: Slack PR author when: << parameters.when >> command: | - # Check `jq` dependency + EXIT_STATUS=1 + + <<# parameters.pass_on_fail >> + set +e + EXIT_STATUS=0 + <> + if ! (command -v jq >/dev/null 2>&1); then echo "This command requires jq to be installed" exit 1 fi - # Check `SLACK_OAUTH_TOKEN` is set - if [[ -z ${SLACK_OAUTH_TOKEN+x} ]]; then + if [[ -z "$SLACK_OAUTH_TOKEN" ]]; then echo "This command requires SLACK_OAUTH_TOKEN to be set" exit 1 fi - API_GITHUB="https://api.github.com/repos/$CIRCLE_PROJECT_USERNAME/$CIRCLE_PROJECT_REPONAME" - - GITHUB_PR_NUMBER=$(echo "$CIRCLE_PULL_REQUEST" | sed "s/.*\/pull\///") - PR_REQUEST_URL="$API_GITHUB/pulls/$GITHUB_PR_NUMBER" - PR_RESPONSE=$(curl --user "${GITHUB_USERNAME}:${GITHUB_PASSWORD}" "$PR_REQUEST_URL") - PR_TITLE=$(echo $PR_RESPONSE | jq -e '.title' | tr -d '"') - - # Get the merge_commit_sha. This is so we can message the PR author, not the commit - # author who may not be the PR author. - MERGE_COMMIT_SHA=$(echo $PR_RESPONSE | jq -e '.merge_commit_sha' | tr -d '"') - - # Sadly, PR_RESPONSE doesn't include the email associated with the MERGE_COMMIT_SHA. - # So we have to get that from the commit information. - COMMIT_REQUEST_URL="$API_GITHUB/commits/$MERGE_COMMIT_SHA" - COMMIT_RESPONSE=$(curl --user "${GITHUB_USERNAME}:${GITHUB_PASSWORD}" "$COMMIT_REQUEST_URL") - PR_AUTHOR_EMAIL=$(echo $COMMIT_RESPONSE | jq -e '.commit.author.email' | tr -d '"') - - SLACK_USER=$(curl -H 'Content-Type: application/x-www-form-urlencoded' \ - -H 'Cache-Control: no-cache' \ - -d "token=$SLACK_OAUTH_TOKEN" \ - -d "email=$PR_AUTHOR_EMAIL" \ - "https://slack.com/api/users.lookupByEmail") - SLACK_USER_ID=$(echo $SLACK_USER | jq -e '.user.id' | tr -d '"') - - if [[ $SLACK_USER_ID == "null" ]]; then - echo "No Slack user found with email $PR_AUTHOR_EMAIL" - # Don't fail the command. This shouldn't be a blocker. - exit 0 + if [[ \ + ("<< parameters.get_slack_user_by >>" == "email" \ + || "<< parameters.get_slack_user_by >>" == "meseeks") \ + ]]; then + + if [[ -z "$GITHUB_PR_AUTHOR_EMAIL" && "<< parameters.get_slack_user_by >>" != "meseeks" ]]; then + echo "GITHUB_PR_AUTHOR_EMAIL not set or is empty string" + exit $EXIT_STATUS + fi + + SLACK_USER=$(curl \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'Cache-Control: no-cache' \ + -d "token=$SLACK_OAUTH_TOKEN" \ + -d "email=$GITHUB_PR_AUTHOR_EMAIL" \ + "https://slack.com/api/users.lookupByEmail") + + echo $SLACK_USER | jq . + SLACK_USER_ID=$(echo $SLACK_USER | jq '.user.id // empty' | tr -d '"') + echo "SLACK_USER_ID by email ($GITHUB_PR_AUTHOR_EMAIL): $SLACK_USER_ID" + fi + + if [[ \ + ("<< parameters.get_slack_user_by >>" == "meseeks" \ + || "<< parameters.get_slack_user_by >>" != "email") \ + && -z "$SLACK_USER_ID" \ + ]]; then + curl \ + -H 'Content-Type: application/x-www-form-urlencoded' \ + -H 'Cache-Control: no-cache' \ + -d "token=$SLACK_OAUTH_TOKEN" \ + "https://slack.com/api/users.list" > /tmp/slack-users.json + fi + + if [[ \ + ("<< parameters.get_slack_user_by >>" == "display_name" \ + || "<< parameters.get_slack_user_by >>" == "meseeks") \ + && -z "$SLACK_USER_ID" \ + ]]; then + + if [[ -z "$GITHUB_PR_AUTHOR_USERNAME" && "<< parameters.get_slack_user_by >>" != "meseeks" ]]; then + echo "GITHUB_PR_AUTHOR_USERNAME not set or is empty string" + exit $EXIT_STATUS + fi + + SLACK_USER_ID=$(jq -r --arg u "$GITHUB_PR_AUTHOR_USERNAME" '.members | .[] | select(.profile.display_name == $u and .profile.display_name != "") | .id' /tmp/slack-users.json) + echo "SLACK_USER_ID by display_name ($GITHUB_PR_AUTHOR_USERNAME): $SLACK_USER_ID" + fi + + if [[ \ + ("<< parameters.get_slack_user_by >>" == "real_name" \ + || "<< parameters.get_slack_user_by >>" == "meseeks") \ + && -z "$SLACK_USER_ID" \ + ]]; then + + if [[ -z "$GITHUB_PR_AUTHOR_NAME" && "<< parameters.get_slack_user_by >>" != "meseeks" ]]; then + echo "GITHUB_PR_AUTHOR_NAME not set or is empty string" + exit $EXIT_STATUS + fi + + SLACK_USER_ID=$(jq -r --arg u "$GITHUB_PR_AUTHOR_NAME" '.members | .[] | select(.profile.real_name == $u) | .id' /tmp/slack-users.json) + echo "SLACK_USER_ID by real_name ($GITHUB_PR_AUTHOR_NAME): $SLACK_USER_ID" + fi + + if [[ \ + ("<< parameters.get_slack_user_by >>" == "title_tag" \ + || "<< parameters.get_slack_user_by >>" == "meseeks") \ + && -z "$SLACK_USER_ID" \ + ]]; then + + if [[ -z "$GITHUB_PR_AUTHOR_USERNAME" && "<< parameters.get_slack_user_by >>" != "meseeks" ]]; then + echo "GITHUB_PR_AUTHOR_USERNAME not set or is empty string" + exit $EXIT_STATUS + fi + + SLACK_TITLE_MATCH_STRING="\[gh:$GITHUB_PR_AUTHOR_USERNAME\]" + SLACK_USER_ID=$(jq -r --arg u "$SLACK_TITLE_MATCH_STRING" '.members | .[] | select(.profile.title | match($u)) | .id' /tmp/slack-users.json) + echo "SLACK_USER_ID by title_tag ($SLACK_TITLE_MATCH_STRING): $SLACK_USER_ID" + fi + + if [[ -z "$SLACK_USER_ID" ]]; then + echo "Unable to find Slack user by << parameters.get_slack_user_by >>" + exit $EXIT_STATUS fi MESSAGE="*<< parameters.message >>*" - CHANNEL=$SLACK_USER_ID + CHANNEL="$SLACK_USER_ID" if [[ -n "<< parameters.channel >>" ]]; then MESSAGE="$MESSAGE\n<@$SLACK_USER_ID>" CHANNEL="<< parameters.channel >>" fi + if [[ -z ${CIRCLECI_API_TOKEN+x} ]]; then + echo "CIRCLECI_API_TOKEN needs to be set to retrieve the workflow name" + WORKFLOW_TEXT="" + else + WORKFLOW_RESPONSE=$(curl "https://circleci.com/api/v2/workflow/$CIRCLE_WORKFLOW_ID?circle-token=$CIRCLECI_API_TOKEN") + echo $WORKFLOW_RESPONSE | jq . + WORKFLOW_NAME=$(echo $WORKFLOW_RESPONSE | jq -r '.name' | tr -d '"') + WORKFLOW_URL="https://circleci.com/workflow-run/$CIRCLE_WORKFLOW_ID" + WORKFLOW_TEXT="<$WORKFLOW_URL|$WORKFLOW_NAME>" + fi + BLOCKS="[ { \"type\": \"section\", @@ -86,20 +186,26 @@ steps: \"type\": \"mrkdwn\", \"text\": \"$MESSAGE\" }, - \"accessory\": { - \"type\": \"button\", - \"text\": { - \"type\": \"plain_text\", - \"text\": \"Visit Job\" - }, - \"url\": \"$CIRCLE_BUILD_URL\" + }, + { + \"type\": \"section\", + \"text\": { + \"type\": \"mrkdwn\", + \"text\": \"*Pull Request:* <$CIRCLE_PULL_REQUEST|$GITHUB_PR_TITLE>\" } }, { \"type\": \"section\", \"text\": { - \"type\": \"mrkdwn\", - \"text\": \"*Pull Request:* <$CIRCLE_PULL_REQUEST|$PR_TITLE>\" + \"type\": \"mrkdwn\", + \"text\": \"*Job:* <$CIRCLE_BUILD_URL|$CIRCLE_JOB>\" + }, + }, + { + \"type\": \"section\", + \"text\": { + \"type\": \"mrkdwn\", + \"text\": \"*Workflow:* $WORKFLOW_TEXT\" } }, { @@ -112,18 +218,24 @@ steps: { \"type\": \"mrkdwn\", \"text\": \"Branch: *$CIRCLE_BRANCH*\" - } + }, ] } ]" + ATTACHMENTS="[ + { + \"color\": \"<< parameters.color >>\", + \"blocks\": $BLOCKS + } + ]" + CURL_ARGS=( - -X POST - -H 'Content-Type: application/x-www-form-urlencoded' - -H 'Cache-Control: no-cache' - -d "token=$SLACK_OAUTH_TOKEN" - -d 'as_user=true' - -d "channel=$CHANNEL" - -d "blocks=$BLOCKS" - ) + -X POST + -H 'Content-Type: application/x-www-form-urlencoded' + -H 'Cache-Control: no-cache' + -d "token=$SLACK_OAUTH_TOKEN" + -d 'as_user=true' + -d "channel=$CHANNEL" + -d "attachments=$ATTACHMENTS") curl "${CURL_ARGS[@]}" "https://slack.com/api/chat.postMessage"