Commitb184a75unknown_key
feat(BLOCK-J): J28 issue duplicate suggestions
feat(BLOCK-J): J28 issue duplicate suggestions
- src/lib/issue-similarity.ts pure token-Jaccard ranker: tokeniseTitle
(Unicode \p{L}\p{N}_- strip, stopwords, MIN_TOKEN_LENGTH=2), jaccard
(iterates smaller set), rankCandidates (minScore/limit/state/excludeId/
excludeNumber with score-desc + createdAt-desc + number-desc tie-breaks),
findSimilar alias, formatSimilarityPercent
- src/routes/issue-similarity.tsx:
- GET /:o/:r/issues/similar.json?q=<title> JSON endpoint
(limit default 5, MAX_RESULT_LIMIT 20, CANDIDATE_LIMIT 500)
- GET /:o/:r/issues/:n/similar HTML "related issues" page
softAuth; private repos 404 for non-owner viewers; DB errors degrade
- Mounted before issueRoutes so /issues/similar.json wins over /issues/:number
- 39 new tests: tokeniseTitle (empty, lowercase, punctuation, stopwords,
min-len, Unicode, dedup, hyphens, digits), jaccard (edge cases +
symmetry), rankCandidates (empty title, minScore, limit, excludeId/
excludeNumber, state filter, tie-breaks, non-mutation, stopword-only),
findSimilar alias, formatSimilarityPercent (rounding, clamp, NaN),
STOPWORDS/constants, route smoke tests
- Full suite: 1283 -> 1322 pass5 files changed+836−1b184a75411690a73a6656389d6037733b692dab3
5 changed files+836−1
ModifiedBUILD_BIBLE.md+4−1View fileUnifiedSplit
@@ -149,6 +149,7 @@ Legend: ✅ shipped · 🟡 partial · ❌ not built
149149| Time-to-first-response metric | ✅ | J25 — `GET /:owner/:repo/insights/response-time[?window=7\|30\|90\|365\|0]` renders p50/mean/p90/fastest/slowest response times, a four-bucket distribution (≤1h, 1h–1d, 1d–1w, >1w), and the oldest-first list of open issues still waiting for a reply. Pure `computeTimeToFirstResponse` (ignores comments by the issue author, clamps negative deltas, skips unparseable dates), `computeIssueStats` (window filter), `summariseResponseTimes` (inclusive-method percentiles), `bucketResponseTimes`, `formatDuration` (`ms`/`s`/`m`/`h m`/`d h`), `buildResponseReport` one-shot, `parseWindow` (allow-list `VALID_WINDOWS`) in `src/lib/response-time.ts`. Linked from the Insights page header. softAuth; private repos 404 for non-owner viewers; DB failure degrades to empty report — never 500. |
150150| Audit log CSV export | ✅ | J26 — `GET /settings/audit.csv` + `GET /:owner/:repo/settings/audit.csv` stream the audit log as RFC 4180 CSV with `Content-Disposition: attachment`. Pure `csvCell` / `csvRow` / `csvDocument` / `formatAuditCsv` / `auditCsvFilename` helpers in `src/lib/audit-csv.ts` implement CRLF termination, `"…"` wrapping + `""` escaping when cells contain `,"\n\r`, and a CSV-injection guard that prefixes any cell starting with `=+-@\t\r` with a single-quote so spreadsheet formula engines don't evaluate attacker-supplied content. Header row is `id,when,actor,action,targetType,targetId,ip,userAgent,metadata`. Same visibility rules as the HTML pages (requireAuth + owner-only for repo variant). Filename is `audit-<scope>-<ISO>.csv`. `Cache-Control: private, no-store`. |
151151| Branch staleness / age report | ✅ | J27 — `GET /:owner/:repo/branches/age[?threshold=0\|30\|60\|90\|180][&sort=age-desc\|age-asc\|name\|ahead-desc\|behind-desc]` walks every branch, fetches the tip commit + ahead/behind counts vs the default branch via `aheadBehind(base, head)` (new `git rev-list --left-right --count` helper in `src/git/repository.ts`), and renders KPI cards (total / non-default / merged / unmerged / median age / oldest), a four-bucket distribution (Fresh <30d, Aging 30–59d, Stale 60–89d, Abandoned ≥90d or missing tip), and a branch table with ahead/behind/last-commit/status columns. Pure `parseThreshold` / `parseSort` / `computeDaysOld` / `classifyBranchAge` / `computeBranchRow` (merged = !default && ahead=0) / `bucketBranches` / `filterByThreshold` / `summariseBranches` (avg + median, excludes default) / `sortBranchRows` (non-mutating, null daysOld sinks) / `buildBranchReport` / `categoryLabel` / `thresholdLabel` / `sortLabel` in `src/lib/branch-age.ts`. softAuth; private repos 404 for non-owner viewers; git failures degrade to empty report — never 500. Linked from repo settings. |
152| Issue duplicate suggestions | ✅ | J28 — `GET /:owner/:repo/issues/similar.json?q=<title>[&limit][&state=open\|closed]` returns ranked matches as JSON for new-issue-form inline suggestions. `GET /:owner/:repo/issues/:n/similar` is a standalone HTML page ranking related issues against the target title. Pure token-Jaccard ranker in `src/lib/issue-similarity.ts`: `tokeniseTitle` lowercases + strips non-`\p{L}\p{N}_-` + drops stopwords + drops tokens <2 chars; `jaccard` is Unicode-safe `|A∩B| / |A∪B|`; `rankCandidates` sorts score-desc with createdAt-desc + number-desc tie-breaks, honours `minScore` (default 0.15) / `limit` (default 5) / `excludeId` / `excludeNumber` / `state`. `formatSimilarityPercent` clamps to [0,1] and emits `"47%"`. Candidates capped at last 500 issues per repo. softAuth; private repos 404 for non-owner viewers. |
152153| GitHub Actions equivalent (workflow runner) | ✅ | `src/lib/workflow-parser.ts`, `src/lib/workflow-runner.ts`, `src/routes/workflows.tsx`; `.gluecron/workflows/*.yml` auto-discovered on push; Bun subprocess executor, per-step timeouts, size-capped logs |
153154| Dependabot equivalent (AI dep bumper) | ✅ | D2 — `dep_update_runs` table, npm registry fetch, plan + apply bumps, creates `gluecron/dep-update-*` branch + PR row via git plumbing. `src/lib/dep-updater.ts`, `src/routes/dep-updater.tsx`, settings UI at `/:owner/:repo/settings/dep-updater`. |
154155| Code scanning UI | ✅ | I5 — `src/routes/code-scanning.tsx`, `GET /:owner/:repo/security`. Aggregates last-100 `gate_runs` matching `%scan%`/`%security%`, rolls up latest status per gate, shows failed/repaired/total cards + scanner status list + recent runs. |
@@ -543,6 +544,8 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
543544- `src/lib/branch-age.ts` (Block J27) — pure branch-staleness helpers. `DAY_MS`, `VALID_THRESHOLDS = [0,30,60,90,180]`, `VALID_SORTS = [age-desc,age-asc,name,ahead-desc,behind-desc]`, `parseThreshold` + `parseSort` (allow-list validators). `computeDaysOld(tipDate, now)` returns floor-days or null for missing/unparseable (future timestamps clamp to 0). `classifyBranchAge(days)` four-bucket taxonomy (`fresh` <30d, `aging` 30–59, `stale` 60–89, `abandoned` ≥90 or null). `computeBranchRow(input, now)` folds the classification in + sets `merged = !isDefault && ahead===0`. `bucketBranches` excludes default. `filterByThreshold(rows, t)` drops default + null-daysOld when `t>0`. `summariseBranches` computes `total / nonDefault / merged / unmerged / withoutTip / oldestName / oldestDaysOld / averageAgeDays / medianAgeDays` (default excluded from everything). `sortBranchRows` is non-mutating, name-tiebreak stable, null daysOld sinks to bottom. `buildBranchReport` one-shot. `categoryLabel` / `thresholdLabel` / `sortLabel` UI copy. `__internal` re-exports.
544545- `src/routes/branch-age.tsx` (Block J27) — serves `GET /:owner/:repo/branches/age`. softAuth; private repos 404 for non-owner viewers. Fetches `listBranches`, `getDefaultBranch`, and per-branch `getCommit` + `aheadBehind(defaultBranch, branch)`. Rows with git failures drop to null tipDate (category becomes `abandoned`) — never crashes. Renders six KPI cards, four bucket cards with age-colour borders, and a branches table with Ahead / Behind / Last-commit-age / Status columns. Threshold + sort selectors auto-submit. Linked from repo settings under the default-branch select.
545546- `src/git/repository.ts` (Block J27 additions) — `aheadBehind(owner, name, base, head)` runs `git rev-list --left-right --count <base>...<head>`. Parses `<behind>\t<ahead>` pair. Returns null on exit failure, non-two-part output, or non-finite numbers. Non-cached — callers fan out per-branch at request time.
547- `src/lib/issue-similarity.ts` (Block J28) — pure duplicate-suggestion ranker. `MIN_TOKEN_LENGTH=2`, `STOPWORDS` (~50 common English fillers), `DEFAULT_MIN_SCORE=0.15`, `DEFAULT_LIMIT=5`. `tokeniseTitle(s)` is Unicode-safe via `\p{L}\p{N}_-`, returns a deduped `Set`. `jaccard(a,b)` iterates the smaller set for efficiency; returns 0 for empty-vs-empty. `rankCandidates(title, candidates, opts)` filters by `minScore` / `state`, excludes by `excludeId` + `excludeNumber`, sorts score-desc with `createdAt`-desc + `number`-desc tie-breaks, slices to `limit`. Never mutates input. `findSimilar` is an alias. `formatSimilarityPercent(s)` clamps + rounds to whole-percent string. `__internal` re-exports.
548- `src/routes/issue-similarity.tsx` (Block J28) — serves `GET /:owner/:repo/issues/similar.json` (JSON suggestions, up to `MAX_RESULT_LIMIT=20`) and `GET /:owner/:repo/issues/:number/similar` (HTML "related issues" page). Fetches up to `CANDIDATE_LIMIT=500` issues ordered by createdAt-desc. softAuth; private repos 404 for non-owner viewers; DB errors → empty candidates → empty matches. Must be mounted before `issueRoutes` so `/issues/similar.json` doesn't get eaten by `/issues/:number`.
546549
547550### 4.7 Views (locked contracts)
548551- `src/views/layout.tsx` — `Layout` accepts `title`, `user`, `notificationCount`
@@ -577,7 +580,7 @@ Everything below is committed, tested, and load-bearing. **Do not delete, rename
577580```bash
578581bun install
579582bun dev # hot reload
580bun test # 1283 tests currently pass
583bun test # 1322 tests currently pass
581584bun run db:migrate
582585```
583586
Addedsrc/__tests__/issue-similarity.test.ts+296−0View fileUnifiedSplit
@@ -0,0 +1,296 @@
1/**
2 * Block J28 — Issue title similarity tests. Pure ranker + route smoke tests.
3 */
4
5import { describe, it, expect } from "bun:test";
6import {
7 MIN_TOKEN_LENGTH,
8 STOPWORDS,
9 DEFAULT_MIN_SCORE,
10 DEFAULT_LIMIT,
11 tokeniseTitle,
12 jaccard,
13 rankCandidates,
14 findSimilar,
15 formatSimilarityPercent,
16 __internal,
17 type SimilarityCandidate,
18} from "../lib/issue-similarity";
19
20describe("issue-similarity — tokeniseTitle", () => {
21 it("empty / non-string input returns empty set", () => {
22 expect(tokeniseTitle("")).toEqual(new Set());
23 expect(tokeniseTitle(null)).toEqual(new Set());
24 expect(tokeniseTitle(undefined)).toEqual(new Set());
25 expect(tokeniseTitle(42)).toEqual(new Set());
26 expect(tokeniseTitle({})).toEqual(new Set());
27 });
28
29 it("lowercases and splits on whitespace", () => {
30 const t = tokeniseTitle("Add Auth To API");
31 expect(t.has("add")).toBe(true);
32 expect(t.has("auth")).toBe(true);
33 expect(t.has("api")).toBe(true);
34 // "to" is a stopword
35 expect(t.has("to")).toBe(false);
36 });
37
38 it("strips punctuation", () => {
39 const t = tokeniseTitle("fix: crash on window.resize()");
40 expect(t.has("fix")).toBe(true);
41 expect(t.has("crash")).toBe(true);
42 expect(t.has("window")).toBe(true);
43 expect(t.has("resize")).toBe(true);
44 });
45
46 it("drops stopwords", () => {
47 const t = tokeniseTitle("the quick and the dead");
48 expect(t.has("the")).toBe(false);
49 expect(t.has("and")).toBe(false);
50 expect(t.has("quick")).toBe(true);
51 expect(t.has("dead")).toBe(true);
52 });
53
54 it("drops tokens shorter than MIN_TOKEN_LENGTH", () => {
55 const t = tokeniseTitle("x y z hello");
56 expect(t.has("x")).toBe(false);
57 expect(t.has("y")).toBe(false);
58 expect(t.has("z")).toBe(false);
59 expect(t.has("hello")).toBe(true);
60 });
61
62 it("handles Unicode letters", () => {
63 const t = tokeniseTitle("café français resumé");
64 expect(t.has("café")).toBe(true);
65 expect(t.has("français")).toBe(true);
66 expect(t.has("resumé")).toBe(true);
67 });
68
69 it("dedups via Set", () => {
70 const t = tokeniseTitle("crash crash crash boom crash");
71 expect(t.size).toBe(2);
72 expect(t.has("crash")).toBe(true);
73 expect(t.has("boom")).toBe(true);
74 });
75
76 it("preserves hyphens + underscores inside tokens", () => {
77 const t = tokeniseTitle("check-ref-format cors_policy");
78 expect(t.has("check-ref-format")).toBe(true);
79 expect(t.has("cors_policy")).toBe(true);
80 });
81
82 it("retains digits", () => {
83 const t = tokeniseTitle("support HTTP2 and IPv6");
84 expect(t.has("http2")).toBe(true);
85 expect(t.has("ipv6")).toBe(true);
86 });
87});
88
89describe("issue-similarity — jaccard", () => {
90 it("returns 0 for two empty sets", () => {
91 expect(jaccard(new Set(), new Set())).toBe(0);
92 });
93 it("returns 1 for identical sets", () => {
94 expect(jaccard(new Set(["a", "b"]), new Set(["a", "b"]))).toBe(1);
95 });
96 it("returns 0 for disjoint sets", () => {
97 expect(jaccard(new Set(["a"]), new Set(["b"]))).toBe(0);
98 });
99 it("half overlap is 1/3", () => {
100 // {a,b} ∩ {b,c} = {b}; union = {a,b,c}; 1/3
101 expect(jaccard(new Set(["a", "b"]), new Set(["b", "c"]))).toBeCloseTo(
102 1 / 3,
103 6
104 );
105 });
106 it("subset: {a} ⊂ {a,b} → 1/2", () => {
107 expect(jaccard(new Set(["a"]), new Set(["a", "b"]))).toBe(0.5);
108 });
109 it("order of arguments doesn't matter", () => {
110 const a = new Set(["x", "y", "z"]);
111 const b = new Set(["y", "z", "w"]);
112 expect(jaccard(a, b)).toBe(jaccard(b, a));
113 });
114});
115
116describe("issue-similarity — rankCandidates", () => {
117 const candidates: SimilarityCandidate[] = [
118 { id: "1", number: 1, title: "Crash on startup", state: "open" },
119 { id: "2", number: 2, title: "Application crashes on boot", state: "open" },
120 { id: "3", number: 3, title: "Dark mode toggle", state: "open" },
121 { id: "4", number: 4, title: "Crash on startup with fresh install", state: "closed" },
122 { id: "5", number: 5, title: "", state: "open" },
123 ];
124
125 it("empty title returns empty", () => {
126 expect(rankCandidates("", candidates)).toEqual([]);
127 });
128
129 it("ranks by score descending", () => {
130 const r = rankCandidates("crash on startup", candidates);
131 expect(r.length).toBeGreaterThan(0);
132 // #1 is an exact token-match → score 1.0
133 expect(r[0]!.id).toBe("1");
134 expect(r[0]!.score).toBe(1);
135 });
136
137 it("minScore drops weak matches", () => {
138 const r = rankCandidates("dark", candidates, { minScore: 0.5 });
139 // "dark" matches "Dark mode toggle" with 1/3 only → under threshold
140 expect(r.length).toBe(0);
141 });
142
143 it("limit caps the result count", () => {
144 const r = rankCandidates("crash startup", candidates, {
145 limit: 1,
146 minScore: 0,
147 });
148 expect(r).toHaveLength(1);
149 });
150
151 it("excludeId skips a candidate by primary key", () => {
152 const r = rankCandidates("crash on startup", candidates, {
153 excludeId: "1",
154 minScore: 0,
155 });
156 expect(r.some((x) => x.id === "1")).toBe(false);
157 });
158
159 it("excludeNumber skips a candidate by issue number", () => {
160 const r = rankCandidates("crash on startup", candidates, {
161 excludeNumber: 1,
162 minScore: 0,
163 });
164 expect(r.some((x) => x.number === 1)).toBe(false);
165 });
166
167 it("state filter restricts candidates", () => {
168 const r = rankCandidates("crash startup", candidates, {
169 state: "open",
170 minScore: 0,
171 });
172 expect(r.every((x) => x.state === "open")).toBe(true);
173 });
174
175 it("ignores candidates whose title yields no tokens", () => {
176 const r = rankCandidates("crash", candidates, { minScore: 0 });
177 expect(r.some((x) => x.id === "5")).toBe(false);
178 });
179
180 it("tie-breaks by createdAt desc", () => {
181 const list: SimilarityCandidate[] = [
182 { id: "old", number: 10, title: "foo bar", createdAt: "2025-01-01" },
183 { id: "new", number: 20, title: "foo bar", createdAt: "2025-06-01" },
184 ];
185 const r = rankCandidates("foo bar", list, { minScore: 0 });
186 expect(r[0]!.id).toBe("new");
187 expect(r[1]!.id).toBe("old");
188 });
189
190 it("falls back to number-desc when createdAt equal", () => {
191 const list: SimilarityCandidate[] = [
192 { id: "a", number: 10, title: "foo bar" },
193 { id: "b", number: 20, title: "foo bar" },
194 ];
195 const r = rankCandidates("foo bar", list, { minScore: 0 });
196 expect(r[0]!.number).toBe(20);
197 });
198
199 it("limit=0 short-circuits to empty", () => {
200 const r = rankCandidates("crash", candidates, { limit: 0, minScore: 0 });
201 expect(r).toEqual([]);
202 });
203
204 it("never mutates the candidate list", () => {
205 const snap = candidates.map((c) => c.id).join(",");
206 rankCandidates("crash startup", candidates);
207 expect(candidates.map((c) => c.id).join(",")).toBe(snap);
208 });
209
210 it("stopword-only title returns empty", () => {
211 const r = rankCandidates("the and or", candidates);
212 expect(r).toEqual([]);
213 });
214
215 it("uses DEFAULT_MIN_SCORE + DEFAULT_LIMIT when opts omitted", () => {
216 expect(DEFAULT_MIN_SCORE).toBe(0.15);
217 expect(DEFAULT_LIMIT).toBe(5);
218 const r = rankCandidates("crash on startup", candidates);
219 expect(r.length).toBeLessThanOrEqual(5);
220 for (const item of r) expect(item.score).toBeGreaterThanOrEqual(0.15);
221 });
222});
223
224describe("issue-similarity — findSimilar", () => {
225 it("alias of rankCandidates", () => {
226 const cs: SimilarityCandidate[] = [
227 { id: "x", number: 1, title: "hello world" },
228 ];
229 expect(findSimilar("hello", cs, { minScore: 0 })).toEqual(
230 rankCandidates("hello", cs, { minScore: 0 })
231 );
232 });
233});
234
235describe("issue-similarity — formatSimilarityPercent", () => {
236 it("formats with percent suffix", () => {
237 expect(formatSimilarityPercent(0)).toBe("0%");
238 expect(formatSimilarityPercent(0.5)).toBe("50%");
239 expect(formatSimilarityPercent(1)).toBe("100%");
240 });
241 it("rounds half-up", () => {
242 expect(formatSimilarityPercent(0.456)).toBe("46%");
243 expect(formatSimilarityPercent(0.454)).toBe("45%");
244 });
245 it("clamps out-of-range to [0,100]", () => {
246 expect(formatSimilarityPercent(-0.5)).toBe("0%");
247 expect(formatSimilarityPercent(2)).toBe("100%");
248 });
249 it("non-finite → 0%", () => {
250 expect(formatSimilarityPercent(Number.NaN)).toBe("0%");
251 expect(formatSimilarityPercent(Number.POSITIVE_INFINITY)).toBe("0%");
252 });
253});
254
255describe("issue-similarity — constants", () => {
256 it("MIN_TOKEN_LENGTH default", () => {
257 expect(MIN_TOKEN_LENGTH).toBe(2);
258 });
259 it("STOPWORDS contains common fillers", () => {
260 for (const w of ["the", "and", "is", "it", "for"]) {
261 expect(STOPWORDS.has(w)).toBe(true);
262 }
263 expect(STOPWORDS.has("crash")).toBe(false);
264 });
265});
266
267describe("issue-similarity — routes", () => {
268 it("GET /:o/:r/issues/similar.json is guarded (never 500)", async () => {
269 const { default: app } = await import("../app");
270 const res = await app.request(
271 "/alice/repo/issues/similar.json?q=crash+startup"
272 );
273 expect([200, 400, 404]).toContain(res.status);
274 });
275
276 it("GET /:o/:r/issues/:n/similar is guarded (never 500)", async () => {
277 const { default: app } = await import("../app");
278 const res = await app.request("/alice/repo/issues/1/similar");
279 expect([200, 404]).toContain(res.status);
280 });
281});
282
283describe("issue-similarity — __internal parity", () => {
284 it("re-exports helpers", () => {
285 expect(__internal.tokeniseTitle).toBe(tokeniseTitle);
286 expect(__internal.jaccard).toBe(jaccard);
287 expect(__internal.rankCandidates).toBe(rankCandidates);
288 expect(__internal.findSimilar).toBe(findSimilar);
289 expect(__internal.formatSimilarityPercent).toBe(formatSimilarityPercent);
290 expect(__internal.MIN_TOKEN_LENGTH).toBe(MIN_TOKEN_LENGTH);
291 expect(__internal.STOPWORDS).toBe(STOPWORDS);
292 expect(__internal.DEFAULT_MIN_SCORE).toBe(DEFAULT_MIN_SCORE);
293 expect(__internal.DEFAULT_LIMIT).toBe(DEFAULT_LIMIT);
294 expect(typeof __internal.toTime).toBe("function");
295 });
296});
Modifiedsrc/app.tsx+5−0View fileUnifiedSplit
@@ -86,6 +86,7 @@ import codeSuggestionsRoutes from "./routes/code-suggestions";
8686import branchRenameRoutes from "./routes/branch-rename";
8787import responseTimeRoutes from "./routes/response-time";
8888import branchAgeRoutes from "./routes/branch-age";
89import issueSimilarityRoutes from "./routes/issue-similarity";
8990import webRoutes from "./routes/web";
9091
9192const app = new Hono();
@@ -179,6 +180,10 @@ app.route("/", compareRoutes);
179180// `/issues/stale` path wins over the dynamic `/issues/:number` (Block J20).
180181app.route("/", staleIssuesRoutes);
181182
183// Issue similarity / duplicate suggestions — must be BEFORE issueRoutes so
184// the static `/issues/similar.json` path wins over `/issues/:number` (J28).
185app.route("/", issueSimilarityRoutes);
186
182187// Issue tracker
183188app.route("/", issueRoutes);
184189
Addedsrc/lib/issue-similarity.ts+232−0View fileUnifiedSplit
@@ -0,0 +1,232 @@
1/**
2 * Block J28 — Issue title similarity / duplicate suggestions.
3 *
4 * Pure token-based Jaccard similarity for issue titles (+ optional bodies).
5 * The idea: when a user is about to open a new issue, we can show the top-N
6 * most similar existing issues so they can check if it's a duplicate before
7 * posting. Also usable on an existing issue to surface related ones.
8 *
9 * The algorithm is deliberately simple and IO-free:
10 * 1. Lowercase → strip punctuation → whitespace-split.
11 * 2. Remove English stopwords.
12 * 3. Drop tokens shorter than `MIN_TOKEN_LENGTH`.
13 * 4. Score = |A ∩ B| / |A ∪ B| (Jaccard index).
14 *
15 * Scores are in [0, 1]; `rankCandidates` returns candidates sorted score-desc
16 * (tie-break newest-first) and filters by `minScore` + `limit`.
17 */
18
19export const MIN_TOKEN_LENGTH = 2;
20
21// Small, deliberately short English stopword list. Keeping it conservative
22// so titles like "add auth to api" don't collapse to nothing.
23export const STOPWORDS: ReadonlySet<string> = new Set([
24 "a",
25 "an",
26 "the",
27 "and",
28 "or",
29 "but",
30 "if",
31 "then",
32 "so",
33 "to",
34 "of",
35 "in",
36 "on",
37 "at",
38 "by",
39 "for",
40 "with",
41 "from",
42 "as",
43 "is",
44 "it",
45 "its",
46 "this",
47 "that",
48 "these",
49 "those",
50 "be",
51 "been",
52 "are",
53 "was",
54 "were",
55 "do",
56 "does",
57 "did",
58 "done",
59 "have",
60 "has",
61 "had",
62 "will",
63 "would",
64 "can",
65 "could",
66 "should",
67 "may",
68 "might",
69 "i",
70 "me",
71 "my",
72 "we",
73 "us",
74 "our",
75 "you",
76 "your",
77 "he",
78 "she",
79 "they",
80 "them",
81 "their",
82]);
83
84/**
85 * Lowercase, strip non-alphanumeric (Unicode-aware), split on whitespace,
86 * drop stopwords + short tokens. Returns a `Set<string>` (dedup implicit).
87 */
88export function tokeniseTitle(input: unknown): Set<string> {
89 if (typeof input !== "string") return new Set();
90 const lower = input.toLowerCase();
91 // Replace anything that isn't a Unicode letter, digit, or dash with space.
92 // Using \p{L} + \p{N} to stay multilingual.
93 const cleaned = lower.replace(/[^\p{L}\p{N}_-]+/gu, " ");
94 const out = new Set<string>();
95 for (const tok of cleaned.split(/\s+/)) {
96 if (tok.length < MIN_TOKEN_LENGTH) continue;
97 if (STOPWORDS.has(tok)) continue;
98 out.add(tok);
99 }
100 return out;
101}
102
103/** Classic Jaccard: |A ∩ B| / |A ∪ B|. Returns 0 when both sets are empty. */
104export function jaccard<T>(a: ReadonlySet<T>, b: ReadonlySet<T>): number {
105 if (a.size === 0 && b.size === 0) return 0;
106 let intersection = 0;
107 // Iterate the smaller set for efficiency.
108 const [small, large] = a.size <= b.size ? [a, b] : [b, a];
109 for (const t of small) if (large.has(t)) intersection++;
110 const union = a.size + b.size - intersection;
111 return union === 0 ? 0 : intersection / union;
112}
113
114export interface SimilarityCandidate {
115 id: string;
116 number: number;
117 title: string;
118 state?: string;
119 createdAt?: Date | string | null;
120}
121
122export interface SimilarityResult {
123 id: string;
124 number: number;
125 title: string;
126 state?: string;
127 score: number;
128}
129
130export interface RankOptions {
131 /** Discard candidates with score strictly below this. Default 0.15. */
132 minScore?: number;
133 /** Return at most this many results. Default 5. */
134 limit?: number;
135 /** Optional id of the source issue — never ranked against itself. */
136 excludeId?: string;
137 /** Optional number of the source issue — never ranked against itself. */
138 excludeNumber?: number;
139 /** If set, restrict candidates to this state. */
140 state?: string;
141}
142
143export const DEFAULT_MIN_SCORE = 0.15;
144export const DEFAULT_LIMIT = 5;
145
146function toTime(v: Date | string | null | undefined): number {
147 if (!v) return 0;
148 if (v instanceof Date) {
149 const t = v.getTime();
150 return Number.isNaN(t) ? 0 : t;
151 }
152 if (typeof v === "string") {
153 const t = new Date(v).getTime();
154 return Number.isNaN(t) ? 0 : t;
155 }
156 return 0;
157}
158
159export function rankCandidates(
160 targetTitle: string,
161 candidates: readonly SimilarityCandidate[],
162 opts: RankOptions = {}
163): SimilarityResult[] {
164 const min = opts.minScore ?? DEFAULT_MIN_SCORE;
165 const limit = Math.max(0, opts.limit ?? DEFAULT_LIMIT);
166 const stateFilter = opts.state;
167 const tTokens = tokeniseTitle(targetTitle);
168 if (tTokens.size === 0 || limit === 0) return [];
169
170 const results: (SimilarityResult & { __t: number })[] = [];
171 for (const c of candidates) {
172 if (opts.excludeId && c.id === opts.excludeId) continue;
173 if (
174 opts.excludeNumber !== undefined &&
175 c.number === opts.excludeNumber
176 ) {
177 continue;
178 }
179 if (stateFilter && c.state !== stateFilter) continue;
180 const cTokens = tokeniseTitle(c.title);
181 if (cTokens.size === 0) continue;
182 const score = jaccard(tTokens, cTokens);
183 if (score < min) continue;
184 results.push({
185 id: c.id,
186 number: c.number,
187 title: c.title,
188 state: c.state,
189 score,
190 __t: toTime(c.createdAt),
191 });
192 }
193
194 results.sort((a, b) => {
195 if (a.score !== b.score) return b.score - a.score;
196 // Tie-break: newer candidates first (more likely relevant).
197 if (a.__t !== b.__t) return b.__t - a.__t;
198 // Stable final tie-break on number-desc.
199 return b.number - a.number;
200 });
201
202 return results.slice(0, limit).map(({ __t, ...rest }) => rest);
203}
204
205/** "47%" style — useful for UI rendering. */
206export function formatSimilarityPercent(score: number): string {
207 if (!Number.isFinite(score)) return "0%";
208 const clamped = Math.max(0, Math.min(1, score));
209 return `${Math.round(clamped * 100)}%`;
210}
211
212/** One-shot convenience: tokenise + rank. */
213export function findSimilar(
214 targetTitle: string,
215 candidates: readonly SimilarityCandidate[],
216 opts?: RankOptions
217): SimilarityResult[] {
218 return rankCandidates(targetTitle, candidates, opts);
219}
220
221export const __internal = {
222 MIN_TOKEN_LENGTH,
223 STOPWORDS,
224 DEFAULT_MIN_SCORE,
225 DEFAULT_LIMIT,
226 tokeniseTitle,
227 jaccard,
228 rankCandidates,
229 findSimilar,
230 formatSimilarityPercent,
231 toTime,
232};
Addedsrc/routes/issue-similarity.tsx+299−0View fileUnifiedSplit
@@ -0,0 +1,299 @@
1/**
2 * Block J28 — Issue title similarity suggestions.
3 *
4 * GET /:owner/:repo/issues/similar.json?q=<title>[&limit=5][&state=open]
5 * → JSON {ok, matches: [{number, title, score, state}]}
6 * Designed for fetch-driven inline suggestions on the new-issue form.
7 *
8 * GET /:owner/:repo/issues/:number/similar
9 * → Full HTML page showing the source issue + ranked related issues.
10 *
11 * softAuth; private repos 404 for non-owner viewers. IO is limited to a
12 * bounded issue list (last `CANDIDATE_LIMIT` issues) so the in-memory ranker
13 * stays O(n*m) on a capped n.
14 */
15
16import { Hono } from "hono";
17import { and, desc, eq } from "drizzle-orm";
18import { db } from "../db";
19import { issues, repositories, users } from "../db/schema";
20import { Layout } from "../views/layout";
21import { RepoHeader } from "../views/components";
22import { softAuth } from "../middleware/auth";
23import type { AuthEnv } from "../middleware/auth";
24import {
25 findSimilar,
26 formatSimilarityPercent,
27 type SimilarityCandidate,
28} from "../lib/issue-similarity";
29
30const issueSimilarityRoutes = new Hono<AuthEnv>();
31
32issueSimilarityRoutes.use("*", softAuth);
33
34const CANDIDATE_LIMIT = 500;
35const MAX_RESULT_LIMIT = 20;
36
37async function resolveRepo(ownerName: string, repoName: string) {
38 try {
39 const [owner] = await db
40 .select()
41 .from(users)
42 .where(eq(users.username, ownerName))
43 .limit(1);
44 if (!owner) return null;
45 const [repo] = await db
46 .select()
47 .from(repositories)
48 .where(
49 and(
50 eq(repositories.ownerId, owner.id),
51 eq(repositories.name, repoName)
52 )
53 )
54 .limit(1);
55 if (!repo) return null;
56 return { owner, repo };
57 } catch {
58 return null;
59 }
60}
61
62async function fetchCandidates(
63 repoId: string
64): Promise<SimilarityCandidate[]> {
65 try {
66 const rows = await db
67 .select({
68 id: issues.id,
69 number: issues.number,
70 title: issues.title,
71 state: issues.state,
72 createdAt: issues.createdAt,
73 })
74 .from(issues)
75 .where(eq(issues.repositoryId, repoId))
76 .orderBy(desc(issues.createdAt))
77 .limit(CANDIDATE_LIMIT);
78 return rows as SimilarityCandidate[];
79 } catch {
80 return [];
81 }
82}
83
84// JSON endpoint — suitable for inline suggestions on the new-issue form.
85issueSimilarityRoutes.get(
86 "/:owner/:repo/issues/similar.json",
87 async (c) => {
88 const { owner: ownerName, repo: repoName } = c.req.param();
89 const user = c.get("user");
90 const q = (c.req.query("q") ?? "").trim();
91 const limitRaw = Number.parseInt(c.req.query("limit") ?? "", 10);
92 const limit =
93 Number.isFinite(limitRaw) && limitRaw > 0
94 ? Math.min(MAX_RESULT_LIMIT, limitRaw)
95 : 5;
96 const stateParam = c.req.query("state");
97 const state =
98 stateParam === "open" || stateParam === "closed" ? stateParam : undefined;
99
100 if (q.length === 0) {
101 return c.json({ ok: true, matches: [] });
102 }
103
104 const resolved = await resolveRepo(ownerName, repoName);
105 if (!resolved) {
106 return c.json({ ok: false, error: "not_found" }, 404);
107 }
108
109 // Private-repo visibility: only the owner sees suggestions.
110 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
111 return c.json({ ok: false, error: "not_found" }, 404);
112 }
113
114 const candidates = await fetchCandidates(resolved.repo.id);
115 const matches = findSimilar(q, candidates, { limit, state }).map((m) => ({
116 number: m.number,
117 title: m.title,
118 state: m.state,
119 score: m.score,
120 percent: formatSimilarityPercent(m.score),
121 }));
122 return c.json({ ok: true, matches });
123 }
124);
125
126// HTML page — "Related issues" view anchored to a specific issue.
127issueSimilarityRoutes.get(
128 "/:owner/:repo/issues/:number/similar",
129 async (c) => {
130 const { owner: ownerName, repo: repoName, number: numParam } = c.req.param();
131 const user = c.get("user");
132 const num = Number.parseInt(numParam, 10);
133
134 if (!Number.isFinite(num) || num <= 0) {
135 return c.html(
136 <Layout title="Not Found" user={user}>
137 <div class="empty-state">
138 <h2>Issue not found</h2>
139 </div>
140 </Layout>,
141 404
142 );
143 }
144
145 const resolved = await resolveRepo(ownerName, repoName);
146 if (!resolved) {
147 return c.html(
148 <Layout title="Not Found" user={user}>
149 <div class="empty-state">
150 <h2>Repository not found</h2>
151 </div>
152 </Layout>,
153 404
154 );
155 }
156
157 if (resolved.repo.isPrivate && (!user || user.id !== resolved.owner.id)) {
158 return c.html(
159 <Layout title="Not Found" user={user}>
160 <div class="empty-state">
161 <h2>Repository not found</h2>
162 </div>
163 </Layout>,
164 404
165 );
166 }
167
168 let source:
169 | { id: string; number: number; title: string; state: string }
170 | null = null;
171 try {
172 const [row] = await db
173 .select({
174 id: issues.id,
175 number: issues.number,
176 title: issues.title,
177 state: issues.state,
178 })
179 .from(issues)
180 .where(
181 and(
182 eq(issues.repositoryId, resolved.repo.id),
183 eq(issues.number, num)
184 )
185 )
186 .limit(1);
187 source = (row as any) || null;
188 } catch {
189 source = null;
190 }
191
192 if (!source) {
193 return c.html(
194 <Layout title="Not Found" user={user}>
195 <div class="empty-state">
196 <h2>Issue not found</h2>
197 </div>
198 </Layout>,
199 404
200 );
201 }
202
203 const candidates = await fetchCandidates(resolved.repo.id);
204 const matches = findSimilar(source.title, candidates, {
205 excludeId: source.id,
206 excludeNumber: source.number,
207 limit: 10,
208 });
209
210 return c.html(
211 <Layout
212 title={`Similar issues — #${source.number}`}
213 user={user}
214 >
215 <RepoHeader owner={ownerName} repo={repoName} />
216 <div style="max-width: 920px">
217 <div class="breadcrumb">
218 <a href={`/${ownerName}/${repoName}/issues`}>issues</a>
219 <span>/</span>
220 <a href={`/${ownerName}/${repoName}/issues/${source.number}`}>
221 #{source.number}
222 </a>
223 <span>/</span>
224 <span>similar</span>
225 </div>
226 <h2 style="margin-top: 12px">
227 Issues similar to{" "}
228 <a href={`/${ownerName}/${repoName}/issues/${source.number}`}>
229 #{source.number}
230 </a>
231 </h2>
232 <p style="color: var(--text-muted); font-size: 13px; margin-bottom: 20px">
233 Ranked by token-Jaccard similarity on the title. Useful for spotting
234 duplicates; not a replacement for reading the thread.
235 </p>
236
237 <div style="padding: 12px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); margin-bottom: 20px">
238 <div style="font-size: 11px; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 4px">
239 Source
240 </div>
241 <div>
242 <span style="color: var(--text-muted)">#{source.number}</span>{" "}
243 <strong>{source.title}</strong>{" "}
244 <span style="font-size: 11px; padding: 2px 8px; border-radius: 10px; background: var(--bg); color: var(--text-muted)">
245 {source.state}
246 </span>
247 </div>
248 </div>
249
250 {matches.length === 0 ? (
251 <div class="empty-state">
252 <p>No similar issues found. This one looks unique.</p>
253 </div>
254 ) : (
255 <table style="width: 100%; border-collapse: collapse">
256 <thead>
257 <tr>
258 <th style="text-align: left; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted)">
259 Issue
260 </th>
261 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 80px">
262 State
263 </th>
264 <th style="text-align: right; padding: 8px; border-bottom: 1px solid var(--border); font-size: 12px; color: var(--text-muted); width: 90px">
265 Similarity
266 </th>
267 </tr>
268 </thead>
269 <tbody>
270 {matches.map((m) => (
271 <tr>
272 <td style="padding: 8px; border-bottom: 1px solid var(--border)">
273 <a
274 href={`/${ownerName}/${repoName}/issues/${m.number}`}
275 >
276 <span style="color: var(--text-muted)">
277 #{m.number}
278 </span>{" "}
279 {m.title}
280 </a>
281 </td>
282 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-size: 11px; color: var(--text-muted)">
283 {m.state ?? "\u2014"}
284 </td>
285 <td style="padding: 8px; border-bottom: 1px solid var(--border); text-align: right; font-family: var(--font-mono); font-size: 12px">
286 {formatSimilarityPercent(m.score)}
287 </td>
288 </tr>
289 ))}
290 </tbody>
291 </table>
292 )}
293 </div>
294 </Layout>
295 );
296 }
297);
298
299export default issueSimilarityRoutes;
0300