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"
+ }
+ ]
}