1+ import { PushCommit } from "@type-challenges/octokit-create-pull-request" ;
2+
3+ import { Action , Context , Github } from "./types" ;
4+
5+ export const getOthers = < A , B > ( condition : boolean , a : A , b : B ) : A | B => ( condition ? a : b ) ;
6+
7+ const action : Action = async ( github , context , core ) => {
8+ const owner = context . repo . owner ;
9+ const repo = context . repo . repo ;
10+ const payload = context . payload || { } ;
11+ const issue = payload . issue ;
12+ const no = context . issue . number ;
13+
14+ if ( ! issue ) return ;
15+
16+ const labels : string [ ] = ( issue . labels || [ ] ) . map ( ( i : any ) => i && i . name ) . filter ( Boolean ) ;
17+
18+ // add to registry directory
19+ if ( isRegistryDirectoryIssue ( labels ) ) {
20+ const body = normalizeBody ( issue . body || "" ) ;
21+
22+ let registryIssue : RegistryIssue ;
23+
24+ try {
25+ registryIssue = parseRegistryIssue ( body ) ;
26+ registryIssue . logo = await resolveLogoContent ( registryIssue . logo , core ) ;
27+ } catch ( error ) {
28+ const message = error instanceof Error ? error . message : "Unknown error" ;
29+ core . error ( `Failed to parse registry issue: ${ message } ` ) ;
30+ await updateComment ( github , context , RegistryMessages . issue_invalid_reply ) ;
31+ return ;
32+ }
33+
34+ const { data : user } = await github . rest . users . getByUsername ( {
35+ username : issue . user . login ,
36+ } ) ;
37+
38+ const registries = await readJsonFile < RegistriesMap > ( github , owner , repo , REGISTRIES_JSON_PATH ) ;
39+ const directory = await readJsonFile < RegistryDirectoryEntry [ ] > ( github , owner , repo , REGISTRY_DIRECTORY_JSON_PATH ) ;
40+
41+ const registryAlreadyExists = registries . hasOwnProperty ( registryIssue . name ) ;
42+
43+ registries [ registryIssue . name ] = registryIssue . url ;
44+ const nextDirectory = updateRegistryDirectory ( directory , registryIssue ) ;
45+
46+ const files : Record < string , string > = {
47+ [ REGISTRIES_JSON_PATH ] : `${ JSON . stringify ( registries , null , 2 ) } \n` ,
48+ [ REGISTRY_DIRECTORY_JSON_PATH ] : `${ JSON . stringify ( nextDirectory , null , 2 ) } \n` ,
49+ } ;
50+
51+ const userEmail = `${ user . id } +${ user . login } @users.noreply.github.com` ;
52+ const commitMessage = `${ registryAlreadyExists ? "chore" : "feat" } (registry): ${ registryAlreadyExists ? "update" : "add" } ${ registryIssue . name } ` ;
53+
54+ const { data : pulls } = await github . rest . pulls . list ( {
55+ owner,
56+ repo,
57+ state : "open" ,
58+ } ) ;
59+
60+ const existing_pull = pulls . find ( ( i ) => i . user ?. login === "github-actions[bot]" && i . title . startsWith ( `#${ no } ` ) ) ;
61+
62+ await PushCommit ( github as any , {
63+ owner,
64+ repo,
65+ base : "main" ,
66+ head : `pulls/${ no } ` ,
67+ changes : {
68+ files,
69+ commit : commitMessage ,
70+ author : {
71+ name : `${ user . name || user . id || user . login } ` ,
72+ email : userEmail ,
73+ } ,
74+ } ,
75+ fresh : ! existing_pull ,
76+ } ) ;
77+
78+ const replyBody = ( prNumber : number , status : "created" | "updated" ) => {
79+ const key : RegistryReplyKey = status === "created" ? "issue_created_reply" : "issue_updated_reply" ;
80+ return RegistryMessages [ key ] . replace ( "{0}" , prNumber . toString ( ) ) ;
81+ } ;
82+
83+ if ( existing_pull ) {
84+ await updateComment ( github , context , replyBody ( existing_pull . number , "updated" ) ) ;
85+ } else {
86+ const { data : pr } = await github . rest . pulls . create ( {
87+ owner,
88+ repo,
89+ base : "main" ,
90+ head : `pulls/${ no } ` ,
91+ title : `#${ no } - ${ registryIssue . name } registry directory update` ,
92+ body : RegistryMessages . pr_body . replace ( / { n o } / g, no . toString ( ) ) . replace ( / { n a m e } / g, registryIssue . name ) ,
93+ labels : [ "auto-generated" , "registry" , "directory" ] ,
94+ } ) ;
95+
96+ await github . rest . issues . addLabels ( {
97+ owner,
98+ repo,
99+ issue_number : pr . number ,
100+ labels : [ "auto-generated" , "registry" , "directory" ] ,
101+ } ) ;
102+
103+ await updateComment ( github , context , replyBody ( pr . number , "created" ) ) ;
104+ }
105+
106+ return ;
107+ }
108+
109+ } ;
110+
111+
112+ const REGISTRIES_JSON_PATH = "apps/v4/public/r/registries.json" ;
113+ const REGISTRY_DIRECTORY_JSON_PATH = "apps/v4/registry/directory.json" ;
114+
115+ const RegistryMessages = {
116+ issue_created_reply : "Thanks! PR #{0} has been created to update the registry directory." ,
117+ issue_updated_reply : "Thanks! PR #{0} has been updated with the latest registry directory changes." ,
118+ issue_invalid_reply : "Failed to parse the issue. Please ensure all required fields are filled in." ,
119+ pr_body :
120+ "This is an auto-generated PR that updates the registry directory for issue #{no}.\n\n" +
121+ "- Registry: {name}\n" +
122+ "- Source issue: #{no}" ,
123+ } as const ;
124+
125+ type RegistryReplyKey = keyof Pick < typeof RegistryMessages , "issue_created_reply" | "issue_updated_reply" > ;
126+
127+ type RegistriesMap = Record < string , string > ;
128+
129+ type RegistryDirectoryEntry = {
130+ name : string ;
131+ homepage : string ;
132+ url : string ;
133+ description : string ;
134+ logo : string ;
135+ } ;
136+
137+ type RegistryIssue = {
138+ name : string ;
139+ url : string ;
140+ homepage : string ;
141+ description : string ;
142+ logo : string ;
143+ } ;
144+
145+ function isRegistryDirectoryIssue ( labels : string [ ] ) {
146+ return [ "registry" , "directory" ] . every ( ( label ) => labels . includes ( label ) ) ;
147+ }
148+
149+ function normalizeBody ( body : string ) {
150+ return body . replace ( / \r \n / g, "\n" ) ;
151+ }
152+
153+ function parseRegistryIssue ( body : string ) : RegistryIssue {
154+ const name = ensureField ( extractSection ( body , "Name" ) , "Name" ) ;
155+ const url = ensureField ( extractSection ( body , "URL" ) , "URL" ) ;
156+ const homepage = ensureField ( extractSection ( body , "Homepage" ) , "Homepage" ) ;
157+ const description = ensureField ( extractSection ( body , "Description" ) , "Description" ) ;
158+ const logoRaw = ensureField ( extractSection ( body , "Logo" ) , "Logo" ) ;
159+
160+ const normalizedName = name . startsWith ( "@" ) ? name : `@${ name } ` ;
161+
162+ return {
163+ name : normalizedName ,
164+ url : url . trim ( ) ,
165+ homepage : homepage . trim ( ) ,
166+ description : description . trim ( ) ,
167+ logo : normalizeLogoValue ( logoRaw ) ,
168+ } ;
169+ }
170+
171+ function ensureField ( value : string | null , field : string ) {
172+ if ( ! value || ! value . trim ( ) ) throw new Error ( `Missing "${ field } " in the issue body.` ) ;
173+ return value . trim ( ) ;
174+ }
175+
176+ function extractSection ( body : string , heading : string ) {
177+ const escapedHeading = heading . replace ( / [ . * + ? ^ $ { } ( ) | [ \] \\ ] / g, "\\$&" ) ;
178+ const regex = new RegExp ( `###\\s+${ escapedHeading } \\s*\\n([\\s\\S]*?)(?=\\n###\\s+|\\n-\\s*\\[|$)` , "i" ) ;
179+ const match = body . match ( regex ) ;
180+ if ( ! match ) return null ;
181+ return match [ 1 ] . trim ( ) ;
182+ }
183+
184+ function stripCodeFence ( value : string ) {
185+ const trimmed = value . trim ( ) ;
186+ const codeFenceRegex = / ^ ` ` ` [ a - z 0 - 9 - ] * \n ( [ \s \S ] * ?) \n ` ` ` $ / i;
187+ const match = trimmed . match ( codeFenceRegex ) ;
188+ if ( match && match [ 1 ] ) {
189+ return match [ 1 ] . trim ( ) ;
190+ }
191+ return trimmed ;
192+ }
193+
194+ function normalizeLogoValue ( value : string ) {
195+ const unwrapped = stripCodeFence ( value ) ;
196+ const markdownImageMatch = unwrapped . match ( / ! \[ [ ^ \] ] * \] \( ( [ ^ ) ] + ) \) / ) ;
197+ if ( markdownImageMatch && markdownImageMatch [ 1 ] ) {
198+ return markdownImageMatch [ 1 ] . trim ( ) ;
199+ }
200+ return unwrapped . trim ( ) ;
201+ }
202+
203+ async function resolveLogoContent ( value : string , core : typeof import ( "@actions/core" ) ) : Promise < string > {
204+ const trimmed = value . trim ( ) ;
205+
206+ if ( isProbablyUrl ( trimmed ) ) {
207+ try {
208+ const fetchFn = ( ( globalThis as unknown as { fetch ?: ( input : any , init ?: any ) => Promise < any > } ) . fetch ) ;
209+
210+ if ( ! fetchFn ) {
211+ throw new Error ( "Global fetch is not available in this runtime." ) ;
212+ }
213+
214+ const response = await fetchFn ( trimmed ) ;
215+
216+ if ( ! response || typeof response . ok !== "boolean" ) {
217+ throw new Error ( "Unexpected response from fetch." ) ;
218+ }
219+
220+ if ( ! response . ok ) {
221+ throw new Error ( `Request failed with status ${ response . status } ` ) ;
222+ }
223+
224+ const headers = response . headers && typeof response . headers . get === "function" ? response . headers : null ;
225+ const contentType = headers ?. get ( "content-type" ) || "" ;
226+ if ( ! contentType . toLowerCase ( ) . includes ( "image/svg" ) ) {
227+ throw new Error ( `Expected SVG content but received "${ contentType } "` ) ;
228+ }
229+
230+ if ( typeof response . text !== "function" ) {
231+ throw new Error ( "Response.text() is not available." ) ;
232+ }
233+
234+ const svg = await response . text ( ) ;
235+ return typeof svg === "string" ? svg . trim ( ) : "" ;
236+ } catch ( error ) {
237+ const message = error instanceof Error ? error . message : "Unknown error" ;
238+ throw new Error ( `Unable to download logo SVG: ${ message } ` ) ;
239+ }
240+ }
241+
242+ return trimmed ;
243+ }
244+
245+ function isProbablyUrl ( value : string ) {
246+ return / ^ h t t p s ? : \/ \/ / i. test ( value ) ;
247+ }
248+
249+ async function readJsonFile < T > ( github : Github , owner : string , repo : string , path : string ) : Promise < T > {
250+ const { data } = await github . rest . repos . getContent ( { owner, repo, path } ) ;
251+
252+ if ( Array . isArray ( data ) || data . type !== "file" || ! ( "content" in data ) ) {
253+ throw new Error ( `Unable to read JSON content from ${ path } ` ) ;
254+ }
255+
256+ const decoded = Buffer . from ( data . content , data . encoding as BufferEncoding ) . toString ( "utf8" ) ;
257+
258+ try {
259+ return JSON . parse ( decoded ) as T ;
260+ } catch ( error ) {
261+ throw new Error ( `Failed to parse JSON from ${ path } : ${ ( error as Error ) . message } ` ) ;
262+ }
263+ }
264+
265+ function updateRegistryDirectory ( directory : RegistryDirectoryEntry [ ] , data : RegistryIssue ) {
266+ const nextDirectory = [ ...directory ] ;
267+ const entry : RegistryDirectoryEntry = {
268+ name : data . name ,
269+ homepage : data . homepage ,
270+ url : data . url ,
271+ description : data . description ,
272+ logo : data . logo ,
273+ } ;
274+
275+ const existingIndex = nextDirectory . findIndex ( ( item ) => item . name === data . name ) ;
276+
277+ if ( existingIndex >= 0 ) {
278+ nextDirectory [ existingIndex ] = entry ;
279+ } else {
280+ nextDirectory . push ( entry ) ;
281+ }
282+
283+ return nextDirectory ;
284+ }
285+
286+ async function updateComment ( github : Github , context : Context , body : string ) {
287+ const { data : comments } = await github . rest . issues . listComments ( {
288+ issue_number : context . issue . number ,
289+ owner : context . repo . owner ,
290+ repo : context . repo . repo ,
291+ } ) ;
292+
293+ const existing_comment = comments . find ( ( i ) => i . user ?. login === "github-actions[bot]" ) ;
294+
295+ if ( existing_comment ) {
296+ return await github . rest . issues . updateComment ( {
297+ comment_id : existing_comment . id ,
298+ issue_number : context . issue . number ,
299+ owner : context . repo . owner ,
300+ repo : context . repo . repo ,
301+ body,
302+ } ) ;
303+ } else {
304+ return await github . rest . issues . createComment ( {
305+ issue_number : context . issue . number ,
306+ owner : context . repo . owner ,
307+ repo : context . repo . repo ,
308+ body,
309+ } ) ;
310+ }
311+ }
312+
313+ export default action ;
314+
315+ export { parseRegistryIssue } ;
0 commit comments