🔥Let’s Do DevOps: GitHub Automated Pull Request Title Fixer🚀
For when your PR titles absolutely need to start with a ticket number
This blog series focuses on presenting complex DevOps projects as simple and approachable via plain language and lots of pictures. You can do it!
Hey all!
Managing your git history is hard. When folks merge their code in, they have a few different options on most platforms, GitHub included.
They can do a Merge Commit, which retains all the commits from the Pull Request, and adds them to the target branch. Sometimes that’s great - when you want to see every single commit, or if several different tickets are in the same PR, this can be great. But if you’re on a busy repo, this could lead to tens of thousands (hundreds of thousands?) of commits in your history, which can get overwhelming.
The alternative most teams use is called a Merge Commit, sometimes called a Squash Merge. That makes a single merge, but uses the PR title as the commit message, which often doesn’t have the right information.
So what do you do? Well, I wrote a GitHub Action that automatically searches the commits in the feature branch for a ticket number, and pre-pends that ticket number to the beginning of the PR title. That way when the PR title is used for a merge commit, the ticket number this change is associated with is kept forever.
It’s automated, and takes exactly no effort by my developers.
If you want to skip the write-up and see the code, scroll to the bottom for a link to the GitHub repo and the GitHub Marketplace link so you can use it yourself right away.
Let’s do this!
Merge Commits
Merge Commits take all the commits on a feature branch (your PR branch) and squashes them into a single merge, that is then added to the target branch. This is great for minimizing the number of commits added to a target branch, but the default behavior on GitHub can be tricky.
GitHub has a few options for the Merge Commit here:
Notice that none of these options takes any information from the commit messages that folks add to their code. For the team I work with today, that’s what we require folks to do - for each commit added to a PR, they’re required to add a commit message that starts with a Jira ticket number. Our Jira integration helps associate those specific commits to the Jira ticket.
For more information, click through here:
It’s a really convenient system, and help with our regulatory requirement to link all code to a real ticket with approvals.
PR Updater Action Logic
That’s enough background, let’s talk about the logic of how this action works:
Check PR for starting with Jira ticket (this also establishes idempotency since we run on each commit). PR title isn't updated.
If no, check PR for Jira ticket anywhere. If identified, we prepend Jira ticket to beginning of PR
If not found, we look at the first commit on the branch and retrieve the Jira ticket from it.
If not, we fail and ask the dev to fix the PR title. This should be rare since we already have established with our teams that each commit (including the first one we'll read) needs to start with a valid Jira ticket number.
And that’s enough background, let’s get to the good stuff - how this Action actually works.
Setup
I write most of my actions in bash - I should probably move to a grown-up language like Python or Go, but it’s just so easy to mock up and move fast, I end up using it a lot.
Let’s start with the setup. I like to test these scripts out locally before embedding them in an Action. One day, I swear, GitHub will natively support this pattern, but until then I write a bit of branching logic.
If I am working locally, I:
export local_testing=true
and set some default values. If we’re in a GitHub Actions context, that wouldn’t have happened, so we’ll choose the second stanza. We read all the information we can from the `github` context, which gives us really everything we need to start. We can find the github Org, the repository name, the PR title, and then on line 15, an interesting one.
The `${{ secrets.GITHUB_TOKEN }}` is a special secret. This isn’t one that I’ve put into the Repo or Org Secret storage. Instead, this is a secret that’s built for the Pull Request run, and it gives access to the control plane of the Pull Request. It’s great for at-runtime code like this, and permits us to both read the PR data, as well as write it (like updating the title!). More information on this token here.
# If local_testing = true, set vars | |
if [[ $local_testing == true ]]; then | |
# If running locally for tesing | |
GH_ORG="kymidd" | |
GH_REPO="TestingPRUpdater" | |
PR_TITLE="$PR_TITLE" | |
GITHUB_TOKEN="$GITHUB_TOKEN" | |
PULL_REQUEST_NUMBER=2 | |
BASE_BRANCH="main" | |
else | |
# If running as an Action | |
GH_ORG="${{ github.repository_owner }}" | |
GH_REPO=$(echo ${{ github.repository }} | cut -d "/" -f 2) | |
PR_TITLE="${{ github.event.pull_request.title }}" | |
GITHUB_TOKEN="${{ secrets.GITHUB_TOKEN }}" | |
PULL_REQUEST_NUMBER="${{ github.event.pull_request.number }}" | |
BASE_BRANCH="${{ github.event.pull_request.base.ref }}" | |
fi |
Functions
We’ll call some code multiple times, so lets move those to functions.
First, we print a lot of line-breaks. I like to make code DRY when it suits readability, and having a lot of “——————-” throughout the code sure doesn’t help. So here’s our function for that purpose.
print_break() { | |
# Print a break in the output | |
echo "" | |
echo "----------------------------------------" | |
echo "" | |
} |
Next, let’s modify the “exit” behavior. If we’re in a GitHub Actions context, we want to fully exit, like `exit 1` or `exit 0` exit to surface whether we succeeded or failed.
When we’re developing locally, not really. I’d prefer to print error messages and pause when there’s an error, rather than exiting my terminal. So we write a function that can pause when there’s an error surfaced, so we can dig in and fix it.
exit_if_not_local() { | |
# If not running locally, exit | |
if [[ $local_testing != true ]]; then | |
print_break | |
exit $1 | |
else | |
echo "🔴 Running locally, not exiting with code $1, press any key to continue" | |
# Wait for keypress to continue | |
while true; do | |
read -rsn1 key # Read a single character silently | |
if [ $? = 0 ]; then | |
echo -e "\n$key is pressed, continuing" | |
break # Exit the loop | |
else | |
echo "Waiting for a keypress" | |
fi | |
done | |
print_break | |
fi | |
} |
Next up we have a function that helps us update the PR Title. First, one line 2, we print the new PR title, then on line 4 we do a curl (and catch the response in $CURL), to the GitHub REST API. You can see on line 9 where we’re reading a lot of the information we set earlier, and also the $PULL_REQUEST_NUMBER - we’ll get to that further on in this write-up.
Then on line 10 we set the body of the request, which is the title attribute should be updated (“patched” in REST terminology) to the new title.
Then on line 11 we do a check to see if the response title matches our target title. If it does, we’re good to go. If not, something went wrong, print the error message out.
update_pr_title() { | |
echo "🟢 New PR title will be: $PR_TITLE" | |
unset CURL | |
CURL=$(curl -s \ | |
-X PATCH \ | |
-H "Accept: application/vnd.github+json" \ | |
-H "Authorization: Bearer $GITHUB_TOKEN" \ | |
-H "X-GitHub-Api-Version: 2022-11-28" \ | |
https://api.github.com/repos/$GH_ORG/$GH_REPO/pulls/$PULL_REQUEST_NUMBER \ | |
-d "{\"title\":\"$PR_TITLE\"}" 2>&1 || true) | |
if [[ $(echo "$CURL" | jq -r '.title') == $PR_TITLE ]]; then | |
echo "🟢 Successfully updated PR title" | |
exit_if_not_local 0 | |
else | |
echo "🔴 Something bad happened updating the PR title, please investigate response:" | |
echo "$CURL" | |
exit_if_not_local 1 | |
fi | |
} |
Start!
Let’s check to make sure all the vars required to run are set, on line 5. If not, exit.
And then let’s start, we first print out what the PR Title currently is. This is mostly for the humans, but also for review later, in case this action updates something it shouldn’t.
Then on line 22, we normalize the PR Title to be in all-caps in a new variable. This makes matching for regex easier.
### | |
# Check vars required to run | |
### | |
if [[ -z $GH_ORG || -z $GH_REPO || -z $PR_TITLE || -z $GITHUB_TOKEN || -z $PULL_REQUEST_NUMBER || -z $BASE_BRANCH ]]; then | |
echo "🔴 One or more variables are undefined, exiting" | |
exit_if_not_local 1 | |
fi | |
### | |
# Start | |
### | |
print_break | |
echo "🟢 The title of your pull request is currently $PR_TITLE" | |
### | |
# Normalize inputs | |
### | |
# Capitalize the PR_TITLE to make regex matches easier downstream | |
NORMALIZED_PR_TITLE=$(echo $PR_TITLE | tr '[:lower:]' '[:upper:]') |
Now it’s time for a whole bunch of bash, regex, and git knowledge. Strap in!
GitHub Actions are generally run from a “detached HEAD” state, which makes it hard to do actual git reference operations again them. Let’s fully check out the PR branch, on line 3, then find the current branch name, on line 8.
Now we want to find the first commit made on this PR branch, which is a bit harder to do that it sounds. First we identify where this branch was… well, “branched” off from the target branch, on line 13. Then we list all the commits (line 18) that have happened since we branched off, and then just grab the first one, line 22.
But we don’t care about the commit hash, we want the commit message, so let’s grab it, line 25, and then normalize it in the same way (line 28) in a new var to all-caps so it’s easy to compare for regex purposes.
Then we do some fancy regex to see if the commit begins with a Jira commit message, or at least if regex thinks it does. If yes, we store that information in a var.
# Checkout the PR branch | |
echo "Checking out PR branch" | |
git checkout -q "${{ github.event.pull_request.head.ref }}" | |
echo "Github head ref is ${{ github.event.pull_request.head.ref }}" | |
# Find current branch name | |
echo "Finding current branch name" | |
CURRENT_BRANCH=$(git branch | grep ^\* | cut -d "*" -f 2 | cut -d " " -f 2) | |
echo "Current branch is $CURRENT_BRANCH" | |
# Find hash of commit most common ancestor, e.g. where branch began | |
echo "Finding hash of commit most common ancestor, e.g. where branch began" | |
BRANCH_MERGE_BASE=$(git merge-base ${BASE_BRANCH} ${CURRENT_BRANCH}) | |
echo "Branch merge base is $BRANCH_MERGE_BASE" | |
# Find all commits since common ancestor | |
echo "Finding all commits since common ancestor" | |
BRANCH_COMMITS=$(git rev-list ${BRANCH_MERGE_BASE}..HEAD) | |
echo "Branch commits are $BRANCH_COMMITS" | |
# Find first commit, extract commit message and then Jira ticket number | |
FIRST_COMMIT=$(echo $BRANCH_COMMITS | head -n 1) | |
echo "First commit is $FIRST_COMMIT" | |
FIRST_COMMIT_MESSAGE=$(git log --max-count=1 --format=%B "$FIRST_COMMIT" | awk 'NF') | |
echo "First commit message is $FIRST_COMMIT_MESSAGE" | |
NORMALIZED_FIRST_COMMIT_MESSAGE=$(echo $FIRST_COMMIT_MESSAGE | tr '[:lower:]' '[:upper:]') | |
echo "Normalized first commit message is $NORMALIZED_FIRST_COMMIT_MESSAGE" | |
JIRA_TICKET=$(echo $NORMALIZED_FIRST_COMMIT_MESSAGE | grep -o -E '^[A-Z]{2,}-[0-9]+' || true) | |
echo "Jira ticket is $JIRA_TICKET" |
If the $JIRA_TICKET var isn’t populated, then we didn’t find a Jira ticket in the commit message, and we should exit. We don’t want to update the PR title unless we’ve found a valid message to put there.
# If commit message doesn't start with Jira ticket, exit | |
if [ -z "$JIRA_TICKET" ]; then | |
# No Jira ticket found in title at all, exit with failure code | |
echo "🔴 No Jira ticket found at beginning of PR title or at beginning of first commit message." | |
echo "🔴 Please update your PR title. You'll also likely need to \"git commit --amend\" your first commit message so Jira Commit Checker Action will succeed" | |
print_break | |
exit_if_not_local 1 | |
fi |
And to cap it off, the real logic of the thing. If the current PR title ($PR_TITLE) matches the commit message of the first commit ($FIRST_COMMIT_MESSAGE) then we’ve already run, and no changes needed (line 2-4). If not, let’s update the PR title so it matches our first commit message, line 6-11.
# Check if first commit matches PR title | |
if [[ "$PR_TITLE" == "$FIRST_COMMIT_MESSAGE" ]]; then | |
echo "🟢 First commit message matches PR title, no changes triggered" | |
exit_if_not_local 0 | |
else | |
echo "🔴 First commit message doesn't match PR title, updating PR title" | |
print_break | |
# Set new PR title to value of first commit message | |
PR_TITLE="$FIRST_COMMIT_MESSAGE" | |
update_pr_title | |
fi |
Calling the Action
Calling the action is really simple, and will require an Action in your own repo that looks like this:
name: PR Title Check and Updater | |
on: | |
pull_request_target: | |
types: | |
- opened | |
- edited | |
- synchronize | |
- labeled | |
- unlabeled | |
jobs: | |
pr_title_check: | |
uses: KyMidd/Action_PRTitleUpdater@v1 |
Demonstration
It’s kind of hilarious setting wrong information on a PR in the title, and then watching the Action spin up and fix it. Aggravating the bot is fun!
Summary
In this write-up we walked through a problem with GitHub PRs, and how they merge changes, and then we talked about how a GitHub Action can head off this issue at the source, by setting the PR Title to something that is valid as a future commit into the target/release branch.
I hope you learned a lot and had fun.
You can find the full Action file that we walked through in this article here.
And you can find the GitHub Marketplace entry for this Action here. You can use this Action right now!!
Thanks all! Good luck out there.
kyler