Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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-sweepgatetest/auto-fix-1776586424172gatetest/auto-fix-1776586534814gatetest/auto-fix-1776590685143gatetest/auto-fix-1776590808199mainops/redeploy-retriggerstyle/dxt-cta-themeworktree-agent-a3377aad30d55da26worktree-agent-a7ef607b7ee1d6c74
issue-similarity.test.ts9.7 KB · 296 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
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
/**
 * Block J28 — Issue title similarity tests. Pure ranker + route smoke tests.
 */

import { describe, it, expect } from "bun:test";
import {
  MIN_TOKEN_LENGTH,
  STOPWORDS,
  DEFAULT_MIN_SCORE,
  DEFAULT_LIMIT,
  tokeniseTitle,
  jaccard,
  rankCandidates,
  findSimilar,
  formatSimilarityPercent,
  __internal,
  type SimilarityCandidate,
} from "../lib/issue-similarity";

describe("issue-similarity — tokeniseTitle", () => {
  it("empty / non-string input returns empty set", () => {
    expect(tokeniseTitle("")).toEqual(new Set());
    expect(tokeniseTitle(null)).toEqual(new Set());
    expect(tokeniseTitle(undefined)).toEqual(new Set());
    expect(tokeniseTitle(42)).toEqual(new Set());
    expect(tokeniseTitle({})).toEqual(new Set());
  });

  it("lowercases and splits on whitespace", () => {
    const t = tokeniseTitle("Add Auth To API");
    expect(t.has("add")).toBe(true);
    expect(t.has("auth")).toBe(true);
    expect(t.has("api")).toBe(true);
    // "to" is a stopword
    expect(t.has("to")).toBe(false);
  });

  it("strips punctuation", () => {
    const t = tokeniseTitle("fix: crash on window.resize()");
    expect(t.has("fix")).toBe(true);
    expect(t.has("crash")).toBe(true);
    expect(t.has("window")).toBe(true);
    expect(t.has("resize")).toBe(true);
  });

  it("drops stopwords", () => {
    const t = tokeniseTitle("the quick and the dead");
    expect(t.has("the")).toBe(false);
    expect(t.has("and")).toBe(false);
    expect(t.has("quick")).toBe(true);
    expect(t.has("dead")).toBe(true);
  });

  it("drops tokens shorter than MIN_TOKEN_LENGTH", () => {
    const t = tokeniseTitle("x y z hello");
    expect(t.has("x")).toBe(false);
    expect(t.has("y")).toBe(false);
    expect(t.has("z")).toBe(false);
    expect(t.has("hello")).toBe(true);
  });

  it("handles Unicode letters", () => {
    const t = tokeniseTitle("café français resumé");
    expect(t.has("café")).toBe(true);
    expect(t.has("français")).toBe(true);
    expect(t.has("resumé")).toBe(true);
  });

  it("dedups via Set", () => {
    const t = tokeniseTitle("crash crash crash boom crash");
    expect(t.size).toBe(2);
    expect(t.has("crash")).toBe(true);
    expect(t.has("boom")).toBe(true);
  });

  it("preserves hyphens + underscores inside tokens", () => {
    const t = tokeniseTitle("check-ref-format cors_policy");
    expect(t.has("check-ref-format")).toBe(true);
    expect(t.has("cors_policy")).toBe(true);
  });

  it("retains digits", () => {
    const t = tokeniseTitle("support HTTP2 and IPv6");
    expect(t.has("http2")).toBe(true);
    expect(t.has("ipv6")).toBe(true);
  });
});

