diff --git a/.github/workflows/testing.yml b/.github/workflows/testing.yml new file mode 100644 index 00000000..a25b6fca --- /dev/null +++ b/.github/workflows/testing.yml @@ -0,0 +1,30 @@ +name: Check PR Test + +on: + push: + branches: + - master + pull_request: + branches: + - master +jobs: + install-dependencies: + runs-on: ubuntu-latest + + strategy: + matrix: + deno-version: [1.36.1] + + steps: + - name: Git Checkout Deno Module + uses: actions/checkout@v2 + - name: Use Deno Version ${{ matrix.deno-version }} + uses: denolib/setup-deno@v2 + with: + 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..d59edcf0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ .vscode .env .idea +deno.lock +*.sh +**/.DS_Store diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f9ce3840..ab80ab54 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,25 +2,39 @@ ## Environment -* Deno >= v1.9.2 -* [Vercel](https://vercel.com/) -* GitHub API v4 +- Deno >= v1.36.1 +- [Vercel](https://vercel.com/) +- GitHub API v4 +- Docker and Docker compose (optional) ## 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. -``` -GITHUB_TOKEN=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +```properties +GITHUB_TOKEN1=XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX +GITHUB_TOKEN2=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 task start ``` -deno run --allow-net --allow-read --allow-env debug.ts + +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 @@ -29,10 +43,31 @@ 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 -If you want to contribute to my project, you should check the lint with the following command. +## What to do before contributing + +### 1. Run deno lint + +```sh +deno task lint +``` + +### 2. Run deno fmt + +```sh +deno task fmt +``` + +### 3. Run deno test +```sh +deno task test ``` -deno lint --unstable -``` \ No newline at end of file diff --git a/README.md b/README.md index 59f9d930..adc0c19b 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,26 @@

- +

GitHub Profile Trophy

🏆 Add dynamically generated GitHub Stat Trophies on your readme

- + - - + + - + - +

- +

@@ -28,20 +28,21 @@

- +

# 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) ```

- +

## Use theme @@ -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 process. 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,6 +500,11 @@ https://github-profile-trophy.vercel.app/?username=ryo-ma&no-frame=true

