DEV Community

Cover image for Stop Manually Updating Jira After Every PR Merge
Glenn Gray
Glenn Gray

Posted on • Originally published at graycloudarch.com

Stop Manually Updating Jira After Every PR Merge

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:

  1. PR title (simplest for the developer to correct)
  2. Commit messages
  3. Branch name in standard format: PROJECT-123-description
  4. 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
Enter fullscreen mode Exit fullscreen mode

:::

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`).'
            })
Enter fullscreen mode Exit fullscreen mode

:::

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"
Enter fullscreen mode Exit fullscreen mode

:::

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)"'
Enter fullscreen mode Exit fullscreen mode

:::

Example output:

ID: 91 | Done
ID: 31 | In Review
ID: 21 | In Progress
Enter fullscreen mode Exit fullscreen mode

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)