describe("issue-similarity — jaccard", () => {
  it("returns 0 for two empty sets", () => {
    expect(jaccard(new Set(), new Set())).toBe(0);
  });
  it("returns 1 for identical sets", () => {
    expect(jaccard(new Set(["a", "b"]), new Set(["a", "b"]))).toBe(1);
  });
  it("returns 0 for disjoint sets", () => {
    expect(jaccard(new Set(["a"]), new Set(["b"]))).toBe(0);
  });
  it("half overlap is 1/3", () => {
    // {a,b} ∩ {b,c} = {b}; union = {a,b,c}; 1/3
    expect(jaccard(new Set(["a", "b"]), new Set(["b", "c"]))).toBeCloseTo(
      1 / 3,
      6
    );
  });
  it("subset: {a} ⊂ {a,b} → 1/2", () => {
    expect(jaccard(new Set(["a"]), new Set(["a", "b"]))).toBe(0.5);
  });
  it("order of arguments doesn't matter", () => {
    const a = new Set(["x", "y", "z"]);
    const b = new Set(["y", "z", "w"]);
    expect(jaccard(a, b)).toBe(jaccard(b, a));
  });
});

describe("issue-similarity — rankCandidates", () => {
  const candidates: SimilarityCandidate[] = [
    { id: "1", number: 1, title: "Crash on startup", state: "open" },
    { id: "2", number: 2, title: "Application crashes on boot", state: "open" },
    { id: "3", number: 3, title: "Dark mode toggle", state: "open" },
    { id: "4", number: 4, title: "Crash on startup with fresh install", state: "closed" },
    { id: "5", number: 5, title: "", state: "open" },
  ];

  it("empty title returns empty", () => {
    expect(rankCandidates("", candidates)).toEqual([]);
  });

  it("ranks by score descending", () => {
    const r = rankCandidates("crash on startup", candidates);
    expect(r.length).toBeGreaterThan(0);
    // #1 is an exact token-match → score 1.0
    expect(r[0]!.id).toBe("1");
    expect(r[0]!.score).toBe(1);
  });

  it("minScore drops weak matches", () => {
    const r = rankCandidates("dark", candidates, { minScore: 0.5 });
    // "dark" matches "Dark mode toggle" with 1/3 only → under threshold
    expect(r.length).toBe(0);
  });

  it("limit caps the result count", () => {
    const r = rankCandidates("crash startup", candidates, {
      limit: 1,
      minScore: 0,
    });
    expect(r).toHaveLength(1);
  });

  it("excludeId skips a candidate by primary key", () => {
    const r = rankCandidates("crash on startup", candidates, {
      excludeId: "1",
      minScore: 0,
    });
    expect(r.some((x) => x.id === "1")).toBe(false);
  });

  it("excludeNumber skips a candidate by issue number", () => {
    const r = rankCandidates("crash on startup", candidates, {
      excludeNumber: 1,
      minScore: 0,
    });
    expect(r.some((x) => x.number === 1)).toBe(false);
  });

  it("state filter restricts candidates", () => {
    const r = rankCandidates("crash startup", candidates, {
      state: "open",
      minScore: 0,
    });
    expect(r.every((x) => x.state === "open")).toBe(true);
  });

  it("ignores candidates whose title yields no tokens", () => {
    const r = rankCandidates("crash", candidates, { minScore: 0 });
    expect(r.some((x) => x.id === "5")).toBe(false);
  });

  it("tie-breaks by createdAt desc", () => {
    const list: SimilarityCandidate[] = [
      { id: "old", number: 10, title: "foo bar", createdAt: "2025-01-01" },
      { id: "new", number: 20, title: "foo bar", createdAt: "2025-06-01" },
    ];
    const r = rankCandidates("foo bar", list, { minScore: 0 });
    expect(r[0]!.id).toBe("new");
    expect(r[1]!.id).toBe("old");
  });

  it("falls back to number-desc when createdAt equal", () => {
    const list: SimilarityCandidate[] = [
      { id: "a", number: 10, title: "foo bar" },
      { id: "b", number: 20, title: "foo bar" },
    ];
    const r = rankCandidates("foo bar", list, { minScore: 0 });
    expect(r[0]!.number).toBe(20);
  });

  it("limit=0 short-circuits to empty", () => {
    const r = rankCandidates("crash", candidates, { limit: 0, minScore: 0 });
    expect(r).toEqual([]);
  });

  it("never mutates the candidate list", () => {
    const snap = candidates.map((c) => c.id).join(",");
    rankCandidates("crash startup", candidates);
    expect(candidates.map((c) => c.id).join(",")).toBe(snap);
  });

  it("stopword-only title returns empty", () => {
    const r = rankCandidates("the and or", candidates);
    expect(r).toEqual([]);
  });

  it("uses DEFAULT_MIN_SCORE + DEFAULT_LIMIT when opts omitted", () => {
    expect(DEFAULT_MIN_SCORE).toBe(0.15);
    expect(DEFAULT_LIMIT).toBe(5);
    const r = rankCandidates("crash on startup", candidates);
    expect(r.length).toBeLessThanOrEqual(5);
    for (const item of r) expect(item.score).toBeGreaterThanOrEqual(0.15);
  });
});

