Blame · Line-by-line history
issue-counts.ts
Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.
| dc32319 | 1 | /** |
| 2 | * Open-issue counts, computed from the issues table rather than read from the | |
| 3 | * denormalized `repositories.issueCount` column. | |
| 4 | * | |
| 5 | * That column is incremented in eight places — the issues route, the MCP | |
| 6 | * write surface, voice-to-pr, the dependency scanner (twice), ai-auto-issues, | |
| 7 | * ai-incident and incident-hooks — and decremented in NONE. Closing an issue | |
| 8 | * updates `issues.state` and never touches it. So it is not an open-issue | |
| 9 | * count at all; it is a monotonically increasing "issues ever created" total | |
| 10 | * that can only go up. | |
| 11 | * | |
| 12 | * The dashboard rendered that number under the label "Open Issues", where it | |
| 13 | * was simply false: close every issue in every repository and the figure is | |
| 14 | * unchanged. The REST repo listing returns the same column, and the API docs | |
| 15 | * present it as `issueCount` alongside starCount and forkCount, so consumers | |
| 16 | * read it the way GitHub's open_issues_count reads — as currently open. | |
| 17 | * | |
| 18 | * Computing at read time is preferred over maintaining the counter because | |
| 19 | * the counter has eight independent write sites and already drifted; fixing | |
| 20 | * it would mean adding decrements to close, reopen and delete, plus a | |
| 21 | * backfill, and leaving eight future chances to drift again. One GROUP BY | |
| 22 | * over the `issues_repo_state` index — which covers exactly | |
| 23 | * (repository_id, state) — is always right and cannot skew. | |
| 24 | * | |
| 25 | * The stored column is deliberately left in place and still written: it is | |
| 26 | * part of the documented REST response shape, so removing it is a contract | |
| 27 | * change rather than a bug fix. | |
| 28 | */ | |
| 29 | ||
| 30 | import { and, count, eq, inArray } from "drizzle-orm"; | |
| 31 | import { db } from "../db"; | |
| 32 | import { issues } from "../db/schema"; | |
| 33 | ||
| 34 | /** | |
| 35 | * Map of repositoryId → number of OPEN issues, for the given repositories. | |
| 36 | * | |
| 37 | * Repositories with no open issues are absent from the map; callers should | |
| 38 | * treat a miss as 0. Returns an empty map for an empty input rather than | |
| 39 | * issuing a query with an empty IN list. | |
| 40 | */ | |
| 41 | export async function openIssueCountsByRepo( | |
| 42 | repoIds: string[] | |
| 43 | ): Promise<Map<string, number>> { | |
| 44 | const out = new Map<string, number>(); | |
| 45 | if (!repoIds.length) return out; | |
| 46 | ||
| 47 | const rows = await db | |
| 48 | .select({ repositoryId: issues.repositoryId, n: count() }) | |
| 49 | .from(issues) | |
| 50 | .where(and(inArray(issues.repositoryId, repoIds), eq(issues.state, "open"))) | |
| 51 | .groupBy(issues.repositoryId); | |
| 52 | ||
| 53 | return countRowsToMap(rows); | |
| 54 | } | |
| 55 | ||
| 56 | /** | |
| 57 | * Fold grouped count rows into a map, coercing the count to a real number. | |
| 58 | * | |
| 59 | * Split out because this is where the subtle bug lives. Postgres `count()` is | |
| 60 | * bigint and the driver hands bigint back as a STRING. A drizzle | |
| 61 | * `sql<number>` annotation is a compile-time cast only, so without an | |
| 62 | * explicit Number() the values stay strings and summing them concatenates: | |
| 63 | * "3" + "5" becomes "35", not 8. Anything unparseable degrades to 0 rather | |
| 64 | * than NaN, which would poison a total. | |
| 65 | */ | |
| 66 | export function countRowsToMap( | |
| 67 | rows: Array<{ repositoryId: string | null; n: number | string | null }> | |
| 68 | ): Map<string, number> { | |
| 69 | const out = new Map<string, number>(); | |
| 70 | for (const row of rows) { | |
| 71 | if (row.repositoryId == null) continue; | |
| 72 | const n = Number(row.n); | |
| 73 | out.set(String(row.repositoryId), Number.isFinite(n) && n > 0 ? n : 0); | |
| 74 | } | |
| 75 | return out; | |
| 76 | } | |
| 77 | ||
| 78 | /** | |
| 79 | * Total open issues across `repoIds`. | |
| 80 | * | |
| 81 | * Falls back to `fallback` if the query fails, so a stat card can never take | |
| 82 | * a page down. The fallback is the stale stored total — wrong, but it is | |
| 83 | * what the page displayed before, and degrading to the old behaviour beats | |
| 84 | * a 500. | |
| 85 | */ | |
| 86 | export async function totalOpenIssues( | |
| 87 | repoIds: string[], | |
| 88 | fallback = 0 | |
| 89 | ): Promise<number> { | |
| 90 | try { | |
| 91 | const counts = await openIssueCountsByRepo(repoIds); | |
| 92 | let total = 0; | |
| 93 | for (const n of counts.values()) total += n; | |
| 94 | return total; | |
| 95 | } catch (err) { | |
| 96 | console.warn( | |
| 97 | "[issue-counts] open issue count failed, using fallback:", | |
| 98 | err instanceof Error ? err.message : err | |
| 99 | ); | |
| 100 | return fallback; | |
| 101 | } | |
| 102 | } |