Skip to content
Red-Green Software

· 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.

#git #tools #testing

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.

GitHubGogsForgejo
HostingGitHub’s cloudYour serverYour server, or Codeberg
CostFree for public repos; paid plansFree, MIT licenseFree, GPL license
Issues, labels, milestonesYesYesYes
Pull requestsYesYesYes
Project boardsYesNoYes
Built-in CIGitHub ActionsNo; use webhooksForgejo Actions
Package registryYesNoYes
Import issues from GitHub—No; repos and wikis onlyYes
ResourcesNone to manageVery lightLight

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.md or ISSUE_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:

GitHubForgejoGogs
Same-repo referenceFixes #42Fixes #42Fixes #42
Another repoFixes owner/repo#42Fixes owner/repo#42Fixes owner/repo#42
Works in commit messagesYesYesYes
Works in pull request descriptionsYesYesNo; use commits
Which branch closes the issueDefault branch onlyDefault branch only, unless a repo setting allows any branchAny branch you push
Fixes: #42 with a colonWorksWorksDoesn’t match
Fix several at onceFixes #1, fixes #2Fixes #1, fixes #2Fixes #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.