Feat miscelleanous 1 (#28)

* Improve logs and better handling of plugins errors

* Add support for timezones

* Prepare next release
This commit is contained in:
Simon Lecoq
2020-12-27 22:30:53 +01:00
committed by GitHub
parent 619113295c
commit 016ab9aca1
24 changed files with 242 additions and 148 deletions

View File

@@ -213,14 +213,19 @@ The `README.md` of it will be displayed on your user profile :
From the `Developer settings` of your account settings, select `Personal access tokens` to create a new token.
No additional scopes are needed, unless you want to include your private repositories metrics or if you want to use the `traffic` plugin.
No additional scopes are needed, unless you want to include your private repositories metrics.
![Setup a GitHub personal token](.github/readme/imgs/setup_personal_token.png)
Be sure to enable `Include private contributions on my profile` in your account settings if you want to include your private contributions :
With a scope-less token, you can still display private contributions by enabling `Include private contributions on my profile` in your account settings :
![Enable "Include private contributions on my profile`"](.github/readme/imgs/setup_private_contributions.png)
Some plugins also require additional scopes, which is indicated in their respective documentation.
In case your token does not have the required scope (and `plugins_errors_fatal` is not enabled), it will be directly notified in the plugin render like below :
![Plugin error example](https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.error.svg)
### 2. Set your GitHub token in your personal repository secrets
Go to the `Settings` of your personal repository to create a new secret and paste your freshly generated GitHub token there.
@@ -258,8 +263,6 @@ When using a token with additional permissions, it is advised to fork this repos
```
In this case, consider watching new releases of this repository to stay up-to-date and enjoy latest features !
If you prefer examples rather than theory, check out this [workflow](https://github.com/lowlighter/lowlighter/blob/master/.github/workflows/metrics.yml) file which generates metrics daily.
#### Preview vs release
It is possible to use `@master` instead of `@latest` to use new features before their official release.
@@ -271,6 +274,12 @@ A new metrics image will be generated and committed to your repository on each r
![Action update example](.github/readme/imgs/example_action_update.png)
#### Workflow examples
Check out this [workflow](https://github.com/lowlighter/lowlighter/blob/master/.github/workflows/metrics.yml) file which generates metrics daily.
Note that most of steps presented there are illustrative examples for this readme and are actually not needed to generate your own metrics.
### 4. Embed the link into your README.md
Edit your repository readme and add your metrics image :
@@ -867,6 +876,8 @@ Add the following to your workflow :
### 🗂️ Projects
⚠️ This plugin requires a personal token with public_repo scope.
The *projects* plugin displays the progress of your profile projects.
![Projects plugin](https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.projects.svg)
@@ -876,6 +887,8 @@ The *projects* plugin displays the progress of your profile projects.
It will consume an additional GitHub request.
Because of GitHub REST API limitation, provided token requires `public_repo` scope to access projects informations.
Add the following to your workflow :
```yaml
- uses: lowlighter/metrics@latest
@@ -926,7 +939,7 @@ Add the following to your workflow :
### 🧮 Traffic
⚠️ This plugin requires a personal token with full repo scope.
⚠️ This plugin requires a personal token with repo scope.
The repositories *traffic* plugin displays the number of pages views across your repositories.
@@ -955,8 +968,6 @@ Add the following to your workflow :
### 🐤 Tweets
🚧 This plugin is available as pre-release on @master
The recent *tweets* plugin displays your latest tweets of the [twitter](https://twitter.com) attached mentioned on your account :
![Tweets plugin](https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.tweets.svg)
@@ -1042,8 +1053,6 @@ Add the following to your workflow :
plugin_habits_days: 14
```
🚧 The following feature is available as pre-release on @master
You can display charts in this section :
![Habits plugin (charts)](https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.habits.charts.svg)
@@ -1063,6 +1072,15 @@ Add the following to your workflow instead :
plugin_habits_charts: yes
```
By default, dates are based on the Greenwich meridian (England time). In order to these metrics to be accurate, 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) :
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
config_timezone: Europe/Paris
```
</details>
### 🎫 Gists

View File

@@ -40,6 +40,13 @@ inputs:
description: Optimize SVG image
default: yes
# Set timezone used by metrics
# See https://en.wikipedia.org/wiki/List_of_tz_database_time_zones
# Some plugins will use this setting to calibrate dates
config_timezone:
description: Timezone used by metrics
default: ""
# Number of repositories to use for metrics
# A high number increase metrics accuracy, but will consume additional API requests when using plugins
repositories:

24
action/dist/index.js vendored

File diff suppressed because one or more lines are too long

View File

@@ -37,7 +37,7 @@
console.log(`Template to use | ${template}`)
//Token for data gathering
const token = core.getInput("token")
const token = core.getInput("token") || ""
console.log(`Github token | ${token ? "provided" : "missing"}`)
if (!token)
throw new Error("You must provide a valid GitHub token to gather your metrics")
@@ -71,16 +71,22 @@
if (!debug)
console.debug = message => debugged.push(message)
console.log(`Debug mode | ${debug}`)
const dflags = (core.getInput("debug_flags") ?? "").split(" ").filter(flag => flag)
const dflags = (core.getInput("debug_flags") || "").split(" ").filter(flag => flag)
console.log(`Debug flags | ${dflags.join(" ")}`)
//Base elements
const base = {}
let parts = (core.getInput("base")||"").split(",").map(part => part.trim())
let parts = (core.getInput("base") || "").split(",").map(part => part.trim())
for (const part of conf.settings.plugins.base.parts)
base[`base.${part}`] = parts.includes(part)
console.log(`Base parts | ${parts.join(", ") || "(none)"}`)
//Config
const config = {
"config.timezone":core.getInput("config_timezone") || ""
}
console.log(`Timezone | ${config.timezone || "(none)"}`)
//Additional plugins
const plugins = {
lines:{enabled:bool(core.getInput("plugin_lines"))},
@@ -102,7 +108,7 @@
//Additional plugins options
//Pagespeed
if (plugins.pagespeed.enabled) {
plugins.pagespeed.token = core.getInput("plugin_pagespeed_token")
plugins.pagespeed.token = core.getInput("plugin_pagespeed_token") || ""
q[`pagespeed.detailed`] = bool(core.getInput(`plugin_pagespeed_detailed`))
console.log(`Pagespeed token | ${plugins.pagespeed.token ? "provided" : "missing"}`)
console.log(`Pagespeed detailed | ${q["pagespeed.detailed"]}`)
@@ -145,7 +151,7 @@
}
//Isocalendar
if (plugins.isocalendar.enabled) {
q["isocalendar.duration"] = core.getInput("plugin_isocalendar_duration") ?? "half-year"
q["isocalendar.duration"] = core.getInput("plugin_isocalendar_duration") || "half-year"
console.log(`Isocalendar duration | ${q["isocalendar.duration"]}`)
}
//Topics
@@ -179,7 +185,7 @@
console.log(`Plugin errors | ${die ? "die" : "ignore"}`)
//Built query
q = {...q, base:false, ...base, repositories, template}
q = {...q, base:false, ...base, ...config, repositories, template}
//Render metrics
const rendered = await metrics({login:user, q, dflags}, {graphql, rest, plugins, conf, die})
@@ -206,7 +212,7 @@
console.log(`Repository | ${github.context.repo.owner}/${github.context.repo.repo}`)
console.log(`Branch | ${branch}`)
//Committer token
const token = core.getInput("committer_token") || core.getInput("token")
const token = core.getInput("committer_token") || core.getInput("token") || ""
console.log(`Committer token | ${token ? "provided" : "missing"}`)
if (!token)
throw new Error("You must provide a valid GitHub token to commit your metrics")
@@ -231,7 +237,7 @@
)
sha = oid
} catch (error) { console.debug(error) }
console.log(`Previous render sha | ${sha || "none"}`)
console.log(`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]`,
@@ -245,8 +251,9 @@
console.log(`Success !`)
process.exit(0)
}
//Errors
} catch (error) {
catch (error) {
console.error(error)
if (!bool(core.getInput("debug")))
for (const log of ["_".repeat(64), "An error occured, logging debug message :", ...debugged])

62
package-lock.json generated
View File

@@ -211,26 +211,26 @@
}
},
"@babel/traverse": {
"version": "7.12.10",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.10.tgz",
"integrity": "sha512-6aEtf0IeRgbYWzta29lePeYSk+YAFIC3kyqESeft8o5CkFlYIMX+EQDDWEiAQ9LHOA3d0oHdgrSsID/CKqXJlg==",
"version": "7.12.12",
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.12.12.tgz",
"integrity": "sha512-s88i0X0lPy45RrLM8b9mz8RPH5FqO9G9p7ti59cToE44xFm1Q+Pjh5Gq4SXBbtb88X7Uy7pexeqRIQDDMNkL0w==",
"dev": true,
"requires": {
"@babel/code-frame": "^7.10.4",
"@babel/generator": "^7.12.10",
"@babel/helper-function-name": "^7.10.4",
"@babel/helper-split-export-declaration": "^7.11.0",
"@babel/parser": "^7.12.10",
"@babel/types": "^7.12.10",
"@babel/code-frame": "^7.12.11",
"@babel/generator": "^7.12.11",
"@babel/helper-function-name": "^7.12.11",
"@babel/helper-split-export-declaration": "^7.12.11",
"@babel/parser": "^7.12.11",
"@babel/types": "^7.12.12",
"debug": "^4.1.0",
"globals": "^11.1.0",
"lodash": "^4.17.19"
}
},
"@babel/types": {
"version": "7.12.11",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.11.tgz",
"integrity": "sha512-ukA9SQtKThINm++CX1CwmliMrE54J6nIYB5XTwL5f/CLFW9owfls+YSU8tVW15RQ2w+a3fSbPjC6HdQNtWZkiA==",
"version": "7.12.12",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.12.12.tgz",
"integrity": "sha512-lnIX7piTxOH22xE7fDXDbSHg9MM1/6ORnafpJmov5rs0kX5g4BZxeXNJLXsMRiO0U5Rb8/FvMS6xlTnTHvxonQ==",
"dev": true,
"requires": {
"@babel/helper-validator-identifier": "^7.12.11",
@@ -280,9 +280,9 @@
}
},
"@octokit/openapi-types": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.0.0.tgz",
"integrity": "sha512-J4bfM7lf8oZvEAdpS71oTvC1ofKxfEZgU5vKVwzZKi4QPiL82udjpseJwxPid9Pu2FNmyRQOX4iEj6W1iOSnPw=="
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.0.1.tgz",
"integrity": "sha512-9AuC04PUnZrjoLiw3uPtwGh9FE4Q3rTqs51oNlQ0rkwgE8ftYsOC+lsrQyvCvWm85smBbSc0FNRKKumvGyb44Q=="
},
"@octokit/plugin-paginate-rest": {
"version": "2.6.2",
@@ -343,18 +343,18 @@
}
},
"@octokit/types": {
"version": "6.1.1",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.1.1.tgz",
"integrity": "sha512-btm3D6S7VkRrgyYF31etUtVY/eQ1KzrNRqhFt25KSe2mKlXuLXJilglRC6eDA2P6ou94BUnk/Kz5MPEolXgoiw==",
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.1.2.tgz",
"integrity": "sha512-LPCpcLbcky7fWfHCTuc7tMiSHFpFlrThJqVdaHgowBTMS0ijlZFfonQC/C1PrZOjD4xRCYgBqH9yttEATGE/nw==",
"requires": {
"@octokit/openapi-types": "^2.0.0",
"@octokit/openapi-types": "^2.0.1",
"@types/node": ">= 8"
}
},
"@types/node": {
"version": "14.14.14",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.14.tgz",
"integrity": "sha512-UHnOPWVWV1z+VV8k6L1HhG7UbGBgIdghqF3l9Ny9ApPghbjICXkUJSd/b9gOgQfjM1r+37cipdw/HJ3F6ICEnQ=="
"version": "14.14.16",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.16.tgz",
"integrity": "sha512-naXYePhweTi+BMv11TgioE2/FXU4fSl29HAH1ffxVciNsH3rYXjNP2yM8wqmSm7jS20gM8TIklKiTen+1iVncw=="
},
"@types/q": {
"version": "1.5.4",
@@ -371,9 +371,9 @@
}
},
"@vercel/ncc": {
"version": "0.25.1",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.25.1.tgz",
"integrity": "sha512-dGecC5+1wLof1MQpey4+6i2KZv4Sfs6WfXkl9KfO32GED4ZPiKxRfvtGPjbjZv0IbqMl6CxtcV1RotXYfd5SSA==",
"version": "0.26.1",
"resolved": "https://registry.npmjs.org/@vercel/ncc/-/ncc-0.26.1.tgz",
"integrity": "sha512-iVhYAL/rpHgjO88GkDHNVNrp7WTfMFBbeWXNgwaDPMv5rDI4hNOAM0u+Zhtbs42XBQE6EccNaY6UDb/tm1+dhg==",
"dev": true
},
"abbrev": {
@@ -445,9 +445,9 @@
"integrity": "sha1-rqdNXmHB+JlhO/ZL2mbUx48v0X0="
},
"axios": {
"version": "0.21.0",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.0.tgz",
"integrity": "sha512-fmkJBknJKoZwem3/IKSSLpkdNXZeBu5Q7GA/aRsr2btgrptmSCxi2oFjZHqGdK9DoTil9PIHlPIZw2EcRJXRvw==",
"version": "0.21.1",
"resolved": "https://registry.npmjs.org/axios/-/axios-0.21.1.tgz",
"integrity": "sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==",
"requires": {
"follow-redirects": "^1.10.0"
}
@@ -1416,9 +1416,9 @@
"dev": true
},
"get-intrinsic": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.0.1.tgz",
"integrity": "sha512-ZnWP+AmS1VUaLgTRy47+zKtjTxz+0xMpx3I52i+aalBK1QP19ggLF3Db89KJX7kjfOfP2eoa01qc++GwPgufPg==",
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.0.2.tgz",
"integrity": "sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg==",
"requires": {
"function-bind": "^1.1.1",
"has": "^1.0.3",

View File

@@ -1,6 +1,6 @@
{
"name": "metrics",
"version": "2.9.0-beta",
"version": "2.9.0",
"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 !",
"main": "index.mjs",
"scripts": {
@@ -24,7 +24,7 @@
"@actions/github": "^4.0.0",
"@octokit/graphql": "^4.5.8",
"@octokit/rest": "^18.0.12",
"axios": "^0.21.0",
"axios": "^0.21.1",
"colors": "^1.4.0",
"compression": "^1.7.4",
"ejs": "^3.1.5",
@@ -39,7 +39,7 @@
"vue-prism-component": "^1.2.0"
},
"devDependencies": {
"@vercel/ncc": "^0.25.1",
"@vercel/ncc": "^0.26.1",
"babel-minify": "^0.5.1",
"libxmljs": "^0.19.7"
}

View File

@@ -8,6 +8,7 @@
import setup from "./setup.mjs"
import metrics from "./metrics.mjs"
import Templates from "./templates/index.mjs"
import util from "util"
/** App */
export default async function () {
@@ -107,7 +108,7 @@
//Compute rendering
try {
//Render
console.debug(`metrics/app/${login} > ${JSON.stringify(req.query)}`)
console.debug(`metrics/app/${login} > ${util.inspect(req.query, {depth:Infinity, maxStringLength:256})}`)
const rendered = await metrics({login, q:parse(req.query)}, {graphql, rest, plugins, conf})
//Cache
if ((!debug)&&(cached)&&(login !== "placeholder"))
@@ -140,7 +141,7 @@
`Debug mode | ${debug}`,
`Restricted to users | ${restricted.size ? [...restricted].join(", ") : "(unrestricted)"}`,
`Cached time | ${cached} seconds`,
`Rate limiter | ${ratelimiter ? JSON.stringify(ratelimiter) : "(enabled)"}`,
`Rate limiter | ${ratelimiter ? util.inspect(req.query, {depth:Infinity, maxStringLength:256}) : "(enabled)"}`,
`Max simultaneous users | ${maxusers ? `${maxusers} users` : "(unrestricted)"}`,
`Plugins enabled | ${enabled.join(", ")}`
].join("\n")))

View File

@@ -11,6 +11,7 @@
import fs from "fs/promises"
import os from "os"
import paths from "path"
import util from "util"
//Setup
export default async function metrics({login, q, dflags = []}, {graphql, rest, plugins, conf, die = false}) {
@@ -19,7 +20,7 @@
//Init
console.debug(`metrics/compute/${login} > start`)
console.debug(JSON.stringify(q))
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 = []
@@ -27,7 +28,7 @@
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 {query, image, style, fonts} = conf.templates[template]
const data = {base:{}}
const data = {base:{}, config:{}}
//Base parts
{
@@ -53,13 +54,10 @@
//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}, {s, pending, imports:{plugins:Plugins, url, imgb64, axios, puppeteer, run, fs, os, paths, format, bytes, shuffle, htmlescape, urlexpand}})
await computer({login, q, dflags}, {conf, data, rest, graphql, plugins}, {s, pending, imports:{plugins:Plugins, url, imgb64, axios, puppeteer, run, fs, os, paths, util, format, bytes, shuffle, htmlescape, urlexpand}})
const promised = await Promise.all(pending)
//Check plugins errors
if (conf.settings.debug)
for (const {name, result = null} of promised)
console.debug(`plugin ${name} ${result ? result.error ? "failed" : "success" : "ignored"} : ${JSON.stringify(result).replace(/^(.{888}).+/, "$1...")}`)
if (die) {
const errors = promised.filter(({result = null}) => !!result?.error).length
if (errors)

View File

@@ -23,7 +23,6 @@
}
//Handle errors
catch (error) {
console.debug(error)
throw {error:{message:`An error occured`}}
throw {error:{message:"An error occured", instance:error}}
}
}

View File

@@ -5,7 +5,8 @@
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.gists))
return null
//Retrieve contribution calendar from graphql api
//Retrieve gists from graphql api
console.debug(`metrics/compute/${login}/plugins > gists > querying api`)
const {user:{gists}} = await graphql(`
query Gists {
user(login: "${login}") {
@@ -27,6 +28,7 @@
`
)
//Iterate through gists
console.debug(`metrics/compute/${login}/plugins > gists > processing ${gists.nodes.length} gists`)
let stargazers = 0, forks = 0, comments = 0
for (const gist of gists.nodes) {
//Skip forks
@@ -42,7 +44,6 @@
}
//Handle errors
catch (error) {
console.debug(error)
throw {error:{message:`An error occured`}}
throw {error:{message:"An error occured", instance:error}}
}
}

View File

@@ -1,5 +1,5 @@
//Setup
export default async function ({login, rest, imports, q}, {enabled = false, from:defaults = 100} = {}) {
export default async function ({login, rest, imports, data, q}, {enabled = false, from:defaults = 100} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
@@ -14,23 +14,25 @@
//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 = []
try {
for (let page = 0; page < pages; page++) {
console.debug(`metrics/compute/${login}/plugins > habits > loaded page ${page}`)
console.debug(`metrics/compute/${login}/plugins > habits > loading page ${page}`)
events.push(...(await rest.activity.listEventsForAuthenticatedUser({username:login, per_page:100, page})).data)
}
} catch { console.debug(`metrics/compute/${login}/plugins > habits > no more events to load`) }
console.debug(`metrics/compute/${login}/plugins > habits > no more events to load (${events.length} loaded)`)
} 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}) => 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} commits`)
const actor = commits[0]?.actor?.id ?? 0
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
.flatMap(({payload}) => payload.commits).map(commit => commit.url)
.map(async commit => (await rest.request(commit)).data.files)
@@ -42,7 +44,8 @@
//Commit day
{
//Compute commit days
const days = commits.map(({created_at}) => (new Date(created_at)).getDay())
console.debug(`metrics/compute/${login}/plugins > habits > searching most active day of week`)
const days = commits.map(({created_at}) => (new Date(new Date(created_at).getTime() + offset)).getDay())
for (const day of days)
habits.commits.days[day] = (habits.commits.days[day] ?? 0) + 1
habits.commits.days.max = Math.max(...Object.values(habits.commits.days))
@@ -52,7 +55,8 @@
//Commit hour
{
//Compute commit hours
const hours = commits.map(({created_at}) => (new Date(created_at)).getHours())
console.debug(`metrics/compute/${login}/plugins > habits > searching most active time of day`)
const hours = commits.map(({created_at}) => (new Date(new Date(created_at).getTime() + offset)).getHours())
for (const hour of hours)
habits.commits.hours[hour] = (habits.commits.hours[hour] ?? 0) + 1
habits.commits.hours.max = Math.max(...Object.values(habits.commits.hours))
@@ -62,6 +66,7 @@
//Indent style
{
//Attempt to guess whether tabs or spaces are used in patches
console.debug(`metrics/compute/${login}/plugins > habits > searching indent style`)
patches
.map(({patch}) => patch.match(/((?:\t)|(?: )) /gm) ?? [])
.forEach(indent => habits.indents[/^\t/.test(indent) ? "tabs" : "spaces"]++)
@@ -70,36 +75,38 @@
//Linguist
if (charts) {
//Check if linguist exists
console.debug(`metrics/compute/${login}/plugins > habits > searching recently used languages using linguist`)
const prefix = {win32:"wsl"}[process.platform] ?? ""
if ((patches.length)&&(await imports.run(`${prefix} which github-linguist`))) {
//Setup for linguist
habits.linguist.available = true
const path = imports.paths.join(imports.os.tmpdir(), `${actor}`)
const path = imports.paths.join(imports.os.tmpdir(), `${commits[0]?.actor?.id ?? 0}`)
//Create temporary directory and save patches
console.debug(`metrics/compute/${login}/plugins > habits > creating temp dir ${path} with ${patches.length} files`)
await imports.fs.mkdir(path, {recursive:true})
await Promise.all(patches.map(async ({name, patch}, i) => await imports.fs.writeFile(imports.paths.join(path, `${i}${imports.paths.extname(name)}`), patch)))
console.debug(`metrics/compute/${login}/plugins > habits > created temp dir ${path} with ${patches.length} files`)
//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 status`, {cwd:path})
console.debug(`metrics/compute/${login}/plugins > habits > created temp git repository`)
//Spawn linguist process
console.debug(`metrics/compute/${login}/plugins > habits > running linguist`)
;(await imports.run(`${prefix} github-linguist`, {cwd:path}))
;(await imports.run(`${prefix} github-linguist --breakdown`, {cwd:path}))
//Parse linguist result
.split("\n").map(line => line.match(/(?<value>[\d.]+)%\s+(?<language>\w+)/)?.groups).filter(line => line)
.map(({value, language}) => habits.linguist.languages[language] = (habits.linguist.languages[language] ?? 0) + value/100)
habits.linguist.ordered = Object.entries(habits.linguist.languages).sort(([an, a], [bn, b]) => b - a)
}
else
console.debug(`metrics/compute/${login}/plugins > habits > linguist is not available`)
console.debug(`metrics/compute/${login}/plugins > habits > linguist not available`)
}
//Results
return habits
}
//Handle errors
catch (error) {
console.debug(error)
throw {error:{message:`An error occured`}}
if (error.error?.message)
throw error
throw {error:{message:"An error occured", instance:error}}
}
}

View File

@@ -20,9 +20,10 @@
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()}"`)
console.debug(`metrics/compute/${login}/plugins > isocalendar > loading ${name} from "${from.toISOString()}" to "${to.toISOString()}"`)
const {user:{calendar:{contributionCalendar:{weeks}}}} = await graphql(`
query Calendar {
user(login: "${login}") {
@@ -44,11 +45,13 @@
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
for (const week of calendar.weeks) {
for (const day of week.contributionDays) {
@@ -60,6 +63,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
let i = 0, j = 0
let svg = `
@@ -96,7 +100,6 @@
}
//Handle errors
catch (error) {
console.debug(error)
throw {error:{message:`An error occured`}}
throw {error:{message:"An error occured", instance:error}}
}
}

