diff --git a/source/app/mocks/api/github/graphql/achievements.default.mjs b/source/app/mocks/api/github/graphql/achievements.default.mjs new file mode 100644 index 00000000..febf670a --- /dev/null +++ b/source/app/mocks/api/github/graphql/achievements.default.mjs @@ -0,0 +1,66 @@ +/**Mocked data */ + export default function({faker, query, login = faker.internet.userName()}) { + console.debug("metrics/compute/mocks > mocking graphql api result > achievements/metrics") + return ({ + user:{ + repositories:{ + nodes:[ + { + createdAt:faker.date.recent(), + nameWithOwner:`${faker.internet.userName()}/${faker.lorem.slug()}`, + }, + ], + totalCount:faker.random.number(100), + }, + forks:{ + nodes:[ + { + createdAt:faker.date.recent(), + nameWithOwner:`${faker.internet.userName()}/${faker.lorem.slug()}`, + }, + ], + totalCount:faker.random.number(100), + }, + popular:{ + nodes:[{stargazers:{totalCount:faker.random.number(50000)}}], + }, + pullRequests:{ + nodes:[ + { + createdAt:faker.date.recent(), + title:faker.lorem.sentence(), + repository:{nameWithOwner:`${faker.internet.userName()}/${faker.lorem.slug()}`}, + }, + ], + totalCount:faker.random.number(50000), + }, + contributionsCollection:{ + pullRequestReviewContributions:{ + nodes:[ + { + occurredAt:faker.date.recent(), + pullRequest:{ + title:faker.lorem.sentence(), + number:faker.random.number(1000), + repository:{nameWithOwner:`${faker.internet.userName()}/${faker.lorem.slug()}`}, + }, + }, + ], + totalCount:faker.random.number(1000), + }, + }, + projects:{totalCount:faker.random.number(100)}, + packages:{totalCount:faker.random.number(100)}, + organizations:{nodes:[], totalCount:faker.random.number(5)}, + gists:{ + nodes:[{createdAt:faker.date.recent(), name:faker.lorem.slug()}], + totalCount:faker.random.number(1000), + }, + starredRepositories:{totalCount:faker.random.number(1000)}, + followers:{totalCount:faker.random.number(10000)}, + following:{totalCount:faker.random.number(10000)}, + bio:faker.lorem.sentence(), + status:{message:faker.lorem.paragraph()}, + }, + }) + } diff --git a/source/app/mocks/api/github/graphql/achievements.metrics.mjs b/source/app/mocks/api/github/graphql/achievements.metrics.mjs new file mode 100644 index 00000000..92f1b640 --- /dev/null +++ b/source/app/mocks/api/github/graphql/achievements.metrics.mjs @@ -0,0 +1,8 @@ +/**Mocked data */ + export default function({faker, query, login = faker.internet.userName()}) { + console.debug("metrics/compute/mocks > mocking graphql api result > achievements/metrics") + return ({ + repository:{viewerHasStarred:faker.random.boolean()}, + viewer:{login}, + }) + } diff --git a/source/app/mocks/api/github/graphql/achievements.octocat.mjs b/source/app/mocks/api/github/graphql/achievements.octocat.mjs new file mode 100644 index 00000000..1fe8cc42 --- /dev/null +++ b/source/app/mocks/api/github/graphql/achievements.octocat.mjs @@ -0,0 +1,8 @@ +/**Mocked data */ + export default function({faker, query, login = faker.internet.userName()}) { + console.debug("metrics/compute/mocks > mocking graphql api result > achievements/octocat") + return ({ + user:{viewerIsFollowing:faker.random.boolean()}, + viewer:{login}, + }) + } diff --git a/source/app/mocks/api/github/graphql/achievements.ranking.mjs b/source/app/mocks/api/github/graphql/achievements.ranking.mjs new file mode 100644 index 00000000..66d402b7 --- /dev/null +++ b/source/app/mocks/api/github/graphql/achievements.ranking.mjs @@ -0,0 +1,10 @@ +/**Mocked data */ + export default function({faker, query, login = faker.internet.userName()}) { + console.debug("metrics/compute/mocks > mocking graphql api result > achievements/ranking") + return ({ + repo_rank:{repositoryCount:faker.random.number(100000)}, + user_rank:{userCount:faker.random.number(100000)}, + repo_total:{repositoryCount:faker.random.number(100000)}, + user_total:{userCount:faker.random.number(100000)}, + }) + } diff --git a/source/app/web/statics/app.placeholder.js b/source/app/web/statics/app.placeholder.js index e3aad293..1e00621a 100644 --- a/source/app/web/statics/app.placeholder.js +++ b/source/app/web/statics/app.placeholder.js @@ -193,6 +193,25 @@ days:options["reactions.days"] } }) : null), + //Achievements + ...(set.plugins.enabled.achievements ? ({ + achievements:{ + list:new Array(8).fill(null).map(_ => ({ + title:faker.lorem.word(), + unlock:null, + text:faker.lorem.sentence(), + icon:``, + rank:faker.random.arrayElement(["A", "B", "C", "X", "$"]), + progress:faker.random.number(100)/100, + value:faker.random.number(1000), + })) + .filter(({rank}) => options["achievements.secrets"] ? true : rank !== "$") + .filter(({rank}) => ({S:5, A:4, B:3, C:2, $:1, X:0}[rank] >= {S:5, A:4, B:3, C:2, $:1, X:0}[options["achievements.threshold"]])) + .sort((a, b) => ({S:5, A:4, B:3, C:2, $:1, X:0}[b.rank]+b.progress*0.99) - ({S:5, A:4, B:3, C:2, $:1, X:0}[a.rank]+a.progress*0.99)) + .slice(0, options["achievements.limit"] || Infinity) + , + } + }) : null), //Introduction ...(set.plugins.enabled.introduction ? ({ introduction:{ diff --git a/source/plugins/achievements/README.md b/source/plugins/achievements/README.md new file mode 100644 index 00000000..258ce21c --- /dev/null +++ b/source/plugins/achievements/README.md @@ -0,0 +1,29 @@ +### 🏆 Achievements + +The *achievements* plugin displays several highlights about what you achieved on GitHub. + + + +
+ + +
+ +Achievements are mostly related to features offered by GitHub, so by unlocking achivements ranks you'll be mastering GitHub in no time! + +A few achievements contains actual real ranking (based on [GitHub search](github.com/search) results)! + +#### ℹ️ Examples workflows + +[➡️ Available options for this plugin](metadata.yml) + +```yaml +- uses: lowlighter/metrics@latest + with: + # ... other options + plugin_achievements: yes + plugin_achievements_threshold: B # Display achievements with rank B or higher + plugin_achievements_secrets: yes # Display unlocked secrets achievements + plugin_achievements_ignored: octonaut # Hide octonaut achievement + plugin_achievements_limit: 0 # Display all unlocked achievement matching threshold and secrets params +``` \ No newline at end of file diff --git a/source/plugins/achievements/index.mjs b/source/plugins/achievements/index.mjs new file mode 100644 index 00000000..b2574f11 --- /dev/null +++ b/source/plugins/achievements/index.mjs @@ -0,0 +1,290 @@ +//Setup + export default async function({login, q, imports, data, computed, graphql, queries, account}, {enabled = false} = {}) { + //Plugin execution + try { + //Check if plugin is enabled and requirements are met + if ((!enabled)||(!q.achievements)) + return null + + //Load inputs + let {threshold, secrets, only, ignored, limit} = imports.metadata.plugins.achievements.inputs({data, q, account}) + + //Initinalization + const list = [] + const {user} = await graphql(queries.achievements({login})) + const ranks = await graphql(queries.achievements.ranking({followers:user.followers.totalCount, stars:user.popular.nodes?.[0]?.stargazers?.totalCount ?? 0})) + + //Developer + { + const value = user.repositories.totalCount + const unlock = user.repositories.nodes?.shift() + list.push({ + title:"Developer", + text:`Published ${value} public repositor${imports.s(value, "y")}`, + icon:"", + ...rank(value, [1, 20, 50, 100]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Forker + { + const value = user.forks.totalCount + const unlock = user.forks.nodes?.shift() + list.push({ + title:"Forker", + text:`Forked ${value} public repositor${imports.s(value, "y")}`, + icon:"", + ...rank(value, [1, 5, 10, 20]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Contributor + { + const value = user.pullRequests.totalCount + const unlock = user.pullRequests.nodes?.shift() + + list.push({ + title:"Contributor", + text:`Opened ${value} pull request${imports.s(value)}`, + icon:"", + ...rank(value, [1, 200, 500, 1000]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Manager + { + const value = user.projects.totalCount + const unlock = user.projects.nodes?.shift() + + list.push({ + title:"Manager", + text:`Created ${value} user project${imports.s(value)}`, + icon:"", + ...rank(value, [1, 2, 3, 4]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Reviewer + { + const value = user.contributionsCollection.pullRequestReviewContributions.totalCount + const unlock = user.contributionsCollection.pullRequestReviewContributions.nodes?.shift() + + list.push({ + title:"Reviewer", + text:`Reviewed ${value} pull request${imports.s(value)}`, + icon:"", + ...rank(value, [1, 200, 500, 1000]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Packager + { + const value = user.packages.totalCount + const unlock = user.packages.nodes?.shift() + + list.push({ + title:"Packager", + text:`Created ${value} package${imports.s(value)}`, + icon:"", + ...rank(value, [1, 5, 10, 20]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Scripter + { + const value = user.gists.totalCount + const unlock = user.gists.nodes?.shift() + + list.push({ + title:"Scripter", + text:`Published ${value} gist${imports.s(value)}`, + icon:"", + ...rank(value, [1, 20, 50, 100]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Worker + { + const value = user.organizations.totalCount + const unlock = user.organizations.nodes?.shift() + + list.push({ + title:"Worker", + text:`Joined ${value} organization${imports.s(value)}`, + icon:"", + ...rank(value, [1, 2, 3, 4]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Stargazer + { + const value = user.starredRepositories.totalCount + const unlock = user.starredRepositories.nodes?.shift() + + list.push({ + title:"Stargazer", + text:`Starred ${value} repositor${imports.s(value, "y")}`, + icon:"", + ...rank(value, [1, 200, 500, 1000]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Follower + { + const value = user.following.totalCount + const unlock = user.following.nodes?.shift() + + list.push({ + title:"Follower", + text:`Following ${value} user${imports.s(value)}`, + icon:"", + ...rank(value, [1, 200, 500, 1000]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Influencer + { + const value = user.followers.totalCount + const unlock = user.followers.nodes?.shift() + + list.push({ + title:"Influencer", + text:`Followed by ${value} user${imports.s(value)}`, + icon:"", + ...rank(value, [1, 200, 500, 1000]), value, unlock:new Date(unlock?.createdAt), + gh:Number(`1${"0".repeat(Math.ceil(Math.log10(ranks.user_rank.userCount)))}`), + }) + } + + //Maintainer + { + const value = user.popular.nodes?.shift()?.stargazers?.totalCount ?? 0 + const unlock = null + + list.push({ + title:"Maintainer", + text:`Maintaining a repository with ${value} star${imports.s(value)}`, + icon:"", + ...rank(value, [1, 1000, 5000, 10000]), value, unlock:new Date(unlock?.createdAt), + gh:Number(`1${"0".repeat(Math.ceil(Math.log10(ranks.repo_rank.repositoryCount)))}`), + }) + } + + //Polyglot + { + const value = new Set(data.user.repositories.nodes.flatMap(repository => repository.languages.edges.map(({node:{name}}) => name))).size + const unlock = null + + list.push({ + title:"Polyglot", + text:`Using ${value} different programming language${imports.s(value)}`, + icon:"", + ...rank(value, [1, 4, 8, 16]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Member + { + const value = computed.registered.diff + const unlock = null + + list.push({ + title:"Member", + text:`Registered ${Math.floor(value)} year${imports.s(Math.floor(value))} ago`, + icon:"", + ...rank(value, [1, 3, 5, 10]), value, unlock:new Date(unlock?.createdAt), + }) + } + + //Verified + { + const value = !/This user hasn't uploaded any GPG keys/i.test((await imports.axios.get(`https://github.com/${login}.gpg`)).data) + const unlock = null + + list.push({ + title:"Verified", + text:"Registered a GPG key to sign commits", + icon:"", + rank:value ? "$" : "X", progress:1, value, unlock:new Date(unlock?.createdAt), + }) + } + + //Explorer + { + const value = !/doesn’t have any starred topics yet/i.test((await imports.axios.get(`https://github.com/stars/${login}/topics`)).data) + const unlock = null + + list.push({ + title:"Explorer", + text:"Starred a topic on GitHub Explore", + icon:"", + rank:value ? "$" : "X", progress:1, value, unlock:new Date(unlock?.createdAt), + }) + } + + //Automater + { + const value = process.env.GITHUB_ACTIONS + const unlock = null + + list.push({ + title:"Automater", + text:"Use GitHub Actions to automate profile updates", + icon:"", + rank:value ? "$" : "X", progress:1, value, unlock:new Date(unlock?.createdAt), + }) + } + + //Infographile + { + const {repository:{viewerHasStarred:value}, viewer:{login:_login}} = await graphql(queries.achievements.metrics()) + const unlock = null + + list.push({ + title:"Infographile", + text:"Fervent supporter of metrics", + icon:"", + rank:(value)&&(login === _login) ? "$" : "X", progress:1, value, unlock:new Date(unlock?.createdAt), + }) + } + + //Octonaut + { + const {user:{viewerIsFollowing:value}, viewer:{login:_login}} = await graphql(queries.achievements.octocat()) + const unlock = null + + list.push({ + title:"Octonaut", + text:"Following octocat", + icon:"", + rank:(value)&&(login === _login) ? "$" : "X", progress:1, value, unlock:new Date(unlock?.createdAt), + }) + } + + //Results + const order = {S:5, A:4, B:3, C:2, $:1, X:0} + const achievements = list + .filter(a => (order[a.rank] >= order[threshold])||((a.rank === "$")&&(secrets))) + .filter(a => (!only.length)||((only.length)&&(only.includes(a.title.toLocaleLowerCase())))) + .filter(a => !ignored.includes(a.title.toLocaleLowerCase())) + .sort((a, b) => (order[b.rank]+b.progress*0.99) - (order[a.rank]+a.progress*0.99)) + .map(({title, unlock, ...achievement}) => ({title:({S:`Master ${title.toLocaleLowerCase()}`, A:`Super ${title.toLocaleLowerCase()}`, B:`Great ${title.toLocaleLowerCase()}`}[achievement.rank] ?? title), unlock:!/invalid date/i.test(unlock) ? `${imports.date(unlock, {timeStyle:"short", timeZone:data.config.timezone?.name})} on ${imports.date(unlock, {dateStyle:"short", timeZone:data.config.timezone?.name})}` : null, ...achievement})) + .slice(0, limit || Infinity) + return {list:achievements} + } + //Handle errors + catch (error) { + throw {error:{message:"An error occured", instance:error}} + } + } + +/**Rank */ + function rank(x, [c, b, a, m]) { + if (x >= a) + return {rank:"A", progress:(x-a)/(m-a)} + else if (x >= b) + return {rank:"B", progress:(x-b)/(a-b)} + else if (x >= c) + return {rank:"C", progress:(x-c)/(b-c)} + return {rank:"X", progress:x/c} + } \ No newline at end of file diff --git a/source/plugins/achievements/metadata.yml b/source/plugins/achievements/metadata.yml new file mode 100644 index 00000000..0d01db13 --- /dev/null +++ b/source/plugins/achievements/metadata.yml @@ -0,0 +1,55 @@ +name: "🏆 Achievements" +cost: ~5 GraphQL request +categorie: github +index: 17 +supports: + - user +inputs: + + # Enable or disable plugin + plugin_achievements: + description: Display achievements + type: boolean + default: no + + # Minimal rank to display + plugin_achievements_threshold: + description: Display rank minimal threshold + type: string + default: C + values: + - A + - B + - C + - X # Not unlocked + + # Display secrets achievements unlocked + plugin_achievements_secrets: + description: Display unlocked secrets achievements + type: boolean + default: yes + + # Number of achievements events to display + # Set to 0 to disable limitations + plugin_achievements_limit: + description: Maximum number of achievements to display + type: number + default: 0 + min: 0 + + # List of unlocked achievements to hide + # Names must be given in lower case, without rank adjective + plugin_achievements_ignored: + description: Unlocked achievements to hide + type: array + format: comma-separated + default: "" + + # List of unlocked achievements to display + # Names must be given in lower case, without rank adjective + # Using this option is equivalent of using "plugin_achievements_ignored" with all existing achievements but the ones listed + plugin_achievements_only: + description: Unlocked achievements to display + type: array + format: comma-separated + default: "" \ No newline at end of file diff --git a/source/plugins/achievements/queries/achievements.graphql b/source/plugins/achievements/queries/achievements.graphql new file mode 100644 index 00000000..7d00e8d6 --- /dev/null +++ b/source/plugins/achievements/queries/achievements.graphql @@ -0,0 +1,95 @@ +query AchievementsDefault { + + user(login: "$login") { + repositories(first: 1, privacy: PUBLIC, affiliations: OWNER, orderBy: {field: CREATED_AT, direction: ASC}) { + nodes { + createdAt + nameWithOwner + } + totalCount + } + forks:repositories(first: 1, privacy: PUBLIC, isFork: true, orderBy: {field: CREATED_AT, direction: ASC}) { + nodes { + createdAt + nameWithOwner + } + totalCount + } + popular:repositories(first:1, orderBy: {field: STARGAZERS, direction: DESC}) { + nodes { + stargazers { + totalCount + } + } + } + pullRequests(orderBy: {field: CREATED_AT, direction: ASC}, first: 1) { + nodes { + createdAt + title + repository { + nameWithOwner + } + } + totalCount + } + contributionsCollection { + pullRequestReviewContributions(first: 1, orderBy: {direction: ASC}) { + nodes { + occurredAt + pullRequest { + title + number + repository { + nameWithOwner + } + } + } + totalCount + } + } + projects(first: 1, orderBy: {field: CREATED_AT, direction: ASC}) { + totalCount + #nodes { This requires additional scopes :/ + # name + #} + } + packages(first: 1, orderBy: {direction: ASC, field: CREATED_AT}) { + totalCount + #nodes { This requires additional scopes :/ + # name + # packageType + # versions(first: 1, orderBy: {direction: ASC, field: CREATED_AT}) { + # nodes { + # id + # } + # } + #} + } + organizations(first: 1) { + nodes { + name + } + totalCount + } + gists(first: 1, orderBy: {field: CREATED_AT, direction: ASC}) { + nodes { + createdAt + name + } + totalCount + } + starredRepositories { + totalCount + } + followers { + totalCount + } + following { + totalCount + } + bio + status { + message + } + } +} diff --git a/source/plugins/achievements/queries/metrics.graphql b/source/plugins/achievements/queries/metrics.graphql new file mode 100644 index 00000000..1c7e31c1 --- /dev/null +++ b/source/plugins/achievements/queries/metrics.graphql @@ -0,0 +1,8 @@ +query AchievementsMetrics { + repository(owner: "lowlighter", name: "metrics") { + viewerHasStarred + } + viewer { + login + } +} \ No newline at end of file diff --git a/source/plugins/achievements/queries/octocat.graphql b/source/plugins/achievements/queries/octocat.graphql new file mode 100644 index 00000000..d6fbdba5 --- /dev/null +++ b/source/plugins/achievements/queries/octocat.graphql @@ -0,0 +1,8 @@ +query AchievementsOctocat { + user(login: "octocat") { + viewerIsFollowing + } + viewer { + login + } +} diff --git a/source/plugins/achievements/queries/ranking.graphql b/source/plugins/achievements/queries/ranking.graphql new file mode 100644 index 00000000..4901fa86 --- /dev/null +++ b/source/plugins/achievements/queries/ranking.graphql @@ -0,0 +1,14 @@ +query AchievementsRanking { + repo_rank:search(query: "stars:>$stars", type: REPOSITORY, first: 0) { + repositoryCount + } + user_rank:search(query: "followers:>$followers", type: USER, first: 0) { + userCount + } + repo_total:search(query: "stars:>-1", type: REPOSITORY, first: 0) { + repositoryCount + } + user_total:search(query: "followers:>-1", type: USER, first: 0) { + userCount + } +} \ No newline at end of file diff --git a/source/plugins/achievements/tests.yml b/source/plugins/achievements/tests.yml new file mode 100644 index 00000000..573f46ff --- /dev/null +++ b/source/plugins/achievements/tests.yml @@ -0,0 +1,13 @@ +- name: Achievements plugin (default) + uses: lowlighter/metrics@latest + with: + token: MOCKED_TOKEN + plugin_achievements: yes + +- name: Achievements plugin (complete) + uses: lowlighter/metrics@latest + with: + token: MOCKED_TOKEN + plugin_achievements_threshold: A + plugin_achievements_secrets: no + plugin_achievements_ignored: octonaut \ No newline at end of file diff --git a/source/templates/classic/partials/_.json b/source/templates/classic/partials/_.json index 543f7812..50ea4af3 100644 --- a/source/templates/classic/partials/_.json +++ b/source/templates/classic/partials/_.json @@ -23,5 +23,6 @@ "anilist", "wakatime", "skyline", - "stackoverflow" + "stackoverflow", + "achievements" ] \ No newline at end of file diff --git a/source/templates/classic/partials/achievements.ejs b/source/templates/classic/partials/achievements.ejs new file mode 100644 index 00000000..48438f99 --- /dev/null +++ b/source/templates/classic/partials/achievements.ejs @@ -0,0 +1,45 @@ +<% if (plugins.achievements) { %> +
+

