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 %> +
+ <% } %> + <% } %> +
+
+
+<% } %>