View File

@@ -12,6 +12,7 @@
//Skipped repositories
skipped = decodeURIComponent(skipped).split(",").map(x => x.trim().toLocaleLowerCase()).filter(x => x)
//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:{}}
for (const repository of data.user.repositories.nodes) {
//Skip repository if asked
@@ -33,6 +34,7 @@
}
}
//Compute languages stats
console.debug(`metrics/compute/${login}/plugins > languages > computing stats`)
Object.keys(languages.stats).map(name => languages.stats[name] /= languages.total)
languages.favorites = Object.entries(languages.stats).sort(([an, a], [bn, b]) => b - a).slice(0, 8).map(([name, value]) => ({name, value, color:languages.colors[name], x:0}))
for (let i = 1; i < languages.favorites.length; i++)
@@ -42,7 +44,6 @@
}
//Handle errors
catch (error) {
console.debug(error)
throw {error:{message:`An error occured`}}
throw {error:{message:"An error occured", instance:error}}
}
}

View File

@@ -8,9 +8,11 @@
//Repositories
const repositories = data.user.repositories.nodes.map(({name}) => name) ?? []
//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 => await rest.repos.getContributorsStats({owner:login, repo})))
//Compute changed lines
console.debug(`metrics/compute/${login}/plugins > lines > computing total diff`)
response.map(({data:repository}) => {
//Check if data are available
if (!Array.isArray(repository))
@@ -29,8 +31,7 @@
}
//Handle errors
catch (error) {
console.debug(error)
throw {error:{message:`An error occured`}}
throw {error:{message:"An error occured", instance:error}}
}
}

