gh-mirror copies GitHub tickets into a local SQLite database: issues, pull request records, comments, labels, native issue fields and project memberships. You collect once, then update incrementally. Queries run offline from that database.
One Go executable provides:
- A CLI for search, listing and reading tickets.
- MCP tools for AI agents, over stdio or HTTP.
- A REST API for scripts and services.
- Portable snapshots that other machines and CI jobs can query.
It needs no external database or search service. Only sync talks to GitHub, and it
never writes there.
mkdir -p ~/.config/gh-mirror
cat > ~/.config/gh-mirror/config.toml <<'EOF'
default_repo = "owner/repository"
[repos."owner/repository"]
preset = "issues"
EOF
export GITHUB_TOKEN=... # read access to the repository
gh-mirror sync # first run collects everything; later runs update
gh-mirror search 'socket timeout'
gh-mirror show '#123'
gh-mirror related '#123'
gh-mirror doctor- Install
- Configure
- Query
- Sync
- Snapshots
- MCP
- REST API
- Semantic and hybrid search
- AI review comment filtering
- Evaluate retrieval
- Development
Install and update with grm:
grm install jsnjack/gh-mirror # installs /usr/local/bin/gh-mirror
grm update # picks up new releasesArchives are also on GitHub releases.
Builds exist for Linux and macOS on amd64 and arm64. Each archive holds the executable
and this README; checksums.txt lists SHA-256 sums. Go is not needed at runtime.
To build from source, install Go 1.26.4 or newer and monova:
make build # writes bin/gh-mirror, CGO disabled; run it as ./bin/gh-mirrorDon't copy a source build into a directory that comes earlier in PATH than grm's
install. It hides the grm version.
Install shell completion. It suggests repositories, local label names, catalog kinds and fixed flag values:
gh-mirror completion bash > ~/.local/share/bash-completion/completions/gh-mirror
gh-mirror completion zsh > "${fpath[1]}/_gh-mirror"
gh-mirror completion fish > ~/.config/fish/completions/gh-mirror.fish| Group | Commands |
|---|---|
| Query | search, list, show, comments, context, related, catalog |
| Collect and publish | sync, index, snapshot, acquire |
| Serve | mcp, serve |
| Inspect | status, doctor, scope, bots, model, config |
| Advanced | evaluate |
- Output format: text when stdout is a terminal, JSON when piped. Override with
--format textor--format json. - JSON is complete: text output shows compact previews; JSON keeps every field.
- Another database:
--db PATHpoints any command at another mirror or snapshot.
The settings file is ~/.config/gh-mirror/config.toml. Only TOML is read.
examples/config.toml lists every setting with its default.
A typical file is short:
default_repo = "owner/repository"
workers = 8
[pr_comments]
exclude_known_ai = true
exclude_ai_threads = true
[repos."owner/repository"]
preset = "all"
pull_request_review_comments = false
[repos."owner/code"]
preset = "pull_requests"Omitted settings keep their defaults:
| Key | Default | Meaning |
|---|---|---|
default_repo |
the only configured repository | Repository for #123 references and catalog |
database |
~/.local/share/gh-mirror/state.sqlite |
Mirror database |
snapshot_dir |
~/.local/share/gh-mirror/snapshots |
Published snapshots |
workers |
4 | Parallel GitHub requests and CPU index workers (1–16) |
max_requests |
3000 | Request budget per sync invocation |
enrichment_interval |
1h |
Refresh of catalogs, fields, relationships and projects |
reconcile_interval |
24h |
Full inventory that removes deleted and transferred tickets |
stale_after |
1h |
Collection age at which sync --if-stale collects |
token_env |
GITHUB_TOKEN |
Environment variable holding the GitHub token |
api_token_env |
GH_MIRROR_API_TOKEN |
Environment variable holding the REST/MCP HTTP token |
listen |
127.0.0.1:8787 |
serve address |
api_url, graphql_url |
api.github.com | GitHub Enterprise endpoints |
- XDG variables apply to the default paths.
--configand--dboverride the settings file and database for one command.- Tokens never enter the database. gh-mirror reads them from the environment only.
gh-mirror config show # compact TOML
gh-mirror config show --effective # every default and resolved switchEach [repos."owner/name"] table selects a preset. Switches set to true or
false override it:
| Preset | Enables |
|---|---|
all |
All eleven switches |
issues |
Issues, issue comments, labels, milestones, issue types, fields, relationships, projects |
pull_requests |
PR records, PR discussion comments, inline review comments |
| Switch | Collects |
|---|---|
issues |
Open and closed issues with their raw payloads |
pull_requests |
Open and closed PR records from the issue listing |
issue_comments |
Issue discussion comments |
pull_request_comments |
PR discussion comments |
pull_request_review_comments |
Inline review comments with diff context and replies |
labels |
Label catalog, including unused labels |
milestones |
Milestone catalog, including closed milestones |
issue_types |
Organization issue type catalog |
fields |
Native issue field definitions and ticket values |
relationships |
Parent, sub-issue, blocked-by and blocking links |
projects |
Owner project catalog and active or archived memberships |
- Comments need their ticket kind. Issue types, fields and relationships need issues.
- Raw tickets always keep titles, bodies, state, authors, assignees, labels, milestone and type. Turning a catalog off does not remove them.
- A table without
presetuses the globalfields/projectsdefaults, with inline review comments off. gh-mirror scopeprints the resolved switches without contacting GitHub.
Some data is never collected:
- Pull request internals: commits, file patches, check runs and approval reviews.
- Other GitHub content: reactions, attachments and Discussions.
- Project details: project cards and project field values.
The token needs read access to the configured repositories, their issues, organization issue types and fields, and owner projects.
- Enabled resources must be readable. A 403, 404 or GraphQL error fails the sync; disable the switch instead.
- Personal repositories have no organization issue fields.
- User-owned Projects endpoints need a classic PAT. Fine-grained and installation tokens are not supported there.
Queries read the local database and need no token.
| Command | Purpose |
|---|---|
search WORDS |
Rank tickets by words or meaning, with source evidence |
list |
List tickets by filters, without search words |
show REF... |
Read up to 100 tickets, without comments |
comments REF |
Page through discussion and review comments |
context REF |
Read bounded evidence for one ticket, for an agent or reviewer |
related REF |
Find related tickets and duplicate candidates |
catalog KIND [SCOPE] |
Read labels, milestones, issue types, issue fields or projects |
REF takes three forms:
owner/repo#123- A GitHub URL such as
https://github.com/owner/repo/pull/456. #123or123, resolved againstdefault_repo.
gh-mirror search 'socket timeout' --state closed --labels-all bug
gh-mirror show '#123' https://github.com/owner/repository/pull/456
gh-mirror comments '#123' --kind review --limit 20
gh-mirror context '#123' --query 'session continuation'
gh-mirror related 123 --include-comments
gh-mirror catalog labels
gh-mirror catalog projects owner- Summaries by default: every query command returns summaries.
--view fulladds raw GitHub records. showtext view lists title, state, labels, assignees, milestone, type, URL, dates, comment count and the start of the body.- Older command names still work.
get,batchandcandidatesare hidden and print a notice naming their replacement.
Search covers titles, bodies, labels, discussion comments and inline reviews in open and closed tickets. The default engine is hybrid: it combines exact words with similarity in meaning. See Semantic and hybrid search.
gh-mirror search 'Acme timeout' --match all --count --facets
gh-mirror search 'socket timeout' --match phrase --in body --in comments
gh-mirror search 'auth' --prefix --in title --exclude-word deprecated
gh-mirror search 'timeout' --repositories owner/support --repositories owner/code
gh-mirror search 'ECONNRESET' --engine lexical| Option | Effect |
|---|---|
--match any |
Any word matches (default) |
--match all |
Every word appears somewhere in the ticket, across sources |
--match phrase |
Consecutive words in one source |
--prefix |
Match word prefixes; in phrase mode, only the last word |
--in |
Restrict to title, body, labels, comments or reviews; repeat |
--exclude-word |
Drop tickets containing the word; repeat |
--evidence-limit |
Matching sources per ticket, 1–10 (default 3) |
--engine |
hybrid (default), lexical or semantic |
- Literal matching: punctuation splits words, and query operators are plain words.
- Lexical limits: at most 32 terms and 16,384 bytes. Larger queries fail.
- Literal modes pick lexical.
--match all,--match phraseand--prefixuse the lexical engine when no engine is given. - Each match has title, URL, score, a
summaryof metadata andevidence. Evidence lists the field, excerpt, source URL and comment ID. Review evidence adds file, line and up to 1,024 characters of diff. - Scores rank results within one query. Higher is better for every engine. They are not percentages or duplicate probabilities.
- Lexical weights: titles and label names count five times more than body and comment text.
search, list and related share these filters. REST and MCP use the field names:
| CLI flag | REST/MCP field | Meaning |
|---|---|---|
--repo |
repo |
One repository (search and list) |
--repositories |
repositories |
Any of these repositories; repeat |
--state, --kind, --type |
state, kind, type |
Open/closed, issue/pull_request, native type (search and list) |
--labels-all |
labels_all |
Every label; repeat |
--labels-any |
labels_any |
Any of the labels; repeat |
--exclude-label |
exclude_labels |
None of the labels; repeat |
--author |
author |
Ticket author login |
--assignee |
assignees |
Any of the assignees; repeat |
--milestone |
milestone |
Milestone title or number |
--created-after, --created-before |
created_after, created_before |
Creation bounds, inclusive |
--updated-after, --updated-before |
updated_after, updated_before |
Update bounds, inclusive |
--project |
project |
Project membership owner/number, archived included (search and list) |
--field 'Priority=High' |
REST field=Priority=High, MCP field_values |
Native field value; repeat |
- Dates accept RFC3339,
YYYY-MM-DDor an age such as12h,7dor2w. A date covers the whole UTC day. - Filters combine with AND. Values inside
--assigneeand--labels-anycombine with OR. - Arrays: REST repeats the parameter; MCP takes JSON arrays. MCP
field_valuesis a list of{"name": "Priority", "value": "High"}.
gh-mirror list --labels-all 'client:Acme' --updated-after 7d --sort updated
gh-mirror list --labels-all 'client:Acme' --limit 100 --cursor "$next_cursor"| Ordering | Default |
|---|---|
list |
Repository and number, ascending |
search, related |
Relevance, best first |
--sort updated or created |
Newest first |
--sort number |
Ascending |
- Paging: results include
has_moreandnext_cursor. Pass the cursor back with the same options;--limitmay change. - Stale cursors: a new sync, index or policy changes the generation. The old cursor then fails with a stale-cursor error, and the query must start again.
--countaddstotal.--facetsadds label, type and project counts, up to 100 values each.- Warnings report uncollected repositories, disabled resources and stale fields or
projects.
--max-enrichment-agesets the threshold (default 24h).
showreads up to 100 tickets from one generation. Duplicates are merged; unknown tickets appear undermissing.commentspages through--kind all|discussion|reviewcomments in creation order.- Previews stop at 1,024 characters and set
truncated.--view fullreturns complete text and raw JSON. - Comment IDs are strings, so large numeric IDs survive JSON parsers.
- Policy exclusions are counted in
excluded_by_policyfor the selected kind.--include-excludedreturns those comments.
related builds a query from the seed ticket and returns similar history, including
closed tickets. The seed itself is excluded.
gh-mirror related owner/support#123 --include-comments --count
gh-mirror related owner/support#123 --repositories owner/support --repositories owner/code- Seed terms: up to 24 distinctive terms, with titles weighted above bodies. Acronyms and error codes such as CSS, API and 403 are kept.
- Labels count by default.
--include-labels=falsedrops them. - Comments are opt-in.
--include-commentsadds recent seed comments, bounded by--comment-limit(default 20, maximum 100). - Scope: the seed repository by default;
--repositorieswidens it.
context returns one ticket's evidence in a bounded size, for an agent or reviewer.
gh-mirror context owner/repository#123 --query 'session continuation'
gh-mirror context owner/repository#123 --purpose proofread --max-characters 20000| Purpose | Use |
|---|---|
evaluate (default) |
General assessment |
classification |
Choosing labels; adds label definitions and rules |
duplicates |
Checking whether two tickets describe the same problem |
proofread |
Rewriting the description; gives the body most of the budget |
The result includes:
- The description with section byte offsets and absent or empty sections.
- Label roles such as client or feature, plus label descriptions.
- Closure and merge metadata.
- Selected comments: ones matching
--query, plus the most recent. - Related tickets one hop away: native relationships, mentions, and explicit duplicate or fix references.
Size and parsing:
- Bounds:
--comment-limit(default 10),--related-limit(default 5) and--max-characters(default 12,000). - Coverage reports omitted comments, unexpanded relations and cut text.
- References are observations. The agent decides whether two tickets describe the same problem.
- Reference parsing ignores
#Nafter a word character, slash, colon or dot. Version strings and URL fragments therefore don't count.
Label conventions such as client:Acme and feature:x are recognized by default.
Configure others per repository:
[[label_rules."owner/repository"]]
prefix = "customer:"
role = "client"
[[label_rules."owner/repository"]]
label = "account:example"
role = "client"
value = "Example"
aliases = ["Example Ltd"]
description = "Affected customer"Exact labels take priority over prefixes, and the longest prefix wins. Run index or
sync to save changed rules into the database. Neither contacts GitHub for this.
| Kind | Default SCOPE |
|---|---|
labels, milestones |
Default repository |
issue_types, issue_fields, projects |
Owner of the default repository |
catalog returns the whole catalog. --limit or --cursor switch to pages. Unknown
kinds fail with the list of valid ones.
Use the query commands to find causes, earlier decisions, workarounds and fixes. Duplicate detection is one case of this.
- Search with exact terms first: error text, component names, client labels.
- Keep closed tickets. They hold most resolutions.
- Read the evidence, then
showthe promising tickets. - Read the discussion with
commentsorcontextbefore trusting a snippet. - Cite source URLs and check
warningsfor stale or missing data.
gh-mirror search 'workaround for downloads after reconnecting' --limit 10
gh-mirror search 'session token retry fix' --in reviews
gh-mirror show owner/support#123 owner/code#456
gh-mirror comments owner/support#123 --view fullScores order candidates for reading. They do not prove relevance or duplication. Semantic results near the bottom can be unrelated.
gh-mirror sync # first run creates the database; later runs update it
gh-mirror status # database path, generation, scope, counts, timestamps
gh-mirror doctor # health checks; exits 1 when one failsAfter collecting, sync updates the semantic index and publishes a snapshot.
--index=false and --publish=false skip those steps.
Agents can run this before each query. It does nothing while the mirror is fresh:
gh-mirror sync --if-stale --quiet # uses stale_after (default 1h)
gh-mirror sync --if-stale --stale-after 15m --quiet # one-off threshold- A fresh mirror is left alone. If the last collection is younger than
stale_after, sync exits 0. It sends no GitHub requests and needs no token. - A running sync is not joined. If another process is collecting, sync exits 0 at once. A sync that was interrupted is not running, so it resumes as usual.
- A stale, uncollected or missing mirror gets a normal sync, including index and publish.
- Concurrent calls collect once. The others report
running, orfreshafter it commits. --stale-afterneeds--if-staleand must be positive.
A skipped sync prints one result instead of the sync report:
{"skipped":true,"reason":"fresh","collected_at":"2026-10-15T09:37:00Z","age":"23m0s","stale_after":"1h0m0s"}
{"skipped":true,"reason":"running","collected_at":"2026-10-15T07:02:00Z","age":"2h58m0s","stale_after":"1h0m0s"}collected_at and age are absent when the mirror has never been collected.
| Data | When |
|---|---|
| Changed issues and comments | Every sync |
| Catalogs, fields, relationships, projects | Every enrichment_interval (1h) |
| Full inventory, removing deleted and transferred tickets | Every reconcile_interval (24h) |
| Everything | sync --full |
- Unchanged repositories cost two requests: one issue listing and one comment listing. Inline review comments add a third. A 304 response still counts.
- Comments are polled separately, so edits to comments on old issues arrive too.
- Deletions are confirmed first. A ticket missing from a full listing is fetched directly. Sync keeps it if GitHub still returns it.
- Renamed or deleted labels make the next enrichment refetch the tickets that carried them.
- A changed token or API host triggers one full reconciliation. A permission change
on the same token needs
--full. - Data format upgrades (
collection_version) rebuild automatically. The old mirror stays readable until the rebuild commits.
- Ctrl-C is safe. Rerun
syncto resume. Completed responses are saved in<database>.sync.sqliteand cost no requests on resume. - A failed sync changes nothing. Readers keep the last complete generation.
- Failures are recorded.
statusanddoctorshow the attempt count and last error while unfinished work exists. - Repeated failures still reach reconcile, because intervals use the current time.
sync --restartdiscards unfinished work.- Keep the database directory on persistent storage, including the resume file.
gh-mirror sync --workers 8 # GitHub requests and indexing
gh-mirror sync --workers 8 --index-workers 4 # cap CPU indexing separately
gh-mirror sync --workers 1 # strictly serial requests- Workers range from 1 to 16 (default 4). They share one request budget and one rate-limit pause.
- Parallel and serial syncs fetch the same pages. There are no speculative requests.
- Secondary rate limits wait at least one minute and back off for up to five attempts.
- An exhausted quota waits until GitHub's reset time. Waits over 61 minutes stop the sync.
- The budget (
max_requests, default 3000) applies per invocation. Stopping at it keeps completed work for the next run. - Serial mode follows GitHub's advice for avoiding secondary limits.
Progress goes to stderr; stdout keeps the JSON result. In a terminal it shows the phase, pages, records, request budget, cache hits and remaining quota:
gh-mirror sync | elapsed 12s
⠹ Hydrating issue metadata
━━━━━━────────────── 100/300 (33%) | 25.0/s | phase ETA 8s
Received: 300 issues, 840 comments | repository 1/1
API: 19/3000 requests | 0 cached | GitHub workers 4/4 active
GitHub remaining: 4981/5000
Resume: 8 resumed responses | 10 saved before this run
cached counts 304 Not Modified answers. GitHub does not charge quota for them. A
finished sync lists committed changes per repository, which the JSON result also
reports under changes:
✓ Sync complete | elapsed 2m41s
surfly/it: issues +3 ~41 -1 | comments +88 -2
surfly/cobro: pull requests +12 ~30 | comments +240
+ added, ~ updated, - removed
Colours mark the phase, bar, retry waits, quota below 25% and 10%, and the outcome.
--color auto (default) colours terminals unless NO_COLOR is set; always and
never override it. Redirected stderr and --debug print plain status lines
instead. --quiet hides progress.
Run one collector at a time, with GITHUB_TOKEN in its environment:
*/15 * * * * /usr/local/bin/gh-mirror --config /etc/gh-mirror/config.toml sync --quietserve and mcp only read. They never start a sync.
- Schema 3 needs a current binary. Older binaries fail with "unsupported database schema".
- Parser or policy version changes re-index locally on the next
syncorindex. This needs no GitHub requests. - Changed listing order: issues and comments are now listed by creation time. The first sync after upgrading cannot reuse cached listing responses or saved listing pages.
A snapshot is a standalone, read-only copy of one complete generation, including its
search index. sync publishes one after each successful run. To publish manually:
gh-mirror snapshot- Never copy the live database. It uses WAL sidecars and changes during sync. Snapshots are self-contained.
- Collector data is stripped. Response caches, the credential hash and resume data are removed, and the file is compacted.
- Publishing is atomic. The file gets a unique name and is fsynced before
latest.jsonpoints to it. - The three newest files are kept. Older snapshots are deleted on publish.
- Many readers can share one snapshot. Readers open it read-only and write nothing.
Copy the file latest.json names, then query the copy:
SNAPSHOTS=~/.local/share/gh-mirror/snapshots
cp "$SNAPSHOTS/$(jq -r .filename "$SNAPSHOTS/latest.json")" ./mirror.sqlite
gh-mirror --db ./mirror.sqlite doctor --max-age 2h
gh-mirror --db ./mirror.sqlite search 'socket timeout'Copy rather than read the published file in place. Pruning can delete it during a long job.
acquire downloads a snapshot from a shared path or an HTTPS serve endpoint:
gh-mirror acquire --source /srv/gh-mirror/snapshots/latest.json \
--dest ./mirror.sqlite --repo owner/repository --max-age 2h > mirror-manifest.json
gh-mirror acquire --source https://mirror.example/snapshots/latest \
--dest ./mirror.sqlite --repo owner/repositoryIt checks the SHA-256, exact repository scope, schema and collection versions, and age. Then it installs the file atomically.
--max-agelimits issue and comment age (default 2h).--max-enrichment-ageseparately limits field and project age (off by default).- A failed acquire leaves the previous destination untouched.
- Downloads are limited to 2 GiB.
Point an MCP client at the executable:
{
"mcpServers": {
"gh-mirror": {
"command": "/usr/local/bin/gh-mirror",
"args": ["--db", "/path/to/mirror.sqlite", "mcp"]
}
}
}serve also exposes Streamable HTTP MCP at http://127.0.0.1:8787/mcp, using the REST
authentication. Every tool is read-only.
| Tool | Set | Purpose |
|---|---|---|
search_issues |
core | Search with filters, evidence and cursors |
find_duplicate_candidates |
core | Related tickets for a seed ticket |
get_issue_context |
core | Bounded evidence for one ticket |
get_issues |
core | Up to 100 tickets, without comments |
get_issue |
core | One ticket; summary without comments by default |
list_comments |
core | A page of comments |
get_sync_status |
core | Generation, scope, coverage and freshness |
list_issues |
all | Filtered listing without search words |
get_catalog |
all | A complete catalog |
list_catalog |
all | A catalog page |
get_project |
all | A project record |
- Core tools by default.
mcp --tools allorserve --mcp-tools alladds the rest. - Every input is described, and fixed choices list their allowed values.
- Results stay small. Their
statusholds onlygenerationandcollected_at;get_sync_statusreturns the full status. get_issuecomments:comments=pagereturns one bounded page;comments=allreturns every comment.- Engine:
search_issuesandfind_duplicate_candidatesacceptengine. Readquery.engine,rankingandwarningsfor what actually ran.
gh-mirror serve # listens on 127.0.0.1:8787
curl 'http://127.0.0.1:8787/v1/search?q=socket+timeout&repo=owner/repository'
curl 'http://127.0.0.1:8787/v1/issues/owner/repository/123?view=summary'
curl -X POST 'http://127.0.0.1:8787/v1/issues/batch' -H 'Content-Type: application/json' \
--data '{"tickets":[{"repo":"owner/repository","number":123}],"view":"summary"}'| Endpoint | Purpose |
|---|---|
GET /health |
Health check |
GET /v1/status |
Generation, scope, coverage and freshness |
GET /v1/search?q=... |
Search with the shared filters, engine, match, prefix, in, exclude_words |
GET /v1/issues |
Filtered listing |
GET /v1/issues/{owner}/{repo}/{number} |
One ticket; view, comments, limit, cursor, comment_kind |
GET /v1/issues/{owner}/{repo}/{number}/comments |
Comment page; kind, view, order, limit, cursor |
GET /v1/issues/{owner}/{repo}/{number}/context |
Ticket context; purpose, query, limits |
GET /v1/issues/{owner}/{repo}/{number}/related |
Related tickets with the shared filters |
POST /v1/issues/batch |
Up to 100 tickets; JSON tickets and optional view |
GET /v1/catalog?kind=...&scope=... |
Catalog; limit or cursor for pages |
GET /v1/projects?owner=...&number=... |
Project record |
GET /snapshots/latest, /snapshots/{filename} |
Snapshot manifest and files |
/v1/candidates?repo=...&number=... remains as an alias of /related.
| Status | Code | Cause |
|---|---|---|
| 400 | invalid_query |
Invalid input |
| 404 | not_found |
Unknown ticket or resource |
| 409 | stale_cursor |
Cursor from an older generation |
| 503 | semantic_index_unavailable |
Explicit semantic/hybrid request without a complete index |
| 500 | internal_error |
Internal failure |
- Authentication: set
GH_MIRROR_API_TOKENand sendAuthorization: Bearer <token>. - A token is required off loopback. Once set, every endpoint requires it, loopback included.
- Use a TLS proxy to expose the server remotely.
The executable bundles the MiniLM L6 model, about 91 MB, and runs it in Go on the CPU.
The first use unpacks it into ~/.cache/gh-mirror/models/. gh-mirror model prints
the model identity.
| Engine | Ranks by | Index needed |
|---|---|---|
lexical |
Literal words (SQLite FTS5 BM25) | No |
semantic |
Meaning only (cosine similarity) | Yes |
hybrid (default) |
Words, meaning and whole-ticket evidence | Yes |
- No index, no error by default. Without a complete index, an omitted engine falls
back to lexical with a
search_engine_fallbackwarning. - Explicit engines are strict.
--engine semanticorhybridfail without a complete index; REST returns 503. - Result size: semantic keeps the 200 most similar tickets. Hybrid adds every literal and ticket-evidence match. Totals and facets count that set.
- Hybrid score:
1/(60+lexical rank) + 1/(60+semantic rank) + 3/(60+ticket rank). Ticket evidence rewards distinctive terms across fields and soft client-label matches. - Filters apply to every engine. Label filters are hard limits; client matches only boost the score.
- Limits: 16,384 bytes and 16 passages per query; hybrid allows 512 literal terms.
- English-oriented model. Hybrid keeps exact client labels and technical terms. The model research compares alternatives.
sync indexes new and changed text after each run. Build or resume the index alone,
without GitHub requests:
gh-mirror index --workers 8
gh-mirror search 'file transfers get stuck indefinitely' --engine semantic- Passages: titles, bodies, labels, comments and reviews are indexed separately. Long text splits into 256-token passages with small overlaps.
- Body passages skip empty template sections and review checklists. Offsets still point into the original text.
- Incremental: unchanged documents are skipped. Edited documents reuse identical passages, and identical text shares one vector.
- Resumable: completed passages are saved immediately. Ctrl-C and rerun.
- Snapshots carry the index, so consumers search offline without rebuilding it.
- After an interrupted index, run
indexagain, thensnapshotto publish.
With Lemonade installed, its Vulkan runtime can encode text on the GPU:
lemonade --no-discovery backends install llamacpp:vulkan
gh-mirror model --check --embedding-backend lemonade-vulkan --format text
gh-mirror index --embedding-backend lemonade-vulkanMake it the default:
[embedding]
backend = "lemonade-vulkan"
batch_size = 8 # 1–32
device = "Vulkan0"- Dedicated process: gh-mirror starts its own localhost
llama-server, offline and authenticated. It stops with gh-mirror, also when gh-mirror is killed on Linux. - The API key stays private. It is passed in a temporary file, not on the command line.
- Same model: an exact FP32 GGUF export of the bundled weights, about 87 MiB of extra cache. No Python tools are needed.
- Verified output: GPU vectors must match CPU reference vectors (cosine ≥ 0.9999, coordinate error ≤ 0.001). Otherwise gh-mirror falls back to the CPU and says why.
- Interchangeable: CPU and Vulkan vectors are compatible; switching keeps the index.
- Runtime path:
embedding.runtimeor--embedding-runtimeoverrides the default Lemonade cache location. - Benchmarks: see the Vulkan benchmark.
To keep AI reviewer comments out of search, context and the semantic index:
[pr_comments]
exclude_known_ai = true # Copilot, CodeRabbit, Gemini Code Assist, Claude
exclude_ai_threads = true # also replies under an excluded inline review
exclude_all_bots = false # every bot, including coverage and dependency bots
exclude_authors = ["custom-reviewer"]
include_authors = ["trusted-reviewer[bot]"]
[repos."owner/code".pr_comments]
exclude_known_ai = false # per-repository override- Raw comments are kept in the mirror and snapshots; only retrieval skips them.
- Detection uses stored data: known bot accounts from the
registry and explicit attribution lines.
Example:
Addressed by [Claude Code](https://claude.com/claude-code). - Declare AI comments explicitly by ending them with
<!-- gh-mirror:ai-generated:claude -->. - Quotes, code and mentions are ignored. Unmarked human comments stay included.
include_authorstakes priority, even inside excluded threads.- Only PR comments are filtered. Issue comments and PR descriptions stay searchable.
- Apply changes with
indexorsync. Readers use the policy saved in their database. - Repository overrides inherit global settings. A given author list replaces the inherited one; an empty list clears it.
gh-mirror bots --format text # detected accounts and exclusion counts
gh-mirror comments owner/code#123 --include-excludedMeasure retrieval against your own relevance judgments:
{
"cases": [
{
"name": "Known socket timeout tickets",
"search": {"query": "socket timeout", "match": "phrase"},
"relevant": [{"repo": "owner/repository", "number": 123}]
},
{
"name": "Known duplicate pair",
"candidates": {"repo": "owner/repository", "number": 123},
"relevant": [{"repo": "owner/repository", "number": 456}]
}
]
}gh-mirror --db ./mirror.sqlite evaluate --cases ./judgments.json --format text- Metrics: Precision@10 (relevant hits divided by 10) and Recall@20, with p50 and p95 latency.
- Judge every relevant ticket. Unjudged results count as misses and cap Precision@10.
- Pinned generation: evaluation fails if the mirror changes during the run.
- Benchmarks: see the measured comparison of the three engines.
make check # goimports, vet, build, race tests, golangci-lint
make release # build archives and publish a GitHub release- Tools: checks need
goimports,golangci-lintandmonova. The Makefile prints install commands for missing ones. - Releases:
make releaseneedsgrmand a clean, pushedmain. It uploads the archives and checksums to a new release taggedvplus monova's version. - Specification: docs/spec.md.
- Synthetic retrieval tests:
go test ./internal/store -run TestRetrievalEvaluation -v.
--debugprints request diagnostics on stderr.--tracewrites detailed metadata togh-mirror-<uid>.login the temporary directory. The file refuses symbolic and hard links.- No secrets in logs. Diagnostics never include tokens or ticket bodies.
- Listings: repository issues
and repository comments,
REST version
2026-03-10. - Metadata: native issue GraphQL schema and Projects.
- Rate limits: rate-limit guidance and best practices.
- GitHub Enterprise Server may lack features. Enabled features that are missing fail the sync instead of returning empty catalogs.