Skip to content

GitHub Issues: Tracking Work That Has a Definition of Done

Lesson 6 of 10Beginner9 min readGitHub Engineering · GitHub FundamentalsVerified: gh 2.98.0 and the GitHub REST API, August 2026

An Issue is a numbered, stateful record attached to a repository. It has a title, a body, a state of open or closed, and whatever metadata you attach to it.

The important word is stateful. An Issue is designed to close. That single property is what distinguishes it from every other conversation surface on GitHub, and it is the basis of every good decision about when to use one.

The test for whether something is an Issue

Section titled “The test for whether something is an Issue”

Ask: what would have to be true for this to close?

If you can answer — a bug stops reproducing, a feature ships, a document is written — it is an Issue. If you cannot, it is a conversation, and belongs in Discussions.

This matters because an Issue tracker is only useful as a signal if the open count means something. A repository with 400 open Issues where half are open-ended discussions has no usable signal: nobody can tell whether the project has 400 outstanding problems or 200. The tracker stops being a work queue and becomes an archive.

Number. Assigned per repository, shared with pull requests — a repository does not have both an Issue #12 and a pull request #12. This is a hint about how GitHub models them internally, which matters when you use the API.

Title. Written for someone scanning a list of 200. “Crash” is useless; “Client crashes on reconnect when the server closes mid-handshake” is scannable.

Body. Markdown, with task lists, code blocks and references. For bugs, the body should let someone reproduce without asking you anything.

State. Open or closed. Closing also records how — completed, or not planned — which is a small distinction that makes reporting much more honest, because “we fixed 40 Issues” and “we closed 40 Issues” are different claims.

Assignees. Who is doing it. Assigning more than one person usually means nobody is doing it.

Labels. Free-form tags. The main axis of triage.

Milestone. A named grouping with an optional due date, typically a release.

Labels are the most-used and most-abused Issue feature. A repository with sixty labels has no labelling scheme; it has sixty synonyms that nobody applies consistently.

A workable scheme uses a small number of orthogonal axes:

AxisExample labelsAnswers
Typebug, feature, docs, choreWhat kind of work is this?
Priorityp0, p1, p2When does it need doing?
Statusneeds-repro, blocked, readyWhat is stopping it?
Areaarea/api, area/uiWhich part of the codebase?

Orthogonal matters: every Issue should take at most one label from each axis. When labels overlap in meaning, people apply them inconsistently and filtering stops working.

Two special labels are worth adopting deliberately. good first issue and help wanted are recognised by GitHub and surfaced in contributor-facing discovery. Applying them accurately is one of the highest-leverage things a maintainer can do for a project’s contributor pipeline — and applying them to something that is neither is a good way to waste a newcomer’s evening.

Manage them from the terminal:

Terminal window
gh label list
gh label create "needs-repro" --description "Cannot reproduce from the report as written" --color FBCA04
gh label clone owner/other-repo

gh label clone is genuinely useful: it copies a label scheme you have refined into a new repository, so every project does not start from GitHub’s defaults.

This is where Issues stop being a to-do list and become part of the engineering record.

Closing keywords. A pull request whose description contains Closes #42 — or Fixes, or Resolves — automatically closes Issue 42 when the pull request merges, and links the two permanently.

Closes #42
Fixes #17, closes #18

Two details people get wrong. The keyword must be in the pull request description or a commit message, not in a review comment. And to close an Issue in a different repository the reference must be fully qualified: Closes owner/repo#42.

Plain references. Mentioning #42 anywhere creates a visible cross-link without closing anything. Useful for “related to” without asserting that this work finishes it.

Commit references. Mentioning an Issue number in a commit message links the commit to the Issue. This is how, months later, you find the commit that fixed a bug by starting from the bug report.

Left to themselves, bug reports arrive as “it doesn’t work”. Templates fix this by supplying structure at creation time.

Markdown templates live in .github/ISSUE_TEMPLATE/ and pre-fill the body. Simple, and easy for a reporter to delete.

Issue forms are YAML in the same directory and render actual form fields — dropdowns, checkboxes, required inputs. Because fields can be required, forms produce far more consistent reports.

name: Bug report
description: Something is not working as documented
labels: ["bug", "needs-repro"]
body:
- type: textarea
id: what-happened
attributes:
label: What happened?
description: What did you expect, and what happened instead?
validations:
required: true
- type: input
id: version
attributes:
label: Version
placeholder: "1.4.2"
validations:
required: true
- type: dropdown
id: os
attributes:
label: Operating system
options: [Linux, macOS, Windows]
validations:
required: true

A config.yml in the same directory can add links to other destinations — a Discussions category for questions, a security policy for vulnerabilities — and optionally disable blank Issues entirely. Routing questions away from the tracker before they are filed is more effective than closing them afterwards.

The search syntax is the difference between a tracker you can use and a list you scroll.

is:issue is:open label:bug no:assignee
is:issue is:open assignee:@me sort:updated-desc
is:issue is:closed closed:>2026-08-01
is:issue is:open -label:needs-repro created:<2026-01-01

The same qualifiers work from the CLI, which is where they become scriptable:

Terminal window
gh issue list --state open --label bug --search "no:assignee" \
--json number,title,createdAt --limit 50

What it doesLists open Issues labelled `bug` with no assignee, as JSON, showing number, title and age.

Why we run itThis is the triage query — unowned bugs are the ones that quietly rot. Structured output means it can feed a report rather than being read by eye.

