From cb9b077fca7473faa88b507f37bbf2a62a2a2ff4 Mon Sep 17 00:00:00 2001 From: Kalle Fagerberg Date: Fri, 12 Aug 2022 17:08:09 +0200 Subject: [PATCH 01/33] Added GITHUB_API env var --- index.ts | 3 ++- src/github_api_client.ts | 7 +++++-- src/utils.ts | 1 + 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/index.ts b/index.ts index a1ab91a8..9dd46fef 100644 --- a/index.ts +++ b/index.ts @@ -5,7 +5,8 @@ import { COLORS, Theme } from "./src/theme.ts"; import { Error400, Error404 } from "./src/error_page.ts"; import "https://deno.land/x/dotenv@v0.5.0/load.ts"; -const client = new GithubAPIClient(); +const apiEndpoint = Deno.env.get("GITHUB_API") || CONSTANTS.DEFAULT_GITHUB_API; +const client = new GithubAPIClient(apiEndpoint); export default async (req: Request) => { const params = parseParams(req); diff --git a/src/github_api_client.ts b/src/github_api_client.ts index 4ca5e1f6..8b5daf95 100644 --- a/src/github_api_client.ts +++ b/src/github_api_client.ts @@ -1,5 +1,6 @@ import { soxa } from "../deps.ts"; import { UserInfo } from "./user_info.ts"; +import { CONSTANTS } from "./utils.ts"; import type { GitHubUserActivity, GitHubUserIssue, @@ -8,7 +9,9 @@ import type { } from "./user_info.ts"; export class GithubAPIClient { - constructor() { + constructor( + private apiEndpoint: string = CONSTANTS.DEFAULT_GITHUB_API, + ) { } async requestUserInfo( token: string | undefined, @@ -114,7 +117,7 @@ export class GithubAPIClient { ) { const variables = { username: username }; const response = await soxa.post( - "https://api.github.com/graphql", + this.apiEndpoint, {}, { data: { query: query, variables }, diff --git a/src/utils.ts b/src/utils.ts index e4067a13..9f868f76 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -62,6 +62,7 @@ export const CONSTANTS = { DEFAULT_MARGIN_H: 0, DEFAULT_NO_BACKGROUND: false, DEFAULT_NO_FRAME: false, + DEFAULT_GITHUB_API: "https://api.github.com/graphql", }; export enum RANK { From fdf98a1c0e2b1af308fbd07af7658474d8716cf5 Mon Sep 17 00:00:00 2001 From: Kalle Fagerberg Date: Fri, 12 Aug 2022 17:10:56 +0200 Subject: [PATCH 02/33] Added GITHUB_API to CONTRIBUTING.md docs --- CONTRIBUTING.md | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9ce3840..1e78136f 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,13 +11,17 @@ Create `.env` file to project root directory, and write your GitHub token to the `.env` file. Please select the authority of `repo` when creating token. -``` +```properties GITHUB_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX + +# if using GitHub Enterprise: +# (this env var defaults to https://api.github.com/graphql) +GITHUB_API=https://github.example.com/api/graphql ``` Run local server. -``` +```sh deno run --allow-net --allow-read --allow-env debug.ts ``` @@ -33,6 +37,6 @@ Read the [.editorconfig](./.editorconfig) If you want to contribute to my project, you should check the lint with the following command. -``` +```sh deno lint --unstable -``` \ No newline at end of file +``` From 8a9ad5931b9f6133e4debb2fd3a61866ee3ed366 Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Thu, 8 Sep 2022 23:06:52 +0900 Subject: [PATCH 03/33] Support to multiple github token --- index.ts | 3 +-- src/github_api_client.ts | 57 +++++++++++++++++++++------------------- 2 files changed, 31 insertions(+), 29 deletions(-) diff --git a/index.ts b/index.ts index 9dd46fef..2498b876 100644 --- a/index.ts +++ b/index.ts @@ -56,8 +56,7 @@ export default async (req: Request) => { }, ); } - const token = Deno.env.get("GITHUB_TOKEN"); - const userInfo = await client.requestUserInfo(token, username); + const userInfo = await client.requestUserInfo(username); if (userInfo === null) { const error = new Error404( "Can not find a user with username: " + username, diff --git a/src/github_api_client.ts b/src/github_api_client.ts index 8b5daf95..af761e35 100644 --- a/src/github_api_client.ts +++ b/src/github_api_client.ts @@ -14,15 +14,14 @@ export class GithubAPIClient { ) { } async requestUserInfo( - token: string | undefined, username: string, ): Promise { // Avoid timeout for the Github API const results = await Promise.all([ - this.requestUserActivity(token, username), - this.requestUserIssue(token, username), - this.requestUserPullRequest(token, username), - this.requestUserRepository(token, username), + this.requestUserActivity(username), + this.requestUserIssue(username), + this.requestUserPullRequest(username), + this.requestUserRepository(username), ]); if (results.some((r) => r == null)) { return null; @@ -30,7 +29,6 @@ export class GithubAPIClient { return new UserInfo(results[0]!, results[1]!, results[2]!, results[3]!); } private async requestUserActivity( - token: string | undefined, username: string, ): Promise { const query = ` @@ -50,10 +48,9 @@ export class GithubAPIClient { } } `; - return await this.request(query, token, username); + return await this.request(query, username); } private async requestUserIssue( - token: string | undefined, username: string, ): Promise { const query = ` @@ -68,10 +65,9 @@ export class GithubAPIClient { } } `; - return await this.request(query, token, username); + return await this.request(query, username); } private async requestUserPullRequest( - token: string | undefined, username: string, ): Promise { const query = ` @@ -83,10 +79,9 @@ export class GithubAPIClient { } } `; - return await this.request(query, token, username); + return await this.request(query, username); } private async requestUserRepository( - token: string | undefined, username: string, ): Promise { const query = ` @@ -108,27 +103,35 @@ export class GithubAPIClient { } } `; - return await this.request(query, token, username); + return await this.request(query, username); } private async request( query: string, - token: string | undefined, username: string, ) { + const tokens = [ + Deno.env.get("GITHUB_TOKEN1"), + Deno.env.get("GITHUB_TOKEN2"), + ]; const variables = { username: username }; - const response = await soxa.post( - this.apiEndpoint, - {}, - { - data: { query: query, variables }, - headers: { Authorization: `bearer ${token}` }, - }, - ).catch((error) => { - console.error(error.response.data.errors[0].message); - }); - if (response.status != 200) { - console.error(`Status code: ${response.status}`); - console.error(response.data); + let response; + for (const token of tokens) { + response = await soxa.post( + this.apiEndpoint, + {}, + { + data: { query: query, variables }, + headers: { Authorization: `bearer ${token}` }, + }, + ).catch((error) => { + console.error(error.response.data.errors[0].message); + }); + if (response.status === 200) { + break; + } else { + console.error(`Status code: ${response.status}`); + console.error(response.data); + } } return response.data.data.user; } From 8f09859d1beed8a6f26175e81d355ce7a41575ab Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Thu, 8 Sep 2022 23:14:20 +0900 Subject: [PATCH 04/33] Fix error response property --- src/github_api_client.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/github_api_client.ts b/src/github_api_client.ts index af761e35..3c5d6c80 100644 --- a/src/github_api_client.ts +++ b/src/github_api_client.ts @@ -124,13 +124,10 @@ export class GithubAPIClient { headers: { Authorization: `bearer ${token}` }, }, ).catch((error) => { - console.error(error.response.data.errors[0].message); + console.error(error.response.data); }); - if (response.status === 200) { + if (response.data.data !== undefined) { break; - } else { - console.error(`Status code: ${response.status}`); - console.error(response.data); } } return response.data.data.user; From d472e5a45af5770b188ae5f87e81643d90ee54bc Mon Sep 17 00:00:00 2001 From: Aryan Date: Wed, 14 Sep 2022 22:05:21 +0530 Subject: [PATCH 05/33] typo mistake --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 59f9d930..86379e0b 100644 --- a/README.md +++ b/README.md @@ -65,7 +65,7 @@ Ranks are `SSS` `SS` `S` `AAA` `AA` `A` `B` `C` `UNKNOWN` `SECRET`. | ---- | ---- | | SSS, SS, S | You are at a hard to reach rank. You can brag. | | AAA, AA, A | You will reach this rank if you do your best. Let's aim here first. | -| B, C | You are currently making good process. Let's aim a bit higher. | +| B, C | You are currently making good progress. Let's aim a bit higher. | | UNKNOWN | You have not taken action yet. Let's act first. | | SECRET | This rank is very rare. The trophy will not be displayed until certain conditions are met. | From 01849558711bf119a560559b5e61b64ec201736d Mon Sep 17 00:00:00 2001 From: SmashedFrenzy16 <68993968+SmashedFrenzy16@users.noreply.github.com> Date: Thu, 19 Jan 2023 11:45:46 +0000 Subject: [PATCH 06/33] Add OG Account Trophy (#174) * Update trophy.ts * Update trophy_list.ts --- src/trophy.ts | 17 +++++++++++++++++ src/trophy_list.ts | 2 ++ src/user_info.ts | 5 +++++ 3 files changed, 24 insertions(+) diff --git a/src/trophy.ts b/src/trophy.ts index cac80cbc..ff602e98 100644 --- a/src/trophy.ts +++ b/src/trophy.ts @@ -199,6 +199,23 @@ export class MultipleOrganizationsTrophy extends Trophy{ } } +export class OGAccountTrophy extends Trophy{ + constructor(score: number){ + const rankConditions = [ + new RankCondition( + RANK.SECRET, + "OG User", + 1, + ), + ]; + super(score, rankConditions); + this.title = "OGUser"; + this.filterTitles = ["OGUser"]; + this.bottomMessage = "Joined 2008" + this.hidden = true; + } +} + export class TotalStarTrophy extends Trophy { constructor(score: number) { const rankConditions = [ diff --git a/src/trophy_list.ts b/src/trophy_list.ts index b9eaa353..6d99519e 100644 --- a/src/trophy_list.ts +++ b/src/trophy_list.ts @@ -9,6 +9,7 @@ import { MultipleLangTrophy, LongTimeAccountTrophy, AncientAccountTrophy, + OGAccountTrophy, Joined2020Trophy, AllSuperRankTrophy, MultipleOrganizationsTrophy, @@ -34,6 +35,7 @@ export class TrophyList { new MultipleLangTrophy(userInfo.languageCount), new LongTimeAccountTrophy(userInfo.durationYear), new AncientAccountTrophy(userInfo.ancientAccount), + new OGAccountTrophy(userInfo.ogAccount), new Joined2020Trophy(userInfo.joined2020), new MultipleOrganizationsTrophy(userInfo.totalOrganizations), ); diff --git a/src/user_info.ts b/src/user_info.ts index c1b25723..e65f7eb2 100644 --- a/src/user_info.ts +++ b/src/user_info.ts @@ -51,6 +51,7 @@ export class UserInfo { public readonly durationYear: number; public readonly ancientAccount: number; public readonly joined2020: number; + public readonly ogAccount: number; constructor( userActivity: GitHubUserActivity, userIssue: GitHubUserIssue, @@ -85,6 +86,9 @@ export class UserInfo { const joined2020 = new Date(userActivity.createdAt).getFullYear() == 2020 ? 1 : 0; + const ogAccount = + new Date(userActivity.createdAt).getFullYear() <= 2008 ? 1 : 0; + this.totalCommits = totalCommits; this.totalFollowers = userActivity.followers.totalCount; this.totalIssues = userIssue.openIssues.totalCount + userIssue.closedIssues.totalCount; @@ -96,5 +100,6 @@ export class UserInfo { this.durationYear = durationYear; this.ancientAccount = ancientAccount; this.joined2020 = joined2020; + this.ogAccount = ogAccount; } } From 5378858628ea199bf583f08e85987ee80897ece8 Mon Sep 17 00:00:00 2001 From: Mohan Yadav Date: Fri, 27 Jan 2023 08:22:22 +0530 Subject: [PATCH 07/33] Update CONTRIBUTING.md (#186) Update CONTRIBUTING.md to use the correct Github environment variables --- CONTRIBUTING.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1e78136f..355ef0a4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -12,7 +12,8 @@ Create `.env` file to project root directory, and write your GitHub token to the Please select the authority of `repo` when creating token. ```properties -GITHUB_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +GITHUB_TOKEN1=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +GITHUB_TOKEN2=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX # if using GitHub Enterprise: # (this env var defaults to https://api.github.com/graphql) From 450f208327a0f428a5a90329b0587fdc735458c7 Mon Sep 17 00:00:00 2001 From: Bhav Beri <43399374+bhavberi@users.noreply.github.com> Date: Thu, 18 May 2023 20:33:15 +0530 Subject: [PATCH 08/33] Added Total Reviews Trophy (#200) --- src/github_api_client.ts | 1 + src/trophy.ts | 50 ++++++++++++++++++++++++++++++++++++++++ src/trophy_list.ts | 2 ++ src/user_info.ts | 3 +++ 4 files changed, 56 insertions(+) diff --git a/src/github_api_client.ts b/src/github_api_client.ts index 3c5d6c80..c23bc60c 100644 --- a/src/github_api_client.ts +++ b/src/github_api_client.ts @@ -38,6 +38,7 @@ export class GithubAPIClient { contributionsCollection { totalCommitContributions restrictedContributionsCount + totalPullRequestReviewContributions } organizations(first: 1) { totalCount diff --git a/src/trophy.ts b/src/trophy.ts index ff602e98..66d9f94f 100644 --- a/src/trophy.ts +++ b/src/trophy.ts @@ -216,6 +216,56 @@ export class OGAccountTrophy extends Trophy{ } } +export class TotalReviewsTrophy extends Trophy { + constructor(score: number) { + const rankConditions = [ + new RankCondition( + RANK.SSS, + "God Reviewer", + 70, + ), + new RankCondition( + RANK.SS, + "Deep Reviewer", + 57, + ), + new RankCondition( + RANK.S, + "Super Reviewer", + 45, + ), + new RankCondition( + RANK.AAA, + "Ultra Reviewer", + 30, + ), + new RankCondition( + RANK.AA, + "Hyper Reviewer", + 20, + ), + new RankCondition( + RANK.A, + "Active Reviewer", + 8, + ), + new RankCondition( + RANK.B, + "Intermediate Reviewer", + 3, + ), + new RankCondition( + RANK.C, + "New Reviewer", + 1, + ), + ]; + super(score, rankConditions); + this.title = "Reviews"; + this.filterTitles = ["Review", "Reviews"]; + } +} + export class TotalStarTrophy extends Trophy { constructor(score: number) { const rankConditions = [ diff --git a/src/trophy_list.ts b/src/trophy_list.ts index 6d99519e..7b5b9c51 100644 --- a/src/trophy_list.ts +++ b/src/trophy_list.ts @@ -6,6 +6,7 @@ import { TotalIssueTrophy, TotalPullRequestTrophy, TotalRepositoryTrophy, + TotalReviewsTrophy, MultipleLangTrophy, LongTimeAccountTrophy, AncientAccountTrophy, @@ -28,6 +29,7 @@ export class TrophyList { new TotalIssueTrophy(userInfo.totalIssues), new TotalPullRequestTrophy(userInfo.totalPullRequests), new TotalRepositoryTrophy(userInfo.totalRepositories), + new TotalReviewsTrophy(userInfo.totalReviews), ); // Secret trophies this.trophies.push( diff --git a/src/user_info.ts b/src/user_info.ts index e65f7eb2..c2e47c3b 100644 --- a/src/user_info.ts +++ b/src/user_info.ts @@ -31,6 +31,7 @@ export type GitHubUserActivity = { contributionsCollection: { totalCommitContributions: number; restrictedContributionsCount: number; + totalPullRequestReviewContributions: number; }; organizations: { totalCount: number; @@ -45,6 +46,7 @@ export class UserInfo { public readonly totalIssues: number; public readonly totalOrganizations: number; public readonly totalPullRequests: number; + public readonly totalReviews: number; public readonly totalStargazers: number; public readonly totalRepositories: number; public readonly languageCount: number; @@ -94,6 +96,7 @@ export class UserInfo { this.totalIssues = userIssue.openIssues.totalCount + userIssue.closedIssues.totalCount; this.totalOrganizations = userActivity.organizations.totalCount; this.totalPullRequests = userPullRequest.pullRequests.totalCount; + this.totalReviews = userActivity.contributionsCollection.totalPullRequestReviewContributions; this.totalStargazers = totalStargazers; this.totalRepositories = userRepository.repositories.totalCount; this.languageCount = languages.size; From 33b4105d8af3abeb5559286aec9093945a52efa4 Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Fri, 19 May 2023 08:57:42 +0900 Subject: [PATCH 09/33] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 86379e0b..4056815f 100644 --- a/README.md +++ b/README.md @@ -41,7 +41,7 @@ Add the following code to your readme. When pasting the code into your profile's ```

- +

## Use theme From 22e46c69e7f9981ac152b10af329f2839378095c Mon Sep 17 00:00:00 2001 From: Alexandro Castro <10711649+AlexcastroDev@users.noreply.github.com> Date: Tue, 26 Sep 2023 14:59:20 +0100 Subject: [PATCH 10/33] feat: implement staticRenderRegeneration (#221) * feat: implement staticRenderRegeneration * chore: remove the check in create * chore: ignore read permission throw * chore: safe read file * fix: stream save * chore: change file folder --- CONTRIBUTING.md | 2 +- index.ts | 26 +++++++--- src/StaticRenderRegeneration/cache_manager.ts | 49 +++++++++++++++++++ src/StaticRenderRegeneration/index.ts | 29 +++++++++++ src/StaticRenderRegeneration/types.ts | 6 +++ src/StaticRenderRegeneration/utils.ts | 42 ++++++++++++++++ src/utils.ts | 3 ++ 7 files changed, 148 insertions(+), 9 deletions(-) create mode 100644 src/StaticRenderRegeneration/cache_manager.ts create mode 100644 src/StaticRenderRegeneration/index.ts create mode 100644 src/StaticRenderRegeneration/types.ts create mode 100644 src/StaticRenderRegeneration/utils.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 355ef0a4..95eaad16 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ GITHUB_API=https://github.example.com/api/graphql Run local server. ```sh -deno run --allow-net --allow-read --allow-env debug.ts +deno run --allow-net --allow-read --allow-env --allow-write debug.ts ``` Open localhost from your browser. diff --git a/index.ts b/index.ts index 2498b876..5f7f6cda 100644 --- a/index.ts +++ b/index.ts @@ -4,11 +4,25 @@ import { CONSTANTS, parseParams } from "./src/utils.ts"; import { COLORS, Theme } from "./src/theme.ts"; import { Error400, Error404 } from "./src/error_page.ts"; import "https://deno.land/x/dotenv@v0.5.0/load.ts"; - +import {staticRenderRegeneration} from "./src/StaticRenderRegeneration/index.ts"; const apiEndpoint = Deno.env.get("GITHUB_API") || CONSTANTS.DEFAULT_GITHUB_API; const client = new GithubAPIClient(apiEndpoint); -export default async (req: Request) => { +const defaultHeaders = new Headers( + { + "Content-Type": "image/svg+xml", + "Cache-Control": `public, max-age=${CONSTANTS.CACHE_MAX_AGE}`, + }, +) + +export default (request: Request) => staticRenderRegeneration(request, { + revalidate: CONSTANTS.REVALIDATE_TIME, + headers: defaultHeaders +}, function (req: Request) { + return app(req); +}); + +async function app (req: Request): Promise{ const params = parseParams(req); const username = params.get("username"); const row = params.getNumberValue("row", CONSTANTS.DEFAULT_MAX_ROW); @@ -69,6 +83,7 @@ export default async (req: Request) => { }, ); } + // Success Response return new Response( new Card( @@ -83,12 +98,7 @@ export default async (req: Request) => { noFrame, ).render(userInfo, theme), { - headers: new Headers( - { - "Content-Type": "image/svg+xml", - "Cache-Control": `public, max-age=${CONSTANTS.CACHE_MAX_AGE}`, - }, - ), + headers: defaultHeaders, }, ); }; diff --git a/src/StaticRenderRegeneration/cache_manager.ts b/src/StaticRenderRegeneration/cache_manager.ts new file mode 100644 index 00000000..3a93e4b4 --- /dev/null +++ b/src/StaticRenderRegeneration/cache_manager.ts @@ -0,0 +1,49 @@ +import { existsSync } from './utils.ts' + +export class CacheManager { + constructor(private revalidateTime: number, private cacheFile: string) {} + + // Reason to use /tmp/: + // https://github.com/orgs/vercel/discussions/314 + get cacheFilePath(): string { + return `/tmp/${this.cacheFile}`; + } + get cacheFileExists(): boolean { + return existsSync(this.cacheFilePath); + } + + get cacheFileLastModified(): Date | null { + if (!this.cacheFileExists) { + return null; + } + const fileInfo = Deno.statSync(this.cacheFilePath); + return fileInfo.mtime ?? null; + } + + get cacheFileLastModifiedGetTime(): number | null { + const lastModified = this.cacheFileLastModified; + if (lastModified === null) { + return null; + } + return lastModified.getTime(); + } + + get isCacheValid(): boolean { + if (this.cacheFileLastModifiedGetTime === null) { + return false; + } + const currentTime = new Date().getTime(); + return currentTime - this.cacheFileLastModifiedGetTime < this.revalidateTime; + } + + async save (response: Response): Promise { + if(response === null) return + // Prevent TypeError: ReadableStream is locked + const text = await response.clone().text() + const data = new TextEncoder().encode(text) + + Deno.writeFile(this.cacheFilePath, data, { create: true }).catch(() => { + console.warn("Failed to save cache file") + }); + } +} \ No newline at end of file diff --git a/src/StaticRenderRegeneration/index.ts b/src/StaticRenderRegeneration/index.ts new file mode 100644 index 00000000..d93fc61f --- /dev/null +++ b/src/StaticRenderRegeneration/index.ts @@ -0,0 +1,29 @@ +import { CacheManager } from "./cache_manager.ts"; +import { StaticRegenerationOptions } from "./types.ts"; +import { getUrl, readCache, generateUUID } from "./utils.ts"; + +export async function staticRenderRegeneration(request: Request, options: StaticRegenerationOptions, render: (request: Request) => Promise) { + // avoid TypeError: Invalid URL at deno:core + const url = getUrl(request) + + // if more conditions are added, make sure to create a variable to skipCache + if (url.pathname === "/favicon.ico") { + return await render(request); + } + + const cacheFile = await generateUUID(url.pathname + (url.search ?? "")); + const cacheManager = new CacheManager(options.revalidate ?? 0, cacheFile); + if (cacheManager.isCacheValid) { + const cache = readCache(cacheManager.cacheFilePath) + if(cache !== null) { + return new Response(cache, { + headers: options.headers ?? new Headers({}), + }); + } + } + + const response = await render(request) + cacheManager.save(response) + + return response +} diff --git a/src/StaticRenderRegeneration/types.ts b/src/StaticRenderRegeneration/types.ts new file mode 100644 index 00000000..e9b79076 --- /dev/null +++ b/src/StaticRenderRegeneration/types.ts @@ -0,0 +1,6 @@ +export interface StaticRegenerationOptions { + // The number of milliseconds before the page should be revalidated + revalidate?: number + // The headers to be sent with the response + headers?: Headers +} \ No newline at end of file diff --git a/src/StaticRenderRegeneration/utils.ts b/src/StaticRenderRegeneration/utils.ts new file mode 100644 index 00000000..ebc19c4b --- /dev/null +++ b/src/StaticRenderRegeneration/utils.ts @@ -0,0 +1,42 @@ +export function getUrl(request: Request) { + try { + return new URL(request.url) + } catch { + return { + pathname: request.url, + search: request.url + } + } +} + +export function readCache(cacheFilePath: string): Uint8Array | null { + try { + return Deno.readFileSync(cacheFilePath) + } catch { + return null + } +} + +// https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest +export async function generateUUID(message: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); + + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); + + return hashHex; +} + + +export const existsSync = (filename: string): boolean => { + try { + Deno.statSync(filename); + // successful, file or directory must exist + return true; + } catch { + return false; + } +}; + \ No newline at end of file diff --git a/src/utils.ts b/src/utils.ts index 9f868f76..3b650435 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -53,6 +53,8 @@ export function abridgeScore(score: number): string { return (Math.sign(score) * Math.abs(score)).toString() + "pt"; } +const HOUR_IN_MILLISECONDS = 60 * 60 * 1000; + export const CONSTANTS = { CACHE_MAX_AGE: 7200, DEFAULT_PANEL_SIZE: 110, @@ -63,6 +65,7 @@ export const CONSTANTS = { DEFAULT_NO_BACKGROUND: false, DEFAULT_NO_FRAME: false, DEFAULT_GITHUB_API: "https://api.github.com/graphql", + REVALIDATE_TIME: HOUR_IN_MILLISECONDS, }; export enum RANK { From 85f806480710de6f70b4f6c15f9991a068159aee Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Tue, 26 Sep 2023 23:29:10 +0900 Subject: [PATCH 11/33] Update README.md From a4d50a11dbd6560527243202cd5ca5c9782daf9a Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Thu, 28 Sep 2023 00:56:48 +0900 Subject: [PATCH 12/33] Fixed the number of Github API requests that were being executed twice. --- index.ts | 2 +- src/github_api_client.ts | 51 +++++++++++++++++++++++++++++----------- src/utils.ts | 1 + 3 files changed, 39 insertions(+), 15 deletions(-) diff --git a/index.ts b/index.ts index 5f7f6cda..0bbbcce8 100644 --- a/index.ts +++ b/index.ts @@ -101,4 +101,4 @@ async function app (req: Request): Promise{ headers: defaultHeaders, }, ); -}; +} diff --git a/src/github_api_client.ts b/src/github_api_client.ts index c23bc60c..c4b8361e 100644 --- a/src/github_api_client.ts +++ b/src/github_api_client.ts @@ -24,6 +24,7 @@ export class GithubAPIClient { this.requestUserRepository(username), ]); if (results.some((r) => r == null)) { + console.error(`Can not find a user with username:'${username}'`); return null; } return new UserInfo(results[0]!, results[1]!, results[2]!, results[3]!); @@ -109,28 +110,50 @@ export class GithubAPIClient { private async request( query: string, username: string, + retryDelay = CONSTANTS.DEFAULT_GITHUB_RETRY_DELAY, ) { const tokens = [ Deno.env.get("GITHUB_TOKEN1"), Deno.env.get("GITHUB_TOKEN2"), ]; + const maxRetries = tokens.length; + const variables = { username: username }; let response; - for (const token of tokens) { - response = await soxa.post( - this.apiEndpoint, - {}, - { - data: { query: query, variables }, - headers: { Authorization: `bearer ${token}` }, - }, - ).catch((error) => { - console.error(error.response.data); - }); - if (response.data.data !== undefined) { - break; + + for (let attempt = 0; attempt < maxRetries; attempt++) { + try { + response = await soxa.post( + this.apiEndpoint, + {}, + { + data: { query: query, variables }, + headers: { Authorization: `bearer ${tokens[attempt]}` }, + }, + ); + if (response.data.errors !== undefined) { + throw new Error( + response.data.errors.map((e: { message: string; type: string }) => + e.message + ).join("\n"), + ); + } + if (response.data.data !== undefined) { + return response.data.data.user; + } else { + return null; + } + } catch (error) { + console.error( + `Attempt ${attempt} failed with GITHUB_TOKEN${attempt + 1}:`, + error, + ); } + + console.log(`Retrying in ${retryDelay / 1000} seconds...`); + await new Promise((resolve) => setTimeout(resolve, retryDelay)); } - return response.data.data.user; + + throw new Error(`Max retries (${maxRetries}) exceeded.`); } } diff --git a/src/utils.ts b/src/utils.ts index 3b650435..0ed5cd30 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -65,6 +65,7 @@ export const CONSTANTS = { DEFAULT_NO_BACKGROUND: false, DEFAULT_NO_FRAME: false, DEFAULT_GITHUB_API: "https://api.github.com/graphql", + DEFAULT_GITHUB_RETRY_DELAY: 1000, REVALIDATE_TIME: HOUR_IN_MILLISECONDS, }; From 5dc3379214adb0b9e53b9d7a87238e6d880cea87 Mon Sep 17 00:00:00 2001 From: Alexandro Castro <10711649+AlexcastroDev@users.noreply.github.com> Date: Fri, 29 Sep 2023 17:54:23 +0100 Subject: [PATCH 13/33] Improvements on workflow and files (#222) * chore: improvements * chore: add testing workflow * fix: types and lint * chore: change dynamic * chore: improvements * chore: no need repository to check status --- .github/workflows/testing.yml | 22 +++ README.md | 6 + index.ts => api/index.ts | 37 ++-- debug.ts | 2 +- deps.ts | 22 ++- src/Helpers/Retry.ts | 44 +++++ src/Helpers/__tests__/Retry.test.ts | 66 ++++++++ src/Repository/GithubRepository.ts | 25 +++ src/Schemas/index.ts | 61 +++++++ src/Services/GithubApiService.ts | 120 +++++++++++++ src/StaticRenderRegeneration/cache_manager.ts | 81 ++++----- src/Types/Request.ts | 5 + src/Types/index.ts | 1 + src/github_api_client.ts | 159 ------------------ vercel.json | 19 ++- 15 files changed, 446 insertions(+), 224 deletions(-) create mode 100644 .github/workflows/testing.yml rename index.ts => api/index.ts (73%) create mode 100644 src/Helpers/Retry.ts create mode 100644 src/Helpers/__tests__/Retry.test.ts create mode 100644 src/Repository/GithubRepository.ts create mode 100644 src/Schemas/index.ts create mode 100644 src/Services/GithubApiService.ts create mode 100644 src/Types/Request.ts create mode 100644 src/Types/index.ts delete mode 100644 src/github_api_client.ts diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 00000000..b8215530 --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,22 @@ +name: Check PR Test + +on: + pull_request: + branches: + - master +jobs: + install-dependencies: + runs-on: ubuntu-latest + steps: + - name: Check if PR is in draft mode + run: | + if [ "${{ github.event.pull_request.draft }}" == "true" ]; then + echo "PR is in draft mode, skipping workflow" + exit 78 + fi + - name: Set up Deno + uses: denolib/setup-deno@v2 + with: + deno-version: "1.37.0" + - name: Run tests + run: deno test --allow-env diff --git a/README.md b/README.md index 4056815f..b4727c8b 100644 --- a/README.md +++ b/README.md @@ -498,3 +498,9 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&no-frame=true # Contribution Guide Check [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. + +# Testing + +```bash +deno test --allow-env +``` diff --git a/index.ts b/api/index.ts similarity index 73% rename from index.ts rename to api/index.ts index 0bbbcce8..4a095c78 100644 --- a/index.ts +++ b/api/index.ts @@ -1,28 +1,31 @@ -import { GithubAPIClient } from "./src/github_api_client.ts"; -import { Card } from "./src/card.ts"; -import { CONSTANTS, parseParams } from "./src/utils.ts"; -import { COLORS, Theme } from "./src/theme.ts"; -import { Error400, Error404 } from "./src/error_page.ts"; +import { Card } from "../src/card.ts"; +import { CONSTANTS, parseParams } from "../src/utils.ts"; +import { COLORS, Theme } from "../src/theme.ts"; +import { Error400, Error404 } from "../src/error_page.ts"; import "https://deno.land/x/dotenv@v0.5.0/load.ts"; -import {staticRenderRegeneration} from "./src/StaticRenderRegeneration/index.ts"; -const apiEndpoint = Deno.env.get("GITHUB_API") || CONSTANTS.DEFAULT_GITHUB_API; -const client = new GithubAPIClient(apiEndpoint); +import { staticRenderRegeneration } from "../src/StaticRenderRegeneration/index.ts"; +import { GithubRepositoryService } from "../src/Repository/GithubRepository.ts"; +import { GithubApiService } from "../src/Services/GithubApiService.ts"; + +const serviceProvider = new GithubApiService(); +const client = new GithubRepositoryService(serviceProvider).repository; const defaultHeaders = new Headers( { "Content-Type": "image/svg+xml", "Cache-Control": `public, max-age=${CONSTANTS.CACHE_MAX_AGE}`, }, -) +); -export default (request: Request) => staticRenderRegeneration(request, { - revalidate: CONSTANTS.REVALIDATE_TIME, - headers: defaultHeaders -}, function (req: Request) { - return app(req); -}); +export default (request: Request) => + staticRenderRegeneration(request, { + revalidate: CONSTANTS.REVALIDATE_TIME, + headers: defaultHeaders, + }, function (req: Request) { + return app(req); + }); -async function app (req: Request): Promise{ +async function app(req: Request): Promise { const params = parseParams(req); const username = params.get("username"); const row = params.getNumberValue("row", CONSTANTS.DEFAULT_MAX_ROW); @@ -83,7 +86,7 @@ async function app (req: Request): Promise{ }, ); } - + // Success Response return new Response( new Card( diff --git a/debug.ts b/debug.ts index ab64cc8a..4d1ac0b0 100644 --- a/debug.ts +++ b/debug.ts @@ -1,4 +1,4 @@ import { serve } from "https://deno.land/std@0.125.0/http/server.ts"; -import requestHandler from "./index.ts"; +import requestHandler from "./api/index.ts"; serve(requestHandler, { port: 8080 }); diff --git a/deps.ts b/deps.ts index 5d6175aa..4b51bfce 100644 --- a/deps.ts +++ b/deps.ts @@ -1 +1,21 @@ -export { soxa } from "https://deno.land/x/soxa@1.4/mod.ts"; +import { Soxa as ServiceProvider } from "https://deno.land/x/soxa@1.4/src/core/Soxa.ts"; +import { defaults } from "https://deno.land/x/soxa@1.4/src/defaults.ts"; +import { + assertEquals, + assertRejects, +} from "https://deno.land/std@0.203.0/assert/mod.ts"; +import { + assertSpyCalls, + spy, +} from "https://deno.land/std@0.203.0/testing/mock.ts"; + +import { CONSTANTS } from "./src/utils.ts"; + +const baseURL = Deno.env.get("GITHUB_API") || CONSTANTS.DEFAULT_GITHUB_API; + +const soxa = new ServiceProvider({ + ...defaults, + baseURL, +}); + +export { assertEquals, assertRejects, assertSpyCalls, soxa, spy }; diff --git a/src/Helpers/Retry.ts b/src/Helpers/Retry.ts new file mode 100644 index 00000000..be1eb9fc --- /dev/null +++ b/src/Helpers/Retry.ts @@ -0,0 +1,44 @@ +export type RetryCallbackProps = { + attempt: number; +}; + +type callbackType = (data: RetryCallbackProps) => Promise | T; + +async function* createAsyncIterable( + callback: callbackType, + retries: number, + delay: number, +) { + for (let i = 0; i < retries; i++) { + try { + const data = await callback({ attempt: i }); + yield data; + return; + } catch (e) { + yield null; + console.error(e); + await new Promise((resolve) => setTimeout(resolve, delay)); + } + } +} + +export class Retry { + constructor(private maxRetries = 2, private retryDelay = 1000) {} + async fetch( + callback: callbackType, + ) { + for await ( + const callbackResult of createAsyncIterable( + callback, + this.maxRetries, + this.retryDelay, + ) + ) { + if (callbackResult) { + return callbackResult as T; + } + } + + throw new Error(`Max retries (${this.maxRetries}) exceeded.`); + } +} diff --git a/src/Helpers/__tests__/Retry.test.ts b/src/Helpers/__tests__/Retry.test.ts new file mode 100644 index 00000000..7fcad13e --- /dev/null +++ b/src/Helpers/__tests__/Retry.test.ts @@ -0,0 +1,66 @@ +import { Retry } from "../Retry.ts"; +import { + assertEquals, + assertRejects, + assertSpyCalls, + spy, +} from "../../../deps.ts"; + +type MockResponse = { + value: number; +}; + +Deno.test("Retry.fetch", () => { + const retryInstance = new Retry(); + const callback = spy(retryInstance, "fetch"); + + retryInstance.fetch(() => { + return { value: 1 }; + }); + + assertSpyCalls(callback, 1); +}); + +Deno.test("Should retry", async () => { + let countErrors = 0; + + const callbackError = () => { + countErrors++; + throw new Error("Panic! Threw Error"); + }; + const retries = 3; + const retryInstance = new Retry(retries); + + await assertRejects( + () => { + return retryInstance.fetch(callbackError); + }, + Error, + `Max retries (${retries}) exceeded.`, + ); + + assertEquals(countErrors, 3); +}); + +Deno.test("Should retry the asyncronous callback", async () => { + let countErrors = 0; + + const callbackError = async () => { + countErrors++; + // Mock request in callback + await new Promise((_, reject) => setTimeout(reject, 100)); + }; + + const retries = 3; + const retryInstance = new Retry(retries); + + await assertRejects( + () => { + return retryInstance.fetch(callbackError); + }, + Error, + `Max retries (${retries}) exceeded.`, + ); + + assertEquals(countErrors, 3); +}); diff --git a/src/Repository/GithubRepository.ts b/src/Repository/GithubRepository.ts new file mode 100644 index 00000000..b345fd35 --- /dev/null +++ b/src/Repository/GithubRepository.ts @@ -0,0 +1,25 @@ +import { + GitHubUserActivity, + GitHubUserIssue, + GitHubUserPullRequest, + GitHubUserRepository, + UserInfo, +} from "../user_info.ts"; + +export abstract class GithubRepository { + abstract requestUserInfo(username: string): Promise; + abstract requestUserActivity( + username: string, + ): Promise; + abstract requestUserIssue(username: string): Promise; + abstract requestUserPullRequest( + username: string, + ): Promise; + abstract requestUserRepository( + username: string, + ): Promise; +} + +export class GithubRepositoryService { + constructor(public repository: GithubRepository) {} +} diff --git a/src/Schemas/index.ts b/src/Schemas/index.ts new file mode 100644 index 00000000..2c7e3f34 --- /dev/null +++ b/src/Schemas/index.ts @@ -0,0 +1,61 @@ +export const queryUserActivity = ` + query userInfo($username: String!) { + user(login: $username) { + createdAt + contributionsCollection { + totalCommitContributions + restrictedContributionsCount + totalPullRequestReviewContributions + } + organizations(first: 1) { + totalCount + } + followers(first: 1) { + totalCount + } + } + } +`; + +export const queryUserIssue = ` + query userInfo($username: String!) { + user(login: $username) { + openIssues: issues(states: OPEN) { + totalCount + } + closedIssues: issues(states: CLOSED) { + totalCount + } + } + } +`; + +export const queryUserPullRequest = ` + query userInfo($username: String!) { + user(login: $username) { + pullRequests(first: 1) { + totalCount + } + } + } +`; + +export const queryUserRepository = ` + query userInfo($username: String!) { + user(login: $username) { + repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}) { + totalCount + nodes { + languages(first: 3, orderBy: {direction:DESC, field: SIZE}) { + nodes { + name + } + } + stargazers { + totalCount + } + } + } + } + } +`; diff --git a/src/Services/GithubApiService.ts b/src/Services/GithubApiService.ts new file mode 100644 index 00000000..4d7a9ccd --- /dev/null +++ b/src/Services/GithubApiService.ts @@ -0,0 +1,120 @@ +import { GithubRepository } from "../Repository/GithubRepository.ts"; +import { + GitHubUserActivity, + GitHubUserIssue, + GitHubUserPullRequest, + GitHubUserRepository, + UserInfo, +} from "../user_info.ts"; +import { + queryUserActivity, + queryUserIssue, + queryUserPullRequest, + queryUserRepository, +} from "../Schemas/index.ts"; +import { soxa } from "../../deps.ts"; +import { Retry } from "../Helpers/Retry.ts"; +import { QueryDefaultResponse } from "../Types/index.ts"; +import { CONSTANTS } from "../utils.ts"; + +// Need to be here - Exporting from another file makes array of null +export const TOKENS = [ + Deno.env.get("GITHUB_TOKEN1"), + Deno.env.get("GITHUB_TOKEN2"), +]; + +export class GithubApiService extends GithubRepository { + async requestUserRepository( + username: string, + ): Promise { + return await this.executeQuery(queryUserRepository, { + username, + }); + } + async requestUserActivity( + username: string, + ): Promise { + return await this.executeQuery(queryUserActivity, { + username, + }); + } + async requestUserIssue(username: string): Promise { + return await this.executeQuery(queryUserIssue, { + username, + }); + } + async requestUserPullRequest( + username: string, + ): Promise { + return await this.executeQuery( + queryUserPullRequest, + { username }, + ); + } + async requestUserInfo(username: string): Promise { + // Avoid to call others if one of them is null + const repository = await this.requestUserRepository(username); + if (repository === null) return null; + + const promises = Promise.allSettled([ + this.requestUserActivity(username), + this.requestUserIssue(username), + this.requestUserPullRequest(username), + ]); + const [activity, issue, pullRequest] = await promises; + const status = [ + activity.status, + issue.status, + pullRequest.status, + ]; + + if (status.includes("rejected")) { + console.error(`Can not find a user with username:' ${username}'`); + return null; + } + + return new UserInfo( + (activity as PromiseFulfilledResult).value, + (issue as PromiseFulfilledResult).value, + (pullRequest as PromiseFulfilledResult).value, + repository, + ); + } + + async executeQuery( + query: string, + variables: { [key: string]: string }, + ) { + try { + const retry = new Retry( + TOKENS.length, + CONSTANTS.DEFAULT_GITHUB_RETRY_DELAY, + ); + const response = await retry.fetch>(async ({ attempt }) => { + return await soxa.post("", {}, { + data: { query: query, variables }, + headers: { + Authorization: `bearer ${TOKENS[attempt]}`, + }, + }); + }) as QueryDefaultResponse<{ user: T; errors?: unknown[] }>; + + if (response.data.data.errors) { + throw new Error("Error from Github API", { + cause: response.data.data.errors, + }); + } + + return response?.data?.data?.user ?? null; + } catch (error) { + // TODO: Move this to a logger instance later + if (error instanceof Error && error.cause) { + console.error(JSON.stringify(error.cause, null, 2)); + } else { + console.error(error); + } + + return null; + } + } +} diff --git a/src/StaticRenderRegeneration/cache_manager.ts b/src/StaticRenderRegeneration/cache_manager.ts index 3a93e4b4..2f11479c 100644 --- a/src/StaticRenderRegeneration/cache_manager.ts +++ b/src/StaticRenderRegeneration/cache_manager.ts @@ -1,49 +1,50 @@ -import { existsSync } from './utils.ts' +import { existsSync } from "./utils.ts"; export class CacheManager { - constructor(private revalidateTime: number, private cacheFile: string) {} - - // Reason to use /tmp/: - // https://github.com/orgs/vercel/discussions/314 - get cacheFilePath(): string { - return `/tmp/${this.cacheFile}`; - } - get cacheFileExists(): boolean { - return existsSync(this.cacheFilePath); - } + constructor(private revalidateTime: number, private cacheFile: string) {} - get cacheFileLastModified(): Date | null { - if (!this.cacheFileExists) { - return null; - } - const fileInfo = Deno.statSync(this.cacheFilePath); - return fileInfo.mtime ?? null; - } + // Reason to use /tmp/: + // https://github.com/orgs/vercel/discussions/314 + get cacheFilePath(): string { + return `/tmp/${this.cacheFile}`; + } + get cacheFileExists(): boolean { + return existsSync(this.cacheFilePath); + } - get cacheFileLastModifiedGetTime(): number | null { - const lastModified = this.cacheFileLastModified; - if (lastModified === null) { - return null; - } - return lastModified.getTime(); + get cacheFileLastModified(): Date | null { + if (!this.cacheFileExists) { + return null; } + const fileInfo = Deno.statSync(this.cacheFilePath); + return fileInfo.mtime ?? null; + } - get isCacheValid(): boolean { - if (this.cacheFileLastModifiedGetTime === null) { - return false; - } - const currentTime = new Date().getTime(); - return currentTime - this.cacheFileLastModifiedGetTime < this.revalidateTime; + get cacheFileLastModifiedGetTime(): number | null { + const lastModified = this.cacheFileLastModified; + if (lastModified === null) { + return null; } + return lastModified.getTime(); + } - async save (response: Response): Promise { - if(response === null) return - // Prevent TypeError: ReadableStream is locked - const text = await response.clone().text() - const data = new TextEncoder().encode(text) - - Deno.writeFile(this.cacheFilePath, data, { create: true }).catch(() => { - console.warn("Failed to save cache file") - }); + get isCacheValid(): boolean { + if (this.cacheFileLastModifiedGetTime === null) { + return false; } -} \ No newline at end of file + const currentTime = new Date().getTime(); + return currentTime - this.cacheFileLastModifiedGetTime < + this.revalidateTime; + } + + async save(response: Response): Promise { + if (response === null) return; + // Prevent TypeError: ReadableStream is locked + const text = await response.clone().text(); + const data = new TextEncoder().encode(text); + + Deno.writeFile(this.cacheFilePath, data, { create: true }).catch(() => { + console.warn("Failed to save cache file"); + }); + } +} diff --git a/src/Types/Request.ts b/src/Types/Request.ts new file mode 100644 index 00000000..5ef517e7 --- /dev/null +++ b/src/Types/Request.ts @@ -0,0 +1,5 @@ +export type QueryDefaultResponse = { + data: { + data: T; + }; +}; diff --git a/src/Types/index.ts b/src/Types/index.ts new file mode 100644 index 00000000..07f4ba9c --- /dev/null +++ b/src/Types/index.ts @@ -0,0 +1 @@ +export * from './Request.ts' \ No newline at end of file diff --git a/src/github_api_client.ts b/src/github_api_client.ts deleted file mode 100644 index c4b8361e..00000000 --- a/src/github_api_client.ts +++ /dev/null @@ -1,159 +0,0 @@ -import { soxa } from "../deps.ts"; -import { UserInfo } from "./user_info.ts"; -import { CONSTANTS } from "./utils.ts"; -import type { - GitHubUserActivity, - GitHubUserIssue, - GitHubUserPullRequest, - GitHubUserRepository, -} from "./user_info.ts"; - -export class GithubAPIClient { - constructor( - private apiEndpoint: string = CONSTANTS.DEFAULT_GITHUB_API, - ) { - } - async requestUserInfo( - username: string, - ): Promise { - // Avoid timeout for the Github API - const results = await Promise.all([ - this.requestUserActivity(username), - this.requestUserIssue(username), - this.requestUserPullRequest(username), - this.requestUserRepository(username), - ]); - if (results.some((r) => r == null)) { - console.error(`Can not find a user with username:'${username}'`); - return null; - } - return new UserInfo(results[0]!, results[1]!, results[2]!, results[3]!); - } - private async requestUserActivity( - username: string, - ): Promise { - const query = ` - query userInfo($username: String!) { - user(login: $username) { - createdAt - contributionsCollection { - totalCommitContributions - restrictedContributionsCount - totalPullRequestReviewContributions - } - organizations(first: 1) { - totalCount - } - followers(first: 1) { - totalCount - } - } - } - `; - return await this.request(query, username); - } - private async requestUserIssue( - username: string, - ): Promise { - const query = ` - query userInfo($username: String!) { - user(login: $username) { - openIssues: issues(states: OPEN) { - totalCount - } - closedIssues: issues(states: CLOSED) { - totalCount - } - } - } - `; - return await this.request(query, username); - } - private async requestUserPullRequest( - username: string, - ): Promise { - const query = ` - query userInfo($username: String!) { - user(login: $username) { - pullRequests(first: 1) { - totalCount - } - } - } - `; - return await this.request(query, username); - } - private async requestUserRepository( - username: string, - ): Promise { - const query = ` - query userInfo($username: String!) { - user(login: $username) { - repositories(first: 100, ownerAffiliations: OWNER, orderBy: {direction: DESC, field: STARGAZERS}) { - totalCount - nodes { - languages(first: 3, orderBy: {direction:DESC, field: SIZE}) { - nodes { - name - } - } - stargazers { - totalCount - } - } - } - } - } - `; - return await this.request(query, username); - } - private async request( - query: string, - username: string, - retryDelay = CONSTANTS.DEFAULT_GITHUB_RETRY_DELAY, - ) { - const tokens = [ - Deno.env.get("GITHUB_TOKEN1"), - Deno.env.get("GITHUB_TOKEN2"), - ]; - const maxRetries = tokens.length; - - const variables = { username: username }; - let response; - - for (let attempt = 0; attempt < maxRetries; attempt++) { - try { - response = await soxa.post( - this.apiEndpoint, - {}, - { - data: { query: query, variables }, - headers: { Authorization: `bearer ${tokens[attempt]}` }, - }, - ); - if (response.data.errors !== undefined) { - throw new Error( - response.data.errors.map((e: { message: string; type: string }) => - e.message - ).join("\n"), - ); - } - if (response.data.data !== undefined) { - return response.data.data.user; - } else { - return null; - } - } catch (error) { - console.error( - `Attempt ${attempt} failed with GITHUB_TOKEN${attempt + 1}:`, - error, - ); - } - - console.log(`Retrying in ${retryDelay / 1000} seconds...`); - await new Promise((resolve) => setTimeout(resolve, retryDelay)); - } - - throw new Error(`Max retries (${maxRetries}) exceeded.`); - } -} diff --git a/vercel.json b/vercel.json index 9bceb939..5288cb87 100644 --- a/vercel.json +++ b/vercel.json @@ -1,7 +1,14 @@ { - "builds":[{ - "src": "**/index.ts", - "use": "vercel-deno@1.1.1" - }], - "routes": [{ "src": "/.*", "dest": "/" }] -} + "public": true, + "functions": { + "api/**/*.[jt]s": { + "runtime": "vercel-deno@3.0.4" + } + }, + "rewrites": [ + { + "source": "/(.*)", + "destination": "/api/$1" + } + ] +} \ No newline at end of file From 495eba6a751c1d68d9bccb5498db578951f2535d Mon Sep 17 00:00:00 2001 From: Alexandro Castro <10711649+AlexcastroDev@users.noreply.github.com> Date: Sat, 30 Sep 2023 06:49:18 +0100 Subject: [PATCH 14/33] chore: types improvements (#226) chore: types improvements chore: types improvements --- .github/workflows/testing.yml | 25 +-- .gitignore | 1 + CONTRIBUTING.md | 15 +- README.md | 159 +++++++++--------- api/index.ts | 13 +- deno.json | 9 + src/.DS_Store | Bin 0 -> 6148 bytes src/Helpers/Logger.ts | 19 +++ src/Helpers/Retry.ts | 4 +- src/Helpers/__tests__/Retry.test.ts | 1 - src/Repository/GithubRepository.ts | 13 +- src/Services/GithubApiService.ts | 64 ++++--- src/StaticRenderRegeneration/cache_manager.ts | 3 +- src/StaticRenderRegeneration/index.ts | 49 +++--- src/StaticRenderRegeneration/types.ts | 10 +- src/StaticRenderRegeneration/utils.ts | 60 +++---- src/Types/EServiceKindError.ts | 4 + src/Types/Request.ts | 6 + src/Types/ServiceError.ts | 20 +++ src/Types/index.ts | 4 +- src/error_page.ts | 5 + src/icons.ts | 2 +- src/pages/Error.ts | 23 +++ src/theme.ts | 5 +- src/trophy.ts | 51 +++--- src/trophy_list.ts | 31 ++-- src/user_info.ts | 11 +- vercel.json | 26 +-- 28 files changed, 387 insertions(+), 246 deletions(-) create mode 100644 deno.json create mode 100644 src/.DS_Store create mode 100644 src/Helpers/Logger.ts create mode 100644 src/Types/EServiceKindError.ts create mode 100644 src/Types/ServiceError.ts create mode 100644 src/pages/Error.ts diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index b8215530..8d42acdc 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -7,16 +7,21 @@ on: jobs: install-dependencies: runs-on: ubuntu-latest + + strategy: + matrix: + deno-version: [1.36.1] + steps: - - name: Check if PR is in draft mode - run: | - if [ "${{ github.event.pull_request.draft }}" == "true" ]; then - echo "PR is in draft mode, skipping workflow" - exit 78 - fi - - name: Set up Deno + - name: Git Checkout Deno Module + uses: actions/checkout@v2 + - name: Use Deno Version ${{ matrix.deno-version }} uses: denolib/setup-deno@v2 with: - deno-version: "1.37.0" - - name: Run tests - run: deno test --allow-env + deno-version: ${{ matrix.deno-version }} + - name: Deno format check + run: deno fmt --check + - name: Deno lint check + run: deno task lint + - name: Test Deno Module + run: deno task test diff --git a/.gitignore b/.gitignore index 3c497e35..2aa5d60a 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .vscode .env .idea +.lock diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 95eaad16..e8cf04a8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,14 +2,14 @@ ## Environment -* Deno >= v1.9.2 -* [Vercel](https://vercel.com/) -* GitHub API v4 +- Deno >= v1.9.2 +- [Vercel](https://vercel.com/) +- GitHub API v4 ## Local Run -Create `.env` file to project root directory, and write your GitHub token to the `.env` file. -Please select the authority of `repo` when creating token. +Create `.env` file to project root directory, and write your GitHub token to the +`.env` file. Please select the authority of `repo` when creating token. ```properties GITHUB_TOKEN1=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX @@ -23,7 +23,7 @@ GITHUB_API=https://github.example.com/api/graphql Run local server. ```sh -deno run --allow-net --allow-read --allow-env --allow-write debug.ts +deno task start ``` Open localhost from your browser. @@ -36,7 +36,8 @@ Read the [.editorconfig](./.editorconfig) ## Run deno lint -If you want to contribute to my project, you should check the lint with the following command. +If you want to contribute to my project, you should check the lint with the +following command. ```sh deno lint --unstable diff --git a/README.md b/README.md index b4727c8b..96bc8cfd 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,26 @@

- +

GitHub Profile Trophy

🏆 Add dynamically generated GitHub Stat Trophies on your readme

- + - - + + - + - +

- +

@@ -28,13 +28,14 @@

- +

# Quick Start -Add the following code to your readme. When pasting the code into your profile's readme, change the `?username=` value to your GitHub's username. +Add the following code to your readme. When pasting the code into your profile's +readme, change the `?username=` value to your GitHub's username. ``` [![trophy](https://github-profile-trophy.vercel.app/?username=ryo-ma)](https://github.com/ryo-ma/github-profile-trophy) @@ -51,6 +52,7 @@ Add optional parameter of the theme. ``` [![trophy](https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=onedark)](https://github.com/ryo-ma/github-profile-trophy) ``` +

@@ -61,22 +63,25 @@ Add optional parameter of the theme. Ranks are `SSS` `SS` `S` `AAA` `AA` `A` `B` `C` `UNKNOWN` `SECRET`. -| Rank | Description | -| ---- | ---- | -| SSS, SS, S | You are at a hard to reach rank. You can brag. | -| AAA, AA, A | You will reach this rank if you do your best. Let's aim here first. | -| B, C | You are currently making good progress. Let's aim a bit higher. | -| UNKNOWN | You have not taken action yet. Let's act first. | -| SECRET | This rank is very rare. The trophy will not be displayed until certain conditions are met. | +| Rank | Description | +| ---------- | ------------------------------------------------------------------------------------------ | +| SSS, SS, S | You are at a hard to reach rank. You can brag. | +| AAA, AA, A | You will reach this rank if you do your best. Let's aim here first. | +| B, C | You are currently making good progress. Let's aim a bit higher. | +| UNKNOWN | You have not taken action yet. Let's act first. | +| SECRET | This rank is very rare. The trophy will not be displayed until certain conditions are met. | ## Secret Rank -The acquisition condition is secret, but you can know the condition by reading this code. + +The acquisition condition is secret, but you can know the condition by reading +this code.

-There are only a few secret trophies. Therefore, if you come up with interesting conditions, I will consider adding a trophy. I am waiting for contributions. +There are only a few secret trophies. Therefore, if you come up with interesting +conditions, I will consider adding a trophy. I am waiting for contributions. # About Display details @@ -90,23 +95,21 @@ There are only a few secret trophies. Therefore, if you come up with interesting 4. Target aggregation result. 5. Next Rank Bar. The road from the current rank to the next rank. - # Optional Request Parameters -* [title](#filter-by-titles) -* [rank](#filter-by-ranks) -* [column](#specify-the-maximum-row--column-size) -* [row](#specify-the-maximum-row--column-size) -* [theme](#apply-theme) -* [margin-w](#margin-width) -* [margin-h](#margin-height) -* [no-bg](#transparent-background) -* [no-frame](#hide-frames) - +- [title](#filter-by-titles) +- [rank](#filter-by-ranks) +- [column](#specify-the-maximum-row--column-size) +- [row](#specify-the-maximum-row--column-size) +- [theme](#apply-theme) +- [margin-w](#margin-width) +- [margin-h](#margin-height) +- [no-bg](#transparent-background) +- [no-frame](#hide-frames) ## Filter by titles -You can filter the display by specifying the titles of trophy. +You can filter the display by specifying the titles of trophy. ``` https://github-profile-trophy.vercel.app/?username=ryo-ma&title=Followers @@ -124,12 +127,13 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&title=Stars,Followers ## Filter by ranks -You can filter the display by specifying the ranks. +You can filter the display by specifying the ranks.\ `Available values: SECRET SSS SS S AAA AA A B C` ``` https://github-profile-trophy.vercel.app/?username=ryo-ma&rank=S ``` +

@@ -146,26 +150,28 @@ You can also exclude ranks. https://github-profile-trophy.vercel.app/?username=ryo-ma&rank=-C,-B ``` - ## Specify the maximum row & column size -You can specify the maximum row and column size. +You can specify the maximum row and column size.\ Trophy will be hidden if it exceeds the range of both row and column. -`Available value: number type` +`Available value: number type`\ `Default: column=6 row=3` Restrict only row + ``` https://github-profile-trophy.vercel.app/?username=ryo-ma&row=2 ``` Restrict only column + ``` https://github-profile-trophy.vercel.app/?username=ryo-ma&column=2 ``` Restrict row & column + ``` https://github-profile-trophy.vercel.app/?username=ryo-ma&row=2&column=3 ``` @@ -175,47 +181,49 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&row=2&column=3

Adaptive column + ``` https://github-profile-trophy.vercel.app/?username=ryo-ma&column=-1 ``` -You can set `columns` to `-1` to adapt the width to the number of trophies, the parameter `row` will be ignored. +You can set `columns` to `-1` to adapt the width to the number of trophies, the +parameter `row` will be ignored. ## Apply theme Available themes. -| theme | -| ---- | -| [flat](#flat) | -| [onedark](#onedark) | -| [gruvbox](#gruvbox) | -| [dracula](#dracula) | -| [monokai](#monokai) | -| [chalk](#chalk) | -| [nord](#nord) | -| [alduin](#alduin) | -| [darkhub](#darkhub) | -| [juicyfresh](#juicyfresh) | -| [buddhism](#buddhism) | -| [oldie](#oldie) | -| [radical](#radical) | -| [onestar](#onestar) | -| [discord](#discord) | -| [algolia](#algolia) | -| [gitdimmed](#gitdimmed) | -| [tokyonight](#tokyonight) | -| [matrix](#matrix) | -| [apprentice](#apprentice) | +| theme | +| --------------------------- | +| [flat](#flat) | +| [onedark](#onedark) | +| [gruvbox](#gruvbox) | +| [dracula](#dracula) | +| [monokai](#monokai) | +| [chalk](#chalk) | +| [nord](#nord) | +| [alduin](#alduin) | +| [darkhub](#darkhub) | +| [juicyfresh](#juicyfresh) | +| [buddhism](#buddhism) | +| [oldie](#oldie) | +| [radical](#radical) | +| [onestar](#onestar) | +| [discord](#discord) | +| [algolia](#algolia) | +| [gitdimmed](#gitdimmed) | +| [tokyonight](#tokyonight) | +| [matrix](#matrix) | +| [apprentice](#apprentice) | | [dark_dimmed](#dark_dimmed) | -| [dark_lover](#dark_lover) | - +| [dark_lover](#dark_lover) | ### flat ``` https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=flat ``` +

@@ -280,7 +288,6 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=nord

- ### alduin ``` @@ -388,7 +395,7 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=tokyonight ```

- +

### matrix @@ -398,7 +405,7 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=matrix ```

- +

### apprentice @@ -408,7 +415,7 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=apprentice ```

- +

### dark_dimmed @@ -418,7 +425,7 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=dark_dimmed ```

- +

### dark_lover @@ -428,13 +435,13 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&theme=dark_lover ```

- +

## Margin Width -You can put a margin in the width between trophies. -`Available value: number type` +You can put a margin in the width between trophies.\ +`Available value: number type`\ `Default: margin-w=0` ``` @@ -447,8 +454,8 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&margin-w=15 ## Margin Height -You can put a margin in the height between trophies. -`Available value: number type` +You can put a margin in the height between trophies.\ +`Available value: number type`\ `Default: margin-h=0` ``` @@ -467,8 +474,8 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&column=3&margin-w=15&m ## Transparent background -You can turn the background transparent. -`Available value: boolean type (true or false)` +You can turn the background transparent.\ +`Available value: boolean type (true or false)`\ `Default: no-bg=false` ``` @@ -479,12 +486,10 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&no-bg=true

- - ## Hide frames -You can hide the frames around the trophies. -`Available value: boolean type (true or false)` +You can hide the frames around the trophies.\ +`Available value: boolean type (true or false)`\ `Default: no-frame=false` ``` @@ -495,12 +500,12 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&no-frame=true

- # Contribution Guide + Check [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. # Testing ```bash -deno test --allow-env +deno task test ``` diff --git a/api/index.ts b/api/index.ts index 4a095c78..68d5215a 100644 --- a/api/index.ts +++ b/api/index.ts @@ -1,11 +1,13 @@ import { Card } from "../src/card.ts"; import { CONSTANTS, parseParams } from "../src/utils.ts"; import { COLORS, Theme } from "../src/theme.ts"; -import { Error400, Error404 } from "../src/error_page.ts"; +import { Error400 } from "../src/error_page.ts"; import "https://deno.land/x/dotenv@v0.5.0/load.ts"; import { staticRenderRegeneration } from "../src/StaticRenderRegeneration/index.ts"; import { GithubRepositoryService } from "../src/Repository/GithubRepository.ts"; import { GithubApiService } from "../src/Services/GithubApiService.ts"; +import { ServiceError } from "../src/Types/index.ts"; +import { ErrorPage } from "../src/pages/Error.ts"; const serviceProvider = new GithubApiService(); const client = new GithubRepositoryService(serviceProvider).repository; @@ -74,14 +76,11 @@ async function app(req: Request): Promise { ); } const userInfo = await client.requestUserInfo(username); - if (userInfo === null) { - const error = new Error404( - "Can not find a user with username: " + username, - ); + if (userInfo instanceof ServiceError) { return new Response( - error.render(), + ErrorPage({ error: userInfo, username }).render(), { - status: error.status, + status: userInfo.code, headers: new Headers({ "Content-Type": "text" }), }, ); diff --git a/deno.json b/deno.json new file mode 100644 index 00000000..327d1ec1 --- /dev/null +++ b/deno.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "start": "deno run -A debug.ts", + "debug": "deno --inspect-brk -A debug.ts", + "format": "deno fmt", + "lint": "deno lint", + "test": "ENV_TYPE=test deno test --allow-env" + } +} diff --git a/src/.DS_Store b/src/.DS_Store new file mode 100644 index 0000000000000000000000000000000000000000..dbcdcabe06f6c0b9f79fd341be39ab7c161065a8 GIT binary patch literal 6148 zcmeH~%}T>i5QWcZ7X>$6c6ndHHwdM^fO!GMmLfYKm=w9c<-ex?^3mjfCz}d4*~x^G`ee7 zI;O^_gG-D6)CI#~ypCCd+B`t*O2<@2XqKhaEVUXjEXx^hmDiPysaX!I;lt|5RuhWF z(|LZ2bXb?FRRl!fn!s&tSKj|0=)cVW*G1ZifC&660=C%p+YMi-dh6unyw^7R6aCwm o8|hr46;q=XbK|XeeUaDvn)kcXF*V8=k8-Mh1e}XZ1pb1+7jJhOK>z>% literal 0 HcmV?d00001 diff --git a/src/Helpers/Logger.ts b/src/Helpers/Logger.ts new file mode 100644 index 00000000..896192a5 --- /dev/null +++ b/src/Helpers/Logger.ts @@ -0,0 +1,19 @@ +const enableLogging = Deno.env.get("ENV_TYPE") !== "test"; + +export class Logger { + public static log(message: unknown): void { + if (!enableLogging) return; + console.log(message); + } + + public static error(message: unknown): void { + if (!enableLogging) return; + + console.error(message); + } + public static warn(message: unknown): void { + if (!enableLogging) return; + + console.warn(message); + } +} diff --git a/src/Helpers/Retry.ts b/src/Helpers/Retry.ts index be1eb9fc..a7ce9a95 100644 --- a/src/Helpers/Retry.ts +++ b/src/Helpers/Retry.ts @@ -1,3 +1,5 @@ +import { Logger } from "./Logger.ts"; + export type RetryCallbackProps = { attempt: number; }; @@ -16,7 +18,7 @@ async function* createAsyncIterable( return; } catch (e) { yield null; - console.error(e); + Logger.error(e); await new Promise((resolve) => setTimeout(resolve, delay)); } } diff --git a/src/Helpers/__tests__/Retry.test.ts b/src/Helpers/__tests__/Retry.test.ts index 7fcad13e..a1fec42b 100644 --- a/src/Helpers/__tests__/Retry.test.ts +++ b/src/Helpers/__tests__/Retry.test.ts @@ -44,7 +44,6 @@ Deno.test("Should retry", async () => { Deno.test("Should retry the asyncronous callback", async () => { let countErrors = 0; - const callbackError = async () => { countErrors++; // Mock request in callback diff --git a/src/Repository/GithubRepository.ts b/src/Repository/GithubRepository.ts index b345fd35..c2a408c6 100644 --- a/src/Repository/GithubRepository.ts +++ b/src/Repository/GithubRepository.ts @@ -1,3 +1,4 @@ +import { ServiceError } from "../Types/index.ts"; import { GitHubUserActivity, GitHubUserIssue, @@ -7,17 +8,19 @@ import { } from "../user_info.ts"; export abstract class GithubRepository { - abstract requestUserInfo(username: string): Promise; + abstract requestUserInfo(username: string): Promise; abstract requestUserActivity( username: string, - ): Promise; - abstract requestUserIssue(username: string): Promise; + ): Promise; + abstract requestUserIssue( + username: string, + ): Promise; abstract requestUserPullRequest( username: string, - ): Promise; + ): Promise; abstract requestUserRepository( username: string, - ): Promise; + ): Promise; } export class GithubRepositoryService { diff --git a/src/Services/GithubApiService.ts b/src/Services/GithubApiService.ts index 4d7a9ccd..685bac58 100644 --- a/src/Services/GithubApiService.ts +++ b/src/Services/GithubApiService.ts @@ -14,8 +14,11 @@ import { } from "../Schemas/index.ts"; import { soxa } from "../../deps.ts"; import { Retry } from "../Helpers/Retry.ts"; -import { QueryDefaultResponse } from "../Types/index.ts"; +import { GithubError, QueryDefaultResponse } from "../Types/index.ts"; import { CONSTANTS } from "../utils.ts"; +import { EServiceKindError } from "../Types/EServiceKindError.ts"; +import { ServiceError } from "../Types/ServiceError.ts"; +import { Logger } from "../Helpers/Logger.ts"; // Need to be here - Exporting from another file makes array of null export const TOKENS = [ @@ -26,35 +29,40 @@ export const TOKENS = [ export class GithubApiService extends GithubRepository { async requestUserRepository( username: string, - ): Promise { + ): Promise { return await this.executeQuery(queryUserRepository, { username, }); } async requestUserActivity( username: string, - ): Promise { + ): Promise { return await this.executeQuery(queryUserActivity, { username, }); } - async requestUserIssue(username: string): Promise { + async requestUserIssue( + username: string, + ): Promise { return await this.executeQuery(queryUserIssue, { username, }); } async requestUserPullRequest( username: string, - ): Promise { + ): Promise { return await this.executeQuery( queryUserPullRequest, { username }, ); } - async requestUserInfo(username: string): Promise { + async requestUserInfo(username: string): Promise { // Avoid to call others if one of them is null const repository = await this.requestUserRepository(username); - if (repository === null) return null; + if (repository instanceof ServiceError) { + Logger.error(repository); + return repository; + } const promises = Promise.allSettled([ this.requestUserActivity(username), @@ -69,15 +77,34 @@ export class GithubApiService extends GithubRepository { ]; if (status.includes("rejected")) { - console.error(`Can not find a user with username:' ${username}'`); - return null; + Logger.error(`Can not find a user with username:' ${username}'`); + return new ServiceError("not found", EServiceKindError.NOT_FOUND); } return new UserInfo( (activity as PromiseFulfilledResult).value, (issue as PromiseFulfilledResult).value, (pullRequest as PromiseFulfilledResult).value, - repository, + repository as GitHubUserRepository, + ); + } + + private handleError(responseErrors: GithubError[]): ServiceError { + const errors = responseErrors ?? []; + const isRateLimitExceeded = (errors ?? []).some((error) => { + error.type.includes(EServiceKindError.RATE_LIMIT); + }); + + if (isRateLimitExceeded) { + throw new ServiceError( + "Rate limit exceeded", + EServiceKindError.RATE_LIMIT, + ); + } + + throw new ServiceError( + "unknown error", + EServiceKindError.NOT_FOUND, ); } @@ -97,24 +124,23 @@ export class GithubApiService extends GithubRepository { Authorization: `bearer ${TOKENS[attempt]}`, }, }); - }) as QueryDefaultResponse<{ user: T; errors?: unknown[] }>; + }) as QueryDefaultResponse<{ user: T }>; - if (response.data.data.errors) { - throw new Error("Error from Github API", { - cause: response.data.data.errors, - }); + if (response?.data?.errors) { + return this.handleError(response?.data?.errors); } - return response?.data?.data?.user ?? null; + return response?.data?.data?.user ?? + new ServiceError("not found", EServiceKindError.NOT_FOUND); } catch (error) { // TODO: Move this to a logger instance later if (error instanceof Error && error.cause) { - console.error(JSON.stringify(error.cause, null, 2)); + Logger.error(JSON.stringify(error.cause, null, 2)); } else { - console.error(error); + Logger.error(error); } - return null; + return new ServiceError("not found", EServiceKindError.NOT_FOUND); } } } diff --git a/src/StaticRenderRegeneration/cache_manager.ts b/src/StaticRenderRegeneration/cache_manager.ts index 2f11479c..f0a3a87c 100644 --- a/src/StaticRenderRegeneration/cache_manager.ts +++ b/src/StaticRenderRegeneration/cache_manager.ts @@ -1,3 +1,4 @@ +import { Logger } from "../Helpers/Logger.ts"; import { existsSync } from "./utils.ts"; export class CacheManager { @@ -44,7 +45,7 @@ export class CacheManager { const data = new TextEncoder().encode(text); Deno.writeFile(this.cacheFilePath, data, { create: true }).catch(() => { - console.warn("Failed to save cache file"); + Logger.warn("Failed to save cache file"); }); } } diff --git a/src/StaticRenderRegeneration/index.ts b/src/StaticRenderRegeneration/index.ts index d93fc61f..bc846213 100644 --- a/src/StaticRenderRegeneration/index.ts +++ b/src/StaticRenderRegeneration/index.ts @@ -1,29 +1,36 @@ import { CacheManager } from "./cache_manager.ts"; import { StaticRegenerationOptions } from "./types.ts"; -import { getUrl, readCache, generateUUID } from "./utils.ts"; +import { getUrl, hashString, readCache } from "./utils.ts"; -export async function staticRenderRegeneration(request: Request, options: StaticRegenerationOptions, render: (request: Request) => Promise) { - // avoid TypeError: Invalid URL at deno:core - const url = getUrl(request) +export async function staticRenderRegeneration( + request: Request, + options: StaticRegenerationOptions, + render: (request: Request) => Promise, +) { + // avoid TypeError: Invalid URL at deno:core + const url = getUrl(request); - // if more conditions are added, make sure to create a variable to skipCache - if (url.pathname === "/favicon.ico") { - return await render(request); - } + // if more conditions are added, make sure to create a variable to skipCache + if (url.pathname === "/favicon.ico") { + return await render(request); + } - const cacheFile = await generateUUID(url.pathname + (url.search ?? "")); - const cacheManager = new CacheManager(options.revalidate ?? 0, cacheFile); - if (cacheManager.isCacheValid) { - const cache = readCache(cacheManager.cacheFilePath) - if(cache !== null) { - return new Response(cache, { - headers: options.headers ?? new Headers({}), - }); - } + const cacheFile = await hashString(url.pathname + (url.search ?? "")); + const cacheManager = new CacheManager(options.revalidate ?? 0, cacheFile); + if (cacheManager.isCacheValid) { + const cache = readCache(cacheManager.cacheFilePath); + if (cache !== null) { + return new Response(cache, { + headers: options.headers ?? new Headers({}), + }); } - - const response = await render(request) - cacheManager.save(response) + } + + const response = await render(request); + + if (response.status >= 200 && response.status < 300) { + cacheManager.save(response); + } - return response + return response; } diff --git a/src/StaticRenderRegeneration/types.ts b/src/StaticRenderRegeneration/types.ts index e9b79076..c98b66c7 100644 --- a/src/StaticRenderRegeneration/types.ts +++ b/src/StaticRenderRegeneration/types.ts @@ -1,6 +1,6 @@ export interface StaticRegenerationOptions { - // The number of milliseconds before the page should be revalidated - revalidate?: number - // The headers to be sent with the response - headers?: Headers -} \ No newline at end of file + // The number of milliseconds before the page should be revalidated + revalidate?: number; + // The headers to be sent with the response + headers?: Headers; +} diff --git a/src/StaticRenderRegeneration/utils.ts b/src/StaticRenderRegeneration/utils.ts index ebc19c4b..0c04cc5c 100644 --- a/src/StaticRenderRegeneration/utils.ts +++ b/src/StaticRenderRegeneration/utils.ts @@ -1,42 +1,42 @@ export function getUrl(request: Request) { - try { - return new URL(request.url) - } catch { - return { - pathname: request.url, - search: request.url - } - } + try { + return new URL(request.url); + } catch { + return { + pathname: request.url, + search: request.url, + }; + } } export function readCache(cacheFilePath: string): Uint8Array | null { - try { - return Deno.readFileSync(cacheFilePath) - } catch { - return null - } + try { + return Deno.readFileSync(cacheFilePath); + } catch { + return null; + } } // https://developer.mozilla.org/en-US/docs/Web/API/SubtleCrypto/digest -export async function generateUUID(message: string): Promise { - const encoder = new TextEncoder(); - const data = encoder.encode(message); - const hashBuffer = await crypto.subtle.digest("SHA-256", data); - - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +export async function hashString(message: string): Promise { + const encoder = new TextEncoder(); + const data = encoder.encode(message); + const hashBuffer = await crypto.subtle.digest("SHA-256", data); - return hashHex; -} + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray.map((b) => b.toString(16).padStart(2, "0")).join( + "", + ); + return hashHex; +} export const existsSync = (filename: string): boolean => { - try { - Deno.statSync(filename); - // successful, file or directory must exist - return true; - } catch { - return false; - } + try { + Deno.statSync(filename); + // successful, file or directory must exist + return true; + } catch { + return false; + } }; - \ No newline at end of file diff --git a/src/Types/EServiceKindError.ts b/src/Types/EServiceKindError.ts new file mode 100644 index 00000000..172ed05f --- /dev/null +++ b/src/Types/EServiceKindError.ts @@ -0,0 +1,4 @@ +export const enum EServiceKindError { + RATE_LIMIT = "RATE_LIMITED", + NOT_FOUND = "NOT_FOUND", +} diff --git a/src/Types/Request.ts b/src/Types/Request.ts index 5ef517e7..85f1362c 100644 --- a/src/Types/Request.ts +++ b/src/Types/Request.ts @@ -1,5 +1,11 @@ +export type GithubError = { + message: string; + type: string; +}; + export type QueryDefaultResponse = { data: { data: T; + errors: GithubError[]; }; }; diff --git a/src/Types/ServiceError.ts b/src/Types/ServiceError.ts new file mode 100644 index 00000000..8a0db072 --- /dev/null +++ b/src/Types/ServiceError.ts @@ -0,0 +1,20 @@ +import { EServiceKindError } from "./EServiceKindError.ts"; + +export class ServiceError extends Error { + constructor(message: string, kind: EServiceKindError) { + super(message); + this.name = "ServiceError"; + this.cause = kind; + } + + get code(): number { + switch (this.cause) { + case EServiceKindError.RATE_LIMIT: + return 419; + case EServiceKindError.NOT_FOUND: + return 404; + default: + return 400; + } + } +} diff --git a/src/Types/index.ts b/src/Types/index.ts index 07f4ba9c..0e52b91c 100644 --- a/src/Types/index.ts +++ b/src/Types/index.ts @@ -1 +1,3 @@ -export * from './Request.ts' \ No newline at end of file +export * from "./Request.ts"; +export * from "./ServiceError.ts"; +export * from "./EServiceKindError.ts"; diff --git a/src/error_page.ts b/src/error_page.ts index 0f3076a4..160bba49 100644 --- a/src/error_page.ts +++ b/src/error_page.ts @@ -14,6 +14,11 @@ export class Error400 extends BaseError { readonly message = "Bad Request"; } +export class Error419 extends BaseError { + readonly status = 419; + readonly message = "Rate Limit Exceeded"; +} + export class Error404 extends BaseError { readonly status = 404; readonly message = "Not Found"; diff --git a/src/icons.ts b/src/icons.ts index 8e7dbf7f..7cabc097 100644 --- a/src/icons.ts +++ b/src/icons.ts @@ -2,7 +2,7 @@ import { RANK } from "./utils.ts"; import { Theme } from "./theme.ts"; const leafIcon = (laurel: string): string => { - return ` + return ` Created by potrace 1.15, written by Peter Selinger 2001-2017 diff --git a/src/pages/Error.ts b/src/pages/Error.ts new file mode 100644 index 00000000..3e93d3dd --- /dev/null +++ b/src/pages/Error.ts @@ -0,0 +1,23 @@ +import { EServiceKindError, ServiceError } from "../Types/index.ts"; +import { Error400, Error404, Error419 } from "../error_page.ts"; + +interface ErrorPageProps { + error: ServiceError; + username: string; +} + +export function ErrorPage({ error, username }: ErrorPageProps) { + let cause: Error400 | Error404 | Error419 = new Error400(); + + if (error.cause === EServiceKindError.RATE_LIMIT) { + cause = new Error419(); + } + + if (error.cause === EServiceKindError.NOT_FOUND) { + cause = new Error404( + "Can not find a user with username: " + username, + ); + } + + return cause; +} diff --git a/src/theme.ts b/src/theme.ts index 04c4ac07..977938dd 100644 --- a/src/theme.ts +++ b/src/theme.ts @@ -1,4 +1,4 @@ -export const COLORS: {[name: string]: Theme} = { +export const COLORS: { [name: string]: Theme } = { default: { BACKGROUND: "#FFF", TITLE: "#000", @@ -550,7 +550,7 @@ export const COLORS: {[name: string]: Theme} = { DEFAULT_RANK_BASE: "#7f6ceb", DEFAULT_RANK_SHADOW: "#a598ed", DEFAULT_RANK_TEXT: "#7f6ceb", - } + }, }; export interface Theme { @@ -577,4 +577,3 @@ export interface Theme { DEFAULT_RANK_SHADOW: string; DEFAULT_RANK_TEXT: string; } - diff --git a/src/trophy.ts b/src/trophy.ts index 66d9f94f..778f30d1 100644 --- a/src/trophy.ts +++ b/src/trophy.ts @@ -1,5 +1,5 @@ -import { getTrophyIcon, getNextRankBar } from "./icons.ts"; -import { CONSTANTS, RANK, abridgeScore, RANK_ORDER } from "./utils.ts"; +import { getNextRankBar, getTrophyIcon } from "./icons.ts"; +import { abridgeScore, CONSTANTS, RANK, RANK_ORDER } from "./utils.ts"; import { Theme } from "./theme.ts"; class RankCondition { @@ -10,7 +10,6 @@ class RankCondition { ) {} } - export class Trophy { rankCondition: RankCondition | null = null; rank: RANK = RANK.UNKNOWN; @@ -59,14 +58,16 @@ export class Trophy { const result = progress / distance; return result; } - render(theme: Theme, + render( + theme: Theme, x = 0, y = 0, panelSize = CONSTANTS.DEFAULT_PANEL_SIZE, noBackground = CONSTANTS.DEFAULT_NO_BACKGROUND, noFrame = CONSTANTS.DEFAULT_NO_FRAME, ): string { - const { BACKGROUND: PRIMARY, TITLE: SECONDARY, TEXT, NEXT_RANK_BAR } = theme; + const { BACKGROUND: PRIMARY, TITLE: SECONDARY, TEXT, NEXT_RANK_BAR } = + theme; const nextRankBar = getNextRankBar( this.title, this.calculateNextRankPercentage(), @@ -90,8 +91,8 @@ export class Trophy { height="${panelSize - 1}" stroke="#e1e4e8" fill="${PRIMARY}" - stroke-opacity="${noFrame ? '0' : '1'}" - fill-opacity="${noBackground ? '0' : '1'}" + stroke-opacity="${noFrame ? "0" : "1"}" + fill-opacity="${noBackground ? "0" : "1"}" /> ${getTrophyIcon(theme, this.rank)} ${this.title} @@ -103,8 +104,8 @@ export class Trophy { } } -export class MultipleLangTrophy extends Trophy{ - constructor(score: number){ +export class MultipleLangTrophy extends Trophy { + constructor(score: number) { const rankConditions = [ new RankCondition( RANK.SECRET, @@ -119,8 +120,8 @@ export class MultipleLangTrophy extends Trophy{ } } -export class AllSuperRankTrophy extends Trophy{ - constructor(score: number){ +export class AllSuperRankTrophy extends Trophy { + constructor(score: number) { const rankConditions = [ new RankCondition( RANK.SECRET, @@ -131,12 +132,12 @@ export class AllSuperRankTrophy extends Trophy{ super(score, rankConditions); this.title = "AllSuperRank"; this.filterTitles = ["AllSuperRank"]; - this.bottomMessage = "All S Rank" + this.bottomMessage = "All S Rank"; this.hidden = true; } } -export class Joined2020Trophy extends Trophy{ - constructor(score: number){ +export class Joined2020Trophy extends Trophy { + constructor(score: number) { const rankConditions = [ new RankCondition( RANK.SECRET, @@ -147,12 +148,12 @@ export class Joined2020Trophy extends Trophy{ super(score, rankConditions); this.title = "Joined2020"; this.filterTitles = ["Joined2020"]; - this.bottomMessage = "Joined 2020" + this.bottomMessage = "Joined 2020"; this.hidden = true; } } -export class AncientAccountTrophy extends Trophy{ - constructor(score: number){ +export class AncientAccountTrophy extends Trophy { + constructor(score: number) { const rankConditions = [ new RankCondition( RANK.SECRET, @@ -163,12 +164,12 @@ export class AncientAccountTrophy extends Trophy{ super(score, rankConditions); this.title = "AncientUser"; this.filterTitles = ["AncientUser"]; - this.bottomMessage = "Before 2010" + this.bottomMessage = "Before 2010"; this.hidden = true; } } -export class LongTimeAccountTrophy extends Trophy{ - constructor(score: number){ +export class LongTimeAccountTrophy extends Trophy { + constructor(score: number) { const rankConditions = [ new RankCondition( RANK.SECRET, @@ -182,8 +183,8 @@ export class LongTimeAccountTrophy extends Trophy{ this.hidden = true; } } -export class MultipleOrganizationsTrophy extends Trophy{ - constructor(score: number){ +export class MultipleOrganizationsTrophy extends Trophy { + constructor(score: number) { const rankConditions = [ new RankCondition( RANK.SECRET, @@ -199,8 +200,8 @@ export class MultipleOrganizationsTrophy extends Trophy{ } } -export class OGAccountTrophy extends Trophy{ - constructor(score: number){ +export class OGAccountTrophy extends Trophy { + constructor(score: number) { const rankConditions = [ new RankCondition( RANK.SECRET, @@ -211,7 +212,7 @@ export class OGAccountTrophy extends Trophy{ super(score, rankConditions); this.title = "OGUser"; this.filterTitles = ["OGUser"]; - this.bottomMessage = "Joined 2008" + this.bottomMessage = "Joined 2008"; this.hidden = true; } } diff --git a/src/trophy_list.ts b/src/trophy_list.ts index 7b5b9c51..d8e406f3 100644 --- a/src/trophy_list.ts +++ b/src/trophy_list.ts @@ -1,22 +1,22 @@ import { - Trophy, - TotalStarTrophy, + AllSuperRankTrophy, + AncientAccountTrophy, + Joined2020Trophy, + LongTimeAccountTrophy, + MultipleLangTrophy, + MultipleOrganizationsTrophy, + OGAccountTrophy, TotalCommitTrophy, TotalFollowerTrophy, TotalIssueTrophy, TotalPullRequestTrophy, TotalRepositoryTrophy, TotalReviewsTrophy, - MultipleLangTrophy, - LongTimeAccountTrophy, - AncientAccountTrophy, - OGAccountTrophy, - Joined2020Trophy, - AllSuperRankTrophy, - MultipleOrganizationsTrophy, + TotalStarTrophy, + Trophy, } from "./trophy.ts"; import { UserInfo } from "./user_info.ts"; -import { RANK_ORDER, RANK } from "./utils.ts"; +import { RANK, RANK_ORDER } from "./utils.ts"; export class TrophyList { private trophies = new Array(); @@ -49,7 +49,9 @@ export class TrophyList { return this.trophies; } private get isAllSRank() { - return this.trophies.every((trophy) => trophy.rank.slice(0, 1) == RANK.S) ? 1 : 0; + return this.trophies.every((trophy) => trophy.rank.slice(0, 1) == RANK.S) + ? 1 + : 0; } filterByHideen() { this.trophies = this.trophies.filter((trophy) => @@ -64,9 +66,9 @@ export class TrophyList { filterByRanks(ranks: Array) { if (ranks.filter((rank) => rank.includes("-")).length !== 0) { this.trophies = this.trophies.filter((trophy) => - !ranks.map(rank => rank.substring(1)).includes(trophy.rank) - ) - return + !ranks.map((rank) => rank.substring(1)).includes(trophy.rank) + ); + return; } this.trophies = this.trophies.filter((trophy) => ranks.includes(trophy.rank) @@ -78,4 +80,3 @@ export class TrophyList { ); } } - diff --git a/src/user_info.ts b/src/user_info.ts index c2e47c3b..7bfdac88 100644 --- a/src/user_info.ts +++ b/src/user_info.ts @@ -88,15 +88,18 @@ export class UserInfo { const joined2020 = new Date(userActivity.createdAt).getFullYear() == 2020 ? 1 : 0; - const ogAccount = - new Date(userActivity.createdAt).getFullYear() <= 2008 ? 1 : 0; + const ogAccount = new Date(userActivity.createdAt).getFullYear() <= 2008 + ? 1 + : 0; this.totalCommits = totalCommits; this.totalFollowers = userActivity.followers.totalCount; - this.totalIssues = userIssue.openIssues.totalCount + userIssue.closedIssues.totalCount; + this.totalIssues = userIssue.openIssues.totalCount + + userIssue.closedIssues.totalCount; this.totalOrganizations = userActivity.organizations.totalCount; this.totalPullRequests = userPullRequest.pullRequests.totalCount; - this.totalReviews = userActivity.contributionsCollection.totalPullRequestReviewContributions; + this.totalReviews = + userActivity.contributionsCollection.totalPullRequestReviewContributions; this.totalStargazers = totalStargazers; this.totalRepositories = userRepository.repositories.totalCount; this.languageCount = languages.size; diff --git a/vercel.json b/vercel.json index 5288cb87..64706355 100644 --- a/vercel.json +++ b/vercel.json @@ -1,14 +1,14 @@ { - "public": true, - "functions": { - "api/**/*.[jt]s": { - "runtime": "vercel-deno@3.0.4" - } - }, - "rewrites": [ - { - "source": "/(.*)", - "destination": "/api/$1" - } - ] -} \ No newline at end of file + "public": true, + "functions": { + "api/**/*.[jt]s": { + "runtime": "vercel-deno@3.0.4" + } + }, + "rewrites": [ + { + "source": "/(.*)", + "destination": "/api/$1" + } + ] +} From c8bbf60527d225bfec4ac17ec7d2567e83bdc59f Mon Sep 17 00:00:00 2001 From: Alex Oliveira Date: Sat, 30 Sep 2023 09:14:43 +0100 Subject: [PATCH 15/33] fix: task run --- deno.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/deno.json b/deno.json index 327d1ec1..2212a01b 100644 --- a/deno.json +++ b/deno.json @@ -1,7 +1,7 @@ { "tasks": { "start": "deno run -A debug.ts", - "debug": "deno --inspect-brk -A debug.ts", + "debug": "deno run --inspect-brk -A debug.ts", "format": "deno fmt", "lint": "deno lint", "test": "ENV_TYPE=test deno test --allow-env" From af753bf270ca148685814803263f0093a7256c23 Mon Sep 17 00:00:00 2001 From: Alexandro Castro <10711649+AlexcastroDev@users.noreply.github.com> Date: Sat, 30 Sep 2023 10:15:58 +0100 Subject: [PATCH 16/33] fix: check of rate limit (#229) * fix: task run * fix: check of rate limit --- .gitignore | 1 + src/Services/GithubApiService.ts | 12 +++++++++--- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.gitignore b/.gitignore index 2aa5d60a..91b922d4 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ .env .idea .lock +*.sh diff --git a/src/Services/GithubApiService.ts b/src/Services/GithubApiService.ts index 685bac58..96627c1a 100644 --- a/src/Services/GithubApiService.ts +++ b/src/Services/GithubApiService.ts @@ -91,9 +91,11 @@ export class GithubApiService extends GithubRepository { private handleError(responseErrors: GithubError[]): ServiceError { const errors = responseErrors ?? []; - const isRateLimitExceeded = (errors ?? []).some((error) => { - error.type.includes(EServiceKindError.RATE_LIMIT); - }); + + const isRateLimitExceeded = errors.some((error) => + error.type.includes(EServiceKindError.RATE_LIMIT) || + error.message.includes("rate limit") + ); if (isRateLimitExceeded) { throw new ServiceError( @@ -133,6 +135,10 @@ export class GithubApiService extends GithubRepository { return response?.data?.data?.user ?? new ServiceError("not found", EServiceKindError.NOT_FOUND); } catch (error) { + if (error instanceof ServiceError) { + Logger.error(error); + return error; + } // TODO: Move this to a logger instance later if (error instanceof Error && error.cause) { Logger.error(JSON.stringify(error.cause, null, 2)); From b4aa73dea1f9b6a452d91498a3de4a5c5d03cff0 Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Sun, 1 Oct 2023 11:49:55 +0900 Subject: [PATCH 17/33] fix: Temporary fix for retry bug --- src/Services/GithubApiService.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/Services/GithubApiService.ts b/src/Services/GithubApiService.ts index 96627c1a..8cdfd6c6 100644 --- a/src/Services/GithubApiService.ts +++ b/src/Services/GithubApiService.ts @@ -120,18 +120,18 @@ export class GithubApiService extends GithubRepository { CONSTANTS.DEFAULT_GITHUB_RETRY_DELAY, ); const response = await retry.fetch>(async ({ attempt }) => { - return await soxa.post("", {}, { + const res = await soxa.post("", {}, { data: { query: query, variables }, headers: { Authorization: `bearer ${TOKENS[attempt]}`, }, }); + if (res?.data?.errors) { + return this.handleError(res?.data?.errors); + } + return res; }) as QueryDefaultResponse<{ user: T }>; - if (response?.data?.errors) { - return this.handleError(response?.data?.errors); - } - return response?.data?.data?.user ?? new ServiceError("not found", EServiceKindError.NOT_FOUND); } catch (error) { @@ -146,7 +146,7 @@ export class GithubApiService extends GithubRepository { Logger.error(error); } - return new ServiceError("not found", EServiceKindError.NOT_FOUND); + return new ServiceError("Rate limit exceeded", EServiceKindError.RATE_LIMIT); } } } From 5246b6791264bc7ac686b01af84b1ab16d6df96e Mon Sep 17 00:00:00 2001 From: Alex Oliveira Date: Tue, 3 Oct 2023 00:04:16 +0100 Subject: [PATCH 18/33] fix: format --- src/Services/GithubApiService.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/Services/GithubApiService.ts b/src/Services/GithubApiService.ts index 8cdfd6c6..18c046e0 100644 --- a/src/Services/GithubApiService.ts +++ b/src/Services/GithubApiService.ts @@ -146,7 +146,10 @@ export class GithubApiService extends GithubRepository { Logger.error(error); } - return new ServiceError("Rate limit exceeded", EServiceKindError.RATE_LIMIT); + return new ServiceError( + "Rate limit exceeded", + EServiceKindError.RATE_LIMIT, + ); } } } From 22a9bc4112e77c266eb9b511abdaec15c28cc033 Mon Sep 17 00:00:00 2001 From: Alexandro Castro <10711649+AlexcastroDev@users.noreply.github.com> Date: Tue, 3 Oct 2023 15:50:52 +0100 Subject: [PATCH 19/33] chore: retries improvements (#230) * chore: retries improvements * chore: improvements on retries * chore: improvements on retries * chore: add unit test * chore: format * chore: format * chore: format * fix: remove console --- .gitignore | 3 +- deps.ts | 12 +- src/.DS_Store | Bin 6148 -> 0 bytes src/Helpers/Retry.ts | 20 +- src/Services/GithubApiService.ts | 60 +- src/Services/__mocks__/notFoundUserMock.json | 20 + src/Services/__mocks__/rateLimitMock.json | 18 + .../__mocks__/successGithubResponse.json | 1541 +++++++++++++++++ .../__tests__/githubApiService.test.ts | 145 ++ src/Services/request.ts | 62 + src/Types/Request.ts | 12 +- src/Types/ServiceError.ts | 1 + 12 files changed, 1843 insertions(+), 51 deletions(-) delete mode 100644 src/.DS_Store create mode 100644 src/Services/__mocks__/notFoundUserMock.json create mode 100644 src/Services/__mocks__/rateLimitMock.json create mode 100644 src/Services/__mocks__/successGithubResponse.json create mode 100644 src/Services/__tests__/githubApiService.test.ts create mode 100644 src/Services/request.ts diff --git a/.gitignore b/.gitignore index 91b922d4..d59edcf0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ .vscode .env .idea -.lock +deno.lock *.sh +**/.DS_Store diff --git a/deps.ts b/deps.ts index 4b51bfce..691ef991 100644 --- a/deps.ts +++ b/deps.ts @@ -6,7 +6,9 @@ import { } from "https://deno.land/std@0.203.0/assert/mod.ts"; import { assertSpyCalls, + returnsNext, spy, + stub, } from "https://deno.land/std@0.203.0/testing/mock.ts"; import { CONSTANTS } from "./src/utils.ts"; @@ -18,4 +20,12 @@ const soxa = new ServiceProvider({ baseURL, }); -export { assertEquals, assertRejects, assertSpyCalls, soxa, spy }; +export { + assertEquals, + assertRejects, + assertSpyCalls, + returnsNext, + soxa, + spy, + stub, +}; diff --git a/src/.DS_Store b/src/.DS_Store deleted file mode 100644 index dbcdcabe06f6c0b9f79fd341be39ab7c161065a8..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeH~%}T>i5QWcZ7X>$6c6ndHHwdM^fO!GMmLfYKm=w9c<-ex?^3mjfCz}d4*~x^G`ee7 zI;O^_gG-D6)CI#~ypCCd+B`t*O2<@2XqKhaEVUXjEXx^hmDiPysaX!I;lt|5RuhWF z(|LZ2bXb?FRRl!fn!s&tSKj|0=)cVW*G1ZifC&660=C%p+YMi-dh6unyw^7R6aCwm o8|hr46;q=XbK|XeeUaDvn)kcXF*V8=k8-Mh1e}XZ1pb1+7jJhOK>z>% diff --git a/src/Helpers/Retry.ts b/src/Helpers/Retry.ts index a7ce9a95..7718f78a 100644 --- a/src/Helpers/Retry.ts +++ b/src/Helpers/Retry.ts @@ -1,3 +1,4 @@ +import { ServiceError } from "../Types/index.ts"; import { Logger } from "./Logger.ts"; export type RetryCallbackProps = { @@ -12,11 +13,17 @@ async function* createAsyncIterable( delay: number, ) { for (let i = 0; i < retries; i++) { + const isLastAttempt = i === retries - 1; try { const data = await callback({ attempt: i }); yield data; return; } catch (e) { + if (e instanceof ServiceError && isLastAttempt) { + yield e; + return; + } + yield null; Logger.error(e); await new Promise((resolve) => setTimeout(resolve, delay)); @@ -29,6 +36,7 @@ export class Retry { async fetch( callback: callbackType, ) { + let lastError = null; for await ( const callbackResult of createAsyncIterable( callback, @@ -36,11 +44,19 @@ export class Retry { this.retryDelay, ) ) { - if (callbackResult) { + const isError = callbackResult instanceof Error; + + if (callbackResult && !isError) { return callbackResult as T; } + + if (isError) { + lastError = callbackResult; + } } - throw new Error(`Max retries (${this.maxRetries}) exceeded.`); + throw new Error(`Max retries (${this.maxRetries}) exceeded.`, { + cause: lastError, + }); } } diff --git a/src/Services/GithubApiService.ts b/src/Services/GithubApiService.ts index 18c046e0..6960de68 100644 --- a/src/Services/GithubApiService.ts +++ b/src/Services/GithubApiService.ts @@ -12,13 +12,12 @@ import { queryUserPullRequest, queryUserRepository, } from "../Schemas/index.ts"; -import { soxa } from "../../deps.ts"; import { Retry } from "../Helpers/Retry.ts"; -import { GithubError, QueryDefaultResponse } from "../Types/index.ts"; import { CONSTANTS } from "../utils.ts"; import { EServiceKindError } from "../Types/EServiceKindError.ts"; import { ServiceError } from "../Types/ServiceError.ts"; import { Logger } from "../Helpers/Logger.ts"; +import { requestGithubData } from "./request.ts"; // Need to be here - Exporting from another file makes array of null export const TOKENS = [ @@ -59,6 +58,7 @@ export class GithubApiService extends GithubRepository { async requestUserInfo(username: string): Promise { // Avoid to call others if one of them is null const repository = await this.requestUserRepository(username); + if (repository instanceof ServiceError) { Logger.error(repository); return repository; @@ -78,7 +78,7 @@ export class GithubApiService extends GithubRepository { if (status.includes("rejected")) { Logger.error(`Can not find a user with username:' ${username}'`); - return new ServiceError("not found", EServiceKindError.NOT_FOUND); + return new ServiceError("Not found", EServiceKindError.NOT_FOUND); } return new UserInfo( @@ -89,27 +89,6 @@ export class GithubApiService extends GithubRepository { ); } - private handleError(responseErrors: GithubError[]): ServiceError { - const errors = responseErrors ?? []; - - const isRateLimitExceeded = errors.some((error) => - error.type.includes(EServiceKindError.RATE_LIMIT) || - error.message.includes("rate limit") - ); - - if (isRateLimitExceeded) { - throw new ServiceError( - "Rate limit exceeded", - EServiceKindError.RATE_LIMIT, - ); - } - - throw new ServiceError( - "unknown error", - EServiceKindError.NOT_FOUND, - ); - } - async executeQuery( query: string, variables: { [key: string]: string }, @@ -120,36 +99,25 @@ export class GithubApiService extends GithubRepository { CONSTANTS.DEFAULT_GITHUB_RETRY_DELAY, ); const response = await retry.fetch>(async ({ attempt }) => { - const res = await soxa.post("", {}, { - data: { query: query, variables }, - headers: { - Authorization: `bearer ${TOKENS[attempt]}`, - }, - }); - if (res?.data?.errors) { - return this.handleError(res?.data?.errors); - } - return res; - }) as QueryDefaultResponse<{ user: T }>; + return await requestGithubData( + query, + variables, + TOKENS[attempt], + ); + }); - return response?.data?.data?.user ?? - new ServiceError("not found", EServiceKindError.NOT_FOUND); + return response; } catch (error) { - if (error instanceof ServiceError) { - Logger.error(error); - return error; + if (error.cause instanceof ServiceError) { + Logger.error(error.cause.message); + return error.cause; } - // TODO: Move this to a logger instance later if (error instanceof Error && error.cause) { Logger.error(JSON.stringify(error.cause, null, 2)); } else { Logger.error(error); } - - return new ServiceError( - "Rate limit exceeded", - EServiceKindError.RATE_LIMIT, - ); + return new ServiceError("not found", EServiceKindError.NOT_FOUND); } } } diff --git a/src/Services/__mocks__/notFoundUserMock.json b/src/Services/__mocks__/notFoundUserMock.json new file mode 100644 index 00000000..75a76f47 --- /dev/null +++ b/src/Services/__mocks__/notFoundUserMock.json @@ -0,0 +1,20 @@ +{ + "data": { + "user": null + }, + "errors": [ + { + "type": "NOT_FOUND", + "path": [ + "user" + ], + "locations": [ + { + "line": 2, + "column": 5 + } + ], + "message": "Could not resolve to a User with the login of 'alekinho'." + } + ] +} diff --git a/src/Services/__mocks__/rateLimitMock.json b/src/Services/__mocks__/rateLimitMock.json new file mode 100644 index 00000000..c49ffb2a --- /dev/null +++ b/src/Services/__mocks__/rateLimitMock.json @@ -0,0 +1,18 @@ +{ + "exceeded": { + "data": { + "documentation_url": "https://docs.github.com/en/free-pro-team@latest/rest/overview/resources-in-the-rest-api#secondary-rate-limits", + "message": "You have exceeded a secondary rate limit. Please wait a few minutes before you try again. If you reach out to GitHub Support for help, please include the request ID DBD8:FB98:31801A8:3222432:65195FDB." + } + }, + "rate_limit": { + "data": { + "errors": [ + { + "type": "RATE_LIMITED", + "message": "API rate limit exceeded for user ID 10711649." + } + ] + } + } +} diff --git a/src/Services/__mocks__/successGithubResponse.json b/src/Services/__mocks__/successGithubResponse.json new file mode 100644 index 00000000..d8198b52 --- /dev/null +++ b/src/Services/__mocks__/successGithubResponse.json @@ -0,0 +1,1541 @@ +{ + "data": { + "data": { + "user": { + "repositories": { + "totalCount": 128, + "nodes": [ + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 23 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 11 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + } + ] + }, + "stargazers": { + "totalCount": 9 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 6 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 6 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Java" + } + ] + }, + "stargazers": { + "totalCount": 5 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + } + ] + }, + "stargazers": { + "totalCount": 5 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Jupyter Notebook" + } + ] + }, + "stargazers": { + "totalCount": 5 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + } + ] + }, + "stargazers": { + "totalCount": 4 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 3 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + } + ] + }, + "stargazers": { + "totalCount": 2 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 2 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 2 + } + }, + { + "languages": { + "nodes": [] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "PHP" + }, + { + "name": "Go" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "Dockerfile" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "Dockerfile" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Dart" + }, + { + "name": "Swift" + }, + { + "name": "Kotlin" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "HTML" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "Vue" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Jupyter Notebook" + }, + { + "name": "Python" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Dart" + }, + { + "name": "HTML" + }, + { + "name": "Swift" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "Vue" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "PHP" + } + ] + }, + "stargazers": { + "totalCount": 1 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "CSS" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "HTML" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "CSS" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "Shell" + }, + { + "name": "Dockerfile" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "CSS" + }, + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "Dockerfile" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "TypeScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "CSS" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "HTML" + }, + { + "name": "C#" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "PHP" + }, + { + "name": "Vue" + }, + { + "name": "Blade" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "CSS" + }, + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "C" + }, + { + "name": "C++" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "C++" + }, + { + "name": "Makefile" + }, + { + "name": "CMake" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "C++" + }, + { + "name": "Makefile" + }, + { + "name": "CMake" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "TypeScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "CSS" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "TypeScript" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Vue" + }, + { + "name": "TypeScript" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "CSS" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "PHP" + }, + { + "name": "JavaScript" + }, + { + "name": "Blade" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "Rust" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "Svelte" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "CSS" + }, + { + "name": "HTML" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "Dockerfile" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Rust" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "Dockerfile" + }, + { + "name": "Shell" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "Shell" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "HTML" + }, + { + "name": "JavaScript" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "CSS" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Dart" + }, + { + "name": "HTML" + }, + { + "name": "Swift" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Dart" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Rust" + }, + { + "name": "JavaScript" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "CSS" + }, + { + "name": "Swift" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "SCSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "Shell" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "SCSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "CSS" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "HTML" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "SCSS" + }, + { + "name": "JavaScript" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "JavaScript" + }, + { + "name": "SCSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "HTML" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "HTML" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "JavaScript" + }, + { + "name": "HTML" + }, + { + "name": "SCSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "Swift" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "HTML" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + }, + { + "languages": { + "nodes": [ + { + "name": "TypeScript" + }, + { + "name": "HTML" + }, + { + "name": "CSS" + } + ] + }, + "stargazers": { + "totalCount": 0 + } + } + ] + } + } + } + } +} diff --git a/src/Services/__tests__/githubApiService.test.ts b/src/Services/__tests__/githubApiService.test.ts new file mode 100644 index 00000000..99c5935b --- /dev/null +++ b/src/Services/__tests__/githubApiService.test.ts @@ -0,0 +1,145 @@ +import { GithubApiService } from "../GithubApiService.ts"; +import { assertEquals, returnsNext, soxa, stub } from "../../../deps.ts"; +import { GitHubUserRepository } from "../../user_info.ts"; + +const rateLimitMock = await import("../__mocks__/rateLimitMock.json", { + assert: { type: "json" }, +}); + +const successGithubResponseMock = await import( + "../__mocks__/successGithubResponse.json", + { assert: { type: "json" } } +); + +const notFoundGithubResponseMock = await import( + "../__mocks__/notFoundUserMock.json", + { assert: { type: "json" } } +); + +import { ServiceError } from "../../Types/index.ts"; + +// Unfortunatelly, The spy is a global instance +// We can't reset mock as Jest does. +stub( + soxa, + "post", + returnsNext([ + // Should get data in first try + new Promise((resolve) => { + resolve(successGithubResponseMock.default); + }), + // // Should get data in second Retry + new Promise((resolve) => { + resolve(rateLimitMock.default.rate_limit); + }), + new Promise((resolve) => { + resolve(successGithubResponseMock.default); + }), + // Should throw NOT FOUND + new Promise((resolve) => { + resolve(notFoundGithubResponseMock.default); + }), + new Promise((resolve) => { + resolve(notFoundGithubResponseMock.default); + }), + // Should throw NOT FOUND even if request the user only + new Promise((resolve) => { + resolve(notFoundGithubResponseMock.default); + }), + new Promise((resolve) => { + resolve(notFoundGithubResponseMock.default); + }), + // Should throw RATE LIMIT + new Promise((resolve) => { + resolve(rateLimitMock.default.rate_limit); + }), + new Promise((resolve) => { + resolve(rateLimitMock.default.rate_limit); + }), + // Should throw RATE LIMIT Exceed + new Promise((resolve) => { + resolve(rateLimitMock.default.rate_limit); + }), + new Promise((resolve) => { + resolve(rateLimitMock.default.exceeded); + }), + ]), +); + +Deno.test("Should get data in first try", async () => { + const provider = new GithubApiService(); + + const data = await provider.requestUserRepository( + "test", + ) as GitHubUserRepository; + + assertEquals(data.repositories.totalCount, 128); +}); + +Deno.test("Should get data in second Retry", async () => { + const provider = new GithubApiService(); + + const data = await provider.requestUserRepository( + "test", + ) as GitHubUserRepository; + + assertEquals(data.repositories.totalCount, 128); +}); + +Deno.test("Should throw NOT FOUND", async () => { + const provider = new GithubApiService(); + let error = null; + + try { + error = await provider.requestUserInfo("test"); + } catch (e) { + error = e; + } + + assertEquals(error.code, 404); + assertEquals(error instanceof ServiceError, true); +}); +Deno.test("Should throw NOT FOUND even if request the user only", async () => { + const provider = new GithubApiService(); + let error = null; + + try { + error = await provider.requestUserRepository("test"); + } catch (e) { + error = e; + } + + assertEquals(error.code, 404); + assertEquals(error instanceof ServiceError, true); +}); + +// The assertRejects() assertion is a little more complicated +// mainly because it deals with Promises. +// https://docs.deno.com/runtime/manual/basics/testing/assertions#throws +Deno.test("Should throw RATE LIMIT", async () => { + const provider = new GithubApiService(); + let error = null; + + try { + error = await provider.requestUserRepository("test"); + } catch (e) { + error = e; + } + + assertEquals(error.code, 419); + assertEquals(error instanceof ServiceError, true); +}); + +Deno.test("Should throw RATE LIMIT Exceed", async () => { + const provider = new GithubApiService(); + let error = null; + + try { + error = await provider.requestUserRepository("test"); + } catch (e) { + error = e; + } + + assertEquals(error.code, 419); + assertEquals(error instanceof ServiceError, true); +}); diff --git a/src/Services/request.ts b/src/Services/request.ts new file mode 100644 index 00000000..8aff36f4 --- /dev/null +++ b/src/Services/request.ts @@ -0,0 +1,62 @@ +import { soxa } from "../../deps.ts"; +import { + EServiceKindError, + GithubErrorResponse, + GithubExceedError, + QueryDefaultResponse, + ServiceError, +} from "../Types/index.ts"; + +export async function requestGithubData( + query: string, + variables: { [key: string]: string }, + token = "", +) { + const response = await soxa.post("", {}, { + data: { query, variables }, + headers: { + Authorization: `bearer ${token}`, + }, + }) as QueryDefaultResponse<{ user: T }>; + const responseData = response.data; + + if (responseData?.data?.user) { + return responseData.data.user; + } + + throw handleError( + responseData as unknown as GithubErrorResponse | GithubExceedError, + ); +} + +function handleError( + reponseErrors: GithubErrorResponse | GithubExceedError, +): ServiceError { + let isRateLimitExceeded = false; + const arrayErrors = (reponseErrors as GithubErrorResponse)?.errors || []; + const objectError = (reponseErrors as GithubExceedError) || {}; + + if (Array.isArray(arrayErrors)) { + isRateLimitExceeded = arrayErrors.some((error) => + error.type.includes(EServiceKindError.RATE_LIMIT) + ); + } + + if (objectError?.message) { + isRateLimitExceeded = objectError?.message.includes( + "rate limit", + ); + } + + if (isRateLimitExceeded) { + throw new ServiceError( + "Rate limit exceeded", + EServiceKindError.RATE_LIMIT, + ); + } + + throw new ServiceError( + "unknown error", + EServiceKindError.NOT_FOUND, + ); +} diff --git a/src/Types/Request.ts b/src/Types/Request.ts index 85f1362c..e0c97ada 100644 --- a/src/Types/Request.ts +++ b/src/Types/Request.ts @@ -3,9 +3,19 @@ export type GithubError = { type: string; }; +export type GithubErrorResponse = { + errors: GithubError[]; +}; + +export type GithubExceedError = { + documentation_url: string; + message: string; +}; + export type QueryDefaultResponse = { data: { data: T; - errors: GithubError[]; + errors?: GithubErrorResponse; + message?: string; }; }; diff --git a/src/Types/ServiceError.ts b/src/Types/ServiceError.ts index 8a0db072..cd0ec7a2 100644 --- a/src/Types/ServiceError.ts +++ b/src/Types/ServiceError.ts @@ -3,6 +3,7 @@ import { EServiceKindError } from "./EServiceKindError.ts"; export class ServiceError extends Error { constructor(message: string, kind: EServiceKindError) { super(message); + this.message = message; this.name = "ServiceError"; this.cause = kind; } From 0cc5b5cbc72d3b924bcf2629280183dc0dc5dc4d Mon Sep 17 00:00:00 2001 From: Bhav Beri <43399374+bhavberi@users.noreply.github.com> Date: Tue, 3 Oct 2023 20:28:45 +0530 Subject: [PATCH 20/33] Added Account Duration/Experience Trophy (#203) * Added Account Duration/Experience Trophy * Minor Formatting Changes * deno fmt correction --------- Co-authored-by: Alexandro Castro <10711649+AlexcastroDev@users.noreply.github.com> --- src/trophy.ts | 51 ++++++++++++++++++++++++++++++++++++++++++++++ src/trophy_list.ts | 2 ++ src/user_info.ts | 5 +++++ 3 files changed, 58 insertions(+) diff --git a/src/trophy.ts b/src/trophy.ts index 778f30d1..24dc708e 100644 --- a/src/trophy.ts +++ b/src/trophy.ts @@ -267,6 +267,57 @@ export class TotalReviewsTrophy extends Trophy { } } +export class AccountDurationTrophy extends Trophy { + constructor(score: number) { + const rankConditions = [ + new RankCondition( + RANK.SSS, + "Seasoned Veteran", + 70, // 20 years + ), + new RankCondition( + RANK.SS, + "GranMaster", + 55, // 15 years + ), + new RankCondition( + RANK.S, + "Master Dev", + 40, // 10 years + ), + new RankCondition( + RANK.AAA, + "Expert Dev", + 28, // 7.5 years + ), + new RankCondition( + RANK.AA, + "Experienced Dev", + 18, // 5 years + ), + new RankCondition( + RANK.A, + "Intermediate Dev", + 11, // 3 years + ), + new RankCondition( + RANK.B, + "Junior Dev", + 6, // 1.5 years + ), + new RankCondition( + RANK.C, + "Newbie", + 2, // 0.5 year + ), + ]; + super(score, rankConditions); + this.title = "Experience"; + this.filterTitles = ["Experience", "Duration", "Since"]; + // this.hidden = true; + } +} + export class TotalStarTrophy extends Trophy { constructor(score: number) { const rankConditions = [ diff --git a/src/trophy_list.ts b/src/trophy_list.ts index d8e406f3..2fcab4c8 100644 --- a/src/trophy_list.ts +++ b/src/trophy_list.ts @@ -1,4 +1,5 @@ import { + AccountDurationTrophy, AllSuperRankTrophy, AncientAccountTrophy, Joined2020Trophy, @@ -40,6 +41,7 @@ export class TrophyList { new OGAccountTrophy(userInfo.ogAccount), new Joined2020Trophy(userInfo.joined2020), new MultipleOrganizationsTrophy(userInfo.totalOrganizations), + new AccountDurationTrophy(userInfo.durationDays), ); } get length() { diff --git a/src/user_info.ts b/src/user_info.ts index 7bfdac88..805402d6 100644 --- a/src/user_info.ts +++ b/src/user_info.ts @@ -51,6 +51,7 @@ export class UserInfo { public readonly totalRepositories: number; public readonly languageCount: number; public readonly durationYear: number; + public readonly durationDays: number; public readonly ancientAccount: number; public readonly joined2020: number; public readonly ogAccount: number; @@ -83,6 +84,9 @@ export class UserInfo { const durationTime = new Date().getTime() - new Date(userActivity.createdAt).getTime(); const durationYear = new Date(durationTime).getUTCFullYear() - 1970; + const durationDays = Math.floor( + durationTime / (1000 * 60 * 60 * 24) / 100, + ); const ancientAccount = new Date(userActivity.createdAt).getFullYear() <= 2010 ? 1 : 0; const joined2020 = new Date(userActivity.createdAt).getFullYear() == 2020 @@ -104,6 +108,7 @@ export class UserInfo { this.totalRepositories = userRepository.repositories.totalCount; this.languageCount = languages.size; this.durationYear = durationYear; + this.durationDays = durationDays; this.ancientAccount = ancientAccount; this.joined2020 = joined2020; this.ogAccount = ogAccount; From 24d831db67b14254604ce16372f8f1af519eda4b Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Wed, 4 Oct 2023 00:01:46 +0900 Subject: [PATCH 21/33] Fix deno version --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index e8cf04a8..f5576ac9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,7 +2,7 @@ ## Environment -- Deno >= v1.9.2 +- Deno >= v1.36.1 - [Vercel](https://vercel.com/) - GitHub API v4 From 05f2bb92c1a0fc556e1fc34c3037ddc9718fcf62 Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Wed, 4 Oct 2023 00:03:26 +0900 Subject: [PATCH 22/33] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 96bc8cfd..55dae963 100644 --- a/README.md +++ b/README.md @@ -42,7 +42,7 @@ readme, change the `?username=` value to your GitHub's username. ```

- +

## Use theme From dc043fbe7878d0cb71a167c39d4e0d2cf87d8b15 Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Wed, 4 Oct 2023 00:06:18 +0900 Subject: [PATCH 23/33] Change DEFAULT_MAX_COLUMN to 8 --- src/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/utils.ts b/src/utils.ts index 0ed5cd30..a3e21fa7 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -58,7 +58,7 @@ const HOUR_IN_MILLISECONDS = 60 * 60 * 1000; export const CONSTANTS = { CACHE_MAX_AGE: 7200, DEFAULT_PANEL_SIZE: 110, - DEFAULT_MAX_COLUMN: 6, + DEFAULT_MAX_COLUMN: 8, DEFAULT_MAX_ROW: 3, DEFAULT_MARGIN_W: 0, DEFAULT_MARGIN_H: 0, From 614e0453d0cc7e4d5c2455985cb470c66b827cb6 Mon Sep 17 00:00:00 2001 From: Alexandro Castro <10711649+AlexcastroDev@users.noreply.github.com> Date: Tue, 3 Oct 2023 16:38:18 +0100 Subject: [PATCH 24/33] feat: add redis cache (#231) * feat: add redis cache * chore: add TTL * chore: change port --- CONTRIBUTING.md | 9 ++++++ api/index.ts | 29 ++++++++++++------ deps.ts | 6 ++++ docker-compose.yml | 7 +++++ env-example | 8 +++++ src/config/cache.ts | 74 +++++++++++++++++++++++++++++++++++++++++++++ src/utils.ts | 1 + 7 files changed, 124 insertions(+), 10 deletions(-) create mode 100644 docker-compose.yml create mode 100644 env-example create mode 100644 src/config/cache.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f5576ac9..7306a082 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -5,6 +5,7 @@ - Deno >= v1.36.1 - [Vercel](https://vercel.com/) - GitHub API v4 +- Docker and Docker compose (optional) ## Local Run @@ -26,6 +27,14 @@ Run local server. deno task start ``` +You can enable the Redis if you want, but it's not mandatory. + +```sh +docker compose up -d +``` + +Rename `env-example` to `.env`, and change ENABLE_REDIS to true + Open localhost from your browser. http://localhost:8080/?username=ryo-ma diff --git a/api/index.ts b/api/index.ts index 68d5215a..059fdeb0 100644 --- a/api/index.ts +++ b/api/index.ts @@ -8,6 +8,7 @@ import { GithubRepositoryService } from "../src/Repository/GithubRepository.ts"; import { GithubApiService } from "../src/Services/GithubApiService.ts"; import { ServiceError } from "../src/Types/index.ts"; import { ErrorPage } from "../src/pages/Error.ts"; +import { cacheProvider } from "../src/config/cache.ts"; const serviceProvider = new GithubApiService(); const client = new GithubRepositoryService(serviceProvider).repository; @@ -75,17 +76,25 @@ async function app(req: Request): Promise { }, ); } - const userInfo = await client.requestUserInfo(username); - if (userInfo instanceof ServiceError) { - return new Response( - ErrorPage({ error: userInfo, username }).render(), - { - status: userInfo.code, - headers: new Headers({ "Content-Type": "text" }), - }, - ); - } + const userKeyCache = ["v1", username].join("-"); + const userInfoCached = await cacheProvider.get(userKeyCache) || "{}"; + let userInfo = JSON.parse(userInfoCached); + const hasCache = !!Object.keys(userInfo).length; + if (!hasCache) { + const userResponseInfo = await client.requestUserInfo(username); + if (userResponseInfo instanceof ServiceError) { + return new Response( + ErrorPage({ error: userInfo, username }).render(), + { + status: userResponseInfo.code, + headers: new Headers({ "Content-Type": "text" }), + }, + ); + } + userInfo = userResponseInfo; + await cacheProvider.set(userKeyCache, JSON.stringify(userInfo)); + } // Success Response return new Response( new Card( diff --git a/deps.ts b/deps.ts index 691ef991..646eaf28 100644 --- a/deps.ts +++ b/deps.ts @@ -11,6 +11,12 @@ import { stub, } from "https://deno.land/std@0.203.0/testing/mock.ts"; +export { + type Bulk, + connect, + type Redis, +} from "https://deno.land/x/redis@v0.31.0/mod.ts"; + import { CONSTANTS } from "./src/utils.ts"; const baseURL = Deno.env.get("GITHUB_API") || CONSTANTS.DEFAULT_GITHUB_API; diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..9036241a --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,7 @@ +version: "3" +services: + redis: + container_name: trophy-redis + image: redis:latest + ports: + - "6379:6379" diff --git a/env-example b/env-example new file mode 100644 index 00000000..6f5a02bb --- /dev/null +++ b/env-example @@ -0,0 +1,8 @@ +GITHUB_TOKEN1= +GITHUB_TOKEN2= +GITHUB_API=https://api.github.com/graphql +ENABLE_REDIS= +REDIS_PORT=6379 +REDIS_HOST= +REDIS_USERNAME= +REDIS_PASSWORD= diff --git a/src/config/cache.ts b/src/config/cache.ts new file mode 100644 index 00000000..4f6aef3f --- /dev/null +++ b/src/config/cache.ts @@ -0,0 +1,74 @@ +import { Bulk, connect, Redis } from "../../deps.ts"; +import { Logger } from "../Helpers/Logger.ts"; +import { CONSTANTS } from "../utils.ts"; + +const enableCache = Deno.env.get("ENABLE_REDIS") || false; + +// https://developer.redis.com/develop/deno/ +class CacheProvider { + private static instance: CacheProvider; + public client: Redis | null = null; + + private constructor() {} + + static getInstance(): CacheProvider { + if (!CacheProvider.instance) { + CacheProvider.instance = new CacheProvider(); + } + return CacheProvider.instance; + } + + async connect(): Promise { + if (!enableCache) return; + this.client = await connect({ + hostname: Deno.env.get("REDIS_HOST") || "", + port: Number(Deno.env.get("REDIS_PORT")) || 6379, + username: Deno.env.get("REDIS_USERNAME") || "", + password: Deno.env.get("REDIS_PASSWORD") || "", + }); + } + + async get(key: string): Promise { + if (!enableCache) return undefined; + + try { + if (!this.client) { + await this.connect(); + } + + return await this.client?.get(key); + } catch { + return undefined; + } + } + + async set(key: string, value: string): Promise { + if (!enableCache) return; + + try { + if (!this.client) { + await this.connect(); + } + await this.client?.set(key, value, { + px: CONSTANTS.REDIS_TTL, + }); + } catch (e) { + Logger.error(`Failed to set cache: ${e.message}`); + } + } + + async del(key: string): Promise { + if (!enableCache) return; + + try { + if (!this.client) { + await this.connect(); + } + await this.client?.del(key); + } catch (e) { + Logger.error(`Failed to delete cache: ${e.message}`); + } + } +} + +export const cacheProvider = CacheProvider.getInstance(); diff --git a/src/utils.ts b/src/utils.ts index a3e21fa7..a244d110 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -67,6 +67,7 @@ export const CONSTANTS = { DEFAULT_GITHUB_API: "https://api.github.com/graphql", DEFAULT_GITHUB_RETRY_DELAY: 1000, REVALIDATE_TIME: HOUR_IN_MILLISECONDS, + REDIS_TTL: HOUR_IN_MILLISECONDS * 4, }; export enum RANK { From 8c8a5a91d092f242a41cabc3d24963164c900f9c Mon Sep 17 00:00:00 2001 From: SmashedFrenzy16 Date: Wed, 4 Oct 2023 07:41:08 +0100 Subject: [PATCH 25/33] Update trophy.ts with spelling correction (#232) --- src/trophy.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trophy.ts b/src/trophy.ts index 24dc708e..14602c6c 100644 --- a/src/trophy.ts +++ b/src/trophy.ts @@ -277,7 +277,7 @@ export class AccountDurationTrophy extends Trophy { ), new RankCondition( RANK.SS, - "GranMaster", + "Grandmaster", 55, // 15 years ), new RankCondition( From 501d8159b77d221b65da518f297a4fd5dcc126d9 Mon Sep 17 00:00:00 2001 From: SmashedFrenzy16 Date: Wed, 4 Oct 2023 14:58:56 +0100 Subject: [PATCH 26/33] Update trophy_list.ts with correct function name (#233) --- src/trophy_list.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/trophy_list.ts b/src/trophy_list.ts index 2fcab4c8..1f190d9b 100644 --- a/src/trophy_list.ts +++ b/src/trophy_list.ts @@ -55,7 +55,7 @@ export class TrophyList { ? 1 : 0; } - filterByHideen() { + filterByHidden() { this.trophies = this.trophies.filter((trophy) => !trophy.hidden || trophy.rank !== RANK.UNKNOWN ); From cb822ea356818df226cc3bb3229cc7158584ec72 Mon Sep 17 00:00:00 2001 From: Alex Oliveira Date: Wed, 4 Oct 2023 17:18:12 +0100 Subject: [PATCH 27/33] hotfix: function name --- src/card.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/card.ts b/src/card.ts index 3403c97e..70e39f49 100644 --- a/src/card.ts +++ b/src/card.ts @@ -26,7 +26,7 @@ export class Card { ): string { const trophyList = new TrophyList(userInfo); - trophyList.filterByHideen(); + trophyList.filterByHidden(); if (this.titles.length != 0) { trophyList.filterByTitles(this.titles); From 8761a4ca03313b1bc657efa2e49e802b97d319c5 Mon Sep 17 00:00:00 2001 From: CodeMaster7000 Date: Wed, 4 Oct 2023 23:46:43 +0100 Subject: [PATCH 28/33] Add license info and link (#235) --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index 55dae963..941dff2b 100644 --- a/README.md +++ b/README.md @@ -504,6 +504,10 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&no-frame=true Check [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. +# License + +This product is licensed under the [MIT License](https://github.com/ryo-ma/github-profile-trophy/blob/master/LICENSE). + # Testing ```bash From c74a43c12e4009df4cd76606e8ff3ce854a40d55 Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Sat, 7 Oct 2023 08:19:01 +0900 Subject: [PATCH 29/33] Update CONTRIBUTING.md --- CONTRIBUTING.md | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7306a082..c0739ed6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,11 +43,21 @@ http://localhost:8080/?username=ryo-ma Read the [.editorconfig](./.editorconfig) -## Run deno lint +## What to do before contributing -If you want to contribute to my project, you should check the lint with the -following command. +### 1. Run deno lint ```sh -deno lint --unstable +deno task lint +``` + +### 2. Run deno fmt + +```sh +deno task fmt +``` +### 3. Run deno test + +```sh +deno task test ``` From 871dc99651e0dd1062f26e8d8689334a4bd288a9 Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Sat, 7 Oct 2023 08:20:02 +0900 Subject: [PATCH 30/33] Update README.md --- README.md | 6 ------ 1 file changed, 6 deletions(-) diff --git a/README.md b/README.md index 941dff2b..b21d0154 100644 --- a/README.md +++ b/README.md @@ -507,9 +507,3 @@ Check [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. # License This product is licensed under the [MIT License](https://github.com/ryo-ma/github-profile-trophy/blob/master/LICENSE). - -# Testing - -```bash -deno task test -``` From 866cdcc5cdb1493d381d8a5ce349be2f2e84277a Mon Sep 17 00:00:00 2001 From: CodeMaster7000 Date: Sun, 8 Oct 2023 14:16:41 +0100 Subject: [PATCH 31/33] Update CONTRIBUTING.md to add a contribution guideline (#238) * Update CONTRIBUTING.md to add a contribution guideline * Update CONTRIBUTING.md --- CONTRIBUTING.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c0739ed6..15a6ad51 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -43,6 +43,15 @@ http://localhost:8080/?username=ryo-ma Read the [.editorconfig](./.editorconfig) +## Pull Requests + +Pull requests are are always welcome! In general, they should a single concern +in the least number of changed lines as possible. For changes that address core +functionality, it is best to open an issue to discuss your proposal first. I +look forward to seeing what you come up with! + +## Run deno lint + ## What to do before contributing ### 1. Run deno lint From 52e1e38caa24bbe0b013522e616445b3d9b6e5bb Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Sun, 8 Oct 2023 22:17:38 +0900 Subject: [PATCH 32/33] Fix format --- CONTRIBUTING.md | 1 + README.md | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 15a6ad51..ab80ab54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -65,6 +65,7 @@ deno task lint ```sh deno task fmt ``` + ### 3. Run deno test ```sh diff --git a/README.md b/README.md index b21d0154..adc0c19b 100644 --- a/README.md +++ b/README.md @@ -506,4 +506,5 @@ Check [CONTRIBUTING.md](./CONTRIBUTING.md) for more details. # License -This product is licensed under the [MIT License](https://github.com/ryo-ma/github-profile-trophy/blob/master/LICENSE). +This product is licensed under the +[MIT License](https://github.com/ryo-ma/github-profile-trophy/blob/master/LICENSE). From 0a3c385fc5b1e26ac55a67845d6daf49932c89f8 Mon Sep 17 00:00:00 2001 From: ryo-ma Date: Sun, 8 Oct 2023 22:19:14 +0900 Subject: [PATCH 33/33] Update testing.yml --- .github/workflows/testing.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml index 8d42acdc..a25b6fca 100644 --- a/.github/workflows/testing.yml +++ b/.github/workflows/testing.yml @@ -1,6 +1,9 @@ name: Check PR Test on: + push: + branches: + - master pull_request: branches: - master