describe("issue-similarity — findSimilar", () => {
  it("alias of rankCandidates", () => {
    const cs: SimilarityCandidate[] = [
      { id: "x", number: 1, title: "hello world" },
    ];
    expect(findSimilar("hello", cs, { minScore: 0 })).toEqual(
      rankCandidates("hello", cs, { minScore: 0 })
    );
  });
});

describe("issue-similarity — formatSimilarityPercent", () => {
  it("formats with percent suffix", () => {
    expect(formatSimilarityPercent(0)).toBe("0%");
    expect(formatSimilarityPercent(0.5)).toBe("50%");
    expect(formatSimilarityPercent(1)).toBe("100%");
  });
  it("rounds half-up", () => {
    expect(formatSimilarityPercent(0.456)).toBe("46%");
    expect(formatSimilarityPercent(0.454)).toBe("45%");
  });
  it("clamps out-of-range to [0,100]", () => {
    expect(formatSimilarityPercent(-0.5)).toBe("0%");
    expect(formatSimilarityPercent(2)).toBe("100%");
  });
  it("non-finite → 0%", () => {
    expect(formatSimilarityPercent(Number.NaN)).toBe("0%");
    expect(formatSimilarityPercent(Number.POSITIVE_INFINITY)).toBe("0%");
  });
});

describe("issue-similarity — constants", () => {
  it("MIN_TOKEN_LENGTH default", () => {
    expect(MIN_TOKEN_LENGTH).toBe(2);
  });
  it("STOPWORDS contains common fillers", () => {
    for (const w of ["the", "and", "is", "it", "for"]) {
      expect(STOPWORDS.has(w)).toBe(true);
    }
    expect(STOPWORDS.has("crash")).toBe(false);
  });
});

describe("issue-similarity — routes", () => {
  it("GET /:o/:r/issues/similar.json is guarded (never 500)", async () => {
    const { default: app } = await import("../app");
    const res = await app.request(
      "/alice/repo/issues/similar.json?q=crash+startup"
    );
    expect([200, 400, 404]).toContain(res.status);
  });

  it("GET /:o/:r/issues/:n/similar is guarded (never 500)", async () => {
    const { default: app } = await import("../app");
    const res = await app.request("/alice/repo/issues/1/similar");
    expect([200, 404]).toContain(res.status);
  });
});

describe("issue-similarity — __internal parity", () => {
  it("re-exports helpers", () => {
    expect(__internal.tokeniseTitle).toBe(tokeniseTitle);
    expect(__internal.jaccard).toBe(jaccard);
    expect(__internal.rankCandidates).toBe(rankCandidates);
    expect(__internal.findSimilar).toBe(findSimilar);
    expect(__internal.formatSimilarityPercent).toBe(formatSimilarityPercent);
    expect(__internal.MIN_TOKEN_LENGTH).toBe(MIN_TOKEN_LENGTH);
    expect(__internal.STOPWORDS).toBe(STOPWORDS);
    expect(__internal.DEFAULT_MIN_SCORE).toBe(DEFAULT_MIN_SCORE);
    expect(__internal.DEFAULT_LIMIT).toBe(DEFAULT_LIMIT);
    expect(typeof __internal.toTime).toBe("function");
  });
});