Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commitdc32319

fix(dashboard): count open issues instead of issues ever created

fix(dashboard): count open issues instead of issues ever created

repositories.issueCount 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
the column is an issues-ever-created total that can only go up.

The dashboard rendered that number under the label "Open Issues". Close
every issue in every repository and the figure does not move. The REST
repo listing returns the same column, and the API docs present it beside
starCount and forkCount, where a consumer reads it the way GitHub's
open_issues_count reads. Both now serve the real count.

Computed at read time rather than repairing the counter. The counter has
eight independent write sites and has already drifted; correcting it
would mean adding decrements to close, reopen and delete, plus a backfill
for existing rows, and would leave eight future chances to drift again.
One GROUP BY over the existing issues_repo_state index — which covers
exactly (repository_id, state) — 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 would be a
contract change rather than a bug fix. The API overwrites the field on
the way out and, if the count query fails, leaves the stored value rather
than dropping the key, so the response shape never changes. The dashboard
falls back to the old stale sum rather than 500ing over a stat card.

The bigint coercion is not incidental. Postgres count() is bigint and the
driver returns it as a STRING; drizzle's sql<number> is a compile-time
cast only, so without an explicit Number() the totals concatenate — "3" +
"5" is "35", not 8. That fold is split into countRowsToMap so the hazard
is tested directly.

Both fault classes proven by injection: dropping the coercion fails 3
tests, reverting the dashboard to the stored sum fails 2.

