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