+ + Achievements +

+
+
+ <% if (plugins.achievements.error) { %> +
+ + <%= plugins.achievements.error.message %> +
+ <% } else { %> + <% for (const {title, text, icon, rank, gh = NaN, progress = 0, unlock = null} of plugins.achievements.list) { %> +
"> +
+ + + + <% if ((progress)||(rank !== "X")) { %> + + <% } %> + + <%- icon %> + +
+
+
+ <%= title %> + <% if ((Number.isFinite(gh))&&(gh < 100000)) { %> + + Top <%= gh %> + + <% } %> +
+
<%= text %>
+
+
+ <% } %> + <% } %> +
+
+
+<% } %> \ No newline at end of file diff --git a/source/templates/classic/style.css b/source/templates/classic/style.css index 54899970..a7c97c02 100644 --- a/source/templates/classic/style.css +++ b/source/templates/classic/style.css @@ -836,6 +836,64 @@ width: 400px; } +/* Achievements */ + .achievement { + display: flex; + margin: 4px 0; + } + .achievement .icon { + margin: 0 4px; + width: 44px; + height: 44px; + } + .achievement .text { + font-size: 12px; + color: #666666; + } + .achievement .unlock { + font-size: 9px; + color: #666666; + } + .achievement .title { + font-size: 14px; + color: #58A6FF; + } + .achievement.x .title { + color: #666666; + } + .achievement.x .icon { + filter: grayscale(1) opacity(.5); + } + .achievement.b .title { + color: #9D8FFF; + } + .achievement.b .icon { + filter: hue-rotate(35deg); + } + .achievement.a .title { + color: #D79533; + } + .achievement.a .icon { + filter: sepia() saturate(2); + } + .achievement.s .title { + color: #FF0000; + } + .achievement.s .icon { + filter: sepia() saturate(100); + } + .achievement.secret .title{ + color: #FF76CD; + } + .achievement.secret .icon { + filter: hue-rotate(100deg); + } + .achievement .gh { + border: 1px solid currentColor; + border-radius: 16px; + font-size: 10px; + padding: 0 5px; + } /* Fade animation */ .af {