mirror of
https://github.com/nathonius/obsidian-github-link.git
synced 2026-07-22 09:20:25 +00:00
add cache layer
This commit is contained in:
parent
3021fb28f1
commit
49e3e2eb97
10 changed files with 334 additions and 209 deletions
206
src/github.ts
206
src/github.ts
|
|
@ -1,206 +0,0 @@
|
|||
import type { OnVerificationCallback } from "@octokit/auth-oauth-device/dist-types/types";
|
||||
import type { RequestUrlParam } from "obsidian";
|
||||
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
|
||||
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
|
||||
import { request } from "@octokit/request";
|
||||
import { requestUrl } from "obsidian";
|
||||
|
||||
const baseApi = "https://api.github.com";
|
||||
|
||||
interface ParsedUrl {
|
||||
url: string;
|
||||
host: string;
|
||||
org?: string;
|
||||
repo?: string;
|
||||
issue?: number;
|
||||
pr?: number;
|
||||
code?: {
|
||||
branch?: string;
|
||||
path?: string;
|
||||
};
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
// TODO: Clean this file up, lots of junk in here
|
||||
|
||||
export async function githubRequest(config: RequestUrlParam, token?: string) {
|
||||
if (!config.headers) {
|
||||
config.headers = {};
|
||||
}
|
||||
config.headers.Accept = "application/vnd.github+json";
|
||||
config.headers["X-GitHub-Api-Version"] = "2022-11-28";
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
try {
|
||||
const response = await requestUrl(config);
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getIssue(org: string, repo: string, issue: number, token?: string) {
|
||||
const result = await githubRequest({ url: `${baseApi}/repos/${org}/${repo}/issues/${issue}` }, token);
|
||||
return result.json as RestEndpointMethodTypes["issues"]["get"]["response"]["data"];
|
||||
}
|
||||
|
||||
export async function getPullRequest(org: string, repo: string, pr: number, token?: string) {
|
||||
const result = await githubRequest(
|
||||
{
|
||||
url: `${baseApi}/repos/${org}/${repo}/pulls/${pr}`,
|
||||
},
|
||||
token,
|
||||
);
|
||||
return result.json as RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
|
||||
}
|
||||
|
||||
export async function getCode(org: string, repo: string, path: string, branch: string, token?: string) {
|
||||
const result = await githubRequest(
|
||||
{
|
||||
url: `${baseApi}/repos/${org}/${repo}/contents/${path}?ref=${branch}`,
|
||||
},
|
||||
token,
|
||||
);
|
||||
return result.json as RestEndpointMethodTypes["repos"]["getContent"]["response"]["data"];
|
||||
}
|
||||
|
||||
export function parseUrl(urlString: string): ParsedUrl {
|
||||
// Some potential URLs:
|
||||
// https://github.com/nathonius/alloy-theme
|
||||
// https://github.com/nathonius/obsidian-trello/issues/45
|
||||
// https://github.com/nathonius/obsidian-trello/pull/54
|
||||
// https://github.com/nathonius/obsidian-trello/blob/main/src/constants.ts
|
||||
// https://github.com/nathonius/obsidian-trello/blob/markdown/src/constants.ts
|
||||
// https://github.com/nathonius/obsidian-trello/blob/markdown/src/constants.ts#L44-L58
|
||||
// https://github.com/nathonius/obsidian-trello/commit/7ea069dd0641441ec20fb194f50e746a21abbaf1
|
||||
// https://github.com/nathonius/obsidian-trello/commit/7ea069dd0641441ec20fb194f50e746a21abbaf1#diff-8fa4b52909f895e8cda060d2035234e0a42ca2c7d3f8f8de1b35a056537bf199R35
|
||||
const url = new URL(urlString);
|
||||
const parsedUrl: ParsedUrl = { url: urlString, host: url.hostname };
|
||||
|
||||
const urlParts = url.pathname.split("/");
|
||||
console.log(urlParts);
|
||||
if (urlParts.length >= 4) {
|
||||
switch (urlParts[3].toLowerCase()) {
|
||||
case "issues":
|
||||
if (urlParts[4]) {
|
||||
const issueNumber = parseInt(urlParts[4], 10);
|
||||
if (!isNaN(issueNumber)) {
|
||||
parsedUrl.issue = issueNumber;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "pull":
|
||||
if (urlParts[4]) {
|
||||
const prNumber = parseInt(urlParts[4], 10);
|
||||
if (!isNaN(prNumber)) {
|
||||
parsedUrl.pr = prNumber;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "blob":
|
||||
parsedUrl.code = {};
|
||||
if (urlParts[4]) {
|
||||
parsedUrl.code.branch = urlParts[4];
|
||||
}
|
||||
if (urlParts[5]) {
|
||||
const pathParts = urlParts.slice(5);
|
||||
parsedUrl.code.path = pathParts.join("/");
|
||||
}
|
||||
break;
|
||||
case "commit":
|
||||
if (urlParts[4]) {
|
||||
parsedUrl.commit = urlParts.slice(4).join("/");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (urlParts.length >= 3) {
|
||||
parsedUrl.repo = urlParts[2];
|
||||
}
|
||||
if (urlParts.length >= 2) {
|
||||
parsedUrl.org = urlParts[1];
|
||||
}
|
||||
|
||||
console.log(parsedUrl);
|
||||
return parsedUrl;
|
||||
}
|
||||
|
||||
async function doFetch(url: RequestInfo | URL, options?: RequestInit | undefined): Promise<Response> {
|
||||
// Octokit always uses a url + options, not a Request object
|
||||
if (typeof url !== "string") {
|
||||
throw new Error("Something has gone horribly wrong and fetch has received unexpected arguments.");
|
||||
}
|
||||
if (options === undefined) {
|
||||
throw new Error("No options given to fetch.");
|
||||
}
|
||||
|
||||
const headers = getHeaders(options.headers);
|
||||
const contentType = headers?.["Content-Type"];
|
||||
const params: RequestUrlParam = {
|
||||
url,
|
||||
headers,
|
||||
method: options.method,
|
||||
body: getBody(options.body),
|
||||
contentType,
|
||||
};
|
||||
const result = await requestUrl(params);
|
||||
const partialResult = {
|
||||
...result,
|
||||
url,
|
||||
headers: new Headers(result.headers),
|
||||
arrayBuffer: () => Promise.resolve(result.arrayBuffer),
|
||||
text: () => Promise.resolve(result.text),
|
||||
json: () => Promise.resolve(result.json),
|
||||
} as Omit<
|
||||
Response,
|
||||
"ok" | "body" | "statusText" | "redirected" | "type" | "clone" | "bodyUsed" | "blob" | "formData"
|
||||
>;
|
||||
return partialResult as Response;
|
||||
}
|
||||
|
||||
function getHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
if (headers instanceof Headers) {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (Array.isArray(headers)) {
|
||||
throw new Error("Got array headers, we don't know what to do with this yet.");
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getBody(body: BodyInit | null | undefined): string | ArrayBuffer | undefined {
|
||||
if (!body) {
|
||||
return undefined;
|
||||
}
|
||||
if (body instanceof ArrayBuffer || typeof body === "string") {
|
||||
return body;
|
||||
}
|
||||
console.warn(`Got an unknown body parameter type, trying to stringify it.`);
|
||||
console.warn(body);
|
||||
try {
|
||||
return JSON.stringify(body);
|
||||
} catch {
|
||||
console.error("Could not stringify body parameter.");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export const auth = (verificationHandler: OnVerificationCallback) =>
|
||||
createOAuthDeviceAuth({
|
||||
clientType: "oauth-app",
|
||||
clientId: "baf0370cb98e1387d244",
|
||||
scopes: ["repo"],
|
||||
onVerification: verificationHandler,
|
||||
request: request.defaults({
|
||||
request: { fetch: doFetch },
|
||||
}),
|
||||
});
|
||||
55
src/github/api.ts
Normal file
55
src/github/api.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import type { CodeResponse, IssueResponse, PullResponse } from "./response";
|
||||
|
||||
import type { RequestUrlParam } from "obsidian";
|
||||
import { requestUrl } from "obsidian";
|
||||
|
||||
const baseApi = "https://api.github.com";
|
||||
|
||||
async function githubRequest(config: RequestUrlParam, token?: string) {
|
||||
if (!config.headers) {
|
||||
config.headers = {};
|
||||
}
|
||||
config.headers.Accept = "application/vnd.github+json";
|
||||
config.headers["X-GitHub-Api-Version"] = "2022-11-28";
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
try {
|
||||
const response = await requestUrl(config);
|
||||
return response;
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function getIssue(org: string, repo: string, issue: number, token?: string): Promise<IssueResponse> {
|
||||
const result = await githubRequest({ url: `${baseApi}/repos/${org}/${repo}/issues/${issue}` }, token);
|
||||
return result.json as IssueResponse;
|
||||
}
|
||||
|
||||
async function getPullRequest(org: string, repo: string, pr: number, token?: string): Promise<PullResponse> {
|
||||
const result = await githubRequest(
|
||||
{
|
||||
url: `${baseApi}/repos/${org}/${repo}/pulls/${pr}`,
|
||||
},
|
||||
token,
|
||||
);
|
||||
return result.json as PullResponse;
|
||||
}
|
||||
|
||||
async function getCode(org: string, repo: string, path: string, branch: string, token?: string): Promise<CodeResponse> {
|
||||
const result = await githubRequest(
|
||||
{
|
||||
url: `${baseApi}/repos/${org}/${repo}/contents/${path}?ref=${branch}`,
|
||||
},
|
||||
token,
|
||||
);
|
||||
return result.json as CodeResponse;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getIssue,
|
||||
getPullRequest,
|
||||
getCode,
|
||||
};
|
||||
83
src/github/auth.ts
Normal file
83
src/github/auth.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
import type { OnVerificationCallback } from "@octokit/auth-oauth-device/dist-types/types";
|
||||
import type { RequestUrlParam } from "obsidian";
|
||||
import { createOAuthDeviceAuth } from "@octokit/auth-oauth-device";
|
||||
import { request } from "@octokit/request";
|
||||
import { requestUrl } from "obsidian";
|
||||
|
||||
function getHeaders(headers: HeadersInit | undefined): Record<string, string> | undefined {
|
||||
if (!headers) {
|
||||
return undefined;
|
||||
}
|
||||
if (headers instanceof Headers) {
|
||||
const result: Record<string, string> = {};
|
||||
headers.forEach((value, key) => {
|
||||
result[key] = value;
|
||||
});
|
||||
return result;
|
||||
}
|
||||
if (Array.isArray(headers)) {
|
||||
throw new Error("Got array headers, we don't know what to do with this yet.");
|
||||
}
|
||||
return headers;
|
||||
}
|
||||
|
||||
function getBody(body: BodyInit | null | undefined): string | ArrayBuffer | undefined {
|
||||
if (!body) {
|
||||
return undefined;
|
||||
}
|
||||
if (body instanceof ArrayBuffer || typeof body === "string") {
|
||||
return body;
|
||||
}
|
||||
console.warn(`Got an unknown body parameter type, trying to stringify it.`);
|
||||
console.warn(body);
|
||||
try {
|
||||
return JSON.stringify(body);
|
||||
} catch {
|
||||
console.error("Could not stringify body parameter.");
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
async function doFetch(url: RequestInfo | URL, options?: RequestInit | undefined): Promise<Response> {
|
||||
// Octokit always uses a url + options, not a Request object
|
||||
if (typeof url !== "string") {
|
||||
throw new Error("Something has gone horribly wrong and fetch has received unexpected arguments.");
|
||||
}
|
||||
if (options === undefined) {
|
||||
throw new Error("No options given to fetch.");
|
||||
}
|
||||
|
||||
const headers = getHeaders(options.headers);
|
||||
const contentType = headers?.["Content-Type"];
|
||||
const params: RequestUrlParam = {
|
||||
url,
|
||||
headers,
|
||||
method: options.method,
|
||||
body: getBody(options.body),
|
||||
contentType,
|
||||
};
|
||||
const result = await requestUrl(params);
|
||||
const partialResult = {
|
||||
...result,
|
||||
url,
|
||||
headers: new Headers(result.headers),
|
||||
arrayBuffer: () => Promise.resolve(result.arrayBuffer),
|
||||
text: () => Promise.resolve(result.text),
|
||||
json: () => Promise.resolve(result.json),
|
||||
} as Omit<
|
||||
Response,
|
||||
"ok" | "body" | "statusText" | "redirected" | "type" | "clone" | "bodyUsed" | "blob" | "formData"
|
||||
>;
|
||||
return partialResult as Response;
|
||||
}
|
||||
|
||||
export const auth = (verificationHandler: OnVerificationCallback) =>
|
||||
createOAuthDeviceAuth({
|
||||
clientType: "oauth-app",
|
||||
clientId: "baf0370cb98e1387d244",
|
||||
scopes: ["repo"],
|
||||
onVerification: verificationHandler,
|
||||
request: request.defaults({
|
||||
request: { fetch: doFetch },
|
||||
}),
|
||||
});
|
||||
82
src/github/cache.ts
Normal file
82
src/github/cache.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import type { IssueResponse, PullResponse } from "./response";
|
||||
|
||||
class CacheEntry<T> {
|
||||
constructor(
|
||||
public value: T,
|
||||
public created: Date = new Date(),
|
||||
public ttl: number = 20,
|
||||
) {}
|
||||
|
||||
get expired(): boolean {
|
||||
const expiry = this.created.getTime() + this.ttl * 60 * 1000;
|
||||
|
||||
return new Date().getTime() > expiry;
|
||||
}
|
||||
}
|
||||
|
||||
class RepoCache {
|
||||
public readonly issueCache: Record<number, CacheEntry<IssueResponse>> = {};
|
||||
public readonly pullCache: Record<string, CacheEntry<PullResponse>> = {};
|
||||
}
|
||||
|
||||
class OrgCache {
|
||||
public readonly repos: Record<string, RepoCache> = {};
|
||||
}
|
||||
|
||||
export class Cache {
|
||||
public readonly orgs: Record<string, OrgCache> = {};
|
||||
|
||||
getIssue(org: string, repo: string, issue: number): IssueResponse | null {
|
||||
const repoCache = this.getRepoCache(org, repo);
|
||||
return this.getCacheValue(repoCache.issueCache[issue] ?? null);
|
||||
}
|
||||
|
||||
setIssue(org: string, repo: string, issue: IssueResponse) {
|
||||
const issueCache = this.getRepoCache(org, repo).issueCache;
|
||||
const existingCache = issueCache[issue.id];
|
||||
if (existingCache) {
|
||||
const now = new Date();
|
||||
existingCache.created = now;
|
||||
existingCache.value = issue;
|
||||
} else {
|
||||
issueCache[issue.id] = new CacheEntry<IssueResponse>(issue);
|
||||
}
|
||||
}
|
||||
|
||||
getPullRequest(org: string, repo: string, pullRequest: number): PullResponse | null {
|
||||
const repoCache = this.getRepoCache(org, repo);
|
||||
return this.getCacheValue(repoCache.pullCache[pullRequest] ?? null);
|
||||
}
|
||||
|
||||
setPullRequest(org: string, repo: string, pullRequest: PullResponse): void {
|
||||
const pullCache = this.getRepoCache(org, repo).pullCache;
|
||||
const existingCache = pullCache[pullRequest.id];
|
||||
if (existingCache) {
|
||||
const now = new Date();
|
||||
existingCache.created = now;
|
||||
existingCache.value = pullRequest;
|
||||
} else {
|
||||
pullCache[pullRequest.id] = new CacheEntry<PullResponse>(pullRequest);
|
||||
}
|
||||
}
|
||||
|
||||
private getRepoCache(org: string, repo: string) {
|
||||
let orgCache = this.orgs[org];
|
||||
if (!orgCache) {
|
||||
orgCache = this.orgs[org] = new OrgCache();
|
||||
}
|
||||
let repoCache = orgCache.repos[repo];
|
||||
if (!repoCache) {
|
||||
repoCache = orgCache.repos[repo] = new RepoCache();
|
||||
}
|
||||
return repoCache;
|
||||
}
|
||||
|
||||
private getCacheValue<T>(cacheEntry: CacheEntry<T> | null): T | null {
|
||||
if (!cacheEntry || cacheEntry.expired) {
|
||||
return null;
|
||||
} else {
|
||||
return cacheEntry.value;
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/github/github.ts
Normal file
33
src/github/github.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import type { IssueResponse, PullResponse } from "./response";
|
||||
|
||||
import { Cache } from "./cache";
|
||||
import { api } from "./api";
|
||||
|
||||
const cache = new Cache();
|
||||
|
||||
export async function getIssue(org: string, repo: string, issue: number, token?: string): Promise<IssueResponse> {
|
||||
const cachedValue = cache.getIssue(org, repo, issue);
|
||||
if (cachedValue) {
|
||||
return Promise.resolve(cachedValue);
|
||||
}
|
||||
|
||||
const response = await api.getIssue(org, repo, issue, token);
|
||||
cache.setIssue(org, repo, response);
|
||||
return response;
|
||||
}
|
||||
|
||||
export async function getPullRequest(
|
||||
org: string,
|
||||
repo: string,
|
||||
pullRequest: number,
|
||||
token?: string,
|
||||
): Promise<PullResponse> {
|
||||
const cachedValue = cache.getPullRequest(org, repo, pullRequest);
|
||||
if (cachedValue) {
|
||||
return Promise.resolve(cachedValue);
|
||||
}
|
||||
|
||||
const response = await api.getPullRequest(org, repo, pullRequest, token);
|
||||
cache.setPullRequest(org, repo, response);
|
||||
return response;
|
||||
}
|
||||
5
src/github/response.ts
Normal file
5
src/github/response.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import type { RestEndpointMethodTypes } from "@octokit/plugin-rest-endpoint-methods";
|
||||
|
||||
export type IssueResponse = RestEndpointMethodTypes["issues"]["get"]["response"]["data"];
|
||||
export type PullResponse = RestEndpointMethodTypes["pulls"]["get"]["response"]["data"];
|
||||
export type CodeResponse = RestEndpointMethodTypes["repos"]["getContent"]["response"]["data"];
|
||||
72
src/github/url-parse.ts
Normal file
72
src/github/url-parse.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
export interface ParsedUrl {
|
||||
url: string;
|
||||
host: string;
|
||||
org?: string;
|
||||
repo?: string;
|
||||
issue?: number;
|
||||
pr?: number;
|
||||
code?: {
|
||||
branch?: string;
|
||||
path?: string;
|
||||
};
|
||||
commit?: string;
|
||||
}
|
||||
|
||||
export function parseUrl(urlString: string): ParsedUrl {
|
||||
// Some potential URLs:
|
||||
// https://github.com/nathonius/alloy-theme
|
||||
// https://github.com/nathonius/obsidian-trello/issues/45
|
||||
// https://github.com/nathonius/obsidian-trello/pull/54
|
||||
// https://github.com/nathonius/obsidian-trello/blob/main/src/constants.ts
|
||||
// https://github.com/nathonius/obsidian-trello/blob/markdown/src/constants.ts
|
||||
// https://github.com/nathonius/obsidian-trello/blob/markdown/src/constants.ts#L44-L58
|
||||
// https://github.com/nathonius/obsidian-trello/commit/7ea069dd0641441ec20fb194f50e746a21abbaf1
|
||||
// https://github.com/nathonius/obsidian-trello/commit/7ea069dd0641441ec20fb194f50e746a21abbaf1#diff-8fa4b52909f895e8cda060d2035234e0a42ca2c7d3f8f8de1b35a056537bf199R35
|
||||
const url = new URL(urlString);
|
||||
const parsedUrl: ParsedUrl = { url: urlString, host: url.hostname };
|
||||
|
||||
const urlParts = url.pathname.split("/");
|
||||
if (urlParts.length >= 4) {
|
||||
switch (urlParts[3].toLowerCase()) {
|
||||
case "issues":
|
||||
if (urlParts[4]) {
|
||||
const issueNumber = parseInt(urlParts[4], 10);
|
||||
if (!isNaN(issueNumber)) {
|
||||
parsedUrl.issue = issueNumber;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "pull":
|
||||
if (urlParts[4]) {
|
||||
const prNumber = parseInt(urlParts[4], 10);
|
||||
if (!isNaN(prNumber)) {
|
||||
parsedUrl.pr = prNumber;
|
||||
}
|
||||
}
|
||||
break;
|
||||
case "blob":
|
||||
parsedUrl.code = {};
|
||||
if (urlParts[4]) {
|
||||
parsedUrl.code.branch = urlParts[4];
|
||||
}
|
||||
if (urlParts[5]) {
|
||||
const pathParts = urlParts.slice(5);
|
||||
parsedUrl.code.path = pathParts.join("/");
|
||||
}
|
||||
break;
|
||||
case "commit":
|
||||
if (urlParts[4]) {
|
||||
parsedUrl.commit = urlParts.slice(4).join("/");
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (urlParts.length >= 3) {
|
||||
parsedUrl.repo = urlParts[2];
|
||||
}
|
||||
if (urlParts.length >= 2) {
|
||||
parsedUrl.org = urlParts[1];
|
||||
}
|
||||
|
||||
return parsedUrl;
|
||||
}
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { getIssue, getPullRequest, parseUrl } from "../github";
|
||||
import { getIssue, getPullRequest } from "../github/github";
|
||||
|
||||
import { PluginSettings } from "../plugin";
|
||||
import { parseUrl } from "../github/url-parse";
|
||||
import { setIcon } from "obsidian";
|
||||
|
||||
// TODO: Split some of this out, there's no reason I should be getting the token when creating this element
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { GithubLinkPluginSettingsTab } from "./settings";
|
|||
import { InlineRenderer } from "./inline/inline";
|
||||
import { Plugin } from "obsidian";
|
||||
import { createInlineViewPlugin } from "./inline/view-plugin";
|
||||
import { getIssue } from "./github";
|
||||
import { getIssue } from "./github/github";
|
||||
|
||||
export let PluginSettings: GithubLinkPluginSettings = { accounts: [] };
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { AuthModal } from "./auth-modal";
|
|||
import type { GithubLinkPlugin } from "./plugin";
|
||||
import { PluginSettings } from "./plugin";
|
||||
import type { Verification } from "@octokit/auth-oauth-device/dist-types/types";
|
||||
import { auth } from "./github";
|
||||
import { auth } from "./github/auth";
|
||||
|
||||
export interface GithubAccount {
|
||||
id: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue