This post was originally published on graycloudarch.com.
You just merged a PR. Now you open Jira, find the ticket, paste the
PR link in a comment, transition the status to Done, and update the
deployed field. Five minutes. Twenty times a week. That's 1,700 minutes
per year per engineer --- nearly 30 hours of pure mechanical overhead.
And that's assuming you remember. On one team I worked with, we
audited the last three months of merged PRs. Thirty percent of tickets
had no update after merge. No comment, no transition, no link. The
ticket just sat in In Dev until someone noticed during sprint
review.
The fix is two GitHub Actions workflows and a shared composite
action. Here's exactly how to build it.
The Architecture
Two workflows, one shared extraction layer:
- Workflow 1: Fires on PR creation --- posts a Jira link comment to the PR so reviewers can navigate directly to the ticket.
- Workflow 2: Fires on PR merge to
main--- posts a comment to the Jira ticket with the PR URL, commit SHA, and who merged it, then transitions the ticket to Done.
Both workflows need to find the Jira ticket ID. Instead of
duplicating that logic, we extract it into a composite action.
Step 1: Composite Action for Ticket Extraction
Create
.github/actions/extract-jira-ticket/action.yml.
The action checks four sources in priority order --- easiest to fix
first:
- PR title (simplest for the developer to correct)
- Commit messages
- Branch name in standard format:
PROJECT-123-description - Branch name with prefix:
feat/PROJECT-123-description
::: {#cb1 .sourceCode}
name: Extract Jira Ticket
description: Extracts Jira ticket from PR title, commits, or branch name
inputs:
jira-base-url:
required: true
jira-user-email:
required: true
jira-api-token:
required: true
outputs:
jira-key:
value: ${{ steps.extract.outputs.jira_key }}
found:
value: ${{ steps.extract.outputs.found }}
runs:
using: composite
steps:
- name: Extract ticket ID
id: extract
shell: bash
run: |
JIRA_KEY=""
# Priority 1: PR title
if [[ "${{ github.event.pull_request.title }}" =~ ([A-Z]+-[0-9]+) ]]; then
JIRA_KEY="${BASH_REMATCH[1]}"
fi
# Priority 2: Branch name
if [ -z "$JIRA_KEY" ]; then
BRANCH="${{ github.head_ref }}"
if [[ "$BRANCH" =~ ([A-Z]+-[0-9]+) ]]; then
JIRA_KEY="${BASH_REMATCH[1]}"
fi
fi
if [ -n "$JIRA_KEY" ]; then
echo "jira_key=$JIRA_KEY" >> $GITHUB_OUTPUT
echo "found=true" >> $GITHUB_OUTPUT
else
echo "found=false" >> $GITHUB_OUTPUT
fi
:::
The regex [A-Z]+-[0-9]+ matches any Jira ticket format:
PROJ-1, IN-89, INFRA-1234. If you
have tickets with lowercase project keys, adjust accordingly.
Step 2: PR Creation Workflow
Create .github/workflows/link-jira-on-pr.yml.
This fires when a PR is opened and posts a formatted comment with the
Jira ticket link. If no ticket is found, it posts a warning so the
author knows to add one --- before review, not after.
::: {#cb2 .sourceCode}
name: Link Jira on PR
on:
pull_request:
types: [opened]
permissions:
pull-requests: write
jobs:
link-jira:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/extract-jira-ticket
id: jira
with:
jira-base-url: ${{ secrets.JIRA_BASE_URL }}
jira-user-email: ${{ secrets.JIRA_USER_EMAIL }}
jira-api-token: ${{ secrets.JIRA_API_TOKEN }}
- name: Post Jira link comment
if: steps.jira.outputs.found == 'true'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `๐ Jira: [${{ steps.jira.outputs.jira-key }}](${{ secrets.JIRA_BASE_URL }}/browse/${{ steps.jira.outputs.jira-key }})`
})
- name: Warn if no ticket found
if: steps.jira.outputs.found == 'false'
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: 'โ ๏ธ No Jira ticket found. Add a ticket ID to the PR title (e.g., `PROJ-123: Your title`).'
})
:::
The warning step matters. It creates a feedback loop that trains the
team to include ticket IDs upfront. Within a few weeks, the warning
fires rarely.
Step 3: PR Merge Workflow
Create .github/workflows/update-jira-on-merge.yml.
This fires when a PR is closed against main. The
if: github.event.pull_request.merged == true guard is
important --- the closed event also fires for PRs that are
closed without merging.
::: {#cb3 .sourceCode}
name: Update Jira on Merge
on:
pull_request:
types: [closed]
branches: [main]
jobs:
update-jira:
if: github.event.pull_request.merged == true
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: ./.github/actions/extract-jira-ticket
id: jira
with:
jira-base-url: ${{ secrets.JIRA_BASE_URL }}
jira-user-email: ${{ secrets.JIRA_USER_EMAIL }}
jira-api-token: ${{ secrets.JIRA_API_TOKEN }}
- name: Post merge comment to Jira
if: steps.jira.outputs.found == 'true'
run: |
HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}"
-u "${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}"
-H "Content-Type: application/json"
-X POST "${{ secrets.JIRA_BASE_URL }}/rest/api/2/issue/${{ steps.jira.outputs.jira-key }}/comment"
-d "{\"body\": \"PR merged: #${{ github.event.pull_request.number }} ${{ github.event.pull_request.html_url }}\nCommit: ${{ github.sha }}\nBy: ${{ github.event.pull_request.merged_by.login }}\"}")
echo "Jira comment HTTP status: $HTTP_STATUS"
[ "$HTTP_STATUS" -eq 201 ] && echo "โ
Comment posted" || echo "โ ๏ธ Comment failed (non-critical)"
- name: Transition ticket to Done
if: steps.jira.outputs.found == 'true'
run: |
TRANSITION_ID="${{ secrets.JIRA_DONE_TRANSITION_ID }}"
[ -z "$TRANSITION_ID" ] && echo "No transition ID configured, skipping" && exit 0
curl -s -u "${{ secrets.JIRA_USER_EMAIL }}:${{ secrets.JIRA_API_TOKEN }}"
-H "Content-Type: application/json"
-X POST "${{ secrets.JIRA_BASE_URL }}/rest/api/2/issue/${{ steps.jira.outputs.jira-key }}/transitions"
-d "{\"transition\": {\"id\": \"$TRANSITION_ID\"}}"
echo "โ
Transitioned to Done"
:::
The comment step uses an HTTP status check rather than relying on
curl's exit code. A failed comment doesn't fail the job --- the PR already
merged, and a missing notification shouldn't generate noise in CI. The
transition step is fully optional: if
JIRA_DONE_TRANSITION_ID isn't set, it skips silently. This
lets you start with just comments and add transitions once you've
verified the workflow runs cleanly.
Finding Your Transition IDs
Transition IDs are project-specific. There's no universal "Done" ID.
Run this against any ticket in your project to find yours:
::: {#cb4 .sourceCode}
curl -u "$JIRA_EMAIL:$JIRA_API_TOKEN"
"$JIRA_BASE_URL/rest/api/2/issue/$TICKET_KEY/transitions"
| jq -r '.transitions[] | "ID: \(.id) | \(.name)"'
:::
Example output:
ID: 91 | Done
ID: 31 | In Review
ID: 21 | In Progress
Set the Done ID as JIRA_DONE_TRANSITION_ID in your
repository secrets.
A Note on Jira API Versions
Use the v2 API: /rest/api/2/. Some teams try v3 and get
silent empty responses --- {"errorMessages":[],"errors":{}} ---
that look exactly like auth failures. It's not auth. The v3 request body
format changed, and error handling is poor. v2 is stable,
well-documented, and works consistently.
Required Secrets
Add these to your GitHub repository secrets:
Secret Value
JIRA_BASE_URL https://yourorg.atlassian.net
JIRA_USER_EMAIL The email address tied to your API token
JIRA_API_TOKEN Generate at id.atlassian.com โ Security โ API tokens
JIRA_DONE_TRANSITION_ID Optional --- from the transitions API call above
For org-wide rollout, set these as organization secrets and restrict
to relevant repositories.
Results
After rolling this out across a team of eight engineers:
- Zero manual Jira updates after merge
- Forgotten ticket updates dropped from 30% to 0%
- Roughly 1,700 minutes per year recovered per engineer
- Every merged PR has a complete audit trail: PR number, URL, commit SHA, who merged it
The composite action pattern also means when you need to extend this
--- adding a Slack notification on merge, posting to Confluence --- you
extend one file, not two.
If you're building out automation like this across your engineering
platform and want a second opinion on the design, I'm available for advisory engagements.
Top comments (0)