diff --git a/source/app/metrics/utils.mjs b/source/app/metrics/utils.mjs index 6872cc7a..f4d41e15 100644 --- a/source/app/metrics/utils.mjs +++ b/source/app/metrics/utils.mjs @@ -208,16 +208,15 @@ export async function which(command) { return false } +/**Code hightlighter */ +export async function highlight(code, lang) { + return lang in prism.languages ? prism.highlight(code, prism.languages[lang]) : code +} + /**Markdown-html sanitizer-interpreter */ export async function markdown(text, {mode = "inline", codelines = Infinity} = {}) { //Sanitize user input once to prevent injections and parse into markdown - let rendered = await marked(htmlunescape(htmlsanitize(text)), { - highlight(code, lang) { - return lang in prism.languages ? prism.highlight(code, prism.languages[lang]) : code - }, - silent:true, - xhtml:true, - }) + let rendered = await marked(htmlunescape(htmlsanitize(text)), {highlight, silent:true, xhtml:true}) //Markdown mode switch (mode) { case "inline": { diff --git a/source/app/mocks/api/github/rest/request.mjs b/source/app/mocks/api/github/rest/request.mjs index 35b29237..c038cc07 100644 --- a/source/app/mocks/api/github/rest/request.mjs +++ b/source/app/mocks/api/github/rest/request.mjs @@ -35,6 +35,7 @@ export default function({faker}, target, that, args) { email:faker.internet.email(), date:`${faker.date.recent(7)}`, }, + url:"https://api.github.com/repos/lowlighter/metrics/commits/MOCKED_SHA", }, author:{ login:faker.internet.userName(), diff --git a/source/app/web/statics/app.placeholder.js b/source/app/web/statics/app.placeholder.js index d0eeb035..44f60d21 100644 --- a/source/app/web/statics/app.placeholder.js +++ b/source/app/web/statics/app.placeholder.js @@ -336,6 +336,23 @@ }, }) : null), + //Code snippet + ...(set.plugins.enabled.code + ? ({ + code: { + snippet: { + sha: faker.git.shortSha(), + message: faker.lorem.sentence(), + filename: 'docs/specifications.html', + status: "modified", + additions: faker.datatype.number(50), + deletions: faker.datatype.number(50), + patch: `@@ -0,0 +1,5 @@
//Imports
+ import app from "./src/app.mjs"
- import app from "./src/app.js"
//Start app
await app()
\\ No newline at end of file`, + repo: `${faker.random.word()}/${faker.random.word()}`, + }, + } + }) + : null), //Languages ...(set.plugins.enabled.languages ? ({ diff --git a/source/plugins/code/README.md b/source/plugins/code/README.md new file mode 100644 index 00000000..276543e9 --- /dev/null +++ b/source/plugins/code/README.md @@ -0,0 +1,27 @@ +### ♐ Code snippet of the day + +> ⚠️ When improperly configured, this plugin could display private code. If you work with sensitive data or company code, it is advised to keep this plugin disabled. *Metrics* and its authors cannot be held responsible for any resulting code leaks, use at your own risk. + +Display a random code snippet from your recent activity history. + + + +
+ + +
+ +#### ℹ️ Examples workflows + +[➡️ Available options for this plugin](metadata.yml) + +```yaml +- uses: lowlighter/metrics@latest + with: + # ... other options + plugin_code: yes + plugin_code_lines: 12 # Only display snippets with less than 12 lines + plugin_code_load: 100 # Fetch 100 events from activity + plugin_code_visibility: public # Only display snippets from public activity + plugin_code_skipped: github/octocat # Skip github/octocat repository +``` \ No newline at end of file diff --git a/source/plugins/code/index.mjs b/source/plugins/code/index.mjs new file mode 100644 index 00000000..8f8e8027 --- /dev/null +++ b/source/plugins/code/index.mjs @@ -0,0 +1,66 @@ +//Setup +export default async function({login, q, imports, data, rest, account}, {enabled = false} = {}) { + //Plugin execution + try { + //Check if plugin is enabled and requirements are met + if ((!enabled)||(!q.code)) + return null + + //Context + let context = {mode:"user"} + if (q.repo) { + console.debug(`metrics/compute/${login}/plugins > code > switched to repository mode`) + const {owner, repo} = data.user.repositories.nodes.map(({name:repo, owner:{login:owner}}) => ({repo, owner})).shift() + context = {...context, mode:"repository", owner, repo} + } + + //Load inputs + let {load, lines, visibility, skipped} = imports.metadata.plugins.code.inputs({data, q, account}) + skipped.push(...data.shared["repositories.skipped"]) + const pages = Math.ceil(load / 100) + + //Get user recent code + console.debug(`metrics/compute/${login}/plugins > code > querying api`) + const events = [] + try { + for (let page = 1; page <= pages; page++) { + console.debug(`metrics/compute/${login}/plugins > code > loading page ${page}/${pages}`) + events.push(...[...await Promise.all([...(context.mode === "repository" ? await rest.activity.listRepoEvents({owner:context.owner, repo:context.repo}) : await rest.activity.listEventsForAuthenticatedUser({username:login, per_page:100, page})).data + .filter(({type}) => type === "PushEvent") + .filter(({actor}) => account === "organization" ? true : actor.login?.toLocaleLowerCase() === login.toLocaleLowerCase()) + .filter(({repo:{name:repo}}) => !((skipped.includes(repo.split("/").pop())) || (skipped.includes(repo)))) + .filter(event => visibility === "public" ? event.public : true) + .flatMap(({payload}) => Promise.all(payload.commits.map(async commit => (await rest.request(commit.url)).data)))])] + .flat() + .filter(({author}) => data.shared["commits.authoring"].filter(authoring => author?.email?.toLocaleLowerCase().includes(authoring)||author?.name?.toLocaleLowerCase().includes(authoring))) + ) + } + } + catch { + console.debug(`metrics/compute/${login}/plugins > code > no more page to load`) + } + console.debug(`metrics/compute/${login}/plugins > code > ${events.length} events loaded`) + + //Search for a random snippet + const files = events + .flatMap(({sha, commit:{message, url}, files}) => files.map(({filename, status, additions, deletions, patch}) => ({sha, message, filename, status, additions, deletions, patch, repo:url.match(/repos[/](?[\s\S]+)[/]git[/]commits/)?.groups?.repo}))) + .filter(({patch}) => (patch ? (patch.match(/\n/mg)?.length ?? 1) : Infinity) < lines) + const snippet = files[Math.floor(Math.random()*files.length)] + + //Trim common indent from content and change line feed + if (!snippet.patch.split("\n").shift().endsWith("@@")) + snippet.patch = snippet.patch.replace(/^(?@@.*?@@)/, "$\n") + const indent = Math.min(...(snippet.patch.match(/^[+-]? +/mg)?.map(indent => (indent.length ?? Infinity) - indent.startsWith("+") - indent.startsWith("-")) ?? [])) || 0 + const content = imports.htmlescape(snippet.patch.replace(/\r\n/mg, "\n").replace(new RegExp(`^([+-]?)${" ".repeat(indent)}`, "mg"), "$1")) + + //Format patch + snippet.patch = imports.htmlunescape((await imports.highlight(content, "diff")).trim()) + + //Results + return {snippet} + } + //Handle errors + catch (error) { + throw {error:{message:"An error occured", instance:error}} + } +} \ No newline at end of file diff --git a/source/plugins/code/metadata.yml b/source/plugins/code/metadata.yml new file mode 100644 index 00000000..e9041ad5 --- /dev/null +++ b/source/plugins/code/metadata.yml @@ -0,0 +1,46 @@ +name: "♐ Code snippet of the day" +cost: 1 REST request per 100 events fetched +category: github +supports: + - user + - organization + - repository +inputs: + + # Enable or disable plugin + plugin_code: + description: Display a random code snippet from recent activity + type: boolean + default: no + + # Maximum number of lines that a code snippet can contain + plugin_code_lines: + description: Maximum number of line that a code snippet can contain + type: number + default: 12 + + # Number of activity events to load + # A high number will consume more requests + plugin_code_load: + description: Number of events to load + type: number + default: 100 + min: 100 + max: 1000 + + # Set events visibility (use this to restrict events when using a "repo" token) + plugin_code_visibility: + description: Set events visibility + type: string + default: public + values: + - public + - all + + # List of repositories that will be skipped + plugin_code_skipped: + description: Repositories to skip + type: array + format: comma-separated + default: "" + example: my-repo-1, my-repo-2, owner/repo-3 ... \ No newline at end of file diff --git a/source/plugins/code/tests.yml b/source/plugins/code/tests.yml new file mode 100644 index 00000000..32bf0a4c --- /dev/null +++ b/source/plugins/code/tests.yml @@ -0,0 +1,5 @@ +- name: Code plugin (default) + uses: lowlighter/metrics@latest + with: + token: MOCKED_TOKEN + plugin_code: yes \ No newline at end of file diff --git a/source/plugins/languages/README.md b/source/plugins/languages/README.md index 62df2db2..881aaa8e 100644 --- a/source/plugins/languages/README.md +++ b/source/plugins/languages/README.md @@ -30,7 +30,7 @@ If you work a lot with other people, these numbers may be less representative of The `plugin_languages_indepth` option lets you get more accurate metrics by cloning each repository you contributed to, running [github/linguist](https://github.com/github/linguist) on it and then iterating over patches matching your username from `git log`. This method is slower than the first one. -> ⚠️ 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* and its authors cannot be held responsible for any eventual code leaks, use at your own risk. +> ⚠️ 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* and its authors cannot be held responsible for any resulting code leaks, use at your own risk. > Source code is available for auditing at [analyzers.mjs](/source/plugins/languages/analyzers.mjs) > 🔣 On web instances, `indepth` is an extra feature and must be enabled globally in `settings.json` diff --git a/source/templates/classic/partials/_.json b/source/templates/classic/partials/_.json index eb2a5191..c02068e3 100644 --- a/source/templates/classic/partials/_.json +++ b/source/templates/classic/partials/_.json @@ -31,5 +31,6 @@ "stackoverflow", "stock", "achievements", - "screenshot" + "screenshot", + "code" ] diff --git a/source/templates/classic/partials/code.ejs b/source/templates/classic/partials/code.ejs new file mode 100644 index 00000000..dbee7bd5 --- /dev/null +++ b/source/templates/classic/partials/code.ejs @@ -0,0 +1,44 @@ +<% if (plugins.code) { %> +
+

+ + Code snippet of the day +

+ <% if (plugins.code.error) { %> +
+
+
+ + <%= plugins.code.error.message %> +
+
+
+ <% } else { %> +
+
+
+ + From <%= plugins.code.snippet.repo %> +
+
+ + <%= plugins.code.snippet.message %> +
+
+ + <%= plugins.code.snippet.sha.substring(0, 8) %> + <%= plugins.code.snippet.filename %> + ++<%= plugins.code.snippet.additions %> --<%= plugins.code.snippet.deletions %> +
+
+
+
+
+
+ <%- plugins.code.snippet.patch %> +
+
+
+ <% } %> +
+<% } %> diff --git a/source/templates/classic/style.css b/source/templates/classic/style.css index 6eb5c9df..ba0c7f24 100644 --- a/source/templates/classic/style.css +++ b/source/templates/classic/style.css @@ -1121,6 +1121,17 @@ overflow: hidden; } +/* Code snippet */ + .snippet .body { + padding-left: 12px; + } + .snippet.additions { + color: #336543; + } + .snippet.deletions { + color: #9A5256; + } + /* Markdown and syntax highlighting */ .markdown b, .markdown i { display: inline-block; @@ -1140,6 +1151,15 @@ width: 97%; margin-top: 4px; } + span.code { + background-color: #7777771F; + padding: 1px 5px; + font-size: 80%; + border-radius: 6px; + color: #777777; + font-family: SFMono-Regular,Consolas,Liberation Mono,Menlo,monospace; + margin: 0 4px -3px; + } .token.comment { color: #669900; } @@ -1160,7 +1180,27 @@ } .token.trimmed { font-style: italic; - color: #77777760 + color: #77777760; + } + .token.coord { + color: #D2A8FF; + font-weight: bold; + } + .token.inserted:not(.prefix) { + color: #AAD0B4DC; + background-color: #336543DC; + } + .token.deleted:not(.prefix) { + color: #EED2D0DC; + background-color: #9A5256DC; + } + +/* Typography */ + .space { + margin-left: 7px; + } + .blue { + color: #58a6ff; } /* Charts */