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

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

@@ -0,0 +1,31 @@
query BaseOrganization {
organization(login: "$login") {
databaseId
name
login
createdAt
avatarUrl
websiteUrl
isVerified
twitterUsername
repositories(last: 0) {
totalCount
totalDiskUsage
nodes {
name
}
}
packages {
totalCount
}
sponsorshipsAsSponsor {
totalCount
}
sponsorshipsAsMaintainer {
totalCount
}
membersWithRole {
totalCount
}
}
}

View File

@@ -0,0 +1,50 @@
query BaseRepositories {
$account(login: "$login") {
repositories($after first: $repositories $forks, orderBy: {field: UPDATED_AT, direction: DESC}) {
edges {
cursor
}
nodes {
name
owner {
login
}
isFork
watchers {
totalCount
}
stargazers {
totalCount
}
languages(first: 8) {
edges {
size
node {
color
name
}
}
}
issues_open: issues(states: OPEN) {
totalCount
}
issues_closed: issues(states: CLOSED) {
totalCount
}
pr_open: pullRequests(states: OPEN) {
totalCount
}
pr_merged: pullRequests(states: MERGED) {
totalCount
}
releases {
totalCount
}
forkCount
licenseInfo {
spdxId
}
}
}
}
}

View File

@@ -0,0 +1,48 @@
query BaseRepository {
$account(login: "$login") {
repository(name: "$repo") {
name
owner {
login
}
isFork
createdAt
diskUsage
homepageUrl
watchers {
totalCount
}
stargazers {
totalCount
}
languages(first: 8) {
edges {
size
node {
color
name
}
}
}
issues_open: issues(states: OPEN) {
totalCount
}
issues_closed: issues(states: CLOSED) {
totalCount
}
pr_open: pullRequests(states: OPEN) {
totalCount
}
pr_merged: pullRequests(states: MERGED) {
totalCount
}
releases {
totalCount
}
forkCount
licenseInfo {
spdxId
}
}
}
}

View File

@@ -0,0 +1,66 @@
query BaseUser {
user(login: "$login") {
databaseId
name
login
createdAt
avatarUrl
websiteUrl
isHireable
twitterUsername
repositories(last: 0 $forks) {
totalCount
totalDiskUsage
nodes {
name
}
}
packages {
totalCount
}
starredRepositories {
totalCount
}
watching {
totalCount
}
sponsorshipsAsSponsor {
totalCount
}
sponsorshipsAsMaintainer {
totalCount
}
contributionsCollection {
totalRepositoriesWithContributedCommits
totalCommitContributions
restrictedContributionsCount
totalIssueContributions
totalPullRequestContributions
totalPullRequestReviewContributions
}
calendar:contributionsCollection(from: "$calendar.from", to: "$calendar.to") {
contributionCalendar {
weeks {
contributionDays {
color
}
}
}
}
repositoriesContributedTo {
totalCount
}
followers {
totalCount
}
following {
totalCount
}
issueComments {
totalCount
}
organizations {
totalCount
}
}
}

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