Suite: 3516 pass, 0 fail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
ccantynz-alt committed on July 29, 2026Parent: 392689d
4 files changed+2751dc3231905dc659a89727187087a61339e31ac9d3
4 changed files+275−1
Addedsrc/__tests__/issue-counts.test.ts+141−0View fileUnifiedSplit
1/**
2 * Regression guard: the dashboard's "Open Issues" figure must reflect issues
3 * that are actually open.
4 *
5 * repositories.issueCount is incremented in eight places — the issues route,
6 * the MCP write surface, voice-to-pr, the dependency scanner (twice),
7 * ai-auto-issues, ai-incident and incident-hooks — and decremented in NONE.
8 * Closing an issue updates issues.state and never touches it. So the column
9 * is an issues-ever-created total that can only go up, and the dashboard
10 * rendered it under the label "Open Issues": close every issue in every
11 * repository and the number does not move. The REST repo listing returns the
12 * same column, and the API docs present it beside starCount and forkCount
13 * where a consumer reads it as GitHub's open_issues_count does.
14 *
15 * Fixed by computing at read time instead of maintaining the counter. The
16 * counter has eight independent write sites and has already drifted; making
17 * it correct would mean adding decrements to close, reopen and delete plus a
18 * backfill, and leaving eight future chances to drift again. One GROUP BY
19 * over the issues_repo_state index cannot skew.
20 *
21 * The stored column stays and is still written — it is part of the
22 * documented REST response shape, so removing it would be a contract change
23 * rather than a bug fix.
24 */
25
26import { describe, it, expect } from "bun:test";
27import { readFileSync } from "node:fs";
28import { join } from "node:path";
29import { countRowsToMap } from "../lib/issue-counts";
30
31const REPO_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
32const REPO_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";
33
34describe("countRowsToMap", () => {
35 it("coerces bigint counts that arrive as strings", () => {
36 // Postgres count() is bigint and the driver returns it as a STRING. This
37 // is the whole hazard: drizzle's sql<number> is a compile-time cast only.
38 const map = countRowsToMap([
39 { repositoryId: REPO_A, n: "3" },
40 { repositoryId: REPO_B, n: "5" },
41 ]);
42 expect(map.get(REPO_A)).toBe(3);
43 expect(map.get(REPO_B)).toBe(5);
44 });
45
46 it("produces totals that ADD rather than concatenate", () => {
47 // The failure mode if the coercion is dropped: "3" + "5" === "35".
48 const map = countRowsToMap([
49 { repositoryId: REPO_A, n: "3" },
50 { repositoryId: REPO_B, n: "5" },
51 ]);
52 let total = 0;
53 for (const n of map.values()) total += n;
54 expect(total).toBe(8);
55 expect(total).not.toBe("35");
56 });
57
58 it("accepts counts that already arrive as numbers", () => {
59 const map = countRowsToMap([{ repositoryId: REPO_A, n: 7 }]);
60 expect(map.get(REPO_A)).toBe(7);
61 });
62
63 it("never yields NaN, which would poison a sum", () => {
64 const map = countRowsToMap([
65 { repositoryId: REPO_A, n: null },
66 { repositoryId: REPO_B, n: "not-a-number" },
67 ]);
68 expect(map.get(REPO_A)).toBe(0);
69 expect(map.get(REPO_B)).toBe(0);
70 let total = 0;
71 for (const n of map.values()) total += n;
72 expect(Number.isNaN(total)).toBe(false);
73 expect(total).toBe(0);
74 });
75
76 it("skips rows with no repository", () => {
77 const map = countRowsToMap([{ repositoryId: null, n: "4" }]);
78 expect(map.size).toBe(0);
79 });
80
81 it("returns an empty map for no rows", () => {
82 expect(countRowsToMap([]).size).toBe(0);
83 });
84});
85
86// --- the readers must use the computed value -------------------------------
87//
88// Assert the CALL form, not the bare identifier: `void totalOpenIssues;`
89// mentions it while computing nothing. Strip ONLY line comments.
90
91function source(...rel: string[]): string {
92 const raw = readFileSync(join(import.meta.dir, "..", ...rel), "utf8");
93 const stripped = raw
94 .split("\n")
95 .filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*"))
96 .join("\n");
97 expect(stripped.length).toBeGreaterThan(0);
98 return stripped;
99}
100
101describe("the dashboard shows computed open issues", () => {
102 const src = source("routes", "dashboard.tsx");
103
104 it("calls totalOpenIssues", () => {
105 expect(src).toContain("totalOpenIssues(");
106 });
107
108 it("no longer sums the stored counter into the stat card", () => {
109 // The exact expression that produced the wrong number.
110 expect(src).not.toContain("value={String(repos.reduce((s, r) => s + r.issueCount, 0))}");
111 });
112
113 it("still labels the card Open Issues, so the fix targets the right stat", () => {
114 // Guards against the assertion above passing because the card was
115 // renamed or deleted rather than corrected.
116 expect(src).toContain('label="Open Issues"');
117 expect(src).toContain("value={String(openIssuesTotal)}");
118 });
119});
120
121describe("the REST repo listing serves computed open issues", () => {
122 const src = source("routes", "api.ts");
123
124 it("calls openIssueCountsByRepo", () => {
125 expect(src).toContain("openIssueCountsByRepo(");
126 });
127
128 it("overwrites issueCount on the serialized rows", () => {
129 expect(src).toContain("row.issueCount = counts.get(");
130 });
131
132 it("keeps serving the field when the count query fails", () => {
133 // Dropping the key would change the documented response shape; a stale
134 // number is better than a missing one.
135 const at = src.indexOf("openIssueCountsByRepo(");
136 expect(at).toBeGreaterThan(-1);
137 const after = src.slice(at, at + 600);
138 expect(after.length).toBeGreaterThan(0);
139 expect(after).toContain("catch");
140 });
141});
Addedsrc/lib/issue-counts.ts+102−0View fileUnifiedSplit
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
30import { and, count, eq, inArray } from "drizzle-orm";
31import { db } from "../db";
32import { 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 */
41export 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 */
66export 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 */
86export 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}
Modifiedsrc/routes/api.ts+20−0View fileUnifiedSplit
1111import { hashPassword } from "../lib/auth";
1212import { orgRoleAtLeast } from "../lib/orgs";
1313import { renderMarkdown } from "../lib/markdown";
14import { openIssueCountsByRepo } from "../lib/issue-counts";
1415
1516// Typed with AuthEnv so handlers can read the viewer that the global
1617// `softAuth` middleware already resolved (needed for private-repo gating).
217218 // diskPath leaks the server's storage layout — never serialize it.
218219 const safe = visible.map(({ diskPath: _diskPath, ...rest }) => rest);
219220
221 // `issueCount` is the stored repositories.issue_count column, which is
222 // incremented in eight places and decremented in none — closing an issue
223 // never touches it — so it is an issues-ever-created total, not an open
224 // count. The API docs list it beside starCount and forkCount, where a
225 // consumer reads it the way GitHub's open_issues_count reads. Serve the
226 // real number. Failure leaves the stored value rather than dropping the
227 // field, so the response shape never changes.
228 try {
229 const counts = await openIssueCountsByRepo(safe.map((r) => r.id));
230 for (const row of safe) {
231 row.issueCount = counts.get(String(row.id)) ?? 0;
232 }
233 } catch (err) {
234 console.warn(
235 "[api] open issue counts failed, serving stored issueCount:",
236 err instanceof Error ? err.message : err
237 );
238 }
239
220240 return c.json(safe);
221241 } catch (err) {
222242 console.error("[api] /users/:username/repos:", err);
Modifiedsrc/routes/dashboard.tsx+12−1View fileUnifiedSplit
4747 type AiSavingsReport,
4848 type AiSavingsLifetimeReport,
4949} from "../lib/ai-hours-saved";
50import { totalOpenIssues } from "../lib/issue-counts";
5051
5152// ─── AI Activity — Last Hour ─────────────────────────────────────────────────
5253
311312 .where(eq(repositories.ownerId, user.id))
312313 .orderBy(desc(repositories.updatedAt));
313314
315 // "Open Issues" below used to sum repositories.issueCount, which is
316 // incremented in eight places and decremented in none — closing an issue
317 // never touches it. It is an issues-ever-created total, so the card was
318 // showing a number that could only ever go up. Count the open rows
319 // instead; see lib/issue-counts.ts.
320 const openIssuesTotal = await totalOpenIssues(
321 repos.map((r) => r.id),
322 repos.reduce((s, r) => s + (r.issueCount || 0), 0)
323 );
324
314325 // Compute health scores for all repos (in parallel)
315326 const repoData = await Promise.all(
316327 repos.map(async (repo) => {
727738 />
728739 <StatBox
729740 label="Open Issues"
730 value={String(repos.reduce((s, r) => s + r.issueCount, 0))}
741 value={String(openIssuesTotal)}
731742 color="var(--red)"
732743 />
733744 </div>
734745