Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
claude/adoring-hopper-5x74bqclaude/affectionate-feynman-ykrf1hclaude/architecture-audit-design-wxprenclaude/build-status-update-3MXsfclaude/charming-meitner-mllb5rclaude/compare-gate-gluecron-s4mFQclaude/confident-faraday-tikcwbclaude/continue-work-XMTlIclaude/crontech-gluecron-deploy-7MIECclaude/crontech-platform-setup-SeKfwclaude/design-2026claude/ecstatic-ptolemy-jMdigclaude/enhance-github-integration-QNHdGclaude/fix-aa-loop-issue-PonMQclaude/fix-actions-and-processclaude/fix-desktop-errors-XqoW8claude/fix-red-workflowsclaude/fix-website-access-6FKJNclaude/gatetest-integration-hardeningclaude/github-audit-improvements-bDFr9claude/gluecron-launch-status-FoMRlclaude/hopeful-lamport-olfCTclaude/issue-to-pr-and-protectionsclaude/jolly-heisenberg-2sg1Qclaude/launch-preparation-QmTb6claude/new-session-xk1l7claude/plan-platform-architecture-kkN4yclaude/platform-analysis-roadmap-1nUGLclaude/platform-launch-assessment-8dWV8claude/polish-platform-release-AeDrUclaude/resume-previous-work-KzyLwclaude/review-crontech-handoff-qYEVqclaude/review-project-completeness-lHhS2claude/review-readme-docs-ulqPKclaude/serene-edison-rj87weclaude/setup-multi-repo-dev-BCwNQclaude/ship-fixes-and-tests-Jvz1cclaude/site-audit-competitive-pctlwgclaude/site-migration-vercel-XstpKclaude/standalone-product-repos-XHFTDcopilot/feat-smart-empty-states-keyboard-first-enhancementcopilot/feat-smart-morning-digest-review-context-restorecopilot/fix-and-process-workflowscopilot/update-ai-powered-code-reviewfeat/debt-mapfeat/push-policy-codeowners-hardeningfeat/smart-digest-contextfeat/stage-impactfeat/t1-secret-migrationfeat/u-polishfeat/w-self-hostfeat/w2-claude-configfix/agent-journey-orphan-sweepfix/audit-sweep-2026-07-26gatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
issue-counts.test.ts5.2 KB · 141 lines
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
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
/**
 * Regression guard: the dashboard's "Open Issues" figure must reflect issues
 * that are actually open.
 *
 * 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, and the dashboard
 * rendered it under the label "Open Issues": close every issue in every
 * repository and the number 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 as GitHub's open_issues_count does.
 *
 * Fixed by computing at read time instead of maintaining the counter. The
 * counter has eight independent write sites and has already drifted; making
 * it correct 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 cannot skew.
 *
 * The stored column stays and is still written — it is part of the
 * documented REST response shape, so removing it would be a contract change
 * rather than a bug fix.
 */

import { describe, it, expect } from "bun:test";
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { countRowsToMap } from "../lib/issue-counts";

const REPO_A = "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa";
const REPO_B = "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb";

describe("countRowsToMap", () => {
  it("coerces bigint counts that arrive as strings", () => {
    // Postgres count() is bigint and the driver returns it as a STRING. This
    // is the whole hazard: drizzle's sql<number> is a compile-time cast only.
    const map = countRowsToMap([
      { repositoryId: REPO_A, n: "3" },
      { repositoryId: REPO_B, n: "5" },
    ]);
    expect(map.get(REPO_A)).toBe(3);
    expect(map.get(REPO_B)).toBe(5);
  });

  it("produces totals that ADD rather than concatenate", () => {
    // The failure mode if the coercion is dropped: "3" + "5" === "35".
    const map = countRowsToMap([
      { repositoryId: REPO_A, n: "3" },
      { repositoryId: REPO_B, n: "5" },
    ]);
    let total = 0;
    for (const n of map.values()) total += n;
    expect(total).toBe(8);
    expect(total).not.toBe("35");
  });

  it("accepts counts that already arrive as numbers", () => {
    const map = countRowsToMap([{ repositoryId: REPO_A, n: 7 }]);
    expect(map.get(REPO_A)).toBe(7);
  });

  it("never yields NaN, which would poison a sum", () => {
    const map = countRowsToMap([
      { repositoryId: REPO_A, n: null },
      { repositoryId: REPO_B, n: "not-a-number" },
    ]);
    expect(map.get(REPO_A)).toBe(0);
    expect(map.get(REPO_B)).toBe(0);
    let total = 0;
    for (const n of map.values()) total += n;
    expect(Number.isNaN(total)).toBe(false);
    expect(total).toBe(0);
  });

  it("skips rows with no repository", () => {
    const map = countRowsToMap([{ repositoryId: null, n: "4" }]);
    expect(map.size).toBe(0);
  });

  it("returns an empty map for no rows", () => {
    expect(countRowsToMap([]).size).toBe(0);
  });
});

// --- the readers must use the computed value -------------------------------
//
// Assert the CALL form, not the bare identifier: `void totalOpenIssues;`
// mentions it while computing nothing. Strip ONLY line comments.

function source(...rel: string[]): string {
  const raw = readFileSync(join(import.meta.dir, "..", ...rel), "utf8");
  const stripped = raw
    .split("\n")
    .filter((l) => !l.trim().startsWith("//") && !l.trim().startsWith("*"))
    .join("\n");
  expect(stripped.length).toBeGreaterThan(0);
  return stripped;
}

describe("the dashboard shows computed open issues", () => {
  const src = source("routes", "dashboard.tsx");

  it("calls totalOpenIssues", () => {
    expect(src).toContain("totalOpenIssues(");
  });

  it("no longer sums the stored counter into the stat card", () => {
    // The exact expression that produced the wrong number.
    expect(src).not.toContain("value={String(repos.reduce((s, r) => s + r.issueCount, 0))}");
  });

  it("still labels the card Open Issues, so the fix targets the right stat", () => {
    // Guards against the assertion above passing because the card was
    // renamed or deleted rather than corrected.
    expect(src).toContain('label="Open Issues"');
    expect(src).toContain("value={String(openIssuesTotal)}");
  });
});

describe("the REST repo listing serves computed open issues", () => {
  const src = source("routes", "api.ts");

  it("calls openIssueCountsByRepo", () => {
    expect(src).toContain("openIssueCountsByRepo(");
  });

  it("overwrites issueCount on the serialized rows", () => {
    expect(src).toContain("row.issueCount = counts.get(");
  });

  it("keeps serving the field when the count query fails", () => {
    // Dropping the key would change the documented response shape; a stale
    // number is better than a missing one.
    const at = src.indexOf("openIssueCountsByRepo(");
    expect(at).toBeGreaterThan(-1);
    const after = src.slice(at, at + 600);
    expect(after.length).toBeGreaterThan(0);
    expect(after).toContain("catch");
  });
});