Requiring linked issues on pull requests Jump to heading
Teams that plan work in an issue tracker want each change traceable to the issue that motivated it: to see what shipped for a feature, to audit why a change was made, to close issues automatically when work lands. In practice a third of pull requests link nothing, and the link is reconstructed later by searching titles. A check that requires a linked issue fixes that at the point where it is cheapest — when the pull request is opened — as long as it is tolerant about where the reference appears and has clear exemptions for changes that genuinely have no issue. This page builds that check for GitHub and GitLab-style keys, within pull request automation and bots.
When to use this approach Jump to heading
- Your organisation tracks work in issues and wants pull requests linked to them.
- Audits or release processes need to map shipped changes to tickets.
- Issue keys already appear in some commit messages, as in enforcing issue keys in commit messages.
- You want issues closed automatically when their pull requests merge.
Step 1 — Decide where a reference may appear Jump to heading
Be generous about location and strict about format. A reference in the title, the description or the branch name all count; what matters is that it is a real issue identifier.
# Accepted forms (adapt the key pattern to your tracker)
# title: "Add export scheduling (PAY-812)"
# description: "Fixes #431" or "Closes PAY-812"
# branch: feature/PAY-812-export-scheduling
key='([A-Z][A-Z0-9]+-[0-9]+|#[0-9]+)' Step 2 — Check the pull request in CI Jump to heading
The check reads the title, body and head branch from the event, and commit trailers from the branch, then passes if any contain a key or the pull request carries an exemption label.
# .github/workflows/linked-issue.yml
on:
pull_request:
types: [opened, edited, synchronize, labeled, unlabeled, reopened]
jobs:
linked-issue:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0, ref: "${{ github.event.pull_request.head.sha }}" }
- env:
TITLE: ${{ github.event.pull_request.title }}
BODY: ${{ github.event.pull_request.body }}
BRANCH: ${{ github.head_ref }}
LABELS: ${{ join(github.event.pull_request.labels.*.name, ',') }}
run: |
key='([A-Z][A-Z0-9]+-[0-9]+|#[0-9]+)'
case ",$LABELS," in *,no-issue,*|*,dependencies,*) echo "exempt by label"; exit 0 ;; esac
trailers=$(git log --format='%(trailers:key=Fixes,valueonly)' "origin/${{ github.base_ref }}..HEAD")
if printf '%s\n%s\n%s\n%s\n' "$TITLE" "$BODY" "$BRANCH" "$trailers" | grep -qE "$key"; then
echo "linked issue found"; exit 0
fi
echo "::error::Link an issue: put a key like PAY-812 or #431 in the title, description or branch name, or add the 'no-issue' label with a reason."
exit 1 Including edited and labeled in the trigger types means the check re-runs as soon as the author fixes the title or adds a label, without a new push.
Step 3 — Validate that the issue exists Jump to heading
A pattern match accepts PAY-99999 even if no such issue exists. For GitHub issues, check through the API; for external trackers, call their API with a read-only token. Keep this step optional and fast, and fail open if the tracker is unavailable, so a tracker outage does not block all merges.
for n in $(printf '%s\n' "$TITLE $BODY" | grep -oE '#[0-9]+' | tr -d '#' | sort -u); do
gh api "repos/$GITHUB_REPOSITORY/issues/$n" --jq '.state' >/dev/null 2>&1 \
|| echo "::warning::#$n does not exist in this repository"
done Step 4 — Close issues automatically on merge Jump to heading
Linking is half the value; closing is the other half. Closing keywords such as Fixes #431 in the description close GitHub issues when the pull request merges into the default branch. For external trackers, a workflow on merge can transition the issue.
on:
pull_request:
types: [closed]
jobs:
transition:
if: github.event.pull_request.merged
runs-on: ubuntu-latest
steps:
- env: { TEXT: "${{ github.event.pull_request.title }} ${{ github.event.pull_request.body }}" }
run: |
for k in $(printf '%s' "$TEXT" | grep -oE '[A-Z][A-Z0-9]+-[0-9]+' | sort -u); do
curl -fsS -m 5 -X POST "$TRACKER_URL/issues/$k/transitions" \
-H "Authorization: Bearer $TRACKER_TOKEN" -d '{"to":"done"}' || echo "could not transition $k"
done Step 5 — Keep exemptions visible and rare Jump to heading
Some changes have no issue: typo fixes, dependency bumps from bots, urgent incident reverts. An exemption label makes them explicit. Report how often it is used, so it does not become the default.
gh pr list --state merged --limit 200 --json labels \
--jq '[.[] | select(any(.labels[]; .name=="no-issue"))] | length' |
xargs -I{} echo "{} of last 200 merged PRs used no-issue" Validation checklist Jump to heading
Frequently Asked Questions Jump to heading
Should the check be required? Jump to heading
Once the team is used to it, yes. Start it as non-required for a couple of weeks so people see the message and fix their habits, then add it to the required checks.
What about commits pushed directly to main? Jump to heading
Direct pushes do not go through pull requests, so the check never sees them. Protect main so changes arrive by pull request, or enforce keys in commit messages server-side as in enforcing commit message policy in pre-receive.
Can the branch name be generated from the issue automatically? Jump to heading
Many trackers and forges can create a branch from an issue with the key in its name. Encouraging that makes the check pass without anyone thinking about it.
Related Jump to heading
- Pull Request Automation & Bots — the parent topic.
- Naming Conventions for Feature Branches — putting the key in the branch name.
- Generating Release Notes from Commit Trailers — another consumer of Fixes: trailers.
- Auto-Labelling Pull Requests by Changed Path — labels that can drive exemptions.