GitHub Issues backend
View as markdownA Shelbi project keeps its board in one of two places. By default the board
is private markdown on your own machine (the file system backend). Set
issue_tracker.backend: github and the board becomes GitHub issues in a
single repo instead: every card is a real issue your team can see, file, and
comment on from github.com, while Shelbi keeps driving it from the TUI and the
CLI exactly as before.
This page covers how that mapping works, what Shelbi writes onto GitHub (and what it deliberately does not), how assignees gate Zen Mode, and how the daemon keeps reads fresh without burning through your API budget.
Enabling it
The backend is selected by the issue_tracker
block in the project YAML:
issue_tracker:
backend: github
github:
repo: owner/repogithub.repo is the owner/repo slug of the repository the issues live in.
It is required whenever backend: github, and it is validated at load time: a
missing or malformed slug fails the load with an actionable error naming the
field, so a config that would not resolve is never written.
Prerequisites
Shelbi talks to GitHub through the gh CLI, and
resolves the auth token out of band rather than storing one in any committed
file. Before enabling the backend:
ghis installed and authenticated. The token is resolved from$GH_TOKEN, then$GITHUB_TOKEN, thengh auth token(theghkeychain), then an out-of-repotokens.yml.gh auth loginis the normal path.- The token can read issues and manage labels on the repo. In practice that
means Issues are enabled on the repo and your account has push access, because
Shelbi creates its label scheme on first use (see
label auto-creation). The repo-scoped
repotoken scope covers both.
No secret is ever written into the project YAML or any other file on disk. The
github: block carries only the non-secret repo selector.
Setup preflights this for you
When you pick GitHub Issues during first-project setup,
Shelbi preflights gh auth and repo access before writing anything, explains
the fix if the check fails, and lets you retry or fall back to the file system.
What the file system backend does instead
Leave issue_tracker out entirely (or set backend: file_system) and the board
stays local: each card is a markdown file under
~/.shelbi/projects/<name>/tasks/, with no GitHub involved. This is the default,
so existing projects keep working untouched. A local read is a directory read and
is free, so none of the caching or budget machinery on this page applies to it.
Backends that are declared but not implemented
The issue_tracker.backend enum also accepts jira and linear. These parse
and validate (a jira: or linear: block requires its selector), but they have
no live store yet: selecting one resolves to a typed error, reported exactly as:
issue_tracker backend `jira` is not yet implemented (only `file_system` is available today)Only file_system and github resolve to a working board today. (That error
string predates the GitHub backend and still names only file_system; github
is nonetheless live.)
How issues map to cards
One Shelbi card is one GitHub issue. The link between them is carried entirely in labels and a hidden metadata block, so the issue stays a perfectly normal GitHub issue that a human can read and comment on.
The label scheme
Each tracked issue carries two Shelbi labels:
- An id anchor:
shelbi:id/<slug>, the stable round-trip anchor that maps a GitHub issue back to its Shelbi id. GitHub caps a label at 50 characters, so an id whose label would overflow is anchored under a truncatedshelbi:id/<slug>-<hash8>label and carries its full id in the metadata block instead. Because the label stays a deterministic function of the id, resolving a card by id is a single server-side label query. - A single status label:
shelbi:status/<status>, naming the card's current column (for exampleshelbi:status/in-progress). Exactly one status label is present at a time.
Every other label on the issue is Shelbi's to leave alone. A move swaps only the
single shelbi:status/* label and preserves every non-Shelbi label untouched, so
your own triage, area, or priority labels ride along undisturbed.
The metadata block
Shelbi's own fields that have no natural GitHub home live in a fenced block at the bottom of the issue body, delimited by HTML comments so they render as nothing on github.com:
Human prose. Written by whoever filed the issue. Shelbi never touches this.
<!-- shelbi:begin -->
```yaml
workflow: app
branch: jlong/fix-login
depends_on:
- some-dependency
priority: 0
```
<!-- shelbi:end -->The fields written into the block are:
| Field | Meaning |
|---|---|
id | The full Shelbi id, present only when the shelbi:id/* anchor label had to be truncated. A short id round-trips through the label alone and omits this. |
workflow | The workflow the card runs under. |
branch | The card's branch: override, when set. |
pr | The card's pull request: its number and url, recorded when the workflow's open_pr opens or finds it. Its state and CI aren't stored here: the hub reads them locally, so a CI result never rewrites the issue or moves it in the Done column. |
depends_on | The card's dependency ids. |
prefers_machine | The machine-affinity routing hint. |
priority | A plain integer, renumbered across a column client-side exactly like the file system board. |
zen | The card's per-issue Zen override. |
launch | The card's launch config. |
Any unknown keys flatten into the card's params, so a newer Shelbi binary's fields survive a read by an older one. A block with no set fields is omitted entirely rather than written as an empty fence.
Shelbi never rewrites the human prose around the block. A field update
(set_fields / set_priority) rewrites only the fenced block and preserves the
prose verbatim. If a human hand-edits the block into something unparseable, the
write path refuses rather than clobbering the edit with default metadata, and the
read path renders the card with empty metadata after warning once.
You rarely need to edit the block by hand. shelbi issue edit <id> opens the
card's fields and prose in $EDITOR and writes back what you changed, and
shelbi issue edit <id> --param <key>=<value> sets a single param. See
shelbi issue edit.
Terminal statuses close the issue
Moving a card to a terminal status closes the GitHub issue, encoding which terminal in GitHub's own close reason so the mapping recovers even from GitHub state alone:
| Shelbi status | GitHub state |
|---|---|
done | closed, state_reason: completed |
canceled | closed, state_reason: not_planned |
The mapping is enforced in both directions. A closed issue always reads as
terminal: its terminal status label if it has one, otherwise done (or
canceled when GitHub's reason is not_planned), so closing an issue on
github.com lands it in a terminal column regardless of a stale non-terminal
status label. Symmetrically, an open issue never reads as terminal: an
open issue still carrying a stale shelbi:status/done or shelbi:status/canceled
label (the shape you get by reopening a done issue to comment) reads as backlog
for re-triage, and the stale label is repaired by the next Shelbi-initiated move.
Issues opened directly on GitHub
An issue someone files on github.com without Shelbi's labels is still handled:
- No
shelbi:id/*label: the issue number stands in as the card's id, so an un-migrated repo still renders and every write, move, and comment path can act on the issue by its number. - No
shelbi:status/*label: the card enters at its workflow's declared initial status (backlogfor the shippedtaskandsubtaskworkflows, and the fallbackdefaultworkflow), never a hardcodedtodo. Landing an externally-filed issue straight in the ready lane would skip your triage step, so it waits in the entry column instead.
Pull requests are filtered out everywhere. GitHub models a PR as a kind of issue, but Shelbi never returns one as a card on any read, search, or poll path.
Label auto-creation
Shelbi creates its labels on first use. Before any write applies a
shelbi:id/* or shelbi:status/* label, it ensures the label exists, creating
any that are missing with the description Managed by shelbi. The six stock
status labels get distinct colors; custom status labels and id labels get
GitHub's default grey. The first write also creates the full stock
shelbi:status/* set, so every stock column has its label. Creation is
idempotent: a label that already exists, or that races into existence, is left
untouched. There is no separate provisioning step.
What is not synced to GitHub
Workspace assignment stays local. A card's assigned_to field (which
workspace is currently working it) is ephemeral routing state, not board state a
github.com viewer should see, so it is never written to GitHub. It lives in a
local assignment overlay (marker files under <project_dir>/assignments/).
Every read folds the overlay back onto the reconstructed card, so the active-
workspace, conflict, and supervision scans still see which workspace owns a card
even though GitHub stores nothing about it. Because assignment never touches the
issue, assigning a workspace does not bump the issue's timestamp or ETag.
This is distinct from GitHub's own assignees (the people assigned to the issue), which Shelbi does read, as the signal that gates which issues it may start. See the next section.
GitHub assignees and Zen Mode
On the file system backend every card is yours: there is no notion of ownership, so nothing is filtered on that axis. On GitHub, where anyone can file an issue in your repo, Shelbi uses the issue's assignees to decide what Zen Mode may act on autonomously.
- Issues Shelbi creates are assigned to you. A card created through Shelbi is
assigned to the authenticated
ghuser, so the work Shelbi files stays eligible for auto-promotion. (If that login cannot be resolved, for example an unauthenticated token or an offline machine, the issue is created unassigned with a warning rather than failing the create; it simply will not be Zen-eligible.) - Shelbi only starts issues assigned to you. On a GitHub board,
shelbi issue startandshelbi issue resumerefuse an issue whose assignees don't include you, and so does every automatic start: the orchestrator's dispatch, the daemon's active-gate dispatch, and the review auto-load. Claim an issue withshelbi issue start <id> --take(it assigns the issue to you on GitHub, then starts it) or by assigning yourself on github.com. The orchestrator never passes--takeon its own. - Zen only auto-promotes backlog issues assigned to you. Under Zen, a backlog issue is mechanically eligible only when its GitHub assignees include the authenticated account's login. Work an outside contributor filed, or that is assigned to someone else, is never auto-promoted. The match is case-insensitive. When the login cannot be resolved the gate fails closed: nothing is eligible, rather than risking autonomous action on the whole repo.
The authenticated login comes from the daemon's board index (captured on the same
GraphQL read that refreshes the board, at no extra API cost), so the gate costs
nothing per tick. shelbi issue list shows each card's owner (me, another
login, or unassigned), and task lines on the
events log carry the same
information as an owner= token.
To make an outside issue eligible, assign yourself to it on GitHub. Adding your account to the issue's assignees is exactly what lets it pass the ownership gate on the next refresh. This gate is only mechanical eligibility; the Zen judgment categories still decide whether an eligible issue is actually promoted.
Freshness and rate limits
A remote board is a paginated API sweep, not a free directory read, so Shelbi
does not let every view hit GitHub on its own cadence. Instead the hub daemon
owns a single board index and every list consumer (the sidebar, the Issues
board, the pollers, shelbi zen scan, shelbi issue list) reads that index
rather than calling GitHub itself.
The board index
The daemon runs one refresh loop per open project. It reads the board through the
store and publishes it to board-index.json in the project's state directory
(~/.shelbi/projects/<name>/). The index holds the open board, an id-to-number
map, and a freshness envelope (when it was fetched, the last-seen rate-limit
remaining and reset, the active read path, and the authenticated login).
The refresh cadence is the issue_tracker.refresh_secs value, defaulting to
30s. Readers treat the index as stale after roughly three refresh intervals
(with a 90s floor). A full cold read is forced at least every 10 minutes;
between cold reads the loop fetches only incremental deltas.
The done-history cache
Closed cards are cached separately, in done-history.json: the first page of
done and canceled issues, newest-closed first, with a 10-minute TTL. The
terminal history barely changes, so it is served from disk without a backend
request until the TTL expires, and a merge or cancel through Shelbi patches the
page immediately so the move is visible on the next paint.
GraphQL with a REST fallback
The board refresh uses GraphQL, which bills against a separate 5,000-points-per-
hour budget. If a GraphQL read fails for any reason that is not a rate limit
(an older GitHub Enterprise host, a token missing a GraphQL scope, a transport
blip), Shelbi falls back to the REST open-issue list for that refresh and records
that the index came from the fallback path. A GraphQL rate limit is not
retried against REST, because the two budgets are separate and hammering REST
would not help. Single-issue fetches use a REST conditional GET with
If-None-Match, so an unchanged issue comes back as a 304 Not Modified that
costs no quota.
The budget, and what shelbi status shows
shelbi status --full prints a ## GitHub section for a remote project with the
read path, the index age, and the per-budget remaining, reset, and request count
over the last hour:
## GitHub
read path: graphql
board index: 17s old (warm)
graphql budget: 4014 remaining, resets 13:22 · 729 requests in the last hour (1 failed)
rest budget: 4971 remaining, resets 13:36 · 142 requests in the last hour (20 not-modified)The bare shelbi status summary condenses this to one line:
github: board 17s · graphql 4014 leftConditional-GET 304 reads show as (N not-modified) and failed attempts as
(N failed), kept separate from spend so neither reads as a consumed request.
A budget governor scales the refresh down as the GraphQL budget tightens: above 2,000 points it runs at the configured cadence; between 500 and 2,000 it slows to a 120s cadence; at 500 or below the index refresh pauses until the quota resets, then resumes. An actual 403/429 parks it until the reset regardless. Single-issue fetches are REST conditional GETs and aren't governed by this GraphQL budget. A separate REST reserve (100 requests) refuses a mutating call up front rather than failing deep inside a transition:
shelbi: refusing to write to owner/repo: only 98 GitHub REST requests remain
(reserve floor 100); the limit resets at 2026-10-02T13:36:00+00:00. Not sending the request.shelbi doctor adds the observed request rate, a projected time to run out,
and a warning naming the top callers when a budget would run out within 30
minutes at the current rate.
When the budget runs out or auth fails
-
Rate limit reached: board reads park until the quota resets rather than hammering a 403. The status section shows the budget as
· parked, and a read that must run returns a typed "board reads … are parked until the GitHub API rate limit resets" error. -
Network unreachable: a connection failure parks reads behind an escalating breaker; the status section adds a
network: unreachable (board reads parked)line. -
ghauth fails: the daemon cannot publish an index, so the section readsboard index: never publishedwith alast error:line. The underlying error names every place the token resolver looked and the fix:no auth token found for the `github` issue-tracker backend: checked $GH_TOKEN, $GITHUB_TOKEN, `gh auth token`, and <token_file>; run `gh auth login` or set GH_TOKEN
Shelbi never renders stale data as if it were live: an unreachable or unauthenticated read surfaces a clear error instead.
Comments, and moving cards from either side
The board is bidirectional. You can drive it from Shelbi or from github.com, and the other side catches up on the next refresh.
- Comments.
shelbi issue commentposts a native GitHub issue comment, and reads surface comments back (new comments appear as events the orchestrator can react to). Shelbi appends a hidden idempotency marker to each comment it posts so a retried send never duplicates; the marker is stripped on every read, so it never reaches the UI. - Moving a card in Shelbi relabels the issue. A move swaps the single
shelbi:status/*label (keeping every other label) and opens or closes the issue when the target status is terminal, in one verified GitHub update. - Editing on GitHub is picked up on the next refresh. Change a card's status label, or close an issue, directly on github.com and Shelbi reconciles it on the next read through the state-and-label mapping described above.
- Merged PRs auto-reconcile. When a review-stage card's pull request is
merged on GitHub out of band (a teammate clicks Merge), the hub poller advances
the card straight to
donewith the local merge skipped (GitHub has already integrated the branch) and emits areason=poller:github-merge-reconciletransition so the move shows up like any other. This runs on a slow reconcile clock, not the fast workspace tick, controlled bygithub_reconcile_interval_secs(default900; set0to disable).
Migrating between backends
Move an existing board from one backend to the other with
shelbi issue-store migrate:
# Push a local markdown board up to GitHub issues.
shelbi issue-store migrate --to github --dry-run # preview first
shelbi issue-store migrate --to github
# Or pull GitHub issues down into a local markdown board.
shelbi issue-store migrate --to file_systemThe migration matches on the stable Shelbi id, so a re-run never duplicates an
already-migrated card. A --to github run paces its creates (default 7.5s
between issues) to stay under GitHub's secondary content-creation limit; switch
the project's issue_tracker.backend once the migration completes. Comments are
not migrated, and workspace assignments are dropped.
See also
- Project config: the
issue_trackerblock's full field reference, includingrefresh_secsand the rate-limitbudgetthresholds. shelbi issue: the CLI that drives the board, includingcommentand theissue-store migratebackend mover.shelbi status: the bootstrap snapshot whose## GitHubsection reports the read path and budget.- Zen Mode: the autonomy the assignee gate protects.
- The events log: where the
poller:github-merge-reconcileand board-refresh lines land. - Set up your first project: choosing GitHub Issues or the file system during onboarding.