2022-10-01 20:01:29 +08:00
/ * *
* @ file This script is used to preview the theme on theme PRs .
* /
import * as dotenv from "dotenv" ;
dotenv . config ( ) ;
2022-10-02 17:05:13 +08:00
import { debug , setFailed } from "@actions/core" ;
2022-09-24 16:20:54 +08:00
import github from "@actions/github" ;
import ColorContrastChecker from "color-contrast-checker" ;
2022-10-01 20:01:29 +08:00
import { info } from "console" ;
2022-09-24 16:20:54 +08:00
import Hjson from "hjson" ;
import snakeCase from "lodash.snakecase" ;
import parse from "parse-diff" ;
2022-10-01 20:01:29 +08:00
import { inspect } from "util" ;
2022-10-02 16:33:29 +08:00
import { isValidHexColor } from "../src/common/utils.js" ;
2022-10-01 20:01:29 +08:00
import { themes } from "../themes/index.js" ;
2022-10-02 17:05:13 +08:00
import { getGithubToken , getRepoInfo } from "./helpers.js" ;
2021-11-05 23:31:46 +08:00
2022-10-02 17:05:13 +08:00
// Script variables.
2022-10-01 20:01:29 +08:00
const COMMENTER = "github-actions[bot]" ;
2021-11-06 22:35:35 +08:00
const COMMENT _TITLE = "Automated Theme Preview" ;
2022-10-01 20:01:29 +08:00
const THEME _PR _FAIL _TEXT = ":x: Theme PR does not adhere to our guidelines." ;
const THEME _PR _SUCCESS _TEXT =
":heavy_check_mark: Theme PR does adhere to our guidelines." ;
const FAIL _TEXT = `
2022-10-07 17:13:01 +08:00
\ rUnfortunately , your theme PR contains an error or does not adhere to our [ theme guidelines ] ( https : //github.com/anuraghazra/github-readme-stats/blob/master/CONTRIBUTING.md#themes-contribution). Please fix the issues below, and we will review your\
2022-10-01 20:01:29 +08:00
\ r PR again . This pull request will * * automatically close in 15 days * * if no changes are made . After this time , you must re - open the PR for it to be reviewed .
` ;
const THEME _CONTRIB _GUIDELINESS = `
\ rHi , thanks for the theme contribution . Please read our theme [ contribution guidelines ] ( https : //github.com/anuraghazra/github-readme-stats/blob/master/CONTRIBUTING.md#themes-contribution).
\ rWe are currently only accepting color combinations from any VSCode theme or themes with good colour combinations to minimize bloating the themes collection .
\ r > Also , note that if this theme is exclusively for your personal use , then instead of adding it to our theme collection , you can use card [ customization options ] ( https : //github.com/anuraghazra/github-readme-stats#customization).
` ;
2022-10-02 23:41:28 +08:00
const COLOR _PROPS = {
title _color : 6 ,
icon _color : 6 ,
text _color : 6 ,
bg _color : 8 ,
border _color : 6 ,
} ;
const ACCEPTED _COLOR _PROPS = Object . keys ( COLOR _PROPS ) ;
const REQUIRED _COLOR _PROPS = ACCEPTED _COLOR _PROPS . slice ( 0 , 4 ) ;
2022-10-01 20:01:29 +08:00
const INVALID _REVIEW _COMMENT = ( commentUrl ) =>
` Some themes are invalid. See the [Automated Theme Preview]( ${ commentUrl } ) comment above for more information. ` ;
/ * *
* Retrieve PR number from the event payload .
*
* @ returns { number } PR number .
* /
const getPrNumber = ( ) => {
if ( process . env . MOCK _PR _NUMBER ) return process . env . MOCK _PR _NUMBER ; // For testing purposes.
2021-11-06 22:35:35 +08:00
2020-08-10 22:31:19 +08:00
const pullRequest = github . context . payload . pull _request ;
if ( ! pullRequest ) {
2022-10-01 20:01:29 +08:00
throw Error ( "Could not get pull request number from context" ) ;
2020-08-03 01:17:34 +08:00
}
2020-08-10 22:31:19 +08:00
return pullRequest . number ;
2022-10-01 20:01:29 +08:00
} ;
/ * *
* Retrieve the commenting user .
* @ returns { string } Commenting user .
* /
const getCommenter = ( ) => {
return process . env . COMMENTER ? process . env . COMMENTER : COMMENTER ;
} ;
/ * *
* Returns whether the comment is a preview comment .
*
* @ param { Object } inputs Action inputs .
* @ param { Object } comment Comment object .
* @ returns { boolean } Whether the comment is a preview comment .
* /
const isPreviewComment = ( inputs , comment ) => {
2021-11-06 22:35:35 +08:00
return (
( inputs . commentAuthor && comment . user
? comment . user . login === inputs . commentAuthor
: true ) &&
( inputs . bodyIncludes && comment . body
? comment . body . includes ( inputs . bodyIncludes )
: true )
) ;
2022-10-01 20:01:29 +08:00
} ;
/ * *
* Find the preview theme comment .
*
* @ param { Object } octokit Octokit instance .
* @ param { number } issueNumber Issue number .
* @ param { string } repo Repository name .
* @ param { string } owner Owner of the repository .
* @ returns { Object } The Github comment object .
* /
const findComment = async ( octokit , issueNumber , owner , repo , commenter ) => {
2021-11-06 22:35:35 +08:00
const parameters = {
2022-10-01 20:01:29 +08:00
owner ,
repo ,
2021-11-06 22:35:35 +08:00
issue _number : issueNumber ,
} ;
const inputs = {
2022-10-01 20:01:29 +08:00
commentAuthor : commenter ,
2021-11-06 22:35:35 +08:00
bodyIncludes : COMMENT _TITLE ,
} ;
2022-10-01 20:01:29 +08:00
// Search each page for the comment
2021-11-06 22:35:35 +08:00
for await ( const { data : comments } of octokit . paginate . iterator (
octokit . rest . issues . listComments ,
parameters ,
) ) {
const comment = comments . find ( ( comment ) =>
2022-10-01 20:01:29 +08:00
isPreviewComment ( inputs , comment ) ,
2021-11-06 22:35:35 +08:00
) ;
2022-10-01 20:01:29 +08:00
if ( comment ) {
debug ( ` Found theme preview comment: ${ inspect ( comment ) } ` ) ;
return comment ;
} else {
debug ( ` No theme preview comment found. ` ) ;
}
2021-11-06 22:35:35 +08:00
}
2022-10-01 20:01:29 +08:00
} ;
/ * *
* Create or update the preview comment .
*
* @ param { Object } octokit Octokit instance .
* @ param { Object } props Comment properties .
* @ return { string } The comment URL .
* /
const upsertComment = async ( octokit , props ) => {
let resp ;
2021-11-06 22:35:35 +08:00
if ( props . comment _id !== undefined ) {
2022-10-01 20:01:29 +08:00
resp = await octokit . issues . updateComment ( props ) ;
2021-11-06 22:35:35 +08:00
} else {
2022-10-01 20:01:29 +08:00
resp = await octokit . issues . createComment ( props ) ;
2021-11-06 22:35:35 +08:00
}
2022-10-01 20:01:29 +08:00
return resp . data . html _url ;
} ;
/ * *
* Adds a review to the pull request .
*
* @ param { Object } octokit Octokit instance .
* @ param { number } prNumber Pull request number .
* @ param { string } owner Owner of the repository .
* @ param { string } repo Repository name .
* @ param { string } reviewState The review state . Options are ( APPROVE , REQUEST _CHANGES , COMMENT , PENDING ) .
* @ param { string } reason The reason for the review .
* /
const addReview = async (
octokit ,
prNumber ,
owner ,
repo ,
reviewState ,
reason ,
) => {
await octokit . pulls . createReview ( {
owner ,
repo ,
pull _number : prNumber ,
event : reviewState ,
body : reason ,
} ) ;
} ;
/ * *
* Add label to pull request .
*
* @ param { Object } octokit Octokit instance .
* @ param { number } prNumber Pull request number .
* @ param { string } owner Repository owner .
* @ param { string } repo Repository name .
* @ param { string [ ] } labels Labels to add .
* /
const addLabel = async ( octokit , prNumber , owner , repo , labels ) => {
await octokit . issues . addLabels ( {
owner ,
repo ,
issue _number : prNumber ,
labels ,
} ) ;
} ;
/ * *
* Remove label from the pull request .
*
* @ param { Object } octokit Octokit instance .
* @ param { number } prNumber Pull request number .
* @ param { string } owner Repository owner .
* @ param { string } repo Repository name .
* @ param { string } label Label to add or remove .
* /
const removeLabel = async ( octokit , prNumber , owner , repo , label ) => {
await octokit . issues . removeLabel ( {
owner ,
repo ,
issue _number : prNumber ,
name : label ,
} ) ;
} ;
/ * *
* Adds or removes a label from the pull request .
*
* @ param { Object } octokit Octokit instance .
* @ param { number } prNumber Pull request number .
* @ param { string } owner Repository owner .
* @ param { string } repo Repository name .
* @ param { string } label Label to add or remove .
* @ param { boolean } add Whether to add or remove the label .
* /
const addRemoveLabel = async ( octokit , prNumber , owner , repo , label , add ) => {
const res = await octokit . pulls . get ( {
owner ,
repo ,
pull _number : prNumber ,
} ) ;
if ( add ) {
if ( ! res . data . labels . find ( ( l ) => l . name === label ) ) {
await addLabel ( octokit , prNumber , owner , repo , [ label ] ) ;
}
} else {
if ( res . data . labels . find ( ( l ) => l . name === label ) ) {
await removeLabel ( octokit , prNumber , owner , repo , label ) ;
}
}
} ;
/ * *
* Retrieve webAim contrast color check link .
*
* @ param { string } color1 First color .
* @ param { string } color2 Second color .
* @ returns { string } WebAim contrast color check link .
* /
const getWebAimLink = ( color1 , color2 ) => {
2021-11-06 22:35:35 +08:00
return ` https://webaim.org/resources/contrastchecker/?fcolor= ${ color1 } &bcolor= ${ color2 } ` ;
2022-10-01 20:01:29 +08:00
} ;
/ * *
* Retrieves the theme GRS url .
*
* @ param { Object } colors The theme colors .
* @ returns { string } GRS theme url .
* /
const getGRSLink = ( colors ) => {
2021-11-06 22:35:35 +08:00
const url = ` https://github-readme-stats.vercel.app/api?username=anuraghazra ` ;
const colorString = Object . keys ( colors )
. map ( ( colorKey ) => ` ${ colorKey } = ${ colors [ colorKey ] } ` )
. join ( "&" ) ;
return ` ${ url } & ${ colorString } &show_icons=true ` ;
2022-10-01 20:01:29 +08:00
} ;
/ * *
* Retrieve javascript object from json string .
*
* @ description Wraps the Hjson parse function to fix several known json syntax errors .
*
* @ param { string } json The json to parse .
* @ returns { Object } Object parsed from the json .
* /
const parseJSON = ( json ) => {
try {
const parsedJson = Hjson . parse ( json ) ;
if ( typeof parsedJson === "object" ) {
return parsedJson ;
} else {
throw new Error ( "PR diff is not a valid theme JSON object." ) ;
}
} catch ( error ) {
let parsedJson = json
2022-10-02 23:41:28 +08:00
. split ( /([\s\r\s]*}[\s\r\s]*,[\s\r\s]*)(?=[\w"-]+:)/ )
2022-10-01 20:01:29 +08:00
. filter ( ( x ) => typeof x !== "string" || ! ! x . trim ( ) ) ;
if ( parsedJson [ 0 ] . replace ( /\s+/g , "" ) === "}," ) {
parsedJson [ 0 ] = "}," ;
2022-10-02 23:41:28 +08:00
if ( ! /\s*}\s*,?\s*$/ . test ( parsedJson [ 1 ] ) ) {
2022-10-01 20:01:29 +08:00
parsedJson . push ( parsedJson . shift ( ) ) ;
} else {
parsedJson . shift ( ) ;
}
return Hjson . parse ( parsedJson . join ( "" ) ) ;
} else {
throw error ;
}
}
} ;
/ * *
* Check whether the theme name is still available .
* @ param { string } name Theme name .
* @ returns { boolean } Whether the theme name is available .
* /
const themeNameAlreadyExists = ( name ) => {
return themes [ name ] !== undefined ;
} ;
/ * *
* Main function .
* /
2022-10-02 23:41:28 +08:00
export const run = async ( prNumber ) => {
2020-08-03 01:17:34 +08:00
try {
2022-10-01 20:01:29 +08:00
const dryRun = process . env . DRY _RUN === "true" || false ;
debug ( "Retrieve action information from context..." ) ;
debug ( ` Context: ${ inspect ( github . context ) } ` ) ;
let commentBody = `
\ r # $ { COMMENT _TITLE }
\ r$ { THEME _CONTRIB _GUIDELINESS }
` ;
2021-11-05 23:31:46 +08:00
const ccc = new ColorContrastChecker ( ) ;
2022-10-01 20:01:29 +08:00
const octokit = github . getOctokit ( getGithubToken ( ) ) ;
2022-10-02 23:41:28 +08:00
const pullRequestId = prNumber ? prNumber : getPrNumber ( ) ;
2022-10-01 20:01:29 +08:00
const commenter = getCommenter ( ) ;
const { owner , repo } = getRepoInfo ( github . context ) ;
debug ( ` Owner: ${ owner } ` ) ;
debug ( ` Repo: ${ repo } ` ) ;
debug ( ` Commenter: ${ commenter } ` ) ;
// Retrieve the PR diff and preview-theme comment.
debug ( "Retrieve PR diff..." ) ;
2021-11-05 23:31:46 +08:00
const res = await octokit . pulls . get ( {
2022-10-01 20:01:29 +08:00
owner ,
repo ,
2020-08-03 01:17:34 +08:00
pull _number : pullRequestId ,
mediaType : {
format : "diff" ,
} ,
} ) ;
2022-10-01 20:01:29 +08:00
debug ( "Retrieve preview-theme comment..." ) ;
const comment = await findComment (
octokit ,
pullRequestId ,
owner ,
repo ,
commenter ,
) ;
2020-08-03 01:17:34 +08:00
2022-10-01 20:01:29 +08:00
// Retrieve theme changes from the PR diff.
debug ( "Retrieve themes..." ) ;
2021-11-05 23:31:46 +08:00
const diff = parse ( res . data ) ;
const content = diff
2020-08-03 01:17:34 +08:00
. find ( ( file ) => file . to === "themes/index.js" )
. chunks [ 0 ] . changes . filter ( ( c ) => c . type === "add" )
. map ( ( c ) => c . content . replace ( "+" , "" ) )
. join ( "" ) ;
2022-10-01 20:01:29 +08:00
const themeObject = parseJSON ( content ) ;
if (
Object . keys ( themeObject ) . every (
( key ) => typeof themeObject [ key ] !== "object" ,
)
) {
throw new Error ( "PR diff is not a valid theme JSON object." ) ;
2020-08-03 01:17:34 +08:00
}
2021-11-05 23:31:46 +08:00
2022-10-01 20:01:29 +08:00
// Loop through themes and create theme preview body.
debug ( "Create theme preview body..." ) ;
const themeValid = Object . fromEntries (
Object . keys ( themeObject ) . map ( ( name ) => [ name , true ] ) ,
) ;
let previewBody = "" ;
for ( const theme in themeObject ) {
debug ( ` Create theme preview for ${ theme } ... ` ) ;
const themeName = theme ;
const colors = themeObject [ theme ] ;
const warnings = [ ] ;
const errors = [ ] ;
// Check if the theme name is valid.
debug ( "Theme preview body: Check if the theme name is valid..." ) ;
if ( themeNameAlreadyExists ( themeName ) ) {
warnings . push ( "Theme name already taken" ) ;
themeValid [ theme ] = false ;
}
if ( themeName !== snakeCase ( themeName ) ) {
warnings . push ( "Theme name isn't in snake_case" ) ;
themeValid [ theme ] = false ;
}
2021-11-05 23:31:46 +08:00
2022-10-01 20:01:29 +08:00
// Check if the theme colors are valid.
debug ( "Theme preview body: Check if the theme colors are valid..." ) ;
let invalidColors = false ;
if ( ! colors ) {
warning . push ( "Theme colors are missing" ) ;
invalidColors = true ;
} else {
2022-10-02 16:33:29 +08:00
const missingKeys = REQUIRED _COLOR _PROPS . filter (
2022-10-01 20:01:29 +08:00
( x ) => ! Object . keys ( colors ) . includes ( x ) ,
2021-11-05 23:31:46 +08:00
) ;
2022-10-02 23:41:28 +08:00
const extraKeys = Object . keys ( colors ) . filter (
( x ) => ! ACCEPTED _COLOR _PROPS . includes ( x ) ,
) ;
if ( missingKeys . length > 0 || extraKeys . length > 0 ) {
2022-10-01 20:01:29 +08:00
for ( const missingKey of missingKeys ) {
errors . push ( ` Theme color properties \` ${ missingKey } \` are missing ` ) ;
}
2022-10-02 23:41:28 +08:00
for ( const extraKey of extraKeys ) {
warnings . push (
` Theme color properties \` ${ extraKey } \` is not supported ` ,
) ;
}
2022-10-01 20:01:29 +08:00
invalidColors = true ;
} else {
for ( const [ colorKey , colorValue ] of Object . entries ( colors ) ) {
if ( colorValue [ 0 ] === "#" ) {
errors . push (
` Theme color property \` ${ colorKey } \` should not start with '#' ` ,
) ;
invalidColors = true ;
2022-10-02 23:41:28 +08:00
} else if ( colorValue . length > COLOR _PROPS [ colorKey ] ) {
errors . push (
` Theme color property \` ${ colorKey } \` can not be longer than \` ${ COLOR _PROPS [ colorKey ] } \` characters ` ,
) ;
invalidColors = true ;
2022-10-02 16:33:29 +08:00
} else if ( ! isValidHexColor ( colorValue ) ) {
2022-10-01 20:01:29 +08:00
errors . push (
` Theme color property \` ${ colorKey } \` is not a valid hex color: <code># ${ colorValue } </code> ` ,
) ;
invalidColors = true ;
}
}
}
}
if ( invalidColors ) {
themeValid [ theme ] = false ;
previewBody += `
\ r # # # $ {
themeName . charAt ( 0 ) . toUpperCase ( ) + themeName . slice ( 1 )
} theme preview
\ r$ { warnings . map ( ( warning ) => ` - :warning: ${ warning } . \n ` ) . join ( "" ) }
\ r$ { errors . map ( ( error ) => ` - :x: ${ error } . \n ` ) . join ( "" ) }
\ r > : x : Cannot create theme preview .
` ;
continue ;
2021-11-05 23:31:46 +08:00
}
2022-10-01 20:01:29 +08:00
// Check color contrast.
debug ( "Theme preview body: Check color contrast..." ) ;
const titleColor = colors . title _color ;
const iconColor = colors . icon _color ;
const textColor = colors . text _color ;
const bgColor = colors . bg _color ;
2022-10-02 16:33:29 +08:00
const borderColor = colors . border _color ;
2022-10-01 20:01:29 +08:00
const url = getGRSLink ( colors ) ;
const colorPairs = {
title _color : [ titleColor , bgColor ] ,
icon _color : [ iconColor , bgColor ] ,
text _color : [ textColor , bgColor ] ,
} ;
Object . keys ( colorPairs ) . forEach ( ( item ) => {
2022-10-02 23:41:28 +08:00
let color1 = colorPairs [ item ] [ 0 ] ;
let color2 = colorPairs [ item ] [ 1 ] ;
color1 = color1 . length === 4 ? color1 . slice ( 0 , 3 ) : color1 . slice ( 0 , 6 ) ;
color2 = color2 . length === 4 ? color2 . slice ( 0 , 3 ) : color2 . slice ( 0 , 6 ) ;
2022-10-01 20:01:29 +08:00
if ( ! ccc . isLevelAA ( ` # ${ color1 } ` , ` # ${ color2 } ` ) ) {
const permalink = getWebAimLink ( color1 , color2 ) ;
warnings . push (
` \` ${ item } \` does not pass [AA contrast ratio]( ${ permalink } ) ` ,
) ;
themeValid [ theme ] = false ;
}
} ) ;
2022-09-24 16:20:54 +08:00
2022-10-01 20:01:29 +08:00
// Create theme preview body.
debug ( "Theme preview body: Create theme preview body..." ) ;
previewBody += `
\ r # # # $ {
themeName . charAt ( 0 ) . toUpperCase ( ) + themeName . slice ( 1 )
} theme preview
\ r$ { warnings . map ( ( warning ) => ` - :warning: ${ warning } . \n ` ) . join ( "" ) }
2021-11-05 23:31:46 +08:00
2022-10-02 16:33:29 +08:00
\ ntitle _color : < code > # $ { titleColor } < / c o d e > | i c o n _ c o l o r : < c o d e > # $ { i c o n C o l o r } < / c o d e > | t e x t _ c o l o r : < c o d e > # $ { t e x t C o l o r } < / c o d e > | b g _ c o l o r : < c o d e > # $ { b g C o l o r } < / c o d e > $ {
borderColor ? ` | border_color: <code># ${ borderColor } </code> ` : ""
}
2022-09-24 16:20:54 +08:00
2022-10-01 20:01:29 +08:00
\ r [ Preview Link ] ( $ { url } )
2021-01-10 15:46:01 +08:00
2022-10-01 20:01:29 +08:00
\ r [ ! [ ] ( $ { url } ) ] ( $ { url } )
` ;
}
2022-09-24 16:20:54 +08:00
2022-10-01 20:01:29 +08:00
// Create comment body.
debug ( "Create comment body..." ) ;
commentBody += `
\ r$ {
Object . values ( themeValid ) . every ( ( value ) => value )
? THEME _PR _SUCCESS _TEXT
: THEME _PR _FAIL _TEXT
}
\ r # # Test results
\ r$ { Object . entries ( themeValid )
. map (
( [ key , value ] ) => ` - ${ value ? ":heavy_check_mark:" : ":x:" } ${ key } ` ,
)
. join ( "\r" ) }
\ r$ {
Object . values ( themeValid ) . every ( ( value ) => value )
? "**Result:** :heavy_check_mark: All themes are valid."
: "**Result:** :x: Some themes are invalid.\n\n" + FAIL _TEXT
}
\ r # # Details
\ r$ { previewBody }
` ;
// Create or update theme-preview comment.
debug ( "Create or update theme-preview comment..." ) ;
let comment _url ;
if ( ! dryRun ) {
comment _url = await upsertComment ( octokit , {
comment _id : comment ? . id ,
issue _number : pullRequestId ,
owner ,
repo ,
body : commentBody ,
} ) ;
} else {
info ( ` DRY_RUN: Comment body: ${ commentBody } ` ) ;
comment _url = "" ;
}
// Change review state and add/remove `invalid` label based on theme PR validity.
debug (
"Change review state and add/remove `invalid` label based on whether all themes passed..." ,
) ;
const themesValid = Object . values ( themeValid ) . every ( ( value ) => value ) ;
const reviewState = themesValid ? "APPROVE" : "REQUEST_CHANGES" ;
const reviewReason = themesValid
? undefined
: INVALID _REVIEW _COMMENT ( comment _url ) ;
if ( ! dryRun ) {
await addReview (
octokit ,
pullRequestId ,
owner ,
repo ,
reviewState ,
reviewReason ,
) ;
await addRemoveLabel (
octokit ,
pullRequestId ,
owner ,
repo ,
"invalid" ,
! themesValid ,
) ;
} else {
info ( ` DRY_RUN: Review state: ${ reviewState } ` ) ;
info ( ` DRY_RUN: Review reason: ${ reviewReason } ` ) ;
}
2020-08-03 01:17:34 +08:00
} catch ( error ) {
2022-10-07 17:09:31 +08:00
debug ( "Set review state to `REQUEST_CHANGES` and add `invalid` label..." ) ;
if ( ! dryRun ) {
await addReview (
octokit ,
pullRequestId ,
owner ,
repo ,
"REQUEST_CHANGES" ,
error . message ,
) ;
2022-10-07 17:10:48 +08:00
await addRemoveLabel (
octokit ,
pullRequestId ,
owner ,
repo ,
"invalid" ,
true ,
) ;
2022-10-07 17:09:31 +08:00
} else {
info ( ` DRY_RUN: Review state: REQUEST_CHANGES ` ) ;
info ( ` DRY_RUN: Review reason: ${ error . message } ` ) ;
}
2022-10-01 20:01:29 +08:00
setFailed ( error . message ) ;
2020-08-03 01:17:34 +08:00
}
2022-10-01 20:01:29 +08:00
} ;
2020-08-03 01:17:34 +08:00
2022-10-02 23:42:58 +08:00
run ( ) ;