Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 1 addition & 2 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,7 @@
"editor.formatOnSave": true,
"editor.defaultFormatter": "esbenp.prettier-vscode",
"[javascript]": {
"editor.tabSize": 2,

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I removed this line since it's redundant with the repository prettier config:

"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"cSpell.words": ["Wakatime"]
"cSpell.words": ["retryer", "Wakatime"]
}
28 changes: 18 additions & 10 deletions packages/core/src/common/retryer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ import { CustomError } from "./error.js";
import { logger } from "./log.js";

/**
* Subset of the response payload the retryer inspects to detect
* rate-limiting and credential failures.
* Error-detection fields the retryer inspects to detect rate-limiting and credential failures.
* Every fetcher's payload is intersected with
* this, so the retryer can read `errors`/`message` regardless of the payload's own shape.
*/
interface ResponseData {
interface ResponseErrors {
errors?: Array<{ type?: string; message?: string }>;
message?: string;
}
Expand All @@ -27,27 +28,34 @@ function getRandomInt(max: number): number {
return Math.floor(Math.random() * max);
}

type FetcherResponse = AxiosResponse<ResponseData>;
/**
* A fetcher's Axios response. `TData` is the shape of `response.data`,
* which is intersected with {@link ResponseErrors} so the retryer can inspect
* `errors`/`message`.
* Defaults to `unknown` (error fields only) for callers that don't care about the payload.
*/
type FetcherResponse<TData = unknown> = AxiosResponse<TData & ResponseErrors>;

type FetcherFunction = (
type FetcherFunction<TData = unknown> = (
variables: Record<string, unknown>,
token: string,
retriesForTests?: number,
) => Promise<FetcherResponse>;
) => Promise<FetcherResponse<TData>>;

/**
* Try to execute the fetcher function until it succeeds or the max number of retries is reached.
*
* @template TData Shape of `response.data` returned by the fetcher.
* @param fetcher The fetcher function.
* @param variables Object with arguments to pass to the fetcher function.
* @param pat Optional PAT override.
* @returns The response from the fetcher function.
*/
const retryer = async (
fetcher: FetcherFunction,
const retryer = async <TData = unknown>(
fetcher: FetcherFunction<TData>,
variables: Record<string, unknown>,
pat: string | null = null,
): Promise<FetcherResponse> => {
): Promise<FetcherResponse<TData>> => {
const PATs = pat
? [{ name: "user PAT from database", value: pat }]
: getConfig().pats;
Expand Down Expand Up @@ -86,7 +94,7 @@ const retryer = async (
return response;
}
} catch (err) {
const e = err as { response?: FetcherResponse };
const e = err as { response?: FetcherResponse<TData> };

// network/unexpected error → let caller treat as failure
if (!e.response) {
Expand Down
112 changes: 0 additions & 112 deletions packages/core/src/fetchers/gist.js

This file was deleted.

136 changes: 136 additions & 0 deletions packages/core/src/fetchers/gist.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
import type { AxiosResponse } from "axios";

import { MissingParamError } from "../common/error.js";
import { request } from "../common/http.js";
import { retryer } from "../common/retryer.js";

import type { GistData } from "./types.js";

const QUERY = `
query gistInfo($gistName: String!) {
viewer {
gist(name: $gistName) {
description
owner {
login
}
stargazerCount
forks {
totalCount
}
files {
name
language {
name
}
size
}
}
}
}
`;

/**
* Gist data fetcher.
*
* @param variables Fetcher variables.
* @param token GitHub token.
* @returns The response.
*/
const fetcher = (
variables: Record<string, unknown>,
token: string,
): Promise<AxiosResponse> => {
return request(
{ query: QUERY, variables },
{ Authorization: `token ${token}` },
);
};

/** A single file within a gist. */
interface GistFile {
name: string;
language: { name: string } | null;
size: number;
}

/** Shape of `response.data` returned by the gist GraphQL query. */
interface GistQueryResponse {
data: {
viewer: {
gist: {
description: string | null;
owner: { login: string };
stargazerCount: number;
forks: { totalCount: number };
files: Array<GistFile>;
} | null;
};
};
}

/**
* This function calculates the primary language of a gist by files size.
*
* @param files Files.
* @returns Primary language, or `null` when no file has a language.
*/
const calculatePrimaryLanguage = (files: Array<GistFile>): string | null => {
const languages: Record<string, number> = {};

for (const file of files) {
if (file.language) {
languages[file.language.name] =
(languages[file.language.name] ?? 0) + file.size;
}
}

let primaryLanguage: string | null = null;
let maxSize = -1;
for (const [language, size] of Object.entries(languages)) {
if (size > maxSize) {
maxSize = size;
primaryLanguage = language;
}
}

return primaryLanguage;
};

/**
* Fetch GitHub gist information by given username and ID.
*
* @param id GitHub gist ID.
* @param pat Optional PAT override.
* @returns Gist data.
*/
const fetchGist = async (
id: string,
pat: string | null = null,
): Promise<GistData> => {
if (!id) {
throw new MissingParamError(["id"], "/api/gist?id=GIST_ID");
}
const res = await retryer<GistQueryResponse>(fetcher, { gistName: id }, pat);
if (res.data.errors) {
throw new Error(res.data.errors[0]?.message);
}
const gist = res.data.data.viewer.gist;
if (!gist) {
throw new Error("Gist not found");
}
const firstFile = gist.files[0];
if (!firstFile) {
throw new Error("Gist has no files");
}
return {
name: firstFile.name,
nameWithOwner: `${gist.owner.login}/${firstFile.name}`,
description: gist.description,
language: calculatePrimaryLanguage(gist.files),
starsCount: gist.stargazerCount,
forksCount: gist.forks.totalCount,
};
};

export { fetchGist };
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ describe("Test fetchGist", () => {
it("should fetch gist correctly", async () => {
mock.onPost("https://api.github.com/graphql").reply(200, gist_data);

let gist = await fetchGist("bbfce31e0217a3689c8d961a356cb10d");
const gist = await fetchGist("bbfce31e0217a3689c8d961a356cb10d");

expect(gist).toStrictEqual({
name: "countries.json",
Expand Down Expand Up @@ -110,6 +110,7 @@ describe("Test fetchGist", () => {
});

it("should throw error if id is not provided", async () => {
// @ts-expect-error testing missing argument
await expect(fetchGist()).rejects.toThrow(
'Missing params "id" make sure you pass the parameters in URL',
);
Expand Down