A GitHub bot that reads new issues and applies a label: bug, feature-request, question, or needs-human-review. It also flags possible duplicates and tracks what it spent doing it.
It runs as a GitHub App against your own repo, using your own AI provider key. Nothing is sent anywhere except the AI provider you configure.
When an issue is opened:
- Title and body are scanned for secrets (API keys, tokens, AWS keys, private keys) and redacted before anything leaves the process.
- The redacted text is embedded and compared against previous issues in the same repo. Close matches get posted as a comment listing candidate issue numbers.
- The redacted text is classified. If confidence is too low, the label becomes
needs-human-reviewand the bot explains why in a comment. - The label is applied, prefixed with
ai:—ai:bug, notbug. Your real label taxonomy stays yours. - The decision is written to a local SQLite database: label, confidence, the model's stated reason, which model, how many tokens.
The bot only ever adds an allowlisted label or posts a comment. It does not close issues, does not apply arbitrary labels, and does not act on anything it isn't confident about.
- It won't close an issue as a duplicate. It tells a maintainer what looks similar and stops there.
- It won't apply a label outside the allowlist. If the model invents one, that's treated the same as low confidence.
- It won't follow instructions embedded in issue text. Issue content is data, not instructions, and the prompt says so explicitly. This has been tested against real injection attempts.
Every decision is recorded. When a human removes an ai: label, that's recorded too. The accuracy report gives you the override rate per label — the percentage of the bot's decisions a human later undid.
This is the point of the whole thing. A classifier that says "95% confident" is making a claim about itself. The override rate is the number that actually tells you whether to trust it.
This is the part you can't work out from the code. Go to Settings → Developer settings → GitHub Apps → New GitHub App.
- Webhook URL: where your instance will receive events. For local development use smee.io — create a channel and use that URL.
- Webhook secret: generate a long random string. You'll need it again in config.
- Permissions → Repository permissions → Issues: Read & write. That's the only permission needed.
- Subscribe to events: Issues.
After creating the app:
- Note the App ID on the app's settings page.
- Generate a private key. This downloads a
.pemfile. Keep it out of git —.gitignorealready excludes*.pem. - Install the app on the repo you want it to watch. After installing, the URL contains the installation ID:
.../settings/installations/<InstallationId>.
Gemini is the tested provider. Get a key from Google AI Studio.
The provider architecture supports Ollama and OpenAI-compatible endpoints, but only Gemini has been run against real traffic. Treat the others as untested.
Copy appsettings.example.json to appsettings.json and fill it in. appsettings.json is gitignored — your real keys stay local.
{
"ConnectionStrings": {
"RepoTagger": "Data Source=RepoTagger.db;Foreign Keys=True;Default Timeout=5;"
},
"GitHub": {
"AppId": 123456,
"ClientId": "your-client-id",
"WebhookSecret": "the-secret-you-generated",
"PrivateKey": "path-or-contents-of-your-pem",
"InstallationId": "from-the-install-url"
},
"AI": {
"Provider": "Gemini",
"ApiKey": "your-gemini-key",
"ModelId": "gemini-3.6-flash",
"EmbeddingModelId": "gemini-embedding-001",
"EscalationModelId": "gemini-3.1-pro-preview",
"MaxAiCallsPerDayPerRepository": 100,
"ConfidenceThreshold": 0.75,
"DuplicateSimilarityThreshold": 0.85
}
}EscalationModelId is optional — leave it empty to disable cascade routing.
ConfidenceThreshold is the line between "apply the label" and "hand it to a human". 0.75 is a sensible start. Push it higher and more issues get routed to a human; push it lower and the bot acts on weaker guesses.
dotnet run
Tables are created on first start. Open an issue on the installed repo and watch the logs.
Before the bot runs, create these four labels in your repo (Settings → Labels → New label):
| Label | Suggested color |
|---|---|
ai:bug |
#d73a4a |
ai:feature-request |
#a2eeef |
ai:question |
#d876e3 |
ai:needs-human-review |
#fbca04 |
If you skip this, the bot may still work — GitHub's API likely creates a missing label automatically the first time it's applied — but it'll get a random color with no description, and you can't fully rule out a failure on a repo you haven't tested against yet. Five minutes now avoids wondering later why a label never showed up.
Open the app's root URL in a browser — https://localhost:5001, or whatever port Kestrel reports on startup.
It shows job counts, the accuracy report, cost per model, decision breakdown, and individual AI calls. It's read-only. Nothing on it changes configuration.
Only the /github/* webhook path is reachable from outside the machine. The dashboard, the report endpoints and Swagger all return 404 to anything that isn't a loopback request. There's no login because there's nothing to log into — if you can reach it, you're already on the box.
If you deploy behind a reverse proxy, check that the proxy sets X-Forwarded-For. Without it every request looks like it came from the proxy, and if the proxy sits on the same machine that reads as local.
It's a SQLite file next to the binary, RepoTagger.db. It's yours. Nothing backs it up, nothing replicates it, nothing will stop you deleting it. Delete it and you lose every decision, every override, every embedding and every cost record — the bot starts again from empty and no longer knows about previous issues for duplicate detection. That's on you.
Poke at it with the sqlite3 CLI:
sqlite3 RepoTagger.db.tables
.headers on
.mode box
-- what the bot decided recently
SELECT IssueNumber, Label, Confidence, ModelReason FROM IssueDecisions ORDER BY Id DESC LIMIT 10;
-- what it spent
SELECT CallType, Model, TotalTokens, OccurredAtUtc FROM AiUsageEvents ORDER BY Id DESC LIMIT 20;
-- jobs that didn't make it
SELECT Id, Status, Attempts, LastError FROM WebhookJobs WHERE Status = 'Failed';
-- labels a human took back off
SELECT * FROM LabelOverrideEvents;Cost is calculated from a local table you fill in yourself. Until you do, the dashboard shows token counts and no dollar figures.
Check your provider's pricing page, then:
INSERT INTO AiPricing (Provider, Model, InputPricePerMillionTokens, OutputPricePerMillionTokens, EffectiveFromUtc, RecordedAtUtc)
VALUES ('Gemini', 'gemini-3.6-flash', 1.50, 7.50, '2026-01-01 00:00:00', datetime('now'));Never update a price row. Insert a new one with a later EffectiveFromUtc. Cost is always calculated with whichever price was active when the call happened, so a price change doesn't rewrite history.
Reasoning tokens are billed at the output rate and counted as output when calculating cost.
These numbers are an estimate for your own visibility. For what you actually owe, check your provider's console.
Most configuration lives in appsettings.json. A few values are constants in code instead, because they're implementation detail rather than something you're expected to tune per deployment. If you do want to change one, here's where they live:
| Value | Where | Default | What it does |
|---|---|---|---|
MaxEmbeddingInputCharacters |
AI/SemanticKernelDuplicateDetector.cs |
8000 | Caps how much title+body text gets sent for embedding. A rough proxy for the provider's token limit, not an exact one. |
CandidatesToFetch / CandidatesToReturn |
AI/SemanticKernelDuplicateDetector.cs |
5 / 3 | How many nearest embeddings get pulled from the vector store, and how many survive filtering to actually appear in a duplicate comment. |
| Retry backoff (30s / 5min / 30min) | Data/WebhookJobRepository.cs, MarkPending |
— | Delay before a failed job's next attempt, by attempt number. |
| Stuck-job threshold | Data/WebhookJobRepository.cs, in both ClaimNextJob and GetOperationsReportAsync |
10 minutes | How long a job can sit Processing before it's treated as abandoned (crashed worker) and reclaimed. It's a literal in two places — change both, or the operations report and the actual reclaim behavior will disagree. |
| Low-confidence report threshold | Data/IssueDecisionRepository.cs, GetIssueBreakdownAsync |
0.90 | The cutoff the dashboard's "low confidence" count uses — matches the prompt's own boundary between "clear" and "reasonable but uncertain," not an arbitrary pick. |
| Allowed labels | AI/Domain/AllowedActions.cs |
bug, feature-request, question, needs-human-review |
Anything the model returns outside this list is treated as low confidence, not applied. |
ai: label prefix |
GitHub/GitHubLabelMapper.cs |
ai: |
What the bot's labels are prefixed with to stay separate from your real taxonomy. |
| Confidence guidance | AI/Prompts/IssueClassifier.yaml |
— | The bands the model is told to use when deciding its own confidence. Changing this changes model behavior, not application logic — test carefully. |
| Rate-limit window | AI/RepositoryRateLimiter.cs |
24 hours, rolling | Not a calendar day — a 24-hour window measured from each call's own timestamp. |
Each instance handles one GitHub App installation with one AI provider key. To cover multiple repositories, run a separate instance for each — its own config file, its own key, its own SQLite file.
This keeps repos fully isolated: one repo's cost, failures, or rate limiting can't touch another. Instances are cheap — SQLite, no external services, no message broker.
Sharing one AI provider key across many busy repos will slow things down, because provider rate limits apply to the key, not the repo. Separate keys avoid that.
- Token counts are sometimes unknown. The Gemini connector doesn't report usage for embedding calls. Those are recorded with unknown cost rather than assumed to be zero, and reports count them separately so an incomplete total doesn't look precise.
- Usage extraction is Gemini-specific. Point this at another provider and cost tracking degrades to "unknown" rather than breaking. It won't give you a wrong number, it just won't give you a number.
- Cascade routing is lightly tested. Escalating a low-confidence result to a stronger model works, but in real traffic the primary model rarely lands in the band that triggers it. If you exercise this path, what you find is genuinely useful — open an issue.
- SQLite and GitHub can't be written atomically. If a GitHub call succeeds and the local write immediately after it fails, a retry can repost a comment. The window is small and the design minimizes it, but it isn't zero and can't be.
- A crashed worker's job looks like a failing job. Both show as retried. A busy repo hitting its rate limit will inflate the retry rate in the operations report.
ASP.NET Core, Semantic Kernel, SQLite with Dapper, sqlite-vec for vector search. No Entity Framework, no external database, no message queue, no frontend build step. The data needs are small and it's built to stay that way.
PolyForm Noncommercial 1.0.0. Free for noncommercial use. Commercial use needs a separate license — open an issue and we'll talk.