View File

@@ -56,13 +56,14 @@
//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) {
//Playlist mode
case "playlist":{
//Start puppeteer and navigate to playlist
console.debug(`metrics/compute/${login}/plugins > music > starting browser`)
const browser = await imports.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/compute/${login}/plugins > music > loaded ${await browser.version()}`)
console.debug(`metrics/compute/${login}/plugins > music > started ${await browser.version()}`)
const page = await browser.newPage()
console.debug(`metrics/compute/${login}/plugins > music > loading page`)
await page.goto(playlist)
@@ -103,7 +104,7 @@
if (Array.isArray(tracks)) {
//Tracks
console.debug(`metrics/compute/${login}/plugins > music > found ${tracks.length} tracks`)
console.debug(JSON.stringify(tracks))
console.debug(imports.util.inspect(tracks, {depth:Infinity, maxStringLength:256}))
//Shuffle tracks
tracks = imports.shuffle(tracks)
}
@@ -120,17 +121,18 @@
//Prepare credentials
const [client_id, client_secret, refresh_token] = token.split(",").map(part => part.trim())
if ((!client_id)||(!client_secret)||(!refresh_token))
throw {error:`Spotify token must contain client id/secret and refresh token`}
throw {error:{message:`Spotify token must contain client id/secret and refresh token`}}
//API call and parse tracklist
try {
//Request access token
console.debug(`metrics/compute/${login}/plugins > music > requesting access token with refresh token for spotify`)
console.debug(`metrics/compute/${login}/plugins > music > requesting access token with spotify refresh token`)
const {data:{access_token:access}} = await imports.axios.post("https://accounts.spotify.com/api/token",
`${new imports.url.URLSearchParams({grant_type:"refresh_token", refresh_token, client_id, client_secret})}`,
{headers:{"Content-Type":"application/x-www-form-urlencoded"}},
)
console.debug(`metrics/compute/${login}/plugins > music > got new access token`)
console.debug(`metrics/compute/${login}/plugins > music > got access token`)
//Retriev tracks
console.debug(`metrics/compute/${login}/plugins > music > querying spotify api`)
tracks = (await imports.axios(`https://api.spotify.com/v1/me/player/recently-played?limit=${limit}&after=${timestamp}`, {headers:{
"Accept":"application/json",
"Content-Type":"application/json",
@@ -143,8 +145,13 @@
}
//Handle errors
catch (error) {
if ((error.response?.status))
throw {error:{message:`API returned ${error.response.status}${error.response.data?.error_description ? ` (${error.response.data.error_description})` : ""}`}, ...raw}
if (error.isAxiosError) {
const status = error.response?.status
const description = error.response.data?.error_description ?? null
message = `API returned ${status}${description ? ` (${description})` : ""}`
error = error.response?.data ?? null
throw {error:{message, instance:error}, ...raw}
}
throw error
}
break
@@ -173,7 +180,6 @@
track.artwork = await imports.imgb64(track.artwork)
}
//Save results
console.debug(`metrics/compute/${login}/plugins > music > success`)
return {...raw, tracks}
}
//Unhandled error
@@ -183,7 +189,6 @@
catch (error) {
if (error.error?.message)
throw error
console.debug(error)
throw {error:{message:`An error occured`}}
throw {error:{message:"An error occured", instance:error}}
}
}

