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

@@ -12,25 +12,15 @@ assignees: ''
👋 Hi there!
Thanks for using metrics and helping us to improve!
Please check the following before filling a bug report:
- It does not duplicate another existing issue
- Retry at least once to confirm that it was not some random error
Please:
- Check you're not duplicating an existing issue
- Provide a clear and concise description
For visual issues, mispelled words, etc. ...
- Provide a description of what you expected to happen
- Optionally add screenshots or additional context
For workflows errors:
- Retry at least once to confirm that error is reproductible
- Paste an excerpt of your workflow step and error logs
For runtime errors...
impacting action version:
- Paste an excerpt of:
- workflow step
- error logs
- direct GitHub links to the above
impacting web instance version:
- Paste used url query
For other issues...
- Just write a clear and concise description of what the bug is
For web instance errors:
- Paste used url query
-->

View File

@@ -12,16 +12,10 @@ assignees: ''
👋 Hi there!
Thanks for using metrics and helping us to improve!
Please check the following before filling a feature request:
- It does not duplicate another existing issue
Please:
- Check you're not duplicating an existing issue
- It is not mentioned in https://github.com/lowlighter/metrics/projects/1
For plugin requests...
- Add "plugin" label
- Optionally add screenshots or additional context
For other requests...
- Just write a clear and concise description about the feature request
- Optionally add screenshots or additional context
- Add correct labeling
- Provide a clear and concise description
-->

View File

@@ -12,21 +12,9 @@ assignees: ''
👋 Hi there!
Thanks for using metrics!
Before asking a question or for help, try to:
- Search for a similar already existing issue
- Check README.md documentation
- Note that most of documentation is collapsed by default, so be sure to open sections marked with "▶",
the solution to your problem may already be present!
For setup help...
- Be sure to create required secrets (METRICS_TOKEN and other plugins token when needed)
- Paste an excerpt of:
- workflow step
- error logs (if applicable)
- direct GitHub links to the above
- Optionally add screenshots or additional context
For other questions...
- Just write about what you want to talk!
Please:
- Search for similar issues
- Check documentation
- Provide a clear and concise description
-->

View File

