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. ![People plugin](https://github.com/lowlighter/lowlighter/blob/master/metrics.plugin.people.svg) @@ -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,