diff --git a/source/app/mocks/api/github/graphql/contributors.commit.mjs b/source/app/mocks/api/github/graphql/contributors.commit.mjs
new file mode 100644
index 00000000..02e6a115
--- /dev/null
+++ b/source/app/mocks/api/github/graphql/contributors.commit.mjs
@@ -0,0 +1,14 @@
+/**Mocked data */
+ export default function({faker, query, login = faker.internet.userName()}) {
+ console.debug("metrics/compute/mocks > mocking graphql api result > contributors/commit")
+ return ({
+ repository:{
+ object:{
+ oid:"MOCKED_SHA",
+ abbreviatedOid:"MOCKED_SHA",
+ messageHeadline:faker.lorem.sentence(),
+ committedDate:faker.date.recent(),
+ },
+ },
+ })
+ }
diff --git a/source/app/mocks/api/github/rest/repos/listCommits.mjs b/source/app/mocks/api/github/rest/repos/listCommits.mjs
index 4f6a89d1..f28ae8c5 100644
--- a/source/app/mocks/api/github/rest/repos/listCommits.mjs
+++ b/source/app/mocks/api/github/rest/repos/listCommits.mjs
@@ -11,13 +11,20 @@
},
data:page < 2 ? new Array(per_page).fill(null).map(() => ({
sha:"MOCKED_SHA",
+ get author() {
+ return this.commit.author
+ },
commit:{
author:{
name:owner,
+ login:faker.internet.userName(),
+ avatar_url:null,
date:`${faker.date.recent(14)}`,
},
committer:{
name:owner,
+ login:faker.internet.userName(),
+ avatar_url:null,
date:`${faker.date.recent(14)}`,
},
},
diff --git a/source/plugins/contributors/README.md b/source/plugins/contributors/README.md
new file mode 100644
index 00000000..5cc4e5d0
--- /dev/null
+++ b/source/plugins/contributors/README.md
@@ -0,0 +1,25 @@
+### đ
Contributors
+
+The *contributors* plugin lets you display repositories contributors from a commit range, that can be specified through either sha, tags, branch, etc.
+
+It's especially useful to acknowledge contributors on release notes.
+
+
+
+
+
+ |
+
+
+#### âšī¸ Examples workflows
+
+[âĄī¸ Available options for this plugin](metadata.yml)
+
+```yaml
+- uses: lowlighter/metrics@latest
+ with:
+ # ... other options
+ plugin_contributors: yes
+ plugin_contributors_base: "" # Base reference (commit, tag, branch, etc.)
+ plugin_contributors_head: master # Head reference (commit, tag, branch, etc.)
+```
\ No newline at end of file
diff --git a/source/plugins/contributors/index.mjs b/source/plugins/contributors/index.mjs
new file mode 100644
index 00000000..a14e8411
--- /dev/null
+++ b/source/plugins/contributors/index.mjs
@@ -0,0 +1,69 @@
+//Setup
+ export default async function({login, q, imports, data, rest, graphql, queries, account}, {enabled = false} = {}) {
+ //Plugin execution
+ try {
+ //Check if plugin is enabled and requirements are met
+ if ((!enabled)||(!q.contributors))
+ return null
+
+ //Load inputs
+ let {head, base} = imports.metadata.plugins.contributors.inputs({data, account, q})
+ const repo = {owner:data.repo.owner.login, repo:data.repo.name}
+
+ //Retrieve head and base commits
+ console.debug(`metrics/compute/${login}/plugins > contributors > querying api head and base commits`)
+ const ref = {
+ head:(await graphql(queries.contributors.commit({...repo, expression:head}))).repository.object,
+ base:(await graphql(queries.contributors.commit({...repo, expression:base}))).repository.object,
+ }
+
+ //Get commit activity
+ console.debug(`metrics/compute/${login}/plugins > contributors > querying api for commits between [${ref.base?.abbreviatedOid ?? null}] and [${ref.head?.abbreviatedOid ?? null}]`)
+ const commits = []
+ for (let page = 0; ; page++) {
+ console.debug(`metrics/compute/${login}/plugins > contributors > loading page ${page}`)
+ try {
+ const {data:loaded} = await rest.repos.listCommits({...repo, per_page:100, page})
+ if (loaded.map(({sha}) => sha).includes(ref.base?.oid)) {
+ console.debug(`metrics/compute/${login}/plugins > contributors > reached ${ref.base?.oid}`)
+ commits.push(...loaded.slice(0, loaded.map(({sha}) => sha).indexOf(ref.base.oid)))
+ break
+ }
+ if (!loaded.length) {
+ console.debug(`metrics/compute/${login}/plugins > contributors > no more page to load`)
+ break
+ }
+ commits.push(...loaded)
+ }
+ catch (error) {
+ if (/Git Repository is empty/.test(error))
+ break
+ throw error
+ }
+ }
+
+ //Remove commits after head
+ const start = Math.max(0, commits.map(({sha}) => sha).indexOf(ref.head?.oid))
+ commits.splice(0, start)
+ console.debug(`metrics/compute/${login}/plugins > contributors > ${commits.length} commits loaded (${start} removed)`)
+
+ //Compute contributors and contributions
+ let contributors = {}
+ for (const {author:{login, avatar_url:avatar}} of commits) {
+ if (!login)
+ continue
+ if (!(login in contributors))
+ contributors[login] = {avatar:avatar ? await imports.imgb64(avatar) : "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mOcOnfpfwAGfgLYttYINwAAAABJRU5ErkJggg==", contributions:0}
+ else
+ contributors[login].contributions++
+ }
+ contributors = Object.fromEntries(Object.entries(contributors).sort((a, b) => b.contributions - a.contributions))
+
+ //Results
+ return {head, base, ref, list:contributors}
+ }
+ //Handle errors
+ catch (error) {
+ throw {error:{message:"An error occured", instance:error}}
+ }
+ }
\ No newline at end of file
diff --git a/source/plugins/contributors/metadata.yml b/source/plugins/contributors/metadata.yml
new file mode 100644
index 00000000..3143b2ce
--- /dev/null
+++ b/source/plugins/contributors/metadata.yml
@@ -0,0 +1,23 @@
+name: "đ
Contributors"
+cost: N/A
+supports:
+ - repository
+inputs:
+
+ # Enable or disable plugin
+ plugin_contributors:
+ description: Display repository contributors
+ type: boolean
+ default: no
+
+ # Base reference (commit, tag, branch, etc.)
+ plugin_contributors_base:
+ description: Base reference
+ type: string
+ default: ""
+
+ # Head reference (commit, tag, branch, etc.)
+ plugin_contributors_head:
+ description: Head reference
+ type: string
+ default: master
\ No newline at end of file
diff --git a/source/plugins/contributors/queries/commit.graphql b/source/plugins/contributors/queries/commit.graphql
new file mode 100644
index 00000000..82ce65c7
--- /dev/null
+++ b/source/plugins/contributors/queries/commit.graphql
@@ -0,0 +1,12 @@
+query ContributorsCommit {
+ repository(owner: "$owner" name: "$repo") {
+ object(expression: "$expression") {
+ ... on Commit {
+ oid
+ abbreviatedOid
+ messageHeadline
+ committedDate
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/source/plugins/contributors/tests.yml b/source/plugins/contributors/tests.yml
new file mode 100644
index 00000000..771015d5
--- /dev/null
+++ b/source/plugins/contributors/tests.yml
@@ -0,0 +1,13 @@
+- name: Contributors plugin (default)
+ uses: lowlighter/metrics@latest
+ with:
+ token: MOCKED_TOKEN
+ plugin_contributors: yes
+
+- name: Contributors plugin (default)
+ uses: lowlighter/metrics@latest
+ with:
+ token: MOCKED_TOKEN
+ plugin_contributors: yes
+ plugin_contributors_head: MOCKED_SHA
+ plugin_contributors_base: MOCKED_SHA
diff --git a/source/templates/classic/style.css b/source/templates/classic/style.css
index b44d6924..ef480595 100644
--- a/source/templates/classic/style.css
+++ b/source/templates/classic/style.css
@@ -686,6 +686,27 @@
fill: #58a6ff;
}
+/* Contributors */
+ .contributors {
+ display: flex;
+ flex-wrap: wrap;
+ margin-left: 6px;
+ }
+ .contributors .label {
+ padding-left: 0;
+ display: flex;
+ align-items: center;
+ position: relative;
+ }
+ .contributors .label img {
+ margin-left: 0;
+ }
+ .contributors .contributions {
+ position: absolute;
+ bottom: 100%;
+ right: 100%;
+ }
+
/* Fade animation */
.af {
opacity: 0;
diff --git a/source/templates/repository/partials/_.json b/source/templates/repository/partials/_.json
index f35e07f6..9bc05974 100644
--- a/source/templates/repository/partials/_.json
+++ b/source/templates/repository/partials/_.json
@@ -7,5 +7,6 @@
"stargazers",
"people",
"activity",
+ "contributors",
"licenses"
]
\ No newline at end of file
diff --git a/source/templates/repository/partials/contributors.ejs b/source/templates/repository/partials/contributors.ejs
new file mode 100644
index 00000000..42f0f968
--- /dev/null
+++ b/source/templates/repository/partials/contributors.ejs
@@ -0,0 +1,30 @@
+<% if (plugins.contributors) { %>
+
+
+
+ Contributors
+ <% if (plugins.contributors.base || plugins.contributors.ref?.base?.abbreviatedOid) { %>
+ from <%= plugins.contributors.base || plugins.contributors.ref?.base?.abbreviatedOid %> to <%= plugins.contributors.head || plugins.contributors.ref?.head?.abbreviatedOid %>
+ <% } else if (plugins.contributors.head || plugins.contributors.ref?.head?.abbreviatedOid) { %>
+ of <%= plugins.contributors.head || plugins.contributors.ref?.head?.abbreviatedOid %>
+ <% } %>
+
+
+
+ <% if (plugins.contributors.error) { %>
+
+
+ <%= plugins.contributors.error.message %>
+
+ <% } else { %>
+ <% for (const [login, {avatar}] of Object.entries(plugins.contributors.list)) { %>
+
+

+ <%= login %>
+
+ <% } %>
+ <% } %>
+
+
+
+<% } %>