@@ -3,7 +3,6 @@ repository:
- .github/**
- .gitignore
- .gitattributes
- README.md
- SECURITY.md
- LICENSE
- CONTRIBUTING.md
@@ -15,22 +14,17 @@ docker:
# Metrics source code editions
core:
- source/app/metrics.mjs
- source/app/setup.mjs
- source/app/metrics/**
action:
- source/app/action/**
- action.yml
web:
- source/app/web/**
- settings.example.json
plugins:
- source/plugins/**
queries:
- source/queries/**
templates:
- source/templates/**
tests:
- source/app/mocks.mjs
- source/app/mocks/**
- tests/**
dependencies:
- package.json

53
.github/index.mjs vendored Normal file
View File

@@ -0,0 +1,53 @@
//Imports
import ejs from "ejs"
import fs from "fs/promises"
import paths from "path"
import url from "url"
import sgit from "simple-git"
import metadata from "../source/app/metrics/metadata.mjs"
//Mode
const [mode = "dryrun"] = process.argv.slice(2)
console.log(`Mode: ${mode}`)
//Paths
const __metrics = paths.join(paths.dirname(url.fileURLToPath(import.meta.url)), "..")
const __action = paths.join(__metrics, "source/app/action")
const __web = paths.join(__metrics, "source/app/web")
const __readme = paths.join(__metrics, ".github/readme")
//Git setup
const git = sgit(__metrics)
const staged = new Set()
//Load plugins metadata
const {plugins, templates} = await metadata({log:false})
//Update generated files
async function update({source, output, options = {}}) {
//Regenerate file
console.log(`Generating ${output}`)
const content = await ejs.renderFile(source, {plugins, templates}, {async:true, ...options})
//Save result
const file = paths.join(__metrics, output)
await fs.writeFile(file, content)
//Add to git
staged.add(file)
}
//Rendering
await update({source:paths.join(__readme, "README.md"), output:"README.md", options:{root:__readme}})
await update({source:paths.join(__readme, "partials/documentation/plugins.md"), output:"source/plugins/README.md"})
await update({source:paths.join(__readme, "partials/documentation/templates.md"), output:"source/templates/README.md"})
await update({source:paths.join(__action, "action.yml"), output:"action.yml"})
await update({source:paths.join(__web, "settings.example.json"), output:"settings.example.json"})
//Commit and push
if (mode === "publish") {
await git
.addConfig("user.name", "GitHub Action")
.addConfig("user.email", "<>")
.add(...staged)
.commit("Auto regenerate files - [Skip GitHub Action]")
.push("origin", "master")
}

View File

@@ -3,38 +3,10 @@
👋 Hi there!
Thanks for contributing to metrics and helping us to improve!
Please check the following before opening a pull request:
- It does not duplicate another existing pull request
- It is not mentioned in https://github.com/lowlighter/metrics/projects/1
- If it is, ensure that maintainers are aware that you're working on this subject
Then, explain briefly what your pull request is about and link any related issues (if applicable) to help us keeping track.
For documentation updates....
- Check spelling before asking for a review
- Respect current formatting (check that your editions blends well with current state)
- Static images must be saved in /.github/readme/imgs and must be of width 1260px
- UI should always be set in English in screenshots
For new plugins...
- Ensure that you created:
- a plugin entrypoint named index.mjs in /source/plugins
- tests in /tests/metrics.test.js
- mocked data if needed (required for all APIs which requires a token or limited in requests)
- Ensure you updated:
- /source/app/action/index.mjs to support new plugin options and use correct typing
- /source/web/statics/* to support new plugin options
- /settings.example.json with new plugin name
- README.md to explain new plugin features
- Include a screenshot in your pull request description
- You can use `&config.output=png` option in your web instance for it
For all code editions...
- Ensure retro-compatibility with previous versions (
- (unless for unreleased features, for which breaking changes are allowed)
- Respect current formatting
- Prefers using appropriate single words for variables and plugins names
- Avoid using uppercases letters, brackets and semicolons when possible to avoid visual pollution
- Comments should be added before each "code paragraph" and are considered indent worthy
Please:
- Read CONTRIBUTING.md first
- Check you're not duplicating another existing pull request
- Add correct labeling
- Provide a clear and concise description
-->

7
.github/readme/README.md vendored Normal file
View File

@@ -0,0 +1,7 @@
# 📊 Metrics
![Build](https://github.com/lowlighter/metrics/workflows/Build/badge.svg)
<% for (const partial of ["introduction", "shared", "setup", "documentation", "references", "license"]) { -%>
<%- await include(`/partials/${partial}.md`) %>
<% } %>

View File

@@ -0,0 +1,5 @@
# 📚 Documentation
<% for (const partial of ["compatibility", "templates", "plugins", "organizations", "contributing"]) { %>
<%- await include(`/partials/documentation/${partial}.md`) -%>
<% } %>

View File

@@ -0,0 +1,15 @@
### 🧰 Template/plugin compatibily matrix
<table>
<tr>
<th nowrap="nowrap">Template\Plugin</th><%# -%>
<% for (const [plugin, {icon}] of Object.entries(plugins).filter(([key, value]) => (value)&&(!["core"].includes(key)))) { %>
<th nowrap="nowrap" align="center"><%= icon %></th><% } %>
</tr><%# -%>
<% for (const [template, {name, readme}] of Object.entries(templates).filter(([key, value]) => (value)&&(!["community"].includes(key)))) { %>
<tr>
<th nowrap="nowrap"><%= name %></th><%# -%>
<% for (const [plugin] of Object.entries(plugins).filter(([key, value]) => (value)&&(!["core"].includes(key)))) { %>
<th nowrap="nowrap" align="center" data-plugin="<%= plugin %>"><%= readme.compatibility[plugin] ? "✔️" : "❌" %></th><% } %>
</tr><% } %>
</table>

View File

@@ -0,0 +1,9 @@
## 💪 Customizing and contributing
Metrics is built to be easily customizable.
Fork this repository, switch used action from `lowlighter/metrics@latest` to your fork and start coding!
To suggest a new feature, report a bug or ask for help, fill an [issue](https://github.com/lowlighter/metrics/issues) describing it.
If you want to contribute, submit a [pull request](https://github.com/lowlighter/metrics/pulls).
Be sure to read [CONTRIBUTING.md](CONTRIBUTING.md) for more information about this.

View File

@@ -0,0 +1,48 @@
### 🏦 Organizations metrics
While metrics targets mainly user accounts, it's possible to render metrics for organization accounts.
![Metrics (organization account)](https://github.com/lowlighter/lowlighter/blob/master/metrics.organization.svg)
<details>
<summary>💬 Metrics for organizations</summary>
Setup is the same as for user accounts, though you'll need to add `read:org` scope, **whether you're member of target organization or not**.
![Add read:org scope to personal token](.github/readme/imgs/setup_token_org_read_scope.png)
You'll also need to set `user` option with your organization name.
If you're encounting errors and your organization is using single sign-on, try to [authorize your personal token](https://docs.github.com/en/free-pro-team@latest/github/authenticating-to-github/authorizing-a-personal-access-token-for-use-with-saml-single-sign-on).
Most of plugins supported by user accounts will work with organization accounts, but note that rendering metrics for organizations consume way more APIs requests.
To support private repositories, add full `repo` scope to your personal token.
#### Example workflow
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
token: ${{ secrets.METRICS_TOKEN }} # A personal token from an user account with read:org scope
committer_token: ${{ secrets.GITHUB_TOKEN }} # GitHub auto-generated token
user: organization-name # Organization name
```
</details>
<details>
<summary>💬 Organizations memberships for user accounts</summary>
Only public memberships can be displayed by metrics by default.
You can manage your membership visibility in the `People` tab of your organization:
![Publish organization membership](.github/readme/imgs/setup_public_membership_org.png)
For organization memberships, add `read:org` scope to your personal token.
![Add read:org scope to personal token](.github/readme/imgs/setup_token_org_read_scope.png)
</details>

View File

@@ -0,0 +1,7 @@
## 🧩 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:
<% for (const [plugin, {name}] of Object.entries(plugins).filter(([key, value]) => value)) { %>
* [<%= name %>](/source/plugins/<%= plugin %>/README.md)<%# -%>
<% } %>

View File

@@ -0,0 +1,7 @@
## 🖼️ Templates
Templates lets you change general appearance of rendered metrics.
See their respective documentation for more informations about how to setup them:
<% for (const [template, {name}] of Object.entries(templates).filter(([key, value]) => value)) { %>
* [<%= name %>](/source/templates/<%= template %>/README.md)<%# -%>
<% } %>

80
.github/readme/partials/introduction.md vendored Normal file
View File

@@ -0,0 +1,80 @@
Generate your metrics that you can embed everywhere, including your GitHub profile readme! It works for both user and organization accounts, and even for repositories!
<table>
<tr>
<th align="center">For user accounts</th>
<th align="center">For organization accounts</th>
</tr>
<tr>
<%- plugins.base.readme.demo?.replace(/<img src=/g, `<img alt="" width="400" src=`) %>
</tr>
</table>
<% {
let cell = 0
const elements = Object.entries(plugins).filter(([key, value]) => (value)&&(!["base", "core"].includes(key)))
if (elements.length%2)
elements.push(["", {}])
%>
<table>
<tr>
<th colspan="2" align="center">
<a href="source/plugins/README.md">🧩 <%= elements.length %> plugins</a>
</th>
</tr>
<% for (let i = 0; i < elements.length; i+=2) {
const cells = [["even", elements[i]], ["odd", elements[i+1]]]
for (const [cell, [plugin, {name, readme}]] of cells) {
if (cell === "even") {
-%>
<tr>
<% } %> <th><a href="source/plugins/<%= plugin %>/README.md"><%= name -%></a></th>
<% if (cell === "odd") {
-%> </tr>
<% }}
for (const [cell, [plugin, {name, readme}]] of cells) {
if (cell === "even") {
-%>
<tr>
<% } %> <%- readme.demo.replace(/<img src=/g, `<img alt="" width="400" src=`)?.split("\n")?.map((x, i) => i ? ` ${x}` : x)?.join("\n") %>
<% if (cell === "odd") {
-%> </tr>
<% }}} -%>
<tr>
<th colspan="2" align="center">
<a href="https://github.com/lowlighter/metrics/projects/1">More to come soon!</a>
</th>
</tr>
</table>
<% } %>
<% {
let cell = 0
const elements = Object.entries(templates).filter(([key, value]) => value)
if (elements.length%2)
elements.push(["", {}])
%>
<table>
<tr>
<th colspan="2" align="center">
<a href="source/templates/README.md">🖼️ <%= elements.length-1 %> templates</a>
</th>
</tr>
<% for (let i = 0; i < elements.length; i+=2) {
const cells = [["even", elements[i]], ["odd", elements[i+1]]]
for (const [cell, [template, {name, readme}]] of cells) {
if (cell === "even") {
-%>
<tr>
<% } %> <th><a href="source/templates/<%= template %>/README.md"><%= name -%></a></th>
<% if (cell === "odd") {
-%> </tr>
<% }}
for (const [cell, [template, {name, readme}]] of cells) {
if (cell === "even") {
-%>
<tr>
<% } %> <%- readme.demo.replace(/<img src=/g, `<img alt="" width="400" src=`)?.split("\n")?.map((x, i) => i ? ` ${x}` : x)?.join("\n") %>
<% if (cell === "odd") {
-%> </tr>
<% }}} -%>
</table>
<% } %>

6
.github/readme/partials/license.md vendored Normal file
View File

@@ -0,0 +1,6 @@
## 📜 License
```
MIT License
Copyright (c) 2020 lowlighter
```

15
.github/readme/partials/references.md vendored Normal file
View File

@@ -0,0 +1,15 @@
## 📖 Useful references
* [GitHub GraphQL API](https://docs.github.com/en/graphql)
* [GitHub GraphQL Explorer](https://docs.github.com/en/free-pro-team@latest/graphql/overview/explorer)
* [GitHub Rest API](https://docs.github.com/en/rest)
* [GitHub Octicons](https://github.com/primer/octicons)
* See [GitHub Logos and Usage](https://github.com/logos) for more information.
### ✨ Inspirations
* [anuraghazra/github-readme-stats](https://github.com/anuraghazra/github-readme-stats)
* [jstrieb/github-stats](https://github.com/jstrieb/github-stats)
* [ankurparihar/readme-pagespeed-insights](https://github.com/ankurparihar/readme-pagespeed-insights)
* [jasonlong/isometric-contributions](https://github.com/jasonlong/isometric-contributions)
* [jamesgeorge007/github-activity-readme](https://github.com/jamesgeorge007/github-activity-readme)

5
.github/readme/partials/setup.md vendored Normal file
View File

@@ -0,0 +1,5 @@
# 📜 How to use?
<% for (const partial of ["action", "shared", "web"]) { -%>
<%- await include(`/partials/setup/${partial}.md`) %>
<% } %>

113
.github/readme/partials/setup/action.md vendored Normal file
View File

@@ -0,0 +1,113 @@
## ⚙️ Using GitHub Action on your profile repository (~5 min setup)
Setup a GitHub Action which runs periodically and pushes your generated metrics image to your repository.
See all supported options in [action.yml](action.yml).
Assuming your username is `my-github-user`, you can then embed rendered metrics in your readme like below:
```markdown
<!-- If you're using "master" as default branch -->
![Metrics](https://github.com/my-github-user/my-github-user/blob/master/github-metrics.svg)
<!-- If you're using "main" as default branch -->
![Metrics](https://github.com/my-github-user/my-github-user/blob/main/github-metrics.svg)
```
<details>
<summary>💬 How to setup?</summary>
### 0. Setup your personal repository
Create a repository with the same name as your GitHub login (if it's not already done).
![Setup personal repository](.github/readme/imgs/setup_personal_repository.png)
Its `README.md` will be displayed on your user profile:
![GitHub Profile Example](.github/readme/imgs/example_github_profile.png)
### 1. Create a GitHub personal token
From the `Developer settings` of your account settings, select `Personal access tokens` to create a new token.
No additional scopes are needed for basic metrics, but you may have to grant additional scope depending on what features you're planning to use:
- `public_repo` scope for some plugins
- `read:org` scope for all organizations related metrics
- `repo` scope for all private repositories related metrics
![Setup a GitHub personal token](.github/readme/imgs/setup_personal_token.png)
A scope-less token 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)
If a plugin has not enough scopes to operate (and `plugins_errors_fatal` isn't enabled), it'll be reported in the rendering like below:
![Plugin error example](https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.error.svg)
### 2. Put your GitHub perosnal token in your repository secrets
Go to the `Settings` of your repository to create a new secret and paste your freshly generated GitHub token there.
![Setup a repository secret](.github/readme/imgs/setup_repository_secret.png)
### 3. Create a GitHub Action workflow in your repository
Create a new workflow from the `Actions` tab of your repository and paste the following:
```yaml
name: Metrics
on:
# Schedule updates (each hour)
schedule: [{cron: "0 * * * *"}]
# Lines below let you run workflow manually and on each commit (optional)
push: {branches: ["master", "main"]}
workflow_dispatch:
jobs:
github-metrics:
runs-on: ubuntu-latest
steps:
# See action.yml for all options
- uses: lowlighter/metrics@latest
with:
# Your GitHub token
token: ${{ secrets.METRICS_TOKEN }}
# GITHUB_TOKEN is a special auto-generated token restricted to current repository, which is used to push files in it
committer_token: ${{ secrets.GITHUB_TOKEN }}
```
See all supported options in [action.yml](action.yml).
Rendered metrics will be committed to your repository on each run.
![Action update example](.github/readme/imgs/example_action_update.png)
#### Choosing between `@latest`, `@master` or a fork
If you wish to use new features as they're being released, you can switch from `@latest` to `@master`.
As the latter is used as a development branch, jobs may fail from time to time (although we try to mitigate this).
When using a token with additional permissions, it is advised to fork this repository and use it instead to minimize security risks:
```yaml
- uses: my-github-username/metrics@master
```
In this case, please consider watching new releases to stay up-to-date and enjoy latest features!
`@latest` will be updated on each release soon after [Planned for next release](https://github.com/lowlighter/metrics/projects/1#column-12378679) is emptied. Metrics doesn't introduce breaking changes **from an user point of view** (i.e. your workflows will always be valid) so you can follow release cycles without worrying.
#### Examples workflows
Metrics displayed on this page are rendered from this [workflow](https://github.com/lowlighter/lowlighter/blob/master/.github/workflows/metrics.yml). You can check it for some code examples.
### 4. Embed link into your README.md
Update your README.md to embed your metrics:
```markdown
<!-- If you're using "master" as default branch -->
![Metrics](https://github.com/my-github-user/my-github-user/blob/master/github-metrics.svg)
<!-- If you're using "main" as default branch -->
![Metrics](https://github.com/my-github-user/my-github-user/blob/main/github-metrics.svg)
```
</details>

21
.github/readme/partials/setup/shared.md vendored Normal file
View File

@@ -0,0 +1,21 @@
## 💕 Using the shared instance (~1 min setup, but with limitations)
For convenience, you can use the shared instance available at [metrics.lecoq.io](https://metrics.lecoq.io) without any additional setup.
```markdown
![Metrics](https://metrics.lecoq.io/my-github-user)
```
This is mostly intended for previews, to enjoy all features consider using GitHub Action instead.
<details>
<summary>💬 Fair use</summary>
To ensure service availability, shared instance has a few limitations:
* Images are cached for 1 hour
* Rendered metrics **won't be updated** during this time window when queried
* You can manually update rendering againg your metrics on [metrics.lecoq.io](https://metrics.lecoq.io)
* There is a rate limiter enabled (it doesn't affect already cached metrics)
* Several plugins may not be available
</details>

140
.github/readme/partials/setup/web.md vendored Normal file
View File

@@ -0,0 +1,140 @@
## 🏗️ Deploying your own web instance (~15 min setup, depending on your sysadmin knowledge)
Setup a metrics instance on your server if you don't want to use GitHub Actions and [metrics.lecoq.io](https://metrics.lecoq.io).
See all supported options in [settings.example.json](settings.example.json).
Assuming your username is `my-github-user`, you can then embed rendered metrics in your readme like below:
```markdown
![Metrics](https://my-personal-domain.com/my-github-user)
```
<details>
<summary>💬 How to setup?</summary>
### 0. Prepare your server
You'll need a server with a recent version [NodeJS](https://nodejs.org) (see used version in [Dockerfile](Dockerfile#L1-L2)).
### 1. Create a GitHub personal token
From the `Developer settings` of your account settings, select `Personal access tokens` to create a new token.
No additional scopes are needed.
![Setup a GitHub personal token](.github/readme/imgs/setup_personal_token.png)
### 2. Install dependencies
Clone repository, install dependencies and copy configuration example to `settings.json`:
```shell
git clone https://github.com/lowlighter/metrics.git
cd metrics/
npm install --only=prod
cp settings.example.json settings.json
```
### 3. Configure your instance and start it
Edit `settings.json` to configure your instance.
```javascript
{
//GitHub API token
"token":"GITHUB_TOKEN",
//Other options...
}
```
See all supported options in [settings.example.json](settings.example.json).
If you plan to make your web instance public, it is advised to restrict its access thanks to rate limiter and access list.
Once you've finished configuring metrics, start your instance:
```shell
npm start
```
Access your server with provided port in `setting.json` from your browser to ensure everything is working.
### 4. Embed link into your README.md
Edit your repository readme and add your metrics image from your server domain:
```markdown
![Metrics](https://my-personal-domain.com/my-github-user)
```
### 6. (optional) Setup your instance as a service
To ensure that your instance will restart if it reboots or crashes, you should set it up as a service.
This is described below for Linux-like systems which support *systemd*.
Create a new service file `/etc/systemd/system/github_metrics.service` and paste the following after editing paths inside:
```ini
[Unit]
Description=Metrics
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/path/to/metrics
ExecStart=/usr/bin/node /path/to/metrics/index.mjs
[Install]
WantedBy=multi-user.target
```
Reload services, enable it, start it and check if it is up and running:
```shell
systemctl daemon-reload
systemctl enable github_metrics
systemctl start github_metrics
systemctl status github_metrics
```
</details>
<details>
<summary>⚠️ HTTP errors code</summary>
Following error codes may be encountered on web instance:
| Error code | Description |
| ------------------------- | -------------------------------------------------------------------------- |
| `400 Bad request` | Invalid query (e.g. unsupported template) |
| `403 Forbidden` | User not allowed in `restricted` users list |
| `404 Not found` | GitHub API did not found the requested user |
| `429 Too many requests` | Thrown when rate limiter is trigerred |
| `500 Internal error` | Server error while generating metrics images (check logs for more details) |
| `503 Service unavailable` | Maximum user capacity reached, only cached images can be accessed for now |
</details>
<details>
<summary>🔗 HTTP parameters</summary>
Most of options from [action.yml](action.yml) are actually supported by web instance, though syntax is slightly different.
All underscores (`_`) must be replaced by dots (`.`) and `plugin_` prefixes must be dropped.
For example, to configure pagespeed plugin you'd use the following:
```
https://my-personal-domain.com/my-github-user?pagespeed=1&pagespeed.detailed=1&pagespeed.url=https%3A%2F%2Fexample.com
```
Note that url parameters must be [encoded](https://developer.mozilla.org/fr/docs/Web/JavaScript/Reference/Objets_globaux/encodeURIComponent).
As for `base` content, which is enabled by default, sections are available through "`base.<section>`".
For example, to display only `repositories` section, use:
```
https://my-personal-domain.com/my-github-user?base=0&base.repositories=1
```
</details>

5
.github/readme/partials/shared.md vendored Normal file
View File

@@ -0,0 +1,5 @@
## 🦑 Interested to get your own?
Try it now at [metrics.lecoq.io](https://metrics.lecoq.io/) with your GitHub username!
Some plugins are not are not available at [metrics.lecoq.io](https://metrics.lecoq.io/), for a fully-featured experience use it as a [GitHub Action](https://github.com/marketplace/actions/github-metrics-as-svg-image) instead!

View File

@@ -73,11 +73,29 @@ jobs:
verify: yes
use_prebuilt_image: master
# Update plugins and template indexes, along with README.md
update-indexes:
name: Publish rebuilt metrics indexes
runs-on: ubuntu-latest
needs: [ action-master-test ]
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
steps:
- name: Checkout repository
uses: actions/checkout@v2
- name: Setup NodeJS
uses: actions/setup-node@v2
with:
node-version: 15
- name: Setup metrics
run: npm ci
- name: Publish rebuild metrics indexes
run: npm run index -- publish
# Build docker image from master and publish it to GitHub registry with release tag
docker-release:
name: Publish release to GitHub registry
runs-on: ubuntu-latest
needs: [ build, analyze, action-master-test ]
needs: [ build, analyze, action-master-test, update-indexes ]
if: github.event_name == 'push' && github.ref == 'refs/heads/master' && contains(github.event.head_commit.message, '[release]')
steps:
- name: Checkout repository

View File

@@ -101,13 +101,13 @@ Review below which contributions are accepted:
</tr>
<tr>
<td>🧱 Core</td>
<td><code>app/metrics.mjs</code>, <code>app/setup.mjs</code>, <code>Dockerfile</code>, <code>package.json</code> ...</td>
<td><code>app/metrics/</code>, <code>Dockerfile</code>, <code>package.json</code> ...</td>
<td>❌</td>
<td>⭕</td>
</tr>
<tr>
<td>🗃️ Repository</td>
<td><code>.github</code>, <code>LICENSE</code>, <code>CONTRIBUTING.md</code>, ...</td>
<td><code>.github/</code>, <code>LICENSE</code>, <code>CONTRIBUTING.md</code>, ...</td>
<td>❌</td>
<td>❌</td>
</tr>
@@ -116,11 +116,11 @@ Review below which contributions are accepted:
**Legend**
* ✔️: Contributions welcomed!
* ⭕: Contributions welcomed, but must be discussed first with a maintainer
* ❌: Only maintainers can edit these files
* ❌: Only maintainers can manage these files
Before working on something, ensure that it isn't listed in [In progress](https://github.com/lowlighter/metrics/projects/1#column-12158618) and that no open pull requests (including drafts) already implement what you want to do.
If it's listed in [Roadmap and todos](https://github.com/lowlighter/metrics/projects/1) be sure to let maintainers that you're working on it. As metrics remains a side projects, things being working can change from one day to another.
If it's listed in [Roadmap and todos](https://github.com/lowlighter/metrics/projects/1) be sure to let maintainers that you're working on it. As metrics remains a side project, things being working on can change from one day to another.
If you're unsure, always open an issue to obtain insights and feedback 🙂
@@ -129,42 +129,43 @@ Metrics is designed to be highly customizable, so you can always decide to gener
</details>
<details>
<summary>🗂️ Project structure</summary>
This section explain how metrics is structured.
**Metrics generator**
* `source/app/setup.mjs` contains configuration setup
* `source/app/mocks.mjs` contains mocked data used for tests
* `source/app/metrics.mjs` contains the metrics renderer
* `source/plugins/*` contains source code of plugins
* `source/queries/*` contains GraphQL queries
* `source/templates/*` contains templates files
* `source/templates/*/image.svg` contains the image template used to render metrics
* `source/templates/*/style.css` contains the style used to render metrics
* `source/templates/*/fonts.css` contains additional fonts used to render metrics
* `source/templates/*/template.mjs` contains the code used to prepare metrics data before rendering
**Web instance**
* `source/app/web/index.mjs` contains metrics web instance entry point
* `source/web/instance.mjs` contains metrics web instance source code
* `source/app/web/statics/*` contains metrics web instance static files
* `settings.example.json` contains metrics web instance settings example
**GitHub action**
* `action.yml` contains GitHub action descriptor
* `source/app/action/index.mjs` contains GitHub action source code
**Others**
* `tests/metrics.test.js` contains tests
* `Dockerfile` contains the docker instructions to build metrics image
* `package.json` contains dependencies lists of metrics along with command line aliases
* `source/app/metrics/` contains core metrics files
* `source/app/action/` contains GitHub action files
* `index.mjs` contains GitHub action entry point
* `action.yml` contains GitHub action descriptor
* `source/app/web/` contains web instance files
* `index.mjs` contains web instance entry point
* `instance.mjs` contains web instance source code
* `settings.example.json` contains web instance settings example
* `statics/` contains web instance static files
* `app.js` contains web instance client source code
* `app.placeholder.js` contains web instance placeholder mocked data
* `source/app/mocks/` contains mocked data files
* `api/` contains mocked api data
* `axios/` contains external REST APIs mocked data
* `github/` contains mocked GitHub api data
* `index.mjs` contains mockers
* `source/plugins/` contains source code of plugins
* `README.md` contains plugin documentation
* `metadata.yml` contains plugin metadata
* `index.mjs` contains plugin source code
* `queries/` contains plugin GraphQL queries
* `source/templates/` contains templates files
* `README.md` contains template documentation
* `image.svg` contains template image used to render metrics
* `style.css` contains style used to render metrics
* `fonts.css` contains additional fonts used to render metrics
* `template.mjs` contains template source code
* `tests/` contains tests
* `testscases.js` contains tests case
* `metrics.test.js` contains metrics testers
* `Dockerfile` contains docker instructions used to build metrics image
* `package.json` contains dependencies and command line aliases
</details>
@@ -197,12 +198,12 @@ Below is a list of used packages.
* To scrap the web
* [libxmljs/libxmljs](https://github.com/libxmljs/libxmljs)
* To test and verify SVG validity
* [marak/colors.js](https://github.com/marak/colors.js)
* To print colors in console
* [facebook/jest](https://github.com/facebook/jest) and [nodeca/js-yaml](https://github.com/nodeca/js-yaml)
* For unit testing
* [marak/faker.js](https://github.com/marak/Faker.js/)
* [marak/faker.js](https://github.com/marak/Faker.js)
* For mocking data
* [steveukx/git-js](https://github.com/steveukx/git-js)
* For simple git operations
</details>
@@ -212,18 +213,9 @@ Below is a list of used packages.
Templates requires you to be comfortable with HTML, CSS and JavaScript ([EJS](https://github.com/mde/ejs) flavored).
Metrics does not really accept contributions on [default templates](https://github.com/lowlighter/metrics/tree/master/source/templates) to keep consistency and avoid bloating main repository with lots of templates, but fear not! It's really easy to create your own template.
Metrics does not really accept contributions on [default templates](https://github.com/lowlighter/metrics/tree/master/source/templates) in order to avoid bloating main repository with a lot of templates, but fear not! Users will still be able to use your custom templates thanks to [community templates](source/templates/community)!
If you make something awesome, don't hesistate to share it so user can use it!
Assuming you created a new template named `your-custom-template`, other users will be able to use this by using the following in their workflow:
```yaml
- uses: lowlighter/metrics@master
with:
# ... other options
template: "@your-custom-template"
setup_community_templates: your-github-login/metrics@master:your-custom-template
```
If you make something awesome, don't hesistate to share it!
<details>
<summary>💬 Creating a new template from scratch</summary>
@@ -231,6 +223,7 @@ Assuming you created a new template named `your-custom-template`, other users wi
Find a cool name for your template and create an eponym folder in [`source/templates`](https://github.com/lowlighter/metrics/tree/master/source/templates).
Then, you'll need to create the following files:
- `README.md` will contain template description and documentation
- `image.svg` will contain the base render structure of your template
- `partials/` is a folder that'll contain parts of your template (called "partials")
- `partials/_.json` is a JSON array which lists your partials (these will be displayed in the same order as listed, unless if overriden by user with `config_order` option)
@@ -246,6 +239,36 @@ Note that by default, `template.mjs` is skipped when using official release with
</details>
<details>
<summary>💬 Creating a <code>README.md</code></summary>
Your `README.md` will document your template and explain how it works.
It must contain at least the following:
```markdown
### 📕 My custom template
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.terminal.svg">
<img width="900" height="1" alt="">
</td>
</table>
#### Examples workflows
'''yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
setup_community_templates: user/metrics@master:template
template: "@template"
'''
```
</details>
<details>
<summary>💬 Creating <code>image.svg</code></summary>
@@ -296,17 +319,17 @@ Basically, you can use JavaScript statements in templating tags (`<% %>`) to dis
This is actually not recommended because it drastically increases the size of generated metrics, but it should also make your rendering more consistant. The trick is to actually restrict the charset used to keep file size small.
Below is a simplified process on how to generate base64 encoded fonts to use in metrics:
1. Find a font on [fonts.google.com](https://fonts.google.com/)
- Select regular, bold, italic and bold+italic fonts
- Open `embed` tab and extract the `href`
2. Open extracted `href` and append `&text=` params with used characters from SVG
- e.g. `&text=%26%27"%7C%60%5E%40°%3F!%23%24%25()*%2B%2C-.%2F0123456789%3A%3B<%3D>ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5D_abcdefghijklmnopqrstuvwxyz%7B%7D~─└├▇□✕`
3. Download each font file from url links from the generated stylesheet
4. Convert them into base64 with `woff` extension on [transfonter.org]https://transfonter.org/) and download archive
5. Extract archive and copy the content of the generated stylesheet to `fonts.css`
6. Update your template
- Include `<defs><style><%= fonts %></style></defs>` to your `image.svg`
- Edit your `style.css` to use yout new font
- 1. Find a font on [fonts.google.com](https://fonts.google.com/)
- Select regular, bold, italic and bold+italic fonts
- Open `embed` tab and extract the `href`
- 2. Open extracted `href` and append `&text=` params with used characters from SVG
- e.g. `&text=%26%27"%7C%60%5E%40°%3F!%23%24%25()*%2B%2C-.%2F0123456789%3A%3B<%3D>ABCDEFGHIJKLMNOPQRSTUVWXYZ%5B%5D_abcdefghijklmnopqrstuvwxyz%7B%7D~─└├▇□✕`
- 3. Download each font file from url links from the generated stylesheet
- 4. Convert them into base64 with `woff` extension on [transfonter.org]https://transfonter.org/) and download archive
- 5. Extract archive and copy the content of the generated stylesheet to `fonts.css`
- 6. Update your template
- Include `<defs><style><%= fonts %></style></defs>` to your `image.svg`
- Edit your `style.css` to use yout new font
</details>
@@ -393,7 +416,7 @@ Plugins are autoloaded so you do not need to do anything special to register the
For GitHub related data, always try to use their [GraphQL API](https://docs.github.com/en/graphql) or their [REST API](https://docs.github.com/en/rest) when possible. Use `puppeteer` in last resort.
When using GraphQL API, `queries` object autoloads queries from [source/queries](https://github.com/lowlighter/metrics/tree/master/source/queries) and will replace all strings prefixed by a dollar sign (`$`) with eponym variables.
When using GraphQL API, `queries` object autoloads queries from your plugin `queries` directory and will replace all strings prefixed by a dollar sign (`$`) with eponym variables.
For example:
```js
@@ -481,26 +504,129 @@ http://localhost:3000/your-github-login?base=0&your-plugin-name=1
</details>
<details>
<summary>💬 Registering plugin options inputs</summary>
<summary>💬 Registering plugin options in <code>metadata.yml</code></summary>
🚧 This section is not available yet
`metadata.yml` is a special file that will be used to parse user inputs and to generate final `action.yml`
```yaml
name: "🧩 Your plugin name"
# Estimate of how many GitHub requests will be used
cost: N/A
# Supported modes
supports:
- user
- organization
- repository
# Inputs list
inputs:
# Enable or disable plugin
plugin_custom:
description: Your custom plugin
type: boolean
default: no
```
The following types are supported:
```yaml
string:
type: string
select:
type: string
values:
- allowed-value-1
- allowed-value-2
- ...
boolean:
type: boolean
number:
type: number
ranged:
type: number
min: 0
max: 100
array:
type: array
format: comma-separated
array_select:
type: array
format: comma-separated
values:
- allowed-value-1
- allowed-value-2
- ...
json:
type: json
```
</details>
<details>
<summary>💬 Create mocked data and tests</summary>
🚧 This section is not available yet
Creating tests for your plugin ensure that external changes don't break it.
You can define your tests cases in [`testscases.js`](/tests/testscases.js), which will automatically test your plugin with:
- Metrics action
- Metrics web instance
- Metrics web instance placeholder (rendered by browser)
As most of APIs (including GitHub) usually have a rate-limit to ensure quality of their service.
To bypass these restrictions but still perform tests, you must mock their data which simulates APIs call returns.
Add them in [`source/app/mocks/api/`](/source/app/mocks/api) folder.
If you're using `axios` or GitHub GraphQL API, these files are autoloaded so you just need to create new functions (see other mocked data for examples).
If you're using GitHub REST API, you'll also need to edit [`source/app/mocks/index.mjs`](/source/app/mocks/index.mjs) to add a new proxied method.
</details>
<details>
<summary>💬 Updating README.md</summary>
<summary>💬 Creating a <code>README.md</code></summary>
🚧 This section is not available yet
Your `README.md` will document your plugin and explain how it works.
It must contain at least the following:
```markdown
### 🧩 Your plugin name
<table>
<td align="center">
<img src="">
<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
'''
```
Note that you **must** keep `<table>` tags as these will be extracted to autogenerated global `README.md` with your example.
</details>
</details>
___
Written by [lowlighter](https://github.com/lowlighter)

View File

@@ -27,7 +27,9 @@ RUN chmod +x /metrics/source/app/action/index.mjs \
&& apt-get install -y python3 \
# Install node modules
&& cd /metrics \
&& npm ci
&& npm ci \
# Rebuild indexes
&& npm run index
# Environment variables
ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD true

1912
README.md

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

191
package-lock.json generated
View File

@@ -894,18 +894,46 @@
}
}
},
"@octokit/auth-token": {
"version": "2.4.4",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.4.tgz",
"integrity": "sha512-LNfGu3Ro9uFAYh10MUZVaT7X2CnNm2C8IDQmabx+3DygYIQjs9FwzFAHN/0t6mu5HEPhxcb1XOuxdpY82vCg2Q==",
"@kwsites/file-exists": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
"requires": {
"@octokit/types": "^6.0.0"
"debug": "^4.1.1"
},
"dependencies": {
"debug": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"requires": {
"ms": "2.1.2"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}
}
},
"@kwsites/promise-deferred": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw=="
},
"@octokit/auth-token": {
"version": "2.4.5",
"resolved": "https://registry.npmjs.org/@octokit/auth-token/-/auth-token-2.4.5.tgz",
"integrity": "sha512-BpGYsPgJt05M7/L/5FoE1PiAbdxXFZkX/3kDYcsvd1v6UhlnE5e96dTDr0ezX/EFwciQxf3cNV0loipsURU+WA==",
"requires": {
"@octokit/types": "^6.0.3"
}
},
"@octokit/core": {
"version": "3.2.4",
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.2.4.tgz",
"integrity": "sha512-d9dTsqdePBqOn7aGkyRFe7pQpCXdibSJ5SFnrTr0axevObZrpz3qkWm7t/NjYv5a66z6vhfteriaq4FRz3e0Qg==",
"version": "3.2.5",
"resolved": "https://registry.npmjs.org/@octokit/core/-/core-3.2.5.tgz",
"integrity": "sha512-+DCtPykGnvXKWWQI0E1XD+CCeWSBhB6kwItXqfFmNBlIlhczuDPbg+P6BtLnVBaRJDAjv+1mrUJuRsFSjktopg==",
"requires": {
"@octokit/auth-token": "^2.4.4",
"@octokit/graphql": "^4.5.8",
@@ -916,56 +944,56 @@
}
},
"@octokit/endpoint": {
"version": "6.0.10",
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.10.tgz",
"integrity": "sha512-9+Xef8nT7OKZglfkOMm7IL6VwxXUQyR7DUSU0LH/F7VNqs8vyd7es5pTfz9E7DwUIx7R3pGscxu1EBhYljyu7Q==",
"version": "6.0.11",
"resolved": "https://registry.npmjs.org/@octokit/endpoint/-/endpoint-6.0.11.tgz",
"integrity": "sha512-fUIPpx+pZyoLW4GCs3yMnlj2LfoXTWDUVPTC4V3MUEKZm48W+XYpeWSZCv+vYF1ZABUm2CqnDVf1sFtIYrj7KQ==",
"requires": {
"@octokit/types": "^6.0.0",
"@octokit/types": "^6.0.3",
"is-plain-object": "^5.0.0",
"universal-user-agent": "^6.0.0"
}
},
"@octokit/graphql": {
"version": "4.5.8",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.5.8.tgz",
"integrity": "sha512-WnCtNXWOrupfPJgXe+vSmprZJUr0VIu14G58PMlkWGj3cH+KLZEfKMmbUQ6C3Wwx6fdhzVW1CD5RTnBdUHxhhA==",
"version": "4.5.9",
"resolved": "https://registry.npmjs.org/@octokit/graphql/-/graphql-4.5.9.tgz",
"integrity": "sha512-c+0yofIugUNqo+ktrLaBlWSbjSq/UF8ChAyxQzbD3X74k1vAuyLKdDJmPwVExUFSp6+U1FzWe+3OkeRsIqV0vg==",
"requires": {
"@octokit/request": "^5.3.0",
"@octokit/types": "^6.0.0",
"@octokit/types": "^6.0.3",
"universal-user-agent": "^6.0.0"
}
},
"@octokit/openapi-types": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-2.3.1.tgz",
"integrity": "sha512-KTzpRDT07euvbBYbPs121YDqq5DT94nBDFIyogsDhOnWL8yDCHev6myeiPTgS+VLmyUbdNCYu6L/gVj+Bd1q8Q=="
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/@octokit/openapi-types/-/openapi-types-3.3.0.tgz",
"integrity": "sha512-s3dd32gagPmKaSLNJ9aPNok7U+tl69YLESf6DgQz5Ml/iipPZtif3GLvWpNXoA6qspFm1LFUZX+C3SqWX/Y/TQ=="
},
"@octokit/plugin-paginate-rest": {
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.7.1.tgz",
"integrity": "sha512-dUsxsEIrBqhlQNfXRhMhXOTQi0SSG38+QWcPGO226HFPFJk44vWukegHfMG3496vLv9T2oT7IuAGssGpcUg5bQ==",
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-2.9.0.tgz",
"integrity": "sha512-XxbOg45r2n/2QpU6hnGDxQNDRrJ7gjYpMXeDbUCigWTHECmjoyFLizkFO2jMEtidMkfiELn7AF8GBAJ/cbPTnA==",
"requires": {
"@octokit/types": "^6.3.1"
"@octokit/types": "^6.6.0"
}
},
"@octokit/plugin-request-log": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.2.tgz",
"integrity": "sha512-oTJSNAmBqyDR41uSMunLQKMX0jmEXbwD1fpz8FG27lScV3RhtGfBa1/BBLym+PxcC16IBlF7KH9vP1BUYxA+Eg=="
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@octokit/plugin-request-log/-/plugin-request-log-1.0.3.tgz",
"integrity": "sha512-4RFU4li238jMJAzLgAwkBAw+4Loile5haQMQr+uhFq27BmyJXcXSKvoQKqh0agsZEiUlW6iSv3FAgvmGkur7OQ=="
},
"@octokit/plugin-rest-endpoint-methods": {
"version": "4.5.2",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.5.2.tgz",
"integrity": "sha512-JXoDIh+QnzFb6C5ZqIcUzDkn1fLrxawi98ZbvYb9s7Z2CJLITUWpbTAxSgseczEho18pYhamEBRR/h3o3HIXJQ==",
"version": "4.9.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.9.0.tgz",
"integrity": "sha512-EAr2epvY8JfXSi/cdMsyyfBctdKkolDH7xSgu3MKBqPRm0WfQ2QvI050jz61XZXoVK3ZgrhdMCyd1GgOFz7CSw==",
"requires": {
"@octokit/types": "^6.3.2",
"@octokit/types": "^6.6.0",
"deprecation": "^2.3.1"
}
},
"@octokit/request": {
"version": "5.4.12",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.4.12.tgz",
"integrity": "sha512-MvWYdxengUWTGFpfpefBBpVmmEYfkwMoxonIB3sUGp5rhdgwjXL1ejo6JbgzG/QD9B/NYt/9cJX1pxXeSIUCkg==",
"version": "5.4.13",
"resolved": "https://registry.npmjs.org/@octokit/request/-/request-5.4.13.tgz",
"integrity": "sha512-WcNRH5XPPtg7i1g9Da5U9dvZ6YbTffw9BN2rVezYiE7couoSyaRsw0e+Tl8uk1fArHE7Dn14U7YqUDy59WaqEw==",
"requires": {
"@octokit/endpoint": "^6.0.1",
"@octokit/request-error": "^2.0.0",
@@ -978,43 +1006,43 @@
}
},
"@octokit/request-error": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.0.4.tgz",
"integrity": "sha512-LjkSiTbsxIErBiRh5wSZvpZqT4t0/c9+4dOe0PII+6jXR+oj/h66s7E4a/MghV7iT8W9ffoQ5Skoxzs96+gBPA==",
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@octokit/request-error/-/request-error-2.0.5.tgz",
"integrity": "sha512-T/2wcCFyM7SkXzNoyVNWjyVlUwBvW3igM3Btr/eKYiPmucXTtkxt2RBsf6gn3LTzaLSLTQtNmvg+dGsOxQrjZg==",
"requires": {
"@octokit/types": "^6.0.0",
"@octokit/types": "^6.0.3",
"deprecation": "^2.0.0",
"once": "^1.4.0"
}
},
"@octokit/rest": {
"version": "18.0.12",
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.12.tgz",
"integrity": "sha512-hNRCZfKPpeaIjOVuNJzkEL6zacfZlBPV8vw8ReNeyUkVvbuCvvrrx8K8Gw2eyHHsmd4dPlAxIXIZ9oHhJfkJpw==",
"version": "18.0.15",
"resolved": "https://registry.npmjs.org/@octokit/rest/-/rest-18.0.15.tgz",
"integrity": "sha512-MBlZl0KeuvFMJ3210hG5xhh/jtYmMDLd5WmO49Wg4Rfg0odeivntWAyq3KofJDP2G8jskCaaOaZBKo0TeO9tFA==",
"requires": {
"@octokit/core": "^3.2.3",
"@octokit/plugin-paginate-rest": "^2.6.2",
"@octokit/plugin-request-log": "^1.0.2",
"@octokit/plugin-rest-endpoint-methods": "4.4.1"
"@octokit/plugin-rest-endpoint-methods": "4.8.0"
},
"dependencies": {
"@octokit/plugin-rest-endpoint-methods": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.4.1.tgz",
"integrity": "sha512-+v5PcvrUcDeFXf8hv1gnNvNLdm4C0+2EiuWt9EatjjUmfriM1pTMM+r4j1lLHxeBQ9bVDmbywb11e3KjuavieA==",
"version": "4.8.0",
"resolved": "https://registry.npmjs.org/@octokit/plugin-rest-endpoint-methods/-/plugin-rest-endpoint-methods-4.8.0.tgz",
"integrity": "sha512-2zRpXDveJH8HsXkeeMtRW21do8wuSxVn1xXFdvhILyxlLWqGQrdJUA1/dk5DM7iAAYvwT/P3bDOLs90yL4S2AA==",
"requires": {
"@octokit/types": "^6.1.0",
"@octokit/types": "^6.5.0",
"deprecation": "^2.3.1"
}
}
}
},
"@octokit/types": {
"version": "6.3.2",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.3.2.tgz",
"integrity": "sha512-H6cbnDumWOQJneyNKCBWgnktRqTWcEm6gq2cIS3frtVgpCqB8zguromnjIWJW375btjnxwmbYBTEAEouruZ2Yw==",
"version": "6.6.0",
"resolved": "https://registry.npmjs.org/@octokit/types/-/types-6.6.0.tgz",
"integrity": "sha512-nmFoU3HCbw1AmnZU/eto2VvzV06+N7oAqXwMmAHGlNDF+KFykksh/VlAl85xc1P5T7Mw8fKYvXNaImNHCCH/rg==",
"requires": {
"@octokit/openapi-types": "^2.3.1",
"@octokit/openapi-types": "^3.3.0",
"@types/node": ">= 8"
}
},
@@ -1111,9 +1139,9 @@
}
},
"@types/node": {
"version": "14.14.21",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.21.tgz",
"integrity": "sha512-cHYfKsnwllYhjOzuC5q1VpguABBeecUp24yFluHpn/BQaVxB1CuQ1FSRZCzrPxrkIfWISXV2LbeoBthLWg0+0A=="
"version": "14.14.22",
"resolved": "https://registry.npmjs.org/@types/node/-/node-14.14.22.tgz",
"integrity": "sha512-g+f/qj/cNcqKkc3tFqlXOYjrmZA+jNBiDzbP3kH+B+otKFqAdPgVTGP1IeKRdMml/aE69as5S4FqtxAbl+LaMw=="
},
"@types/normalize-package-data": {
"version": "2.4.0",
@@ -1877,11 +1905,6 @@
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz",
"integrity": "sha1-p9BVi9icQveV3UIyj3QIMcpTvCU="
},
"colors": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/colors/-/colors-1.4.0.tgz",
"integrity": "sha512-a+UqTh4kgZg/SlGvfbzDHpgRu7AAQOmmqRHJnxhRZICKFUT91brVhNNt58CMWU9PsBbv3PDCZUHbVxuDiH2mtA=="
},
"combined-stream": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz",
@@ -2329,9 +2352,9 @@
}
},
"entities": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz",
"integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w=="
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz",
"integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A=="
},
"error-ex": {
"version": "1.3.2",
@@ -2704,10 +2727,9 @@
"dev": true
},
"faker": {
"version": "5.1.0",
"resolved": "https://registry.npmjs.org/faker/-/faker-5.1.0.tgz",
"integrity": "sha512-RrWKFSSA/aNLP0g3o2WW1Zez7/MnMr7xkiZmoCfAGZmdkDQZ6l2KtuXHN5XjdvpRjDl8+3vf+Rrtl06Z352+Mw==",
"dev": true
"version": "5.2.0",
"resolved": "https://registry.npmjs.org/faker/-/faker-5.2.0.tgz",
"integrity": "sha512-UlrF1NNRIdzEPtBcy5l8JTlnXQZdz+4pQc3v2TAVocW39nnczCNQ0g0CBKgPGISJPzA2DqJVN1kdr+FCRFdN5g=="
},
"fast-deep-equal": {
"version": "3.1.3",
@@ -2785,9 +2807,9 @@
}
},
"follow-redirects": {
"version": "1.13.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.1.tgz",
"integrity": "sha512-SSG5xmZh1mkPGyKzjZP8zLjltIfpW32Y5QpdNJyjcfGxK3qo3NDDkZOZSFiGn1A6SclQxY9GzEwAHQ3dmYRWpg=="
"version": "1.13.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.13.2.tgz",
"integrity": "sha512-6mPTgLxYm3r6Bkkg0vNM0HTjfGrOEtsfbhagQvbxDEsEkpNhw582upBaoRZylzen6krEmxXJgt9Ju6HiI4O7BA=="
},
"for-in": {
"version": "1.0.2",
@@ -2928,9 +2950,9 @@
"dev": true
},
"get-intrinsic": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.0.2.tgz",
"integrity": "sha512-aeX0vrFm21ILl3+JpFFRNe9aUvp6VFZb2/CTbgLb8j75kOhvoNYjt9d8KA/tJG4gSo8nzEDedRl0h7vDmBYRVg==",
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.1.0.tgz",
"integrity": "sha512-M11rgtQp5GZMZzDL7jLTNxbDfurpzuau5uqRWDPvlHjfvg3TdScAZo96GLvhMjImrmR8uAt0FS2RLoMrfWGKlg==",
"requires": {
"function-bind": "^1.1.1",
"has": "^1.0.3",
@@ -4833,7 +4855,6 @@
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.0.0.tgz",
"integrity": "sha512-pqon0s+4ScYUvX30wxQi3PogGFAlUyH0awepWvwkj4jD4v+ova3RiYw8bmA6x2rDrEaj8i/oWKoRxpVNW+Re8Q==",
"dev": true,
"requires": {
"argparse": "^2.0.1"
},
@@ -4841,8 +4862,7 @@
"argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"dev": true
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="
}
}
},
@@ -6460,6 +6480,31 @@
"integrity": "sha512-VUJ49FC8U1OxwZLxIbTTrDvLnf/6TDgxZcK8wxR8zs13xpx7xbG60ndBlhNrFi2EMuFRoeDoJO7wthSLq42EjA==",
"dev": true
},
"simple-git": {
"version": "2.31.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-2.31.0.tgz",
"integrity": "sha512-/+rmE7dYZMbRAfEmn8EUIOwlM2G7UdzpkC60KF86YAfXGnmGtsPrKsym0hKvLBdFLLW019C+aZld1+6iIVy5xA==",
"requires": {
"@kwsites/file-exists": "^1.1.1",
"@kwsites/promise-deferred": "^1.1.1",
"debug": "^4.3.1"
},
"dependencies": {
"debug": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz",
"integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==",
"requires": {
"ms": "2.1.2"
}
},
"ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
}
}
},
"sisteransi": {
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz",

View File

@@ -6,7 +6,8 @@
"scripts": {
"start": "node source/app/web/index.mjs",
"test": "npx jest",
"upgrade": "npm install @actions/core@latest @actions/github@latest @octokit/graphql@latest @octokit/rest@latest axios@latest colors@latest compression@latest ejs@latest express@latest express-rate-limit@latest image-to-base64@latest memory-cache@latest prismjs@latest puppeteer@latest svgo@latest vue@latest faker@latest jest@latest js-yaml@latest libxmljs@latest"
"index": "node .github/index.mjs",
"upgrade": "npm install @actions/core@latest @actions/github@latest @octokit/graphql@latest @octokit/rest@latest axios@latest compression@latest ejs@latest express@latest express-rate-limit@latest faker@latest image-to-base64@latest js-yaml@latest memory-cache@latest prismjs@latest puppeteer@latest svgo@latest vue@latest jest@latest libxmljs@latest"
},
"repository": {
"type": "git",
@@ -21,26 +22,26 @@
"dependencies": {
"@actions/core": "^1.2.6",
"@actions/github": "^4.0.0",
"@octokit/graphql": "^4.5.8",
"@octokit/rest": "^18.0.12",
"@octokit/graphql": "^4.5.9",
"@octokit/rest": "^18.0.15",
"axios": "^0.21.1",
"colors": "^1.4.0",
"compression": "^1.7.4",
"ejs": "^3.1.5",
"express": "^4.17.1",
"express-rate-limit": "^5.2.3",
"faker": "^5.1.0",
"faker": "^5.2.0",
"image-to-base64": "^2.1.1",
"js-yaml": "^4.0.0",
"memory-cache": "^0.2.0",
"prismjs": "^1.23.0",
"puppeteer": "^5.5.0",
"simple-git": "^2.31.0",
"svgo": "^1.3.2",
"vue": "^2.6.12",
"vue-prism-component": "^1.2.0"
},
"devDependencies": {
"jest": "^26.6.3",
"js-yaml": "^4.0.0",
"libxmljs": "^0.19.7"
}
}

View File

@@ -1,82 +1,82 @@
{
"//":"This is an example of configuration file for web instance",
"//":"It is not needed when using metrics as GitHub action",
"//": "Example of configuration for metrics web instance",
"//": "====================================================================",
"token":"MY GITHUB API TOKEN", "//":"Your own GitHub API token (required)",
"restricted":[], "//":"List of authorized users, leave empty for unrestricted",
"cached":3600000, "//":"Cached time for generated images, 0 to disable",
"maxusers":0, "//":"Maximum number of users, 0 for unlimited",
"ratelimiter":null, "//":"Rate limiter (see express-rate-limit documentation for options)",
"port":3000, "//":"Listening port",
"optimize":true, "//":"Optimize SVG image",
"debug":false, "//":"Debug mode",
"mocked":false, "//":"Use mocked data",
"repositories":100, "//":"Number of repositories to use to compute metrics",
"community":{ "//":"Community settings",
"templates":[], "//":"Community templates"
"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":{ "//":"Template configuration",
"default":"classic", "//":"Default template",
"enabled":[], "//":"Enabled templates, leave empty to enable all templates"
"templates": {
"default": "classic", "//": "Default template",
"enabled": [], "//": "Enabled templates (empty to enable all)"
},
"plugins":{ "//":"Additional plugins (optional)",
"pagespeed":{ "//":"Pagespeed plugin",
"enabled":false, "//":"Enable or disable PageSpeed metrics",
"token":null, "//":"Pagespeed token (optional)"
"plugins": { "//": "Global plugin configuration",
"activity":{
"enabled": false, "//": "Display recent activity"
},
"traffic":{ "//":"Traffic plugin (GitHub API token must be RW for this to work)",
"enabled":false, "//":"Enable or disable repositories total page views is last two weeks"
"anilist":{
"enabled": false, "//": "Display data from your AniList account"
},
"lines":{ "//":"Lines plugin",
"enabled":false, "//":"Enable or disable repositories total lines added/removed"
"followup":{
"enabled": false, "//": "Display follow-up of repositories issues and pull requests"
},
"habits":{ "//":"Habits plugin",
"enabled":false, "//":"Enable or disable coding habits metrics",
"from":200, "//":"Number of activity events to base habits on (up to 1000)"
"gists":{
"enabled": false, "//": "Display gists metrics"
},
"languages":{ "//":"Languages plugin",
"enabled":false, "//":"Enable or disable most used languages metrics"
"habits":{
"enabled": false, "//": "Display coding habits metrics"
},
"followup":{ "//":"Follow-up plugin",
"enabled":false, "//":"Enable or disable owned repositories issues and pull requests metrics"
"isocalendar":{
"enabled": false, "//": "Display an isometric view of your commits calendar"
},
"music":{ "//":"Music plugin",
"enabled":false, "//":"Enable or disable music recently played / random track from playlist",
"token":null, "//":"Music provider token (may be required depending on provider)"
"languages":{
"enabled": false, "//": "Display most used languages metrics"
},
"posts":{ "//":"Posts plugin",
"enabled":false, "//":"Enable or disable recents posts"
"lines":{
"enabled": false, "//": "Display lines of code metrics"
},
"isocalendar":{ "//":"Isometric calendar plugin",
"enabled":false, "//":"Enable or disable isometric calendar"
"music":{
"token": null, "//":"Music provider personal token",
"enabled": false, "//": "Display your music tracks"
},
"gists":{ "//":"Gists plugin",
"enabled":false, "//":"Enable or disable gists metrics"
"pagespeed":{
"token": null, "//":"PageSpeed token",
"enabled": false, "//": "Display a website Google PageSpeed metrics"
},
"topics":{ "//":"Topics plugin",
"enabled":false, "//":"Enable or disable starred topics display"
"people":{
"enabled": false, "//": "Display GitHub users from various affiliations"
},
"projects":{ "//":"Projects plugin",
"enabled":false, "//":"Enable or disable personal projects display"
"posts":{
"enabled": false, "//": "Display recent posts"
},
"tweets":{ "//":"Tweets plugin",
"enabled":false, "//":"Enable or disable recent tweets display",
"token":null, "//":"Twitter token (required when enabled)"
"projects":{
"enabled": false, "//": "Display active projects"
},
"stars":{ "//":"Stars plugin",
"enabled":false, "//":"Enable or disable recently starred repositories display"
"stargazers":{
"enabled": false, "//": "Display stargazers metrics"
},
"stargazers":{ "//":"Stargazers plugin",
"enabled":false, "//":"Enable or disable stargazers charts display"
"stars":{
"enabled": false, "//": "Display recently starred repositories"
},
"activity":{ "//":"Activity plugin",
"enabled":false, "//":"Enable or disable recent activity display"
"topics":{
"enabled": false, "//": "Display starred topics"
},
"people":{ "//":"People plugin",
"enabled":false, "//":"Enable or disable people display"
"traffic":{
"enabled": false, "//": "Display repositories traffic metrics"
},
"anilist":{ "//":"Anilist plugin",
"enabled":false, "//":"Enable or disable anilist display"
}
"tweets":{
"token": null, "//":"Twitter API token",
"enabled": false, "//": "Display recent tweets"
},
"//": ""
}
}

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

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