- # Contribution Guide + 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). diff --git a/index.ts b/api/index.ts similarity index 50% rename from index.ts rename to api/index.ts index a1ab91a8..059fdeb0 100644 --- a/index.ts +++ b/api/index.ts @@ -1,13 +1,34 @@ -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 } 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"; +import { cacheProvider } from "../src/config/cache.ts"; -const client = new GithubAPIClient(); +const serviceProvider = new GithubApiService(); +const client = new GithubRepositoryService(serviceProvider).repository; -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); @@ -55,19 +76,24 @@ export default async (req: Request) => { }, ); } - const token = Deno.env.get("GITHUB_TOKEN"); - const userInfo = await client.requestUserInfo(token, username); - if (userInfo === null) { - const error = new Error404( - "Can not find a user with username: " + username, - ); - return new Response( - error.render(), - { - status: error.status, - 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( @@ -83,12 +109,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/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/deno.json b/deno.json new file mode 100644 index 00000000..2212a01b --- /dev/null +++ b/deno.json @@ -0,0 +1,9 @@ +{ + "tasks": { + "start": "deno run -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" + } +} diff --git a/deps.ts b/deps.ts index 5d6175aa..646eaf28 100644 --- a/deps.ts +++ b/deps.ts @@ -1 +1,37 @@ -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, + returnsNext, + spy, + 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; + +const soxa = new ServiceProvider({ + ...defaults, + baseURL, +}); + +export { + assertEquals, + assertRejects, + assertSpyCalls, + returnsNext, + soxa, + spy, + stub, +}; 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/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 new file mode 100644 index 00000000..7718f78a --- /dev/null +++ b/src/Helpers/Retry.ts @@ -0,0 +1,62 @@ +import { ServiceError } from "../Types/index.ts"; +import { Logger } from "./Logger.ts"; + +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++) { + 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)); + } + } +} + +export class Retry { + constructor(private maxRetries = 2, private retryDelay = 1000) {} + async fetch( + callback: callbackType, + ) { + let lastError = null; + for await ( + const callbackResult of createAsyncIterable( + callback, + this.maxRetries, + this.retryDelay, + ) + ) { + const isError = callbackResult instanceof Error; + + if (callbackResult && !isError) { + return callbackResult as T; + } + + if (isError) { + lastError = callbackResult; + } + } + + throw new Error(`Max retries (${this.maxRetries}) exceeded.`, { + cause: lastError, + }); + } +} diff --git a/src/Helpers/__tests__/Retry.test.ts b/src/Helpers/__tests__/Retry.test.ts new file mode 100644 index 00000000..a1fec42b --- /dev/null +++ b/src/Helpers/__tests__/Retry.test.ts @@ -0,0 +1,65 @@ +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..c2a408c6 --- /dev/null +++ b/src/Repository/GithubRepository.ts @@ -0,0 +1,28 @@ +import { ServiceError } from "../Types/index.ts"; +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..6960de68 --- /dev/null +++ b/src/Services/GithubApiService.ts @@ -0,0 +1,123 @@ +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 { Retry } from "../Helpers/Retry.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 = [ + 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 instanceof ServiceError) { + Logger.error(repository); + return repository; + } + + 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")) { + 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 as GitHubUserRepository, + ); + } + + 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 requestGithubData( + query, + variables, + TOKENS[attempt], + ); + }); + + return response; + } catch (error) { + if (error.cause instanceof ServiceError) { + Logger.error(error.cause.message); + return error.cause; + } + if (error instanceof Error && error.cause) { + Logger.error(JSON.stringify(error.cause, null, 2)); + } else { + Logger.error(error); + } + 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/StaticRenderRegeneration/cache_manager.ts b/src/StaticRenderRegeneration/cache_manager.ts new file mode 100644 index 00000000..f0a3a87c --- /dev/null +++ b/src/StaticRenderRegeneration/cache_manager.ts @@ -0,0 +1,51 @@ +import { Logger } from "../Helpers/Logger.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); + } + + 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(() => { + Logger.warn("Failed to save cache file"); + }); + } +} diff --git a/src/StaticRenderRegeneration/index.ts b/src/StaticRenderRegeneration/index.ts new file mode 100644 index 00000000..bc846213 --- /dev/null +++ b/src/StaticRenderRegeneration/index.ts @@ -0,0 +1,36 @@ +import { CacheManager } from "./cache_manager.ts"; +import { StaticRegenerationOptions } from "./types.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); + + // 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 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); + + if (response.status >= 200 && response.status < 300) { + cacheManager.save(response); + } + + return response; +} diff --git a/src/StaticRenderRegeneration/types.ts b/src/StaticRenderRegeneration/types.ts new file mode 100644 index 00000000..c98b66c7 --- /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; +} diff --git a/src/StaticRenderRegeneration/utils.ts b/src/StaticRenderRegeneration/utils.ts new file mode 100644 index 00000000..0c04cc5c --- /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 hashString(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; + } +}; 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 new file mode 100644 index 00000000..e0c97ada --- /dev/null +++ b/src/Types/Request.ts @@ -0,0 +1,21 @@ +export type GithubError = { + message: string; + type: string; +}; + +export type GithubErrorResponse = { + errors: GithubError[]; +}; + +export type GithubExceedError = { + documentation_url: string; + message: string; +}; + +export type QueryDefaultResponse = { + data: { + data: T; + errors?: GithubErrorResponse; + message?: string; + }; +}; diff --git a/src/Types/ServiceError.ts b/src/Types/ServiceError.ts new file mode 100644 index 00000000..cd0ec7a2 --- /dev/null +++ b/src/Types/ServiceError.ts @@ -0,0 +1,21 @@ +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; + } + + 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 new file mode 100644 index 00000000..0e52b91c --- /dev/null +++ b/src/Types/index.ts @@ -0,0 +1,3 @@ +export * from "./Request.ts"; +export * from "./ServiceError.ts"; +export * from "./EServiceKindError.ts"; 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); 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/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/github_api_client.ts b/src/github_api_client.ts deleted file mode 100644 index 4ca5e1f6..00000000 --- a/src/github_api_client.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { soxa } from "../deps.ts"; -import { UserInfo } from "./user_info.ts"; -import type { - GitHubUserActivity, - GitHubUserIssue, - GitHubUserPullRequest, - GitHubUserRepository, -} from "./user_info.ts"; - -export class GithubAPIClient { - constructor() { - } - 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), - ]); - if (results.some((r) => r == null)) { - return null; - } - return new UserInfo(results[0]!, results[1]!, results[2]!, results[3]!); - } - private async requestUserActivity( - token: string | undefined, - username: string, - ): Promise { - const query = ` - query userInfo($username: String!) { - user(login: $username) { - createdAt - contributionsCollection { - totalCommitContributions - restrictedContributionsCount - } - organizations(first: 1) { - totalCount - } - followers(first: 1) { - totalCount - } - } - } - `; - return await this.request(query, token, username); - } - private async requestUserIssue( - token: string | undefined, - 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, token, username); - } - private async requestUserPullRequest( - token: string | undefined, - username: string, - ): Promise { - const query = ` - query userInfo($username: String!) { - user(login: $username) { - pullRequests(first: 1) { - totalCount - } - } - } - `; - return await this.request(query, token, username); - } - private async requestUserRepository( - token: string | undefined, - 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, token, username); - } - private async request( - query: string, - token: string | undefined, - username: string, - ) { - const variables = { username: username }; - const response = await soxa.post( - "https://api.github.com/graphql", - {}, - { - 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); - } - return response.data.data.user; - } -} 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/trophy.ts b/src/trophy.ts index cac80cbc..14602c6c 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,6 +200,124 @@ 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 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 AccountDurationTrophy extends Trophy { + constructor(score: number) { + const rankConditions = [ + new RankCondition( + RANK.SSS, + "Seasoned Veteran", + 70, // 20 years + ), + new RankCondition( + RANK.SS, + "Grandmaster", + 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 b9eaa353..1f190d9b 100644 --- a/src/trophy_list.ts +++ b/src/trophy_list.ts @@ -1,20 +1,23 @@ import { - Trophy, - TotalStarTrophy, + AccountDurationTrophy, + AllSuperRankTrophy, + AncientAccountTrophy, + Joined2020Trophy, + LongTimeAccountTrophy, + MultipleLangTrophy, + MultipleOrganizationsTrophy, + OGAccountTrophy, TotalCommitTrophy, TotalFollowerTrophy, TotalIssueTrophy, TotalPullRequestTrophy, TotalRepositoryTrophy, - MultipleLangTrophy, - LongTimeAccountTrophy, - AncientAccountTrophy, - Joined2020Trophy, - AllSuperRankTrophy, - MultipleOrganizationsTrophy, + TotalReviewsTrophy, + 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(); @@ -27,6 +30,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( @@ -34,8 +38,10 @@ 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), + new AccountDurationTrophy(userInfo.durationDays), ); } get length() { @@ -45,9 +51,11 @@ 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() { + filterByHidden() { this.trophies = this.trophies.filter((trophy) => !trophy.hidden || trophy.rank !== RANK.UNKNOWN ); @@ -60,9 +68,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) @@ -74,4 +82,3 @@ export class TrophyList { ); } } - diff --git a/src/user_info.ts b/src/user_info.ts index c1b25723..805402d6 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,12 +46,15 @@ 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; public readonly durationYear: number; + public readonly durationDays: number; public readonly ancientAccount: number; public readonly joined2020: number; + public readonly ogAccount: number; constructor( userActivity: GitHubUserActivity, userIssue: GitHubUserIssue, @@ -80,21 +84,33 @@ 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 ? 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.totalStargazers = totalStargazers; this.totalRepositories = userRepository.repositories.totalCount; this.languageCount = languages.size; this.durationYear = durationYear; + this.durationDays = durationDays; this.ancientAccount = ancientAccount; this.joined2020 = joined2020; + this.ogAccount = ogAccount; } } diff --git a/src/utils.ts b/src/utils.ts index e4067a13..a244d110 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -53,15 +53,21 @@ 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, - DEFAULT_MAX_COLUMN: 6, + DEFAULT_MAX_COLUMN: 8, DEFAULT_MAX_ROW: 3, DEFAULT_MARGIN_W: 0, DEFAULT_MARGIN_H: 0, 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, + REDIS_TTL: HOUR_IN_MILLISECONDS * 4, }; export enum RANK { diff --git a/vercel.json b/vercel.json index 9bceb939..64706355 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" + } + ] }