An Issue Workspace for Neovim: browse issue trackers through one UI, and pair every issue with a local, Git-managed workspace of notes, metadata, and analysis.
Status: 0.1.0 — early.
Works today: four providers — Jira, Redmine, GitHub, GitLab — caching, the local index (JSON or SQLite+FTS5), the picker across all four UI backends, local search, bookmarks, collections, export, sync with change detection, optional AI backends with saved analysis history, and an issue buffer with editable memo / metadata / prompt written back to your Git-managed workspace.
Not yet: 1.0. The public API is documented and stable in intent, but is not frozen until a third-party provider has proven the interface.
The public API may break between minor versions until 1.0. See DESIGN.md.
A walkthrough, from nothing to a workspace worth keeping.
1. Find something to work on.
<leader>ji that server's query, fetched now
<leader>jf that server's issues, already local
Both open the same picker and filter as you type — ji asks the server, jf
asks your machine and also searches what you wrote. Type status:open,
priority:high, or any word from your own notes.
Both are per server: with several configured you pick one first.
2. Open it. <CR> in the picker. The issue is on top, read-only, labelled
as such. Below the divider is your workspace.
3. Write what you learn.
" type under ## Memo, then
:wOnly the files whose content changed are written, so :w on an unmodified
buffer produces no Git noise. Put structured facts under ## Metadata as YAML —
priority: high, tags: [timeout, cache] — and they become filterable.
4. Mark it. <leader>jm bookmarks; <leader>jb lists bookmarks.
5. Come back later.
:IssueHub sync " what moved on the remote?
<leader>jc " what moved since I last looked?Sync reports per issue: PROJ-123: status Open → In Progress, +2 comments. It
never touches your notes.
6. Find it again, weeks later, by something only you would remember:
:IssueHub find eviction
:IssueHub find --meta priority=high
:IssueHub find 認証7. Ask a model about it (optional, off by default):
<leader>jp conversation window, right side
:IssueHub analyze run the prompt at the bottom
Answers accumulate in that window and are stored in the workspace, marked
OUTDATED once the issue moves on.
8. Commit. It is your directory:
git -C ~/notes/issuehub add -A && git -C ~/notes/issuehub commit -m "PROJ-123 notes":IssueHub fetch " page the whole server into the cache, in background
:IssueHub fetch status " progress, or what is cached and how freshThen <leader>jf works entirely offline over everything you fetched.
Issues are a source; what you learn while working them is knowledge. Trackers are good at the former and hopeless at the latter. issuehub keeps the remote issue read-only and cached, and gives you a plain directory of Markdown next to it that you own, diff, and commit.
Zero vendor lock-in. issuehub does not implement a picker, a git integration, a diff viewer, a markdown renderer, or an AI client. It detects what you already use and delegates:
| Concern | Delegated to |
|---|---|
| Picker | snacks.picker → fzf-lua → telescope → vim.ui.select |
| Git | fugitive / gitsigns / the git CLI |
| Diff | :diffthis |
| Markdown | render-markdown.nvim / markview / anything |
| File explorer | oil.nvim / neo-tree / mini.files |
| Notify & input | vim.notify / vim.ui.input |
No hard dependencies. dependencies = {}. Every integration is a runtime
pcall. issuehub works with zero plugins installed — just less comfortably.
| Type | Hosts | Auth | ID form |
|---|---|---|---|
jira |
Cloud and Server/DC | API token (Basic) or PAT (Bearer) | PROJ-123 |
redmine |
self-hosted | X-Redmine-API-Key |
12345 |
github |
github.com and Enterprise Server | PAT (Bearer) | owner/repo#123 |
gitlab |
gitlab.com and self-managed | PRIVATE-TOKEN |
group/project#12 |
Any number of instances of each type can be registered — see Multiple servers.
:IssueHub fetch " pick a server, or the only one
:IssueHub fetch jira_internal
:IssueHub fetch status " progress, or what is already cached
:IssueHub fetch stop " halt after the page in flight
:IssueHub fetch resume " continue a partial walkPages through everything a server's query matches, in the background. Every request is already async, so Neovim stays usable throughout — measured against the real GitHub API, the event loop kept ticking while 400 issues came down over 4 pages in 5 seconds.
It is per server, since each is a different amount of traffic against a different system; with several configured you are asked which.
Pages merge into the cache as they arrive, so an interrupted walk keeps what it collected and knows where to continue. A fresh walk replaces the list, a resumed one appends.
The list itself is cached separately from the issues, under .state/lists/,
because "which issues matched this query, and when did I last ask" is a
different fact with its own freshness:
issuehub cached lists:
github 1240 issues 2h ago
jira_internal 8300 issues 12m ago, partial
By default a query fetches one page (100 issues). A backlog of twenty thousand tickets is not pulled down by accident.
github = {
token_cmd = { "gh", "auth", "token" },
max_results = 500, -- page through until this many (default: one page)
per_page = 100, -- page size; every provider caps this at 100
},max_results is per provider instance, so a small internal tracker and a large
corporate one can differ. Paging stops early at a short page, and GitHub search
stops before its 1000-result ceiling rather than erroring.
For older tickets, a targeted query usually beats paging:
:IssueHub search project = PROJ AND updated >= -90dOther things sized for a big tracker:
:IssueHub syncwith no argument asks for confirmation abovesync.confirm_above(default 200), because it is one request per issue.- Cache and index writes skip
fsync—.state/is rebuildable by design, and the durability was costing more than it bought. - A bulk fetch writes the index once rather than once per issue.
The config key is an instance name, and type selects the implementation.
It defaults to the key, so jira = { … } needs no type — but any number of
instances of the same tracker can coexist:
providers = {
jira = { -- type defaults to "jira"
url = "https://your-org.atlassian.net",
user = "you@example.com",
token_env = "JIRA_CLOUD_TOKEN",
},
jira_internal = {
type = "jira", -- a second Jira, self-hosted
url = "https://jira.internal.example",
token_env = "JIRA_INTERNAL_TOKEN",
http = { no_proxy = "*" }, -- reachable directly, unlike the SaaS one
},
gitlab_saas = { type = "gitlab", token_env = "GITLAB_COM_TOKEN" },
gitlab_internal = {
type = "gitlab",
url = "https://gitlab.internal.example",
token_env = "GITLAB_INTERNAL_TOKEN",
},
}The instance name becomes the URI scheme and the workspace directory, so the same issue key on two servers never collides:
jira://PROJ-123 → workspace/jira/PROJ-123/
jira_internal://PROJ-123 → workspace/jira_internal/PROJ-123/
Each instance has its own credential, its own default query, and its own network
settings. :IssueHub open prompts for the instance when more than one is
configured.
The preferred credential is a per-user API token (token_env /
token_cmd), because a token is revocable and never becomes your login
password. For Redmine that is the key under My account → API access key; for
Jira Server/DC a Personal Access Token; for Jira Cloud an API token paired
with your email in user.
Some self-hosted instances issue no tokens at all. For those, HTTP Basic —
username and password — is supported: set user plus a password source.
providers = {
redmine_a = {
type = "redmine",
url = "https://redmine-a.corp.example",
user = "your-login",
password_env = "REDMINE_A_PASSWORD", -- or password_cmd = { "pass", "show", "redmine-a" }
},
jira = {
url = "https://jira.corp.example", -- self-hosted Server/DC
user = "your-login",
password_cmd = { "pass", "show", "jira" },
},
}The password resolves the same three ways a token does — _env, _cmd, or a
function — in that order of preference. A literal password = "…" works but
warns, because it sits in your config in plaintext; password_env /
password_cmd keep it out. However it is supplied, the password travels to
curl through its config on stdin, never as a command-line argument (which
ps would expose), and :checkhealth issuehub reports only that it resolved,
never the value.
Basic auth needs both halves: user and a password source. When both a
token and a password are configured on one instance, the password wins.
GitHub and GitLab IDs are repository-qualified so one workspace can span many
repositories. They are percent-encoded on disk
(github/owner%2Frepo%23123/) — which is exactly why paths are RFC 3986 encoded.
GitHub pull requests are included alongside issues; GitHub numbers both in one
sequence per repository, so owner/repo#123 stays unambiguous. Their status
reads Open, Draft, Merged, or Closed.
closed is always taken from what the API states, never guessed from a status
label: Jira's statusCategory, Redmine's /issue_statuses.json, and the
state field on GitHub and GitLab.
- Neovim 0.11+ (0.12 recommended)
curl(required)git,ripgrep,sqlite3(recommended)- A Level 1 picker — snacks.nvim, fzf-lua, or telescope.nvim — strongly recommended; see Picker tiers.
lazy.nvim
{
"tya5/issuehub.nvim",
opts = {
workspace = "~/notes/issuehub",
providers = {
github = {
-- Reuses your existing `gh` login; no token stored anywhere.
token_cmd = { "gh", "auth", "token" },
default_query = "is:issue is:open involves:@me",
},
},
},
}No cmd or event is needed: plugin/issuehub.lua only registers commands and
defers every require, so loading it costs practically nothing and lazy.nvim
treats a spec like the one above as eager.
If you add
keys, addlazy = falsetoo.{ "tya5/issuehub.nvim", lazy = false, -- ← required once `keys` is present keys = { { "<leader>ji", "<Plug>(IssueHubOpen)", desc = "Issues" } }, opts = { ... }, }Specifying
keys(orcmd/event/ft) switches lazy.nvim into deferred mode for the whole plugin, so:IssueHubwould not exist and:checkhealth issuehubwould report "No healthcheck found" until you first pressed one of those keys.lazy = falseavoids that at no real cost, because the startup file requires nothing.
Full example: LazyVim, GitHub via gh, with keymaps
return {
{
"tya5/issuehub.nvim",
lazy = false,
opts = {
workspace = "~/notes/issuehub",
providers = {
github = {
token_cmd = { "gh", "auth", "token" },
default_query = "is:issue is:open involves:@me",
},
},
},
keys = {
{ "<leader>ji", "<Plug>(IssueHubOpen)", desc = "Issues (issuehub)" },
{ "<leader>jf", "<Plug>(IssueHubFind)", desc = "Find in issue notes" },
{ "<leader>jr", "<Plug>(IssueHubRefresh)", desc = "Refresh issue" },
{ "<leader>jb", "<cmd>IssueHub bookmarks<cr>", desc = "Bookmarked issues" },
{ "<leader>jc", "<cmd>IssueHub changed<cr>", desc = "Changed since last seen" },
},
},
{
"folke/which-key.nvim",
optional = true,
opts = { spec = { { "<leader>j", group = "issues" } } },
},
}:checkhealth issuehubEverything should be green except a note that your workspace is not a Git repository. It works either way, but the whole point is that your notes are committable:
git -C ~/notes/issuehub initvim.pack (Neovim 0.12+)
vim.pack.add({ "https://github.com/tya5/issuehub.nvim" })
require("issuehub").setup({ workspace = "~/notes/issuehub", ... })require("issuehub").setup({
-- REQUIRED. A Git-managed knowledge base; there is deliberately no default.
workspace = "~/notes/issuehub",
index = "auto", -- "auto" | "json" | "sqlite"
providers = {
jira = {
url = "https://your-org.atlassian.net",
user = "you@example.com", -- Jira Cloud only
token_env = "JIRA_TOKEN", -- or token_cmd / token
default_query = "assignee = currentUser() AND resolution = Unresolved",
comment_limit = 20,
-- Detected from the hostname (*.atlassian.net => cloud). Set explicitly
-- for Cloud on a vanity domain.
-- flavor = "cloud",
},
redmine = {
url = "https://redmine.example.com",
token_env = "REDMINE_API_KEY",
default_query = { assigned_to_id = "me", status_id = "open" },
},
github = {
-- url defaults to https://api.github.com
-- Enterprise Server: url = "https://ghe.example.com/api/v3"
token_env = "GITHUB_TOKEN",
default_query = "is:open assignee:@me", -- GitHub search syntax
},
gitlab = {
-- url defaults to https://gitlab.com (self-managed: your instance root)
token_env = "GITLAB_TOKEN",
default_query = { scope = "assigned_to_me", state = "opened" },
},
},
ui = { picker = "auto" }, -- "auto"|"snacks"|"fzf"|"telescope"|"select"
sync = { on_open = "stale", stale_after = 900 },
export = { dir = nil, default_format = "markdown" }, -- dir defaults to cwd
backend = "none", -- "none" | "a2a" | your own
backends = {},
log_level = vim.log.levels.WARN,
})workspace is genuinely required — setup() reports an error if it is missing,
rather than failing later. Keys belonging to unreleased milestones (backend,
export) are rejected rather than silently ignored.
Behind a proxy, a TLS-inspecting gateway, or both? Everything below is optional —
with no http block, curl already honours http_proxy, https_proxy, and
no_proxy from your environment, which is what most managed machines set.
require("issuehub").setup({
workspace = "~/notes/issuehub",
http = {
-- Proxy. Omit to use the environment; set to override it.
proxy = "http://proxy.corp.example:8080",
no_proxy = "localhost,127.0.0.1,.internal.example",
-- Authenticating proxy. The password is a credential: keep it out of
-- your config the same way you keep tokens out.
proxy_user = "DOMAIN\\you",
proxy_password_env = "PROXY_PASSWORD", -- or proxy_password_cmd
proxy_auth = "ntlm", -- basic|digest|ntlm|negotiate|anyauth
-- TLS inspection: trust your organisation's root, do NOT disable checking.
cacert = "~/certs/corporate-root.pem",
-- Mutual TLS, if your gateway requires a client certificate.
client_cert = "~/certs/client.pem",
client_key = "~/certs/client.key",
client_key_password_env = "CLIENT_KEY_PASSWORD",
timeout = 30000,
retries = 2,
},
})Per-provider overrides. An internal tracker reached directly while SaaS goes through the proxy is the common mixed case:
providers = {
jira = { url = "https://your-org.atlassian.net", token_env = "JIRA_TOKEN" },
redmine = {
url = "https://redmine.internal",
token_env = "REDMINE_API_KEY",
http = { no_proxy = "*" }, -- bypass the proxy for this host only
},
}If your company inspects TLS, requests fail with a certificate error. There are two ways out, and they are not equivalent:
http = { cacert = "~/certs/corporate-root.pem" } -- correct
http = { ssl_verify = false } -- last resortssl_verify = false disables certificate verification entirely: your API
tokens travel over a connection nobody is checking. issuehub accepts it, because
some internal CAs genuinely cannot be exported, but it will log a warning and
:checkhealth issuehub reports it as an error for as long as it is on. Treat
it as a temporary state, not a setup step.
To find your root certificate:
# macOS: export from the System keychain
security find-certificate -a -c "Your Corp Root" -p \
/Library/Keychains/System.keychain > ~/certs/corporate-root.pem
# Linux: it is usually already installed
ls /etc/ssl/certs/ca-certificates.crt:checkhealth issuehubThe Network section shows the effective proxy (with any password stripped),
whether a custom CA is in use, the mTLS state, and ssl_verify. Per-provider
overrides are listed separately, since those are easy to forget about.
Never put a token in your config file. Resolution order:
token = function() ... end -- 1. escape hatch
token_cmd = { "op", "read", "op://vault/jira" } -- 2. password manager
token_env = "JIRA_TOKEN" -- 3. environmentFor GitHub, token_cmd = { "gh", "auth", "token" } reuses the login you already
have and stores nothing. For GitLab, glab auth token does the same.
Tokens are cached in memory for the session only, are passed to curl on stdin
(never argv, so ps cannot see them), and are unconditionally redacted from the
log file.
:IssueHub open [uri] " picker over the default query, or open a URI
:IssueHub search <query> " provider-side search (JQL / GitHub qualifiers / ...)
:IssueHub find <text> " local search; --regex forces the ripgrep path
:IssueHub local " everything already cached, offline
:IssueHub sync [target] " re-fetch and report what changed
:IssueHub changed " issues that moved since you last opened them
:IssueHub collection ... " manage and open collections
:IssueHub export [fmt] [source]
:IssueHub import <file> " merge memo/meta/bookmarks back in
:IssueHub analyze " analyse via the configured backend
:IssueHub analyses " analysis history for the current issue
:IssueHub refresh " re-fetch the current issue buffer
:IssueHub bookmark " toggle a bookmark on the current issue
:IssueHub bookmarks " picker over bookmarked issues
:IssueHub reindex " rebuild the index from cache
:IssueHub provider list
:IssueHub healthNo keymaps are created. Bind the <Plug> maps yourself:
vim.keymap.set("n", "<leader>ji", "<Plug>(IssueHubOpen)")
vim.keymap.set("n", "<leader>jf", "<Plug>(IssueHubFind)")
vim.keymap.set("n", "<leader>jr", "<Plug>(IssueHubRefresh)")| Tier | Backend | |
|---|---|---|
| Primary | snacks.picker | reference implementation |
| Secondary | fzf-lua | fully supported |
| Compatible | telescope | works; upstream is in maintenance mode |
| Fallback | vim.ui.select |
single column, no preview, no multi-select |
The fallback guarantees issuehub functions with zero plugins. It is not a target for feature parity, and browsing issues through it is genuinely worse.
Opening an issue gives you one Markdown buffer: the issue on top (read-only, from cache) and your own notes below (editable, stored in your workspace).
# PROJ-123 Timeout on cache warmup
- Status: In Progress
- Analysis: 2026-07-19T11-17-00Z (outdated)
## Description read-only
Warmup exceeds 30s when the cache is cold.
## Comments (42) read-only
────────────────────────────── your workspace below
## Memo editable → memo.md
Root cause is the cold-cache path.
- [ ] confirm with staging
## Metadata editable → metadata.yaml
priority: highThe labels on the right and the divider are virtual text — they are markers, not part of the file. Editing above the divider is reverted, so it is worth seeing the boundary rather than discovering it.
:w writes the three editable sections to their files — and only the ones whose
content actually changed, so you do not get empty commits. Emptying a section
deletes its file rather than leaving a stub.
It is plain Markdown, so Treesitter, folding, /, marks, and any markdown
renderer you already use work unmodified. issuehub implements no editor.
Read-only is advisory. Neovim has no way to lock part of a buffer, so edits
above the ## Memo heading are reverted with a warning rather than prevented.
Anything you typed in the editable sections is kept.
metadata.yaml is written back verbatim. Comments, key order, and spacing survive exactly as you typed them — issuehub parses that file for search and export, but never reformats it.
<Plug>(IssueHubOpen) and <Plug>(IssueHubFind) behave identically: the picker
opens straight away and typing filters it. Only the corpus differs.
| Corpus | Typing filters over | |
|---|---|---|
IssueHubOpen |
that server's query, fetched now | ID, status, title, and your notes |
IssueHubFind |
that server's issues, already local | the same |
Both are scoped to a server and a project. A server usually holds many projects, and a list mixing them is as hard to read as one mixing servers. You are asked only when there is a choice — one server, or one project, and nothing prompts.
jira = {
url = "https://your-org.atlassian.net",
token_env = "JIRA_TOKEN",
projects = { "PROJ", "OPS" }, -- offer just these
default_project = "PROJ", -- or skip the prompt entirely
},Without either setting, the choices come from the projects actually seen
locally — so it costs nothing on a fresh workspace and sharpens as you use it.
(all projects) is always offered.
Project is part of every row, so project:ops filters in the picker and
--meta project=OPS filters exactly. Export takes provider/project as a
source:
:IssueHub export csv jira/PROJYour memo and metadata ride along on each row as hidden match text, so typing
認証 finds an issue whose notes mention it even though nothing on screen
does. Nothing is fetched, and no prompt appears.
Metadata and the issue's own fields are folded in as key:value tokens, so
you can filter structurally right in the picker — and status: behaves exactly
like priority:, because when you are filtering you have no reason to care
which of them came from the tracker and which you typed:
status:open from the tracker
state:closed normalised open/closed, whatever the tracker calls it
provider:github which instance it came from
assignee:tya5
bookmarked:true
priority:high from your metadata.yaml
tags:cache a value inside a YAML list
status:open priority:high both (most pickers treat a space as AND)
The same names work in the exact form:
:IssueHub find --meta state=open --meta priority=high
:IssueHub find --meta status=in-progress
:IssueHub find --meta labels=cachePicker filtering is substring matching, so
priority:highalso matchespriority:highest. Use--metawhen you need an exact comparison — it parses the YAML rather than the text, and reaches analysis history and regexes a picker filter cannot.If you write a key that collides with a built-in one — say
status: waitingin your metadata — yours wins. The workspace is yours, and a status you set deliberately should not be shadowed by the tracker's.
:IssueHub find eviction " ranked full-text across everything local
:IssueHub find "cache.warm" " a fixed string, not a pattern
:IssueHub find --regex cache.* " forces the ripgrep path
:IssueHub find --meta priority=high " filter by metadata
:IssueHub find --meta owner " issues where the key is set at all
:IssueHub find --meta tags=cache " matches a value inside a YAML list
:IssueHub find eviction --meta priority=high " text AND filterFilters are ANDed, values are case-insensitive, and the same syntax works in the
<Plug>(IssueHubFind) prompt — one parser serves both, so they cannot drift.
find searches the cached issue and everything you wrote about it — memo,
metadata, and analysis history — and tells you which of those matched:
PROJ-123 In Progress 2026-07-19 Timeout on cache warmup [memo]
PROJ-140 Open 2026-07-18 Retry storm on failover [analyses]
Two engines, chosen automatically:
| Query | Engine | Why |
|---|---|---|
| ASCII, with sqlite3 + FTS5 | SQLite FTS5 | ranked by relevance |
| Non-ASCII (Japanese, Chinese, Thai …) | ripgrep | see below |
--regex |
ripgrep | FTS5 cannot do it |
| No FTS5 | ripgrep | |
| Neither available | index substring scan | incomplete; warns |
They are complementary rather than redundant: FTS5 ranks whole documents by relevance, which suits an accumulating knowledge base; ripgrep finds exact lines and regexes.
Why non-ASCII goes to ripgrep. FTS5's
unicode61tokenizer splits on whitespace, so a run of Japanese becomes a single token —認証まわりの調査メモis one term, and searching認証matches nothing. Thetrigramtokenizer fixes 3-character queries but still fails on 2-character ones, which is the most common Japanese word length. ripgrep handles all of it, so those queries are routed there. Install ripgrep if you write notes in a language without spaces.
Collections are local, static, cross-provider lists — a sprint, a release, an
investigation. They live in .issuehub/collections/<slug>.yaml and are committed
with the rest of your workspace.
:IssueHub collection add Sprint A " add the current issue (or picker selection)
:IssueHub collection Sprint A " open it in the picker
:IssueHub collection list
:IssueHub collection remove Sprint AThey are deliberately static lists, not saved queries: a list diffs cleanly in Git, and "why is this issue in here" always has a literal answer.
:IssueHub export " current view, default format
:IssueHub export csv sprint-a " a collection, as CSV
:IssueHub export json bookmarks
:IssueHub export markdown changedSources are a collection name, a provider instance name, or one of local,
all, bookmarks, changed. With no source, export acts on what you were
just looking at — the last view the picker showed.
all (and a provider name) means the cache and your workspace merged. The
two do not hold the same set: an issue you annotated months ago may have fallen
out of the cache, and an issue you fetched may have no notes. Exporting either
alone silently drops rows, so the union is the default, with blank cells where
one side has nothing to say.
Ordered for analysis rather than for reading:
uri, provider, id, title, status, closed,
created_at, closed_at, updated_at, age_days, days_to_close,
assignee, reporter, labels, comments, url, bookmarked, fetched_at, memo,
meta.<your keys...>
created_at and closed_at are what a defect curve is built from.
age_days and days_to_close are precomputed — date arithmetic in a
spreadsheet is where these analyses usually go wrong; an open issue ages to now
and leaves days_to_close empty.
:IssueHub export csv all
# then: closed per month, straight from the CSVMetadata keys are flattened to meta.<key>, multi-value fields join with ; ,
and fetched_at is included so staleness travels with the data. Export performs
no network I/O — run :IssueHub sync or :IssueHub fetch first if freshness
matters.
Markdown export keeps memos as prose under a ## Notes heading rather than
cramming multi-line text into table cells.
Add your own format:
require("issuehub.core.export").register("xlsx", { ext = "xlsx", write = fn })Round-tripping through a spreadsheet is a real workflow — triage a few hundred rows in the tool that is good at rows, then bring the annotations home.
:IssueHub import ~/triage.csv --dry-run " see what it would do
:IssueHub import ~/triage.csv
:IssueHub import ~/triage.jsonImport is deliberately not the inverse of export. Only the half you own
comes back — memo, meta.*, bookmarked. The issue columns are read and
discarded, because the tracker owns those and a two-week-old spreadsheet must
not overwrite the cache with fiction. Rows for issues you have no notes on yet
are created; rows whose uri is not a valid issue URI are reported and skipped;
a column that is absent means "not in this file", not "clear it".
The file wins on conflict, with no per-row prompt. That is only defensible
because the workspace is a Git repository — git diff is the undo — so the
report names every issue whose content was replaced, and warns loudly if the
workspace is not under Git. --dry-run prints the identical report and writes
nothing.
One asymmetry worth knowing: metadata.yaml is normally written back verbatim,
so comments and key order survive an edit. An import regenerates it from the
merged keys, so comments in that file are lost — keys you did not export are
still preserved, and the report counts the issues where comments went.
:IssueHub sync " re-fetch everything you have locally
:IssueHub sync jira " just one provider instance
:IssueHub sync <uri> " just one issue
:IssueHub changed " picker over what moved since you last lookedSync reports what actually moved, per issue:
issuehub: 3 changed, 27 unchanged
PROJ-123: status Open → In Progress, assignee, +2 comments
PROJ-140: description
12345: status New → Closed
Sync never touches your notes. It refreshes the cache and nothing else — a remote edit cannot rewrite what you wrote.
Two different questions are answered separately:
- What just moved? — the report above, from comparing the fetched issue against the cached one.
- What moved since I last looked? —
:IssueHub changed, and aChanged:line in the issue header. This is derived fromstate.yaml, so it survives restarts, accumulates across syncs, and clears when you actually open the issue — not when a sync happens to run.
Sync targets everything cached plus anything with local notes, so an issue you annotated months ago is still tracked even if it fell out of the cache.
:IssueHub prompt " opens a window on the rightThe prompt is not in the issue buffer. It lives in a side window with the whole conversation for that issue — every past prompt and response, oldest first, with the next prompt at the bottom:
# Conversation — jira://PROJ-123
### 2026-07-19T11:17:00Z · claude-opus-4-8 · OUTDATED
> What is the root cause?
The connection pool saturates before the cache fills.
────────────────────────────── next turn
## Prompt editable → prompt.md:w writes prompt.md, :IssueHub analyze runs it, and the answer appears in
the same window. A prompt is one turn of a conversation and you write the next
one by reading the previous answers — which is awkward when the prompt sits
between memo and metadata and the answers live somewhere else entirely.
:IssueHub translate ja " translate through the backend, store it
:IssueHub translations " open a stored oneStores the result as translations/ja.md beside your notes — one file per
language, tracked in Git, and editable by hand when the machine gets it wrong.
translate = {
default_language = "ja", -- skips the prompt
languages = { "ja", "en" }, -- offered when no default is set
include_comments = false, -- comments can dominate a busy issue
},Staleness works like an analysis: the file records the issue revision it was made
from, and the issue header shows ja (current) or ja (outdated) — derived, so a
git revert puts it right. Translated text is searchable with :IssueHub find.
User-triggered only, and it needs a backend: with the default none, nothing is
sent anywhere.
Worth knowing before you enable one, since the point of these files is that they
are committed: a translation is the issue's own title and description, not a
summary, so translating puts tracker content into your Git history — where an
analysis can also quote it, and where the directory names already disclose which
issues you follow regardless. include_comments = true widens that to comment
bodies and commenter names, in Git and in every request to the backend; the
default is false for request size, but disclosure is the bigger reason to
leave it alone.
:IssueHub attachments " list, pick one, download it
:IssueHub attachments --all " every attachment on this issueCache, not workspace. Files land in .state/attachments/…, which is
git-ignored. A binary can't be removed from Git history once committed, and a
screenshot pasted into a ticket is often more sensitive than the ticket text —
so this is the one part of an issue that never becomes a tracked file. Deleting
.state/ is still safe: metadata returns with the next sync, bytes re-download.
Nothing is fetched implicitly. A sync records that a file exists — the
issue header shows Files: 3 (none downloaded) — and only an explicit request
transfers anything. Fetching attachments during a sync of twenty thousand
issues would move gigabytes nobody asked for.
attachments = { max_size = 50 * 1024 * 1024 }, -- refuse anything largerDownloads reuse the instance's credentials, proxy, and TLS settings: an attachment is as protected as its issue, and an unauthenticated fetch would save a login page that looks like a PDF.
Jira and Redmine report attachments through their APIs, with size and type. GitHub and GitLab have none — an upload is a link in the body — so those are read out of the body text, and size and type are reported as unknown rather than guessed.
:IssueHub bookmark " toggle, from inside an issue buffer
:IssueHub bookmarks " picker over everything bookmarkedBookmarks live in state.yaml next to your notes, so they are part of what you
commit, not derived state that a reindex can lose.
issuehub has no AI of its own. A Backend is the only channel through which
anything leaves your machine, and the default is none: nothing is sent
anywhere unless you configure it.
For any OpenAI-compatible endpoint — OpenAI, a corporate gateway, Azure, a local llama.cpp/vLLM/Ollama:
backend = "openai",
backends = {
openai = {
url = "https://gateway.corp/v1", -- the /v1 base, or the full endpoint
model = "gpt-4o-mini",
token_env = "OPENAI_API_KEY", -- or token_cmd; Bearer by default
-- system = "You are terse.", -- optional
-- api_key_header = "api-key", -- Azure-style key header instead of Bearer
-- query = { ["api-version"] = "2024-02-01" }, -- Azure
},
},It uses the same curl transport as the providers, so the key travels on stdin (never argv) and it works wherever curl does — Windows included.
Reasoning models (GPT-5 family, o-series). The default request carries only
model,messages, andstream— notemperature, nomax_tokens— so a model that rejects a non-default temperature or the legacy token cap works as-is; just setmodel. To cap output on those, usemax_completion_tokens(the newer name);max_tokensis still sent for older endpoints when you set it.modelis also overridable per request, so a future command can pick a cheaper model for translation and a stronger one for analysis.
For an A2A agent instead:
backend = "a2a",
backends = {
a2a = { url = "http://localhost:9100", token_env = "A2A_TOKEN" },
},:IssueHub analyze " analyse the current issue, save the result
:IssueHub analyses " browse this issue's analysis historyThe request carries the cached issue plus your workspace overlay, and the prompt
comes from the issue's own prompt.md when you have written one — otherwise a
sensible default.
Every analysis is kept under analyses/<timestamp>/ with its prompt, its
response, and the issue revision it was made against.
Staleness is derived, never stored. An analysis is current when the
recorded revision matches the cached issue and outdated otherwise — so it
cannot go wrong after a manual edit, a git revert, or a sync that happened
while Neovim was closed. The issue header shows it:
- Analysis: 2026-07-19T11-17-00Z (outdated)
An outdated analysis is also never fed back in as context for a new one, since that would propagate its staleness.
issuehub is an information provider and knowledge store; a conversational
agent — reached over a protocol like AG-UI by a separate client such as
reyn.nvim — is the analysis engine. Two public functions are the seam, and
issuehub carries no dependency on the agent side:
-- What issuehub knows about an issue, for the client to hand to the agent.
local ctx = require("issuehub").context("jira://PROJ-1", { include_analyses = true })
-- Save what the agent concluded back into the issue's history.
require("issuehub").record_analysis("jira://PROJ-1", {
response = "...", backend = "reyn", model = "gpt-5.6",
})context() gives attachments as file paths, not content — an agent that
shares the filesystem opens them itself, which is the point: embedding a log in
the prompt is the token cost worth avoiding. (This is the opposite of what a
remote model needs, so the backend path in the section above embeds text
instead — a model cannot open a path.) It reports what is on disk and never
fetches; undownloaded names anything the client should pull first with
:IssueHub attachments. record_analysis() keeps the resulting knowledge in
issuehub, with the same derived staleness as any analysis, even though another
tool produced it.
fetch_attachments(uri, ids, cb) is the programmatic pull — context() never
reaches the network, this does, so the split between "what is on disk" and "go
fetch" stays explicit. Already-downloaded files return immediately; per-file
failures come back keyed by id, and ids is nil for all of them.
The interface anticipates more than issue analysis. Requests carry a kind, and a backend advertises which kinds it handles — so an LLM client slots in without the interface moving:
require("issuehub.backend").register("my-llm", {
name = "my-llm",
setup = function(self, opts) return true end,
capabilities = function()
return { kinds = { "analyze", "complete" }, streaming = true, models = { "..." } }
end,
discover = function(self, cb) cb(nil, self:capabilities()) end,
health = function() return true, "ready" end,
send = function(self, req, opts, cb)
-- req.kind, req.prompt, req.context.{issue,overlay,selection,documents}
-- opts.on_chunk(text) for streaming; call cb(nil, { text = ..., model = ... })
end,
})Free-form completion is reachable through the same interface:
require("issuehub.backend").complete("Draft a release note.", {}, function(err, res)
print(res.text)
end)Nothing in issuehub calls complete() yet — it exists so an LLM backend can be
dropped in and driven by your own code, or by a future feature, without the
contract changing. Requests of a kind the backend does not advertise are refused
with a clear message rather than sent and misunderstood.
The bundled A2A backend is written against the JSON-RPC
message/sendshape with agent-card discovery, but has not been exercised against a live agent. Treat it as a starting point and please report mismatches.
Only what belongs in Git lives at the root; everything derived is under
.state/, which is git-ignored automatically.
~/notes/issuehub/
├── .issuehub/collections/ # tracked (0.4)
├── .state/ # NOT tracked: cache, index, locks
├── jira/PROJ-123/
│ ├── memo.md # your notes
│ ├── metadata.yaml # free-form key/value
│ ├── prompt.md # the next prompt (conversation window)
│ ├── translations/ja.md # one per language, tracked, hand-editable
│ ├── state.yaml # bookmark, last-seen revision
│ └── analyses/
│ └── 2026-07-19T11-17-00Z/ # prompt.md, response.md, metadata.yaml
└── redmine/12345/
Issue IDs are RFC 3986 percent-encoded for path safety, so PROJ-123 stays
exactly that and only the rare PROJ/123 becomes PROJ%2F123 — the tree stays
readable in oil.nvim, git diff, and grep.
The same core exists as a standalone Python CLI at
tya5/issuehub, for use outside Neovim — the
shell, scripts, and analysis with pandas over the same workspace.
This plugin does not require it. The two are independent implementations that
read and write the same Git-managed workspace, kept consistent by a shared
on-disk contract and conformance corpus. The plugin shells out to the CLI for one
thing only — aggregation (summarize) — and degrades with a clear message when
it is absent. Nothing else in the plugin depends on it.
Reasoning, including the measurement that decided it: DESIGN.md §24.
:help issuehubThe help file is the reference: every option, command, and public API function, plus the extension guide. This README is the tour.
issuehub is meant to be extended from outside. Four registries are public:
require("issuehub.provider").register("mytracker", provider) -- a tracker
require("issuehub.backend").register("my-llm", backend) -- an agent/model
require("issuehub.core.export").register("xlsx", exporter) -- a format
-- picker adapters: implement pick(view, opts) + three capability flagsA provider converts a remote payload to the canonical Issue and nothing else: no
UI, no workspace access, all I/O async, errors returned rather than thrown.
Backend requests carry a kind, so an LLM client slots in without the interface
moving. See :help issuehub-extending and §7 / §16 of
DESIGN.md.
:help issuehub-api lists the public surface. Anything not listed there is
internal and may change without notice — that boundary is what will be frozen
at 1.0.
issuehub handles API tokens. They never reach argv, a log file, or disk — see SECURITY.md for the specifics and for what the cache does store in plain text.
Issues and PRs welcome. Please read CONTRIBUTING.md and DESIGN.md first — the architecture has a few load-bearing constraints (no hard dependencies, no cross-provider workflow enum) that a PR can accidentally break.
nvim -l spec/runner.lua # run the specs; no toolchain needed
stylua lua plugin ftplugin specMIT © tya5