From fed51cc2113c0b580a4c8b79b823b6c7bedfd910 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sun, 30 Nov 2025 18:53:35 +0330 Subject: [PATCH 01/10] init --- packages/app/server/api/repo/search.get.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index eb8764dc..0c873fd0 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -7,6 +7,8 @@ const querySchema = z.object({ text: z.string(), }); +console.log("querySchema", querySchema); + interface SearchDebugInfo { startTime: string; endTime: string; From 907386030a39cb215b13768ff78097f54db01b16 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sun, 30 Nov 2025 19:29:47 +0330 Subject: [PATCH 02/10] stream --- packages/app/app/components/RepoSearch.vue | 50 +++- packages/app/server/api/repo/search.get.ts | 328 +++++---------------- 2 files changed, 127 insertions(+), 251 deletions(-) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index 6b5a971d..54d22d9a 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -24,11 +24,53 @@ watch( try { const response = await fetch( `/api/repo/search?text=${encodeURIComponent(newValue)}`, - { signal: activeController.signal }, + { signal: controller.signal }, ); - const data = (await response.json()) as { nodes: RepoNode[] }; - if (activeController === controller) { - searchResults.value = data.nodes ?? []; + + const reader = response.body?.getReader(); + if (!reader) return; + + const decoder = new TextDecoder(); + let buffer = ""; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + + buffer += decoder.decode(value, { stream: true }); + const lines = buffer.split("\n\n"); + buffer = lines.pop() || ""; + + for (const line of lines) { + if (!line.startsWith("data: ")) continue; + const data = line.slice(6); + + if (data === "[DONE]") { + isLoading.value = false; + return; + } + + try { + const repo = JSON.parse(data) as RepoNode & { error?: string }; + if (!repo.error) { + // Insert sorted by stars (higher first) + const idx = searchResults.value.findIndex( + (r) => r.stars < repo.stars, + ); + if (idx === -1) { + searchResults.value.push(repo); + } else { + searchResults.value.splice(idx, 0, repo); + } + // Keep only top 10 + if (searchResults.value.length > 10) { + searchResults.value.pop(); + } + } + } catch { + // Skip malformed JSON + } + } } } catch (err: any) { if (err.name !== "AbortError") { diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index 0c873fd0..b990cbb9 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -7,263 +7,97 @@ const querySchema = z.object({ text: z.string(), }); -console.log("querySchema", querySchema); - -interface SearchDebugInfo { - startTime: string; - endTime: string; - totalElapsedMs: number; - processedRepositories: number; - matchesFound: number; - averageProcessingTimePerRepo: number; - repositoriesPerSecond: number; - searchQuery: string; - status: "completed" | "aborted" | "error"; - flowErrors: Array<{ - stage: string; - error: string; - timestamp: string; - repositoryContext?: string; - }>; - flowStages: Array<{ - stage: string; - timestamp: string; - details?: string; - }>; -} - export default defineEventHandler(async (event) => { - const request = toWebRequest(event); - const signal = request.signal; - - const flowErrors: SearchDebugInfo["flowErrors"] = []; - const flowStages: SearchDebugInfo["flowStages"] = []; - - const addFlowStage = (stage: string, details?: string) => { - flowStages.push({ - stage, - timestamp: new Date().toISOString(), - details, - }); - }; - - const addFlowError = ( - stage: string, - error: string, - repositoryContext?: string, - ) => { - flowErrors.push({ - stage, - error, - timestamp: new Date().toISOString(), - repositoryContext, - }); - }; - - try { - addFlowStage("query_validation", "Starting query validation"); - - const query = await getValidatedQuery(event, (data) => - querySchema.parse(data), - ); - - if (!query.text) { - addFlowStage("early_return", "Empty query text"); - return { nodes: [], debug: null }; - } - - addFlowStage("query_validated", `Query: "${query.text}"`); - - let app; - try { - addFlowStage("octokit_init", "Initializing Octokit app"); - app = useOctokitApp(event); - addFlowStage("octokit_ready", "Octokit app initialized successfully"); - } catch (err: any) { - addFlowError("octokit_init", err.message); - throw new Error(`Failed to initialize Octokit: ${err.message}`); - } + const query = await getValidatedQuery(event, (data) => + querySchema.parse(data), + ); - const searchText = query.text.toLowerCase(); - const matches: RepoNode[] = []; - const startTime = Date.now(); - let processedRepositories = 0; - let status: SearchDebugInfo["status"] = "completed"; - let skippedRepositories = 0; - let suspendedErrors = 0; - - addFlowStage( - "repository_iteration_start", - `Starting to iterate repositories for search: "${searchText}"`, - ); - - try { - await app.eachRepository(async ({ repository }) => { - try { - if (signal.aborted) { - addFlowStage( - "search_aborted", - `Aborted at repository: ${repository.full_name}`, - ); - status = "aborted"; - return; - } - - if (repository.private) { - skippedRepositories++; - return; - } - - processedRepositories++; - - // Add periodic progress tracking - if (processedRepositories % 100 === 0) { - const elapsed = Date.now() - startTime; - addFlowStage( - "progress_checkpoint", - `Processed ${processedRepositories} repositories in ${elapsed}ms`, - ); - } - - const repoName = repository.name.toLowerCase(); - const ownerLogin = repository.owner.login.toLowerCase(); + if (!query.text) { + return { nodes: [] }; + } - let nameScore, ownerScore; + // Set SSE headers + setResponseHeaders(event, { + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); + + const app = useOctokitApp(event); + const searchText = query.text.toLowerCase(); + const seen = new Set(); + + const stream = new ReadableStream({ + async start(controller) { + const encoder = new TextEncoder(); + + const sendResult = (repo: Omit) => { + if (seen.has(repo.id)) return; + seen.add(repo.id); + controller.enqueue(encoder.encode(`data: ${JSON.stringify(repo)}\n\n`)); + }; + + const sendDone = () => { + controller.enqueue(encoder.encode(`data: [DONE]\n\n`)); + controller.close(); + }; + + try { + for await (const { installation } of app.eachInstallation.iterator()) { try { - nameScore = stringSimilarity.compareTwoStrings( - repoName, - searchText, - ); - ownerScore = stringSimilarity.compareTwoStrings( - ownerLogin, - searchText, - ); - } catch (err: any) { - addFlowError( - "string_similarity", - err.message, - repository.full_name, - ); - // Use fallback scoring - nameScore = repoName.includes(searchText) ? 0.5 : 0; - ownerScore = ownerLogin.includes(searchText) ? 0.5 : 0; - } + const octokit = await app.getInstallationOctokit(installation.id); - try { - matches.push({ - id: repository.id, - name: repository.name, - owner: { - login: repository.owner.login, - avatarUrl: repository.owner.avatar_url, - }, - stars: repository.stargazers_count || 0, - score: Math.max(nameScore, ownerScore), - }); - } catch (err: any) { - addFlowError("match_creation", err.message, repository.full_name); - } - } catch (err: any) { - if ( - err.message?.includes("suspended") || - err.message?.includes("Installation") - ) { - suspendedErrors++; - addFlowError( - "repository_suspended", - err.message, - repository.full_name, + const { data } = await octokit.request( + "GET /installation/repositories", + { per_page: 100 }, ); - return; + + for (const repo of data.repositories) { + if (repo.private) continue; + + const nameScore = stringSimilarity.compareTwoStrings( + repo.name.toLowerCase(), + searchText, + ); + const ownerScore = stringSimilarity.compareTwoStrings( + repo.owner.login.toLowerCase(), + searchText, + ); + const score = Math.max(nameScore, ownerScore); + + // Only send if it's a decent match + if ( + score > 0.3 || + repo.name.toLowerCase().includes(searchText) || + repo.owner.login.toLowerCase().includes(searchText) + ) { + sendResult({ + id: repo.id, + name: repo.name, + owner: { + login: repo.owner.login, + avatarUrl: repo.owner.avatar_url, + }, + stars: repo.stargazers_count || 0, + }); + } + } + } catch { + // Skip suspended installations } - addFlowError( - "repository_processing", - err.message, - repository.full_name, - ); - throw err; } - }); - addFlowStage( - "repository_iteration_complete", - `Completed repository iteration`, - ); - } catch (err: any) { - if ( - err.message?.includes("suspended") || - err.message?.includes("Installation") - ) { - addFlowError( - "iteration_suspended", - `Installation suspended after processing ${processedRepositories} repositories: ${err.message}`, + sendDone(); + } catch (err) { + controller.enqueue( + encoder.encode( + `data: ${JSON.stringify({ error: (err as Error).message })}\n\n`, + ), ); - status = "completed"; - } else { - addFlowError("iteration_failed", err.message); - status = "error"; - throw err; + controller.close(); } - } - - const totalElapsed = Date.now() - startTime; - - addFlowStage("sorting_matches", `Sorting ${matches.length} matches`); - - try { - matches.sort((a, b) => - b.score !== a.score ? b.score - a.score : b.stars - a.stars, - ); - addFlowStage("sorting_complete", "Matches sorted successfully"); - } catch (err: any) { - addFlowError("sorting", err.message); - } - - const top = matches.slice(0, 10).map((node) => ({ - id: node.id, - name: node.name, - owner: node.owner, - stars: node.stars, - })); + }, + }); - addFlowStage("response_preparation", `Prepared ${top.length} top results`); - - const debugInfo: SearchDebugInfo = { - startTime: new Date(startTime).toISOString(), - endTime: new Date().toISOString(), - totalElapsedMs: totalElapsed, - processedRepositories, - matchesFound: matches.length, - averageProcessingTimePerRepo: - processedRepositories > 0 ? totalElapsed / processedRepositories : 0, - repositoriesPerSecond: processedRepositories / (totalElapsed / 1000), - searchQuery: query.text, - status, - flowErrors, - flowStages, - }; - - return { nodes: top, debug: debugInfo }; - } catch (error) { - addFlowError("global_error", (error as Error).message); - - return { - nodes: [], - error: true, - message: (error as Error).message, - debug: { - startTime: new Date().toISOString(), - endTime: new Date().toISOString(), - totalElapsedMs: 0, - processedRepositories: 0, - matchesFound: 0, - averageProcessingTimePerRepo: 0, - repositoriesPerSecond: 0, - searchQuery: "", - status: "error" as const, - flowErrors, - flowStages, - }, - }; - } + return stream; }); From ca7366e2f4ca37e5717822a64ad77cd7eb3ff379 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sun, 30 Nov 2025 19:40:27 +0330 Subject: [PATCH 03/10] fix: add abort controller --- packages/app/app/components/RepoSearch.vue | 3 +++ packages/app/server/api/repo/search.get.ts | 8 ++++++++ 2 files changed, 11 insertions(+) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index 54d22d9a..b6e1ee3c 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -5,12 +5,14 @@ const searchResults = ref([]); const isLoading = ref(false); let activeController: AbortController | null = null; +let activeReader: ReadableStreamDefaultReader | null = null; const throttledSearch = useThrottle(search, 500, true, false); watch( throttledSearch, async (newValue) => { activeController?.abort(); + activeReader?.cancel(); searchResults.value = []; if (!newValue) { isLoading.value = false; @@ -29,6 +31,7 @@ watch( const reader = response.body?.getReader(); if (!reader) return; + activeReader = reader; const decoder = new TextDecoder(); let buffer = ""; diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index b990cbb9..1796931b 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -8,6 +8,9 @@ const querySchema = z.object({ }); export default defineEventHandler(async (event) => { + const request = toWebRequest(event); + const signal = request.signal; + const query = await getValidatedQuery(event, (data) => querySchema.parse(data), ); @@ -44,6 +47,11 @@ export default defineEventHandler(async (event) => { try { for await (const { installation } of app.eachInstallation.iterator()) { + if (signal.aborted) { + controller.close(); + return; + } + try { const octokit = await app.getInstallationOctokit(installation.id); From 11064a7d6b9abf37671e1e3557eaa8ba2c2122c4 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Mon, 1 Dec 2025 10:45:00 +0330 Subject: [PATCH 04/10] refacor: simplified --- packages/app/app/components/RepoSearch.vue | 94 ++++++---------------- packages/app/server/api/repo/search.get.ts | 84 +++++++------------ 2 files changed, 53 insertions(+), 125 deletions(-) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index b6e1ee3c..ef21a0ec 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -1,37 +1,36 @@ @@ -154,10 +113,7 @@ function openFirstResult() { /> -
+
No repositories found
diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index 1796931b..3663038e 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -1,16 +1,12 @@ import { z } from "zod"; import { useOctokitApp } from "../../utils/octokit"; import stringSimilarity from "string-similarity"; -import type { RepoNode } from "../../utils/types"; const querySchema = z.object({ text: z.string(), }); export default defineEventHandler(async (event) => { - const request = toWebRequest(event); - const signal = request.signal; - const query = await getValidatedQuery(event, (data) => querySchema.parse(data), ); @@ -19,7 +15,8 @@ export default defineEventHandler(async (event) => { return { nodes: [] }; } - // Set SSE headers + const { signal } = toWebRequest(event); + setResponseHeaders(event, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", @@ -29,65 +26,45 @@ export default defineEventHandler(async (event) => { const app = useOctokitApp(event); const searchText = query.text.toLowerCase(); const seen = new Set(); + const encoder = new TextEncoder(); - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - - const sendResult = (repo: Omit) => { - if (seen.has(repo.id)) return; - seen.add(repo.id); - controller.enqueue(encoder.encode(`data: ${JSON.stringify(repo)}\n\n`)); - }; - - const sendDone = () => { - controller.enqueue(encoder.encode(`data: [DONE]\n\n`)); - controller.close(); - }; + const send = (data: string) => encoder.encode(`data: ${data}\n\n`); + return new ReadableStream({ + async start(controller) { try { for await (const { installation } of app.eachInstallation.iterator()) { - if (signal.aborted) { - controller.close(); - return; - } + if (signal.aborted) break; try { const octokit = await app.getInstallationOctokit(installation.id); - const { data } = await octokit.request( "GET /installation/repositories", { per_page: 100 }, ); for (const repo of data.repositories) { - if (repo.private) continue; + if (repo.private || seen.has(repo.id)) continue; - const nameScore = stringSimilarity.compareTwoStrings( - repo.name.toLowerCase(), - searchText, + const name = repo.name.toLowerCase(); + const owner = repo.owner.login.toLowerCase(); + const score = Math.max( + stringSimilarity.compareTwoStrings(name, searchText), + stringSimilarity.compareTwoStrings(owner, searchText), ); - const ownerScore = stringSimilarity.compareTwoStrings( - repo.owner.login.toLowerCase(), - searchText, - ); - const score = Math.max(nameScore, ownerScore); - // Only send if it's a decent match - if ( - score > 0.3 || - repo.name.toLowerCase().includes(searchText) || - repo.owner.login.toLowerCase().includes(searchText) - ) { - sendResult({ - id: repo.id, - name: repo.name, - owner: { - login: repo.owner.login, - avatarUrl: repo.owner.avatar_url, - }, - stars: repo.stargazers_count || 0, - }); + if (score > 0.3 || name.includes(searchText) || owner.includes(searchText)) { + seen.add(repo.id); + controller.enqueue( + send( + JSON.stringify({ + id: repo.id, + name: repo.name, + owner: { login: repo.owner.login, avatarUrl: repo.owner.avatar_url }, + stars: repo.stargazers_count || 0, + }), + ), + ); } } } catch { @@ -95,17 +72,12 @@ export default defineEventHandler(async (event) => { } } - sendDone(); + controller.enqueue(send("[DONE]")); } catch (err) { - controller.enqueue( - encoder.encode( - `data: ${JSON.stringify({ error: (err as Error).message })}\n\n`, - ), - ); + controller.enqueue(send(JSON.stringify({ error: (err as Error).message }))); + } finally { controller.close(); } }, }); - - return stream; }); From 8511277fc1a5a0accf5a8f38dec9ea42844baba0 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Mon, 1 Dec 2025 10:47:06 +0330 Subject: [PATCH 05/10] prettier --- packages/app/app/components/RepoSearch.vue | 50 ++++++++++++++++++---- packages/app/server/api/repo/search.get.ts | 15 +++++-- 2 files changed, 53 insertions(+), 12 deletions(-) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index ef21a0ec..2f53f83a 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -53,8 +53,14 @@ watch( if (repo.error) continue; // Insert sorted by stars, keep top 10 - const idx = searchResults.value.findIndex((r) => r.stars < repo.stars); - searchResults.value.splice(idx === -1 ? searchResults.value.length : idx, 0, repo); + const idx = searchResults.value.findIndex( + (r) => r.stars < repo.stars, + ); + searchResults.value.splice( + idx === -1 ? searchResults.value.length : idx, + 0, + repo, + ); if (searchResults.value.length > 10) searchResults.value.pop(); } catch { // Skip malformed JSON @@ -71,18 +77,41 @@ watch( ); const examples = [ - { owner: "vitejs", name: "vite", avatar: "https://avatars.githubusercontent.com/u/65625612?v=4" }, - { owner: "rolldown", name: "rolldown", avatar: "https://avatars.githubusercontent.com/u/94954945?s=200&v=4" }, - { owner: "vuejs", name: "core", avatar: "https://avatars.githubusercontent.com/u/6128107?v=4" }, - { owner: "sveltejs", name: "svelte", avatar: "https://avatars.githubusercontent.com/u/23617963?s=200&v=4" }, - { owner: "Tresjs", name: "tres", avatar: "https://avatars.githubusercontent.com/u/119253150?v=4" }, + { + owner: "vitejs", + name: "vite", + avatar: "https://avatars.githubusercontent.com/u/65625612?v=4", + }, + { + owner: "rolldown", + name: "rolldown", + avatar: "https://avatars.githubusercontent.com/u/94954945?s=200&v=4", + }, + { + owner: "vuejs", + name: "core", + avatar: "https://avatars.githubusercontent.com/u/6128107?v=4", + }, + { + owner: "sveltejs", + name: "svelte", + avatar: "https://avatars.githubusercontent.com/u/23617963?s=200&v=4", + }, + { + owner: "Tresjs", + name: "tres", + avatar: "https://avatars.githubusercontent.com/u/119253150?v=4", + }, ]; const router = useRouter(); function openFirstResult() { const [first] = searchResults.value; if (first) { - router.push({ name: "repo:details", params: { owner: first.owner.login, repo: first.name } }); + router.push({ + name: "repo:details", + params: { owner: first.owner.login, repo: first.name }, + }); } } @@ -113,7 +142,10 @@ function openFirstResult() { />
-
+
No repositories found
diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index 3663038e..02edd0ef 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -53,14 +53,21 @@ export default defineEventHandler(async (event) => { stringSimilarity.compareTwoStrings(owner, searchText), ); - if (score > 0.3 || name.includes(searchText) || owner.includes(searchText)) { + if ( + score > 0.3 || + name.includes(searchText) || + owner.includes(searchText) + ) { seen.add(repo.id); controller.enqueue( send( JSON.stringify({ id: repo.id, name: repo.name, - owner: { login: repo.owner.login, avatarUrl: repo.owner.avatar_url }, + owner: { + login: repo.owner.login, + avatarUrl: repo.owner.avatar_url, + }, stars: repo.stargazers_count || 0, }), ), @@ -74,7 +81,9 @@ export default defineEventHandler(async (event) => { controller.enqueue(send("[DONE]")); } catch (err) { - controller.enqueue(send(JSON.stringify({ error: (err as Error).message }))); + controller.enqueue( + send(JSON.stringify({ error: (err as Error).message })), + ); } finally { controller.close(); } From fed3a03e06f36a475ca2b9da230f9e8d348c7e0f Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Mon, 1 Dec 2025 11:58:55 +0330 Subject: [PATCH 06/10] update --- packages/app/server/api/repo/search.get.ts | 39 +++++++++++----------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index 02edd0ef..c9bcc0b5 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -26,12 +26,13 @@ export default defineEventHandler(async (event) => { const app = useOctokitApp(event); const searchText = query.text.toLowerCase(); const seen = new Set(); - const encoder = new TextEncoder(); - const send = (data: string) => encoder.encode(`data: ${data}\n\n`); - - return new ReadableStream({ + const stream = new ReadableStream({ async start(controller) { + const encoder = new TextEncoder(); + const send = (data: string) => + controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + try { for await (const { installation } of app.eachInstallation.iterator()) { if (signal.aborted) break; @@ -59,18 +60,16 @@ export default defineEventHandler(async (event) => { owner.includes(searchText) ) { seen.add(repo.id); - controller.enqueue( - send( - JSON.stringify({ - id: repo.id, - name: repo.name, - owner: { - login: repo.owner.login, - avatarUrl: repo.owner.avatar_url, - }, - stars: repo.stargazers_count || 0, - }), - ), + send( + JSON.stringify({ + id: repo.id, + name: repo.name, + owner: { + login: repo.owner.login, + avatarUrl: repo.owner.avatar_url, + }, + stars: repo.stargazers_count || 0, + }), ); } } @@ -79,14 +78,14 @@ export default defineEventHandler(async (event) => { } } - controller.enqueue(send("[DONE]")); + send("[DONE]"); } catch (err) { - controller.enqueue( - send(JSON.stringify({ error: (err as Error).message })), - ); + send(JSON.stringify({ error: (err as Error).message })); } finally { controller.close(); } }, }); + + return stream; }); From 8178a3f3e0134634d7bfbfe1dfe0be3b780051af Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sun, 1 Feb 2026 20:14:45 +0330 Subject: [PATCH 07/10] chore: clean frontend --- packages/app/app/components/RepoSearch.vue | 161 ++++++++++----------- 1 file changed, 79 insertions(+), 82 deletions(-) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index 2f53f83a..78573fd1 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -1,117 +1,112 @@ @@ -150,7 +145,9 @@ function openFirstResult() {
-
Or try it on:
+
+ Or try it on: +
Date: Sun, 1 Feb 2026 20:25:54 +0330 Subject: [PATCH 08/10] backend --- packages/app/server/api/repo/search.get.ts | 150 +++++++++++---------- 1 file changed, 80 insertions(+), 70 deletions(-) diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index c9bcc0b5..989377d7 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -1,91 +1,101 @@ -import { z } from "zod"; -import { useOctokitApp } from "../../utils/octokit"; -import stringSimilarity from "string-similarity"; +import stringSimilarity from 'string-similarity' +import { z } from 'zod' +import { useOctokitApp } from '../../utils/octokit' const querySchema = z.object({ text: z.string(), -}); +}) export default defineEventHandler(async (event) => { - const query = await getValidatedQuery(event, (data) => - querySchema.parse(data), - ); + const query = await getValidatedQuery(event, data => querySchema.parse(data)) if (!query.text) { - return { nodes: [] }; + return { nodes: [] } } - const { signal } = toWebRequest(event); + const { signal } = toWebRequest(event) setResponseHeaders(event, { - "Content-Type": "text/event-stream", - "Cache-Control": "no-cache", - Connection: "keep-alive", - }); + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + 'Connection': 'keep-alive', + }) - const app = useOctokitApp(event); - const searchText = query.text.toLowerCase(); - const seen = new Set(); + const app = useOctokitApp(event) + const searchText = query.text.toLowerCase() - const stream = new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder(); - const send = (data: string) => - controller.enqueue(encoder.encode(`data: ${data}\n\n`)); + async function* iterateMatches() { + const seen = new Set() + + for await (const { installation } of app.eachInstallation.iterator()) { + if (signal.aborted) { + return + } try { - for await (const { installation } of app.eachInstallation.iterator()) { - if (signal.aborted) break; - - try { - const octokit = await app.getInstallationOctokit(installation.id); - const { data } = await octokit.request( - "GET /installation/repositories", - { per_page: 100 }, - ); - - for (const repo of data.repositories) { - if (repo.private || seen.has(repo.id)) continue; - - const name = repo.name.toLowerCase(); - const owner = repo.owner.login.toLowerCase(); - const score = Math.max( - stringSimilarity.compareTwoStrings(name, searchText), - stringSimilarity.compareTwoStrings(owner, searchText), - ); - - if ( - score > 0.3 || - name.includes(searchText) || - owner.includes(searchText) - ) { - seen.add(repo.id); - send( - JSON.stringify({ - id: repo.id, - name: repo.name, - owner: { - login: repo.owner.login, - avatarUrl: repo.owner.avatar_url, - }, - stars: repo.stargazers_count || 0, - }), - ); - } - } - } catch { - // Skip suspended installations + const octokit = await app.getInstallationOctokit(installation.id) + const { data } = await octokit.request( + 'GET /installation/repositories', + { per_page: 100 }, + ) + + for (const repo of data.repositories) { + if (repo.private || seen.has(repo.id)) { + continue + } + + const name = repo.name.toLowerCase() + const owner = repo.owner.login.toLowerCase() + const score = Math.max( + stringSimilarity.compareTwoStrings(name, searchText), + stringSimilarity.compareTwoStrings(owner, searchText), + ) + + if ( + score > 0.3 + || name.includes(searchText) + || owner.includes(searchText) + ) { + seen.add(repo.id) + yield JSON.stringify({ + id: repo.id, + name: repo.name, + owner: { + login: repo.owner.login, + avatarUrl: repo.owner.avatar_url, + }, + stars: repo.stargazers_count || 0, + }) } } + } + catch { + // Skip suspended installations + } + } - send("[DONE]"); - } catch (err) { - send(JSON.stringify({ error: (err as Error).message })); - } finally { - controller.close(); + yield '[DONE]' + } + + const stream = new ReadableStream({ + async start(controller) { + const send = (data: string) => { + controller.enqueue(`data: ${data}\n\n`) + } + + try { + for await (const match of iterateMatches()) { + send(match) + } + } + catch (err) { + send(JSON.stringify({ error: (err as Error).message })) + } + finally { + controller.close() } }, - }); + }) - return stream; -}); + return stream.pipeThrough(new TextEncoderStream()) +}) From d60433028bca51d23a743d4ad385d937c5f42c29 Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Sun, 1 Feb 2026 20:26:42 +0330 Subject: [PATCH 09/10] prettier --- packages/app/app/components/RepoSearch.vue | 105 ++++++++++----------- packages/app/server/api/repo/search.get.ts | 83 ++++++++-------- 2 files changed, 92 insertions(+), 96 deletions(-) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index 78573fd1..f3aa5b67 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -1,18 +1,18 @@ @@ -145,9 +144,7 @@ function openFirstResult() {
-
- Or try it on: -
+
Or try it on:
{ - const query = await getValidatedQuery(event, data => querySchema.parse(data)) + const query = await getValidatedQuery(event, (data) => + querySchema.parse(data), + ); if (!query.text) { - return { nodes: [] } + return { nodes: [] }; } - const { signal } = toWebRequest(event) + const { signal } = toWebRequest(event); setResponseHeaders(event, { - 'Content-Type': 'text/event-stream', - 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', - }) + "Content-Type": "text/event-stream", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }); - const app = useOctokitApp(event) - const searchText = query.text.toLowerCase() + const app = useOctokitApp(event); + const searchText = query.text.toLowerCase(); async function* iterateMatches() { - const seen = new Set() + const seen = new Set(); for await (const { installation } of app.eachInstallation.iterator()) { if (signal.aborted) { - return + return; } try { - const octokit = await app.getInstallationOctokit(installation.id) + const octokit = await app.getInstallationOctokit(installation.id); const { data } = await octokit.request( - 'GET /installation/repositories', + "GET /installation/repositories", { per_page: 100 }, - ) + ); for (const repo of data.repositories) { if (repo.private || seen.has(repo.id)) { - continue + continue; } - const name = repo.name.toLowerCase() - const owner = repo.owner.login.toLowerCase() + const name = repo.name.toLowerCase(); + const owner = repo.owner.login.toLowerCase(); const score = Math.max( stringSimilarity.compareTwoStrings(name, searchText), stringSimilarity.compareTwoStrings(owner, searchText), - ) + ); if ( - score > 0.3 - || name.includes(searchText) - || owner.includes(searchText) + score > 0.3 || + name.includes(searchText) || + owner.includes(searchText) ) { - seen.add(repo.id) + seen.add(repo.id); yield JSON.stringify({ id: repo.id, name: repo.name, @@ -65,37 +67,34 @@ export default defineEventHandler(async (event) => { avatarUrl: repo.owner.avatar_url, }, stars: repo.stargazers_count || 0, - }) + }); } } - } - catch { + } catch { // Skip suspended installations } } - yield '[DONE]' + yield "[DONE]"; } const stream = new ReadableStream({ async start(controller) { const send = (data: string) => { - controller.enqueue(`data: ${data}\n\n`) - } + controller.enqueue(`data: ${data}\n\n`); + }; try { for await (const match of iterateMatches()) { - send(match) + send(match); } - } - catch (err) { - send(JSON.stringify({ error: (err as Error).message })) - } - finally { - controller.close() + } catch (err) { + send(JSON.stringify({ error: (err as Error).message })); + } finally { + controller.close(); } }, - }) + }); - return stream.pipeThrough(new TextEncoderStream()) -}) + return stream.pipeThrough(new TextEncoderStream()); +}); From 908082c28c72a7f716933890ea1b255585e71f3b Mon Sep 17 00:00:00 2001 From: AmirSa12 Date: Mon, 2 Feb 2026 16:01:29 +0330 Subject: [PATCH 10/10] refactor --- packages/app/app/components/RepoSearch.vue | 11 +---- packages/app/server/api/repo/search.get.ts | 49 +++++++++++++++++++--- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/packages/app/app/components/RepoSearch.vue b/packages/app/app/components/RepoSearch.vue index f3aa5b67..02f9c97d 100644 --- a/packages/app/app/components/RepoSearch.vue +++ b/packages/app/app/components/RepoSearch.vue @@ -47,16 +47,7 @@ watch( return; } - // Insert sorted by stars, keep top 10 - const idx = searchResults.value.findIndex((r) => r.stars < repo.stars); - searchResults.value.splice( - idx === -1 ? searchResults.value.length : idx, - 0, - repo, - ); - if (searchResults.value.length > 10) { - searchResults.value.pop(); - } + searchResults.value.push(repo); } catch { // Skip malformed JSON } diff --git a/packages/app/server/api/repo/search.get.ts b/packages/app/server/api/repo/search.get.ts index b8241ee1..5592eb30 100644 --- a/packages/app/server/api/repo/search.get.ts +++ b/packages/app/server/api/repo/search.get.ts @@ -6,6 +6,21 @@ const querySchema = z.object({ text: z.string(), }); +const INSTALLATION_CACHE_TTL_MS = 5 * 60 * 1000; + +interface CachedRepos { + fetchedAt: number; + repos: Array<{ + id: number; + name: string; + private: boolean; + owner: { login: string; avatar_url: string }; + stargazers_count?: number; + }>; +} + +const installationRepoCache = new Map(); + export default defineEventHandler(async (event) => { const query = await getValidatedQuery(event, (data) => querySchema.parse(data), @@ -26,6 +41,32 @@ export default defineEventHandler(async (event) => { const app = useOctokitApp(event); const searchText = query.text.toLowerCase(); + async function getInstallationRepos(installationId: number) { + const cached = installationRepoCache.get(installationId); + const now = Date.now(); + + if (cached && now - cached.fetchedAt < INSTALLATION_CACHE_TTL_MS) { + return cached.repos; + } + + try { + const octokit = await app.getInstallationOctokit(installationId); + const { data } = await octokit.request("GET /installation/repositories", { + per_page: 100, + }); + installationRepoCache.set(installationId, { + fetchedAt: now, + repos: data.repositories, + }); + return data.repositories; + } catch { + if (cached) { + return cached.repos; + } + throw new Error("Unable to load repositories"); + } + } + async function* iterateMatches() { const seen = new Set(); @@ -35,13 +76,9 @@ export default defineEventHandler(async (event) => { } try { - const octokit = await app.getInstallationOctokit(installation.id); - const { data } = await octokit.request( - "GET /installation/repositories", - { per_page: 100 }, - ); + const repos = await getInstallationRepos(installation.id); - for (const repo of data.repositories) { + for (const repo of repos) { if (repo.private || seen.has(repo.id)) { continue; }