Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

issue-similarity.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

issue-similarity.tsxBlame299 lines · 1 contributor
b184a75Claude1/**
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;