2024-07-18 22:39:57 +02:00
import { setTimeout } from 'timers/promises' ;
2024-01-18 09:04:43 +01:00
import { ApplicationFunction , Probot } from 'probot' ;
2024-03-28 07:18:23 +01:00
import { throttleAll } from 'promise-throttle-all' ;
2022-09-21 22:39:54 +02:00
import getMetaData from 'metadata-scraper' ;
2022-09-12 12:45:19 +02:00
2023-01-27 14:22:17 +01:00
import { getPlaylistIdFromUrl } from './getPlaylistIdFromUrl' ;
2022-10-09 15:02:17 +02:00
type ReviewEvent = 'REQUEST_CHANGES' | 'COMMENT' | 'APPROVE' ;
2024-01-18 09:04:43 +01:00
const appFn : ApplicationFunction = ( app : Probot , { getRouter } ) = > {
2024-07-18 22:39:57 +02:00
getRouter ! ( '/ping' ) . get ( '/pong' , ( _ , res ) = > res . sendStatus ( 200 ) ) ;
2024-01-18 09:04:43 +01:00
2022-09-12 20:58:06 +02:00
app . on (
[ 'pull_request.opened' , 'pull_request.synchronize' ] ,
2024-01-17 22:41:21 +01:00
async ( { payload , octokit , log } ) = > {
2022-09-12 20:58:06 +02:00
const registryDirectoryPath = 'playlists/registry/' ;
const siQueryStart = '?si=' ;
2022-09-13 15:32:52 +02:00
2023-01-27 14:22:17 +01:00
const pull_number = payload . number ;
2022-09-21 22:39:54 +02:00
const workingRepo = {
2023-01-27 14:22:17 +01:00
owner : payload.repository.owner.login ,
repo : payload.repository.name
2022-09-12 20:58:06 +02:00
} ;
2023-01-27 15:39:28 +01:00
const removeRegistryPathFromFilename = ( filename : string ) = >
2022-09-12 20:58:06 +02:00
filename . replace ( registryDirectoryPath , '' ) ;
2022-09-21 22:39:54 +02:00
const upsertReview = async (
review_id : number | undefined ,
2023-01-27 15:39:28 +01:00
event : ReviewEvent ,
body : string
2022-09-21 22:39:54 +02:00
) = > {
2022-09-12 20:58:06 +02:00
if ( review_id ) {
2023-01-27 14:22:17 +01:00
await octokit . pulls . updateReview ( {
2022-09-21 22:39:54 +02:00
. . . workingRepo ,
2022-09-12 20:58:06 +02:00
pull_number ,
review_id ,
body
} ) ;
} else {
2023-01-27 14:22:17 +01:00
await octokit . pulls . createReview ( {
2022-09-21 22:39:54 +02:00
. . . workingRepo ,
2022-09-12 20:58:06 +02:00
pull_number ,
2022-09-21 22:39:54 +02:00
event ,
body
2022-09-12 20:58:06 +02:00
} ) ;
}
} ;
2024-01-17 19:56:31 +01:00
const repoAllowlist = [
{ owner : 'mackorone' , repo : 'spotify-playlist-archive' } ,
{ owner : 'maciejpedzich' , repo : 'bot-testing-ground' }
] ;
2023-02-23 18:48:41 +01:00
2024-01-17 19:56:31 +01:00
try {
2022-12-23 14:46:07 +01:00
const isAllowlistedRepo = repoAllowlist . find (
2022-09-14 06:53:05 +02:00
( { owner , repo } ) = >
2022-09-21 22:39:54 +02:00
workingRepo . owner === owner && workingRepo . repo === repo
2022-09-14 06:53:05 +02:00
) ;
2022-12-23 14:46:07 +01:00
if ( ! isAllowlistedRepo ) return ;
2022-09-14 06:53:05 +02:00
2024-07-17 16:26:07 +02:00
type PRFileArray = Awaited <
ReturnType < typeof octokit.pulls.listFiles >
> [ 'data' ] ;
const prFiles : PRFileArray = [ ] ;
let page = 1 ;
2024-07-18 22:39:57 +02:00
let isLoadingPages = true ;
2024-07-17 16:26:07 +02:00
let timeToRateLimitReset = 0 ;
2024-07-18 22:39:57 +02:00
while ( isLoadingPages ) {
await setTimeout ( timeToRateLimitReset ) ;
2024-07-17 16:26:07 +02:00
const { data , headers } = await octokit . pulls . listFiles ( {
. . . workingRepo ,
pull_number ,
page
} ) ;
prFiles . push ( . . . data ) ;
let now = Date . now ( ) ;
timeToRateLimitReset =
headers [ 'x-ratelimit-remaining' ] !== '0'
? 0
: ( Number ( headers [ 'x-ratelimit-reset' ] ) || now ) - now ;
if ( headers . link ? . includes ( ` rel= \ "next \ " ` ) ) page ++ ;
2024-07-18 22:39:57 +02:00
else isLoadingPages = false ;
2024-07-17 16:26:07 +02:00
}
2022-09-12 20:58:06 +02:00
const filesToVerify = prFiles . filter (
( { status , filename } ) = >
2023-02-23 18:48:41 +01:00
filename . startsWith ( registryDirectoryPath ) &&
[ 'added' , 'modified' ] . includes ( status )
2022-09-12 20:58:06 +02:00
) ;
2022-12-23 14:46:07 +01:00
if ( filesToVerify . length === 0 ) return ;
2024-07-18 22:39:57 +02:00
let numEntriesBeforeCooldown = 3 ;
let numProcessedEntries = 0 ;
let cooldownTimeout = 1500 ;
2024-03-28 07:18:23 +01:00
const playlistSearchResults = await throttleAll (
2024-03-30 08:37:04 +01:00
1 ,
2024-03-28 07:18:23 +01:00
filesToVerify . map ( ( { filename } ) = > async ( ) = > {
2023-01-27 15:39:28 +01:00
const filenameWithoutRegistryPath = removeRegistryPathFromFilename (
filename
) . replace ( 'https:/' , 'https://' ) ;
2023-01-27 14:22:17 +01:00
2023-01-27 15:39:28 +01:00
const url = getPlaylistIdFromUrl ( filenameWithoutRegistryPath )
2023-01-27 15:48:17 +01:00
? filenameWithoutRegistryPath
2023-01-27 15:39:28 +01:00
: ` https://open.spotify.com/playlist/ ${ filenameWithoutRegistryPath } ` ;
2022-09-21 22:39:54 +02:00
2024-07-18 22:39:57 +02:00
if (
numProcessedEntries > 0 &&
numProcessedEntries % numEntriesBeforeCooldown === 0
)
await setTimeout ( cooldownTimeout ) ;
2022-09-21 22:39:54 +02:00
const spotifyResponse = await fetch ( url ) ;
2023-02-23 18:48:41 +01:00
const expectedStatusCodes = [ 200 , 400 , 404 ] ;
2022-09-22 06:14:31 +02:00
2024-03-28 07:18:23 +01:00
if ( ! expectedStatusCodes . includes ( spotifyResponse . status ) )
throw new Error (
2024-07-18 22:39:57 +02:00
` Received ${ spotifyResponse . status } status code from ${ url } `
2024-03-28 07:18:23 +01:00
) ;
2022-12-23 14:46:07 +01:00
const found = spotifyResponse . status === 200 ;
2023-02-02 17:01:45 +01:00
let details = '' ;
2022-09-21 22:39:54 +02:00
if ( found ) {
const html = await spotifyResponse . text ( ) ;
2024-07-17 16:26:07 +02:00
const { author : authorUrl , description } = await getMetaData ( {
2024-03-30 08:37:04 +01:00
html ,
customRules : {
author : {
rules : [
[
'meta[name="music:creator"]' ,
( e ) = > e . getAttribute ( 'content' )
]
]
}
}
} ) ;
let authorName = ( authorUrl as string ) . endsWith ( '/user/spotify' )
2024-03-28 08:37:55 +01:00
? 'Spotify'
2024-03-30 08:37:04 +01:00
: '' ;
if ( authorName === '' ) {
const playlistAuthorResponse = await fetch ( authorUrl as string ) ;
if ( ! playlistAuthorResponse . ok )
throw new Error (
2024-07-18 22:39:57 +02:00
` Received ${ playlistAuthorResponse . status } status code from ${ authorUrl } `
2024-03-30 08:37:04 +01:00
) ;
const authorPageHtml = await playlistAuthorResponse . text ( ) ;
const { title : authorPageTitle } = await getMetaData ( {
html : authorPageHtml
} ) ;
authorName = authorPageTitle as string ;
}
2024-03-28 08:37:55 +01:00
2022-09-21 22:39:54 +02:00
const playlistMeta = ( description || '' )
. split ( ' · ' )
2024-03-28 08:37:55 +01:00
. filter ( ( text ) = > text !== 'Playlist' )
2024-03-30 08:37:04 +01:00
. concat ( authorName as string ) ;
2022-09-21 22:39:54 +02:00
2024-01-17 22:41:21 +01:00
details = playlistMeta . join ( ' · ' ) ;
2022-09-21 22:39:54 +02:00
}
2022-09-12 20:58:06 +02:00
2024-07-18 22:39:57 +02:00
numProcessedEntries ++ ;
2022-09-12 20:58:06 +02:00
return {
2023-01-27 15:39:28 +01:00
filename : filenameWithoutRegistryPath ,
2022-09-21 22:39:54 +02:00
found ,
2023-02-02 17:01:45 +01:00
details ,
2022-09-21 22:39:54 +02:00
url
2022-09-12 20:58:06 +02:00
} ;
} )
) ;
2023-01-27 15:39:28 +01:00
let successText = ` 🎉 @ ${ workingRepo . owner } can merge your pull request! 🎉 ` ;
let reviewEvent : ReviewEvent = 'APPROVE' ;
let identifiedPlaylistsText = '' ;
2023-02-23 18:48:41 +01:00
const validEntries = playlistSearchResults . filter (
2023-02-02 17:46:58 +01:00
( { found , filename , url } ) = >
found && ! filename . includes ( siQueryStart ) && filename !== url
2022-09-21 22:39:54 +02:00
) ;
if ( validEntries . length > 0 ) {
const playlistLinks = validEntries
2023-02-02 17:01:45 +01:00
. map ( ( { url , details } ) = > ` - [ ${ details } ]( ${ url } ) ` )
2022-09-21 22:39:54 +02:00
. join ( '\n' ) ;
2022-12-23 14:46:07 +01:00
identifiedPlaylistsText = ` ### ✅ These playlists have been indentified: \ n ${ playlistLinks } ` ;
2022-09-21 22:39:54 +02:00
}
2022-09-12 20:58:06 +02:00
2023-02-02 17:01:45 +01:00
let renameRequiredText = '' ;
2023-02-23 18:48:41 +01:00
const entriesToRename = playlistSearchResults . filter (
2023-01-27 15:39:28 +01:00
( { found , filename } ) = >
found &&
2023-01-28 12:43:06 +01:00
filename . includes ( siQueryStart ) &&
! getPlaylistIdFromUrl ( filename )
2023-01-27 15:39:28 +01:00
) ;
2023-02-02 17:01:45 +01:00
if ( entriesToRename . length > 0 ) {
const renameList = entriesToRename
2022-09-13 10:03:48 +02:00
. map ( ( { filename } ) = > {
2023-01-28 12:43:06 +01:00
const filenameWithoutRegistryPath =
2023-01-27 15:39:28 +01:00
removeRegistryPathFromFilename ( filename ) ;
2023-01-27 14:22:17 +01:00
2023-01-28 12:43:06 +01:00
const [ targetFilename ] =
filenameWithoutRegistryPath . split ( siQueryStart ) ;
2022-09-13 10:03:48 +02:00
2023-01-28 12:43:06 +01:00
return ` - From \` ${ filenameWithoutRegistryPath } \` to ** ${ targetFilename } ** ` ;
2022-09-13 10:03:48 +02:00
} )
. join ( '\n' ) ;
2022-09-21 22:39:54 +02:00
successText = '' ;
reviewEvent = 'REQUEST_CHANGES' ;
2023-02-02 17:01:45 +01:00
renameRequiredText = ` ### ⚠️ You have to rename these entries: \ n ${ renameList } ` ;
2023-01-28 12:43:06 +01:00
}
let urlEntriesToRenameText = '' ;
2023-02-23 18:48:41 +01:00
const urlFilenameEntries = playlistSearchResults . filter (
2023-02-02 17:01:45 +01:00
( { filename , url } ) = > filename === url
2023-01-28 12:43:06 +01:00
) ;
if ( urlFilenameEntries . length > 0 ) {
successText = '' ;
2023-02-02 17:01:45 +01:00
2023-02-02 17:46:58 +01:00
const forkPageUrl = payload . pull_request . head . repo . html_url ;
const httpsDirUrl = ` ${ forkPageUrl } /tree/main/playlists/registry/https: ` ;
const baseCreateUrl = ` ${ forkPageUrl } /new/main/playlists/registry/FOO ` ;
2023-02-02 17:01:45 +01:00
const linkList = urlFilenameEntries . map ( ( { url } ) = > {
const playlistId = getPlaylistIdFromUrl ( url ) ;
2023-02-02 17:46:58 +01:00
const createFilePageUrl = ` ${ baseCreateUrl } ?filename= ${ playlistId } &value=REMOVE%20THIS%20TEXT%20FIRST ` ;
2023-02-02 17:01:45 +01:00
return ` \ t- [Create \` ${ playlistId } \` ]( ${ createFilePageUrl } ) ` ;
} ) ;
2023-01-28 12:43:06 +01:00
reviewEvent = 'REQUEST_CHANGES' ;
2023-02-02 17:46:58 +01:00
urlEntriesToRenameText = ` ### ⚠️ Some entries are malformed playlist URLs \ n \ nHere's how you can correct them: \ n \ n1. Go to [the \` https: \` folder]( ${ httpsDirUrl } ), click on the three dots on the right-hand side, and choose _Delete directory_ \ n \ n2. Use the links below to create valid entries: \ n ${ linkList } ` ;
2022-09-21 22:39:54 +02:00
}
2022-09-12 20:58:06 +02:00
2023-01-27 15:39:28 +01:00
let notFoundText = '' ;
2023-02-23 18:48:41 +01:00
const notFoundPlaylists = playlistSearchResults . filter (
2023-01-27 15:39:28 +01:00
( { found } ) = > ! found
) ;
2023-01-27 14:22:17 +01:00
if ( notFoundPlaylists . length > 0 ) {
2023-02-02 17:01:45 +01:00
const notFoundList = notFoundPlaylists
2023-01-27 14:22:17 +01:00
. map ( ( { filename } ) = > ` - ${ filename } ` )
. join ( '\n' ) ;
successText = '' ;
reviewEvent = 'REQUEST_CHANGES' ;
2023-02-02 17:01:45 +01:00
notFoundText = ` ### ❌ These entries don't point to any existing public playlists: \ n ${ notFoundList } ` ;
2023-01-27 14:22:17 +01:00
}
2022-09-21 22:39:54 +02:00
const reviewBody = [
identifiedPlaylistsText ,
2023-02-02 17:01:45 +01:00
renameRequiredText ,
2023-01-28 12:43:06 +01:00
urlEntriesToRenameText ,
2022-09-21 22:39:54 +02:00
notFoundText ,
successText
]
. filter ( Boolean )
. join ( '\n\n' ) ;
2023-01-27 15:39:28 +01:00
const { data : reviews } = await octokit . pulls . listReviews ( {
2022-09-21 22:39:54 +02:00
. . . workingRepo ,
2023-01-27 15:39:28 +01:00
pull_number
2022-09-12 20:58:06 +02:00
} ) ;
2023-01-27 15:39:28 +01:00
const [ existingReview ] = reviews ;
await upsertReview ( existingReview ? . id , reviewEvent , reviewBody ) ;
2024-01-17 22:41:21 +01:00
} catch ( e ) {
const error = e as Error ;
log . error ( { stack : error?.stack } , error . message ) ;
2023-01-27 15:39:28 +01:00
await upsertReview (
undefined ,
'COMMENT' ,
2024-07-18 22:39:57 +02:00
` Something went wrong while validating new entries! @ ${ workingRepo . owner } should handle it shortly. `
2023-01-27 15:39:28 +01:00
) ;
2022-09-12 20:58:06 +02:00
}
}
) ;
2022-09-12 12:45:19 +02:00
} ;
2024-01-15 07:20:19 +01:00
2024-01-17 19:56:31 +01:00
export = appFn ;