View File

@@ -15,29 +15,37 @@
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()
await Promise.all(["performance", "accessibility", "best-practices", "seo"].map(async category => {
const {score, title} = (await imports.axios.get(`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?category=${category}&url=${url}&key=${token}`)).data.lighthouseResult.categories[category]
console.debug(`metrics/compute/${login}/plugins > pagespeed > performing audit ${category}`)
const request = await imports.axios.get(`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?category=${category}&url=${url}&key=${token}`)
console.debug(request.data)
const {score, title} = request.data.lighthouseResult.categories[category]
scores.set(category, {score, title})
console.debug(`metrics/compute/${login}/plugins > pagespeed > ${category} audit performed`)
console.debug(`metrics/compute/${login}/plugins > pagespeed > performed audit ${category} (status code ${request.status})`)
}))
result.scores = [scores.get("performance"), scores.get("accessibility"), scores.get("best-practices"), scores.get("seo")]
//Detailed metrics
if (detailed)
Object.assign(result.metrics, ...(await imports.axios.get(`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?&url=${url}&key=${token}`)).data.lighthouseResult.audits.metrics.details.items)
//Integrity check
if (result.scores.filter(score => score).length < 4)
throw {error:{message:"Incomplete PageSpeed results"}, url}
if (detailed) {
console.debug(`metrics/compute/${login}/plugins > pagespeed > performing detailed audit`)
const request = await imports.axios.get(`https://www.googleapis.com/pagespeedonline/v5/runPagespeed?&url=${url}&key=${token}`)
console.debug(request.data)
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
}
//Handle errors
catch (error) {
if (error.response?.status)
throw {error:{message:`PageSpeed token error (code ${error.response.status})`}, url}
if (error.error?.message)
throw error
console.debug(error)
throw {error:{message:`An error occured`}}
let message = "An error occured"
if (error.isAxiosError) {
const status = error.response?.status
const description = error.response?.data?.error?.message?.match(/Lighthouse returned error: (?<description>[A-Z_]+)/)?.groups?.description ?? null
message = `API returned ${status}${description ? ` (${description})` : ""}`
error = error.response?.data ?? null
}
throw {error:{message, instance:error}}
}
}

