11import stringSimilarity from "string-similarity" ;
22import { z } from "zod" ;
3+ import { useBucket } from "../../utils/bucket" ;
34import { useOctokitApp } from "../../utils/octokit" ;
45
56const querySchema = z . object ( {
67 text : z . string ( ) ,
78} ) ;
89
9- const INSTALLATION_CACHE_TTL_MS = 5 * 60 * 1000 ;
10+ const REPO_INDEX_CACHE_KEY = "repo-search:index" ;
11+ const REPO_INDEX_CACHE_TTL_MS = 5 * 60 * 1000 ;
1012
11- interface CachedRepos {
12- fetchedAt : number ;
13- repos : Array < {
14- id : number ;
15- name : string ;
16- private : boolean ;
17- owner : { login : string ; avatar_url : string } ;
18- stargazers_count ?: number ;
19- } > ;
13+ interface RepoSearchIndexItem {
14+ id : number ;
15+ name : string ;
16+ ownerLogin : string ;
17+ ownerAvatarUrl : string ;
18+ stars : number ;
2019}
2120
22- const installationRepoCache = new Map < number , CachedRepos > ( ) ;
21+ interface RepoSearchIndexCache {
22+ fetchedAt : number ;
23+ repos : RepoSearchIndexItem [ ] ;
24+ }
2325
2426export default defineEventHandler ( async ( event ) => {
2527 const query = await getValidatedQuery ( event , ( data ) =>
2628 querySchema . parse ( data ) ,
2729 ) ;
28-
2930 if ( ! query . text ) {
3031 return { nodes : [ ] } ;
3132 }
3233
3334 const { signal } = toWebRequest ( event ) ;
34-
35- setResponseHeaders ( event , {
36- "Content-Type" : "text/event-stream" ,
37- "Cache-Control" : "no-cache" ,
38- Connection : "keep-alive" ,
39- } ) ;
40-
41- const app = useOctokitApp ( event ) ;
4235 const searchText = query . text . toLowerCase ( ) ;
36+ const app = useOctokitApp ( event ) ;
37+ const bucket = useBucket ( event ) ;
4338
44- async function getInstallationRepos ( installationId : number ) {
45- const cached = installationRepoCache . get ( installationId ) ;
46- const now = Date . now ( ) ;
47-
48- if ( cached && now - cached . fetchedAt < INSTALLATION_CACHE_TTL_MS ) {
49- return cached . repos ;
50- }
51-
52- try {
53- const octokit = await app . getInstallationOctokit ( installationId ) ;
54- const { data } = await octokit . request ( "GET /installation/repositories" , {
55- per_page : 100 ,
56- } ) ;
57- installationRepoCache . set ( installationId , {
58- fetchedAt : now ,
59- repos : data . repositories ,
60- } ) ;
61- return data . repositories ;
62- } catch {
63- if ( cached ) {
64- return cached . repos ;
65- }
66- throw new Error ( "Unable to load repositories" ) ;
67- }
68- }
69-
70- async function * iterateMatches ( ) {
39+ const fetchInstalledRepos = async ( ) => {
7140 const seen = new Set < number > ( ) ;
41+ const repos : RepoSearchIndexItem [ ] = [ ] ;
7242
7343 for await ( const { installation } of app . eachInstallation . iterator ( ) ) {
7444 if ( signal . aborted ) {
75- return ;
45+ break ;
7646 }
7747
7848 try {
79- const repos = await getInstallationRepos ( installation . id ) ;
80-
81- for ( const repo of repos ) {
82- if ( repo . private || seen . has ( repo . id ) ) {
83- continue ;
84- }
85-
86- const name = repo . name . toLowerCase ( ) ;
87- const owner = repo . owner . login . toLowerCase ( ) ;
88- const score = Math . max (
89- stringSimilarity . compareTwoStrings ( name , searchText ) ,
90- stringSimilarity . compareTwoStrings ( owner , searchText ) ,
49+ const octokit = await app . getInstallationOctokit ( installation . id ) ;
50+ let page = 1 ;
51+
52+ while ( true ) {
53+ const { data } = await octokit . request (
54+ "GET /installation/repositories" ,
55+ {
56+ page,
57+ per_page : 100 ,
58+ } ,
9159 ) ;
9260
93- if (
94- score > 0.3 ||
95- name . includes ( searchText ) ||
96- owner . includes ( searchText )
97- ) {
61+ for ( const repo of data . repositories ) {
62+ if ( repo . private || seen . has ( repo . id ) ) {
63+ continue ;
64+ }
9865 seen . add ( repo . id ) ;
99- yield JSON . stringify ( {
66+ repos . push ( {
10067 id : repo . id ,
10168 name : repo . name ,
102- owner : {
103- login : repo . owner . login ,
104- avatarUrl : repo . owner . avatar_url ,
105- } ,
69+ ownerLogin : repo . owner . login ,
70+ ownerAvatarUrl : repo . owner . avatar_url ,
10671 stars : repo . stargazers_count || 0 ,
10772 } ) ;
10873 }
74+
75+ if ( data . repositories . length < 100 ) {
76+ break ;
77+ }
78+ page += 1 ;
10979 }
11080 } catch {
11181 // Skip suspended installations
11282 }
11383 }
11484
115- yield "[DONE]" ;
116- }
85+ return repos ;
86+ } ;
87+
88+ const getIndexedRepos = async ( ) => {
89+ const now = Date . now ( ) ;
90+ const cached =
91+ await bucket . getItem < RepoSearchIndexCache > ( REPO_INDEX_CACHE_KEY ) ;
92+
93+ if ( cached && now - cached . fetchedAt < REPO_INDEX_CACHE_TTL_MS ) {
94+ return cached . repos ;
95+ }
96+
97+ const repos = await fetchInstalledRepos ( ) ;
98+ if ( ! signal . aborted ) {
99+ await bucket . setItem ( REPO_INDEX_CACHE_KEY , {
100+ fetchedAt : now ,
101+ repos,
102+ } ) ;
103+ }
104+
105+ return repos ;
106+ } ;
107+
108+ setResponseHeaders ( event , {
109+ "Content-Type" : "text/event-stream" ,
110+ "Cache-Control" : "no-cache" ,
111+ Connection : "keep-alive" ,
112+ } ) ;
117113
118114 const stream = new ReadableStream < string > ( {
119115 async start ( controller ) {
@@ -122,9 +118,52 @@ export default defineEventHandler(async (event) => {
122118 } ;
123119
124120 try {
125- for await ( const match of iterateMatches ( ) ) {
126- send ( match ) ;
121+ const repos = await getIndexedRepos ( event , signal ) ;
122+ const matches = repos
123+ . map ( ( repo ) => {
124+ const name = repo . name . toLowerCase ( ) ;
125+ const owner = repo . ownerLogin . toLowerCase ( ) ;
126+ const score = Math . max (
127+ stringSimilarity . compareTwoStrings ( name , searchText ) ,
128+ stringSimilarity . compareTwoStrings ( owner , searchText ) ,
129+ ) ;
130+
131+ if (
132+ score <= 0.3 &&
133+ ! name . includes ( searchText ) &&
134+ ! owner . includes ( searchText )
135+ ) {
136+ return null ;
137+ }
138+
139+ return {
140+ ...repo ,
141+ score,
142+ } ;
143+ } )
144+ . filter (
145+ ( repo ) : repo is RepoSearchIndexItem & { score : number } => ! ! repo ,
146+ )
147+ . sort ( ( a , b ) => b . score - a . score || b . stars - a . stars ) ;
148+
149+ for ( const repo of matches ) {
150+ if ( signal . aborted ) {
151+ break ;
152+ }
153+ send (
154+ JSON . stringify ( {
155+ id : repo . id ,
156+ name : repo . name ,
157+ owner : {
158+ login : repo . ownerLogin ,
159+ avatarUrl : repo . ownerAvatarUrl ,
160+ } ,
161+ stars : repo . stars ,
162+ } ) ,
163+ ) ;
127164 }
165+
166+ send ( "[DONE]" ) ;
128167 } catch ( err ) {
129168 send ( JSON . stringify ( { error : ( err as Error ) . message } ) ) ;
130169 } finally {
0 commit comments