Tracker integrations
heap. syncs issues from external trackers into its columns. The provider layer
is descriptor-driven: every tracker is one ProviderDescriptor in
src/integrations/ProviderRegistry.cpp,
executed by the generic RestIssueProvider. Adding a REST tracker is a registry
entry — no networking or UI code. Jira and Trello are the two bespoke exceptions.
Access tokens live in the OS keychain (QtKeychain), never in state.json.
See docs/DATA.md for what does get persisted.
Connecting a tracker (users)
Open Settings → Integrations. Each card is collapsed; click it to expand. There are three ways to authenticate, depending on the provider:
| Method | Providers | What you do |
|---|---|---|
| One-click browser | GitHub, GitLab, Jira, Trello, Todoist, Asana, ClickUp, Sentry, Bitbucket* | Click Connect with browser, authorize, done. |
| Personal access token | all | Open Advanced, fill in the fields, press Connect. Fallback everywhere. |
| Device code | GitHub | The card shows a short code — enter it on the page that opens. |
* Only in a build that carries the OAuth app credentials, and Gitea/Forgejo/ self-hosted GitLab only once you register a client ID on your instance. If the button is missing, see “No browser button?” below — the token path always works.
Signing in tells the tracker who you are, not what to sync. Asana, ClickUp, Sentry and Bitbucket also need a scope — a workspace, list, org/project or repo; the card names what is missing and opens Advanced at it. GitHub, GitLab and Jira need nothing: leave the repo/project/JQL blank and they pull the issues assigned to you, and Jira picks your Atlassian site by itself.
Once connected, Sync now pulls issues and Test connection validates the credentials.
Enabling one-click OAuth (maintainers)
Step-by-step registration for every provider — where to click, which scopes, which loopback literal — lives in OAUTH-SETUP.md. This section is the mechanism behind it.
One-click needs an OAuth app registered with the provider. Where those credentials come from depends on what the provider accepts.
A client_id is public by design (it ships in every binary — the GitHub CLI
does the same), so the ones for public clients are committed in
OAuthClients.h. A client secret is
not, and is never committed. But several providers (Atlassian, Todoist,
ClickUp, Bitbucket, Sentry) refuse a public client and will not do PKCE-only, so
for those the choice is “ship a secret” or “no browser sign-in at all”. Release
builds take them from CI:
HEAP_OAUTH_JIRA_CLIENT_ID=… HEAP_OAUTH_JIRA_CLIENT_SECRET=… cmake -S . -B build
CMake reads HEAP_OAUTH_* from the environment and generates
build/generated/OAuthClients.gen.h. Deliberately not a -D: CI uploads the
whole build/ tree as an artifact, and a cache variable would leak through
CMakeCache.txt and compile_commands.json. The configure log prints provider
names only, never values. release.yml passes them from repository secrets of
the same name.
Locally, an untracked oauth-clients.local.cmake at the repo root works too:
set(HEAP_OAUTH_JIRA_CLIENT_ID "…")
set(HEAP_OAUTH_JIRA_CLIENT_SECRET "…")
This is not real secrecy — strings heap.exe finds an embedded secret, the
same as for any desktop OAuth client. It means an attacker has to extract it
rather than read it in the repo, and it lets the credential be rotated without a
commit.
Nothing here is required. An unset variable is an empty value: that provider’s browser button is hidden and the card falls back to a personal access token. That is what a local build, a fork’s CI and every test target get — see “No browser button?” below.
The loopback redirect URI every OAuth app must register is
http://127.0.0.1:51789/ (OAuthManager::redirectUri(); device flow ignores it).
GitHub — done (Device Flow)
GitHub OAuth Apps can’t do PKCE and would need a client secret for the web flow,
which is unsafe to embed. heap. uses the Device Authorization Grant instead
(client ID only, no secret). The app is registered:
- OAuth App heap, owner
sectapunterx— https://github.com/settings/applications/3713650 - Enable Device Flow is checked; scope
repo.
To recreate: https://github.com/settings/applications/new → name heap,
homepage https://github.com/sectapunterx/heap, callback http://127.0.0.1:51789/,
tick Enable Device Flow → Register → copy the Client ID into
HEAP_OAUTH_GITHUB_CLIENT_ID in OAuthClients.h.
GitLab — done (PKCE, no secret)
Registered on gitlab.com as app heap (owner sectapunterx), Confidential=No,
scope api, callback http://127.0.0.1:51789/; the Application ID is committed
as HEAP_OAUTH_GITLAB_CLIENT_ID. To recreate:
- https://gitlab.com/-/user_settings/applications
- Redirect URI:
http://127.0.0.1:51789/ - Uncheck Confidential (native/PKCE client).
- Scopes:
api - Save → copy the Application ID into
HEAP_OAUTH_GITLAB_CLIENT_ID.
Self-hosted GitLab: users register the same under their instance and paste the Application ID under Advanced (the host field points the flow at their server).
Gitea / Forgejo (per-instance)
These are self-hosted, so there is no single client ID to ship. Users create an
OAuth2 application under Settings → Applications on their instance
(redirect http://127.0.0.1:51789/) and paste the client ID under Advanced.
The confidential five
Todoist, Asana, ClickUp, Sentry and Bitbucket all refuse a public client, so each
needs both halves in HEAP_OAUTH_<PROVIDER>_CLIENT_ID / _CLIENT_SECRET.
Register at:
| Provider | Where | Notes |
|---|---|---|
| Todoist | https://developer.todoist.com/appconsole.html | Scope data:read; the token never expires |
| Asana | https://app.asana.com/0/my-apps | PKCE and a secret; 1h token + refresh |
| ClickUp | Workspace Settings → Apps | Scopes are picked on ClickUp’s consent screen, not in the URL; token exchange is JSON |
| Sentry | Settings → Developer Settings → New Public Integration | Scopes org:read project:read event:read |
| Bitbucket | Workspace Settings → OAuth consumers | Client credentials go in an HTTP Basic header; tick the issue permission; 2h token + refresh |
Jira — Atlassian 3LO
https://developer.atlassian.com/console/myapps/ → Create → OAuth 2.0
integration, add the Jira API permission with scopes
read:jira-work write:jira-work read:jira-user offline_access, and set the
callback to http://127.0.0.1:51789/.
Two Atlassian-specific details the flow handles:
- The authorize URL carries
audience=api.atlassian.comandprompt=consent. Withoutprompt=consentAtlassian issues no refresh token, and the session would die an hour later. - A 3LO token is not bound to a site. It is only accepted at
https://api.atlassian.com/ex/jira/{cloudId}, never atacme.atlassian.net. After sign-in heap calls/oauth/token/accessible-resources, picks the site (keeping the one the card already names, so a second site can’t silently repoint synced issues) and cachescloudId+siteUrlin the card’s config.
Atlassian has no PKCE-only mode, so this needs a client secret. Browser sign-in is Cloud only; Server/DC uses a Personal Access Token (below).
Jira Server / Data Center
A different product behind the same name, and heap detects which one it is
rather than asking: it reads deploymentType from {site}/rest/api/2/serverInfo
on the first request, falling back to the URL (*.atlassian.net is Cloud,
anything else self-hosted is Server/DC) when the instance refuses anonymous
reads.
| Cloud | Server / Data Center | |
|---|---|---|
| API | /rest/api/3 | /rest/api/2 |
| Credential | account email + API token, sent as HTTP Basic | Personal Access Token, sent as Bearer |
| Search | POST /search/jql | POST /search |
| Description | ADF, flattened to text | already plain text |
| Browser sign-in | yes (3LO) | no |
On Server/DC, leave the Email field empty and paste a Personal Access Token from your avatar → Profile → Personal Access Tokens. An instance too old for PATs still works: fill in your username and it falls back to HTTP Basic.
Either way this is the Advanced path, not the browser one: open Advanced, fill in Base URL (your own host, prefix and all) and the token, then press Connect. Atlassian’s browser sign-in only ever grants Cloud sites, so on a self-hosted instance it signs in and reaches nothing — the card says so and points here.
Trello — token in the fragment
https://trello.com/power-ups/admin → your Power-Up → API key. Trello has
no authorization-code grant at all: /1/authorize returns the token in the URL
fragment, which a browser never puts on the wire. The loopback listener
serves a page whose script posts the token back and then scrubs the address bar.
No secret is involved — the app key is public, it appears in every authorize
URL — but the key’s allowed origins must include http://127.0.0.1:51789
or Trello refuses the redirect. Only HEAP_OAUTH_TRELLO_CLIENT_ID is needed.
Redmine — no OAuth
Redmine ships no OAuth 2.0 provider at all. The REST API only takes an API key
(My account → API access key), sent as X-Redmine-API-Key. This is not a
gap in heap and will not change until Redmine itself changes.
No browser button?
The card offers Connect with browser only when this build can run the flow with no help from you. It is hidden when any of these is true:
| Reason | What to do |
|---|---|
| You built heap yourself, and the provider needs a client secret | Register your own OAuth app and pass HEAP_OAUTH_<PROVIDER>_CLIENT_ID/_SECRET at configure time, or paste a client ID under Advanced |
| Self-hosted provider (Gitea, Forgejo, self-managed GitLab) | There is no single app to ship — register one on your instance and paste its client ID under Advanced |
| The provider has no OAuth at all (Redmine) | Use the API key; this is not going to change |
| GitHub on a build against Qt < 6.9 | The device grant needs Qt 6.9 (OAuthManager::deviceFlowAvailable()); use a token |
In every case the personal-access-token path under Advanced is fully supported — it is not a degraded mode. It is also there on cards that do show the browser button: open Advanced and the Connect button appears beside Test connection, which is how you reach a self-hosted instance the vendor’s OAuth gateway has never heard of.
Mattermost — people, not issues
The one integration that imports contacts instead of tasks. Its descriptor
is a Directory kind: no issues to pull, no status to map to a column, nothing
to push back.
Signing in. Most corporate servers switch personal access tokens off and
leave the OAuth provider disabled (both are System Console settings), so the
primary path is the same credentials you use in the Mattermost app. The password
is sent once to POST /api/v4/users/login and never stored — only the session
token it returns, which lives in the keychain like any other. heap refuses to
send it at all unless the server URL is https (or loopback). A personal access
token works too, where your admin allows them.
What is imported. Everyone you have a direct-message conversation with, plus the members of your group DMs, plus the members of any channels you name in Also import members of. Ordinary channels are opt-in on purpose: one company-wide channel would otherwise import the entire company. Bots, deactivated accounts and you are skipped, and a channel is read at most 1000 members deep.
Where they land. Docs → Contacts, with the person’s name, job title (or the
role their permissions imply), handle and where you met them. People you have
DM’d also get an entry in the People rail with the state idle — so
@their.handle autocompletes, and the rail’s pending badge keeps meaning
“people you owe an answer to”.
Your edits win. Each imported contact remembers what the server last said. A field still matching that follows the server; a field you changed is yours and stays. Deleting an imported contact is remembered, so the next sync does not bring it back — undo reverses that too. Nothing is ever deleted by a sync: a colleague who leaves simply stops being updated.
One workspace. The card binds to the profile it was first synced in, so a background sync cannot pour your colleagues into an unrelated workspace. A manual Sync now rebinds it to wherever you are.
How auth is applied
RestIssueProvider::buildRequest sends the token per the descriptor’s AuthRecipe
(GitHub token , GitLab PRIVATE-TOKEN, …). An OAuth access token is always
sent as Authorization: Bearer regardless — the config carries authMode=oauth,
set when a browser sign-in succeeds. PAT and OAuth therefore coexist on one card.
Token refresh
Short-lived OAuth tokens (GitLab ~2 h) are renewed automatically. Before a sync
or a status write-back, AppController::ensureFreshToken checks the stored
tokenExpiresAt; within a minute of expiry it spends the refresh token
(OAuthRefresh.h, a plain form POST that also works on Qt 6.4). A session with
no recorded expiry gets one refresh-and-retry on its first 401. Providers
rotate the refresh token, so the new one is stored before the new access
token. Only the token endpoint refusing the grant itself — invalid_grant and
friends, or a 400/401 — drops the card back to disconnected and asks you to
sign in again. No answer at all (offline, DNS, a 30 s timeout, a captive
portal), a 5xx or a 429 keeps you signed in: the card says offline, the
refresh is retried after 15 s, 30 s, 1 min … up to every 15 minutes, and the
next successful pull clears it. tokenExpiresAt is stored in UTC with its
offset (…Z); a value written by 0.5.2 or older, without an offset, is read as
the local time it was. GitHub device-flow tokens don’t expire, so there is
nothing to refresh.
Where secrets live
The OS keychain, service heap.integrations, key <provider>/<field>
(github/token, gitlab/refreshToken). Values over ~2 KB (Windows Credential
Manager caps a blob at 2560 bytes) are split across <key>#0, <key>#1, … .
A run that must not touch your real data — --data-dir / HEAP_DATA_DIR, or
the test suites — never opens the keychain: it keeps a secrets.json next to its
own state.json instead. Builds without QtKeychain always use that file.
What a pulled issue brings with it
A mirrored issue becomes an ordinary task carrying the tracker’s own view of
it: assignee, reporter, issue type, project, milestone, comment count, due
date, label colours, and the created/updated timestamps. The card shows a
provider badge and the tracker’s key (#1234, PROJ-123); O opens the
issue in your browser; the editor lists the rest in a read-only strip.
Not every provider exposes every field, and two are skipped on purpose:
Jira’s comment count (asking for it inlines every comment body of all 100
issues in the search response) and Redmine’s (it needs a per-issue request).
Asana’s tags are not requested either — tags:read is not among the scopes
the card asks for, and an unscoped field fails the whole request.
The tracker owns a task’s due date only while you have not touched it. Edit or snooze the deadline and it becomes yours: later syncs leave it alone, and a due date removed upstream no longer clears it.
When both sides changed something
Title, description and priority merge three-way against what the tracker sent last time: a field you edited here survives a sync that did not touch it, and a field only the tracker changed follows the tracker. When both changed, your version is kept, the card carries a conflict chip, the sync toast names it, and the editor shows the tracker’s version with Use tracker version / Keep mine. Keeping yours re-flags the card the next time the tracker changes that field again. Labels merge the same way by name: a label removed in the tracker is removed here, one you added or removed here stays that way.
Changing the filter
A sync that no longer returns an issue only means “gone” when the card’s filter (repo, project, JQL, board, host…) is the one the card was last pulled under. Switch a card from a repo to “my issues”, or edit the JQL, and the cards the new filter does not cover are marked outside filter instead — still live issues, kept as they are. The integration card offers to archive them in one go.
Moves while a tracker is away
Moving a mirrored card while its tracker is disconnected or unreachable no longer vanishes: the card shows not synced, and the move is sent after the next successful pull. A move made in the tracker in the meantime wins. On Jira, where the workflow decides which moves exist, each pull also asks for every issue’s available transitions; a drop into a column none of them maps to is refused on the spot, with a toast naming the columns the issue can go to.
Writing back from “my issues” mode
With repo/project blank, a sync pulls the issues assigned to you across every repo, so there is no single repo to write to. Each such issue remembers the repo it came from, and moving its card writes to that repo — never to the configured one, where the same number is a different issue. An issue whose repo the tracker did not name stays read-only.
How much a sync pulls
Every tracker caps a list response — 100 issues for GitHub and GitLab, 50 for Gitea and Sentry, 100 for Jira. heap follows the continuation the API offers until the tracker says there is no more, or until it has read 20 pages, whichever comes first. One sync therefore mirrors at most a couple of thousand issues and always terminates, even against a server that keeps claiming another page.
| Tracker | How it says “there is more” |
|---|---|
| GitHub, GitLab, Gitea, Forgejo, Sentry | Link: <…>; rel="next" header |
| Bitbucket | next URL in the body |
| Todoist, Asana | a cursor in the body, handed back as a query parameter |
| Redmine, ClickUp | neither — the offset is stepped until a page comes back short |
| Jira Cloud | nextPageToken |
| Jira Server/DC | startAt row offset |
| Trello | one request returns everything |
A next link is followed only when it points at the same host and scheme as the request that produced it. The link is server-supplied and the request carries your token; following it off-origin would hand the token to whoever wrote it.
If a page fails part-way through a walk, the pages already read are still merged and a second toast says how far it got — losing 100 real issues because page 3 timed out would be the worse answer. Hitting the 20-page cap reports the same way, so a truncated sync is never silent.
Rate limits. A 429 or a 5xx is retried up to three times, waiting as
long as the server’s Retry-After header asks (in either the seconds or the
HTTP-date form) and otherwise backing off 1s, 2s, 4s, capped at 30s. A rate
limit that clears this way is not reported — nothing went wrong. A 401,
403 or 404 is not retried: it will answer the same however long you wait,
and retrying only delays the message telling you what to fix.