From c2f0cbe2b1da13d6d6504865598095e56deb164b Mon Sep 17 00:00:00 2001 From: Simon Lecoq <22963968+lowlighter@users.noreply.github.com> Date: Tue, 26 Jul 2022 01:19:34 +0200 Subject: [PATCH] feat(plugins/lines): add `plugin_lines_sections` and new features (#1151) [skip ci] --- source/app/metrics/utils.mjs | 4 +- .../app/web/statics/embed/app.placeholder.js | 13 ++- .../embed/placeholders/lines.history.svg | 54 +++++++++++ source/plugins/lines/examples.yml | 13 ++- source/plugins/lines/index.mjs | 96 +++++++++++++++++-- source/plugins/lines/metadata.yml | 35 ++++++- source/templates/classic/partials/_.json | 1 + .../classic/partials/base.repositories.ejs | 2 +- source/templates/classic/partials/lines.ejs | 49 +++++++++- source/templates/classic/style.css | 41 ++++++++ .../rest/repos/getContributorsStats.mjs | 10 +- 11 files changed, 297 insertions(+), 21 deletions(-) create mode 100644 source/app/web/statics/embed/placeholders/lines.history.svg diff --git a/source/app/metrics/utils.mjs b/source/app/metrics/utils.mjs index 49e87ca9..feef0480 100644 --- a/source/app/metrics/utils.mjs +++ b/source/app/metrics/utils.mjs @@ -30,10 +30,12 @@ import twemojis from "twemoji-parser" import url from "url" import util from "util" import xmlformat from "xml-formatter" +import * as d3 from "d3" +import D3node from "d3-node" prism_lang() //Exports -export { axios, emoji, fetch, fs, git, minimatch, opengraph, os, paths, processes, rss, sharp, url, util } +export { axios, d3, D3node, emoji, fetch, fs, git, minimatch, opengraph, os, paths, processes, rss, sharp, url, util } /**Returns module __dirname */ export function __module(module) { diff --git a/source/app/web/statics/embed/app.placeholder.js b/source/app/web/statics/embed/app.placeholder.js index ac38afeb..5a05c684 100644 --- a/source/app/web/statics/embed/app.placeholder.js +++ b/source/app/web/statics/embed/app.placeholder.js @@ -167,8 +167,17 @@ ...(set.plugins.enabled.lines ? ({ lines: { - added: `${faker.datatype.number(100)}.${faker.datatype.number(9)}k`, - deleted: `${faker.datatype.number(100)}.${faker.datatype.number(9)}k`, + added: faker.datatype.number(1000000), + deleted: faker.datatype.number(1000000), + changed: faker.datatype.number(1000000), + sections: options["lines.sections"].split(",").map(x => x.trim()), + repos: new Array(options["lines.repositories.limit"] || 4).fill(null).map(_ => ({ + handle: `${faker.random.word()}/${faker.random.word()}`, + added: faker.datatype.number(10000), + deleted: faker.datatype.number(10000), + changed: faker.datatype.number(10000), + })), + history: await staticPlaceholder(set.plugins.enabled.lines, "lines.history.svg"), }, }) : null), diff --git a/source/app/web/statics/embed/placeholders/lines.history.svg b/source/app/web/statics/embed/placeholders/lines.history.svg new file mode 100644 index 00000000..b093b7d0 --- /dev/null +++ b/source/app/web/statics/embed/placeholders/lines.history.svg @@ -0,0 +1,54 @@ + + + + + + April + + + + July + + + + October + + + + 2022 + + + + April + + + + July + + + + + + + 400 + + + + 200 + + + + 0.0 + + + + −200 + + + + −400 + + + + + \ No newline at end of file diff --git a/source/plugins/lines/examples.yml b/source/plugins/lines/examples.yml index 73dc9fde..9879fed2 100644 --- a/source/plugins/lines/examples.yml +++ b/source/plugins/lines/examples.yml @@ -1,7 +1,18 @@ -- name: Lines of code changed +- name: Compact display in base plugin uses: lowlighter/metrics@latest with: filename: metrics.plugin.lines.svg token: ${{ secrets.METRICS_TOKEN }} base: repositories plugin_lines: yes + +- name: Repositories and diff history + uses: lowlighter/metrics@latest + with: + filename: metrics.plugin.lines.history.svg + token: ${{ secrets.METRICS_TOKEN }} + base: "" + plugin_lines: yes + plugin_lines_sections: repositories, history + plugin_lines_repositories_limit: 2 + plugin_lines_history_limit: 1 \ No newline at end of file diff --git a/source/plugins/lines/index.mjs b/source/plugins/lines/index.mjs index 1fd00b43..cb4cede6 100644 --- a/source/plugins/lines/index.mjs +++ b/source/plugins/lines/index.mjs @@ -7,7 +7,7 @@ export default async function({login, data, imports, rest, q, account}, {enabled return null //Load inputs - let {skipped} = imports.metadata.plugins.lines.inputs({data, account, q}) + let {skipped, sections, "repositories.limit":_repositories_limit, "history.limit":_history_limit} = imports.metadata.plugins.lines.inputs({data, account, q}) skipped.push(...data.shared["repositories.skipped"]) //Context @@ -22,23 +22,101 @@ export default async function({login, data, imports, rest, q, account}, {enabled //Get contributors stats from repositories console.debug(`metrics/compute/${login}/plugins > lines > querying api`) - const lines = {added: 0, deleted: 0, changed: 0} - const response = [...await Promise.allSettled(repositories.map(({repo, owner}) => (skipped.includes(repo.toLocaleLowerCase())) || (skipped.includes(`${owner}/${repo}`.toLocaleLowerCase())) ? {} : rest.repos.getContributorsStats({owner, repo})))].filter(({status}) => status === "fulfilled").map(({value}) => value) + const repos = {}, weeks = {} + const response = [...await Promise.allSettled(repositories.map(async ({repo, owner}) => (skipped.includes(repo.toLocaleLowerCase())) || (skipped.includes(`${owner}/${repo}`.toLocaleLowerCase())) ? {} : {handle:`${owner}/${repo}`, stats:(await rest.repos.getContributorsStats({owner, repo})).data}))].filter(({status}) => status === "fulfilled").map(({value}) => value) //Compute changed lines console.debug(`metrics/compute/${login}/plugins > lines > computing total diff`) - response.map(({data: repository}) => { + response.map(({handle, stats}) => { //Check if data are available - if (!Array.isArray(repository)) + if (!Array.isArray(stats)) return //Compute editions - const contributors = repository.filter(({author}) => context.mode === "repository" ? true : author?.login?.toLocaleLowerCase() === login.toLocaleLowerCase()) - for (const contributor of contributors) - contributor.weeks.forEach(({a = 0, d = 0, c = 0}) => (lines.added += a, lines.deleted += d, lines.changed += c)) + repos[handle] = {added:0, deleted:0, changed:0} + const contributors = stats.filter(({author}) => context.mode === "repository" ? true : author?.login?.toLocaleLowerCase() === login.toLocaleLowerCase()) + for (const contributor of contributors) { + let added = 0, changed = 0, deleted = 0 + contributor.weeks.forEach(({a = 0, d = 0, c = 0, w}) => { + added += a + deleted += d + changed += c + //Compute editions per week + const date = new Date(w * 1000).toISOString().substring(0, 10) + if (!weeks[date]) + weeks[date] = {added:0, deleted:0, changed:0} + weeks[date].added += a + weeks[date].deleted += d + weeks[date].changed += c + }) + console.debug(`metrics/compute/${login}/plugins > lines > ${handle}: @${contributor.author.login} +${added} -${deleted} ~${changed}`) + repos[handle].added += added + repos[handle].deleted += deleted + repos[handle].changed += changed + } }) //Results - return lines + const result = { + sections, + added: Object.entries(repos).map(([_, {added}]) => added).reduce((a, b) => a + b, 0), + deleted: Object.entries(repos).map(([_, {deleted}]) => deleted).reduce((a, b) => a + b, 0), + changed: Object.entries(repos).map(([_, {changed}]) => changed).reduce((a, b) => a + b, 0), + repos:Object.entries(repos).map(([handle, stats]) => ({handle, ...stats})).sort((a, b) => (b.added + b.deleted + b.changed) - (a.added + a.deleted + a.changed)).slice(0, _repositories_limit), + weeks:Object.entries(weeks).map(([date, stats]) => ({date, ...stats})).filter(({added, deleted, changed}) => added + deleted + changed).sort((a, b) => new Date(a.date) - new Date(b.date)), + } + + //Diff graphs + if (sections.includes("history")) { + //Generate SVG + const height = 315, width = 480 + const margin = 5, offset = 34 + const {d3} = imports + const weeks = result.weeks.filter(({date}) => !_history_limit ? true : new Date(date) > new Date(new Date().getFullYear() - _history_limit, 0, 0)) + const d3n = new imports.D3node() + const svg = d3n.createSVG(width, height) + + //Time range + const start = new Date(weeks.at(0).date) + const end = new Date(weeks.at(-1).date) + const x = d3.scaleTime() + .domain([start, end]) + .range([margin+offset, width-(offset+margin)]) + svg.append("g") + .attr("transform", `translate(0,${height-(offset+margin)})`) + .call(d3.axisBottom(x)) + .selectAll("text") + .attr("transform", "translate(-5,5) rotate(-45)") + .style("text-anchor", "end") + .style("font-size", 20) + + //Diff range + const points = weeks.flatMap(({added, deleted, changed}) => [added+changed, deleted+changed]) + const extremum = Math.max(...points) + const y = d3.scaleLinear() + .domain([extremum, -extremum]) + .range([margin, height-(offset+margin)]) + svg.append("g") + .attr("transform", `translate(${margin+offset},0)`) + .call(d3.axisLeft(y).ticks(7).tickFormat(d3.format(".2s"))) + .selectAll("text") + .style("font-size", 20) + + //Generate history + for (const {type, sign, fill} of [{type:"added", sign:+1, fill:"rgb(63, 185, 80)"}, {type:"deleted", sign:-1, fill:"rgb(218, 54, 51)"}]) { + svg.append("path") + .datum(weeks.map(({date, ...diff}) => [new Date(date), sign*(diff[type]+diff.changed)])) + .attr("d", d3.area() + .x(d => x(d[0])) + .y0(d => y(d[1])) + .y1(() => y(0)) + ) + .attr("fill", fill) + } + result.history = d3n.svgString() + } + + //Results + return result } //Handle errors catch (error) { diff --git a/source/plugins/lines/metadata.yml b/source/plugins/lines/metadata.yml index 02d01003..f48fdfa7 100644 --- a/source/plugins/lines/metadata.yml +++ b/source/plugins/lines/metadata.yml @@ -3,7 +3,8 @@ category: github description: | This plugin displays the number of lines of code added and removed across repositories. examples: - default: https://github.com/lowlighter/metrics/blob/examples/metrics.plugin.lines.svg + +Repositories and diff history: https://github.com/lowlighter/metrics/blob/examples/metrics.plugin.lines.history.svg + Compact display in base plugin: https://github.com/lowlighter/metrics/blob/examples/metrics.plugin.lines.svg index: 18 supports: - user @@ -27,3 +28,35 @@ inputs: default: "" example: my-repo-1, my-repo-2, owner/repo-3, ... inherits: repositories_skipped + + plugin_lines_sections: + description: | + Displayed sections + + - `base` will display the total lines added and removed in `base.repositories` section + - `repositories` will display repositories with the most lines added and removed + - `history` will display a graph displaying lines added and removed over time + type: array + format: comma-separated + default: base + example: repositories, history + values: + - base + - repositories + - history + + plugin_lines_repositories_limit: + description: | + Display limit + type: number + default: 4 + min: 0 + + plugin_lines_history_limit: + description: | + Years to display + + Will display the last `n` years, relative to current year + type: number + default: 1 + zero: disable \ No newline at end of file diff --git a/source/templates/classic/partials/_.json b/source/templates/classic/partials/_.json index f207da59..d6ee0cd2 100644 --- a/source/templates/classic/partials/_.json +++ b/source/templates/classic/partials/_.json @@ -3,6 +3,7 @@ "introduction", "base.activity+community", "base.repositories", + "lines", "followup", "discussions", "languages", diff --git a/source/templates/classic/partials/base.repositories.ejs b/source/templates/classic/partials/base.repositories.ejs index 7d7757db..f1744357 100644 --- a/source/templates/classic/partials/base.repositories.ejs +++ b/source/templates/classic/partials/base.repositories.ejs @@ -28,7 +28,7 @@ <%= computed.diskUsage %> used - <% if (plugins.lines) { %> + <% if ((plugins.lines)&&(plugins.lines.sections?.includes("base"))) { %>
<% if (plugins.lines.error) { %> diff --git a/source/templates/classic/partials/lines.ejs b/source/templates/classic/partials/lines.ejs index ca606c7e..27c60319 100644 --- a/source/templates/classic/partials/lines.ejs +++ b/source/templates/classic/partials/lines.ejs @@ -1 +1,48 @@ -<%# Included in base.repositories.ejs %> \ No newline at end of file +<% if ((plugins.lines)&&((plugins.lines.sections.includes("history"))||(plugins.lines.sections.includes("repositories")))) { %> +
+

+ + Lines of code pushed +

+ <% if (plugins.lines.error) { %> +
+
+
+ + <%= plugins.lines.error.message %> +
+
+
+ <% } else { %> + <% if (plugins.lines.sections?.includes("repositories")) { %> +
+
+ <% for (const {handle, added, deleted, changed} of plugins.lines.repos) { %> +
+ + <%= handle %> +
+ <% } %> +
+
+ <% for (const {handle, added, deleted, changed} of plugins.lines.repos) { %> +
+ <% for (let i = 1; i <= 5; i++) { %> +
+ <% } %> +
<%= `+${f(added+changed)}`.padStart(7) %> <%= `-${f(deleted+changed)}`.padStart(7) %>
+   +
+ <% } %> +
+
+ <% } %> + <% if ((plugins.lines.sections?.includes("history"))&&(plugins.lines.history)) { %> +
+

Diff history

+ <%- plugins.lines.history %> +
+ <% } %> + <% } %> +
+<% } %> \ No newline at end of file diff --git a/source/templates/classic/style.css b/source/templates/classic/style.css index d16ac4e0..39096502 100644 --- a/source/templates/classic/style.css +++ b/source/templates/classic/style.css @@ -853,6 +853,47 @@ fill: currentColor; } +/* Diff stats */ + .diff-handle { + color: #58a6ff; + max-width: 200px; + text-overflow: ellipsis; + overflow: hidden; + } + .large .diff-handle { + max-width: 400px; + } + .diff-box { + display: inline-block; + width: 8px; + height: 8px; + margin-left: 1px; + background-color: rgba(110, 118, 129, 0.4); + border: 1px solid rgba(246, 240, 251, 0.1); + } + .diff-box:first-child { + margin-left: 9px; + } + .diff-box.added { + background-color: rgb(63, 185, 80); + } + .diff-box.deleted { + background-color: rgb(218, 54, 51); + } + .diff-stats { + margin-left: 4px; + font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace; + font-weight: bold; + font-size: 12px; + white-space: pre; + } + .added { + color: rgb(63, 185, 80); + } + .deleted { + color: rgb(218, 54, 51); + } + /* People */ .people { padding: 0 10px; diff --git a/tests/mocks/api/github/rest/repos/getContributorsStats.mjs b/tests/mocks/api/github/rest/repos/getContributorsStats.mjs index a32727e3..89df465e 100644 --- a/tests/mocks/api/github/rest/repos/getContributorsStats.mjs +++ b/tests/mocks/api/github/rest/repos/getContributorsStats.mjs @@ -13,11 +13,11 @@ export default async function({faker}, target, that, [{owner, repo}]) { { total: faker.datatype.number(10000), weeks: [ - {w: 1, a: faker.datatype.number(10000), d: faker.datatype.number(10000), c: faker.datatype.number(10000)}, - {w: 2, a: faker.datatype.number(10000), d: faker.datatype.number(10000), c: faker.datatype.number(10000)}, - {w: 3, a: faker.datatype.number(10000), d: faker.datatype.number(10000), c: faker.datatype.number(10000)}, - {w: 4, a: faker.datatype.number(10000), d: faker.datatype.number(10000), c: faker.datatype.number(10000)}, - ], + {w: faker.date.recent(), a: faker.datatype.number(10000), d: faker.datatype.number(10000), c: faker.datatype.number(10000)}, + {w: faker.date.recent(), a: faker.datatype.number(10000), d: faker.datatype.number(10000), c: faker.datatype.number(10000)}, + {w: faker.date.recent(), a: faker.datatype.number(10000), d: faker.datatype.number(10000), c: faker.datatype.number(10000)}, + {w: faker.date.recent(), a: faker.datatype.number(10000), d: faker.datatype.number(10000), c: faker.datatype.number(10000)}, + ].sort((a, b) => new Date(a.w) - new Date(b.w)), author: { login: owner, },