View File

@@ -11,10 +11,12 @@
//Limit
limit = Math.max(1, Math.min(30, Number(limit)))
//Retrieve posts
console.debug(`metrics/compute/${login}/plugins > posts > processing with source ${source}`)
let posts = null
switch (source) {
//Dev.to
case "dev.to":{
console.debug(`metrics/compute/${login}/plugins > posts > querying api`)
posts = (await imports.axios.get(`https://dev.to/api/articles?username=${login}&state=fresh`)).data.map(({title, readable_publish_date:date}) => ({title, date}))
break
}
@@ -37,7 +39,6 @@
}
//Handle errors
catch (error) {
console.debug(error)
throw {error:{message:`An error occured`}}
throw {error:{message:"An error occured", instance:error}}
}
}

View File

@@ -10,6 +10,7 @@
//Limit
limit = Math.max(1, Math.min(100, Number(limit)))
//Retrieve contribution calendar from graphql api
console.debug(`metrics/compute/${login}/plugins > projects > querying api`)
const {user:{projects}} = await graphql(`
query Projects {
user(login: "${login}") {
@@ -31,6 +32,7 @@
`
)
//Iterate through projects and format them
console.debug(`metrics/compute/${login}/plugins > posts > processing ${projects.nodes.length} projects`)
const list = []
for (const project of projects.nodes) {
//Format date
@@ -52,7 +54,9 @@
}
//Handle errors
catch (error) {
console.debug(error)
throw {error:{message:`An error occured`}}
let message = "An error occured"
if (error.errors?.map(({type}) => type)?.includes("INSUFFICIENT_SCOPES"))
message = "Insufficient token rights"
throw {error:{message, instance:error}}
}
}