Expected resultA JSON array of Issue objects with the three requested fields.

Three queries worth running regularly on any repository you maintain: open bugs with no assignee, Issues not updated in ninety days, and Issues labelled needs-repro older than a month. Each identifies a different kind of decay.

The full command family is covered in gh issue; the essentials:

Terminal window
gh issue create --title "Retry logic drops the final attempt" --body-file report.md --label bug
gh issue list --state open --limit 20
gh issue view 42 --comments
gh issue edit 42 --add-label p1 --add-assignee @me --milestone "v2.1"
gh issue close 42 --reason completed
gh issue develop 42 --checkout

gh issue develop is the least-known and most useful of these: it creates a branch linked to the Issue and checks it out, so the connection between the work and its record exists from the first commit rather than being remembered at pull request time.

Issues can be added to Projects, which provide boards, custom fields, roadmaps and cross-repository views.

The division of responsibility is worth stating clearly, because conflating them is common: the Issue is the record of the work and lives with the repository. The Project is a view over many Issues, used for planning. An Issue is complete without a Project; a Project is just a lens.

Planning tooling is a subject of its own and is not covered further here — this lesson deliberately stops at the boundary, because “how should we run our sprint” is not a question about GitHub Issues.

Most Issues that stall do so because they cannot be acted on, not because nobody cares.

A bug report needs four things, and it is worth stating them explicitly because reporters routinely supply two:

What you did. Precise enough to repeat. “Called the client with a 4KB payload against a server that closes the connection after the headers” — not “used the client”.

What you expected. Often omitted because it feels obvious to the reporter and is not to anyone else.

What happened instead. The actual output, error or behaviour. Paste it; do not describe it.

Your environment. Version, operating system, and anything unusual about the setup. Most irreproducible bugs are environment differences nobody wrote down.

A feature request has a different shape. The useful version describes the problem, not the solution:

I need to run the client behind a proxy that requires authentication. Currently there is no way to supply credentials, so I am patching session.py locally.

That invites a design conversation. “Add a proxy_auth parameter” pre-commits to one answer and often the wrong one — the maintainer may know a better mechanism, or that one already exists.

A few numbers tell you a great deal about how a project is run, and all are visible without asking anyone.

Open-to-closed ratio over time. Steadily rising open count means intake exceeds capacity. That is not a criticism — it is the normal state of a popular unpaid project — but it tells you how long your report is likely to wait.

Age of the oldest open Issue. Something open for four years is either genuinely hard or the tracker is an archive.

Time to first response. More predictive of a good experience than time to close. A project that replies within a day and fixes in a month is more pleasant than one that fixes in a week after three weeks of silence.

Proportion with no label. Unlabelled Issues are untriaged. A tracker that is mostly unlabelled is one nobody is reading.

Terminal window
gh issue list --state open --search "no:label" --json number --jq 'length'
gh issue list --state closed --limit 100 --json closedAt,createdAt \
--jq '[.[] | ((.closedAt | fromdateiso8601) - (.createdAt | fromdateiso8601)) / 86400] | add / length | floor'

The second computes mean days-to-close over the last hundred. It is crude — it ignores the ones still open, which are the slow ones — and it is still more informative than the raw open count.

Closing is a communication act, and doing it carelessly is how projects acquire a reputation for being unwelcoming.

Say why. “Fixed in #57” or “not planned — this conflicts with the design in #12” takes ten seconds and leaves a record.

Use the reason field. completed and not_planned are different claims, and reporting that conflates them overstates how much was fixed.

Do not close silently. An Issue that disappears with no comment reads as dismissal, whatever was intended.

Closing is not rejection. A tracker where everything stays open to avoid seeming unwelcoming becomes useless, and an honest “not planned” respects the reporter’s time more than five years of ambiguity.

Using Issues for open-ended discussion. They never close, and the open count stops meaning anything.

Titles written for the author. “Bug in the thing” is not findable in six months.

Sixty labels. A scheme nobody can apply consistently is not a scheme.

Not linking pull requests to Issues. Loses the archaeology when it is most needed.

Assigning three people. Diffuses responsibility; nobody owns it.

Closing without a reason. “Completed” and “not planned” tell very different stories later.

Leaving Issues open forever out of politeness. An honest “not planned” respects the reporter more than five years of silence.

Use the repository you created in the cluster exercise.

  1. Create three labels forming one axis — bug, feature, docs.
  2. Open an Issue with gh issue create, describing something genuinely fixable.
  3. Run gh issue develop <number> --checkout and confirm you are on a new linked branch.
  4. Make a commit and push, then open a pull request whose body contains Closes #<number>.
  5. Merge the pull request and confirm the Issue closed automatically.
  6. Run gh issue list --state closed --json number,title,closedAt and observe the record.

Step 5 is the one to watch closely — seeing the Issue close by itself makes the link between the two systems concrete in a way that reading about it does not.

  • An Issue is a stateful record designed to close; if it cannot close, it is a Discussion.
  • Issues and pull requests share one number sequence per repository.
  • A small orthogonal label scheme is usable; a large one is not.
  • Closing keywords in a pull request body link and close automatically, and cross-repository references must be fully qualified.
  • Issue forms enforce required fields, which produces reports you can act on.
  • Search qualifiers work identically in the web interface and the CLI, which makes triage scriptable.
Free Git Engineer Cheat Sheet BundleSix printable references plus downloadable toolkit files — commands, recovery, aliases, a CODEOWNERS starter. No email required.