The great refactor (#82)
This commit is contained in:
103
source/app/action/action.yml
Normal file
103
source/app/action/action.yml
Normal file
@@ -0,0 +1,103 @@
|
||||
# ====================================================================================
|
||||
# Inputs and configuration
|
||||
|
||||
inputs:
|
||||
<% for (const [plugin, {name, action}] of Object.entries(plugins)) { %>
|
||||
# ====================================================================================
|
||||
# <%- name %>
|
||||
<% for (const [input, {comment, descriptor}] of Object.entries(action)) { %>
|
||||
<%- comment.split("\n").map((line, i) => `${i ? " " : ""}${line}`).join("\n").trim() %>
|
||||
<%- descriptor.split("\n").map((line, i) => `${i ? " " : ""}${line}`).join("\n") -%>
|
||||
<% }} %>
|
||||
|
||||
# ====================================================================================
|
||||
# Action metadata
|
||||
name: GitHub metrics as SVG image
|
||||
author: lowlighter
|
||||
description: An SVG generator with 20+ metrics about your GitHub account! Additional plugins are available to display even more!
|
||||
branding:
|
||||
icon: user-check
|
||||
color: gray-dark
|
||||
|
||||
# The action will parse its name to check if it's the official action or if it's a forked one
|
||||
# On the official action, it'll use the docker image published on GitHub registry when using a released version, allowing faster runs
|
||||
# On a forked action, it'll rebuild the docker image from Dockerfile to take into account changes you made
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- run: |
|
||||
# Create environment file from inputs and GitHub variables
|
||||
cd $METRICS_ACTION_PATH
|
||||
touch .env
|
||||
for INPUT in $(echo $INPUTS | jq -r 'to_entries|map("INPUT_\(.key|ascii_upcase)=\(.value|@uri)")|.[]'); do
|
||||
echo $INPUT >> .env
|
||||
done
|
||||
env | grep -E '^(GITHUB|ACTIONS|CI)' >> .env
|
||||
echo "Environment variable: loaded"
|
||||
|
||||
# Source repository (picked from action name)
|
||||
METRICS_SOURCE=$(echo $METRICS_ACTION | sed -E 's/metrics.*?$//g')
|
||||
echo "Source: $METRICS_SOURCE"
|
||||
|
||||
# Version (picked from package.json)
|
||||
METRICS_VERSION=$(grep -Po '(?<="version": ").*(?=")' package.json)
|
||||
echo "Version: $METRICS_VERSION"
|
||||
|
||||
# Image tag (extracted from version or from env)
|
||||
METRICS_TAG=v$(echo $METRICS_VERSION | sed -r 's/^([0-9]+[.][0-9]+).*/\1/')
|
||||
if [[ $METRICS_USE_PREBUILT_IMAGE ]]; then
|
||||
METRICS_TAG=$METRICS_USE_PREBUILT_IMAGE
|
||||
echo "Pre-built image: yes"
|
||||
fi
|
||||
echo "Image tag: $METRICS_TAG"
|
||||
|
||||
# Image name
|
||||
# Pre-built image
|
||||
if [[ $METRICS_USE_PREBUILT_IMAGE ]]; then
|
||||
echo "Using pre-built version $METRICS_TAG, will pull docker image from GitHub registry"
|
||||
METRICS_IMAGE=ghcr.io/lowlighter/metrics:$METRICS_TAG
|
||||
docker image pull $METRICS_IMAGE > /dev/null
|
||||
# Official action
|
||||
elif [[ $METRICS_SOURCE == "lowlighter" ]]; then
|
||||
# Is unreleased version
|
||||
set +e
|
||||
METRICS_IS_RELEASED=$(expr $(expr match $METRICS_VERSION .*-beta) == 0)
|
||||
set -e
|
||||
echo "Is released version: $METRICS_IS_RELEASED"
|
||||
# Rebuild image for unreleased version
|
||||
if [[ "$METRICS_IS_RELEASED" -gt "0" ]]; then
|
||||
echo "Using released version $METRICS_TAG, will pull docker image from GitHub registry"
|
||||
METRICS_IMAGE=ghcr.io/lowlighter/metrics:$METRICS_TAG
|
||||
# Use registry for released version
|
||||
else
|
||||
echo "Using an unreleased version ($METRICS_VERSION)"
|
||||
METRICS_IMAGE=metrics:$METRICS_VERSION
|
||||
fi
|
||||
# Forked action
|
||||
else
|
||||
echo "Using a forked version"
|
||||
METRICS_IMAGE=metrics:forked-$METRICS_VERSION
|
||||
fi
|
||||
echo "Image name: $METRICS_IMAGE"
|
||||
|
||||
# Build image if necessary
|
||||
set +e
|
||||
docker image inspect $METRICS_IMAGE > /dev/null
|
||||
METRICS_IMAGE_NEEDS_BUILD="$?"
|
||||
set -e
|
||||
if [[ "$METRICS_IMAGE_NEEDS_BUILD" -gt "0" ]]; then
|
||||
echo "Image $METRICS_IMAGE is not present locally, rebuilding it from Dockerfile"
|
||||
docker build -t $METRICS_IMAGE . > /dev/null
|
||||
else
|
||||
echo "Image $METRICS_IMAGE is present locally"
|
||||
fi
|
||||
|
||||
# Run docker image with current environment
|
||||
docker run --volume $GITHUB_EVENT_PATH:$GITHUB_EVENT_PATH --env-file .env $METRICS_IMAGE
|
||||
rm .env
|
||||
shell: bash
|
||||
env:
|
||||
METRICS_ACTION: ${{ github.action }}
|
||||
METRICS_ACTION_PATH: ${{ github.action_path }}
|
||||
METRICS_USE_PREBUILT_IMAGE: ${{ inputs.use_prebuilt_image }}
|
||||
INPUTS: ${{ toJson(inputs) }}
|
||||
@@ -2,336 +2,225 @@
|
||||
import core from "@actions/core"
|
||||
import github from "@actions/github"
|
||||
import octokit from "@octokit/graphql"
|
||||
import setup from "../setup.mjs"
|
||||
import mocks from "../mocks.mjs"
|
||||
import metrics from "../metrics.mjs"
|
||||
import setup from "../metrics/setup.mjs"
|
||||
import mocks from "../mocks/index.mjs"
|
||||
import metrics from "../metrics/index.mjs"
|
||||
|
||||
;((async function () {
|
||||
//Input parser
|
||||
const input = {
|
||||
get:(name) => {
|
||||
const value = `${core.getInput(name)}`.trim()
|
||||
try { return decodeURIComponent(value) }
|
||||
catch { return value}
|
||||
},
|
||||
bool:(name, {default:defaulted = undefined} = {}) => /^(?:[Tt]rue|[Oo]n|[Yy]es)$/.test(input.get(name)) ? true : /^(?:[Ff]alse|[Oo]ff|[Nn]o)$/.test(input.get(name)) ? false : defaulted,
|
||||
number:(name, {default:defaulted = undefined} = {}) => Number.isFinite(Number(input.get(name))) ? Number(input.get(name)) : defaulted,
|
||||
string:(name, {default:defaulted = undefined} = {}) => input.get(name) || defaulted,
|
||||
array:(name, {separator = ","} = {}) => input.get(name).split(separator).map(value => value.trim()).filter(value => value),
|
||||
object:(name) => JSON.parse(input.get(name) || "{}"),
|
||||
}
|
||||
//Info logger
|
||||
const info = (left, right, {token = false} = {}) => console.log(`${`${left}`.padEnd(48)} │ ${
|
||||
Array.isArray(right) ? right.join(", ") || "(none)" :
|
||||
right === undefined ? "(default)" :
|
||||
token ? /^MOCKED/.test(right) ? "(MOCKED TOKEN)" : (right ? "(provided)" : "(missing)") :
|
||||
typeof right === "object" ? JSON.stringify(right) :
|
||||
right
|
||||
}`)
|
||||
//Debug message buffer
|
||||
const debugged = []
|
||||
//Runner
|
||||
try {
|
||||
//Initialization
|
||||
console.log("─".repeat(64))
|
||||
console.log(`Metrics`)
|
||||
console.log("─".repeat(64))
|
||||
process.on("unhandledRejection", error => { throw error })
|
||||
//Debug message buffer
|
||||
let DEBUG = true
|
||||
const debugged = []
|
||||
|
||||
//Skip process if needed
|
||||
if ((github.context.eventName === "push")&&(github.context.payload?.head_commit)) {
|
||||
if (/\[Skip GitHub Action\]/.test(github.context.payload.head_commit.message)) {
|
||||
console.log(`Skipped because [Skip GitHub Action] is in commit message`)
|
||||
process.exit(0)
|
||||
}
|
||||
//Info logger
|
||||
const info = (left, right, {token = false} = {}) => console.log(`${`${left}`.padEnd(56 + 9*(/0m$/.test(left)))} │ ${
|
||||
Array.isArray(right) ? right.join(", ") || "(none)" :
|
||||
right === undefined ? "(default)" :
|
||||
token ? /^MOCKED/.test(right) ? "(MOCKED TOKEN)" : (right ? "(provided)" : "(missing)") :
|
||||
typeof right === "object" ? JSON.stringify(right) :
|
||||
right
|
||||
}`)
|
||||
info.section = (left = "", right = " ") => info(`\x1b[36m${left}\x1b[0m`, right)
|
||||
info.group = ({metadata, name, inputs}) => {
|
||||
info.section(metadata.plugins[name]?.name?.match(/(?<section>[\w\s]+)/i)?.groups?.section?.trim(), " ")
|
||||
for (const [input, value] of Object.entries(inputs))
|
||||
info(metadata.plugins[name]?.inputs[input]?.description ?? input, value, {token:metadata.plugins[name]?.inputs[input]?.type === "token"})
|
||||
}
|
||||
info.break = () => console.log("─".repeat(88))
|
||||
|
||||
//Runner
|
||||
try {
|
||||
//Initialization
|
||||
info.break()
|
||||
info.section(`Metrics`)
|
||||
process.on("unhandledRejection", error => { throw error })
|
||||
|
||||
//Skip process if needed
|
||||
if ((github.context.eventName === "push")&&(github.context.payload?.head_commit)) {
|
||||
if (/\[Skip GitHub Action\]/.test(github.context.payload.head_commit.message)) {
|
||||
console.log(`Skipped because [Skip GitHub Action] is in commit message`)
|
||||
process.exit(0)
|
||||
}
|
||||
}
|
||||
|
||||
//Pre-Setup
|
||||
const community = {
|
||||
templates:input.array("setup_community_templates")
|
||||
}
|
||||
info("Setup - community templates", community.templates)
|
||||
//Load configuration
|
||||
const {conf, Plugins, Templates} = await setup({log:false, nosettings:true, community:{templates:core.getInput("setup_community_templates")}})
|
||||
const {metadata} = conf
|
||||
info("Setup", "complete")
|
||||
info("Version", conf.package.version)
|
||||
|
||||
//Load configuration
|
||||
const {conf, Plugins, Templates} = await setup({log:false, nosettings:true, community})
|
||||
info("Setup", "complete")
|
||||
info("Version", conf.package.version)
|
||||
//Core inputs
|
||||
const {
|
||||
user:_user, token,
|
||||
template, query, "setup.community.templates":_templates,
|
||||
filename, optimize, verify,
|
||||
debug, "debug.flags":dflags, "use.mocked.data":mocked, dryrun,
|
||||
"plugins.errors.fatal":die,
|
||||
"committer.token":_token, "committer.branch":_branch,
|
||||
"use.prebuilt.image":_image,
|
||||
...config
|
||||
} = metadata.plugins.core.inputs.action({core})
|
||||
const q = {...query, template}
|
||||
|
||||
//Debug mode
|
||||
const debug = input.bool("debug", {default:false})
|
||||
info("Debug mode", debug)
|
||||
if (!debug)
|
||||
console.debug = message => debugged.push(message)
|
||||
const dflags = input.array("debug_flags", {separator:" "})
|
||||
info("Debug flags", dflags)
|
||||
//Docker image
|
||||
if (_image)
|
||||
info("Using prebuilt image", image)
|
||||
|
||||
//Load svg template, style, fonts and query
|
||||
const template = input.string("template", {default:"classic"})
|
||||
info("Template used", template)
|
||||
//Debug mode and flags
|
||||
info("Debug mode", debug)
|
||||
if (!debug) {
|
||||
console.debug = message => debugged.push(message)
|
||||
DEBUG = false
|
||||
}
|
||||
info("Debug flags", dflags)
|
||||
|
||||
//Token for data gathering
|
||||
const token = input.string("token")
|
||||
info("GitHub token", token, {token:true})
|
||||
if (!token)
|
||||
throw new Error("You must provide a valid GitHub token to gather your metrics")
|
||||
const api = {}
|
||||
api.graphql = octokit.graphql.defaults({headers:{authorization: `token ${token}`}})
|
||||
info("Github GraphQL API", "ok")
|
||||
api.rest = github.getOctokit(token)
|
||||
info("Github REST API", "ok")
|
||||
//Apply mocking if needed
|
||||
if (input.bool("use_mocked_data", {default:false})) {
|
||||
Object.assign(api, await mocks(api))
|
||||
info("Use mocked API", true)
|
||||
}
|
||||
//Extract octokits
|
||||
const {graphql, rest} = api
|
||||
//Token for data gathering
|
||||
info("GitHub token", token, {token:true})
|
||||
if (!token)
|
||||
throw new Error("You must provide a valid GitHub token to gather your metrics")
|
||||
const api = {}
|
||||
api.graphql = octokit.graphql.defaults({headers:{authorization: `token ${token}`}})
|
||||
info("Github GraphQL API", "ok")
|
||||
api.rest = github.getOctokit(token)
|
||||
info("Github REST API", "ok")
|
||||
//Apply mocking if needed
|
||||
if (mocked) {
|
||||
Object.assign(api, await mocks(api))
|
||||
info("Use mocked API", true)
|
||||
}
|
||||
//Extract octokits
|
||||
const {graphql, rest} = api
|
||||
|
||||
//SVG output
|
||||
const filename = input.string("filename", {default:"github-metrics.svg"})
|
||||
info("SVG output", filename)
|
||||
//GitHub user
|
||||
let authenticated
|
||||
try {
|
||||
authenticated = (await rest.users.getAuthenticated()).data.login
|
||||
}
|
||||
catch {
|
||||
authenticated = github.context.repo.owner
|
||||
}
|
||||
const user = _user || authenticated
|
||||
info("GitHub account", user)
|
||||
|
||||
//SVG optimization
|
||||
const optimize = input.bool("optimize", {default:true})
|
||||
conf.optimize = optimize
|
||||
info("SVG optimization", optimize)
|
||||
//Current repository
|
||||
info("Current repository", `${github.context.repo.owner}/${github.context.repo.repo}`)
|
||||
|
||||
//Verify svg
|
||||
const verify = input.bool("verify")
|
||||
info("SVG verification after generation", verify)
|
||||
|
||||
//GitHub user
|
||||
let authenticated
|
||||
try {
|
||||
authenticated = (await rest.users.getAuthenticated()).data.login
|
||||
}
|
||||
catch {
|
||||
authenticated = github.context.repo.owner
|
||||
}
|
||||
const user = input.string("user", {default:authenticated})
|
||||
info("Target GitHub user", user)
|
||||
|
||||
//Base elements
|
||||
const base = {}
|
||||
const parts = input.array("base")
|
||||
for (const part of conf.settings.plugins.base.parts)
|
||||
base[`base.${part}`] = parts.includes(part)
|
||||
info("Base parts", parts)
|
||||
|
||||
//Config
|
||||
const config = {
|
||||
"config.timezone":input.string("config_timezone"),
|
||||
"config.output":input.string("config_output"),
|
||||
"config.animations":input.bool("config_animations"),
|
||||
"config.padding":input.string("config_padding"),
|
||||
"config.order":input.array("config_order"),
|
||||
}
|
||||
info("Timezone", config["config.timezone"] ?? "(system default)")
|
||||
info("Convert SVG", config["config.output"] ?? "(no)")
|
||||
info("Enable SVG animations", config["config.animations"])
|
||||
info("SVG bottom padding", config["config.padding"])
|
||||
info("Content order", config["config.order"])
|
||||
|
||||
//Additional plugins
|
||||
const plugins = {
|
||||
lines:{enabled:input.bool("plugin_lines")},
|
||||
traffic:{enabled:input.bool("plugin_traffic")},
|
||||
pagespeed:{enabled:input.bool("plugin_pagespeed")},
|
||||
habits:{enabled:input.bool("plugin_habits")},
|
||||
languages:{enabled:input.bool("plugin_languages")},
|
||||
followup:{enabled:input.bool("plugin_followup")},
|
||||
music:{enabled:input.bool("plugin_music")},
|
||||
posts:{enabled:input.bool("plugin_posts")},
|
||||
isocalendar:{enabled:input.bool("plugin_isocalendar")},
|
||||
gists:{enabled:input.bool("plugin_gists")},
|
||||
topics:{enabled:input.bool("plugin_topics")},
|
||||
projects:{enabled:input.bool("plugin_projects")},
|
||||
tweets:{enabled:input.bool("plugin_tweets")},
|
||||
stars:{enabled:input.bool("plugin_stars")},
|
||||
stargazers:{enabled:input.bool("plugin_stargazers")},
|
||||
activity:{enabled:input.bool("plugin_activity")},
|
||||
people:{enabled:input.bool("plugin_people")},
|
||||
anilist:{enabled:input.bool("plugin_anilist")},
|
||||
}
|
||||
let q = Object.fromEntries(Object.entries(plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => [key, true]))
|
||||
info("Plugins enabled", Object.entries(plugins).filter(([key, plugin]) => plugin.enabled).map(([key]) => key))
|
||||
//Additional plugins options
|
||||
//Pagespeed
|
||||
if (plugins.pagespeed.enabled) {
|
||||
plugins.pagespeed.token = input.string("plugin_pagespeed_token")
|
||||
info("Pagespeed token", plugins.pagespeed.token, {token:true})
|
||||
for (const option of ["url"])
|
||||
info(`Pagespeed ${option}`, q[`pagespeed.${option}`] = input.string(`plugin_pagespeed_${option}`))
|
||||
for (const option of ["detailed", "screenshot"])
|
||||
info(`Pagespeed ${option}`, q[`pagespeed.${option}`] = input.bool(`plugin_pagespeed_${option}`))
|
||||
//Committer
|
||||
const committer = {}
|
||||
if (!dryrun) {
|
||||
//Compute committer informations
|
||||
committer.commit = true
|
||||
committer.token = _token || token
|
||||
committer.branch = _branch || github.context.ref.replace(/^refs[/]heads[/]/, "")
|
||||
info("Committer token", committer.token, {token:true})
|
||||
if (!committer.token)
|
||||
throw new Error("You must provide a valid GitHub token to commit your metrics")
|
||||
info("Committer branch", committer.branch)
|
||||
//Instantiate API for committer
|
||||
committer.rest = github.getOctokit(committer.token)
|
||||
info("Committer REST API", "ok")
|
||||
try {
|
||||
info("Committer account", (await committer.rest.users.getAuthenticated()).data.login)
|
||||
}
|
||||
//Languages
|
||||
if (plugins.languages.enabled) {
|
||||
for (const option of ["ignored", "skipped", "colors"])
|
||||
info(`Languages ${option}`, q[`languages.${option}`] = input.array(`plugin_languages_${option}`))
|
||||
catch {
|
||||
info("Committer account", "(github-actions)")
|
||||
}
|
||||
//Habits
|
||||
if (plugins.habits.enabled) {
|
||||
for (const option of ["facts", "charts"])
|
||||
info(`Habits ${option}`, q[`habits.${option}`] = input.bool(`plugin_habits_${option}`))
|
||||
for (const option of ["from", "days"])
|
||||
info(`Habits ${option}`, q[`habits.${option}`] = input.number(`plugin_habits_${option}`))
|
||||
}
|
||||
//Music
|
||||
if (plugins.music.enabled) {
|
||||
plugins.music.token = input.string("plugin_music_token")
|
||||
info("Music token", plugins.music.token, {token:true})
|
||||
for (const option of ["provider", "mode", "playlist", "user"])
|
||||
info(`Music ${option}`, q[`music.${option}`] = input.string(`plugin_music_${option}`))
|
||||
for (const option of ["limit"])
|
||||
info(`Music ${option}`, q[`music.${option}`] = input.number(`plugin_music_${option}`))
|
||||
}
|
||||
//Posts
|
||||
if (plugins.posts.enabled) {
|
||||
for (const option of ["source", "user"])
|
||||
info(`Posts ${option}`, q[`posts.${option}`] = input.string(`plugin_posts_${option}`))
|
||||
for (const option of ["limit"])
|
||||
info(`Posts ${option}`, q[`posts.${option}`] = input.number(`plugin_posts_${option}`))
|
||||
}
|
||||
//Isocalendar
|
||||
if (plugins.isocalendar.enabled) {
|
||||
for (const option of ["duration"])
|
||||
info(`Isocalendar ${option}`, q[`isocalendar.${option}`] = input.string(`plugin_isocalendar_${option}`))
|
||||
}
|
||||
//Topics
|
||||
if (plugins.topics.enabled) {
|
||||
for (const option of ["mode", "sort"])
|
||||
info(`Topics ${option}`, q[`topics.${option}`] = input.string(`plugin_topics_${option}`))
|
||||
for (const option of ["limit"])
|
||||
info(`Topics ${option}`, q[`topics.${option}`] = input.number(`plugin_topics_${option}`))
|
||||
}
|
||||
//Projects
|
||||
if (plugins.projects.enabled) {
|
||||
for (const option of ["repositories"])
|
||||
info(`Projects ${option}`, q[`projects.${option}`] = input.string(`plugin_projects_${option}`))
|
||||
for (const option of ["limit"])
|
||||
info(`Projects ${option}`, q[`projects.${option}`] = input.number(`plugin_projects_${option}`))
|
||||
for (const option of ["descriptions"])
|
||||
info(`Projects ${option}`, q[`projects.${option}`] = input.bool(`plugin_projects_${option}`))
|
||||
}
|
||||
//Tweets
|
||||
if (plugins.tweets.enabled) {
|
||||
plugins.tweets.token = input.string("plugin_tweets_token")
|
||||
info("Tweets token", plugins.tweets.token, {token:true})
|
||||
for (const option of ["user"])
|
||||
info(`Tweets ${option}`, q[`tweets.${option}`] = input.string(`plugin_tweets_${option}`))
|
||||
for (const option of ["limit"])
|
||||
info(`Tweets ${option}`, q[`tweets.${option}`] = input.number(`plugin_tweets_${option}`))
|
||||
}
|
||||
//Stars
|
||||
if (plugins.stars.enabled) {
|
||||
for (const option of ["limit"])
|
||||
info(`Stars ${option}`, q[`stars.${option}`] = input.number(`plugin_stars_${option}`))
|
||||
}
|
||||
//Activity
|
||||
if (plugins.activity.enabled) {
|
||||
for (const option of ["limit", "days"])
|
||||
info(`Activity ${option}`, q[`activity.${option}`] = input.number(`plugin_activity_${option}`))
|
||||
for (const option of ["filter"])
|
||||
info(`Activity ${option}`, q[`activity.${option}`] = input.array(`plugin_activity_${option}`))
|
||||
}
|
||||
//People
|
||||
if (plugins.people.enabled) {
|
||||
for (const option of ["limit", "size"])
|
||||
info(`People ${option}`, q[`people.${option}`] = input.number(`plugin_people_${option}`))
|
||||
for (const option of ["types", "thanks"])
|
||||
info(`People ${option}`, q[`people.${option}`] = input.array(`plugin_people_${option}`))
|
||||
for (const option of ["identicons"])
|
||||
info(`People ${option}`, q[`people.${option}`] = input.bool(`plugin_people_${option}`))
|
||||
}
|
||||
//Anilist
|
||||
if (plugins.anilist.enabled) {
|
||||
for (const option of ["limit"])
|
||||
info(`Anilist ${option}`, q[`anilist.${option}`] = input.number(`plugin_anilist_${option}`))
|
||||
for (const option of ["medias", "sections"])
|
||||
info(`Anilist ${option}`, q[`anilist.${option}`] = input.array(`plugin_anilist_${option}`))
|
||||
for (const option of ["shuffle"])
|
||||
info(`Anilist ${option}`, q[`anilist.${option}`] = input.bool(`plugin_anilist_${option}`))
|
||||
for (const option of ["user"])
|
||||
info(`Anilist ${option}`, q[`anilist.${option}`] = input.string(`plugin_anilist_${option}`))
|
||||
}
|
||||
|
||||
//Repositories to use
|
||||
const repositories = input.number("repositories")
|
||||
const forks = input.bool("repositories_forks")
|
||||
info("Repositories to process", repositories)
|
||||
info("Include forked repositories", forks)
|
||||
|
||||
//Die on plugins errors
|
||||
const die = input.bool("plugins_errors_fatal")
|
||||
info("Plugin errors", die ? "(exit with error)" : "(displayed in generated SVG)")
|
||||
|
||||
//Build query
|
||||
const query = input.object("query")
|
||||
info("Query additional params", query)
|
||||
q = {...query, ...q, base:false, ...base, ...config, repositories, "repositories.forks":forks, template}
|
||||
|
||||
//Render metrics
|
||||
const {rendered} = await metrics({login:user, q, dflags}, {graphql, rest, plugins, conf, die, verify}, {Plugins, Templates})
|
||||
info("Rendering", "complete")
|
||||
|
||||
//Commit to repository
|
||||
const dryrun = input.bool("dryrun")
|
||||
if (dryrun)
|
||||
info("Dry-run", "complete")
|
||||
else {
|
||||
//Repository and branch
|
||||
const branch = input.string("committer_branch", {default:github.context.ref.replace(/^refs[/]heads[/]/, "")})
|
||||
info("Current repository", `${github.context.repo.owner}/${github.context.repo.repo}`)
|
||||
info("Current branch", branch)
|
||||
//Committer token
|
||||
const token = input.string("committer_token", {default:input.string("token")})
|
||||
info("Committer token", token, {token:true})
|
||||
if (!token)
|
||||
throw new Error("You must provide a valid GitHub token to commit your metrics")
|
||||
const rest = github.getOctokit(token)
|
||||
info("Committer REST API", "ok")
|
||||
try {
|
||||
info("Committer", (await rest.users.getAuthenticated()).data.login)
|
||||
}
|
||||
catch {
|
||||
info("Committer", "(github-actions)")
|
||||
}
|
||||
//Retrieve previous render SHA to be able to update file content through API
|
||||
let sha = null
|
||||
try {
|
||||
const {repository:{object:{oid}}} = await graphql(`
|
||||
query Sha {
|
||||
repository(owner: "${github.context.repo.owner}", name: "${github.context.repo.repo}") {
|
||||
object(expression: "${branch}:${filename}") { ... on Blob { oid } }
|
||||
}
|
||||
//Retrieve previous render SHA to be able to update file content through API
|
||||
committer.sha = null
|
||||
try {
|
||||
const {repository:{object:{oid}}} = await graphql(`
|
||||
query Sha {
|
||||
repository(owner: "${github.context.repo.owner}", name: "${github.context.repo.repo}") {
|
||||
object(expression: "${committer.branch}:${filename}") { ... on Blob { oid } }
|
||||
}
|
||||
`
|
||||
)
|
||||
sha = oid
|
||||
} catch (error) { console.debug(error) }
|
||||
info("Previous render sha", sha ?? "(none)")
|
||||
//Update file content through API
|
||||
await rest.repos.createOrUpdateFileContents({
|
||||
...github.context.repo, path:filename, message:`Update ${filename} - [Skip GitHub Action]`,
|
||||
content:Buffer.from(rendered).toString("base64"),
|
||||
branch,
|
||||
...(sha ? {sha} : {})
|
||||
})
|
||||
info("Commit to current repository", "ok")
|
||||
}
|
||||
}
|
||||
`
|
||||
)
|
||||
committer.sha = oid
|
||||
} catch (error) { console.debug(error) }
|
||||
info("Previous render sha", committer.sha ?? "(none)")
|
||||
}
|
||||
else
|
||||
info("Dry-run", true)
|
||||
|
||||
//Success
|
||||
console.log(`Success, thanks for using metrics !`)
|
||||
process.exit(0)
|
||||
}
|
||||
//Errors
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
if (!input.bool("debug"))
|
||||
for (const log of ["─".repeat(64), "An error occured, logging debug message :", ...debugged])
|
||||
//SVG file
|
||||
conf.optimize = optimize
|
||||
info("SVG output", filename)
|
||||
info("SVG optimization", optimize)
|
||||
info("SVG verification after generation", verify)
|
||||
|
||||
//Template
|
||||
info.break()
|
||||
info.section("Templates")
|
||||
info("Community templates", _templates)
|
||||
info("Template used", template)
|
||||
info("Query additional params", query)
|
||||
|
||||
//Core config
|
||||
info.break()
|
||||
info.group({metadata, name:"core", inputs:config})
|
||||
info("Plugin errors", die ? "(exit with error)" : "(displayed in generated SVG)")
|
||||
Object.assign(q, config)
|
||||
|
||||
//Base content
|
||||
info.break()
|
||||
const {base:parts, ...base} = metadata.plugins.base.inputs.action({core})
|
||||
info.group({metadata, name:"base", inputs:base})
|
||||
info("Base sections", parts)
|
||||
base.base = false
|
||||
for (const part of conf.settings.plugins.base.parts)
|
||||
base[`base.${part}`] = parts.includes(part)
|
||||
Object.assign(q, base)
|
||||
|
||||
//Additional plugins
|
||||
const plugins = {}
|
||||
for (const name of Object.keys(Plugins).filter(key => !["base", "core"].includes(key))) {
|
||||
//Parse inputs
|
||||
const {[name]:enabled, ...inputs} = metadata.plugins[name].inputs.action({core})
|
||||
plugins[name] = {enabled}
|
||||
//Register user inputs
|
||||
if (enabled) {
|
||||
info.break()
|
||||
info.group({metadata, name, inputs:enabled ? inputs : {}})
|
||||
q[name] = true
|
||||
for (const [key, value] of Object.entries(inputs)) {
|
||||
//Store token in plugin configuration
|
||||
if (metadata.plugins[name].inputs[key].type === "token")
|
||||
plugins[name][key] = value
|
||||
//Store value in query
|
||||
else
|
||||
q[`${name}.${key}`] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
//Render metrics
|
||||
info.break()
|
||||
info.section("Rendering")
|
||||
const {rendered} = await metrics({login:user, q, dflags}, {graphql, rest, plugins, conf, die, verify}, {Plugins, Templates})
|
||||
info("Status", "complete")
|
||||
|
||||
//Commit metrics
|
||||
if (committer.commit) {
|
||||
await committer.rest.repos.createOrUpdateFileContents({
|
||||
...github.context.repo, path:filename, message:`Update ${filename} - [Skip GitHub Action]`,
|
||||
content:Buffer.from(rendered).toString("base64"),
|
||||
branch:committer.branch,
|
||||
...(committer.sha ? {sha:committer.sha} : {})
|
||||
})
|
||||
info("Commit to repository", "success")
|
||||
}
|
||||
|
||||
//Success
|
||||
info.break()
|
||||
console.log(`Success, thanks for using metrics!`)
|
||||
process.exit(0)
|
||||
}
|
||||
//Errors
|
||||
catch (error) {
|
||||
console.error(error)
|
||||
//Print debug buffer if debug was not enabled (if it is, it's already logged on the fly)
|
||||
if (!DEBUG)
|
||||
for (const log of [info.break(), "An error occured, logging debug message :", ...debugged])
|
||||
console.log(log)
|
||||
core.setFailed(error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
})()).catch(error => process.exit(1))
|
||||
core.setFailed(error.message)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
@@ -1,289 +0,0 @@
|
||||
//Imports
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import paths from "path"
|
||||
import util from "util"
|
||||
import axios from "axios"
|
||||
import url from "url"
|
||||
import puppeteer from "puppeteer"
|
||||
import processes from "child_process"
|
||||
import ejs from "ejs"
|
||||
import imgb64 from "image-to-base64"
|
||||
import SVGO from "svgo"
|
||||
|
||||
//Setup
|
||||
export default async function metrics({login, q, dflags = []}, {graphql, rest, plugins, conf, die = false, verify = false, convert = null}, {Plugins, Templates}) {
|
||||
//Compute rendering
|
||||
try {
|
||||
|
||||
//Init
|
||||
console.debug(`metrics/compute/${login} > start`)
|
||||
console.debug(util.inspect(q, {depth:Infinity, maxStringLength:256}))
|
||||
const template = q.template || conf.settings.templates.default
|
||||
const repositories = Math.max(0, Number(q.repositories)) || conf.settings.repositories || 100
|
||||
const pending = []
|
||||
if ((!(template in Templates))||(!(template in conf.templates))||((conf.settings.templates.enabled.length)&&(!conf.settings.templates.enabled.includes(template))))
|
||||
throw new Error("unsupported template")
|
||||
const {image, style, fonts, views, partials} = conf.templates[template]
|
||||
const queries = conf.queries
|
||||
const data = {animated:true, base:{}, config:{}, errors:[], plugins:{}, computed:{}}
|
||||
const s = (value, end = "") => value !== 1 ? {y:"ies", "":"s"}[end] : end
|
||||
|
||||
//Base parts
|
||||
{
|
||||
const defaulted = ("base" in q) ? !!q.base : true
|
||||
for (const part of conf.settings.plugins.base.parts)
|
||||
data.base[part] = `base.${part}` in q ? !!q[ `base.${part}`] : defaulted
|
||||
}
|
||||
//Partial parts
|
||||
{
|
||||
data.partials = new Set([
|
||||
...decodeURIComponent(q["config.order"] ?? "").split(",").map(x => x.trim().toLocaleLowerCase()).filter(partial => partials.includes(partial)),
|
||||
...partials,
|
||||
])
|
||||
console.debug(`metrics/compute/${login} > content order : ${[...data.partials]}`)
|
||||
}
|
||||
|
||||
//Query data from GitHub API
|
||||
await common({login, q, data, queries, repositories, graphql})
|
||||
//Compute metrics
|
||||
console.debug(`metrics/compute/${login} > compute`)
|
||||
const computer = Templates[template].default || Templates[template]
|
||||
await computer({login, q, dflags}, {conf, data, rest, graphql, plugins, queries, account:data.account}, {s, pending, imports:{plugins:Plugins, url, imgb64, axios, puppeteer, run, fs, os, paths, util, format, bytes, shuffle, htmlescape, urlexpand, __module}})
|
||||
const promised = await Promise.all(pending)
|
||||
|
||||
//Check plugins errors
|
||||
{
|
||||
const errors = [...promised.filter(({result = null}) => result?.error), ...data.errors]
|
||||
if (errors.length) {
|
||||
console.warn(`metrics/compute/${login} > ${errors.length} errors !`)
|
||||
if (die)
|
||||
throw new Error(`An error occured during rendering, dying`)
|
||||
else
|
||||
console.warn(util.inspect(errors, {depth:Infinity, maxStringLength:256}))
|
||||
}
|
||||
}
|
||||
|
||||
//Template rendering
|
||||
console.debug(`metrics/compute/${login} > render`)
|
||||
let rendered = await ejs.render(image, {...data, s, f:format, style, fonts}, {views, async:true})
|
||||
//Apply resizing
|
||||
const {resized, mime} = await svgresize(rendered, {paddings:q["config.padding"], convert})
|
||||
rendered = resized
|
||||
|
||||
//Additional SVG transformations
|
||||
if (/svg/.test(mime)) {
|
||||
//Optimize rendering
|
||||
if ((conf.settings?.optimize)&&(!q.raw)) {
|
||||
console.debug(`metrics/compute/${login} > optimize`)
|
||||
const svgo = new SVGO({full:true, plugins:[{cleanupAttrs:true}, {inlineStyles:false}]})
|
||||
const {data:optimized} = await svgo.optimize(rendered)
|
||||
rendered = optimized
|
||||
}
|
||||
//Verify svg
|
||||
if (verify) {
|
||||
console.debug(`metrics/compute/${login} > verify SVG`)
|
||||
const libxmljs = (await import("libxmljs")).default
|
||||
const parsed = libxmljs.parseXml(rendered)
|
||||
if (parsed.errors.length)
|
||||
throw new Error(`Malformed SVG : \n${parsed.errors.join("\n")}`)
|
||||
}
|
||||
}
|
||||
|
||||
//Result
|
||||
console.debug(`metrics/compute/${login} > success`)
|
||||
return {rendered, mime}
|
||||
}
|
||||
//Internal error
|
||||
catch (error) {
|
||||
//User not found
|
||||
if (((Array.isArray(error.errors))&&(error.errors[0].type === "NOT_FOUND")))
|
||||
throw new Error("user not found")
|
||||
//Generic error
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** Common query */
|
||||
async function common({login, q, data, queries, repositories, graphql}) {
|
||||
//Iterate through account types
|
||||
for (const account of ["user", "organization"]) {
|
||||
try {
|
||||
//Query data from GitHub API
|
||||
console.debug(`metrics/compute/${login}/common > account ${account}`)
|
||||
const forks = q["repositories.forks"] || false
|
||||
const queried = await graphql(queries[{user:"common", organization:"common.organization"}[account]]({login, "calendar.from":new Date(Date.now()-14*24*60*60*1000).toISOString(), "calendar.to":(new Date()).toISOString(), forks:forks ? "" : ", isFork: false"}))
|
||||
Object.assign(data, {user:queried[account]})
|
||||
common.post?.[account]({login, data})
|
||||
//Query repositories from GitHub API
|
||||
{
|
||||
//Iterate through repositories
|
||||
let cursor = null
|
||||
let pushed = 0
|
||||
do {
|
||||
console.debug(`metrics/compute/${login}/common > retrieving repositories after ${cursor}`)
|
||||
const {[account]:{repositories:{edges, nodes}}} = await graphql(queries.repositories({login, account, after:cursor ? `after: "${cursor}"` : "", repositories:Math.min(repositories, {user:100, organization:25}[account]), forks:forks ? "" : ", isFork: false"}))
|
||||
cursor = edges?.[edges?.length-1]?.cursor
|
||||
data.user.repositories.nodes.push(...nodes)
|
||||
pushed = nodes.length
|
||||
} while ((pushed)&&(cursor)&&(data.user.repositories.nodes.length < repositories))
|
||||
//Limit repositories
|
||||
console.debug(`metrics/compute/${login}/common > keeping only ${repositories} repositories`)
|
||||
data.user.repositories.nodes.splice(repositories)
|
||||
console.debug(`metrics/compute/${login}/common > loaded ${data.user.repositories.nodes.length} repositories`)
|
||||
}
|
||||
//Success
|
||||
console.debug(`metrics/compute/${login}/common > graphql query > account ${account} > success`)
|
||||
return
|
||||
} catch (error) {
|
||||
console.debug(`metrics/compute/${login}/common > account ${account} > failed : ${error}`)
|
||||
console.debug(`metrics/compute/${login}/common > checking next account`)
|
||||
}
|
||||
}
|
||||
//Not found
|
||||
console.debug(`metrics/compute/${login}/common > no more account type`)
|
||||
throw new Error("user not found")
|
||||
}
|
||||
|
||||
/** Common query post-processing */
|
||||
common.post = {
|
||||
//User
|
||||
user({login, data}) {
|
||||
console.debug(`metrics/compute/${login}/common > applying common post`)
|
||||
data.account = "user"
|
||||
Object.assign(data.user, {
|
||||
isVerified:false,
|
||||
})
|
||||
},
|
||||
//Organization
|
||||
organization({login, data}) {
|
||||
console.debug(`metrics/compute/${login}/common > applying common post`)
|
||||
data.account = "organization",
|
||||
Object.assign(data.user, {
|
||||
isHireable:false,
|
||||
starredRepositories:{totalCount:0},
|
||||
watching:{totalCount:0},
|
||||
contributionsCollection:{
|
||||
totalRepositoriesWithContributedCommits:0,
|
||||
totalCommitContributions:0,
|
||||
restrictedContributionsCount:0,
|
||||
totalIssueContributions:0,
|
||||
totalPullRequestContributions:0,
|
||||
totalPullRequestReviewContributions:0,
|
||||
},
|
||||
calendar:{contributionCalendar:{weeks:[]}},
|
||||
repositoriesContributedTo:{totalCount:0},
|
||||
followers:{totalCount:0},
|
||||
following:{totalCount:0},
|
||||
issueComments:{totalCount:0},
|
||||
organizations:{totalCount:0},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns module __dirname */
|
||||
function __module(module) {
|
||||
return paths.join(paths.dirname(url.fileURLToPath(module)))
|
||||
}
|
||||
|
||||
/** Formatter */
|
||||
function format(n, {sign = false} = {}) {
|
||||
for (const {u, v} of [{u:"b", v:10**9}, {u:"m", v:10**6}, {u:"k", v:10**3}])
|
||||
if (n/v >= 1)
|
||||
return `${(sign)&&(n > 0) ? "+" : ""}${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")}${u}`
|
||||
return `${(sign)&&(n > 0) ? "+" : ""}${n}`
|
||||
}
|
||||
|
||||
/** Bytes formatter */
|
||||
function bytes(n) {
|
||||
for (const {u, v} of [{u:"E", v:10**18}, {u:"P", v:10**15}, {u:"T", v:10**12}, {u:"G", v:10**9}, {u:"M", v:10**6}, {u:"k", v:10**3}])
|
||||
if (n/v >= 1)
|
||||
return `${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")} ${u}B`
|
||||
return `${n} byte${n > 1 ? "s" : ""}`
|
||||
}
|
||||
|
||||
/** Array shuffler */
|
||||
function shuffle(array) {
|
||||
for (let i = array.length-1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random()*(i+1))
|
||||
;[array[i], array[j]] = [array[j], array[i]]
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
/** Escape html */
|
||||
function htmlescape(string, u = {"&":true, "<":true, ">":true, '"':true, "'":true}) {
|
||||
return string
|
||||
.replace(/&(?!(?:amp|lt|gt|quot|apos);)/g, u["&"] ? "&" : "&")
|
||||
.replace(/</g, u["<"] ? "<" : "<")
|
||||
.replace(/>/g, u[">"] ? ">" : ">")
|
||||
.replace(/"/g, u['"'] ? """ : '"')
|
||||
.replace(/'/g, u["'"] ? "'" : "'")
|
||||
}
|
||||
|
||||
/** Expand url */
|
||||
async function urlexpand(url) {
|
||||
try {
|
||||
return (await axios.get(url)).request.res.responseUrl
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/** Run command */
|
||||
async function run(command, options) {
|
||||
return await new Promise((solve, reject) => {
|
||||
console.debug(`metrics/command > ${command}`)
|
||||
const child = processes.exec(command, options)
|
||||
let [stdout, stderr] = ["", ""]
|
||||
child.stdout.on("data", data => stdout += data)
|
||||
child.stderr.on("data", data => stderr += data)
|
||||
child.on("close", code => {
|
||||
console.debug(`metrics/command > ${command} > exited with code ${code}`)
|
||||
return code === 0 ? solve(stdout) : reject(stderr)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Render svg */
|
||||
async function svgresize(svg, {paddings = "6%", convert} = {}) {
|
||||
//Instantiate browser if needed
|
||||
if (!svgresize.browser) {
|
||||
svgresize.browser = await puppeteer.launch({headless:true, executablePath:process.env.PUPPETEER_BROWSER_PATH, args:["--no-sandbox", "--disable-extensions", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]})
|
||||
console.debug(`metrics/svgresize > started ${await svgresize.browser.version()}`)
|
||||
}
|
||||
//Format padding
|
||||
const [pw = 1, ph] = paddings.split(",").map(padding => `${padding}`.substring(0, padding.length-1)).map(value => 1+Number(value)/100)
|
||||
const padding = {width:pw, height:ph ?? pw}
|
||||
console.debug(`metrics/svgresize > padding width*${padding.width}, height*${padding.height}`)
|
||||
//Render through browser and resize height
|
||||
const page = await svgresize.browser.newPage()
|
||||
await page.setContent(svg, {waitUntil:"load"})
|
||||
let mime = "image/svg+xml"
|
||||
let {resized, width, height} = await page.evaluate(async padding => {
|
||||
//Disable animations
|
||||
const animated = !document.querySelector("svg").classList.contains("no-animations")
|
||||
if (animated)
|
||||
document.querySelector("svg").classList.add("no-animations")
|
||||
//Get bounds and resize
|
||||
let {y:height, width} = document.querySelector("svg #metrics-end").getBoundingClientRect()
|
||||
height = Math.ceil(height*padding.height)
|
||||
width = Math.ceil(width*padding.width)
|
||||
//Resize svg
|
||||
document.querySelector("svg").setAttribute("height", height)
|
||||
//Enable animations
|
||||
if (animated)
|
||||
document.querySelector("svg").classList.remove("no-animations")
|
||||
//Result
|
||||
return {resized:new XMLSerializer().serializeToString(document.querySelector("svg")), height, width}
|
||||
}, padding)
|
||||
//Convert if required
|
||||
if (convert) {
|
||||
console.debug(`metrics/svgresize > convert to ${convert}`)
|
||||
resized = await page.screenshot({type:convert, clip:{x:0, y:0, width, height}, omitBackground:true})
|
||||
mime = `image/${convert}`
|
||||
}
|
||||
//Result
|
||||
await page.close()
|
||||
return {resized, mime}
|
||||
}
|
||||
92
source/app/metrics/index.mjs
Normal file
92
source/app/metrics/index.mjs
Normal file
@@ -0,0 +1,92 @@
|
||||
//Imports
|
||||
import util from "util"
|
||||
import ejs from "ejs"
|
||||
import SVGO from "svgo"
|
||||
import * as utils from "./utils.mjs"
|
||||
|
||||
//Setup
|
||||
export default async function metrics({login, q, dflags = []}, {graphql, rest, plugins, conf, die = false, verify = false, convert = null}, {Plugins, Templates}) {
|
||||
//Compute rendering
|
||||
try {
|
||||
|
||||
//Debug
|
||||
console.debug(`metrics/compute/${login} > start`)
|
||||
console.debug(util.inspect(q, {depth:Infinity, maxStringLength:256}))
|
||||
|
||||
//Load template
|
||||
const template = q.template || conf.settings.templates.default
|
||||
if ((!(template in Templates))||(!(template in conf.templates))||((conf.settings.templates.enabled.length)&&(!conf.settings.templates.enabled.includes(template))))
|
||||
throw new Error("unsupported template")
|
||||
const {image, style, fonts, views, partials} = conf.templates[template]
|
||||
const computer = Templates[template].default || Templates[template]
|
||||
|
||||
//Initialization
|
||||
const pending = []
|
||||
const queries = conf.queries
|
||||
const data = {animated:true, base:{}, config:{}, errors:[], plugins:{}, computed:{}}
|
||||
const imports = {plugins:Plugins, templates:Templates, metadata:conf.metadata, ...utils}
|
||||
|
||||
//Partial parts
|
||||
{
|
||||
data.partials = new Set([
|
||||
...decodeURIComponent(q["config.order"] ?? "").split(",").map(x => x.trim().toLocaleLowerCase()).filter(partial => partials.includes(partial)),
|
||||
...partials,
|
||||
])
|
||||
console.debug(`metrics/compute/${login} > content order : ${[...data.partials]}`)
|
||||
}
|
||||
|
||||
//Executing base plugin and compute metrics
|
||||
console.debug(`metrics/compute/${login} > compute`)
|
||||
await Plugins.base({login, q, data, rest, graphql, plugins, queries, pending, imports}, conf)
|
||||
await computer({login, q, dflags}, {conf, data, rest, graphql, plugins, queries, account:data.account}, {pending, imports})
|
||||
const promised = await Promise.all(pending)
|
||||
|
||||
//Check plugins errors
|
||||
const errors = [...promised.filter(({result = null}) => result?.error), ...data.errors]
|
||||
if (errors.length) {
|
||||
console.warn(`metrics/compute/${login} > ${errors.length} errors !`)
|
||||
if (die)
|
||||
throw new Error(`An error occured during rendering, dying`)
|
||||
else
|
||||
console.warn(util.inspect(errors, {depth:Infinity, maxStringLength:256}))
|
||||
}
|
||||
|
||||
//Rendering and resizing
|
||||
console.debug(`metrics/compute/${login} > render`)
|
||||
let rendered = await ejs.render(image, {...data, s:imports.s, f:imports.format, style, fonts}, {views, async:true})
|
||||
const {resized, mime} = await imports.svgresize(rendered, {paddings:q["config.padding"], convert})
|
||||
rendered = resized
|
||||
|
||||
//Additional SVG transformations
|
||||
if (/svg/.test(mime)) {
|
||||
//Optimize rendering
|
||||
if ((conf.settings?.optimize)&&(!q.raw)) {
|
||||
console.debug(`metrics/compute/${login} > optimize`)
|
||||
const svgo = new SVGO({full:true, plugins:[{cleanupAttrs:true}, {inlineStyles:false}]})
|
||||
const {data:optimized} = await svgo.optimize(rendered)
|
||||
rendered = optimized
|
||||
}
|
||||
//Verify svg
|
||||
if (verify) {
|
||||
console.debug(`metrics/compute/${login} > verify SVG`)
|
||||
const libxmljs = (await import("libxmljs")).default
|
||||
const parsed = libxmljs.parseXml(rendered)
|
||||
if (parsed.errors.length)
|
||||
throw new Error(`Malformed SVG : \n${parsed.errors.join("\n")}`)
|
||||
}
|
||||
}
|
||||
|
||||
//Result
|
||||
console.debug(`metrics/compute/${login} > success`)
|
||||
return {rendered, mime}
|
||||
}
|
||||
//Internal error
|
||||
catch (error) {
|
||||
//User not found
|
||||
if (((Array.isArray(error.errors))&&(error.errors[0].type === "NOT_FOUND")))
|
||||
throw new Error("user not found")
|
||||
//Generic error
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
282
source/app/metrics/metadata.mjs
Normal file
282
source/app/metrics/metadata.mjs
Normal file
@@ -0,0 +1,282 @@
|
||||
//Imports
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
import yaml from "js-yaml"
|
||||
import url from "url"
|
||||
|
||||
/** Metadata descriptor parser */
|
||||
export default async function metadata({log = true} = {}) {
|
||||
//Paths
|
||||
const __metrics = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "../../..")
|
||||
const __templates = path.join(__metrics, "source/templates")
|
||||
const __plugins = path.join(__metrics, "source/plugins")
|
||||
|
||||
//Init
|
||||
const logger = log ? console.debug : () => null
|
||||
|
||||
//Load plugins metadata
|
||||
let Plugins = {}
|
||||
logger(`metrics/metadata > loading plugins metadata`)
|
||||
for (const name of await fs.promises.readdir(__plugins)) {
|
||||
if (!(await fs.promises.lstat(path.join(__plugins, name))).isDirectory())
|
||||
continue
|
||||
logger(`metrics/metadata > loading plugin metadata [${name}]`)
|
||||
Plugins[name] = await metadata.plugin({__plugins, name, logger})
|
||||
}
|
||||
//Reorder keys
|
||||
const {base, core, ...plugins} = Plugins
|
||||
Plugins = {base, core, ...plugins}
|
||||
|
||||
//Load templates metadata
|
||||
let Templates = {}
|
||||
logger(`metrics/metadata > loading templates metadata`)
|
||||
for (const name of await fs.promises.readdir(__templates)) {
|
||||
if (!(await fs.promises.lstat(path.join(__templates, name))).isDirectory())
|
||||
continue
|
||||
if (/^@/.test(name))
|
||||
continue
|
||||
logger(`metrics/metadata > loading template metadata [${name}]`)
|
||||
Templates[name] = await metadata.template({__templates, name, plugins, logger})
|
||||
}
|
||||
//Reorder keys
|
||||
const {classic, repository, community, ...templates} = Templates
|
||||
Templates = {classic, repository, ...templates, community}
|
||||
|
||||
//Metadata
|
||||
return {plugins:Plugins, templates:Templates}
|
||||
}
|
||||
|
||||
/** Metadata extractor for templates */
|
||||
metadata.plugin = async function ({__plugins, name, logger}) {
|
||||
try {
|
||||
//Load meta descriptor
|
||||
const raw = `${await fs.promises.readFile(path.join(__plugins, name, "metadata.yml"), "utf-8")}`
|
||||
const {inputs, ...meta} = yaml.load(raw)
|
||||
|
||||
//Inputs parser
|
||||
{
|
||||
meta.inputs = function ({data:{user = null} = {}, q, account}, defaults = {}) {
|
||||
//Support check
|
||||
if (!account)
|
||||
logger(`metrics/inputs > account type not set for plugin ${name}!`)
|
||||
if (account !== "bypass") {
|
||||
if (!meta.supports?.includes(account))
|
||||
throw {error:{message:`Not supported for: ${account}`, instance:new Error()}}
|
||||
if ((q.repo)&&(!meta.supports?.includes("repository")))
|
||||
throw {error:{message:`Not supported for: ${account} repositories`, instance:new Error()}}
|
||||
}
|
||||
//Inputs checks
|
||||
const result = Object.fromEntries(Object.entries(inputs).map(([key, {type, format, default:defaulted, min, max, values}]) => [
|
||||
//Format key
|
||||
metadata.to.query(key, {name}),
|
||||
//Format value
|
||||
(defaulted => {
|
||||
//Default value
|
||||
let value = q[metadata.to.query(key)] ?? q[key] ?? defaulted
|
||||
//Apply type conversion
|
||||
switch (type) {
|
||||
//Booleans
|
||||
case "boolean":{
|
||||
if (/^(?:[Tt]rue|[Oo]n|[Yy]es|1)$/.test(value))
|
||||
return true
|
||||
if (/^(?:[Ff]alse|[Oo]ff|[Nn]o|0)$/.test(value))
|
||||
return false
|
||||
return defaulted
|
||||
}
|
||||
//Numbers
|
||||
case "number":{
|
||||
value = Number(value)
|
||||
if (!Number.isFinite(value))
|
||||
value = defaulted
|
||||
if (Number.isFinite(min))
|
||||
value = Math.max(min, value)
|
||||
if (Number.isFinite(max))
|
||||
value = Math.min(value, max)
|
||||
return value
|
||||
}
|
||||
//Array
|
||||
case "array":{
|
||||
try {
|
||||
value = decodeURIComponent(value)
|
||||
}
|
||||
catch {
|
||||
logger(`metrics/inputs > failed to decode uri : ${value}`)
|
||||
value = defaulted
|
||||
}
|
||||
const separators = {"comma-separated":",", "space-separated":" "}
|
||||
const separator = separators[[format].flat().filter(s => s in separators)[0]] ?? ","
|
||||
return value.split(separator).map(v => v.trim().toLocaleLowerCase()).filter(v => Array.isArray(values) ? values.includes(v) : true).filter(v => v)
|
||||
}
|
||||
//String
|
||||
case "string":{
|
||||
value = value.trim()
|
||||
if (user) {
|
||||
if (value === ".user.login")
|
||||
return user.login
|
||||
if (value === ".user.twitter")
|
||||
return user.twitterUsername
|
||||
if (value === ".user.website")
|
||||
return user.websiteUrl
|
||||
}
|
||||
if ((Array.isArray(values))&&(!values.includes(value)))
|
||||
return defaulted
|
||||
return value
|
||||
}
|
||||
//JSON
|
||||
case "json":{
|
||||
try {
|
||||
value = JSON.parse(value)
|
||||
}
|
||||
catch {
|
||||
logger(`metrics/inputs > failed to parse json : ${value}`)
|
||||
value = JSON.parse(defaulted)
|
||||
}
|
||||
return value
|
||||
}
|
||||
//Token
|
||||
case "token":{
|
||||
return value
|
||||
}
|
||||
//Default
|
||||
default:{
|
||||
return value
|
||||
}
|
||||
}
|
||||
})(defaults[key] ?? defaulted)
|
||||
]))
|
||||
logger(`metrics/inputs > ${name} > ${JSON.stringify(result)}`)
|
||||
return result
|
||||
}
|
||||
Object.assign(meta.inputs, inputs, Object.fromEntries(Object.entries(inputs).map(([key, value]) => [metadata.to.query(key, {name}), value])))
|
||||
}
|
||||
|
||||
//Action metadata
|
||||
{
|
||||
//Extract comments
|
||||
const comments = {}
|
||||
raw.split(/(\r?\n){2,}/m)
|
||||
.map(x => x.trim()).filter(x => x)
|
||||
.map(x => x.split("\n").map(y => y.trim()).join("\n"))
|
||||
.map(x => {
|
||||
const input = x.match(new RegExp(`^\\s*(?<input>${Object.keys(inputs).join("|")}):`, "m"))?.groups?.input ?? null
|
||||
if (input)
|
||||
comments[input] = x.match(new RegExp(`(?<comment>[\\s\\S]*?)(?=(?:${Object.keys(inputs).sort((a, b) => b.length - a.length).join("|")}):)`))?.groups?.comment
|
||||
})
|
||||
|
||||
//Action descriptor
|
||||
meta.action = Object.fromEntries(Object.entries(inputs).map(([key, value]) => [
|
||||
key,
|
||||
{
|
||||
comment:comments[key] ?? "",
|
||||
descriptor:yaml.dump({[key]:Object.fromEntries(Object.entries(value).filter(([key]) => ["description", "default", "required"].includes(key)))}, {quotingType:'"', noCompatMode:true})
|
||||
}
|
||||
]))
|
||||
|
||||
//Action inputs
|
||||
meta.inputs.action = function ({core}) {
|
||||
//Build query object from inputs
|
||||
const q = {}
|
||||
for (const key of Object.keys(inputs)) {
|
||||
const value = `${core.getInput(key)}`.trim()
|
||||
try {
|
||||
q[key] = decodeURIComponent(value)
|
||||
}
|
||||
catch {
|
||||
logger(`metrics/inputs > failed to decode uri : ${value}`)
|
||||
q[key] = value
|
||||
}
|
||||
}
|
||||
return meta.inputs({q, account:"bypass"})
|
||||
}
|
||||
}
|
||||
|
||||
//Web metadata
|
||||
{
|
||||
meta.web = Object.fromEntries(Object.entries(inputs).map(([key, {type, description:text, example, default:defaulted, min = 0, max = 9999, values}]) => [
|
||||
//Format key
|
||||
metadata.to.query(key),
|
||||
//Value descriptor
|
||||
(() => {
|
||||
switch (type) {
|
||||
case "boolean":
|
||||
return {text, type:"boolean"}
|
||||
case "number":
|
||||
return {text, type:"number", min, max, defaulted}
|
||||
case "array":
|
||||
return {text, type:"text", placeholder:example ?? defaulted, defaulted}
|
||||
case "string":{
|
||||
if (Array.isArray(values))
|
||||
return {text, type:"select", values}
|
||||
else
|
||||
return {text, type:"text", placeholder:example ?? defaulted, defaulted}
|
||||
}
|
||||
case "json":
|
||||
return {text, type:"text", placeholder:example ?? defaulted, defaulted}
|
||||
default:
|
||||
return null
|
||||
}
|
||||
})()
|
||||
]).filter(([key, value]) => (value)&&(key !== name)))
|
||||
}
|
||||
|
||||
//Readme metadata
|
||||
{
|
||||
//Extract demos
|
||||
const raw = `${await fs.promises.readFile(path.join(__plugins, name, "README.md"), "utf-8")}`
|
||||
const demo = raw.match(/(?<demo><table>[\s\S]*?<[/]table>)/)?.groups?.demo?.replace(/<[/]?(?:table|tr)>/g, "")?.trim() ?? "<td></td>"
|
||||
|
||||
//Readme descriptor
|
||||
meta.readme = {demo}
|
||||
}
|
||||
|
||||
//Icon
|
||||
meta.icon = meta.name.split(" ")[0] ?? null
|
||||
|
||||
//Result
|
||||
return meta
|
||||
}
|
||||
catch (error) {
|
||||
logger(`metrics/metadata > failed to load plugin ${name}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Metadata extractor for templates */
|
||||
metadata.template = async function ({__templates, name, plugins, logger}) {
|
||||
try {
|
||||
//Load meta descriptor
|
||||
const raw = `${await fs.promises.readFile(path.join(__templates, name, "README.md"), "utf-8")}`
|
||||
|
||||
//Compatibility
|
||||
const partials = path.join(__templates, name, "partials")
|
||||
const compatibility = Object.fromEntries(Object.entries(plugins).map(([key]) => [key, false]))
|
||||
if ((fs.existsSync(partials))&&((await fs.promises.lstat(partials)).isDirectory())) {
|
||||
for (let plugin of await fs.promises.readdir(partials)) {
|
||||
plugin = plugin.match(/(?<plugin>^[\s\S]+(?=[.]ejs$))/)?.groups?.plugin ?? null
|
||||
if (plugin in compatibility)
|
||||
compatibility[plugin] = true
|
||||
}
|
||||
}
|
||||
|
||||
//Result
|
||||
return {
|
||||
name:raw.match(/^### (?<name>[\s\S]+?)\n/)?.groups?.name?.trim(),
|
||||
readme:{
|
||||
demo:raw.match(/(?<demo><table>[\s\S]*?<[/]table>)/)?.groups?.demo?.replace(/<[/]?(?:table|tr)>/g, "")?.trim() ?? (name === "community" ? `<td>See <a href="/source/templates/community/README.md">documentation</a> 🌍</td>` : "<td></td>"),
|
||||
compatibility:{...compatibility, base:true},
|
||||
},
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
logger(`metrics/metadata > failed to load template ${name}: ${error}`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** Metadata converters */
|
||||
metadata.to = {
|
||||
query(key, {name = null} = {}) {
|
||||
key = key.replace(/^plugin_/, "").replace(/_/g, ".")
|
||||
return name ? key.replace(new RegExp(`^(${name}.)`, "g"), "") : key
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,9 @@
|
||||
import util from "util"
|
||||
import url from "url"
|
||||
import processes from "child_process"
|
||||
import metadata from "./metadata.mjs"
|
||||
|
||||
//Templates and plugins
|
||||
const Templates = {}
|
||||
const Plugins = {}
|
||||
|
||||
@@ -12,11 +14,10 @@
|
||||
export default async function ({log = true, nosettings = false, community = {}} = {}) {
|
||||
|
||||
//Paths
|
||||
const __metrics = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "../..")
|
||||
const __metrics = path.join(path.dirname(url.fileURLToPath(import.meta.url)), "../../..")
|
||||
const __statics = path.join(__metrics, "source/app/web/statics")
|
||||
const __templates = path.join(__metrics, "source/templates")
|
||||
const __plugins = path.join(__metrics, "source/plugins")
|
||||
const __queries = path.join(__metrics, "source/queries")
|
||||
const __package = path.join(__metrics, "package.json")
|
||||
const __settings = path.join(__metrics, "settings.json")
|
||||
const __modules = path.join(__metrics, "node_modules")
|
||||
@@ -28,6 +29,7 @@
|
||||
templates:{},
|
||||
queries:{},
|
||||
settings:{},
|
||||
metadata:{},
|
||||
paths:{
|
||||
statics:__statics,
|
||||
templates:__templates,
|
||||
@@ -61,7 +63,11 @@
|
||||
conf.package = JSON.parse(`${await fs.promises.readFile(__package)}`)
|
||||
logger(`metrics/setup > load package.json > success`)
|
||||
|
||||
//Load community template
|
||||
//Load community templates
|
||||
if ((typeof conf.settings.community.templates === "string")&&(conf.settings.community.templates.length)) {
|
||||
logger(`metrics/setup > parsing community templates list`)
|
||||
conf.settings.community.templates = [...new Set([...decodeURIComponent(conf.settings.community.templates).split(",").map(v => v.trim().toLocaleLowerCase()).filter(v => v)])]
|
||||
}
|
||||
if ((Array.isArray(conf.settings.community.templates))&&(conf.settings.community.templates.length)) {
|
||||
//Clean remote repository
|
||||
logger(`metrics/setup > ${conf.settings.community.templates.length} community templates to install`)
|
||||
@@ -104,9 +110,9 @@
|
||||
|
||||
//Load templates
|
||||
for (const name of await fs.promises.readdir(__templates)) {
|
||||
//Search for template
|
||||
//Search for templates
|
||||
const directory = path.join(__templates, name)
|
||||
if (!(await fs.promises.lstat(directory)).isDirectory())
|
||||
if ((!(await fs.promises.lstat(directory)).isDirectory())||(!fs.existsSync(path.join(directory, "partials/_.json"))))
|
||||
continue
|
||||
logger(`metrics/setup > load template [${name}]`)
|
||||
//Cache templates files
|
||||
@@ -138,39 +144,54 @@
|
||||
|
||||
//Load plugins
|
||||
for (const name of await fs.promises.readdir(__plugins)) {
|
||||
//Search for plugins
|
||||
const directory = path.join(__plugins, name)
|
||||
if (!(await fs.promises.lstat(directory)).isDirectory())
|
||||
continue
|
||||
//Cache plugins scripts
|
||||
logger(`metrics/setup > load plugin [${name}]`)
|
||||
Plugins[name] = (await import(url.pathToFileURL(path.join(__plugins, name, "index.mjs")).href)).default
|
||||
Plugins[name] = (await import(url.pathToFileURL(path.join(directory, "index.mjs")).href)).default
|
||||
logger(`metrics/setup > load plugin [${name}] > success`)
|
||||
}
|
||||
|
||||
//Load queries
|
||||
for (const query of await fs.promises.readdir(__queries)) {
|
||||
//Cache queries
|
||||
const name = query.replace(/[.]graphql$/, "")
|
||||
logger(`metrics/setup > load query [${name}]`)
|
||||
conf.queries[`_${name}`] = `${await fs.promises.readFile(path.join(__queries, query))}`
|
||||
logger(`metrics/setup > load query [${name}] > success`)
|
||||
//Debug
|
||||
if (conf.settings.debug) {
|
||||
Object.defineProperty(conf.queries, `_${name}`, {
|
||||
get() {
|
||||
logger(`metrics/setup > reload query [${name}]`)
|
||||
const raw = `${fs.readFileSync(path.join(__queries, query))}`
|
||||
logger(`metrics/setup > reload query [${name}] > success`)
|
||||
return raw
|
||||
//Register queries
|
||||
const __queries = path.join(directory, "queries")
|
||||
if (fs.existsSync(__queries)) {
|
||||
//Alias for default query
|
||||
const queries = conf.queries[name] = function () {
|
||||
if (!queries[name])
|
||||
throw new ReferenceError(`Default query for ${name} undefined`)
|
||||
return queries[name](...arguments)
|
||||
}
|
||||
})
|
||||
//Load queries
|
||||
for (const file of await fs.promises.readdir(__queries)) {
|
||||
//Cache queries
|
||||
const query = file.replace(/[.]graphql$/, "")
|
||||
logger(`metrics/setup > load query [${name}/${query}]`)
|
||||
queries[`_${query}`] = `${await fs.promises.readFile(path.join(__queries, file))}`
|
||||
logger(`metrics/setup > load query [${name}/${query}] > success`)
|
||||
//Debug
|
||||
if (conf.settings.debug) {
|
||||
Object.defineProperty(queries, `_${query}`, {
|
||||
get() {
|
||||
logger(`metrics/setup > reload query [${name}/${query}]`)
|
||||
const raw = `${fs.readFileSync(path.join(__queries, file))}`
|
||||
logger(`metrics/setup > reload query [${name}/${query}] > success`)
|
||||
return raw
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
//Create queries formatters
|
||||
Object.keys(queries).map(query => queries[query.substring(1)] = (vars = {}) => {
|
||||
let queried = queries[query]
|
||||
for (const [key, value] of Object.entries(vars))
|
||||
queried = queried.replace(new RegExp(`[$]${key}`, "g"), value)
|
||||
return queried
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
//Create queries formatters
|
||||
Object.keys(conf.queries).map(name => conf.queries[name.substring(1)] = (vars = {}) => {
|
||||
let query = conf.queries[name]
|
||||
for (const [key, value] of Object.entries(vars))
|
||||
query = query.replace(new RegExp(`[$]${key}`, "g"), value)
|
||||
return query
|
||||
})
|
||||
//Load metadata (plugins)
|
||||
conf.metadata = await metadata({log})
|
||||
|
||||
//Conf
|
||||
logger(`metrics/setup > setup > success`)
|
||||
124
source/app/metrics/utils.mjs
Normal file
124
source/app/metrics/utils.mjs
Normal file
@@ -0,0 +1,124 @@
|
||||
//Imports
|
||||
import fs from "fs/promises"
|
||||
import os from "os"
|
||||
import paths from "path"
|
||||
import url from "url"
|
||||
import util from "util"
|
||||
import processes from "child_process"
|
||||
import axios from "axios"
|
||||
import puppeteer from "puppeteer"
|
||||
import imgb64 from "image-to-base64"
|
||||
|
||||
export {fs, os, paths, url, util, processes, axios, puppeteer, imgb64}
|
||||
|
||||
/** Returns module __dirname */
|
||||
export function __module(module) {
|
||||
return paths.join(paths.dirname(url.fileURLToPath(module)))
|
||||
}
|
||||
|
||||
/** Plural formatter */
|
||||
export function s(value, end = "") {
|
||||
return value !== 1 ? {y:"ies", "":"s"}[end] : end
|
||||
}
|
||||
|
||||
/** Formatter */
|
||||
export function format(n, {sign = false} = {}) {
|
||||
for (const {u, v} of [{u:"b", v:10**9}, {u:"m", v:10**6}, {u:"k", v:10**3}])
|
||||
if (n/v >= 1)
|
||||
return `${(sign)&&(n > 0) ? "+" : ""}${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")}${u}`
|
||||
return `${(sign)&&(n > 0) ? "+" : ""}${n}`
|
||||
}
|
||||
|
||||
/** Bytes formatter */
|
||||
export function bytes(n) {
|
||||
for (const {u, v} of [{u:"E", v:10**18}, {u:"P", v:10**15}, {u:"T", v:10**12}, {u:"G", v:10**9}, {u:"M", v:10**6}, {u:"k", v:10**3}])
|
||||
if (n/v >= 1)
|
||||
return `${(n/v).toFixed(2).substr(0, 4).replace(/[.]0*$/, "")} ${u}B`
|
||||
return `${n} byte${n > 1 ? "s" : ""}`
|
||||
}
|
||||
|
||||
/** Array shuffler */
|
||||
export function shuffle(array) {
|
||||
for (let i = array.length-1; i > 0; i--) {
|
||||
const j = Math.floor(Math.random()*(i+1))
|
||||
;[array[i], array[j]] = [array[j], array[i]]
|
||||
}
|
||||
return array
|
||||
}
|
||||
|
||||
/** Escape html */
|
||||
export function htmlescape(string, u = {"&":true, "<":true, ">":true, '"':true, "'":true}) {
|
||||
return string
|
||||
.replace(/&(?!(?:amp|lt|gt|quot|apos);)/g, u["&"] ? "&" : "&")
|
||||
.replace(/</g, u["<"] ? "<" : "<")
|
||||
.replace(/>/g, u[">"] ? ">" : ">")
|
||||
.replace(/"/g, u['"'] ? """ : '"')
|
||||
.replace(/'/g, u["'"] ? "'" : "'")
|
||||
}
|
||||
|
||||
/** Expand url */
|
||||
export async function urlexpand(url) {
|
||||
try {
|
||||
return (await axios.get(url)).request.res.responseUrl
|
||||
} catch {
|
||||
return url
|
||||
}
|
||||
}
|
||||
|
||||
/** Run command */
|
||||
export async function run(command, options) {
|
||||
return await new Promise((solve, reject) => {
|
||||
console.debug(`metrics/command > ${command}`)
|
||||
const child = processes.exec(command, options)
|
||||
let [stdout, stderr] = ["", ""]
|
||||
child.stdout.on("data", data => stdout += data)
|
||||
child.stderr.on("data", data => stderr += data)
|
||||
child.on("close", code => {
|
||||
console.debug(`metrics/command > ${command} > exited with code ${code}`)
|
||||
return code === 0 ? solve(stdout) : reject(stderr)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/** Render svg */
|
||||
export async function svgresize(svg, {paddings = ["6%"], convert} = {}) {
|
||||
//Instantiate browser if needed
|
||||
if (!svgresize.browser) {
|
||||
svgresize.browser = await puppeteer.launch({headless:true, executablePath:process.env.PUPPETEER_BROWSER_PATH, args:["--no-sandbox", "--disable-extensions", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]})
|
||||
console.debug(`metrics/svgresize > started ${await svgresize.browser.version()}`)
|
||||
}
|
||||
//Format padding
|
||||
const [pw = 1, ph] = paddings.map(padding => `${padding}`.substring(0, padding.length-1)).map(value => 1+Number(value)/100)
|
||||
const padding = {width:pw, height:ph ?? pw}
|
||||
console.debug(`metrics/svgresize > padding width*${padding.width}, height*${padding.height}`)
|
||||
//Render through browser and resize height
|
||||
const page = await svgresize.browser.newPage()
|
||||
await page.setContent(svg, {waitUntil:"load"})
|
||||
let mime = "image/svg+xml"
|
||||
let {resized, width, height} = await page.evaluate(async padding => {
|
||||
//Disable animations
|
||||
const animated = !document.querySelector("svg").classList.contains("no-animations")
|
||||
if (animated)
|
||||
document.querySelector("svg").classList.add("no-animations")
|
||||
//Get bounds and resize
|
||||
let {y:height, width} = document.querySelector("svg #metrics-end").getBoundingClientRect()
|
||||
height = Math.ceil(height*padding.height)
|
||||
width = Math.ceil(width*padding.width)
|
||||
//Resize svg
|
||||
document.querySelector("svg").setAttribute("height", height)
|
||||
//Enable animations
|
||||
if (animated)
|
||||
document.querySelector("svg").classList.remove("no-animations")
|
||||
//Result
|
||||
return {resized:new XMLSerializer().serializeToString(document.querySelector("svg")), height, width}
|
||||
}, padding)
|
||||
//Convert if required
|
||||
if (convert) {
|
||||
console.debug(`metrics/svgresize > convert to ${convert}`)
|
||||
resized = await page.screenshot({type:convert, clip:{x:0, y:0, width, height}, omitBackground:true})
|
||||
mime = `image/${convert}`
|
||||
}
|
||||
//Result
|
||||
await page.close()
|
||||
return {resized, mime}
|
||||
}
|
||||
1459
source/app/mocks.mjs
1459
source/app/mocks.mjs
File diff suppressed because it is too large
Load Diff
66
source/app/mocks/api/axios/get/lastfm.mjs
Normal file
66
source/app/mocks/api/axios/get/lastfm.mjs
Normal file
@@ -0,0 +1,66 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, url, options, login = faker.internet.userName()}) {
|
||||
//Last.fm api
|
||||
if (/^https:..ws.audioscrobbler.com.*$/.test(url)) {
|
||||
//Get recently played tracks
|
||||
if (/user.getrecenttracks/.test(url)) {
|
||||
console.debug(`metrics/compute/mocks > mocking lastfm api result > ${url}`)
|
||||
const artist = faker.random.word()
|
||||
const album = faker.random.words(3)
|
||||
const track = faker.random.words(5)
|
||||
const date = faker.date.recent()
|
||||
return ({
|
||||
status:200,
|
||||
data:{
|
||||
recenttracks:{
|
||||
"@attr":{
|
||||
page:"1",
|
||||
perPage:"1",
|
||||
user:"RJ",
|
||||
total:"100",
|
||||
pages:"100",
|
||||
},
|
||||
track:[
|
||||
{
|
||||
artist:{
|
||||
mbid:"",
|
||||
"#text":artist,
|
||||
},
|
||||
album:{
|
||||
mbid:"",
|
||||
"#text":album,
|
||||
},
|
||||
image:[
|
||||
{
|
||||
size:"small",
|
||||
"#text":faker.image.abstract(),
|
||||
},
|
||||
{
|
||||
size:"medium",
|
||||
"#text":faker.image.abstract(),
|
||||
},
|
||||
{
|
||||
size:"large",
|
||||
"#text":faker.image.abstract(),
|
||||
},
|
||||
{
|
||||
size:"extralarge",
|
||||
"#text":faker.image.abstract(),
|
||||
},
|
||||
],
|
||||
streamable:"0",
|
||||
date:{
|
||||
uts:Math.floor(date.getTime() / 1000),
|
||||
"#text":date.toUTCString().slice(5, 22),
|
||||
},
|
||||
url:faker.internet.url(),
|
||||
name:track,
|
||||
mbid:"",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
105
source/app/mocks/api/axios/get/pagespeed.mjs
Normal file
105
source/app/mocks/api/axios/get/pagespeed.mjs
Normal file
@@ -0,0 +1,105 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, url, options, login = faker.internet.userName()}) {
|
||||
//Tested url
|
||||
const tested = url.match(/&url=(?<tested>.*?)(?:&|$)/)?.groups?.tested ?? faker.internet.url()
|
||||
//Pagespeed api
|
||||
if (/^https:..www.googleapis.com.pagespeedonline.v5/.test(url)) {
|
||||
//Pagespeed result
|
||||
if (/v5.runPagespeed.*&key=MOCKED_TOKEN/.test(url)) {
|
||||
console.debug(`metrics/compute/mocks > mocking pagespeed api result > ${url}`)
|
||||
return ({
|
||||
status:200,
|
||||
data:{
|
||||
captchaResult:"CAPTCHA_NOT_NEEDED",
|
||||
id:tested,
|
||||
lighthouseResult:{
|
||||
requestedUrl:tested,
|
||||
finalUrl:tested,
|
||||
lighthouseVersion:"6.3.0",
|
||||
audits:{
|
||||
"final-screenshot":{
|
||||
id:"final-screenshot",
|
||||
title:"Final Screenshot",
|
||||
score: null,
|
||||
details:{
|
||||
data:"data:image/jpg;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
|
||||
type:"screenshot",
|
||||
timestamp:Date.now()
|
||||
}
|
||||
},
|
||||
metrics:{
|
||||
id:"metrics",
|
||||
title:"Metrics",
|
||||
score: null,
|
||||
details:{
|
||||
items:[
|
||||
{
|
||||
observedFirstContentfulPaint:faker.random.number(500),
|
||||
observedFirstVisualChangeTs:faker.time.recent(),
|
||||
observedFirstContentfulPaintTs:faker.time.recent(),
|
||||
firstContentfulPaint:faker.random.number(500),
|
||||
observedDomContentLoaded:faker.random.number(500),
|
||||
observedFirstMeaningfulPaint:faker.random.number(1000),
|
||||
maxPotentialFID:faker.random.number(500),
|
||||
observedLoad:faker.random.number(500),
|
||||
firstMeaningfulPaint:faker.random.number(500),
|
||||
observedCumulativeLayoutShift:faker.random.float({max:1}),
|
||||
observedSpeedIndex:faker.random.number(1000),
|
||||
observedSpeedIndexTs:faker.time.recent(),
|
||||
observedTimeOriginTs:faker.time.recent(),
|
||||
observedLargestContentfulPaint:faker.random.number(1000),
|
||||
cumulativeLayoutShift:faker.random.float({max:1}),
|
||||
observedFirstPaintTs:faker.time.recent(),
|
||||
observedTraceEndTs:faker.time.recent(),
|
||||
largestContentfulPaint:faker.random.number(2000),
|
||||
observedTimeOrigin:faker.random.number(10),
|
||||
speedIndex:faker.random.number(1000),
|
||||
observedTraceEnd:faker.random.number(2000),
|
||||
observedDomContentLoadedTs:faker.time.recent(),
|
||||
observedFirstPaint:faker.random.number(500),
|
||||
totalBlockingTime:faker.random.number(500),
|
||||
observedLastVisualChangeTs:faker.time.recent(),
|
||||
observedFirstVisualChange:faker.random.number(500),
|
||||
observedLargestContentfulPaintTs:faker.time.recent(),
|
||||
estimatedInputLatency:faker.random.number(100),
|
||||
observedLoadTs:faker.time.recent(),
|
||||
observedLastVisualChange:faker.random.number(1000),
|
||||
firstCPUIdle:faker.random.number(1000),
|
||||
interactive:faker.random.number(1000),
|
||||
observedNavigationStartTs:faker.time.recent(),
|
||||
observedNavigationStart:faker.random.number(10),
|
||||
observedFirstMeaningfulPaintTs:faker.time.recent()
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
},
|
||||
categories:{
|
||||
"best-practices":{
|
||||
id:"best-practices",
|
||||
title:"Best Practices",
|
||||
score:faker.random.float({max:1}),
|
||||
},
|
||||
seo:{
|
||||
id:"seo",
|
||||
title:"SEO",
|
||||
score:faker.random.float({max:1}),
|
||||
},
|
||||
accessibility:{
|
||||
id:"accessibility",
|
||||
title:"Accessibility",
|
||||
score:faker.random.float({max:1}),
|
||||
},
|
||||
performance: {
|
||||
id:"performance",
|
||||
title:"Performance",
|
||||
score:faker.random.float({max:1}),
|
||||
}
|
||||
},
|
||||
},
|
||||
analysisUTCTimestamp:`${faker.date.recent()}`,
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
65
source/app/mocks/api/axios/get/spotify.mjs
Normal file
65
source/app/mocks/api/axios/get/spotify.mjs
Normal file
@@ -0,0 +1,65 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, url, options, login = faker.internet.userName()}) {
|
||||
//Spotify api
|
||||
if (/^https:..api.spotify.com/.test(url)) {
|
||||
//Get recently played tracks
|
||||
if (/me.player.recently-played/.test(url)&&(options?.headers?.Authorization === "Bearer MOCKED_TOKEN_ACCESS")) {
|
||||
console.debug(`metrics/compute/mocks > mocking spotify api result > ${url}`)
|
||||
const artist = faker.random.words()
|
||||
const track = faker.random.words(5)
|
||||
return ({
|
||||
status:200,
|
||||
data:{
|
||||
items:[
|
||||
{
|
||||
track:{
|
||||
album:{
|
||||
album_type:"single",
|
||||
artists:[
|
||||
{
|
||||
name:artist,
|
||||
type:"artist",
|
||||
}
|
||||
],
|
||||
images:[
|
||||
{
|
||||
height:640,
|
||||
url:faker.image.abstract(),
|
||||
width:640
|
||||
},
|
||||
{
|
||||
height:300,
|
||||
url:faker.image.abstract(),
|
||||
width:300
|
||||
},
|
||||
{
|
||||
height:64,
|
||||
url:faker.image.abstract(),
|
||||
width:64
|
||||
}
|
||||
],
|
||||
name:track,
|
||||
release_date:`${faker.date.past()}`.substring(0, 10),
|
||||
type:"album",
|
||||
},
|
||||
artists:[
|
||||
{
|
||||
name:artist,
|
||||
type:"artist",
|
||||
}
|
||||
],
|
||||
name:track,
|
||||
preview_url:faker.internet.url(),
|
||||
type:"track",
|
||||
},
|
||||
played_at:`${faker.date.recent()}`,
|
||||
context:{
|
||||
type:"album",
|
||||
}
|
||||
},
|
||||
],
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
64
source/app/mocks/api/axios/get/twitter.mjs
Normal file
64
source/app/mocks/api/axios/get/twitter.mjs
Normal file
@@ -0,0 +1,64 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, url, options, login = faker.internet.userName()}) {
|
||||
//Twitter api
|
||||
if (/^https:..api.twitter.com/.test(url)) {
|
||||
//Get user profile
|
||||
if ((/users.by.username/.test(url))&&(options?.headers?.Authorization === "Bearer MOCKED_TOKEN")) {
|
||||
console.debug(`metrics/compute/mocks > mocking twitter api result > ${url}`)
|
||||
const username = url.match(/username[/](?<username>.*?)[?]/)?.groups?.username ?? faker.internet.userName()
|
||||
return ({
|
||||
status:200,
|
||||
data:{
|
||||
data:{
|
||||
profile_image_url:faker.image.people(),
|
||||
name:faker.name.findName(),
|
||||
verified:faker.random.boolean(),
|
||||
id:faker.random.number(1000000).toString(),
|
||||
username,
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
//Get recent tweets
|
||||
if ((/tweets.search.recent/.test(url))&&(options?.headers?.Authorization === "Bearer MOCKED_TOKEN")) {
|
||||
console.debug(`metrics/compute/mocks > mocking twitter api result > ${url}`)
|
||||
return ({
|
||||
status:200,
|
||||
data:{
|
||||
data:[
|
||||
{
|
||||
id:faker.random.number(100000000000000).toString(),
|
||||
created_at:`${faker.date.recent()}`,
|
||||
entities:{
|
||||
mentions:[
|
||||
{start:22, end:33, username:"lowlighter"},
|
||||
],
|
||||
},
|
||||
text:"Checkout metrics from @lowlighter ! #GitHub",
|
||||
},
|
||||
{
|
||||
id:faker.random.number(100000000000000).toString(),
|
||||
created_at:`${faker.date.recent()}`,
|
||||
text:faker.lorem.paragraph(),
|
||||
}
|
||||
],
|
||||
includes:{
|
||||
users:[
|
||||
{
|
||||
id:faker.random.number(100000000000000).toString(),
|
||||
name:"lowlighter",
|
||||
username:"lowlighter",
|
||||
},
|
||||
]
|
||||
},
|
||||
meta:{
|
||||
newest_id:faker.random.number(100000000000000).toString(),
|
||||
oldest_id:faker.random.number(100000000000000).toString(),
|
||||
result_count:2,
|
||||
next_token:"MOCKED_CURSOR",
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
124
source/app/mocks/api/axios/post/anilist.mjs
Normal file
124
source/app/mocks/api/axios/post/anilist.mjs
Normal file
@@ -0,0 +1,124 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, url, body, login = faker.internet.userName()}) {
|
||||
if (/^https:..graphql.anilist.co/.test(url)) {
|
||||
//Initialization and media generator
|
||||
const query = body.query
|
||||
const media = ({type}) => ({
|
||||
title:{romaji:faker.lorem.words(), english:faker.lorem.words(), native:faker.lorem.words()},
|
||||
description:faker.lorem.paragraphs(),
|
||||
type,
|
||||
status:faker.random.arrayElement(["FINISHED", "RELEASING", "NOT_YET_RELEASED", "CANCELLED", "HIATUS"]),
|
||||
episodes:100+faker.random.number(100),
|
||||
volumes:faker.random.number(100),
|
||||
chapters:100+faker.random.number(1000),
|
||||
averageScore:faker.random.number(100),
|
||||
countryOfOrigin:"JP",
|
||||
genres:new Array(6).fill(null).map(_ => faker.lorem.word()),
|
||||
coverImage:{medium:null},
|
||||
startDate:{year:faker.date.past(20).getFullYear()}
|
||||
})
|
||||
//User statistics query
|
||||
if (/^query Statistics /.test(query)) {
|
||||
console.debug(`metrics/compute/mocks > mocking anilist api result > Statistics`)
|
||||
return ({
|
||||
status:200,
|
||||
data:{
|
||||
data:{
|
||||
User:{
|
||||
id:faker.random.number(100000),
|
||||
name:faker.internet.userName(),
|
||||
about:null,
|
||||
statistics:{
|
||||
anime:{
|
||||
count:faker.random.number(1000),
|
||||
minutesWatched:faker.random.number(100000),
|
||||
episodesWatched:faker.random.number(10000),
|
||||
genres:new Array(4).fill(null).map(_ => ({genre:faker.lorem.word()})),
|
||||
},
|
||||
manga:{
|
||||
count:faker.random.number(1000),
|
||||
chaptersRead:faker.random.number(100000),
|
||||
volumesRead:faker.random.number(10000),
|
||||
genres:new Array(4).fill(null).map(_ => ({genre:faker.lorem.word()})),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
//Favorites characters
|
||||
if (/^query FavoritesCharacters /.test(query)) {
|
||||
console.debug(`metrics/compute/mocks > mocking anilist api result > Favorites characters`)
|
||||
return ({
|
||||
status:200,
|
||||
data:{
|
||||
data:{
|
||||
User:{
|
||||
favourites:{
|
||||
characters:{
|
||||
nodes:new Array(2+faker.random.number(16)).fill(null).map(_ => ({
|
||||
name:{full:faker.name.findName(), native:faker.name.findName()},
|
||||
image:{medium:null}
|
||||
}),
|
||||
),
|
||||
pageInfo:{currentPage:1, hasNextPage:false}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
//Favorites anime/manga query
|
||||
if (/^query Favorites /.test(query)) {
|
||||
console.debug(`metrics/compute/mocks > mocking anilist api result > Favorites`)
|
||||
const type = /anime[(]/.test(query) ? "ANIME" : /manga[(]/.test(query) ? "MANGA" : "OTHER"
|
||||
return ({
|
||||
status:200,
|
||||
data:{
|
||||
data:{
|
||||
User:{
|
||||
favourites:{
|
||||
[type.toLocaleLowerCase()]:{
|
||||
nodes:new Array(16).fill(null).map(_ => media({type})),
|
||||
pageInfo:{currentPage:1, hasNextPage:false},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
//Medias query
|
||||
if (/^query Medias /.test(query)) {
|
||||
console.debug(`metrics/compute/mocks > mocking anilist api result > Medias`)
|
||||
const type = body.variables.type
|
||||
return ({
|
||||
status:200,
|
||||
data:{
|
||||
data:{
|
||||
MediaListCollection:{
|
||||
lists:[
|
||||
{
|
||||
name:{ANIME:"Watching", MANGA:"Reading", OTHER:"Completed"}[type],
|
||||
isCustomList:false,
|
||||
entries:new Array(16).fill(null).map(_ => ({
|
||||
status:faker.random.arrayElement(["CURRENT", "PLANNING", "COMPLETED", "DROPPED", "PAUSED", "REPEATING"]),
|
||||
progress:faker.random.number(100),
|
||||
progressVolumes: null,
|
||||
score:0,
|
||||
startedAt:{year:null, month:null, day:null},
|
||||
completedAt:{year:null, month:null, day:null},
|
||||
media:media({type})
|
||||
})),
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
22
source/app/mocks/api/axios/post/spotify.mjs
Normal file
22
source/app/mocks/api/axios/post/spotify.mjs
Normal file
@@ -0,0 +1,22 @@
|
||||
//Imports
|
||||
import urls from "url"
|
||||
|
||||
/** Mocked data */
|
||||
export default function ({faker, url, body, login = faker.internet.userName()}) {
|
||||
if (/^https:..accounts.spotify.com.api.token/.test(url)) {
|
||||
//Access token generator
|
||||
const params = new urls.URLSearchParams(body)
|
||||
if ((params.get("grant_type") === "refresh_token")&&(params.get("client_id") === "MOCKED_CLIENT_ID")&&(params.get("client_secret") === "MOCKED_CLIENT_SECRET")&&(params.get("refresh_token") === "MOCKED_REFRESH_TOKEN")) {
|
||||
console.debug(`metrics/compute/mocks > mocking spotify api result > ${url}`)
|
||||
return ({
|
||||
status:200,
|
||||
data:{
|
||||
access_token:"MOCKED_TOKEN_ACCESS",
|
||||
token_type:"Bearer",
|
||||
expires_in:3600,
|
||||
scope:"user-read-recently-played user-read-private",
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
48
source/app/mocks/api/github/graphql/base.repositories.mjs
Normal file
48
source/app/mocks/api/github/graphql/base.repositories.mjs
Normal file
@@ -0,0 +1,48 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > base/repositories`)
|
||||
return /after: "MOCKED_CURSOR"/m.test(query) ? ({
|
||||
user:{
|
||||
repositories:{
|
||||
edges:[],
|
||||
nodes:[],
|
||||
}
|
||||
}
|
||||
}) : ({
|
||||
user:{
|
||||
repositories:{
|
||||
edges:[
|
||||
{
|
||||
cursor:"MOCKED_CURSOR"
|
||||
},
|
||||
],
|
||||
nodes:[
|
||||
{
|
||||
name:faker.random.words(),
|
||||
watchers:{totalCount:faker.random.number(1000)},
|
||||
stargazers:{totalCount:faker.random.number(10000)},
|
||||
owner:{login},
|
||||
languages:{
|
||||
edges:[
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
]
|
||||
},
|
||||
issues_open:{totalCount:faker.random.number(100)},
|
||||
issues_closed:{totalCount:faker.random.number(100)},
|
||||
pr_open:{totalCount:faker.random.number(100)},
|
||||
pr_merged:{totalCount:faker.random.number(100)},
|
||||
releases:{totalCount:faker.random.number(100)},
|
||||
forkCount:faker.random.number(100),
|
||||
licenseInfo:{spdxId:"MIT"}
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
35
source/app/mocks/api/github/graphql/base.repository.mjs
Normal file
35
source/app/mocks/api/github/graphql/base.repository.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > base/repository`)
|
||||
return ({
|
||||
user:{
|
||||
repository:{
|
||||
name:"metrics",
|
||||
owner:{login},
|
||||
createdAt:new Date().toISOString(),
|
||||
diskUsage:Math.floor(Math.random()*10000),
|
||||
homepageUrl:faker.internet.url(),
|
||||
watchers:{totalCount:faker.random.number(1000)},
|
||||
stargazers:{totalCount:faker.random.number(10000)},
|
||||
languages:{
|
||||
edges:[
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
{size:faker.random.number(100000), node:{color:faker.internet.color(), name:faker.lorem.word()}},
|
||||
]
|
||||
},
|
||||
issues_open:{totalCount:faker.random.number(100)},
|
||||
issues_closed:{totalCount:faker.random.number(100)},
|
||||
pr_open:{totalCount:faker.random.number(100)},
|
||||
pr_merged:{totalCount:faker.random.number(100)},
|
||||
releases:{totalCount:faker.random.number(100)},
|
||||
forkCount:faker.random.number(100),
|
||||
licenseInfo:{spdxId:"MIT"}
|
||||
},
|
||||
}
|
||||
})
|
||||
}
|
||||
68
source/app/mocks/api/github/graphql/base.user.mjs
Normal file
68
source/app/mocks/api/github/graphql/base.user.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > base/user`)
|
||||
return ({
|
||||
user: {
|
||||
databaseId:faker.random.number(10000000),
|
||||
name:faker.name.findName(),
|
||||
login,
|
||||
createdAt:`${faker.date.past(10)}`,
|
||||
avatarUrl:faker.image.people(),
|
||||
websiteUrl:faker.internet.url(),
|
||||
isHireable:faker.random.boolean(),
|
||||
twitterUsername:login,
|
||||
repositories:{totalCount:faker.random.number(100), totalDiskUsage:faker.random.number(100000), nodes:[]},
|
||||
packages:{totalCount:faker.random.number(10)},
|
||||
starredRepositories:{totalCount:faker.random.number(1000)},
|
||||
watching:{totalCount:faker.random.number(100)},
|
||||
sponsorshipsAsSponsor:{totalCount:faker.random.number(10)},
|
||||
sponsorshipsAsMaintainer:{totalCount:faker.random.number(10)},
|
||||
contributionsCollection:{
|
||||
totalRepositoriesWithContributedCommits:faker.random.number(100),
|
||||
totalCommitContributions:faker.random.number(10000),
|
||||
restrictedContributionsCount:faker.random.number(10000),
|
||||
totalIssueContributions:faker.random.number(100),
|
||||
totalPullRequestContributions:faker.random.number(1000),
|
||||
totalPullRequestReviewContributions:faker.random.number(1000),
|
||||
},
|
||||
calendar:{
|
||||
contributionCalendar:{
|
||||
weeks:[
|
||||
{
|
||||
contributionDays:[
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
]
|
||||
},
|
||||
{
|
||||
contributionDays:[
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
]
|
||||
},
|
||||
{
|
||||
contributionDays:[
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
{color:faker.random.arrayElement(["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"])},
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
repositoriesContributedTo:{totalCount:faker.random.number(100)},
|
||||
followers:{totalCount:faker.random.number(1000)},
|
||||
following:{totalCount:faker.random.number(1000)},
|
||||
issueComments:{totalCount:faker.random.number(1000)},
|
||||
organizations:{totalCount:faker.random.number(10)}
|
||||
}
|
||||
})
|
||||
}
|
||||
39
source/app/mocks/api/github/graphql/gists.default.mjs
Normal file
39
source/app/mocks/api/github/graphql/gists.default.mjs
Normal file
@@ -0,0 +1,39 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > gists/default`)
|
||||
return /after: "MOCKED_CURSOR"/m.test(query) ? ({
|
||||
user:{
|
||||
gists:{
|
||||
edges:[],
|
||||
nodes:[],
|
||||
}
|
||||
}
|
||||
}) : ({
|
||||
user:{
|
||||
gists:{
|
||||
edges:[
|
||||
{
|
||||
cursor:"MOCKED_CURSOR"
|
||||
},
|
||||
],
|
||||
totalCount:faker.random.number(100),
|
||||
nodes:[
|
||||
{
|
||||
stargazerCount:faker.random.number(10),
|
||||
isFork:false,
|
||||
forks:{totalCount:faker.random.number(10)},
|
||||
files:[{name:faker.system.fileName()}],
|
||||
comments:{totalCount:faker.random.number(10)}
|
||||
},
|
||||
{
|
||||
stargazerCount:faker.random.number(10),
|
||||
isFork:false,
|
||||
forks:{totalCount:faker.random.number(10)},
|
||||
files:[{name:faker.system.fileName()}],
|
||||
comments:{totalCount:faker.random.number(10)}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
32
source/app/mocks/api/github/graphql/isocalendar.calendar.mjs
Normal file
32
source/app/mocks/api/github/graphql/isocalendar.calendar.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > isocalendar/calendar`)
|
||||
//Generate calendar
|
||||
const date = new Date(query.match(/from: "(?<date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z)"/)?.groups?.date)
|
||||
const to = new Date(query.match(/to: "(?<date>\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z)"/)?.groups?.date)
|
||||
const weeks = []
|
||||
let contributionDays = []
|
||||
for (; date <= to; date.setDate(date.getDate()+1)) {
|
||||
//Create new week on sunday
|
||||
if (date.getDay() === 0) {
|
||||
weeks.push({contributionDays})
|
||||
contributionDays = []
|
||||
}
|
||||
//Random contributions
|
||||
const contributionCount = Math.min(10, Math.max(0, faker.random.number(14)-4))
|
||||
contributionDays.push({
|
||||
contributionCount,
|
||||
color:["#ebedf0", "#9be9a8", "#40c463", "#30a14e", "#216e39"][Math.ceil(contributionCount/10/0.25)],
|
||||
date:date.toISOString().substring(0, 10)
|
||||
})
|
||||
}
|
||||
return ({
|
||||
user: {
|
||||
calendar:{
|
||||
contributionCalendar:{
|
||||
weeks
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
24
source/app/mocks/api/github/graphql/people.default.mjs
Normal file
24
source/app/mocks/api/github/graphql/people.default.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > people/default`)
|
||||
const type = query.match(/(?<type>followers|following)[(]/)?.groups?.type ?? "(unknown type)"
|
||||
return /after: "MOCKED_CURSOR"/m.test(query) ? ({
|
||||
user:{
|
||||
[type]:{
|
||||
edges:[],
|
||||
}
|
||||
}
|
||||
}) : ({
|
||||
user:{
|
||||
[type]:{
|
||||
edges:new Array(Math.ceil(20+80*Math.random())).fill(null).map((login = faker.internet.userName()) => ({
|
||||
cursor:"MOCKED_CURSOR",
|
||||
node:{
|
||||
login,
|
||||
avatarUrl:null,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
28
source/app/mocks/api/github/graphql/people.repository.mjs
Normal file
28
source/app/mocks/api/github/graphql/people.repository.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > People`)
|
||||
const type = query.match(/(?<type>stargazers|watchers)[(]/)?.groups?.type ?? "(unknown type)"
|
||||
return /after: "MOCKED_CURSOR"/m.test(query) ? ({
|
||||
user:{
|
||||
repository:{
|
||||
[type]:{
|
||||
edges:[],
|
||||
}
|
||||
}
|
||||
}
|
||||
}) : ({
|
||||
user:{
|
||||
repository:{
|
||||
[type]:{
|
||||
edges:new Array(Math.ceil(20+80*Math.random())).fill(null).map((login = faker.internet.userName()) => ({
|
||||
cursor:"MOCKED_CURSOR",
|
||||
node:{
|
||||
login,
|
||||
avatarUrl:null,
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
32
source/app/mocks/api/github/graphql/people.sponsors.mjs
Normal file
32
source/app/mocks/api/github/graphql/people.sponsors.mjs
Normal file
@@ -0,0 +1,32 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > People`)
|
||||
const type = query.match(/(?<type>sponsorshipsAsSponsor|sponsorshipsAsMaintainer)[(]/)?.groups?.type ?? "(unknown type)"
|
||||
return /after: "MOCKED_CURSOR"/m.test(query) ? ({
|
||||
user:{
|
||||
login,
|
||||
[type]:{
|
||||
edges:[]
|
||||
}
|
||||
}
|
||||
}) : ({
|
||||
user:{
|
||||
login,
|
||||
[type]:{
|
||||
edges:new Array(Math.ceil(20+80*Math.random())).fill(null).map((login = faker.internet.userName()) => ({
|
||||
cursor:"MOCKED_CURSOR",
|
||||
node:{
|
||||
sponsorEntity:{
|
||||
login:faker.internet.userName(),
|
||||
avatarUrl:null,
|
||||
},
|
||||
sponsorable:{
|
||||
login:faker.internet.userName(),
|
||||
avatarUrl:null,
|
||||
}
|
||||
}
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
21
source/app/mocks/api/github/graphql/projects.repository.mjs
Normal file
21
source/app/mocks/api/github/graphql/projects.repository.mjs
Normal file
@@ -0,0 +1,21 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > projects/repository`)
|
||||
return ({
|
||||
user:{
|
||||
repository:{
|
||||
project:{
|
||||
name:"Repository project example",
|
||||
updatedAt:`${faker.date.recent()}`,
|
||||
body:faker.lorem.paragraph(),
|
||||
progress:{
|
||||
doneCount:faker.random.number(10),
|
||||
inProgressCount:faker.random.number(10),
|
||||
todoCount:faker.random.number(10),
|
||||
enabled:true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
24
source/app/mocks/api/github/graphql/projects.user.mjs
Normal file
24
source/app/mocks/api/github/graphql/projects.user.mjs
Normal file
@@ -0,0 +1,24 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > projects/user`)
|
||||
return ({
|
||||
user:{
|
||||
projects:{
|
||||
totalCount:1,
|
||||
nodes:[
|
||||
{
|
||||
name:"User-owned project",
|
||||
updatedAt:`${faker.date.recent()}`,
|
||||
body:faker.lorem.paragraph(),
|
||||
progress:{
|
||||
doneCount:faker.random.number(10),
|
||||
inProgressCount:faker.random.number(10),
|
||||
todoCount:faker.random.number(10),
|
||||
enabled:true
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
20
source/app/mocks/api/github/graphql/stargazers.default.mjs
Normal file
20
source/app/mocks/api/github/graphql/stargazers.default.mjs
Normal file
@@ -0,0 +1,20 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > stargazers/default`)
|
||||
return /after: "MOCKED_CURSOR"/m.test(query) ? ({
|
||||
repository:{
|
||||
stargazers:{
|
||||
edges:[],
|
||||
}
|
||||
}
|
||||
}) : ({
|
||||
repository:{
|
||||
stargazers:{
|
||||
edges:new Array(faker.random.number({min:50, max:100})).fill(null).map(() => ({
|
||||
starredAt:`${faker.date.recent(30)}`,
|
||||
cursor:"MOCKED_CURSOR"
|
||||
}))
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
37
source/app/mocks/api/github/graphql/stars.default.mjs
Normal file
37
source/app/mocks/api/github/graphql/stars.default.mjs
Normal file
@@ -0,0 +1,37 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker, query, login = faker.internet.userName()}) {
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api result > stars/default`)
|
||||
return ({
|
||||
user:{
|
||||
starredRepositories:{
|
||||
edges:[
|
||||
{
|
||||
starredAt:`${faker.date.recent(14)}`,
|
||||
node:{
|
||||
description:"📊 An image generator with 20+ metrics about your GitHub account such as activity, community, repositories, coding habits, website performances, music played, starred topics, etc. that you can put on your profile or elsewhere !",
|
||||
forkCount:faker.random.number(100),
|
||||
isFork:false,
|
||||
issues:{
|
||||
totalCount:faker.random.number(100),
|
||||
},
|
||||
nameWithOwner:"lowlighter/metrics",
|
||||
openGraphImageUrl:"https://repository-images.githubusercontent.com/293860197/7fd72080-496d-11eb-8fe0-238b38a0746a",
|
||||
pullRequests:{
|
||||
totalCount:faker.random.number(100),
|
||||
},
|
||||
stargazerCount:faker.random.number(10000),
|
||||
licenseInfo:{
|
||||
nickname:null,
|
||||
name:"MIT License"
|
||||
},
|
||||
primaryLanguage:{
|
||||
color:"#f1e05a",
|
||||
name:"JavaScript"
|
||||
}
|
||||
}
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
28
source/app/mocks/api/github/rest/commits.mjs
Normal file
28
source/app/mocks/api/github/rest/commits.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
/** Mocked data */
|
||||
export default function({faker}, target, that, [{page, per_page, owner, repo}]) {
|
||||
console.debug(`metrics/compute/mocks > mocking rest api result > rest.repos.listCommits`)
|
||||
return ({
|
||||
status:200,
|
||||
url:`https://api.github.com/repos/${owner}/${repo}/commits?per_page=${per_page}&page=${page}`,
|
||||
headers: {
|
||||
server:"GitHub.com",
|
||||
status:"200 OK",
|
||||
"x-oauth-scopes":"repo",
|
||||
},
|
||||
data:page < 2 ? new Array(per_page).fill(null).map(() =>
|
||||
({
|
||||
sha:"MOCKED_SHA",
|
||||
commit:{
|
||||
author:{
|
||||
name:owner,
|
||||
date:`${faker.date.recent(14)}`
|
||||
},
|
||||
committer:{
|
||||
name:owner,
|
||||
date:`${faker.date.recent(14)}`
|
||||
},
|
||||
}
|
||||
})
|
||||
) : []
|
||||
})
|
||||
}
|
||||
18
source/app/mocks/api/github/rest/contributors.mjs
Normal file
18
source/app/mocks/api/github/rest/contributors.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Mocked data */
|
||||
export default function({faker}, target, that, [{owner, repo}]) {
|
||||
console.debug(`metrics/compute/mocks > mocking rest api result > rest.repos.listContributors`)
|
||||
return ({
|
||||
status:200,
|
||||
url:`https://api.github.com/repos/${owner}/${repo}/contributors`,
|
||||
headers: {
|
||||
server:"GitHub.com",
|
||||
status:"200 OK",
|
||||
"x-oauth-scopes":"repo",
|
||||
},
|
||||
data:new Array(40+faker.random.number(60)).fill(null).map(() => ({
|
||||
login:faker.internet.userName(),
|
||||
avatar_url:null,
|
||||
contributions:faker.random.number(1000),
|
||||
}))
|
||||
})
|
||||
}
|
||||
325
source/app/mocks/api/github/rest/events.mjs
Normal file
325
source/app/mocks/api/github/rest/events.mjs
Normal file
@@ -0,0 +1,325 @@
|
||||
/** Mocked data */
|
||||
export default function ({faker}, target, that, [{username:login, page, per_page}]) {
|
||||
console.debug(`metrics/compute/mocks > mocking rest api result > rest.activity.listEventsForAuthenticatedUser`)
|
||||
return ({
|
||||
status:200,
|
||||
url:`https://api.github.com/users/${login}/events?per_page=${per_page}&page=${page}`,
|
||||
headers:{
|
||||
server:"GitHub.com",
|
||||
status:"200 OK",
|
||||
"x-oauth-scopes":"repo",
|
||||
},
|
||||
data:page < 1 ? [] : [
|
||||
{
|
||||
id:"10000000000",
|
||||
type:"CommitCommentEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
comment:{
|
||||
user:{
|
||||
login,
|
||||
},
|
||||
path:faker.system.fileName(),
|
||||
commit_id:"MOCKED_SHA",
|
||||
body:faker.lorem.sentence(),
|
||||
}
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000001",
|
||||
type:"PullRequestReviewCommentEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
action:"created",
|
||||
comment:{
|
||||
user:{
|
||||
login,
|
||||
},
|
||||
body:faker.lorem.paragraph(),
|
||||
},
|
||||
pull_request:{
|
||||
title:faker.lorem.sentence(),
|
||||
number:1,
|
||||
user:{
|
||||
login:faker.internet.userName(),
|
||||
},
|
||||
body:"",
|
||||
}
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000002",
|
||||
type:"IssuesEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
action:faker.random.arrayElement(["opened", "closed", "reopened"]),
|
||||
issue:{
|
||||
number:2,
|
||||
title:faker.lorem.sentence(),
|
||||
user:{
|
||||
login,
|
||||
},
|
||||
body:faker.lorem.paragraph(),
|
||||
performed_via_github_app:null
|
||||
}
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000003",
|
||||
type:"GollumEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
pages:[
|
||||
{
|
||||
page_name:faker.lorem.sentence(),
|
||||
title:faker.lorem.sentence(),
|
||||
summary:null,
|
||||
action:"created",
|
||||
sha:"MOCKED_SHA",
|
||||
}
|
||||
]
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000004",
|
||||
type:"IssueCommentEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
action:"created",
|
||||
issue:{
|
||||
number:3,
|
||||
title:faker.lorem.sentence(),
|
||||
user:{
|
||||
login,
|
||||
},
|
||||
labels:[
|
||||
{
|
||||
name:"lorem ipsum",
|
||||
color:"d876e3",
|
||||
}
|
||||
],
|
||||
state:"open",
|
||||
},
|
||||
comment:{
|
||||
body:faker.lorem.paragraph(),
|
||||
performed_via_github_app:null
|
||||
}
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000005",
|
||||
type:"ForkEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
forkee:{
|
||||
name:faker.random.word(),
|
||||
full_name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
}
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000006",
|
||||
type:"PullRequestReviewEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
action:"created",
|
||||
review:{
|
||||
user:{
|
||||
login,
|
||||
},
|
||||
state:"approved",
|
||||
},
|
||||
pull_request:{
|
||||
state:"open",
|
||||
number:4,
|
||||
locked:false,
|
||||
title:faker.lorem.sentence(),
|
||||
user:{
|
||||
login:faker.internet.userName(),
|
||||
},
|
||||
}
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000007",
|
||||
type:"ReleaseEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
action:"published",
|
||||
release:{
|
||||
tag_name:`v${faker.random.number()}.${faker.random.number()}`,
|
||||
name:faker.random.words(4),
|
||||
draft:faker.random.boolean(),
|
||||
prerelease:faker.random.boolean(),
|
||||
}
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000008",
|
||||
type:"CreateEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
ref:faker.lorem.slug(),
|
||||
ref_type:faker.random.arrayElement(["tag", "branch"]),
|
||||
master_branch:"master",
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"100000000009",
|
||||
type:"WatchEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:"lowlighter/metrics",
|
||||
},
|
||||
payload:{action:"started"},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000010",
|
||||
type:"DeleteEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
ref:faker.lorem.slug(),
|
||||
ref_type:faker.random.arrayElement(["tag", "branch"]),
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000011",
|
||||
type:"PushEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
size:1,
|
||||
ref:"refs/heads/master",
|
||||
commits:[
|
||||
{
|
||||
sha:"MOCKED_SHA",
|
||||
message:faker.lorem.sentence(),
|
||||
}
|
||||
]
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000012",
|
||||
type:"PullRequestEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
action:faker.random.arrayElement(["opened", "closed"]),
|
||||
number:5,
|
||||
pull_request:{
|
||||
user:{
|
||||
login,
|
||||
},
|
||||
state:"open",
|
||||
title:faker.lorem.sentence(),
|
||||
additions:faker.random.number(1000),
|
||||
deletions:faker.random.number(1000),
|
||||
changed_files:faker.random.number(10),
|
||||
}
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000013",
|
||||
type:"MemberEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{
|
||||
member:{
|
||||
login:faker.internet.userName(),
|
||||
},
|
||||
action:"added"
|
||||
},
|
||||
created_at:faker.date.recent(7),
|
||||
},
|
||||
{
|
||||
id:"10000000014",
|
||||
type:"PublicEvent",
|
||||
actor:{
|
||||
login,
|
||||
},
|
||||
repo:{
|
||||
name:`${faker.random.word()}/${faker.random.word()}`,
|
||||
},
|
||||
payload:{},
|
||||
created_at:faker.date.recent(7),
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
23
source/app/mocks/api/github/rest/ratelimit.mjs
Normal file
23
source/app/mocks/api/github/rest/ratelimit.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Mocked data */
|
||||
export default function({faker}, target, that, args) {
|
||||
return ({
|
||||
status:200,
|
||||
url:"https://api.github.com/rate_limit",
|
||||
headers:{
|
||||
server:"GitHub.com",
|
||||
status:"200 OK",
|
||||
"x-oauth-scopes":"repo",
|
||||
},
|
||||
data:{
|
||||
resources:{
|
||||
core:{limit:5000, used:0, remaining:5000, reset:0},
|
||||
search:{limit:30, used:0, remaining:30, reset:0},
|
||||
graphql:{limit:5000, used:0, remaining:5000, reset:0},
|
||||
integration_manifest:{limit:5000, used:0, remaining:5000, reset:0},
|
||||
source_import:{limit:100, used:0, remaining:100, reset:0},
|
||||
code_scanning_upload:{limit:500, used:0, remaining:500, reset:0},
|
||||
},
|
||||
rate:{limit:5000, used:0, remaining:"MOCKED", reset:0}
|
||||
}
|
||||
})
|
||||
}
|
||||
59
source/app/mocks/api/github/rest/raw.mjs
Normal file
59
source/app/mocks/api/github/rest/raw.mjs
Normal file
@@ -0,0 +1,59 @@
|
||||
/** Mocked data */
|
||||
export default function({faker}, target, that, args) {
|
||||
//Arguments
|
||||
const [url] = args
|
||||
//Head request
|
||||
if (/^HEAD .$/.test(url)) {
|
||||
console.debug(`metrics/compute/mocks > mocking rest api result > rest.request HEAD`)
|
||||
return ({
|
||||
status:200,
|
||||
url:"https://api.github.com/",
|
||||
headers:{
|
||||
server:"GitHub.com",
|
||||
status:"200 OK",
|
||||
"x-oauth-scopes":"repo",
|
||||
},
|
||||
data:undefined
|
||||
})
|
||||
}
|
||||
//Commit content
|
||||
if (/^https:..api.github.com.repos.lowlighter.metrics.commits.MOCKED_SHA/.test(url)) {
|
||||
console.debug(`metrics/compute/mocks > mocking rest api result > rest.request ${url}`)
|
||||
return ({
|
||||
status:200,
|
||||
url:"https://api.github.com/repos/lowlighter/metrics/commits/MOCKED_SHA",
|
||||
data:{
|
||||
sha:"MOCKED_SHA",
|
||||
commit:{
|
||||
author:{
|
||||
name:faker.internet.userName(),
|
||||
email:faker.internet.email(),
|
||||
date:`${faker.date.recent(7)}`,
|
||||
},
|
||||
committer:{
|
||||
name:faker.internet.userName(),
|
||||
email:faker.internet.email(),
|
||||
date:`${faker.date.recent(7)}`,
|
||||
},
|
||||
},
|
||||
author:{
|
||||
login:faker.internet.userName(),
|
||||
id:faker.random.number(100000000),
|
||||
},
|
||||
committer:{
|
||||
login:faker.internet.userName(),
|
||||
id:faker.random.number(100000000),
|
||||
},
|
||||
files: [
|
||||
{
|
||||
sha:"MOCKED_SHA",
|
||||
filename:faker.system.fileName(),
|
||||
patch:"@@ -0,0 +1,5 @@\n+//Imports\n+ import app from \"./src/app.mjs\"\n+\n+//Start app\n+ await app()\n\\ No newline at end of file"
|
||||
},
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return target(...args)
|
||||
}
|
||||
27
source/app/mocks/api/github/rest/stats.mjs
Normal file
27
source/app/mocks/api/github/rest/stats.mjs
Normal file
@@ -0,0 +1,27 @@
|
||||
/** Mocked data */
|
||||
export default function({faker}, target, that, [{owner, repo}]) {
|
||||
console.debug(`metrics/compute/mocks > mocking rest api result > rest.repos.getContributorsStats`)
|
||||
return ({
|
||||
status:200,
|
||||
url:`https://api.github.com/repos/${owner}/${repo}/stats/contributors`,
|
||||
headers:{
|
||||
server:"GitHub.com",
|
||||
status:"200 OK",
|
||||
"x-oauth-scopes":"repo",
|
||||
},
|
||||
data:[
|
||||
{
|
||||
total:faker.random.number(10000),
|
||||
weeks:[
|
||||
{w:1, a:faker.random.number(10000), d:faker.random.number(10000), c:faker.random.number(10000)},
|
||||
{w:2, a:faker.random.number(10000), d:faker.random.number(10000), c:faker.random.number(10000)},
|
||||
{w:3, a:faker.random.number(10000), d:faker.random.number(10000), c:faker.random.number(10000)},
|
||||
{w:4, a:faker.random.number(10000), d:faker.random.number(10000), c:faker.random.number(10000)},
|
||||
],
|
||||
author: {
|
||||
login:owner,
|
||||
}
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
18
source/app/mocks/api/github/rest/username.mjs
Normal file
18
source/app/mocks/api/github/rest/username.mjs
Normal file
@@ -0,0 +1,18 @@
|
||||
/** Mocked data */
|
||||
export default function({faker}, target, that, [{username}]) {
|
||||
console.debug(`metrics/compute/mocks > mocking rest api result > rest.repos.getByUsername`)
|
||||
return ({
|
||||
status:200,
|
||||
url:`'https://api.github.com/users/${username}/`,
|
||||
headers: {
|
||||
server:"GitHub.com",
|
||||
status:"200 OK",
|
||||
"x-oauth-scopes":"repo",
|
||||
},
|
||||
data:{
|
||||
login:faker.internet.userName(),
|
||||
avatar_url:null,
|
||||
contributions:faker.random.number(1000),
|
||||
}
|
||||
})
|
||||
}
|
||||
23
source/app/mocks/api/github/rest/views.mjs
Normal file
23
source/app/mocks/api/github/rest/views.mjs
Normal file
@@ -0,0 +1,23 @@
|
||||
/** Mocked data */
|
||||
export default function({faker}, target, that, [{owner, repo}]) {
|
||||
console.debug(`metrics/compute/mocks > mocking rest api result > rest.repos.getViews`)
|
||||
const count = faker.random.number(10000)*2
|
||||
const uniques = faker.random.number(count)*2
|
||||
return ({
|
||||
status:200,
|
||||
url:`https://api.github.com/repos/${owner}/${repo}/traffic/views`,
|
||||
headers:{
|
||||
server:"GitHub.com",
|
||||
status:"200 OK",
|
||||
"x-oauth-scopes":"repo",
|
||||
},
|
||||
data:{
|
||||
count,
|
||||
uniques,
|
||||
views:[
|
||||
{timestamp:`${faker.date.recent()}`, count:count/2, uniques:uniques/2},
|
||||
{timestamp:`${faker.date.recent()}`, count:count/2, uniques:uniques/2},
|
||||
]
|
||||
}
|
||||
})
|
||||
}
|
||||
130
source/app/mocks/index.mjs
Normal file
130
source/app/mocks/index.mjs
Normal file
@@ -0,0 +1,130 @@
|
||||
//Imports
|
||||
import axios from "axios"
|
||||
import faker from "faker"
|
||||
import paths from "path"
|
||||
import urls from "url"
|
||||
import fs from "fs/promises"
|
||||
|
||||
//Mocked state
|
||||
let mocked = false
|
||||
|
||||
//Mocking
|
||||
export default async function ({graphql, rest}) {
|
||||
|
||||
//Check if already mocked
|
||||
if (mocked)
|
||||
return {graphql, rest}
|
||||
mocked = true
|
||||
console.debug(`metrics/compute/mocks > mocking`)
|
||||
|
||||
//Load mocks
|
||||
const __mocks = paths.join(paths.dirname(urls.fileURLToPath(import.meta.url)))
|
||||
const mock = async ({directory, mocks}) => {
|
||||
for (const entry of await fs.readdir(directory)) {
|
||||
if ((await fs.lstat(paths.join(directory, entry))).isDirectory()) {
|
||||
if (!mocks[entry])
|
||||
mocks[entry] = {}
|
||||
await mock({directory:paths.join(directory, entry), mocks:mocks[entry]})
|
||||
}
|
||||
else
|
||||
mocks[entry.replace(/[.]mjs$/, "")] = (await import(urls.pathToFileURL(paths.join(directory, entry)).href)).default
|
||||
}
|
||||
return mocks
|
||||
}
|
||||
const mocks = await mock({directory:paths.join(__mocks, "api"), mocks:{}})
|
||||
|
||||
//GraphQL API mocking
|
||||
{
|
||||
//Unmocked
|
||||
console.debug(`metrics/compute/mocks > mocking graphql api`)
|
||||
const unmocked = graphql
|
||||
//Mocked
|
||||
graphql = new Proxy(unmocked, {
|
||||
apply(target, that, args) {
|
||||
//Arguments
|
||||
const [query] = args
|
||||
const login = query.match(/login: "(?<login>.*?)"/)?.groups?.login ?? faker.internet.userName()
|
||||
|
||||
//Search for mocked query
|
||||
for (const mocked of Object.keys(mocks.github.graphql))
|
||||
if (new RegExp(`^query ${mocked.replace(/([.]\w)/g, (_, g) => g.toLocaleUpperCase().substring(1)).replace(/^(\w)/g, (_, g) => g.toLocaleUpperCase())} `).test(query))
|
||||
return mocks.github.graphql[mocked]({faker, query, login})
|
||||
|
||||
//Unmocked call
|
||||
return target(...args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//Rest API mocking
|
||||
{
|
||||
//Unmocked
|
||||
console.debug(`metrics/compute/mocks > mocking rest api`)
|
||||
const unmocked = {
|
||||
request:rest.request,
|
||||
rateLimit:rest.rateLimit.get,
|
||||
listEventsForAuthenticatedUser:rest.activity.listEventsForAuthenticatedUser,
|
||||
getViews:rest.repos.getViews,
|
||||
getContributorsStats:rest.repos.getContributorsStats,
|
||||
listCommits:rest.repos.listCommits,
|
||||
listContributors:rest.repos.listContributors,
|
||||
getByUsername:rest.users.getByUsername,
|
||||
}
|
||||
//Mocked
|
||||
rest.request = new Proxy(unmocked.request, {apply:mocks.github.rest.raw.bind(null, {faker})})
|
||||
rest.rateLimit.get = new Proxy(unmocked.rateLimit, {apply:mocks.github.rest.ratelimit.bind(null, {faker})})
|
||||
rest.activity.listEventsForAuthenticatedUser = new Proxy(unmocked.listEventsForAuthenticatedUser, {apply:mocks.github.rest.events.bind(null, {faker})})
|
||||
rest.repos.getViews = new Proxy(unmocked.getViews, {apply:mocks.github.rest.views.bind(null, {faker})})
|
||||
rest.repos.getContributorsStats = new Proxy(unmocked.getContributorsStats, {apply:mocks.github.rest.stats.bind(null, {faker})})
|
||||
rest.repos.listCommits = new Proxy(unmocked.listCommits, {apply:mocks.github.rest.commits.bind(null, {faker})})
|
||||
rest.repos.listContributors = new Proxy(unmocked.listContributors, {apply:mocks.github.rest.contributors.bind(null, {faker})})
|
||||
rest.users.getByUsername = new Proxy(unmocked.getByUsername, {apply:mocks.github.rest.username.bind(null, {faker})})
|
||||
}
|
||||
|
||||
//Axios mocking
|
||||
{
|
||||
//Unmocked
|
||||
console.debug(`metrics/compute/mocks > mocking axios`)
|
||||
const unmocked = {get:axios.get, post:axios.post}
|
||||
|
||||
//Mocked post requests
|
||||
axios.post = new Proxy(unmocked.post, {
|
||||
apply:function(target, that, args) {
|
||||
//Arguments
|
||||
const [url, body] = args
|
||||
|
||||
//Search for mocked request
|
||||
for (const service of Object.keys(mocks.axios.post)) {
|
||||
const mocked = mocks.axios.post[service]({faker, url, body})
|
||||
if (mocked)
|
||||
return mocked
|
||||
}
|
||||
|
||||
//Unmocked call
|
||||
return target(...args)
|
||||
}
|
||||
})
|
||||
|
||||
//Mocked get requests
|
||||
axios.get = new Proxy(unmocked.get, {
|
||||
apply:function(target, that, args) {
|
||||
//Arguments
|
||||
const [url, options] = args
|
||||
|
||||
//Search for mocked request
|
||||
for (const service of Object.keys(mocks.axios.get)) {
|
||||
const mocked = mocks.axios.get[service]({faker, url, options})
|
||||
if (mocked)
|
||||
return mocked
|
||||
}
|
||||
|
||||
//Unmocked call
|
||||
return target(...args)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
//Return mocked elements
|
||||
return {graphql, rest}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,9 @@
|
||||
import compression from "compression"
|
||||
import cache from "memory-cache"
|
||||
import util from "util"
|
||||
import setup from "../setup.mjs"
|
||||
import mocks from "../mocks.mjs"
|
||||
import metrics from "../metrics.mjs"
|
||||
import setup from "../metrics/setup.mjs"
|
||||
import mocks from "../mocks/index.mjs"
|
||||
import metrics from "../metrics/index.mjs"
|
||||
|
||||
/** App */
|
||||
export default async function ({mock, nosettings} = {}) {
|
||||
@@ -66,8 +66,11 @@
|
||||
|
||||
//Base routes
|
||||
const limiter = ratelimit({max:debug ? Number.MAX_SAFE_INTEGER : 60, windowMs:60*1000})
|
||||
const metadata = Object.fromEntries(Object.entries(conf.metadata.plugins)
|
||||
.filter(([key]) => !["base", "core"].includes(key))
|
||||
.map(([key, value]) => [key, Object.fromEntries(Object.entries(value).filter(([key]) => ["name", "icon", "web", "supports"].includes(key)))]))
|
||||
const enabled = Object.entries(metadata).map(([name]) => ({name, enabled:plugins[name]?.enabled ?? false}))
|
||||
const templates = Object.entries(Templates).map(([name]) => ({name, enabled:(conf.settings.templates.enabled.length ? conf.settings.templates.enabled.includes(name) : true) ?? false}))
|
||||
const enabled = Object.entries(Plugins).map(([name]) => ({name, enabled:plugins[name]?.enabled ?? false}))
|
||||
const actions = {flush:new Map()}
|
||||
let requests = (await rest.rateLimit.get()).data.rate
|
||||
setInterval(async () => requests = (await rest.rateLimit.get()).data.rate, 30*1000)
|
||||
@@ -80,6 +83,7 @@
|
||||
//Plugins and templates
|
||||
app.get("/.plugins", limiter, (req, res) => res.status(200).json(enabled))
|
||||
app.get("/.plugins.base", limiter, (req, res) => res.status(200).json(conf.settings.plugins.base.parts))
|
||||
app.get("/.plugins.metadata", limiter, (req, res) => res.status(200).json(metadata))
|
||||
app.get("/.templates", limiter, (req, res) => res.status(200).json(templates))
|
||||
app.get("/.templates/:template", limiter, (req, res) => req.params.template in conf.templates ? res.status(200).json(conf.templates[req.params.template]) : res.sendStatus(404))
|
||||
for (const template in conf.templates)
|
||||
@@ -148,7 +152,7 @@
|
||||
//Compute rendering
|
||||
try {
|
||||
//Render
|
||||
const q = parse(req.query)
|
||||
const q = req.query
|
||||
console.debug(`metrics/app/${login} > ${util.inspect(q, {depth:Infinity, maxStringLength:256})}`)
|
||||
const {rendered, mime} = await metrics({login, q}, {
|
||||
graphql, rest, plugins, conf,
|
||||
@@ -195,19 +199,3 @@
|
||||
`Server ready !`
|
||||
].join("\n")))
|
||||
}
|
||||
|
||||
/** Query parser */
|
||||
function parse(query) {
|
||||
for (const [key, value] of Object.entries(query)) {
|
||||
//Parse number
|
||||
if (/^\d+$/.test(value))
|
||||
query[key] = Number(value)
|
||||
//Parse boolean
|
||||
if (/^(?:true|false)$/.test(value))
|
||||
query[key] = (value === "true")||(value === true)
|
||||
//Parse null
|
||||
if (/^null$/.test(value))
|
||||
query[key] = null
|
||||
}
|
||||
return query
|
||||
}
|
||||
|
||||
29
source/app/web/settings.example.json
Normal file
29
source/app/web/settings.example.json
Normal file
@@ -0,0 +1,29 @@
|
||||
{
|
||||
"//": "Example of configuration for metrics web instance",
|
||||
"//": "====================================================================",
|
||||
|
||||
"token": "MY GITHUB API TOKEN", "//": "GitHub Personal Token (required)",
|
||||
"restricted": [], "//": "Authorized users (empty to disable)",
|
||||
"maxusers": 0, "//": "Maximum users, (0 to disable)",
|
||||
"cached": 3600000, "//": "Cache time rendered metrics (0 to disable)",
|
||||
"ratelimiter": null, "//": "Rate limiter (see express-rate-limit documentation)",
|
||||
"port": 3000, "//": "Listening port",
|
||||
"optimize": true, "//": "SVG optimization",
|
||||
"debug": false, "//": "Debug logs",
|
||||
"mocked": false, "//": "Use mocked data instead of live APIs",
|
||||
"repositories": 100, "//": "Number of repositories to use",
|
||||
"community": {
|
||||
"templates": [], "//": "Additional community templates to setup"
|
||||
},
|
||||
"templates": {
|
||||
"default": "classic", "//": "Default template",
|
||||
"enabled": [], "//": "Enabled templates (empty to enable all)"
|
||||
},
|
||||
"plugins": { "//": "Global plugin configuration",
|
||||
<% for (const name of Object.keys(plugins).filter(v => !["base", "core"].includes(v))) { -%>
|
||||
"<%= name %>":{
|
||||
<%- JSON.stringify(Object.fromEntries(Object.entries(plugins[name].inputs).filter(([key, {type}]) => type === "token").map(([key, {description:value}]) => [key.replace(new RegExp(`^plugin_${name}_`), ""), value])), null, 6).replace(/^[{]/gm, "").replace(/^\s*[}]$/gm, "").replace(/": "/gm, `${'": null,'.padEnd(22)} "//":"`).replace(/"$/gm, '",').trimStart().replace(/\n$/gm, "\n ") %>"enabled": false, "//": "<%= plugins[name].inputs[`plugin_${name}`].description %>"
|
||||
},
|
||||
<% } %>"//": ""
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
//Init
|
||||
const {data:templates} = await axios.get("/.templates")
|
||||
const {data:plugins} = await axios.get("/.plugins")
|
||||
const {data:metadata} = await axios.get("/.plugins.metadata")
|
||||
const {data:base} = await axios.get("/.plugins.base")
|
||||
const {data:version} = await axios.get("/.version")
|
||||
templates.sort((a, b) => (a.name.startsWith("@") ^ b.name.startsWith("@")) ? (a.name.startsWith("@") ? 1 : -1) : a.name.localeCompare(b.name))
|
||||
@@ -60,111 +61,20 @@
|
||||
list:plugins,
|
||||
enabled:{base:Object.fromEntries(base.map(key => [key, true]))},
|
||||
descriptions:{
|
||||
pagespeed:"⏱️ Website performances",
|
||||
languages:"🈷️ Most used languages",
|
||||
followup:"🎟️ Issues and pull requests",
|
||||
traffic:"🧮 Pages views",
|
||||
lines:"👨💻 Lines of code changed",
|
||||
habits:"💡 Coding habits",
|
||||
music:"🎼 Music plugin",
|
||||
posts:"✒️ Recent posts",
|
||||
isocalendar:"📅 Isometric commit calendar",
|
||||
gists:"🎫 Gists metrics",
|
||||
topics:"📌 Starred topics",
|
||||
projects:"🗂️ Projects",
|
||||
tweets:"🐤 Latest tweets",
|
||||
stars:"🌟 Recently starred repositories",
|
||||
stargazers:"✨ Stargazers over last weeks",
|
||||
activity:"📰 Recent activity",
|
||||
people:"🧑🤝🧑 People",
|
||||
anilist:"🌸 Anilist",
|
||||
base:"🗃️ Base content",
|
||||
"base.header":"Header",
|
||||
"base.activity":"Account activity",
|
||||
"base.community":"Community stats",
|
||||
"base.repositories":"Repositories metrics",
|
||||
"base.metadata":"Metadata",
|
||||
...Object.fromEntries(Object.entries(metadata).map(([key, {name}]) => [key, name]))
|
||||
},
|
||||
options:{
|
||||
descriptions:{
|
||||
"languages.ignored":{text:"Ignored languages", placeholder:"lang-0, lang-1, ..."},
|
||||
"languages.skipped":{text:"Skipped repositories", placeholder:"repo-0, repo-1, ..."},
|
||||
"languages.colors":{text:"Custom language colors", placeholder:"0:#ff0000, javascript:yellow, ..."},
|
||||
"pagespeed.detailed":{text:"Detailed audit", type:"boolean"},
|
||||
"pagespeed.screenshot":{text:"Audit screenshot", type:"boolean"},
|
||||
"pagespeed.url":{text:"Url", placeholder:"(default to GitHub attached)"},
|
||||
"habits.from":{text:"Events to use", type:"number", min:1, max:1000},
|
||||
"habits.days":{text:"Max events age", type:"number", min:1, max:30},
|
||||
"habits.facts":{text:"Display facts", type:"boolean"},
|
||||
"habits.charts":{text:"Display charts", type:"boolean"},
|
||||
"music.provider":{text:"Provider", placeholder:"spotify"},
|
||||
"music.playlist":{text:"Playlist url", placeholder:"https://embed.music.apple.com/en/playlist/"},
|
||||
"music.limit":{text:"Limit", type:"number", min:1, max:100},
|
||||
"music.user":{text:"Username", placeholder:"(default to GitHub login)"},
|
||||
"posts.limit":{text:"Limit", type:"number", min:1, max:30},
|
||||
"posts.user":{text:"Username", placeholder:"(default to GitHub login)"},
|
||||
"posts.source":{text:"Source", type:"select", values:["dev.to"]},
|
||||
"isocalendar.duration":{text:"Duration", type:"select", values:["half-year", "full-year"]},
|
||||
"projects.limit":{text:"Limit", type:"number", min:0, max:100},
|
||||
"projects.repositories":{text:"Repositories projects", placeholder:"user/repo/projects/1, ..."},
|
||||
"projects.descriptions":{text:"Projects descriptions", type:"boolean"},
|
||||
"topics.mode":{text:"Mode", type:"select", values:["starred", "mastered"]},
|
||||
"topics.sort":{text:"Sort by", type:"select", values:["starred", "activity", "stars", "random"]},
|
||||
"topics.limit":{text:"Limit", type:"number", min:0, max:20},
|
||||
"tweets.limit":{text:"Limit", type:"number", min:1, max:10},
|
||||
"tweets.user":{text:"Username", placeholder:"(default to GitHub attached)"},
|
||||
"stars.limit":{text:"Limit", type:"number", min:1, max:100},
|
||||
"activity.limit":{text:"Limit", type:"number", min:1, max:100},
|
||||
"activity.days":{text:"Max events age", type:"number", min:1, max:9999},
|
||||
"activity.filter":{text:"Events type", placeholder:"all"},
|
||||
"people.size":{text:"Limit", type:"number", min:16, max:64},
|
||||
"people.limit":{text:"Limit", type:"number", min:1, max:9999},
|
||||
"people.types":{text:"Types", placeholder:"followers, following"},
|
||||
"people.thanks":{text:"Special thanks", placeholder:"user1, user2, ..."},
|
||||
"people.identicons":{text:"Use identicons", type:"boolean"},
|
||||
"anilist.medias":{text:"Medias to display", placeholder:"anime, manga"},
|
||||
"anilist.sections":{text:"Sections to display", placeholder:"favorites, watching, reading, characters"},
|
||||
"anilist.limit":{text:"Limit", type:"number", min:0, max:9999},
|
||||
"anilist.shuffle":{text:"Shuffle data", type:"boolean"},
|
||||
"anilist.user":{text:"Username", placeholder:"(default to GitHub login)"},
|
||||
},
|
||||
"languages.ignored":"",
|
||||
"languages.skipped":"",
|
||||
"pagespeed.detailed":false,
|
||||
"pagespeed.screenshot":false,
|
||||
"habits.from":200,
|
||||
"habits.days":14,
|
||||
"habits.facts":true,
|
||||
"habits.charts":false,
|
||||
"music.provider":"",
|
||||
"music.playlist":"",
|
||||
"music.limit":4,
|
||||
"music.user":"",
|
||||
"posts.limit":4,
|
||||
"posts.user":"",
|
||||
"posts.source":"dev.to",
|
||||
"isocalendar.duration":"half-year",
|
||||
"projects.limit":4,
|
||||
"projects.repositories":"",
|
||||
"topics.mode":"starred",
|
||||
"topics.sort":"stars",
|
||||
"topics.limit":12,
|
||||
"tweets.limit":2,
|
||||
"tweets.user":"",
|
||||
"stars.limit":4,
|
||||
"activity.limit":5,
|
||||
"activity.days":14,
|
||||
"activity.filter":"all",
|
||||
"people.size":28,
|
||||
"people.limit":28,
|
||||
"people.types":"followers, following",
|
||||
"people.thanks":"",
|
||||
"people.identicons":false,
|
||||
"anilist.medias":"anime, manga",
|
||||
"anilist.sections":"favorites",
|
||||
"anilist.limit":2,
|
||||
"anilist.shuffle":true,
|
||||
"anilist.user":"",
|
||||
descriptions:{...(Object.assign({}, ...Object.entries(metadata).flatMap(([key, {web}]) => web)))},
|
||||
...(Object.fromEntries(Object.entries(
|
||||
Object.assign({}, ...Object.entries(metadata).flatMap(([key, {web}]) => web)))
|
||||
.map(([key, {defaulted}]) => [key, defaulted])
|
||||
))
|
||||
},
|
||||
},
|
||||
templates:{
|
||||
@@ -226,7 +136,7 @@
|
||||
`# Visit https://github.com/lowlighter/metrics/blob/master/action.yml for full reference`,
|
||||
`name: Metrics`,
|
||||
`on:`,
|
||||
` # Schedule updates`,
|
||||
` # Schedule updates (each hour)`,
|
||||
` schedule: [{cron: "0 * * * *"}]`,
|
||||
` # Lines below let you run workflow manually and on each commit`,
|
||||
` push: {branches: ["master", "main"]}`,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
(function () {
|
||||
//Load asset
|
||||
(function ({axios, faker, ejs} = {axios:globalThis.axios, faker:globalThis.faker, ejs:globalThis.ejs}) {
|
||||
//Load assets
|
||||
const cached = new Map()
|
||||
async function load(url) {
|
||||
if (!cached.has(url))
|
||||
@@ -19,7 +19,7 @@
|
||||
return values.sort((a, b) => b - a)
|
||||
}
|
||||
//Placeholder function
|
||||
window.placeholder = async function (set) {
|
||||
globalThis.placeholder = async function (set) {
|
||||
//Load templates informations
|
||||
let {image, style, fonts, partials} = await load(`/.templates/${set.templates.selected}`)
|
||||
await Promise.all(partials.map(async partial => await load(`/.templates/${set.templates.selected}/partials/${partial}.ejs`)))
|
||||
@@ -78,6 +78,7 @@
|
||||
avatar:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg=="
|
||||
},
|
||||
//User data
|
||||
account:"user",
|
||||
user:{
|
||||
databaseId:faker.random.number(10000000),
|
||||
name:"(placeholder)",
|
||||
@@ -127,10 +128,10 @@
|
||||
id:faker.random.number(100000000000000).toString(),
|
||||
created_at:faker.date.recent(),
|
||||
entities: {
|
||||
mentions: [ { start: 22, end: 33, username: 'lowlighter' } ]
|
||||
mentions: [ {start:22, end:33, username:"lowlighter"} ]
|
||||
},
|
||||
text: 'Checkout metrics from <span class="mention">@lowlighter</span> ! <span class="hashtag">#GitHub</span> ',
|
||||
mentions: [ 'lowlighter' ]
|
||||
mentions: ["lowlighter"]
|
||||
},
|
||||
...new Array(Number(options["tweets.limit"])-1).fill(null).map(_ => ({
|
||||
id:faker.random.number(100000000000000).toString(),
|
||||
@@ -590,4 +591,10 @@
|
||||
//Render
|
||||
return await ejs.render(image, data, {async:true, rmWhitespace:true})
|
||||
}
|
||||
//Reset globals contexts
|
||||
globalThis.placeholder.init = function(globals) {
|
||||
axios = globals.axios || axios
|
||||
faker = globals.faker || faker
|
||||
ejs = globals.ejs || ejs
|
||||
}
|
||||
})()
|
||||
|
||||
Reference in New Issue
Block a user