The great refactor (#82)

This commit is contained in:
Simon Lecoq
2021-01-30 12:31:09 +01:00
committed by GitHub
parent f8c6d19a4e
commit 682e43e10b
158 changed files with 6738 additions and 5022 deletions

View 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) }}

View File

@@ -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)
}

View File

@@ -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["&"] ? "&amp;" : "&")
.replace(/</g, u["<"] ? "&lt;" : "<")
.replace(/>/g, u[">"] ? "&gt;" : ">")
.replace(/"/g, u['"'] ? "&quot;" : '"')
.replace(/'/g, u["'"] ? "&apos;" : "'")
}
/** 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}
}

View 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
}
}

View 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
}
}

View File

@@ -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`)

View 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["&"] ? "&amp;" : "&")
.replace(/</g, u["<"] ? "&lt;" : "<")
.replace(/>/g, u[">"] ? "&gt;" : ">")
.replace(/"/g, u['"'] ? "&quot;" : '"')
.replace(/'/g, u["'"] ? "&apos;" : "'")
}
/** 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}
}

File diff suppressed because it is too large Load Diff

View 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:"",
},
],
},
},
})
}
}
}

View 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()}`,
}
})
}
}
}

View 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",
}
},
],
}
})
}
}
}

View 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",
},
}
})
}
}
}

View 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})
})),
}
]
}
}
}
})
}
}
}

View 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",
}
})
}
}
}

View 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"}
},
]
}
}
})
}

View 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"}
},
}
})
}

View 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)}
}
})
}

View 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)}
}
]
}
}
})
}

View 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
}
}
}
})
}

View 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,
}
}))
}
}
})
}

View 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,
}
}))
}
}
}
})
}

View 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,
}
}
}))
}
}
})
}

View 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
}
}
}
}
})
}

View 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
}
}
]
}
}
})
}

View 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"
}))
}
}
})
}

View 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"
}
}
},
]
}
}
})
}

View 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)}`
},
}
})
) : []
})
}

View 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),
}))
})
}

View 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),
}
]
})
}

View 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}
}
})
}

View 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)
}

View 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,
}
}
]
})
}

View 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),
}
})
}

View 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
View 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}
}

View File

@@ -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
}

View 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 %>"
},
<% } %>"//": ""
}
}

View File

@@ -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"]}`,

View File

@@ -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
}
})()

25
source/plugins/README.md Normal file
View File

@@ -0,0 +1,25 @@
## 🧩 Plugins
Plugins are features which provide additional content and lets you customize your rendered metrics.
See their respective documentation for more informations about how to setup them:
* [🗃️ Base content](/source/plugins/base/README.md)
* [🧱 Core](/source/plugins/core/README.md)
* [📰 Recent activity](/source/plugins/activity/README.md)
* [🌸 Anilist](/source/plugins/anilist/README.md)
* [🎟️ Follow-up of issues and pull requests](/source/plugins/followup/README.md)
* [🎫 Gists](/source/plugins/gists/README.md)
* [💡 Coding habits](/source/plugins/habits/README.md)
* [📅 Isometric commit calendar](/source/plugins/isocalendar/README.md)
* [🈷️ Most used languages](/source/plugins/languages/README.md)
* [👨‍💻 Lines of code changed](/source/plugins/lines/README.md)
* [🎼 Music plugin](/source/plugins/music/README.md)
* [⏱️ Website performances](/source/plugins/pagespeed/README.md)
* [🧑‍🤝‍🧑 People plugin](/source/plugins/people/README.md)
* [✒️ Recent posts](/source/plugins/posts/README.md)
* [🗂️ Projects](/source/plugins/projects/README.md)
* [✨ Stargazers over last weeks](/source/plugins/stargazers/README.md)
* [🌟 Recently starred repositories](/source/plugins/stars/README.md)
* [📌 Starred topics](/source/plugins/topics/README.md)
* [🧮 Repositories traffic](/source/plugins/traffic/README.md)
* [🐤 Latest tweets](/source/plugins/tweets/README.md)

View File

@@ -0,0 +1,44 @@
### 📰 Recent activity
The *activity* plugin displays your recent activity on GitHub.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.activity.svg">
<img width="900" height="1" alt="">
</td>
</table>
It uses data from [GitHub events](https://docs.github.com/en/free-pro-team@latest/developers/webhooks-and-events/github-event-types) and is able to track the following events:
| Event | Description |
| ------------ | ----------------------------------------------- |
| `push` | Push of commits |
| `issue` | Opening/Reopening/Closing of issues |
| `pr` | Opening/Closing of pull requests |
| `ref/create` | Creation of git tags or git branches |
| `ref/delete` | Deletion of git tags or git branches |
| `release` | Publication of new releases |
| `review` | Review of pull requests |
| `comment` | Comments on commits, issues and pull requests |
| `wiki` | Edition of wiki pages |
| `fork` | Forking of repositories |
| `star` | Starring of repositories |
| `public` | Repositories made public |
| `member` | Addition of new collaborator in repository |
Use a full `repo` scope token to display **private** events.
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_activity: yes
plugin_activity_limit: 5 # Limit to 5 events
plugin_activity_days: 14 # Keep only events from last 14 days (can be set to 0 to disable limitations)
plugin_activity_filter: all # Show all events (use table above to filter events types)
```

View File

@@ -1,24 +1,21 @@
//Setup
export default async function ({login, rest, q, account}, {enabled = false} = {}) {
export default async function ({login, data, rest, q, account, imports}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.activity))
return null
//Parameters override
let {"activity.limit":limit = 5, "activity.days":days = 7, "activity.filter":filter = "all"} = q
//Events
limit = Math.max(1, Math.min(100, Number(limit)))
//Days
days = Number(days) > 0 ? Number(days) : Infinity
//Filtered events
filter = decodeURIComponent(filter).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => x)
//Load inputs
let {limit, days, filter} = imports.metadata.plugins.activity.inputs({data, q, account})
if (!days)
days = Infinity
//Get user recent activity
console.debug(`metrics/compute/${login}/plugins > activity > querying api`)
const {data:events} = await rest.activity.listEventsForAuthenticatedUser({username:login, per_page:100})
console.debug(`metrics/compute/${login}/plugins > activity > ${events.length} events loaded`)
//Extract activity events
const activity = events
.filter(({actor}) => account === "organization" ? true : actor.login === login)

View File

@@ -0,0 +1,51 @@
name: "📰 Recent activity"
cost: 1 REST request per 100 events
supports:
- user
- organization
inputs:
# Enable or disable plugin
plugin_activity:
description: Display recent activity
type: boolean
default: no
# Number of activity events to display
plugin_activity_limit:
description: Maximum number of events to display
type: number
default: 5
min: 1
max: 100
# Filter events by age
# Set to 0 to disable age filtering
plugin_activity_days:
description: Maximum event age
type: number
default: 14
min: 0
max: 365
# Filter events by type
plugin_activity_filter:
description: Events types to keep
type: array
format: comma-separated
default: all
values:
- all # Display all types of events
- comment # Display commits, issues and pull requests comments
- ref/create # Display tags and branches creations
- ref/delete # Display tags and branches deletions
- release # Display published releases
- push # Display commits
- issue # Display issues events
- pr # Display pull requests events
- review # Display pull request reviews
- wiki # Display wiki editions
- fork # Display forked repositories
- star # Display starred repositories
- member # Display collaborators additions
- public # Display repositories made public

View File

@@ -0,0 +1,40 @@
### 🌸 Anilist <sup>🚧 pre-release on <code>@master</code></sup>
The *anilist* plugin lets you display your favorites animes, mangas and characters from your [AniList](https://anilist.co) account.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.anilist.svg">
<details><summary>Manga version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.anilist.manga.svg">
</details>
<details open><summary>Favorites characters version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.anilist.characters.svg">
</details>
<img width="900" height="1" alt="">
</td>
</table>
This plugin is composed of the following sections, which can be displayed or hidden through `plugin_anilist_sections` option:
- `favorites` will display your favorites mangas and animes
- `watching` will display animes currently in your watching list
- `reading` will display manga currently in your reading list
- `characters` will display characters you liked
These sections can also be filtered by media type, which can be either `anime`, `manga` or both.
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_anilist: yes
plugin_anilist_medias: anime, manga # Display both animes and mangas
plugin_anilist_sections: favorites, characters # Display only favorites and characters sections
plugin_anilist_limit: 2 # Limit to 2 entry per section (characters section excluded)
plugin_anilist_shuffle: yes # Shuffle data for more varied outputs
plugin_anilist_user: .user.login # Use same username as GitHub login
```

View File

@@ -1,42 +1,33 @@
//Setup
export default async function ({login, imports, q}, {enabled = false} = {}) {
export default async function ({login, data, queries, imports, q, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.anilist))
return null
//Parameters override
let {"anilist.medias":medias = ["anime", "manga"], "anilist.sections":sections = ["favorites"], "anilist.limit":limit = 2, "anilist.shuffle":shuffle = true, "anilist.user":user = login} = q
//Medias types
medias = decodeURIComponent(medias).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => ["anime", "manga"].includes(x))
//Sections
sections = decodeURIComponent(sections).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => ["favorites", "watching", "reading", "characters"].includes(x))
//Limit medias
limit = Math.max(0, Number(limit))
//GraphQL queries
const query = {
statistics:`${await imports.fs.readFile(`${imports.__module(import.meta.url)}/queries/statistics.graphql`)}`,
characters:`${await imports.fs.readFile(`${imports.__module(import.meta.url)}/queries/characters.graphql`)}`,
medias:`${await imports.fs.readFile(`${imports.__module(import.meta.url)}/queries/medias.graphql`)}`,
favorites:`${await imports.fs.readFile(`${imports.__module(import.meta.url)}/queries/favorites.graphql`)}`,
}
//Load inputs
let {limit, medias, sections, shuffle, user} = imports.metadata.plugins.anilist.inputs({data, account, q})
//Initialization
const result = {user:{stats:null, genres:[]}, lists:Object.fromEntries(medias.map(type => [type, {}])), characters:[], sections}
//User statistics
{
//Query API
console.debug(`metrics/compute/${login}/plugins > anilist > querying api (user statistics)`)
const {data:{data:{User:{statistics:stats}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user}, query:query.statistics})
const {data:{data:{User:{statistics:stats}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user}, query:queries.anilist.statistics()})
//Format and save results
result.user.stats = stats
result.user.genres = [...new Set([...stats.anime.genres.map(({genre}) => genre), ...stats.manga.genres.map(({genre}) => genre)])]
}
//Medias lists
if ((sections.includes("watching"))||(sections.includes("reading"))) {
for (const type of medias) {
//Query API
console.debug(`metrics/compute/${login}/plugins > anilist > querying api (medias lists - ${type})`)
const {data:{data:{MediaListCollection:{lists}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user, type:type.toLocaleUpperCase()}, query:query.medias})
const {data:{data:{MediaListCollection:{lists}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user, type:type.toLocaleUpperCase()}, query:queries.anilist.medias()})
//Format and save results
for (const {name, entries} of lists) {
//Format results
@@ -50,6 +41,7 @@
}
}
}
//Favorites anime/manga
if (sections.includes("favorites")) {
for (const type of medias) {
@@ -60,7 +52,7 @@
let next = false
do {
console.debug(`metrics/compute/${login}/plugins > anilist > querying api (favorites ${type}s - page ${page})`)
const {data:{data:{User:{favourites:{[type]:{nodes, pageInfo:cursor}}}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user, page}, query:query.favorites.replace(/[$]type/g, type)})
const {data:{data:{User:{favourites:{[type]:{nodes, pageInfo:cursor}}}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user, page}, query:queries.anilist.favorites({type})})
page = cursor.currentPage
next = cursor.hasNextPage
list.push(...await Promise.all(nodes.map(media => format({media:{progess:null, score:null, media}, imports}))))
@@ -74,6 +66,7 @@
}
}
}
//Favorites characters
if (sections.includes("characters")) {
//Query API
@@ -83,7 +76,7 @@
let next = false
do {
console.debug(`metrics/compute/${login}/plugins > anilist > querying api (favorites characters - page ${page})`)
const {data:{data:{User:{favourites:{characters:{nodes, pageInfo:cursor}}}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user, page}, query:query.characters})
const {data:{data:{User:{favourites:{characters:{nodes, pageInfo:cursor}}}}}} = await imports.axios.post("https://graphql.anilist.co", {variables:{name:user, page}, query:queries.anilist.characters()})
page = cursor.currentPage
next = cursor.hasNextPage
for (const {name:{full:name}, image:{medium:artwork}} of nodes)
@@ -92,6 +85,7 @@
//Format and save results
result.characters = shuffle ? imports.shuffle(characters) : characters
}
//Results
return result
}

View File

@@ -0,0 +1,55 @@
name: "🌸 Anilist"
cost: N/A
supports:
- user
- organization
inputs:
# Enable or disable plugin
plugin_anilist:
description: Display data from your AniList account
type: boolean
default: no
# Types of medias to display
plugin_anilist_medias:
description: Medias types to display
type: array
format: comma-separated
default: anime, manga
values:
- anime
- manga
# Sections to display
# Values from "plugin_anilist_medias" may impact displayed sections
plugin_anilist_sections:
description: Sections to display
type: array
format: comma-separated
default: favorites
values:
- favorites # Favorites animes and mangas (depending on plugin_anilist_medias values)
- watching # Animes in your watching list
- reading # Mangas in your reading list
- characters # Favorites characters
# Number of entries to display per section (this does not impacts characters section)
# Set to 0 to disable limitations
plugin_anilist_limit:
description: Maximum number of entries to display per section
type: number
default: 2
min: 0
# Shuffle AniList data for varied outputs
plugin_anilist_shuffle:
description: Shuffle AniList data
type: boolean
default: yes
# Username on AniList
plugin_anilist_user:
type: string
description: AniList login
default: .user.login

View File

@@ -0,0 +1,38 @@
### 🗃️ Base content
The *base* content is all metrics enabled by default.
<table>
<tr>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.classic.svg">
<img width="900" height="1" alt="">
</td>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.organization.svg">
<img width="900" height="1" alt="">
</td>
</tr>
</table>
It contains the following sections:
* `header`, which usually contains your username, your two-week commits calendars and a few additional data
* `activity`, which contains your recent activity (commits, pull requests, issues, etc.)
* `community`, which contains your community stats (following, sponsors, organizations, etc.)
* `repositories`, which contains your repositories stats (license, forks, stars, etc.)
* `metadata`, which contains informations about generated metrics
These are all enabled by default, but you can explicitely opt out from them.
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
base: header, repositories # Only display "header" and "repositories" sections
repositories: 100 # Query only last 100 repositories
repositories_forks: no # Don't include forks
```

View File

@@ -0,0 +1,89 @@
/**
* Base plugin is a special plugin because of historical reasons.
* It populates initial data object directly instead of returning a result like others plugins
*/
//Setup
export default async function ({login, graphql, data, q, queries, imports}, conf) {
//Load inputs
console.debug(`metrics/compute/${login}/base > started`)
let {repositories, repositories_forks:forks} = imports.metadata.plugins.base.inputs({data, q, account:"bypass"}, {repositories:conf.settings.repositories ?? 100})
//Base parts (legacy handling for web instance)
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
//Iterate through account types
for (const account of ["user", "organization"]) {
try {
//Query data from GitHub API
console.debug(`metrics/compute/${login}/base > account ${account}`)
const queried = await graphql(queries.base[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]})
postprocess?.[account]({login, data})
//Query repositories from GitHub API
{
//Iterate through repositories
let cursor = null
let pushed = 0
do {
console.debug(`metrics/compute/${login}/base > retrieving repositories after ${cursor}`)
const {[account]:{repositories:{edges, nodes}}} = await graphql(queries.base.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}/base > keeping only ${repositories} repositories`)
data.user.repositories.nodes.splice(repositories)
console.debug(`metrics/compute/${login}/base > loaded ${data.user.repositories.nodes.length} repositories`)
}
//Success
console.debug(`metrics/compute/${login}/base > graphql query > account ${account} > success`)
return {}
} catch (error) {
console.debug(`metrics/compute/${login}/base > account ${account} > failed : ${error}`)
console.debug(`metrics/compute/${login}/base > checking next account`)
}
}
//Not found
console.debug(`metrics/compute/${login}/base > no more account type`)
throw new Error("user not found")
}
//Query post-processing
const postprocess = {
//User
user({login, data}) {
console.debug(`metrics/compute/${login}/base > applying postprocessing`)
data.account = "user"
Object.assign(data.user, {
isVerified:false,
})
},
//Organization
organization({login, data}) {
console.debug(`metrics/compute/${login}/base > applying postprocessing`)
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},
})
}
}

View File

@@ -0,0 +1,34 @@
name: "🗃️ Base content"
cost: 1 GraphQL request
supports:
- user
- organization
- repository
inputs:
# Base content
base:
description: Metrics base content
type: array
format: comma-separated
default: header, activity, community, repositories, metadata
values:
- header # name, commits calendar, ...
- activity # commits, issues/pull requests opened, ...
- community # following, stars, sponsors, ...
- repositories # license, stars, forks, ...
- metadata # svg generation metadata
# Number of repositories to use to computes metrics
# Using more will result in more accurate metrics, but you may hit GitHub rate-limit more easily if you use a lot of plugins
repositories:
description: Number of repositories to use
type: number
default: 100
min: 0
# Include forked repositories into metrics
repositories_forks:
description: Include forks in metrics
type: boolean
default: no

View File

@@ -1,4 +1,4 @@
query MetricsOrganization {
query BaseOrganization {
organization(login: "$login") {
databaseId
name

View File

@@ -1,4 +1,4 @@
query Repositories {
query BaseRepositories {
$account(login: "$login") {
repositories($after first: $repositories $forks, orderBy: {field: UPDATED_AT, direction: DESC}) {
edges {

View File

@@ -1,4 +1,4 @@
query Repository {
query BaseRepository {
$account(login: "$login") {
repository(name: "$repo") {
name

View File

@@ -1,4 +1,4 @@
query Metrics {
query BaseUser {
user(login: "$login") {
databaseId
name

View File

@@ -0,0 +1,87 @@
### 🧱 Core
Metrics also have general options that impact global metrics rendering.
[➡️ Available options](metadata.yml)
### 🌐 Set timezone
By default, dates are based on Greenwich meridian (GMT/UTC).
Set your timezone (see [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for a list of supported timezones) using `config_timezone` option.
#### Examples workflows
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
config_timezone: Europe/Paris
```
### 📦 Ordering content
You can order metrics content by using `config_order` option.
It is not mandatory to specify all partials of used templates.
Omitted one will be appended using default order.
#### Examples workflows
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
base: header
plugin_isocalendar: yes
plugin_languages: yes
plugin_stars: yes
config_order: base.header, isocalendar, languages, stars
```
### 🎞️ SVG CSS Animations
As rendered metrics use HTML and CSS, some templates have animations.
You can choose to disable them by using `config_animations` option.
#### Examples workflows
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
committer_branch: my-branch
```
### 🔲 Adjust padding
Height of rendered metrics is computed after being rendered through an headless browser.
As it can depend on fonts and operating system, it is possible that final result is cropped or has blank space at the bottom.
You can adjust padding by using `config_padding` option.
Specify a single value to apply it to both height and with, and two values to use the first one for width and the second for height. Both positive and negative values are accepted, but you must specify a percentage.
#### Examples workflows
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
config_padding: 6%, 10% # 6% width padding, 10% height padding
```
### 💱 Convert output to PNG/JPEG
It is possible to convert output from SVG to PNG or JPEG images by using `config_output` option.
Note that `png` does not support animations while `jpeg` does not support both animations and transparency.
#### Examples workflows
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
config_output: png
```

View File

@@ -1,5 +1,12 @@
/** Template common processor */
export default async function ({login, q, dflags}, {conf, data, rest, graphql, plugins, queries, account}, {s, pending, imports}) {
/**
* Core plugin is a special plugin because of historical reasons.
* It is used by templates to setup global configuration.
*/
//Setup
export default async function ({login, q, dflags}, {conf, data, rest, graphql, plugins, queries, account}, {pending, imports}) {
//Load inputs
imports.metadata.plugins.core.inputs({data, account, q})
//Init
const computed = data.computed = {commits:0, sponsorships:0, licenses:{favorite:"", used:{}}, token:{}, repositories:{watchers:0, stargazers:0, issues_open:0, issues_closed:0, pr_open:0, pr_merged:0, forks:0, forked:0, releases:0}}
@@ -28,7 +35,6 @@
for (const name of Object.keys(imports.plugins)) {
if (!plugins[name]?.enabled)
continue
pending.push((async () => {
try {
console.debug(`metrics/compute/${login}/plugins > ${name} > started`)
@@ -74,7 +80,7 @@
const diff = (Date.now()-(new Date(data.user.createdAt)).getTime())/(365*24*60*60*1000)
const years = Math.floor(diff)
const months = Math.floor((diff-years)*12)
computed.registration = years ? `${years} year${s(years)} ago` : months ? `${months} month${s(months)} ago` : `${Math.ceil(diff*365)} day${s(Math.ceil(diff*365))} ago`
computed.registration = years ? `${years} year${imports.s(years)} ago` : months ? `${months} month${imports.s(months)} ago` : `${Math.ceil(diff*365)} day${imports.s(Math.ceil(diff*365))} ago`
computed.cakeday = years > 1 ? [new Date(), new Date(data.user.createdAt)].map(date => date.toISOString().match(/(?<mmdd>\d{2}-\d{2})(?=T)/)?.groups?.mmdd).every((v, _, a) => v === a[0]) : false
//Compute calendar
@@ -118,4 +124,7 @@
return {name:"dflag.halloween", result:true}
})())
}
//Results
return null
}

View File

@@ -0,0 +1,164 @@
name: "🧱 Core"
cost: N/A
supports:
- user
- organization
- repository
inputs:
# User account personal token
# No additional scopes are needed unless you want to include private repositories metrics
# Some plugins may also require additional scopes
token:
description: GitHub Personal Token
type: token
required: true
# GitHub username
user:
description: GitHub username
type: string
default: "" # Defaults to "token" owner
# Set to "${{ secrets.GITHUB_TOKEN }}"
committer_token:
description: GitHub Token used to commit metrics
type: token
default: "" # Defaults to "token"
# Branch used to commit rendered metrics
committer_branch:
description: Branch used to commit rendered metrics
type: string
default: "" # Defaults to your repository default branch
# Rendered metrics output path, relative to repository's root
filename:
description: Rendered metrics output path
type: string
default: github-metrics.svg
# Optimize SVG image to reduce its filesize
# Some templates may not support this option
optimize:
description: SVG optimization
type: boolean
default: yes
# Setup additional templates from remote repositories
setup_community_templates:
description: Additional community templates to setup
type: array
format:
- comma-separated
- /(?<user>[-a-z0-9]+)[/](?<repo>[-a-z0-9]+)@(?<branch>[-a-z0-9]+):(?<template>[-a-z0-9]+)/
default: ""
# Template to use
# To use community template, prefix its name with "@"
template:
description: Template to use
type: string
default: classic
# Additional query parameters (JSON string)
# Some templates may require additional parameters which you can specify here
# Do not use this option to pass plugins parameters as they'll be overwritten by the other options
query:
description: Additional query parameters
type: json
default: "{}"
# Timezone used by metrics
# See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
config_timezone:
description: Timezone used
type: string
default: ""
# Specify in which order metrics content will be displayed
# If you omit some partials, they'll be appended at the end in default order
# See "partials/_.json" of each template for a list of supported partials
config_order:
description: Configure content order
type: array
format: comma-separated
default: ""
# Enable SVG CSS animations
config_animations:
description: SVG CSS animations
type: boolean
default: yes
# Configure padding for output image (percentage value)
# It can be used to add padding to generated metrics if rendering is cropped or has too much empty space
# Specify one value (for both width and height) or two values (one for width and one for height)
config_padding:
description: Image padding
type: array
format: comma-separated
default: 6%
# Metrics output format
config_output:
description: Output image format
type: string
default: svg
values:
- svg
- png # Does not support animations
- jpeg # Does not support animations and transparency
# ====================================================================================
# Options below are mostly used for testing
# Throw on plugins errors
# If disabled, metrics will handle errors gracefully with a message in rendered metrics
plugins_errors_fatal:
description: Die on plugins errors
type: boolean
default: no
# Debug mode
# Note that this will automatically be enabled if job fails
debug:
description: Debug logs
type: boolean
default: no
# Ensure SVG can be correctly parsed after generation
verify:
description: Verify SVG
type: boolean
default: no
# Debug flags
debug_flags:
description: Debug flags
type: array
format: space-separated
default: ""
values:
- --cakeday
- --hireable
- --halloween
# Dry-run mode (perform generation without pushing it)
dryrun:
description: Enable dry-run
type: boolean
default: no
# Use mocked data to bypass external APIs
use_mocked_data:
description: Use mocked data instead of live APIs
type: boolean
default: no
# Use a pre-built image from GitHub registry (experimental)
# See https://github.com/users/lowlighter/packages/container/package/metrics for more information
use_prebuilt_image:
description: Use pre-built image from GitHub registry
type: string
default: ""

View File

@@ -0,0 +1,22 @@
### 🎟️ Follow-up of issues and pull requests
The *followup* plugin displays the ratio of open/closed issues and the ratio of open/merged pull requests across all your repositories, which shows if they're well-maintained or not.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.followup.svg">
<img width="900" height="1" alt="">
</td>
</table>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_followup: yes
```

View File

@@ -1,10 +1,14 @@
//Setup
export default async function ({computed, q}, {enabled = false} = {}) {
export default async function ({data, computed, imports, q, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.followup))
return null
//Load inputs
imports.metadata.plugins.followup.inputs({data, account, q})
//Define getters
const followup = {
issues:{
@@ -18,6 +22,7 @@
get merged() { return computed.repositories.pr_merged }
}
}
//Results
return followup
}

View File

@@ -0,0 +1,13 @@
name: "🎟️ Follow-up of issues and pull requests"
cost: 0 API request
supports:
- user
- organization
- repository
inputs:
# Enable or disable plugin
plugin_followup:
description: Display follow-up of repositories issues and pull requests
type: boolean
default: no

View File

@@ -0,0 +1,21 @@
### 🎫 Gists
The *gists* plugin displays your [gists](https://gist.github.com) metrics.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.gists.svg">
<img width="900" height="1" alt="">
</td>
</table>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_gists: yes
```

View File

@@ -1,12 +1,14 @@
//Setup
export default async function ({login, graphql, q, queries, account}, {enabled = false} = {}) {
export default async function ({login, data, graphql, q, imports, queries, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.gists))
return null
if (account === "organization")
throw {error:{message:"Not available for organizations"}}
//Load inputs
imports.metadata.plugins.gists.inputs({data, account, q})
//Query gists from GitHub API
const gists = []
{
@@ -23,6 +25,7 @@
} while ((pushed)&&(cursor))
console.debug(`metrics/compute/${login}/plugins > gists > loaded ${gists.length} gists`)
}
//Iterate through gists
console.debug(`metrics/compute/${login}/plugins > gists > processing ${gists.length} gists`)
let stargazers = 0, forks = 0, comments = 0, files = 0
@@ -36,6 +39,7 @@
comments += gist.comments.totalCount
files += gist.files.length
}
//Results
return {totalCount:gists.totalCount, stargazers, forks, files, comments}
}

View File

@@ -0,0 +1,11 @@
name: "🎫 Gists"
cost: 1 GraphQL request per 100 gists
supports:
- user
inputs:
# Enable or disable plugin
plugin_gists:
description: Display gists metrics
type: boolean
default: no

View File

@@ -1,4 +1,4 @@
query Gists {
query GistsDefault {
user(login: "$login") {
gists($after first: 100, orderBy: {field: UPDATED_AT, direction: DESC}) {
edges {

View File

@@ -0,0 +1,38 @@
### 💡 Coding habits
The coding *habits* plugin display metrics based on your recent activity, such as active hours or languages recently used.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.habits.facts.svg">
<details open><summary>Charts version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.habits.charts.svg">
</details>
<img width="900" height="1" alt="">
</td>
</table>
Using more events will improve accuracy of these metrics, although it'll increase the number of GitHub requests used.
Active hours and days are computed through your commit history, while indent style is deduced from your recent diffs.
Recent languages activity is also computed from your recent diffs, using [github/linguist](https://github.com/github/linguist).
Use a full `repo` scope token to access **private** events.
By default, dates use Greenwich meridian (GMT/UTC). Be sure to set your timezone (see [here](https://en.wikipedia.org/wiki/List_of_tz_database_time_zones) for a list of supported timezones) for accurate metrics.
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_habits: yes
plugin_habits_from: 200 # Use 200 events to compute habits
plugin_habits_days: 14 # Keep only events from last 14 days
plugin_habits_facts: yes # Display facts section
plugin_habits_charts: yes # Display charts section
config_timezone: Europe/Paris # Set timezone
```

View File

@@ -1,20 +1,19 @@
//Setup
export default async function ({login, rest, imports, data, q, account}, {enabled = false, from:defaults = 100} = {}) {
export default async function ({login, data, rest, imports, q, account}, {enabled = false, ...defaults} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.habits))
return null
//Parameters override
let {"habits.from":from = defaults.from ?? 500, "habits.days":days = 14, "habits.facts":facts = true, "habits.charts":charts = false} = q
//Events
from = Math.max(1, Math.min(1000, Number(from)))
//Days
days = Math.max(1, Math.min(30, Number(days)))
//Load inputs
let {from, days, facts, charts} = imports.metadata.plugins.habits.inputs({data, account, q}, defaults)
//Initialization
const habits = {facts, charts, commits:{hour:NaN, hours:{}, day:NaN, days:{}}, indents:{style:"", spaces:0, tabs:0}, linguist:{available:false, ordered:[], languages:{}}}
const pages = Math.ceil(from/100)
const offset = data.config.timezone?.offset ?? 0
//Get user recent activity
console.debug(`metrics/compute/${login}/plugins > habits > querying api`)
const events = []
@@ -25,12 +24,14 @@
}
} catch { console.debug(`metrics/compute/${login}/plugins > habits > no more page to load`) }
console.debug(`metrics/compute/${login}/plugins > habits > ${events.length} events loaded`)
//Get user recent commits
const commits = events
.filter(({type}) => type === "PushEvent")
.filter(({actor}) => account === "organization" ? true : actor.login === login)
.filter(({created_at}) => new Date(created_at) > new Date(Date.now()-days*24*60*60*1000))
console.debug(`metrics/compute/${login}/plugins > habits > filtered out ${commits.length} push events over last ${days} days`)
//Retrieve edited files and filter edited lines (those starting with +/-) from patches
console.debug(`metrics/compute/${login}/plugins > habits > loading patches`)
const patches = [...await Promise.allSettled(commits
@@ -41,6 +42,7 @@
.map(({value}) => value)
.flatMap(files => files.map(file => ({name:imports.paths.basename(file.filename), patch:file.patch ?? ""})))
.map(({name, patch}) => ({name, patch:patch.split("\n").filter(line => /^[-+]/.test(line)).map(line => line.substring(1)).join("\n")}))
//Commit day
{
//Compute commit days
@@ -52,6 +54,7 @@
//Compute day with most commits
habits.commits.day = days.length ? ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][Object.entries(habits.commits.days).sort(([an, a], [bn, b]) => b - a).map(([day, occurence]) => day)[0]] ?? NaN : NaN
}
//Commit hour
{
//Compute commit hours
@@ -63,6 +66,7 @@
//Compute hour with most commits
habits.commits.hour = hours.length ? `${Object.entries(habits.commits.hours).sort(([an, a], [bn, b]) => b - a).map(([hour, occurence]) => hour)[0]}`.padStart(2, "0") : NaN
}
//Indent style
{
//Attempt to guess whether tabs or spaces are used in patches
@@ -72,6 +76,7 @@
.forEach(indent => habits.indents[/^\t/.test(indent) ? "tabs" : "spaces"]++)
habits.indents.style = habits.indents.spaces > habits.indents.tabs ? "spaces" : habits.indents.tabs > habits.indents.spaces ? "tabs" : ""
}
//Linguist
if (charts) {
//Check if linguist exists
@@ -87,7 +92,7 @@
await Promise.all(patches.map(async ({name, patch}, i) => await imports.fs.writeFile(imports.paths.join(path, `${i}${imports.paths.extname(name)}`), patch)))
//Create temporary git repository
console.debug(`metrics/compute/${login}/plugins > habits > creating temp git repository`)
await imports.run(`git init && git add . && git config user.name "linguist" && git config user.email "null@github.com" && git commit -m "linguist"`, {cwd:path}).catch(console.debug)
await imports.run(`git init && git add . && git config user.name "linguist" && git config user.email "<>" && git commit -m "linguist"`, {cwd:path}).catch(console.debug)
await imports.run(`git status`, {cwd:path})
//Spawn linguist process
console.debug(`metrics/compute/${login}/plugins > habits > running linguist`)
@@ -100,6 +105,7 @@
else
console.debug(`metrics/compute/${login}/plugins > habits > linguist not available`)
}
//Results
return habits
}

View File

@@ -0,0 +1,43 @@
name: "💡 Coding habits"
cost: 1 REST request per 100 events + 1 REST request pet commit
supports:
- user
- organization
inputs:
# Enable or disable plugin
plugin_habits:
description: Display coding habits metrics
type: boolean
default: no
# Number of events to use to computes habits
# Using more will result in more accurate metrics, but you may hit GitHub rate-limit more easily
plugin_habits_from:
description: Number of events to use
type: number
default: 200
min: 1
max: 1000
# Filter used events to compute habits by age
plugin_habits_days:
description: Maximum event age
type: number
default: 14
min: 1
max: 30
# Display tidbits about your most active hours/days, indents used (spaces/tabs), etc.
# This is deduced from your recent activity
plugin_habits_facts:
description: Display coding habits collected facts based on recent activity
type: boolean
default: yes
# Display charts of most active time of the day and most active day of the week
# Also display languages recently used (this is not the same as plugin_languages, as the latter is an all-time stats)
plugin_habits_charts:
description: Display coding habits charts based on recent activity
type: boolean
default: no

View File

@@ -0,0 +1,25 @@
### 📅 Isometric commit calendar
The *isocalendar* plugin displays an isometric view of your commits calendar, along with a few additional stats like current streak and commit average per day.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.isocalendar.svg">
<details><summary>Full year version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.isocalendar.fullyear.svg">
</details>
<img width="900" height="1" alt="">
</td>
</table>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_isocalendar: yes
plugin_isocalendar_duration: full-year # Display full year instead of half year
```

View File

@@ -1,16 +1,14 @@
//Setup
export default async function ({login, graphql, q, queries, account}, {enabled = false} = {}) {
export default async function ({login, data, graphql, q, imports, queries, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.isocalendar))
return null
if (account === "organization")
throw {error:{message:"Not available for organizations"}}
//Parameters override
let {"isocalendar.duration":duration = "half-year"} = q
//Duration in days
duration = ["full-year", "half-year"].includes(duration) ? duration : "full-year"
//Load inputs
let {duration} = imports.metadata.plugins.isocalendar.inputs({data, account, q})
//Compute start day
const now = new Date()
const start = new Date(now)
@@ -18,23 +16,27 @@
start.setFullYear(now.getFullYear()-1)
else
start.setHours(-24*180)
//Compute padding to ensure last row is complete
const padding = new Date(start)
padding.setHours(-14*24)
//Retrieve contribution calendar from graphql api
console.debug(`metrics/compute/${login}/plugins > isocalendar > querying api`)
const calendar = {}
for (const [name, from, to] of [["padding", padding, start], ["weeks", start, now]]) {
console.debug(`metrics/compute/${login}/plugins > isocalendar > loading ${name} from "${from.toISOString()}" to "${to.toISOString()}"`)
const {user:{calendar:{contributionCalendar:{weeks}}}} = await graphql(queries.calendar({login, from:from.toISOString(), to:to.toISOString()}))
const {user:{calendar:{contributionCalendar:{weeks}}}} = await graphql(queries.isocalendar.calendar({login, from:from.toISOString(), to:to.toISOString()}))
calendar[name] = weeks
}
//Apply padding
console.debug(`metrics/compute/${login}/plugins > isocalendar > applying padding`)
const firstweek = calendar.weeks[0].contributionDays
const padded = calendar.padding.flatMap(({contributionDays}) => contributionDays).filter(({date}) => !firstweek.map(({date}) => date).includes(date))
while (firstweek.length < 7)
firstweek.unshift(padded.pop())
//Compute the highest contributions in a day, streaks and average commits per day
console.debug(`metrics/compute/${login}/plugins > isocalendar > computing stats`)
let max = 0, streak = {max:0, current:0}, values = [], average = 0
@@ -47,6 +49,7 @@
}
}
average = (values.reduce((a, b) => a + b, 0)/values.length).toFixed(2).replace(/[.]0+$/, "")
//Compute SVG
console.debug(`metrics/compute/${login}/plugins > isocalendar > computing svg render`)
const size = 6
@@ -80,6 +83,7 @@
i++
}
svg += `</g></svg>`
//Results
return {streak, max, average, svg, duration}
}

View File

@@ -0,0 +1,20 @@
name: "📅 Isometric commit calendar"
cost: 2-3 REST requests
supports:
- user
inputs:
# Enable or disable plugin
plugin_isocalendar:
description: Display an isometric view of your commits calendar
type: boolean
default: no
# Set time window shown by isometric calendar
plugin_isocalendar_duration:
description: Set time window shown by isometric calendar
type: string
default: half-year
values:
- half-year
- full-year

View File

@@ -1,4 +1,4 @@
query Calendar {
query IsocalendarCalendar {
user(login: "$login") {
calendar:contributionsCollection(from: "$from", to: "$to") {
contributionCalendar {

View File

@@ -0,0 +1,29 @@
### 🈷️ Most used languages <sup>🚧 <code>plugin_languages_colors</code> on <code>@master</code></sup>
The *languages* plugin displays which programming languages you use the most across all your repositories.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.languages.svg">
<img width="900" height="1" alt="">
</td>
</table>
It is possible to use custom colors for languages instead of those provided by GitHub by using `plugin_languages_colors` option.
You can specify either an index with a color, or a language name (case insensitive) with a color.
Colors can be either in hexadecimal format or a [named color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
It is also possible to use a predefined set of colors from [colorsets.json](colorsets.json)
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_languages: yes
plugin_languages_ignored: html, css # List of languages to ignore
plugin_languages_skipped: my-test-repo # List of repositories to skip
plugin_languages_colors: "0:orange, javascript:#ff0000, ..." # Make most used languages orange and JavaScript red
```

View File

@@ -1,22 +1,21 @@
//Setup
export default async function ({login, data, imports, q}, {enabled = false} = {}) {
export default async function ({login, data, imports, q, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.languages))
return null
//Parameters override
let {"languages.ignored":ignored = "", "languages.skipped":skipped = "", "languages.colors":colors = ""} = q
//Ignored languages
ignored = decodeURIComponent(ignored).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => x)
//Skipped repositories
skipped = decodeURIComponent(skipped).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => x)
//Custom colors
const colorsets = JSON.parse(`${await imports.fs.readFile(`${imports.__module(import.meta.url)}/colorsets.json`)}`)
if (`${colors}` in colorsets)
colors = colorsets[`${colors}`]
colors = Object.fromEntries(decodeURIComponent(colors).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => x).map(x => x.split(":").map(x => x.trim())))
console.debug(`metrics/compute/${login}/plugins > languages > custom colors ${JSON.stringify(colors)}`)
//Load inputs
let {ignored, skipped, colors} = imports.metadata.plugins.languages.inputs({data, account, q})
//Custom colors
const colorsets = JSON.parse(`${await imports.fs.readFile(`${imports.__module(import.meta.url)}/colorsets.json`)}`)
if (`${colors}` in colorsets)
colors = colorsets[`${colors}`]
colors = Object.fromEntries(decodeURIComponent(colors).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => x).map(x => x.split(":").map(x => x.trim())))
console.debug(`metrics/compute/${login}/plugins > languages > custom colors ${JSON.stringify(colors)}`)
//Iterate through user's repositories and retrieve languages data
console.debug(`metrics/compute/${login}/plugins > languages > processing ${data.user.repositories.nodes.length} repositories`)
const languages = {colors:{}, total:0, stats:{}}
@@ -39,6 +38,7 @@
languages.total += size
}
}
//Compute languages stats
console.debug(`metrics/compute/${login}/plugins > languages > computing stats`)
Object.keys(languages.stats).map(name => languages.stats[name] /= languages.total)
@@ -48,6 +48,7 @@
if ((colors[i])&&(!colors[languages.favorites[i].name.toLocaleLowerCase()]))
languages.favorites[i].color = colors[i]
}
//Results
return languages
}

View File

@@ -0,0 +1,40 @@
name: "🈷️ Most used languages"
cost: 0 API request
supports:
- user
- organization
- repository
inputs:
# Enable or disable plugin
plugin_languages:
description: Display most used languages metrics
type: boolean
default: no
# List of languages that will be ignored
plugin_languages_ignored:
description: Languages to ignore
type: array
format: comma-separated
default: ""
# List of repositories that will be skipped
plugin_languages_skipped:
description: Repositories to skip
type: array
format: comma-separated
default: ""
# Overrides
# Use `${n}:${color}` to change the color of the n-th most used language (e.g. "0:red" to make your most used language red)
# Use `${language}:${color}` to change the color of named language (e.g. "javascript:red" to make JavaScript language red, language case is ignored)
# Use a value from `colorsets.json` to use a predefined set of colors
# Both hexadecimal and named colors are supported
plugin_languages_colors:
description: Custom languages colors
type: array
format:
- comma-separated
- /((?<index>[0-9])|(?<language>[-+a-z0-9#])):(?<color>#?[-a-z0-9]+)/
default: github

View File

@@ -0,0 +1,21 @@
### 👨‍💻 Lines of code changed
The *lines* of code plugin displays the number of lines of code you have added and removed across all of your repositories.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.lines.svg">
<img width="900" height="1" alt="">
</td>
</table>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_lines: yes
```

View File

@@ -1,11 +1,14 @@
//Setup
export default async function ({login, data, rest, q}, {enabled = false} = {}) {
export default async function ({login, data, imports, rest, q, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.lines))
return null
//Load inputs
imports.metadata.plugins.lines.inputs({data, account, q})
//Context
let context = {mode:"user"}
if (q.repo) {
@@ -15,10 +18,12 @@
//Repositories
const repositories = data.user.repositories.nodes.map(({name:repo, owner:{login:owner}}) => ({repo, owner})) ?? []
//Get contributors stats from repositories
console.debug(`metrics/compute/${login}/plugins > lines > querying api`)
const lines = {added:0, deleted:0}
const response = await Promise.all(repositories.map(async ({repo, owner}) => await rest.repos.getContributorsStats({owner, repo})))
//Compute changed lines
console.debug(`metrics/compute/${login}/plugins > lines > computing total diff`)
response.map(({data:repository}) => {
@@ -31,6 +36,7 @@
if (contributor)
contributor.weeks.forEach(({a, d}) => (lines.added += a, lines.deleted += d))
})
//Results
return lines
}

View File

@@ -0,0 +1,13 @@
name: "👨‍💻 Lines of code changed"
cost: 1 REST request per repository
supports:
- user
- organization
- repository
inputs:
# Enable or disable plugin
plugin_lines:
description: Display lines of code metrics
type: boolean
default: no

View File

@@ -0,0 +1,197 @@
### 🎼 Music plugin <sup>🚧 <code>lastfm</code> on <code>@master</code></sup>
The *music* plugin lets you display :
<table>
<td align="center">
<details open><summary>🎼 Favorite tracks version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.music.playlist.svg">
</details>
<details open><summary>Recently listened version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.music.recent.svg">
</details>
<img width="900" height="1" alt="">
</td>
</table>
It can work in the following modes:
### Playlist mode
Select randomly a few tracks from a given playlist to share your favorites tracks with your visitors.
Select a music provider below for instructions.
<details>
<summary>Apple Music</summary>
Extract the *embed* URL of the playlist you want to share.
To do so, connect to [music.apple.com](https://music.apple.com/) and select the playlist you want to share.
From `...` menu, select `Share` and `Copy embed code`.
![Copy embed code of playlist](/.github/readme/imgs/plugin_music_playlist_apple.png)
Extract the source link from the code pasted in your clipboard:
```html
<iframe allow="" frameborder="" height="" style="" sandbox="" src="https://embed.music.apple.com/**/playlist/********"></iframe>
```
And use this value in `plugin_music_playlist` option.
</details>
<details>
<summary>Spotify</summary>
Extract the *embed* URL of the playlist you want to share.
To do so, Open Spotify and select the playlist you want to share.
From `...` menu, select `Share` and `Copy embed code`.
![Copy embed code of playlist](/.github/readme/imgs/plugin_music_playlist_spotify.png)
Extract the source link from the code pasted in your clipboard:
```html
<iframe src="https://open.spotify.com/embed/playlist/********" width="" height="" frameborder="0" allowtransparency="" allow=""></iframe>
```
And use this value in `plugin_music_playlist` option.
</details>
<details>
<summary>Last.fm</summary>
This mode is not supported for now.
</details>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_music: yes
plugin_music_limit: 4 # Limit to 4 entries
plugin_music_playlist: https://******** # Use extracted playlist link
# (plugin_music_provider and plugin_music_mode will be set automatically)
```
### Recently played mode
Display tracks you have played recently.
Select a music provider below for additional instructions.
<details>
<summary>Apple Music</summary>
This mode is not supported for now.
I tried to find a way with *smart playlists*, *shortcuts* and other stuff but could not figure a workaround to do it without paying the $99 fee for the developer program.
So unfortunately this isn't available for now.
</details>
<details>
<summary>Spotify</summary>
Spotify does not have *personal tokens*, so it makes the process a bit longer because you're required to follow the [authorization workflow](https://developer.spotify.com/documentation/general/guides/authorization-guide/)... Follow the instructions below for a *TL;DR* to obtain a `refresh_token`.
Sign in to the [developer dashboard](https://developer.spotify.com/dashboard/) and create a new app.
Keep your `client_id` and `client_secret` and let this tab open for now.
![Add a redirect url](/.github/readme/imgs/plugin_music_recent_spotify_token_0.png)
Open the settings and add a new *Redirect url*. Normally it is used to setup callbacks for apps, but just put `https://localhost` instead (it is mandatory as per the [authorization guide](https://developer.spotify.com/documentation/general/guides/authorization-guide/), even if not used).
Forge the authorization url with your `client_id` and the encoded `redirect_uri` you whitelisted, and access it from your browser:
```
https://accounts.spotify.com/authorize?client_id=********&response_type=code&scope=user-read-recently-played&redirect_uri=https%3A%2F%2Flocalhost
```
When prompted, authorize your application.
![Authorize application](/.github/readme/imgs/plugin_music_recent_spotify_token_1.png)
Once redirected to `redirect_uri`, extract the generated authorization `code` from your url bar.
![Extract authorization code from url](/.github/readme/imgs/plugin_music_recent_spotify_token_2.png)
Go back to your developer dashboard tab, and open the web console of your browser to paste the following JavaScript code, with your own `client_id`, `client_secret`, authorization `code` and `redirect_uri`.
```js
(async () => {
console.log(await (await fetch("https://accounts.spotify.com/api/token", {
method:"POST",
headers:{"Content-Type":"application/x-www-form-urlencoded"},
body:new URLSearchParams({
grant_type:"authorization_code",
redirect_uri:"https://localhost",
client_id:"********",
client_secret:"********",
code:"********",
})
})).json())
})()
```
It should return a JSON response with the following content:
```json
{
"access_token":"********",
"expires_in": 3600,
"scope":"user-read-recently-played",
"token_type":"Bearer",
"refresh_token":"********"
}
```
Register your `client_id`, `client_secret` and `refresh_token` in secrets to finish setup.
</details>
<details>
<summary>Last.fm</summary>
Obtain a Last.fm API key.
To do so, you can simply [create an API account](https://www.last.fm/api/account/create) or [use an existing one](https://www.last.fm/api/accounts).
Register your API key to finish setup.
</details>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_music: yes
plugin_music_provider: spotify # Use Spotify as provider
plugin_music_mode: recent # Set plugin mode
plugin_music_limit: 4 # Limit to 4 entries
plugin_music_token: "${{ secrets.SPOTIFY_CLIENT_ID }}, ${{ secrets.SPOTIFY_CLIENT_SECRET }}, ${{ secrets.SPOTIFY_REFRESH_TOKEN }}"
```
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_music: yes
plugin_music_provider: lastfm # Use Last.fm as provider
plugin_music_mode: recent # Set plugin mode
plugin_music_limit: 4 # Limit to 4 entries
plugin_music_user: .user.login # Use same username as GitHub login
plugin_music_token: ${{ secrets.LASTFM_API_KEY }}
```

View File

@@ -21,20 +21,22 @@
}
//Setup
export default async function ({login, imports, q}, {enabled = false, token = ""} = {}) {
export default async function ({login, imports, data, q, account}, {enabled = false, token = ""} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.music))
return null
//Initialization
const raw = {
get provider() { return providers[provider]?.name ?? "" },
get mode() { return modes[mode] ?? "Unconfigured music plugin"},
}
let tracks = null
//Parameters override
let {"music.provider":provider = "", "music.mode":mode = "", "music.playlist":playlist = null, "music.limit":limit = 4, "music.user":user = login} = q
//Load inputs
let {provider, mode, playlist, limit, user} = imports.metadata.plugins.music.inputs({data, account, q})
//Auto-guess parameters
if ((playlist)&&(!mode))
mode = "playlist"
@@ -59,6 +61,7 @@
}
//Limit
limit = Math.max(1, Math.min(100, Number(limit)))
//Handle mode
console.debug(`metrics/compute/${login}/plugins > music > processing mode ${mode} with provider ${provider}`)
switch (mode) {
@@ -197,6 +200,7 @@
default:
throw {error:{message:`Unsupported mode "${mode}"`}, ...raw}
}
//Format tracks
if (Array.isArray(tracks)) {
//Limit tracklist
@@ -213,6 +217,7 @@
//Save results
return {...raw, tracks}
}
//Unhandled error
throw {error:{message:`An error occured (could not retrieve tracks)`}}
}

View File

@@ -0,0 +1,63 @@
name: "🎼 Music plugin"
cost: N/A
supports:
- user
- organization
inputs:
# Enable or disable plugin
plugin_music:
description: Display your music tracks
type: boolean
default: no
# Name of music provider
# This is optional for "playlist" mode (it can be deduced automatically from "plugin_music_playlist" url)
# This is required in other modes
plugin_music_provider:
description: Music provider
type: string
default: ""
values:
- apple # Apple Music
- spotify # Spotify
- lastfm # Last.fm
# Music provider token
# This may be required depending on music provider used and plugin mode
# - "apple" : not required
# - "spotify" : required for "recent" mode, format is "client_id, client_secret, refresh_token"
# - "lastfm" : required, format is "api_key"
plugin_music_token:
description: Music provider personal token
type: token
default: ""
# Plugin mode
plugin_music_mode:
description: Plugin mode
type: string
default: "" # Defaults to "recent" or to "playlist" if "plugin_music_playlist" is specified
values:
- playlist # Display tracks from an embed playlist randomly
- recent # Display recently listened tracks
# Embed playlist url (i.e. url used by music player iframes)
plugin_music_playlist:
description: Embed playlist url
type: string
default: ""
# Number of music tracks to display
plugin_music_limit:
description: Maximum number of tracks to display
type: number
default: 4
min: 1
max: 100
# Username on music provider service
plugin_music_user:
description: Music provider username
type: string
default: .user.login

View File

@@ -0,0 +1,40 @@
### ⏱️ Website performances
The *pagespeed* plugin adds the performance statistics of the website attached on your account:
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.pagespeed.svg">
<details><summary>Detailed version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.pagespeed.detailed.svg">
</details>
<details><summary>With screenshot version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.pagespeed.screenshot.svg">
</details>
<img width="900" height="1" alt="">
</td>
</table>
These metrics are computed through [Google's PageSpeed API](https://developers.google.com/speed/docs/insights/v5/get-started), which yields the same results as [web.dev](https://web.dev).
See [performance scoring](https://web.dev/performance-scoring/) and [score calculator](https://googlechrome.github.io/lighthouse/scorecalc/) for more informations about how PageSpeed compute these statistics.
Although not mandatory, you can generate an API key for PageSpeed API [here](https://developers.google.com/speed/docs/insights/v5/get-started) to avoid hitting rate limiter.
Expect 10 to 30 seconds to generate the results.
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_pagespeed: yes
plugin_pagespeed_token: ${{ secrets.PAGESPEED_TOKEN }} # Optional but recommended
plugin_pagespeed_detailed: yes # Print detailed audit metrics
plugin_pagespeed_screenshot: no # Display a screenshot of your website
plugin_pagespeed_url: .user.website # Website to audit (defaults to your GitHub linked website)
```

View File

@@ -1,18 +1,18 @@
//Setup
export default async function ({login, imports, data, q}, {enabled = false, token = null} = {}) {
export default async function ({login, imports, data, q, account}, {enabled = false, token = null} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.pagespeed)||((!data.user.websiteUrl)&&(!q["pagespeed.url"])))
return null
//Parameters override
let {"pagespeed.detailed":detailed = false, "pagespeed.screenshot":screenshot = false, "pagespeed.url":url = data.user.websiteUrl} = q
//Duration in days
detailed = !!detailed
//Load inputs
let {detailed, screenshot, url} = imports.metadata.plugins.pagespeed.inputs({data, account, q})
//Format url if needed
if (!/^https?:[/][/]/.test(url))
url = `https://${url}`
const result = {url, detailed, scores:[], metrics:{}}
//Load scores from API
console.debug(`metrics/compute/${login}/plugins > pagespeed > querying api for ${url}`)
const scores = new Map()
@@ -31,6 +31,7 @@
}
}))
result.scores = [scores.get("performance"), scores.get("accessibility"), scores.get("best-practices"), scores.get("seo")]
//Detailed metrics
if (detailed) {
console.debug(`metrics/compute/${login}/plugins > pagespeed > performing detailed audit`)
@@ -39,6 +40,7 @@
Object.assign(result.metrics, ...request.data.lighthouseResult.audits.metrics.details.items)
console.debug(`metrics/compute/${login}/plugins > pagespeed > performed detailed audit (status code ${request.status})`)
}
//Results
return result
}

View File

@@ -0,0 +1,42 @@
name: "⏱️ Website performances"
cost: N/A
supports:
- user
- organization
- repository
inputs:
# Enable or disable plugin
plugin_pagespeed:
description: Display a website Google PageSpeed metrics
type: boolean
default: no
# Website to audit with PageSpeed
plugin_pagespeed_url:
description: Audited website
type: string
default: .user.website
# Display the following additional metrics from audited website:
# First Contentful Paint, Speed Index, Largest Contentful Paint, Time to Interactive, Total Blocking Time, Cumulative Layout Shift
# See https://web.dev/performance-scoring/ and https://googlechrome.github.io/lighthouse/scorecalc/ for more informations
plugin_pagespeed_detailed:
description: Detailed audit result
type: boolean
default: no
# Display a screenshot of audited website
# May increases significantly filesize
plugin_pagespeed_screenshot:
description: Display a screenshot of your website
type: boolean
default: no
# PageSpeed API token
# This is optional, but providing it will avoid hitting rate-limiter
# See https://developers.google.com/speed/docs/insights/v5/get-started for more informations
plugin_pagespeed_token:
description: PageSpeed token
type: token
default: ""

View File

@@ -0,0 +1,52 @@
### 🧑‍🤝‍🧑 People plugin <sup>🚧 <code>plugin_people_thanks</code>, repository version and "sponsors" on <code>@master</code></sup>
The *people* plugin can display people you're following or sponsoring, and also users who're following or sponsoring you.
In repository mode, it's possible to display sponsors, stargazers, watchers.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.people.followers.svg">
<details><summary>Followed people version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.people.following.svg">
</details>
<details><summary>Special thanks version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.people.thanks.svg">
</details>
<details><summary>Repository template version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.people.repository.svg">
</details>
<img width="900" height="1" alt="">
</td>
</table>
The following types are supported:
| Type | Alias | User metrics | Repository metrics |
| --------------- | ------------------------------------ | :----------------: | :----------------: |
| `followers` | | ✔️ | ❌ |
| `following` | `followed` | ✔️ | ❌ |
| `sponsoring` | `sponsored`, `sponsorshipsAsSponsor` | ✔️ | ❌ |
| `sponsors` | `sponsorshipsAsMaintainer` | ✔️ | ✔️ |
| `contributors` | | ❌ | ✔️ |
| `stargazers` | | ❌ | ✔️ |
| `watchers` | | ❌ | ✔️ |
| `thanks` | | ✔️ | ✔️ |
Sections will be ordered the same as specified in `plugin_people_types`.
`sponsors` for repositories will output the same as the owner's sponsors.
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_people: yes
plugin_people_types: followers, thanks # Display followers and "thanks" sections
plugin_people_limit: 28 # Limit to 28 entries per section
plugin_people_size: 28 # Size in pixels of displayed avatars
plugin_people_identicons: no # Use avatars (do not use identicons)
plugin_people_thanks: lowlighter, octocat # Users that will be displayed in "thanks" sections
```

View File

@@ -20,14 +20,10 @@
context = {...context, mode:"repository", types:["contributors", "stargazers", "watchers", "sponsorshipsAsMaintainer", "thanks"], default:"stargazers, watchers", owner, repo}
}
//Parameters override
let {"people.limit":limit = 28, "people.types":types = context.default, "people.size":size = 28, "people.identicons":identicons = false, "people.thanks":thanks = []} = q
//Limit
limit = Math.max(1, limit)
//Repositories projects
types = [...new Set(decodeURIComponent(types ?? "").split(",").map(type => type.trim()).map(type => (context.alias[type] ?? type)).filter(type => context.types.includes(type)) ?? [])]
//Special thanks
thanks = decodeURIComponent(thanks ?? "").split(",").map(user => user.trim()).filter(user => user)
//Load inputs
let {limit, types, size, identicons, thanks} = imports.metadata.plugins.people.inputs({data, account, q}, {types:context.default})
//Filter types
types = [...new Set([...types].map(type => (context.alias[type] ?? type)).filter(type => context.types.includes(type)) ?? [])]
//Retrieve followers from graphql api
console.debug(`metrics/compute/${login}/plugins > people > querying api`)
@@ -52,8 +48,8 @@
do {
console.debug(`metrics/compute/${login}/plugins > people > retrieving ${type} after ${cursor}`)
const {[type]:{edges}} = (
type in context.sponsorships ? (await graphql(queries["people.sponsors"]({login:context.owner ?? login, type, size, after:cursor ? `after: "${cursor}"` : "", target:context.sponsorships[type], account})))[account] :
context.mode === "repository" ? (await graphql(queries["people.repository"]({login:context.owner, repository:context.repo, type, size, after:cursor ? `after: "${cursor}"` : "", account})))[account].repository :
type in context.sponsorships ? (await graphql(queries.people.sponsors({login:context.owner ?? login, type, size, after:cursor ? `after: "${cursor}"` : "", target:context.sponsorships[type], account})))[account] :
context.mode === "repository" ? (await graphql(queries.people.repository({login:context.owner, repository:context.repo, type, size, after:cursor ? `after: "${cursor}"` : "", account})))[account].repository :
(await graphql(queries.people({login, type, size, after:cursor ? `after: "${cursor}"` : ""}))).user
)
cursor = edges?.[edges?.length-1]?.cursor

View File

@@ -0,0 +1,61 @@
name: "🧑‍🤝‍🧑 People plugin"
cost: 1 GraphQL request per 100 users + 1 REST request per user in "plugin_people_thanks"
supports:
- user
- organization
- repository
inputs:
# Enable or disable plugin
plugin_people:
description: Display GitHub users from various affiliations
type: boolean
default: no
# Number of users to display per section
plugin_people_limit:
description: Maximum number of user to display
type: number
default: 28
min: 0
# Size of displayed user's avatar
plugin_people_size:
description: Size of displayed GitHub users' avatars
type: number
default: 28
min: 8
max: 64
# List of section to display
# Ordering will be kept
plugin_people_types:
description: Affiliations to display
type: array
format: comma-separated
default: followers, following
values:
- followers # For user metrics
- following # For user metrics
- followed # For user metrics, alias for "following"
- sponsoring # For user metrics
- sponsored # Alias for "sponsored"
- sponsors # For both user and repository metrics
- contributors # For repository metrics
- stargazers # For repository metrics
- watchers # For repository metrics
- thanks # For both user and repository metrics, see "plugin_people_thanks" below
# When displaying "thanks" section, specified users list will be displayed
# This is useful to craft "Special thanks" badges
plugin_people_thanks:
description: GitHub users to personally thanks
type: array
format: comma-separated
default: ""
# Use GitHub identicons instead of users' avatar (for privacy purposes)
plugin_people_identicons:
description: Use identicons instead of avatars
type: boolean
default: no

View File

@@ -1,4 +1,4 @@
query People {
query PeopleDefault {
user(login: "$login") {
login
$type($after first: 100) {

View File

@@ -0,0 +1,23 @@
### ✒️ Recent posts
The recent *posts* plugin displays recent articles you wrote on an external source, like [dev.to](https://dev.to).
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.posts.svg">
<img width="900" height="1" alt="">
</td>
</table>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_posts: yes
plugin_posts_source: dev.to # External source
plugin_people_user: .github.user # Use same username as GitHub login
```

View File

@@ -1,14 +1,14 @@
//Setup
export default async function ({login, imports, q}, {enabled = false} = {}) {
export default async function ({login, data, imports, q, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.posts))
return null
//Parameters override
let {"posts.source":source = "", "posts.limit":limit = 4, "posts.user":user = login} = q
//Limit
limit = Math.max(1, Math.min(30, Number(limit)))
//Load inputs
let {source, limit, user} = imports.metadata.plugins.posts.inputs({data, account, q})
//Retrieve posts
console.debug(`metrics/compute/${login}/plugins > posts > processing with source ${source}`)
let posts = null
@@ -23,6 +23,7 @@
default:
throw {error:{message:`Unsupported source "${source}"`}}
}
//Format posts
if (Array.isArray(posts)) {
//Limit tracklist
@@ -33,6 +34,7 @@
//Results
return {source, list:posts}
}
//Unhandled error
throw {error:{message:`An error occured (could not retrieve posts)`}}
}

View File

@@ -0,0 +1,35 @@
name: "✒️ Recent posts"
cost: N/A
supports:
- user
- organization
inputs:
# Enable or disable plugin
plugin_posts:
description: Display recent posts
type: boolean
default: no
# Posts external source
plugin_posts_source:
description: Posts external source
type: string
default: ""
values:
- dev.to # Dev.to
# Number of posts to display
plugin_posts_limit:
description: Maximum number of posts to display
type: number
default: 4
min: 1
max: 30
# Username on external posts source
plugin_posts_user:
description: Posts external source username
type: string
default: .user.login

View File

@@ -0,0 +1,55 @@
### 🗂️ Projects <sup>🚧 <code>plugin_projects_descriptions</code> on <code>@master</code></sup>
⚠️ This plugin requires a personal token with public_repo scope.
The *projects* plugin displays the progress of your profile projects.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.projects.svg">
<img width="900" height="1" alt="">
</td>
</table>
Because of GitHub REST API limitation, provided token requires `public_repo` scope to access projects informations.
Note that by default, projects have progress tracking disabled.
To enable it, open the `≡ Menu` and edit the project to opt-in to `Track project progress` (it can be a bit confusing since it's actually not in the project settings).
![Enable "Track project progress"](/.github/readme/imgs/plugin_projects_track_progress.png)
<details>
<summary>💬 Create a personal project on GitHub</summary>
On your profile, select the `Projects` tab:
![Create a new project](/.github/readme/imgs/plugin_projects_create.png)
Fill the informations and set visibility to *public*:
![Configure project](/.github/readme/imgs/plugin_projects_setup.png)
</details>
<details>
<summary>💬 Use repositories projects</summary>
It is possible to display projects related to repositories along with personal projects.
To do so, open your repository project and retrieve the last URL endpoint, in the format `:user/:repository/projects/:project_id` (for example, `lowlighter/metrics/projects/1`) and add it in the `plugin_projects_repositories` option. Enable `Track project progress` in the project settings to display a progress bar in generated metrics.
![Add a repository project](/.github/readme/imgs/plugin_projects_repositories.png)
</details>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_projects: yes
plugin_projects_repositories: lowlighter/metrics/projects/1 # Display #1 project of lowlighter/metrics repository
plugin_projects_limit: 4 # Limit to 4 entries
plugin_projects_descriptions: yes # Display projects descriptions
```

View File

@@ -1,25 +1,26 @@
//Setup
export default async function ({login, graphql, q, queries, account}, {enabled = false} = {}) {
export default async function ({login, data, imports, graphql, q, queries, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.projects))
return null
//Parameters override
let {"projects.limit":limit = 4, "projects.repositories":repositories = "", "projects.descriptions":descriptions = false} = q
//Load inputs
let {limit, repositories, descriptions} = imports.metadata.plugins.projects.inputs({data, account, q})
//Repositories projects
repositories = decodeURIComponent(repositories ?? "").split(",").map(repository => repository.trim()).filter(repository => /[-\w]+[/][-\w]+[/]projects[/]\d+/.test(repository)) ?? []
//Limit
limit = Math.max(repositories.length, Math.min(100, Number(limit)))
repositories = repositories.filter(repository => /[-\w]+[/][-\w]+[/]projects[/]\d+/.test(repository))
//Retrieve user owned projects from graphql api
console.debug(`metrics/compute/${login}/plugins > projects > querying api`)
const {[account]:{projects}} = await graphql(queries.projects({login, limit, account}))
const {[account]:{projects}} = await graphql(queries.projects.user({login, limit, account}))
//Retrieve repositories projects from graphql api
for (const identifier of repositories) {
//Querying repository project
console.debug(`metrics/compute/${login}/plugins > projects > querying api for ${identifier}`)
const {user, repository, id} = identifier.match(/(?<user>[-\w]+)[/](?<repository>[-\w]+)[/]projects[/](?<id>\d+)/)?.groups
const {[account]:{repository:{project}}} = await graphql(queries["projects.repository"]({user, repository, id, account}))
const {[account]:{repository:{project}}} = await graphql(queries.projects.repository({user, repository, id, account}))
//Adding it to projects list
console.debug(`metrics/compute/${login}/plugins > projects > registering ${identifier}`)
project.name = `${project.name} (${user}/${repository})`
@@ -43,9 +44,11 @@
//Append
list.push({name:project.name, updated, description:project.body, progress:{enabled, todo, doing, done, total:todo+doing+done}})
}
//Limit
console.debug(`metrics/compute/${login}/plugins > projects > keeping only ${limit} projects`)
list.splice(limit)
//Results
return {list, totalCount:projects.totalCount, descriptions}
}

View File

@@ -0,0 +1,39 @@
name: "🗂️ Projects"
cost: 1 GraphQL request + 1 GraphQL request per repository project
supports:
- user
- organization
- repository
inputs:
# Enable or disable plugin
plugin_projects:
description: Display active projects
type: boolean
default: no
# Number of projects to display
# Set to 0 to only display "plugin_projects_repositories" projects
# Projects listed in "plugin_projects_repositories" are not affected by this option
plugin_projects_limit:
description: Maximum number of projects to display
type: number
default: 4
min: 0
max: 100
# List of repository projects to display, using the following format:
# :user/:repo/projects/:project_id
plugin_projects_repositories:
description: List of repository project identifiers to disaplay
type: array
format:
- comma-separated
- /(?<user>[-a-z0-9]+)[/](?<repo>[-a-z0-9]+)[/]projects[/](?<id>[0-9]+)/
default: ""
# Display projects descriptions
plugin_projects_descriptions:
description: Display projects descriptions
type: boolean
default: no

View File

@@ -1,4 +1,4 @@
query RepositoryProject {
query ProjectsRepository {
$account(login: "$user") {
repository(name: "$repository") {
project(number: $id) {

View File

@@ -1,4 +1,4 @@
query Projects {
query ProjectsUser {
$account(login: "$login") {
projects(last: $limit, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
totalCount

View File

@@ -0,0 +1,21 @@
### ✨ Stargazers over last weeks
The *stargazers* plugin displays your stargazers evolution across all of your repositories over the last two weeks.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.stargazers.svg">
<img width="900" height="1" alt="">
</td>
</table>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_stargazers: yes
```

View File

@@ -1,10 +1,14 @@
//Setup
export default async function ({login, graphql, data, q, queries}, {enabled = false} = {}) {
export default async function ({login, graphql, data, imports, q, queries, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.stargazers))
return null
//Load inputs
imports.metadata.plugins.stargazers.inputs({data, account, q})
//Retrieve stargazers from graphql api
console.debug(`metrics/compute/${login}/plugins > stargazers > querying api`)
const repositories = data.user.repositories.nodes.map(({name:repository, owner:{login:owner}}) => ({repository, owner})) ?? []
@@ -25,6 +29,7 @@
console.debug(`metrics/compute/${login}/plugins > stargazers > loaded ${dates.length} stargazers for ${repository}`)
}
console.debug(`metrics/compute/${login}/plugins > stargazers > loaded ${dates.length} stargazers in total`)
//Compute stargazers increments
const days = 14
const increments = {dates:Object.fromEntries([...new Array(days).fill(null).map((_, i) => [new Date(Date.now()-i*24*60*60*1000).toISOString().slice(0, 10), 0]).reverse()]), max:NaN, min:NaN}
@@ -34,6 +39,7 @@
.map(date => increments.dates[date]++)
increments.min = Math.min(...Object.values(increments.dates))
increments.max = Math.max(...Object.values(increments.dates))
//Compute total stargazers
let stargazers = data.computed.repositories.stargazers
const total = {dates:{...increments.dates}, max:NaN, min:NaN}
@@ -47,8 +53,10 @@
}
total.min = Math.min(...Object.values(total.dates))
total.max = Math.max(...Object.values(total.dates))
//Months name
const months = ["", "Jan.", "Feb.", "Mar.", "Apr.", "May", "June", "July", "Aug.", "Sep.", "Oct.", "Nov.", "Dec."]
//Results
return {total, increments, months}
}

View File

@@ -0,0 +1,13 @@
name: "✨ Stargazers over last weeks"
cost: 1 GraphQL request per 100 stargazers
supports:
- user
- organization
- repository
inputs:
# Enable or disable plugin
plugin_stargazers:
description: Display stargazers metrics
type: boolean
default: no

View File

@@ -1,4 +1,4 @@
query Stargazers {
query StargazersDefault {
repository(name: "$repository", owner: "$login") {
stargazers($after first: 100, orderBy: {field: STARRED_AT, direction: ASC}) {
edges {

Some files were not shown because too many files have changed in this diff Show More