@@ -0,0 +1,130 @@
/**
* 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}}
const avatar = imports.imgb64(data.user.avatarUrl)
console.debug(`metrics/compute/${login} > formatting common metrics`)
//Timezone config
if (q["config.timezone"]) {
const timezone = data.config.timezone = {name:q["config.timezone"], offset:0}
try {
timezone.offset = Number(new Date().toLocaleString("fr", {timeZoneName:"short", timeZone:timezone.name}).match(/UTC[+](?<offset>\d+)/)?.groups?.offset*60*60*1000) || 0
console.debug(`metrics/compute/${login} > timezone set to ${timezone.name} (${timezone.offset > 0 ? "+" : ""}${Math.round(timezone.offset/(60*60*1000))} hours)`)
} catch {
timezone.error = `Failed to use timezone "${timezone.name}"`
console.debug(`metrics/compute/${login} > failed to use timezone "${timezone.name}"`)
}
}
//Animations
if ("config.animations" in q) {
data.animated = q["config.animations"]
console.debug(`metrics/compute/${login} > animations ${data.animated ? "enabled" : "disabled"}`)
}
//Plugins
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`)
data.plugins[name] = await imports.plugins[name]({login, q, imports, data, computed, rest, graphql, queries, account}, plugins[name])
console.debug(`metrics/compute/${login}/plugins > ${name} > completed`)
}
catch (error) {
console.debug(`metrics/compute/${login}/plugins > ${name} > completed (error)`)
data.plugins[name] = error
}
finally {
const result = {name, result:data.plugins[name]}
console.debug(imports.util.inspect(result, {depth:Infinity, maxStringLength:256}))
return result
}
})())
}
//Iterate through user's repositories
for (const repository of data.user.repositories.nodes) {
//Simple properties with totalCount
for (const property of ["watchers", "stargazers", "issues_open", "issues_closed", "pr_open", "pr_merged", "releases"])
computed.repositories[property] += repository[property].totalCount
//Forks
computed.repositories.forks += repository.forkCount
if (repository.isFork)
computed.repositories.forked++
//License
if (repository.licenseInfo)
computed.licenses.used[repository.licenseInfo.spdxId] = (computed.licenses.used[repository.licenseInfo.spdxId] ?? 0) + 1
}
//Total disk usage
computed.diskUsage = `${imports.bytes(data.user.repositories.totalDiskUsage*1000)}`
//Compute licenses stats
computed.licenses.favorite = Object.entries(computed.licenses.used).sort(([an, a], [bn, b]) => b - a).slice(0, 1).map(([name, value]) => name) ?? ""
//Compute total commits
computed.commits += data.user.contributionsCollection.totalCommitContributions + data.user.contributionsCollection.restrictedContributionsCount
//Compute registration date
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${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
computed.calendar = data.user.calendar.contributionCalendar.weeks.flatMap(({contributionDays}) => contributionDays).slice(0, 14).reverse()
//Avatar (base64)
computed.avatar = await avatar || "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="
//Token scopes
computed.token.scopes = (await rest.request("HEAD /")).headers["x-oauth-scopes"].split(", ")
//Meta
data.meta = {version:conf.package.version, author:conf.package.author}
//Debug flags
if ((dflags.includes("--cakeday"))||(q["dflag.cakeday"])) {
console.debug(`metrics/compute/${login} > applying dflag --cakeday`)
computed.cakeday = true
}
if ((dflags.includes("--hireable"))||(q["dflag.hireable"])) {
console.debug(`metrics/compute/${login} > applying dflag --hireable`)
data.user.isHireable = true
}
if ((dflags.includes("--halloween"))||(q["dflag.halloween"])) {
console.debug(`metrics/compute/${login} > applying dflag --halloween`)
//Haloween color replacer
const halloween = content => content
.replace(/--color-calendar-graph/g, "--color-calendar-halloween-graph")
.replace(/#9be9a8/gi, "var(--color-calendar-halloween-graph-day-L1-bg)")
.replace(/#40c463/gi, "var(--color-calendar-halloween-graph-day-L2-bg)")
.replace(/#30a14e/gi, "var(--color-calendar-halloween-graph-day-L3-bg)")
.replace(/#216e39/gi, "var(--color-calendar-halloween-graph-day-L4-bg)")
//Update contribution calendar colors
computed.calendar.map(day => day.color = halloween(day.color))
//Update isocalendar colors
const waiting = [...pending]
pending.push((async () => {
await Promise.all(waiting)
if (data.plugins.isocalendar?.svg)
data.plugins.isocalendar.svg = halloween(data.plugins.isocalendar.svg)
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

@@ -0,0 +1,23 @@
query GistsDefault {
user(login: "$login") {
gists($after first: 100, orderBy: {field: UPDATED_AT, direction: DESC}) {
edges {
cursor
}
totalCount
nodes {
stargazerCount
isFork
forks {
totalCount
}
files {
name
}
comments {
totalCount
}
}
}
}
}

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

@@ -0,0 +1,15 @@
query IsocalendarCalendar {
user(login: "$login") {
calendar:contributionsCollection(from: "$from", to: "$to") {
contributionCalendar {
weeks {
contributionDays {
contributionCount
color
date
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
query PeopleDefault {
user(login: "$login") {
login
$type($after first: 100) {
edges {
cursor
node {
login
avatarUrl(size: $size)
}
}
}
}
}

View File

@@ -0,0 +1,15 @@
query PeopleRepository {
$account(login: "$login") {
repository(name: "$repository") {
$type($after first: 100) {
edges {
cursor
node {
login
avatarUrl(size: $size)
}
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
query PeopleSponsors {
$account(login: "$login") {
login
$type($after first: 100) {
edges {
cursor
node {
$target {
... on User {
login
avatarUrl(size: $size)
}
}
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,17 @@
query ProjectsRepository {
$account(login: "$user") {
repository(name: "$repository") {
project(number: $id) {
name
body
updatedAt
progress {
doneCount
inProgressCount
todoCount
enabled
}
}
}
}
}

View File

@@ -0,0 +1,18 @@
query ProjectsUser {
$account(login: "$login") {
projects(last: $limit, states: OPEN, orderBy: {field: UPDATED_AT, direction: DESC}) {
totalCount
nodes {
name
body
updatedAt
progress {
doneCount
inProgressCount
todoCount
enabled
}
}
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
### 🌟 Recently starred repositories
The *stars* plugin displays your recently starred repositories.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.stars.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_stars: yes
plugin_stars_limit: 4 # Limit to 4 entries
```

View File

@@ -1,19 +1,18 @@
//Setup
export default async function ({login, graphql, q, queries, account}, {enabled = false} = {}) {
export default async function ({login, data, graphql, q, queries, imports, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.stars))
return null
if (account === "organization")
throw {error:{message:"Not available for organizations"}}
//Parameters override
let {"stars.limit":limit = 4} = q
//Limit
limit = Math.max(1, Math.min(100, Number(limit)))
//Load inputs
let {limit} = imports.metadata.plugins.stars.inputs({data, account, q})
//Retrieve user stars from graphql api
console.debug(`metrics/compute/${login}/plugins > stars > querying api`)
const {user:{starredRepositories:{edges:repositories}}} = await graphql(queries.starred({login, limit}))
const {user:{starredRepositories:{edges:repositories}}} = await graphql(queries.stars({login, limit}))
//Format starred repositories
for (const edge of repositories) {
//Format date
@@ -25,6 +24,7 @@
updated = `${Math.floor(time)} day${time >= 2 ? "s" : ""} ago`
edge.starred = updated
}
//Results
return {repositories}
}

View File

@@ -0,0 +1,19 @@
name: "🌟 Recently starred repositories"
cost: 1 GraphQL request
supports:
- user
inputs:
# Enable or disable plugin
plugin_stars:
description: Display recently starred repositories
type: boolean
default: no
# Number of stars to display
plugin_stars_limit:
description: Maximum number of stars to display
type: number
default: 4
min: 1
max: 100

View File

@@ -0,0 +1,32 @@
query StarsDefault {
user(login: "$login") {
starredRepositories(first: $limit, orderBy: {field: STARRED_AT, direction: DESC}) {
edges {
starredAt
node {
description
forkCount
isFork
issues {
totalCount
}
nameWithOwner
openGraphImageUrl
licenseInfo {
nickname
spdxId
name
}
pullRequests {
totalCount
}
stargazerCount
primaryLanguage {
color
name
}
}
}
}
}
}

View File

@@ -0,0 +1,30 @@
### 📌 Starred topics
The *topics* plugin displays your [starred topics](https://github.com/stars?filter=topics).
Check out [GitHub topics](https://github.com/topics) to search interesting topics.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.topics.svg">
<details open><summary>Mastered and known technologies version</summary>
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.topics.mastered.svg">
</details>
<img width="900" height="1" alt="">
</td>
</table>
This uses puppeteer to navigate through your starred topics page.
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_topics: yes
plugin_topics_sort: stars # Sort by most starred topics
plugin_topics_mode: mastered # Display icons instead of labels
plugin_topics_limit: 0 # Disable limitations
```

View File

@@ -1,24 +1,15 @@
//Setup
export default async function ({login, imports, q, account}, {enabled = false} = {}) {
export default async function ({login, data, imports, q, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.topics))
return null
if (account === "organization")
throw {error:{message:"Not available for organizations"}}
//Parameters override
let {"topics.sort":sort = "stars", "topics.mode":mode = "starred", "topics.limit":limit} = q
//Shuffle
const shuffle = (sort === "random")
//Sort method
sort = {starred:"created", activity:"updated", stars:"stars", random:"created"}[sort] ?? "starred"
//Limit
if (!Number.isFinite(limit))
limit = (mode === "mastered" ? 0 : 15)
limit = Math.max(0, Math.min(20, Number(limit)))
//Mode
mode = ["starred", "mastered"].includes(mode) ? mode : "starred"
//Load inputs
let {sort, mode, limit} = imports.metadata.plugins.topics.inputs({data, account, q})
const shuffle = (sort === "random")
//Start puppeteer and navigate to topics
console.debug(`metrics/compute/${login}/plugins > topics > searching starred topics`)
let topics = []
@@ -26,6 +17,7 @@
const browser = await imports.puppeteer.launch({headless:true, executablePath:process.env.PUPPETEER_BROWSER_PATH, args:["--no-sandbox", "--disable-extensions", "--disable-setuid-sandbox", "--disable-dev-shm-usage"]})
console.debug(`metrics/compute/${login}/plugins > topics > started ${await browser.version()}`)
const page = await browser.newPage()
//Iterate through pages
for (let i = 1; i <= 100; i++) {
//Load page
@@ -47,14 +39,17 @@
}
topics.push(...starred)
}
//Close browser
console.debug(`metrics/compute/${login}/plugins > music > closing browser`)
await browser.close()
//Shuffle topics
if (shuffle) {
console.debug(`metrics/compute/${login}/plugins > topics > shuffling topics`)
topics = imports.shuffle(topics)
}
//Limit topics (starred mode)
if ((mode === "starred")&&(limit > 0)) {
console.debug(`metrics/compute/${login}/plugins > topics > keeping only ${limit} topics`)
@@ -62,6 +57,7 @@
if (removed.length)
topics.push({name:`And ${removed.length} more...`, description:removed.map(({name}) => name).join(", "), icon:null})
}
//Convert icons to base64
console.debug(`metrics/compute/${login}/plugins > topics > loading artworks`)
for (const topic of topics) {
@@ -72,16 +68,19 @@
//Escape HTML description
topic.description = imports.htmlescape(topic.description)
}
//Filter topics with icon (mastered mode)
if (mode === "mastered") {
console.debug(`metrics/compute/${login}/plugins > topics > filtering topics with icon`)
topics = topics.filter(({icon}) => icon)
}
//Limit topics (mastered mode)
if ((mode === "mastered")&&(limit > 0)) {
console.debug(`metrics/compute/${login}/plugins > topics > keeping only ${limit} topics`)
topics.splice(limit)
}
//Results
return {mode, list:topics}
}

View File

@@ -0,0 +1,41 @@
name: "📌 Starred topics"
cost: N/A
supports:
- user
inputs:
# Enable or disable plugin
plugin_topics:
description: Display starred topics
type: boolean
default: no
# Plugin mode
plugin_topics_mode:
description: Plugin mode
type: string
default: starred
values:
- starred # Display starred topics as labels
- mastered # Display starred topics as mastered/known technologies icons
# Topics sorting order
plugin_topics_sort:
description: Sorting method of starred topics
type: string
default: stars
values:
- stars # Sort topics by stargazers
- activity # Sort topics by recent activity
- starred # Sort topics by the date you starred them
- random # Sort topics randomly
# Number of topics to display
# Set to 0 to disable limitations
# When in "starred" mode, additional topics will be grouped into an ellipsis
plugin_topics_limit:
description: Maximum number of topics to display
type: number
default: 15
min: 0
max: 20

View File

@@ -0,0 +1,26 @@
### 🧮 Repositories traffic
⚠️ This plugin requires a personal token with repo scope.
The repositories *traffic* plugin displays the number of page views across your repositories.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.traffic.svg">
</td>
</table>
Because of GitHub REST API limitation, provided token requires full `repo` scope to access traffic informations.
![Token with repo scope](/.github/readme/imgs/setup_token_repo_scope.png)
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_traffic: yes
```

View File

@@ -1,19 +1,26 @@
//Setup
export default async function ({login, imports, data, rest, q}, {enabled = false} = {}) {
export default async function ({login, imports, data, rest, q, account}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.traffic))
return null
//Load inputs
imports.metadata.plugins.traffic.inputs({data, account, q})
//Repositories
const repositories = data.user.repositories.nodes.map(({name:repo, owner:{login:owner}}) => ({repo, owner})) ?? []
//Get views stats from repositories
console.debug(`metrics/compute/${login}/plugins > traffic > querying api`)
const views = {count:0, uniques:0}
const response = await Promise.all(repositories.map(async ({repo, owner}) => await rest.repos.getViews({owner, repo})))
//Compute views
console.debug(`metrics/compute/${login}/plugins > traffic > computing stats`)
response.filter(({data}) => data).map(({data:{count, uniques}}) => (views.count += count, views.uniques += uniques))
//Results
return {views}
}

View File

@@ -0,0 +1,13 @@
name: "🧮 Repositories traffic"
cost: 1 REST request per repository
supports:
- user
- organization
- repository
inputs:
# Enable or disable plugin
plugin_traffic:
description: Display repositories traffic metrics
type: boolean
default: no

View File

@@ -0,0 +1,36 @@
### 🐤 Tweets
The recent *tweets* plugin displays your latest tweets from your [Twitter](https://twitter.com) account.
<table>
<td align="center">
<img src="https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.tweets.svg">
<img width="900" height="1" alt="">
</td>
</table>
<details>
<summary>💬 Obtaining a Twitter token</summary>
To get a Twitter token, you'll need to apply to the [developer program](https://apps.twitter.com).
It's a bit tedious, but it seems that requests are approved quite quickly.
Create an app from your [developer dashboard](https://developer.twitter.com/en/portal/dashboard) and register your bearer token in your repository secrets.
![Twitter token](/.github/readme/imgs/plugin_tweets_secrets.png)
</details>
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
```yaml
- uses: lowlighter/metrics@latest
with:
# ... other options
plugin_tweets: yes
plugin_tweets_token: ${{ secrets.TWITTER_TOKEN }} # Required
plugin_tweets_limit: 2 # Limit to 2 tweets
plugin_tweets_user: .user.twitter # Defaults to your GitHub linked twitter username
```

View File

@@ -1,30 +1,34 @@
//Setup
export default async function ({login, imports, data, q}, {enabled = false, token = null} = {}) {
export default async function ({login, imports, data, q, account}, {enabled = false, token = ""} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.tweets))
return null
//Parameters override
let {"tweets.limit":limit = 2, "tweets.user":username = data.user.twitterUsername} = q
//Limit
limit = Math.max(1, Math.min(10, Number(limit)))
//Load inputs
let {limit, user:username} = imports.metadata.plugins.tweets.inputs({data, account, q})
//Load user profile
console.debug(`metrics/compute/${login}/plugins > tweets > loading twitter profile (@${username})`)
const {data:{data:profile = null}} = await imports.axios.get(`https://api.twitter.com/2/users/by/username/${username}?user.fields=profile_image_url,verified`, {headers:{Authorization:`Bearer ${token}`}})
//Load tweets
console.debug(`metrics/compute/${login}/plugins > tweets > querying api`)
const {data:{data:tweets = []}} = await imports.axios.get(`https://api.twitter.com/2/tweets/search/recent?query=from:${username}&tweet.fields=created_at&expansions=entities.mentions.username`, {headers:{Authorization:`Bearer ${token}`}})
//Load profile image
if (profile?.profile_image_url) {
console.debug(`metrics/compute/${login}/plugins > tweets > loading profile image`)
profile.profile_image = await imports.imgb64(profile.profile_image_url)
}
//Load tweets
console.debug(`metrics/compute/${login}/plugins > tweets > querying api`)
const {data:{data:tweets = []}} = await imports.axios.get(`https://api.twitter.com/2/tweets/search/recent?query=from:${username}&tweet.fields=created_at&expansions=entities.mentions.username`, {headers:{Authorization:`Bearer ${token}`}})
//Limit tweets
if (limit > 0) {
console.debug(`metrics/compute/${login}/plugins > tweets > keeping only ${limit} tweets`)
tweets.splice(limit)
}
//Format tweets
await Promise.all(tweets.map(async tweet => {
//Mentions
@@ -44,6 +48,7 @@
.replace(/https?:[/][/](t.co[/]\w+)/g, ` <span class="link">$1</span> `)
, {"&":true})
}))
//Result
return {username, profile, list:tweets}
}

View File

@@ -0,0 +1,33 @@
name: "🐤 Latest tweets"
cost: N/A
supports:
- user
- organization
inputs:
# Enable or disable plugin
plugin_tweets:
description: Display recent tweets
type: boolean
default: no
# Twitter API token
# See https://apps.twitter.com for more informations
plugin_tweets_token:
description: Twitter API token
type: token
default: ""
# Number of tweets to display
plugin_tweets_limit:
description: Maximum number of tweets to display
type: number
default: 2
min: 1
max: 10
# Twitter username
plugin_tweets_user:
description: Twitter username
type: string
default: .user.twitter