/* These will throw `RefinedGitHubAPIError` if something goes wrong or if it's a 404. Probably don't catch them so they will appear in the console next to the name of the feature that caused them. Usage: import * as api from '../github-helpers/api.js'; const user = await api.v3(`/users/${username}`); const repositoryCommits = await api.v3('commits'); // Without a leading `/`, this is equivalent to `/repo/$current-repository/commits` const data = await api.v4('{user(login: "user") {name}}'); Returns: a Promise that resolves into an object. If the response body is empty, you'll receive an object like {status: 200} The second argument is an options object, it lets you define accept error HTTP codes as a valid response, like: { ignoreHTTPStatus: true } so the call will not throw an error but it will return as usual. */ import mem from 'mem'; import * as pageDetect from 'github-url-detection'; import {JsonObject, AsyncReturnType} from 'type-fest'; import features from '../feature-manager.js'; import {getRepo} from './index.js'; import optionsStorage from '../options-storage.js'; type JsonError = { message: string; }; type GraphQLResponse = { message?: string; data?: JsonObject; errors?: JsonError[]; }; type RestResponse = { httpStatus: number; headers: Headers; ok: boolean; } & AnyObject; export const escapeKey = (...keys: Array): string => '_' + String(keys).replace(/[^a-z\d]/gi, '_'); export class RefinedGitHubAPIError extends Error { response: AnyObject = {}; constructor(...messages: string[]) { super(messages.join('\n')); } } const settings = optionsStorage.getAll(); export async function expectToken(): Promise { const {personalToken} = await settings; if (!personalToken) { throw new Error('Personal token required for this feature'); } return personalToken; } export async function expectTokenScope(scope: string): Promise { const {headers} = await v3('/'); const tokenScopes = headers.get('X-OAuth-Scopes')!; if (!tokenScopes.split(', ').includes(scope)) { throw new Error('The token you provided does not have ' + (tokenScopes ? `the \`${scope}\` scope. It only includes \`${tokenScopes}\`.` : 'any scope.')); } } const api3 = pageDetect.isEnterprise() ? `${location.origin}/api/v3/` : 'https://api.github.com/'; const api4 = pageDetect.isEnterprise() ? `${location.origin}/api/graphql` : 'https://api.github.com/graphql'; type GHRestApiOptions = { ignoreHTTPStatus?: boolean; method?: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'; body?: JsonObject; headers?: HeadersInit; json?: boolean; }; type GHGraphQLApiOptions = { allowErrors?: boolean; }; const v3defaults: GHRestApiOptions = { ignoreHTTPStatus: false, method: 'GET', body: undefined, json: true, }; const v4defaults: GHGraphQLApiOptions = { allowErrors: false, }; export const v3 = mem(async ( query: string, options: GHRestApiOptions = v3defaults, ): Promise => { const {ignoreHTTPStatus, method, body, headers, json} = {...v3defaults, ...options}; const {personalToken} = await settings; if (!query.startsWith('https')) { query = query.startsWith('/') ? query.slice(1) : ['repos', getRepo()!.nameWithOwner, query].filter(Boolean).join('/'); } const url = new URL(query, api3); features.log.http(url); const response = await fetch(url.href, { method, body: body && JSON.stringify(body), headers: { 'User-Agent': 'Refined GitHub', Accept: 'application/vnd.github.v3+json', ...headers, ...personalToken && {Authorization: `token ${personalToken}`}, }, }); const textContent = await response.text(); const apiResponse = json ? JSON.parse(textContent) : {textContent}; if (response.ok || ignoreHTTPStatus) { return Object.assign(apiResponse, { httpStatus: response.status, headers: response.headers, ok: response.ok, }); } throw await getError(apiResponse); }, { cacheKey: JSON.stringify, }); export const v3paginated = async function * ( query: string, options?: GHRestApiOptions, ): AsyncGenerator> { while (true) { // eslint-disable-next-line no-await-in-loop const response = await v3(query, options); yield response; const match = /<([^>]+)>; rel="next"/.exec(response.headers.get('link')!); if (match) { query = match[1]!; } else { return; } } }; export const v4 = mem(async ( query: string, options: GHGraphQLApiOptions = v4defaults, ): Promise => { const personalToken = await expectToken(); if (/^(query )?{/.test(query.trimStart())) { throw new TypeError('`query` should only be what’s inside \'query {...}\', like \'user(login: "foo") { name }\', but is \n' + query); } query = query.replace('repository() {', () => `repository(owner: "${getRepo()!.owner}", name: "${getRepo()!.name}") {`); features.log.http(`{ ${query} }`); const response = await fetch(api4, { headers: { 'User-Agent': 'Refined GitHub', Authorization: `bearer ${personalToken}`, Accept: 'application/vnd.github.merge-info-preview+json', }, method: 'POST', body: JSON.stringify({query: query.trimStart().startsWith('mutation') ? query : `{${query}}`}), }); const apiResponse: GraphQLResponse = await response.json(); const { data = {}, errors = [], } = apiResponse; if (errors.length > 0 && !options.allowErrors) { throw new RefinedGitHubAPIError('GraphQL:', ...errors.map(error => error.message)); } if (response.ok) { return data; } throw await getError(apiResponse as JsonObject); }, { cacheKey([query, options]) { // `repository()` uses global state and must be handled explicitly // https://github.com/refined-github/refined-github/issues/5821 // https://github.com/sindresorhus/eslint-plugin-unicorn/issues/1864 const key = [query, options]; if (query.includes('repository() {')) { key.push(getRepo()?.nameWithOwner); } return JSON.stringify(key); }, }); export async function getError(apiResponse: JsonObject): Promise { const {personalToken} = await settings; if ((apiResponse.message as string)?.includes('API rate limit exceeded')) { return new RefinedGitHubAPIError( 'Rate limit exceeded.', personalToken ? 'It may be time for a walk! πŸƒ 🌞' : 'Set your token in the options or take a walk! πŸƒ 🌞', ); } if (apiResponse.message === 'Bad credentials') { return new RefinedGitHubAPIError( 'The token seems to be incorrect or expired. Update it in the options.', ); } const error = new RefinedGitHubAPIError( 'Unable to fetch.', personalToken ? 'Ensure that your token has access to this repo.' : 'Maybe adding a token in the options will fix this issue.', JSON.stringify(apiResponse, null, '\t'), // Beautify ); error.response = apiResponse; return error; }