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
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.
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.
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:
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 rolling this out and hitting edge cases — multi-project Jira setups, tickets that span repos, or teams that don't follow branch naming conventions — get in touch. The extraction logic and composite action pattern are straightforward to extend once the baseline is working.
Top comments (0)