From 4879ed01365aa20be83102420dd62f4f8fdfbf6a Mon Sep 17 00:00:00 2001
From: Simon Lecoq <22963968+lowlighter@users.noreply.github.com>
Date: Mon, 25 Jan 2021 22:08:29 +0100
Subject: [PATCH] People plugin : repository template support (#78)
---
README.md | 47 +++++++-
action.yml | 20 +++-
source/app/action/index.mjs | 2 +-
source/app/mocks.mjs | 107 +++++++++++++++++-
source/app/web/statics/app.js | 4 +-
source/app/web/statics/app.placeholder.js | 30 +++--
source/plugins/people/index.mjs | 59 +++++++---
source/queries/people.repository.graphql | 17 ++-
source/queries/people.sponsors.graphql | 18 +++
source/templates/classic/partials/people.ejs | 30 ++---
source/templates/repository/partials/_.json | 3 +-
.../templates/repository/partials/people.ejs | 43 +++++++
source/templates/repository/template.mjs | 5 +
tests/metrics.test.js | 21 ++++
14 files changed, 343 insertions(+), 63 deletions(-)
create mode 100644 source/queries/people.sponsors.graphql
create mode 100644 source/templates/repository/partials/people.ejs
diff --git a/README.md b/README.md
index 499b0f8d..f206d91b 100644
--- a/README.md
+++ b/README.md
@@ -181,6 +181,21 @@ But there's more with [plugins](https://github.com/lowlighter/metrics/tree/maste
+ Special thanks version
+
+
+
+
+ Repository template version
+
+
+
+
+ Sponsorships version
+
+
+
+
@@ -1528,7 +1543,8 @@ Add the following to your workflow:
### 🧑🤝🧑 People
-The *people* plugin displays your followers and followed users' avatars.
+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.

@@ -1537,6 +1553,24 @@ The *people* plugin displays your followers and followed users' avatars.
It will consume an additional GitHub request per group of 100 users fetched.
+The following types are supported:
+
+| Type | Alias | User metrics | Repository metrics |
+| --------------- | ------------------------------------ | :----------------: | :----------------: |
+| `followers` | | ✔️ | ❌ |
+| `following` | `followed` | ✔️ | ❌ |
+| `sponsoring`* | `sponsored`, `sponsorshipsAsSponsor` | ✔️ | ❌ |
+| `sponsors`* | `sponsorshipsAsMaintainer` | ✔️ | ✔️ |
+| `contributors`* | | ❌ | ✔️ |
+| `stargazers`* | | ❌ | ✔️ |
+| `watchers`* | | ❌ | ✔️ |
+| `thanks`* | | ✔️ | ✔️ |
+
+ 🚧 Types marked with * are available as pre-release on @master branch (unstable)
+
+Sections will be ordered the same as specified in `plugin_people_types`.
+`sponsors` for repositories will output the same as the owner's sponsors.
+
Add the following to your workflow:
```yaml
- uses: lowlighter/metrics@latest
@@ -1557,6 +1591,17 @@ It is possible to use [identicons](https://github.blog/2013-08-14-identicons/) i
plugin_people_identicons: yes
```
+ 🚧 This feature is available as pre-release on @master branch (unstable)
+
+It is possible to thanks personally users by adding the following to your workflow:
+```yaml
+- uses: lowlighter/metrics@master
+ with:
+ # ... other options
+ plugin_people_types: thanks
+ plugin_people_thanks: github-user-1, github-user-2, ...
+```
+
### 🌸 Anilist
diff --git a/action.yml b/action.yml
index 2bf1b6d2..459389e9 100644
--- a/action.yml
+++ b/action.yml
@@ -469,13 +469,29 @@ inputs:
default: 28
# List of users categories to display (comma separated)
- # Supported values are:
+ # For user's metrics, supported values are:
# - "followers"
- # - "following"
+ # - "following"/"followed"
+ # - "sponsors"/"sponsorshipsAsMaintainer"
+ # - "sponsoring"/"sponsored"/"sponsorshipsAsSponsor"
+ # - "thanks" (see "plugin_people_thanks" below)
+ # For repositories' metrics, supported values are:
+ # - "contributors"
+ # - "stargazers"
+ # - "watchers"
+ # - "sponsors"/"sponsorshipsAsMaintainer"
+ # - "thanks" (see "plugin_people_thanks" below)
plugin_people_types:
description: Categories to display
default: followers, following
+ # List of users to thanks (comma seperated)
+ # When using "thanks" as a type, it'll display the users you listed in this option
+ # This can be used to create "Special thanks" badges that you can embed elsewhere
+ plugin_people_thanks:
+ description: Users to thanks in "thanks" section type
+ default: ""
+
# Display GitHub identicons instead of users' real avatar
# Mostly for privacy purposes
plugin_people_identicons:
diff --git a/source/app/action/index.mjs b/source/app/action/index.mjs
index e14e6e86..fd66b3a7 100644
--- a/source/app/action/index.mjs
+++ b/source/app/action/index.mjs
@@ -239,7 +239,7 @@
if (plugins.people.enabled) {
for (const option of ["limit", "size"])
info(`People ${option}`, q[`people.${option}`] = input.number(`plugin_people_${option}`))
- for (const option of ["types"])
+ for (const option of ["types", "thanks"])
info(`People ${option}`, q[`people.${option}`] = input.array(`plugin_people_${option}`))
for (const option of ["identicons"])
info(`People ${option}`, q[`people.${option}`] = input.bool(`plugin_people_${option}`))
diff --git a/source/app/mocks.mjs b/source/app/mocks.mjs
index 57b48da2..afdeed52 100644
--- a/source/app/mocks.mjs
+++ b/source/app/mocks.mjs
@@ -362,7 +362,7 @@
}) : ({
user:{
[type]:{
- edges:new Array(Math.ceil(20+80*Math.random())).fill(undefined).map((login = faker.internet.userName()) => ({
+ edges:new Array(Math.ceil(20+80*Math.random())).fill(null).map((login = faker.internet.userName()) => ({
cursor:"MOCKED_CURSOR",
node:{
login,
@@ -373,6 +373,66 @@
}
})
}
+ //People query (repositories)
+ if (/^query PeopleRepository /.test(query)) {
+ console.debug(`metrics/compute/mocks > mocking graphql api result > People`)
+ const type = query.match(/(?stargazers|watchers)[(]/)?.groups?.type ?? "(unknown type)"
+ return /after: "MOCKED_CURSOR"/m.test(query) ? ({
+ user:{
+ repository:{
+ [type]:{
+ edges:[],
+ }
+ }
+ }
+ }) : ({
+ user:{
+ repository:{
+ [type]:{
+ edges:new Array(Math.ceil(20+80*Math.random())).fill(null).map((login = faker.internet.userName()) => ({
+ cursor:"MOCKED_CURSOR",
+ node:{
+ login,
+ avatarUrl:null,
+ }
+ }))
+ }
+ }
+ }
+ })
+ }
+ //People sponsors query
+ if (/^query PeopleSponsors /.test(query)) {
+ console.debug(`metrics/compute/mocks > mocking graphql api result > People`)
+ const type = query.match(/(?sponsorshipsAsSponsor|sponsorshipsAsMaintainer)[(]/)?.groups?.type ?? "(unknown type)"
+ return /after: "MOCKED_CURSOR"/m.test(query) ? ({
+ user:{
+ login,
+ [type]:{
+ edges:[]
+ }
+ }
+ }) : ({
+ user:{
+ login,
+ [type]:{
+ edges:new Array(Math.ceil(20+80*Math.random())).fill(null).map((login = faker.internet.userName()) => ({
+ cursor:"MOCKED_CURSOR",
+ node:{
+ sponsorEntity:{
+ login:faker.internet.userName(),
+ avatarUrl:null,
+ },
+ sponsorable:{
+ login:faker.internet.userName(),
+ avatarUrl:null,
+ }
+ }
+ }))
+ }
+ }
+ })
+ }
//Unmocked call
return target(...args)
}
@@ -389,6 +449,8 @@
getViews:rest.repos.getViews,
getContributorsStats:rest.repos.getContributorsStats,
listCommits:rest.repos.listCommits,
+ listContributors:rest.repos.listContributors,
+ getByUsername:rest.users.getByUsername,
}
//Raw request
@@ -893,6 +955,49 @@
})
}
})
+
+ //Repository contributors
+ rest.repos.listContributors = new Proxy(unmocked.listContributors, {
+ apply:function(target, that, [{owner, repo}]) {
+ console.debug(`metrics/compute/mocks > mocking rest api result > rest.repos.listContributors`)
+ return ({
+ status:200,
+ url:`https://api.github.com/repos/${owner}/${repo}/contributors`,
+ headers: {
+ server:"GitHub.com",
+ status:"200 OK",
+ "x-oauth-scopes":"repo",
+ },
+ data:new Array(40+faker.random.number(60)).fill(null).map(() => ({
+ login:faker.internet.userName(),
+ avatar_url:null,
+ contributions:faker.random.number(1000),
+ }))
+ })
+ }
+ })
+
+ //User informations
+ rest.users.getByUsername = new Proxy(unmocked.getByUsername, {
+ apply:function(target, that, [{username}]) {
+ console.debug(`metrics/compute/mocks > mocking rest api result > rest.repos.getByUsername`)
+ return ({
+ status:200,
+ url:`'https://api.github.com/users/${username}/`,
+ headers: {
+ server:"GitHub.com",
+ status:"200 OK",
+ "x-oauth-scopes":"repo",
+ },
+ data:{
+ login:faker.internet.userName(),
+ avatar_url:null,
+ contributions:faker.random.number(1000),
+ }
+ })
+ }
+ })
+
}
//Axios mocking
diff --git a/source/app/web/statics/app.js b/source/app/web/statics/app.js
index d745b741..8e8f28a4 100644
--- a/source/app/web/statics/app.js
+++ b/source/app/web/statics/app.js
@@ -76,7 +76,7 @@
stars:"🌟 Recently starred repositories",
stargazers:"✨ Stargazers over last weeks",
activity:"📰 Recent activity",
- people:"🧑🤝🧑 Followers and followed",
+ people:"🧑🤝🧑 People",
anilist:"🌸 Anilist",
base:"🗃️ Base content",
"base.header":"Header",
@@ -120,6 +120,7 @@
"people.size":{text:"Limit", type:"number", min:16, max:64},
"people.limit":{text:"Limit", type:"number", min:1, max:9999},
"people.types":{text:"Types", placeholder:"followers, following"},
+ "people.thanks":{text:"Special thanks", placeholder:"user1, user2, ..."},
"people.identicons":{text:"Use identicons", type:"boolean"},
"anilist.medias":{text:"Medias to display", placeholder:"anime, manga"},
"anilist.sections":{text:"Sections to display", placeholder:"favorites, watching, reading, characters"},
@@ -157,6 +158,7 @@
"people.size":28,
"people.limit":28,
"people.types":"followers, following",
+ "people.thanks":"",
"people.identicons":false,
"anilist.medias":"anime, manga",
"anilist.sections":"favorites",
diff --git a/source/app/web/statics/app.placeholder.js b/source/app/web/statics/app.placeholder.js
index bfc65b78..9003dbd4 100644
--- a/source/app/web/statics/app.placeholder.js
+++ b/source/app/web/statics/app.placeholder.js
@@ -222,17 +222,25 @@
}) : null),
//People
...(set.plugins.enabled.people ? ({
- people:{
- types:options["people.types"].split(",").map(x => x.trim()),
- size:options["people.size"],
- followers:new Array(Number(options["people.limit"])).fill(null).map(_ => ({
- login:faker.internet.userName(),
- avatar:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
- })),
- following:new Array(Number(options["people.limit"])).fill(null).map(_ => ({
- login:faker.internet.userName(),
- avatar:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
- }))
+ get people() {
+ const types = options["people.types"].split(",").map(x => x.trim())
+ .map(x => ({followed:"following", sponsors:"sponsorshipsAsMaintainer", sponsored:"sponsorshipsAsSponsor", sponsoring:"sponsorshipsAsSponsor"})[x] ?? x)
+ .filter(x => ["followers", "following", "sponsorshipsAsMaintainer", "sponsorshipsAsSponsor"].includes(x))
+ return {
+ types,
+ size:options["people.size"],
+ ...(Object.fromEntries(types.map(type => [
+ type,
+ new Array(Number(options["people.limit"])).fill(null).map(_ => ({
+ login:faker.internet.userName(),
+ avatar:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
+ }))
+ ]))),
+ thanks:options["people.thanks"].split(",").map(x => x.trim()).map(login => ({
+ login,
+ avatar:"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==",
+ }))
+ }
}
}) : null),
//Music
diff --git a/source/plugins/people/index.mjs b/source/plugins/people/index.mjs
index f6ae8841..646be9d3 100644
--- a/source/plugins/people/index.mjs
+++ b/source/plugins/people/index.mjs
@@ -1,33 +1,66 @@
//Setup
- export default async function ({login, graphql, q, queries, imports}, {enabled = false} = {}) {
+ export default async function ({login, data, graphql, rest, q, queries, imports}, {enabled = false} = {}) {
//Plugin execution
try {
//Check if plugin is enabled and requirements are met
if ((!enabled)||(!q.people))
return null
+ //Context
+ let context = {
+ mode:"user",
+ types:["followers", "following", "sponsorshipsAsMaintainer", "sponsorshipsAsSponsor", "thanks"],
+ default:"followers, following",
+ alias:{followed:"following", sponsors:"sponsorshipsAsMaintainer", sponsored:"sponsorshipsAsSponsor", sponsoring:"sponsorshipsAsSponsor"},
+ sponsorships:{sponsorshipsAsMaintainer:"sponsorEntity", sponsorshipsAsSponsor:"sponsorable"}
+ }
+ if (q.repo) {
+ console.debug(`metrics/compute/${login}/plugins > people > switched to repository mode`)
+ const {owner, repo} = data.user.repositories.nodes.map(({name:repo, owner:{login:owner}}) => ({repo, owner})).shift()
+ context = {...context, mode:"repo", types:["contributors", "stargazers", "watchers", "sponsorshipsAsMaintainer", "thanks"], default:"stargazers, watchers", owner, repo}
+ }
+
//Parameters override
- let {"people.limit":limit = 28, "people.types":types = "followers, following", "people.size":size = 28, "people.identicons":identicons = false} = q
+ 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 = decodeURIComponent(types ?? "").split(",").map(type => type.trim()).filter(type => ["followers", "following"].includes(type)) ?? []
+ 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)
//Retrieve followers from graphql api
console.debug(`metrics/compute/${login}/plugins > people > querying api`)
- const result = {followers:[], following:[]}
+ const result = Object.fromEntries(types.map(type => [type, []]))
for (const type of types) {
//Iterate through people
console.debug(`metrics/compute/${login}/plugins > people > retrieving ${type}`)
- let cursor = null
- let pushed = 0
- do {
- console.debug(`metrics/compute/${login}/plugins > people > retrieving ${type} after ${cursor}`)
- const {user:{[type]:{edges}}} = await graphql(queries.people({login, type, size, after:cursor ? `after: "${cursor}"` : ""}))
- cursor = edges?.[edges?.length-1]?.cursor
- result[type].push(...edges.map(({node}) => node))
- pushed = edges.length
- } while ((pushed)&&(cursor))
+ //Rest
+ if (type === "contributors") {
+ const {owner, repo} = context
+ const {data:nodes} = await rest.repos.listContributors({owner, repo})
+ result[type].push(...nodes.map(({login, avatar_url}) => ({login, avatarUrl:avatar_url})))
+ }
+ else if (type === "thanks") {
+ const nodes = await Promise.all(thanks.map(async username => (await rest.users.getByUsername({username})).data))
+ result[type].push(...nodes.map(({login, avatar_url}) => ({login, avatarUrl:avatar_url})))
+ }
+ //GraphQL
+ else {
+ let cursor = null
+ let pushed = 0
+ 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]}))).user :
+ context.mode === "repo" ? (await graphql(queries["people.repository"]({login:context.owner, repository:context.repo, type, size, after:cursor ? `after: "${cursor}"` : ""}))).user.repository :
+ (await graphql(queries.people({login, type, size, after:cursor ? `after: "${cursor}"` : ""}))).user
+ )
+ cursor = edges?.[edges?.length-1]?.cursor
+ result[type].push(...edges.map(({node}) => node[context.sponsorships[type]] ?? node))
+ pushed = edges.length
+ } while ((pushed)&&(cursor)&&(result[type].length <= limit))
+ }
//Limit people
if (limit > 0) {
console.debug(`metrics/compute/${login}/plugins > people > keeping only ${limit} ${type}`)
diff --git a/source/queries/people.repository.graphql b/source/queries/people.repository.graphql
index 94f1d5b3..b6b6e949 100644
--- a/source/queries/people.repository.graphql
+++ b/source/queries/people.repository.graphql
@@ -1,14 +1,13 @@
-query Repository {
+query PeopleRepository {
user(login: "$login") {
repository(name: "$repository") {
- $type(first: 100) {
- pageInfo {
- hasNextPage
- endCursor
- }
- nodes {
- avatarUrl(size: 24)
- login
+ $type($after first: 100) {
+ edges {
+ cursor
+ node {
+ login
+ avatarUrl(size: $size)
+ }
}
}
}
diff --git a/source/queries/people.sponsors.graphql b/source/queries/people.sponsors.graphql
new file mode 100644
index 00000000..5b12565c
--- /dev/null
+++ b/source/queries/people.sponsors.graphql
@@ -0,0 +1,18 @@
+query PeopleSponsors {
+ user(login: "$login") {
+ login
+ $type($after first: 100) {
+ edges {
+ cursor
+ node {
+ $target {
+ ... on User {
+ login
+ avatarUrl(size: $size)
+ }
+ }
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/templates/classic/partials/people.ejs b/source/templates/classic/partials/people.ejs
index 4b991d0b..65bd2704 100644
--- a/source/templates/classic/partials/people.ejs
+++ b/source/templates/classic/partials/people.ejs
@@ -15,11 +15,15 @@
<% } else { %>
- <% if (plugins.people.types?.includes("followers")) { %>
+ <% for (const type of plugins.people.types) { %>
- <%= user.followers.totalCount %> follower<%= s(user.followers.totalCount) %>
+ <% if (type === "thanks") { %>
+ Special thanks
+ <% } else { %>
+ <%= user[type].totalCount %> <%= {followers:`follower${s(user[type].totalCount)}`, following:"followed", sponsorshipsAsSponsor:"sponsored", sponsorshipsAsMaintainer:`sponsor${s(user[type].totalCount)}`}[type] %>
+ <% } %>
@@ -29,27 +33,7 @@
<%= plugins.people.error.message %>
<% } else { %>
- <% for (const user of plugins.people.followers) { %>
<% } %>
- <% } %>
-
-
-
- <% } %>
- <% if (plugins.people.types?.includes("following")) { %>
-
-
-
- <%= user.following.totalCount %> followed
-
-
-
- <% if (plugins.people.error) { %>
-
-
- <%= plugins.people.error.message %>
-
- <% } else { %>
- <% for (const user of plugins.people.following) { %>
<% } %>
+ <% for (const user of plugins.people[type]) { %>
<% } %>
<% } %>
diff --git a/source/templates/repository/partials/_.json b/source/templates/repository/partials/_.json
index 9952d97d..76964226 100644
--- a/source/templates/repository/partials/_.json
+++ b/source/templates/repository/partials/_.json
@@ -4,5 +4,6 @@
"languages",
"projects",
"pagespeed",
- "stargazers"
+ "stargazers",
+ "people"
]
\ No newline at end of file
diff --git a/source/templates/repository/partials/people.ejs b/source/templates/repository/partials/people.ejs
new file mode 100644
index 00000000..3ff6539c
--- /dev/null
+++ b/source/templates/repository/partials/people.ejs
@@ -0,0 +1,43 @@
+<% if (plugins.people) { %>
+ <% if (plugins.people.error) { %>
+
+
+
+ People
+
+
+
+
+
+ <%= plugins.people.error.message %>
+
+
+
+
+ <% } else { %>
+ <% for (const type of plugins.people.types) { %>
+
+
+
+ <% if (type === "thanks") { %>
+ Special thanks
+ <% } else { %>
+ <%= repo[type].totalCount %> <%= {watchers:`watcher${s(repo[type].totalCount)}`, stargazers:`stargazer${s(repo[type].totalCount)}`, contributors:`contributor${s(repo[type].totalCount)}`, sponsorshipsAsSponsor:"sponsored", sponsorshipsAsMaintainer:`sponsor${s(repo[type].totalCount)}`}[type] %>
+ <% } %>
+
+
+
+ <% if (plugins.people.error) { %>
+
+
+ <%= plugins.people.error.message %>
+
+ <% } else { %>
+ <% for (const user of plugins.people[type]) { %>
<% } %>
+ <% } %>
+
+
+
+ <% } %>
+ <% } %>
+<% } %>
\ No newline at end of file
diff --git a/source/templates/repository/template.mjs b/source/templates/repository/template.mjs
index cbee549e..c70d7c60 100644
--- a/source/templates/repository/template.mjs
+++ b/source/templates/repository/template.mjs
@@ -15,6 +15,11 @@
console.debug(`metrics/compute/${login}/${repo} > retrieving single repository ${repo}`)
const {user:{repository}} = await graphql(queries.repository({login, repo}))
data.user.repositories.nodes = [repository]
+ data.repo = repository
+
+ //Contributors and sponsors
+ data.repo.contributors = {totalCount:(await rest.repos.listContributors({owner:data.repo.owner.login, repo})).data.length}
+ data.repo.sponsorshipsAsMaintainer = data.user.sponsorshipsAsMaintainer
//Get commit activity
console.debug(`metrics/compute/${login}/${repo} > querying api for commits`)
diff --git a/tests/metrics.test.js b/tests/metrics.test.js
index cbbfb22c..2ac7bda1 100644
--- a/tests/metrics.test.js
+++ b/tests/metrics.test.js
@@ -286,6 +286,27 @@
plugin_people:true,
plugin_people_types:"following",
}, {skip:["terminal", "repository"]}],
+ ["People plugin (sponsoring)", {
+ plugin_people:true,
+ plugin_people_types:"sponsoring",
+ }, {skip:["terminal", "repository"]}],
+ ["People plugin (sponsors)", {
+ plugin_people:true,
+ plugin_people_types:"sponsors",
+ }, {skip:["terminal"]}],
+ ["People plugin (stargazers)", {
+ plugin_people:true,
+ plugin_people_types:"stargazers",
+ }, {skip:["classic", "terminal"]}],
+ ["People plugin (watchers)", {
+ plugin_people:true,
+ plugin_people_types:"watchers",
+ }, {skip:["classic", "terminal"]}],
+ ["People plugin (thanks)", {
+ plugin_people:true,
+ plugin_people_types:"thanks",
+ plugin_people_thanks:"lowlighter",
+ }, {skip:["classic", "terminal"]}],
["People plugin (identicons)", {
plugin_people:true,
plugin_people_identicons:true,