Why this matters
A project is more than its code. It also has a list of things that are broken, things people want, and questions nobody has answered yet. If that list lives only in one person's head, work gets lost, repeated, or done twice.
GitHub keeps that list in issues. An issue is a numbered note attached to a project. It describes a problem, a request, or a question. Each issue gets a number, such as #12, and other notes can point to that number.
This lesson covers three parts of that system. The first is writing an issue that someone else can act on. The second is using labels and milestones to sort issues. The third is linking an issue to the pull request that fixes it.
The Git commands in this lesson were run in a small local repository. The web steps are described from GitHub's interface, which can change over time, so the button names may differ slightly on your screen.
Tags and releases, covered earlier in this module, name a commit as a release. Milestones, described below, group issues toward a goal, and a release is often that goal.
Terms used in this lesson
A repository (repo) is a project folder that Git tracks, along with its full history. GitHub hosts repositories and adds an Issues page to each one.
An issue is a numbered note on the Issues page. It has a title, a body, comments, labels, and a state that is either open or closed.
A label is a short colored tag on an issue, such as bug. Labels are for sorting.
A milestone is a named goal, such as "Version 0.2", with an optional due date. Issues and pull requests can be added to it.
A pull request (PR) is a request to merge the changes on one branch into another branch, usually main. Other people can read the changes and comment before anything is merged.
The default branch is the main branch of the repository, usually named main. A closing keyword, explained later, only closes an issue when the change reaches the default branch.
A closing keyword is a word such as Fixes. When it is followed by an issue number, GitHub closes that issue once the change is merged.
What makes an issue useful
Imagine a small program that greets people. You give it a name and it prints a greeting. Here is the program:
def greet(name):
first = name.split()[0]
return "Hello, " + first
print(greet("Ada Lovelace"))
print(greet(""))The first call works. The second call crashes. Run it with python3 greet.py and the output is:
Hello, Ada
Traceback (most recent call last):
File "greet.py", line 7, in <module>
print(greet(""))
~~~~~^^^^
File "greet.py", line 2, in greet
first = name.split()[0]
~~~~~~~~~~~~^^^
IndexError: list index out of rangeThe folder path before greet.py is removed from the output above. Everything else is copied exactly as the program printed it.
The last line names the error. An IndexError is Python's name for a crash that happens when code asks for a position in a list that does not exist. The empty string splits into an empty list, so position 0 does not exist.
Now compare two issues that report this crash. The first one is the kind that wastes everyone's time:
Title: It's broken
Body: The greeter doesn't work. Please fix.A maintainer reading this has to ask questions before doing anything. Which program? Which input? What does "broken" mean?
The second one can be acted on right away:
Title: Greeter crashes when the name is empty
Steps to reproduce:
1. Run: python3 greet.py
2. The file calls greet("") with an empty name.
Expected: a greeting, or a clear message about the empty name.
Actual: the program stops with this error:
IndexError: list index out of range
Environment: Python 3.14.4 on Linux.Both issues describe the same bug. Only the second one gives the reader a way to see it, a way to check the fix, and a way to tell it apart from other problems.
The parts of a good issue
A useful issue has five parts.
A title that names the problem. "Greeter crashes when the name is empty" tells a reader what is wrong without opening the issue. "It's broken" does not. People scan titles in a list, so the title has to work alone.
Steps to reproduce. These are the exact actions, in order, that show the problem. Someone who follows the steps should see the same result you saw. If you cannot reproduce the problem, say so. That is still useful information.
Expected and actual. Say what should happen and what does happen. For the actual part, paste the error text exactly as it appeared. Do not retype it from memory and do not shorten it. A small change in the text, such as a different exception name, can point a maintainer to a different cause.
Context. Give the version of the tool, the operating system, and the commit ID if you know it. A commit ID is the long code Git gives each saved snapshot. The short form uses the first 7 characters, such as 9c24173. The version tells a maintainer whether the problem still exists in the newest code.
One problem per issue. If the greeter also has a spelling mistake in its help text, open a second issue. Each issue can then be fixed, merged, and closed on its own. An issue that covers three problems can only be closed when all three are fixed, which is slow and confusing.
Before you open a new issue, search the existing ones for the same error text. A duplicate is closed with a link to the original, so searching first saves time for both you and the maintainer.
Labels
A label is a colored tag. A repository can have as many labels as its owners want. Many projects use a small set with fixed meanings:
| Label | Use it when |
|---|---|
| bug | Something that used to work now fails, or the program does something the docs say it should not |
| question | The author needs an answer before anyone can act |
| documentation | The fix is a change to the docs, not to the code |
Labels are most useful for filtering. In the search box on the Issues page, typing label:bug shows only the issues with that label. Typing is:open label:bug shows only the open bugs.
To add a label to an issue, open the issue and use the Labels box on the right side of the page. An issue can have several labels at once. The issue from the example above would have the bug label. If someone also asks how to set a language for the greeting, that is a separate issue with the question label.
A few habits keep labels useful:
Use the same label the same way every time. A label called bugs next to a label called bug splits the count, so a search for one misses the other.
Keep the list short. Ten labels with overlapping meanings are harder to use than three clear ones.
Renaming a label keeps it on the issues that already have it. Deleting a label removes it from every issue that had it. If you only want a new name, rename the label instead of deleting it.
Milestones
A milestone is a goal with an optional due date. You add issues and pull requests to it. GitHub shows a progress bar that fills as the items in the milestone close.
Suppose the greeter project plans a release called "Version 0.2" with a due date. You add the crash issue, a question about languages, and two pull requests to that milestone. When all four are closed, the bar is full and the release is ready.
Labels and milestones answer different questions. A label says what kind of work an issue is. A milestone says which goal it belongs to. An issue can have many labels, but it can have only one milestone at a time. If you find yourself wanting a label such as next-week, use a milestone with a due date instead, because the date will show up in the milestone view and stay correct when the week ends.
Linking an issue to a pull request
A pull request is the usual way to fix an issue. You make a branch, change the code, and open a pull request that asks to merge the branch into main. A reviewer reads the changes, and once the change is merged, the code is part of the default branch.
The link between the issue and the pull request is made with a closing keyword. Here is a fix for the crash. It is on its own branch so the change can be reviewed before it reaches main:
git switch -c fix-empty-name
# edit greet.py to handle the empty name, then save the file
git add greet.py
git commit -m "Handle empty name in greet" -m "Fixes #12"
git push -u origin fix-empty-nameThe git switch -c fix-empty-name line creates a new branch and moves onto it. The -m flag gives the commit message. Using -m twice puts the second text in its own paragraph, which is where GitHub looks for the keyword. The git push line sends the branch to GitHub. It needs a remote named origin, which the scratch repository used for this lesson does not have, so the push line was not run here.
The commit and the branch commands were run in the scratch repository. Here is the fixed program, with the same two calls and one more:
def greet(name):
parts = name.split()
if not parts:
return "Hello, friend"
return "Hello, " + parts[0]
print(greet("Ada Lovelace"))
print(greet(""))
print(greet(" "))Running it prints:
Hello, Ada
Hello, friend
Hello, friendThe " " case is a name made only of spaces. It also has no words, so it gets the same fallback. This is the kind of extra case that a good pull request description should mention.
Next, the pull request itself. On GitHub you open a pull request from the branch, and the description box takes the same text as the commit. A simple description is enough:
Title: Handle empty name in greet
Body:
Fixes #12
When the name is empty or only spaces, greet returns "Hello, friend"
instead of crashing.GitHub reads the words in the description and in every commit message. When it finds a closing keyword followed by an issue number, it records a link. The keyword must be followed by a space and then # and the number, so Fixes #12 works and Fixes 12 does not.
These are the closing keywords GitHub recognizes: close, closes, closed, fix, fixes, fixed, resolve, resolves, resolved. Upper case and lower case both work.
The issue is closed when the pull request is merged into the default branch, not when the pull request is opened. A pull request that is still under review does not close anything. Once it is merged, GitHub closes issue #12 and adds a note to the issue that points to the pull request.
The same keyword in a commit message also closes an issue, as long as the commit reaches the default branch. Here is a search for the commits in the scratch repository that carry the keyword, using a Git command:
git log --oneline --grep='Fixes #12'The output in the scratch repository was:
f148134 Handle empty name in greet
3f5a280 Handle empty input in the greeterThe --grep option filters commits whose message contains the text inside the quotes. Both commits match, because the first commit in this scratch repository also used Fixes #12. In a real project, you would run this search to check whether a fix is already on the branch before you open a new issue for it.
Checking which words link an issue
The local script below is not GitHub. It only shows how the closing keywords are matched, so you can see which lines would count. It reads a file with one message per line and prints the issue numbers each line would close.
import re
import sys
KEYWORDS = r"(?:close|closes|closed|fix|fixes|fixed|resolve|resolves|resolved)"
PATTERN = re.compile(rf"\b{KEYWORDS}\s+#(\d+)\b", re.IGNORECASE)
with open(sys.argv[1], encoding="utf-8") as f:
for line_no, line in enumerate(f, start=1):
text = line.rstrip("\n")
hits = PATTERN.findall(text)
print(f"{line_no}: {text!r} -> issues {hits if hits else 'none'}")It was run on five lines:
1: 'Fix crash #12' -> issues none
2: 'fixes #12' -> issues ['12']
3: 'Fixes: #12' -> issues none
4: 'Fixed #5 and closes #9' -> issues ['5', '9']
5: 'Related to #12' -> issues noneLine 2 matches because the keyword is lower case and followed by a space and #12. Line 3 does not match, because a colon sits between the keyword and the number, so the script does not treat it as a closing keyword. Line 5 has the number but no keyword, so it links nothing for closing purposes. GitHub still shows a plain mention such as Related to #12 in the issue's timeline, so people can find the related work, but the issue stays open.
A harder case: a feature that takes two pull requests
Not every pull request finishes an issue. Suppose issue #15 asks for the greeter to support more languages, and it has a checklist in its body:
Title: Support greetings in more languages
- [ ] Spanish greeting
- [ ] French greeting
- [ ] Language option on the command lineGitHub shows the checklist as boxes. The first pull request adds the Spanish greeting only. Its description should use Refs #15, not a closing keyword:
Title: Add Spanish greeting
Refs #15
Adds "Hola" as the greeting for the Spanish option. French and the
command line option come in later pull requests.Refs links the pull request to the issue, so the work shows up in the issue's timeline, but it does not close the issue. The issue stays open until the last pull request, whose description says Fixes #15.
A single pull request can also close several issues, but each issue needs its own keyword. The checker treats the words Fixes #10 and #11 as closing only issue 10:
Fixes #10 and #11 -> issues ['10']
Fixes #10, fixes #11 -> issues ['10', '11']Write Fixes #10, fixes #11 to close both. The same rule applies on GitHub, so this is the form to use when one change fixes two issues.
Common mistakes
Writing the number without the keyword. A commit message that says Fix crash #12 does not close issue 12. The script above prints issues none for that line. Put the keyword in front of the number: Fixes #12.
Leaving out the hash sign. Fixes 12 is not a closing keyword. The script prints issues none for it too. The # is required.
Saving a commit with nothing staged. A common reason a commit is missing is that the changed file was never added. Running the commit command with no staged change gives this output, and no commit is made:
On branch main
nothing to commit, working tree cleanThe exit code is 1, which means the command failed. Run git add on the changed file first, then commit again.
Running Git in the wrong folder. If you run a Git command outside the project folder, Git reports this:
fatal: not a git repository (or any of the parent directories): .gitThe fix is to move into the project folder first. The word fatal means Git stopped and did nothing.
Merging the pull request into the wrong branch. A pull request that targets a branch other than main does not close the issue when it is merged, because the change has not reached the default branch yet. If issue #12 is still open after the merge, check the base branch of the pull request.
Putting several problems in one issue. Two problems in one issue means one of them blocks closing the issue. Split them, then link each pull request to its own issue.
Practice
Exercise 1
Rewrite this issue so that a maintainer can act on it. The original text is "login doesnt work on my phone".
Show the answer
A good version has a specific title, steps, expected and actual results, and context. For example:
Title: Login button does nothing on Android Chrome 120
Steps to reproduce:
1. Open the sign-in page on an Android phone with Chrome 120.
2. Enter a valid email and password.
3. Tap "Sign in".
Expected: the dashboard opens.
Actual: the button shows no change and no error appears.
Environment: Android 14, Chrome 120. Works on desktop Chrome.The exact details depend on the real bug. The point is that each part the reader needs is present.
Exercise 2
Which label fits this issue? "The README says to run npm start, but the command is npm run start."
Show the answer
documentation. The fix is a change to the README text, not to the code. The table in the Labels section lists the meaning of this label.
Exercise 3
A commit message says Fix bug #12. You merge it into main. Does issue 12 close?
Show the answer
No. The word Fix is followed by the word bug, not by #12, so GitHub finds no closing keyword. Change the message to Fixes #12. Editing a commit that has not been pushed is covered in the earlier lesson on fixing the last commit.
Exercise 4
A pull request has Fixes #12 in its description. It is merged into a branch named feature/forms, not main. Is issue 12 closed?
Show the answer
No. The issue closes when the change reaches the default branch. Merge feature/forms into main through another pull request, and the link takes effect at that point.
Exercise 5
Which of these lines would close issue 5 on merge?
a) Refs #5
b) Resolves #5
c) Resolves 5
Show the answer
Only b. Refs #5 has a number with a # sign, but Refs is not a closing keyword, so it links the issue without closing it. Resolves 5 has a closing keyword but no # sign, so GitHub does not recognize the number at all.
Summary
- A good issue has a clear title, exact steps, the expected and actual results with the error text pasted exactly, and the context needed to reproduce it.
- Labels sort issues by kind, such as
bugorquestion, and a search likelabel:bugfilters them. - A milestone groups issues and pull requests toward one goal, and an issue can belong to only one milestone.
- A closing keyword such as
Fixes #12in a pull request description or commit message closes the issue when the change reaches the default branch. - Keep one problem per issue, so each fix can close its own issue.