Add indepth languages analysis (#325)

This commit is contained in:
Simon Lecoq
2021-05-25 20:41:53 +02:00
committed by GitHub
parent 38e85eec11
commit 22d442a03c
5 changed files with 139 additions and 5 deletions

View File

@@ -17,6 +17,16 @@ You can specify either an index with a color, or a language name (case insensiti
Colors can be either in hexadecimal format or a [named color](https://developer.mozilla.org/en-US/docs/Web/CSS/color_value).
It is also possible to use a predefined set of colors from [colorsets.json](colorsets.json)
**Using `indepth` statistics**
Languages statistics are computed using the top languages of each repository you contributed to.
If you work a lot with other people, these numbers may be less representative of your actual work.
The `plugin_languages_indepth` option lets you get more accurate metrics by cloning each repository, running [github/linguist](https://github.com/github/linguist) on it and iterating over patches matching your username from `git log`, but will be **significantly slower**.
> ⚠️ Although *metrics* does not send any code to external sources, you must understand that when using this option repositories are cloned locally temporarly on the GitHub Action runner. If you work with sensitive data or company code, it is advised to keep this option disabled. *Metrics* cannot be held responsible for any eventual code leaks, use at your own risk.
> Source code is available for auditing at [indepth.mjs](/source/plugins/languages/indepth.mjs)
#### Examples workflows
[➡️ Available options for this plugin](metadata.yml)
@@ -32,4 +42,5 @@ It is also possible to use a predefined set of colors from [colorsets.json](colo
plugin_languages_details: bytes-size, percentage # Additionally display total bytes size and percentage
plugin_languages_threshold: 2% # Hides all languages less than 2%
plugin_languages_limit: 8 # Display up to 8 languages
plugin_languages_indepth: no # Get indepth stats (see documentation before enabling)
```

View File

@@ -0,0 +1,105 @@
/**Indepth analyzer */
export default async function({login, data, imports}, {skipped, ignored}) {
//Check prerequisites
if (!await imports.which("github-linguist"))
throw new Error("Feature requires github-linguist")
//Compute repositories stats
const results = {total:0, stats:{}}
for (const repository of data.user.repositories.nodes) {
const repo = `${repository.owner.login}/${repository.name}`
console.debug(`metrics/compute/${login}/plugins > languages > indepth > checking ${repo}`)
//Skip repository if asked
if ((skipped.includes(repository.name.toLocaleLowerCase())) || (skipped.includes(`${repository.owner.login}/${repository.name}`.toLocaleLowerCase()))) {
console.debug(`metrics/compute/${login}/plugins > languages > skipped repository ${repository.owner.login}/${repository.name}`)
continue
}
//Analyze
try {
await analyze(arguments[0], {repo, results})
}
catch {
console.debug(`metrics/compute/${login}/plugins > languages > indepth > an error occured while processing ${repo}, skipping...`)
}
}
//Ignore languages if asked
Object.assign(results.stats, Object.fromEntries(Object.entries(results.stats).filter(([lang]) => !ignored.includes(lang.toLocaleLowerCase()))))
return results
}
/**Clone and analyze a single repository */
async function analyze({login, data, imports}, {repo, results}) {
//Git clone into a temporary directory
const path = imports.paths.join(imports.os.tmpdir(), `${data.user.databaseId}-${repo.replace(/[^\w]/g, "_")}`)
console.debug(`metrics/compute/${login}/plugins > languages > indepth > cloning ${repo} to temp dir ${path}`)
await imports.fs.rmdir(path, {recursive:true})
await imports.fs.mkdir(path, {recursive:true})
const git = await imports.git(path)
await git.clone(`https://github.com/${repo}`, ".").status()
//Spawn linguist process and map files to languages
console.debug(`metrics/compute/${login}/plugins > languages > indepth > running linguist`)
const files = {}
{
const stdout = await imports.run("github-linguist --breakdown", {cwd:path}, {log:false})
let lang = null
for (const line of stdout.split("\n").map(line => line.trim())) {
//Ignore empty lines
if (!line.length)
continue
//Language marker
if (/^(?<lang>[\s\S]+):\s*$/.test(line)) {
lang = line.match(/^(?<lang>[\s\S]+):\s*$/)?.groups?.lang ?? null
continue
}
//Store language
if (lang) {
files[line] = {lang}
continue
}
}
}
//Processing diff
const per_page = 10
console.debug(`metrics/compute/${login}/plugins > languages > indepth > checking git log`)
for (let page = 0; ; page++) {
try {
const stdout = await imports.run(`git log --author="${login}" --format="" --patch --max-count=${per_page} --skip=${page*per_page}`, {cwd:path}, {log:false})
let file = null, lang = null
if (!stdout.trim().length) {
console.debug(`metrics/compute/${login}/plugins > languages > indepth > no more commits`)
break
}
console.debug(`metrics/compute/${login}/plugins > languages > indepth > processing commits ${page*per_page} from ${(page+1)*per_page}`)
for (const line of stdout.split("\n").map(line => line.trim())) {
//Ignore empty lines or unneeded lines
if ((!/^[+]/.test(line))||(!line.length))
continue
//File marker
if (/^[+]{3}\sb[/](?<file>[\s\S]+)$/.test(line)) {
file = line.match(/^[+]{3}\sb[/](?<file>[\s\S]+)$/)?.groups?.file ?? null
lang = files[file]?.lang ?? null
continue
}
//Ignore unkonwn languages
if (!lang)
continue
//Added line marker
if (/^[+]\s(?<line>[\s\S]+)$/.test(line)) {
const size = Buffer.byteLength(line.match(/^[+]\s(?<line>[\s\S]+)$/)?.groups?.line ?? "", "utf-8")
results.stats[lang] = (results.stats[lang] ?? 0) + size
results.total += size
}
}
}
catch {
console.debug(`metrics/compute/${login}/plugins > languages > indepth > an error occured on page ${page}, skipping...`)
}
}
//Cleaning
console.debug(`metrics/compute/${login}/plugins > languages > indepth > cleaning temp dir ${path}`)
await imports.fs.rmdir(path, {recursive:true})
}

View File

@@ -1,3 +1,6 @@
//Imports
import indepth_analyzer from "./indepth.mjs"
//Setup
export default async function({login, data, imports, q, account}, {enabled = false} = {}) {
//Plugin execution
@@ -7,7 +10,7 @@ export default async function({login, data, imports, q, account}, {enabled = fal
return null
//Load inputs
let {ignored, skipped, colors, details, threshold, limit} = imports.metadata.plugins.languages.inputs({data, account, q})
let {ignored, skipped, colors, details, threshold, limit, indepth} = imports.metadata.plugins.languages.inputs({data, account, q})
threshold = (Number(threshold.replace(/%$/, "")) || 0) / 100
skipped.push(...data.shared["repositories.skipped"])
if (!limit)
@@ -43,6 +46,12 @@ export default async function({login, data, imports, q, account}, {enabled = fal
}
}
//Indepth mode
if (indepth) {
console.debug(`metrics/compute/${login}/plugins > languages > switching to indepth mode (this may take some time)`)
Object.assign(languages, await indepth_analyzer({login, data, imports}, {skipped, ignored}))
}
//Compute languages stats
console.debug(`metrics/compute/${login}/plugins > languages > computing stats`)
languages.favorites = Object.entries(languages.stats).sort(([_an, a], [_bn, b]) => b - a).slice(0, limit).map(([name, value]) => ({name, value, size:value, color:languages.colors[name], x:0})).filter(({value}) => value / languages.total > threshold)

View File

@@ -69,4 +69,11 @@ inputs:
plugin_languages_threshold:
description: Minimum threshold
type: string
default: 0%
default: 0%
# Compute indepth languages statistics by cloning repositories and processing your commits individually
# See documentation before enabling
plugin_languages_indepth:
description: Indepth languages processing (see documentation before enabling)
type: boolean
default: false