View File

@@ -14,10 +14,11 @@
//Limit
limit = Math.max(1, Math.min(20, Number(limit)))
//Start puppeteer and navigate to topics
console.debug(`metrics/compute/${login}/plugins > topics > searching starred topics`)
let topics = []
console.debug(`metrics/compute/${login}/plugins > topics > starting browser`)
const browser = await imports.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/compute/${login}/plugins > topics > loaded ${await browser.version()}`)
console.debug(`metrics/compute/${login}/plugins > topics > started ${await browser.version()}`)
const page = await browser.newPage()
//Iterate through pages
for (let i = 1; i <= 100; i++) {
@@ -32,17 +33,22 @@
description:li.querySelector(".f5").innerText,
icon:li.querySelector("img")?.src ?? null,
})))
console.debug(`metrics/compute/${login}/plugins > topics > extracted ${starred.length} starred topics`)
//Check if next page exists
if (!starred.length)
if (!starred.length) {
console.debug(`metrics/compute/${login}/plugins > topics > no more page to load`)
break
}
topics.push(...starred)
}
//Close browser
console.debug(`metrics/compute/${login}/plugins > music > closing browser`)
await browser.close()
//Shuffle topics
if (shuffle)
if (shuffle) {
console.debug(`metrics/compute/${login}/plugins > topics > shuffling topics`)
topics = imports.shuffle(topics)
}
//Limit topics
if (limit > 0) {
console.debug(`metrics/compute/${login}/plugins > topics > keeping only ${limit} topics`)
@@ -51,6 +57,7 @@
topics.push({name:`And ${removed.length} more...`, description:removed.map(({name}) => name).join(", "), icon:null})
}
//Convert icons to base64
console.debug(`metrics/compute/${login}/plugins > topics > loading artworks`)
for (const topic of topics) {
if (topic.icon) {
console.debug(`metrics/compute/${login}/plugins > topics > processing ${topic.name}`)
@@ -64,7 +71,6 @@
}
//Handle errors
catch (error) {
console.debug(error)
throw {error:{message:`An error occured`}}
throw {error:{message:"An error occured", instance:error}}
}
}

View File

@@ -8,9 +8,11 @@
//Repositories
const repositories = data.user.repositories.nodes.map(({name}) => name) ?? []
//Get views stats from repositories
console.debug(`metrics/compute/${login}/plugins > traffic > querying api`)
const views = {count:0, uniques:0}
const response = await Promise.all(repositories.map(async repo => await rest.repos.getViews({owner:login, repo})))
//Compute views
console.debug(`metrics/compute/${login}/plugins > traffic > computing stats`)
response.filter(({data}) => data).map(({data:{count, uniques}}) => (views.count += count, views.uniques += uniques))
//Format values
views.count = imports.format(views.count)
@@ -20,9 +22,9 @@
}
//Handle errors
catch (error) {
let message = "An error occured"
if (error.status === 403)
throw {error:{message:`Insufficient token rights`}}
console.debug(error)
throw {error:{message:`An error occured`}}
message = "Insufficient token rights"
throw {error:{message, instance:error}}
}
}

View File

@@ -14,7 +14,7 @@
console.debug(`metrics/compute/${login}/plugins > tweets > loading twitter profile (@${username})`)
const {data:{data:profile = null}} = await imports.axios.get(`https://api.twitter.com/2/users/by/username/${username}?user.fields=profile_image_url,verified`, {headers:{Authorization:`Bearer ${token}`}})
//Load tweets
console.debug(`metrics/compute/${login}/plugins > tweets > loading tweets`)
console.debug(`metrics/compute/${login}/plugins > tweets > querying api`)
const {data:{data:tweets = []}} = await imports.axios.get(`https://api.twitter.com/2/tweets/search/recent?query=from:${username}&tweet.fields=created_at&expansions=entities.mentions.username`, {headers:{Authorization:`Bearer ${token}`}})
//Load profile image
if (profile?.profile_image_url) {
@@ -50,7 +50,13 @@
}
//Handle errors
catch (error) {
console.debug(error)
throw {error:{message:`An error occured`}}
let message = "An error occured"
if (error.isAxiosError) {
const status = error.response?.status
const description = error.response?.data?.errors?.[0]?.message ?? null
message = `API returned ${status}${description ? ` (${description})` : ""}`
error = error.response?.data ?? null
}
throw {error:{message, instance:error}}
}
}

