· 7 min read
Issue tracking: from bug report to commit
Tracking bugs and features with GitHub for public projects, or self-hosted Gogs or Forgejo, and linking every issue to the tests and commits that resolve it.
A bug that lives only in someone’s inbox or memory will be forgotten, fixed twice, or “fixed” without anyone checking. An issue tracker gives every bug and feature a number, a history and an owner. Used well, it also answers the question every future maintainer asks of a line of code: why is this here?
This post covers where to host your issues, how to write ones worth reading, and how to connect each issue to the tests and commits that resolve it.
Why track issues at all
- Nothing gets lost. Every report, idea and complaint has one place to live.
- Priorities are visible. Labels and milestones show what matters now and what can wait.
- Discussion stays attached. Screenshots, logs and decisions sit with the issue, not scattered across email and chat.
- History explains the code. When commits reference issues, anyone can trace a change back to the report that caused it, and forward to the fix.
Where to host: public or your own
GitHub for public projects
GitHub Issues is the default for open source. Your users already have accounts, it’s free for public repositories, and it integrates with everything: pull requests, GitHub Actions, project boards and the gh command-line tool.
The trade-off is control. Your issues, and your users’ data, live on someone else’s service, under their terms.
Self-hosting: Gogs or Forgejo
For private client work, internal tools, or simply owning your own data, you can run a git server with built-in issue tracking on your own hardware. Two good options are written in Go and ship as a single binary or container image.
Gogs is the original “painless self-hosted Git service”. It’s tiny, running comfortably on a quarter of a CPU and 64 MB of RAM, and covers the essentials: repositories, issues, pull requests, wikis, webhooks and protected branches.
Forgejo is a community-run project descended from Gogs. Gitea forked from Gogs in 2016, and Forgejo forked from Gitea in 2022. It’s much more fully featured and releases often. Codeberg, a nonprofit public alternative to GitHub, runs on Forgejo.
| GitHub | Gogs | Forgejo | |
|---|---|---|---|
| Hosting | GitHub’s cloud | Your server | Your server, or Codeberg |
| Cost | Free for public repos; paid plans | Free, MIT license | Free, GPL license |
| Issues, labels, milestones | Yes | Yes | Yes |
| Pull requests | Yes | Yes | Yes |
| Project boards | Yes | No | Yes |
| Built-in CI | GitHub Actions | No; use webhooks | Forgejo Actions |
| Package registry | Yes | No | Yes |
| Import issues from GitHub | — | No; repos and wikis only | Yes |
| Resources | None to manage | Very light | Light |
Running Forgejo
A minimal compose.yaml, based on the example in the Forgejo Docker docs:
networks:
forgejo:
external: false
services:
server:
image: codeberg.org/forgejo/forgejo:17
container_name: forgejo
environment:
- USER_UID=1000
- USER_GID=1000
restart: always
networks:
- forgejo
volumes:
- ./forgejo:/data
- /etc/timezone:/etc/timezone:ro
- /etc/localtime:/etc/localtime:ro
ports:
- "3000:3000"
- "222:22"docker compose up -d
Then open http://your-server:3000/ and complete the setup page. The first account you create becomes the administrator. For production, put it behind a reverse proxy with HTTPS and use PostgreSQL or MySQL instead of the default SQLite.
Running Gogs
For Gogs 0.14, the container from its Docker README:
mkdir -p /var/gogs
docker run --name=gogs -p 10022:22 -p 10880:3000 -v /var/gogs:/data gogs/gogs:0.14
Open http://your-server:10880/ and the install page walks you through the database, URL and SSH settings. Clones then look like git clone ssh://git@your-server:10022/you/project.git.
The Gogs project has announced a new, more secure container image that becomes the default in 0.16, so check the README for the version you install.
Writing issues worth reading
A good bug report lets someone who has never seen the problem reproduce it. A template makes that the default:
---
name: Bug report
about: Something isn't working
labels: bug
---
**What happened**
**What you expected to happen**
**Steps to reproduce**
1.
2.
3.
**Version and environment**
(app version, OS, browser)
**Logs or screenshots**
Where to put it:
- GitHub and Forgejo:
.github/ISSUE_TEMPLATE/bug_report.md. Forgejo also reads.forgejo/ISSUE_TEMPLATE/, so one file can serve both. - Gogs: a single file at
.gogs/ISSUE_TEMPLATE.md,.github/ISSUE_TEMPLATE.mdorISSUE_TEMPLATE.md.
Then keep the tracker tidy:
- Labels for type and state:
bug,feature,docs,needs-info,good first issue. - Milestones for releases, so “what’s left for 1.2?” is one click.
- One problem per issue. Split anything that would need two separate fixes.
- Close with a reason. Fixed in which commit, or why it won’t be fixed.
Connecting the dots: issue, test, commit
Here’s the whole loop for a single bug, the way we work it.
1. The issue
Someone files #42: “Crash when the input file is empty”, with steps to reproduce.
2. A branch named after it
git switch -c 42-empty-input-crash
Putting the issue number in the branch name means anyone can tell what a branch is for at a glance. On GitHub, gh issue develop 42 --checkout creates a branch and links it to the issue in one step.
3. A failing test first
Before touching the fix, reproduce the bug in a test, and name it after the issue:
def test_issue_42_empty_input_does_not_crash(tmp_path):
empty = tmp_path / "empty.csv"
empty.write_text("")
assert load_rows(empty) == []
Run it and watch it fail. That red test proves you’ve found the bug, and it guards against the bug ever coming back.
4. Commits that mention the issue
Reference the issue in every related commit. A plain mention like Refs #42 links the commit to the issue’s timeline without closing anything:
git commit -m "Add failing test for empty input" -m "Refs #42"
git commit -m "Return no rows for an empty file" -m "Fixes #42"
The second commit uses a closing keyword. When it lands, the tracker closes #42 and links it to the commit that fixed it.
5. The pull request
git push -u origin 42-empty-input-crash
gh pr create --fill # GitHub; or open the pull request in the web UI
Put Fixes #42 in the pull request description too. Reviewers see which issue it resolves, and on GitHub the issue and pull request are linked in both directions.
6. Merge, and the loop closes
When the pull request merges into the default branch, #42 closes automatically. Its timeline now shows the branch, every commit that mentioned it, the pull request and the fix. The regression test stays in the suite, green, forever.
Closing keywords on each platform
All three platforms understand the same nine closing keywords:
close closes closed fix fixes fixed resolve resolves resolved
They’re case-insensitive. Forgejo and Gogs also understand reopen, reopens and reopened. The details differ in ways that matter:
| GitHub | Forgejo | Gogs | |
|---|---|---|---|
| Same-repo reference | Fixes #42 | Fixes #42 | Fixes #42 |
| Another repo | Fixes owner/repo#42 | Fixes owner/repo#42 | Fixes owner/repo#42 |
| Works in commit messages | Yes | Yes | Yes |
| Works in pull request descriptions | Yes | Yes | No; use commits |
| Which branch closes the issue | Default branch only | Default branch only, unless a repo setting allows any branch | Any branch you push |
Fixes: #42 with a colon | Works | Works | Doesn’t match |
| Fix several at once | Fixes #1, fixes #2 | Fixes #1, fixes #2 | Fixes #1, fixes #2 |
To be safe everywhere, write the keyword, one space, then the reference, like Fixes #42, and repeat the keyword for each issue.
Forgejo can also log time from a commit. Add @ and a duration after the issue number, as in Fixes #42 @1h30m, and the time is added to the issue’s time tracker when it closes.
Going the other way: from code back to the issue
The links pay off months later, when you’re staring at a strange line of code:
git blame -L 120,130 src/loader.py # which commit last changed these lines?
git show 7866990 # read its message: "Fixes #42"
Or start from the issue and find every commit that touched it:
git log --oneline -E --grep='#42([^0-9]|$)'22c0d9f Refs #42: handle tabs too
7866990 Fix crash on empty input
The ([^0-9]|$) part stops #42 from also matching #420 and #4217. A plain --grep='#42' would include them.
Make it a habit with a commit-msg hook
A small hook can reject commits that don’t mention an issue. Keep it in the repository so the whole team shares it. .githooks/commit-msg:
#!/bin/sh
# Reject commits that don't mention an issue, like "Fixes #42" or "Refs #42".
# Merge commits and fixup! commits are let through.
msg_file="$1"
if grep -qE '^(Merge|fixup!|squash!)' "$msg_file"; then
exit 0
fi
if ! grep -qE '#[0-9]+' "$msg_file"; then
echo "commit-msg: mention an issue, for example 'Fixes #42' or 'Refs #42'." >&2
exit 1
fi
Turn it on once per clone:
chmod +x .githooks/commit-msg
git config core.hooksPath .githooks$ git commit -m "Tidy up"
commit-msg: mention an issue, for example 'Fixes #42' or 'Refs #42'.
Hooks run on each developer’s machine and can be skipped with --no-verify, so treat this as a helpful reminder. For a hard rule, add the same check to your CI.
Working from the terminal on GitHub
The GitHub CLI keeps the whole loop in your terminal:
gh issue create --title "Crash on empty input" --label bug
gh issue list --label bug
gh issue view 42
gh issue develop 42 --checkout # branch linked to the issue
gh pr create --fill
gh issue close 42 --comment "Fixed in 7866990"A checklist
- Every bug and feature gets an issue before it gets code.
- Issues follow a template, with steps to reproduce.
- Branches are named
<issue>-<short-description>. - A failing test reproduces the bug, named after the issue.
- Every commit mentions an issue; the fix says
Fixes #<issue>. - Pull requests repeat
Fixes #<issue>in the description. - Close every issue with a reason and a link.
Further reading: GitHub’s guide to linking a pull request to an issue, Forgejo’s linked references, and the chapter on contributing to a project in Pro Git. For the branching side, see Git for every project.