View File

@@ -1,6 +1,7 @@
//Imports
import fs from "fs"
import path from "path"
import util from "util"
/** Setup */
export default async function ({log = true} = {}) {
@@ -30,7 +31,7 @@
conf.settings.plugins = {}
conf.settings.plugins.base = {parts:["header", "activity", "community", "repositories", "metadata"]}
if (conf.settings.debug)
logger(conf.settings)
logger(util.inspect(conf.settings, {depth:Infinity, maxStringLength:256}))
//Load package settings
logger(`metrics/setup > load package.json`)

View File

@@ -13,7 +13,7 @@
+ (!!plugins.isocalendar)*192 + (plugins.isocalendar?.duration === 'full-year')*100
+ (!!plugins.gists)*68
+ (!!plugins.topics)*160
+ (!!plugins.projects)*22 + (plugins.projects?.list?.length ?? 0)*60
+ (!!plugins.projects)*22 + (plugins.projects?.list?.length ?? 0)*60 + (!!plugins.projects?.error)*22
+ (!!plugins.tweets)*64 + (plugins.tweets?.list?.length ?? 0)*90
+ Math.max(0, (((!!base.metadata)+(!!base.header)+((!!base.activity)||(!!base.community))+(!!base.repositories)+((!!plugins.habits))+(!!plugins.pagespeed)+(!!plugins.languages)+(!!plugins.music)+(!!plugins.posts)+(!!plugins.isocalendar)+(!!plugins.gists)+(!!plugins.topics)+(!!plugins.projects))-1))*4
%>">
@@ -774,7 +774,7 @@
<% if (base.metadata) { %>
<footer>
<span>These metrics <%= !computed.token.scopes.includes("repo") ? "does not include" : "includes" %> private contributions</span>
<span>These metrics <%= !computed.token.scopes.includes("repo") ? "does not include all" : "includes" %> private contributions<% if ((config.timezone?.name)&&(!config.timezone?.error)) { %>, timezone <%= config.timezone.name %><% } %></span>
<span>Last updated <%= new Date().toGMTString() %> with lowlighter/metrics@<%= meta.version %></span>
</footer>
<% } %>

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 73 KiB

View File

@@ -5,18 +5,36 @@
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, releases:0}}
const avatar = imports.imgb64(data.user.avatarUrl)
data.plugins = {}
console.debug(`metrics/compute/${login} > formatting common metrics`)
//Timezone config
if (q["config.timezone"]) {
const timezone = data.config.timezone = {name:q["config.timezone"], offset:0}
try {
timezone.offset = Number(new Date().toLocaleString("fr", {timeZoneName:"short", timeZone:timezone.name}).match(/UTC[+](?<offset>\d+)/)?.groups?.offset*60*60*1000) || 0
console.debug(`metrics/compute/${login} > timezone set to ${timezone.name} (${timezone.offset > 0 ? "+" : ""}${Math.round(timezone.offset/(60*60*1000))} hours)`)
} catch {
timezone.error = `Failed to use timezone "${timezone.name}"`
console.debug(`metrics/compute/${login} > failed to use timezone "${timezone.name}"`)
}
}
//Plugins
for (const name of Object.keys(imports.plugins)) {
pending.push((async () => {
try {
console.debug(`metrics/compute/${login}/plugins > ${name} > started`)
data.plugins[name] = await imports.plugins[name]({login, q, imports, data, computed, rest, graphql}, plugins[name])
console.debug(`metrics/compute/${login}/plugins > ${name} > completed (${data.plugins[name] !== null ? "success" : "skipped"})`)
}
catch (error) {
console.debug(`metrics/compute/${login}/plugins > ${name} > completed (error)`)
data.plugins[name] = error
}
finally {
return {name, result:data.plugins[name]}
const result = {name, result:data.plugins[name]}
console.debug(imports.util.inspect(result, {depth:Infinity, maxStringLength:256}))
return result
}
})())
}
@@ -30,14 +48,14 @@
computed.repositories.forks += repository.forkCount
//License
if (repository.licenseInfo)
computed.licenses.used[repository.licenseInfo.spdxId] = (computed.licenses.used[repository.licenseInfo.spdxId] || 0) + 1
computed.licenses.used[repository.licenseInfo.spdxId] = (computed.licenses.used[repository.licenseInfo.spdxId] ?? 0) + 1
}
//Total disk usage
computed.diskUsage = `${imports.bytes(data.user.repositories.totalDiskUsage*1000)}`
//Compute licenses stats
computed.licenses.favorite = Object.entries(computed.licenses.used).sort(([an, a], [bn, b]) => b - a).slice(0, 1).map(([name, value]) => name) || ""
computed.licenses.favorite = Object.entries(computed.licenses.used).sort(([an, a], [bn, b]) => b - a).slice(0, 1).map(([name, value]) => name) ?? ""
//Compute total commits
computed.commits += data.user.contributionsCollection.totalCommitContributions + data.user.contributionsCollection.restrictedContributionsCount
@@ -62,11 +80,11 @@
data.meta = {version:conf.package.version, author:conf.package.author}
//Debug flags
if (dflags.includes("--cakeday")||q["dflag.cakeday"]) {
if ((dflags.includes("--cakeday"))||(q["dflag.cakeday"])) {
console.debug(`metrics/compute/${login} > applying dflag --cakeday`)
computed.cakeday = true
}
if (dflags.includes("--hireable")||q["dflag.hireable"]) {
if ((dflags.includes("--hireable"))||(q["dflag.hireable"])) {
console.debug(`metrics/compute/${login} > applying dflag --hireable`)
data.user.isHireable = true
}