diff --git a/packages/decap-cms-backend-bitbucket/src/implementation.ts b/packages/decap-cms-backend-bitbucket/src/implementation.ts index e58afed51a64..2f6ce39d706e 100644 --- a/packages/decap-cms-backend-bitbucket/src/implementation.ts +++ b/packages/decap-cms-backend-bitbucket/src/implementation.ts @@ -60,6 +60,8 @@ type BitbucketStatusComponent = { status: string; }; +const { fetchWithTimeout: fetch } = unsentRequest; + // Implementation wrapper class export default class BitbucketBackend implements Implementation { lock: AsyncLock; @@ -72,6 +74,7 @@ export default class BitbucketBackend implements Implementation { initialWorkflowStatus: string; }; repo: string; + isBranchConfigured: boolean; branch: string; apiRoot: string; baseUrl: string; @@ -111,6 +114,7 @@ export default class BitbucketBackend implements Implementation { this.repo = config.backend.repo || ''; this.branch = config.backend.branch || 'master'; + this.isBranchConfigured = config.backend.branch ? true : false; this.apiRoot = config.backend.api_root || 'https://api.bitbucket.org/2.0'; this.baseUrl = config.base_url || ''; this.siteId = config.site_id || ''; @@ -190,6 +194,18 @@ export default class BitbucketBackend implements Implementation { async authenticate(state: Credentials) { this.token = state.token as string; + if (!this.isBranchConfigured) { + const repo = await fetch(`${this.apiRoot}/repositories/${this.repo}`, { + headers: { + Authorization: `token ${this.token}`, + }, + }) + .then(res => res.json()) + .catch(() => null); + if (repo) { + this.branch = repo.mainbranch.name; + } + } this.refreshToken = state.refresh_token; this.api = new API({ requestFunction: this.apiRequestFunction, @@ -216,7 +232,16 @@ export default class BitbucketBackend implements Implementation { if (!isCollab) { throw new Error('Your BitBucket user account does not have access to this repo.'); } - + // if (!this.isBranchConfigured) { + // const defaultBranchName = await getDefaultBranchName({ + // backend: 'bitbucket', + // repo: this.repo, + // token: this.token, + // }); + // if (defaultBranchName) { + // this.branch = defaultBranchName; + // } + // } const user = await this.api.user(); // Authorized user diff --git a/packages/decap-cms-backend-github/src/implementation.tsx b/packages/decap-cms-backend-github/src/implementation.tsx index f5412b974c2d..31149d8e9f26 100644 --- a/packages/decap-cms-backend-github/src/implementation.tsx +++ b/packages/decap-cms-backend-github/src/implementation.tsx @@ -69,6 +69,7 @@ export default class GitHub implements Implementation { initialWorkflowStatus: string; }; originRepo: string; + isBranchConfigured: boolean; repo?: string; openAuthoringEnabled: boolean; useOpenAuthoring?: boolean; @@ -106,7 +107,7 @@ export default class GitHub implements Implementation { } this.api = this.options.API || null; - + this.isBranchConfigured = config.backend.branch ? true : false; this.openAuthoringEnabled = config.backend.open_authoring || false; if (this.openAuthoringEnabled) { if (!this.options.useWorkflow) { @@ -320,6 +321,18 @@ export default class GitHub implements Implementation { async authenticate(state: Credentials) { this.token = state.token as string; + // Query the default branch name when the `branch` property is missing + // in the config file + if (!this.isBranchConfigured) { + const repoInfo = await fetch(`${this.apiRoot}/repos/${this.originRepo}`, { + headers: { Authorization: `token ${this.token}` }, + }) + .then(res => res.json()) + .catch(() => null); + if (repoInfo && repoInfo.default_branch) { + this.branch = repoInfo.default_branch; + } + } const apiCtor = this.useGraphql ? GraphQLAPI : API; this.api = new apiCtor({ token: this.token, @@ -354,6 +367,13 @@ export default class GitHub implements Implementation { throw new Error('Your GitHub user account does not have access to this repo.'); } + // if (!this.isBranchConfigured) { + // const defaultBranchName = await this.api.getDefaultBranchName() + // if (defaultBranchName) { + // this.branch = defaultBranchName; + // } + // } + // Authorized user return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring }; } diff --git a/packages/decap-cms-backend-gitlab/src/__tests__/gitlab.spec.js b/packages/decap-cms-backend-gitlab/src/__tests__/gitlab.spec.js index e5afc7ce783d..4953ac3e4a0d 100644 --- a/packages/decap-cms-backend-gitlab/src/__tests__/gitlab.spec.js +++ b/packages/decap-cms-backend-gitlab/src/__tests__/gitlab.spec.js @@ -109,6 +109,7 @@ const resp = { access_level: 30, }, }, + default_branch: 'main', }, readOnly: { permissions: { @@ -194,7 +195,16 @@ describe('gitlab backend', () => { .reply(200, userResponse || resp.user.success); api + // The `authenticate` method of the API class from netlify-cms-backend-gitlab + // calls the same endpoint twice for gettng a single project. + // First time through `this.api.hasWriteAccess() + // Second time through the method `getDefaultBranchName` from lib-util + // As a result, we need to repeat the same response twice. + // Otherwise, we'll get an error: "No match for request to + // https://gitlab.com/api/v4" + .get(expectedRepoUrl) + .times(2) .query(true) .reply(200, projectResponse || resp.project.success); } diff --git a/packages/decap-cms-backend-gitlab/src/implementation.ts b/packages/decap-cms-backend-gitlab/src/implementation.ts index 50853f438319..769257f0bea3 100644 --- a/packages/decap-cms-backend-gitlab/src/implementation.ts +++ b/packages/decap-cms-backend-gitlab/src/implementation.ts @@ -21,6 +21,7 @@ import { allEntriesByFolder, filterByExtension, branchFromContentKey, + getDefaultBranchName, } from 'decap-cms-lib-util'; import AuthenticationPage from './AuthenticationPage'; @@ -53,6 +54,7 @@ export default class GitLab implements Implementation { initialWorkflowStatus: string; }; repo: string; + isBranchConfigured: boolean; branch: string; apiRoot: string; token: string | null; @@ -84,6 +86,7 @@ export default class GitLab implements Implementation { this.repo = config.backend.repo || ''; this.branch = config.backend.branch || 'master'; + this.isBranchConfigured = config.backend.branch ? true : false; this.apiRoot = config.backend.api_root || 'https://gitlab.com/api/v4'; this.token = ''; this.squashMerges = config.backend.squash_merges || false; @@ -150,6 +153,16 @@ export default class GitLab implements Implementation { throw new Error('Your GitLab user account does not have access to this repo.'); } + if (!this.isBranchConfigured) { + const defaultBranchName = await getDefaultBranchName({ + backend: 'gitlab', + repo: this.repo, + token: this.token, + }); + if (defaultBranchName) { + this.branch = defaultBranchName; + } + } // Authorized user return { ...user, login: user.username, token: state.token as string }; } diff --git a/packages/decap-cms-lib-util/src/API.ts b/packages/decap-cms-lib-util/src/API.ts index f328cf78292e..277f045d3603 100644 --- a/packages/decap-cms-lib-util/src/API.ts +++ b/packages/decap-cms-lib-util/src/API.ts @@ -40,6 +40,26 @@ class RateLimitError extends Error { } } +async function parseJsonResponse(response: Response) { + const json = await response.json(); + if (!response.ok) { + return Promise.reject(json); + } + return json; +} + +export function parseResponse(response: Response) { + const contentType = response.headers.get('Content-Type'); + if (contentType && contentType.match(/json/)) { + return parseJsonResponse(response); + } + const textPromise = response.text().then(text => { + if (!response.ok) return Promise.reject(text); + return text; + }); + return textPromise; +} + export async function requestWithBackoff( api: API, req: ApiRequest, @@ -96,6 +116,140 @@ export async function requestWithBackoff( } } +// Options is an object which contains all the standard network request properties +// for modifying HTTP requests and may contains `params` property + +type Param = string | number; + +type ParamObject = Record; + +type HeaderObj = Record; + +type HeaderConfig = { + headers?: HeaderObj; + token?: string | undefined; +}; + +type Backend = 'github' | 'gitlab' | 'bitbucket'; + +// RequestConfig contains all the standard properties of a Request object and +// several custom properties: +// - "headers" property is an object whose properties and values are string types +// - `token` property to allow passing tokens for users using a private repo. +// - `params` property for customizing response +// - `backend`(compulsory) to specify which backend to be used: Github, Gitlab etc. + +type RequestConfig = Omit & + HeaderConfig & { + backend: Backend; + params?: ParamObject; + }; + +export const apiRoots = { + github: 'https://api.github.com', + gitlab: 'https://gitlab.com/api/v4', + bitbucket: 'https://api.bitbucket.org/2.0', +}; + +export const endpointConstants = { + singleRepo: { + bitbucket: '/repositories', + github: '/repos', + gitlab: '/projects', + }, +}; + +const api = { + buildRequest(req: ApiRequest) { + return req; + }, +}; + +function constructUrlWithParams(url: string, params?: ParamObject) { + if (params) { + const paramList = []; + for (const key in params) { + paramList.push(`${key}=${encodeURIComponent(params[key])}`); + } + if (paramList.length) { + url += `?${paramList.join('&')}`; + } + } + return url; +} + +async function constructRequestHeaders(headerConfig: HeaderConfig) { + const { token, headers } = headerConfig; + const baseHeaders: HeaderObj = { 'Content-Type': 'application/json; charset=utf-8', ...headers }; + if (token) { + baseHeaders['Authorization'] = `token ${token}`; + } + return Promise.resolve(baseHeaders); +} + +function handleRequestError(error: FetchError, responseStatus: number, backend: Backend) { + throw new APIError(error.message, responseStatus, backend); +} + +export async function apiRequest( + path: string, + config: RequestConfig, + parser = (response: Response) => parseResponse(response), +) { + const { token, backend, ...props } = config; + const options = { cache: 'no-cache', ...props }; + const headers = await constructRequestHeaders({ headers: options.headers || {}, token }); + const baseUrl = apiRoots[backend]; + const url = constructUrlWithParams(`${baseUrl}${path}`, options.params); + let responseStatus = 500; + try { + const req = unsentRequest.fromFetchArguments(url, { + ...options, + headers, + }) as unknown as ApiRequest; + const response = await requestWithBackoff(api, req); + responseStatus = response.status; + const parsedResponse = await parser(response); + return parsedResponse; + } catch (error) { + return handleRequestError(error, responseStatus, backend); + } +} + +export async function getDefaultBranchName(configs: { + backend: Backend; + repo: string; + token?: string; +}) { + let apiPath; + const { token, backend, repo } = configs; + switch (backend) { + case 'gitlab': { + apiPath = `/projects/${encodeURIComponent(repo)}`; + break; + } + case 'bitbucket': { + apiPath = `/repositories/${repo}`; + break; + } + default: { + apiPath = `/repos/${repo}`; + } + } + const repoInfo = await apiRequest(apiPath, { token, backend }); + let defaultBranchName; + if (backend === 'bitbucket') { + const { + mainbranch: { name }, + } = repoInfo; + defaultBranchName = name; + } else { + const { default_branch } = repoInfo; + defaultBranchName = default_branch; + } + return defaultBranchName; +} + export async function readFile( id: string | null | undefined, fetchContent: () => Promise, diff --git a/packages/decap-cms-lib-util/src/__tests__/api.spec.js b/packages/decap-cms-lib-util/src/__tests__/api.spec.js index 675ff0458e36..6cf5e07806d0 100644 --- a/packages/decap-cms-lib-util/src/__tests__/api.spec.js +++ b/packages/decap-cms-lib-util/src/__tests__/api.spec.js @@ -1,4 +1,5 @@ import * as api from '../API'; + describe('Api', () => { describe('getPreviewStatus', () => { it('should return preview status on matching context', () => { diff --git a/packages/decap-cms-lib-util/src/index.ts b/packages/decap-cms-lib-util/src/index.ts index 962c91fd3e25..9881366de927 100644 --- a/packages/decap-cms-lib-util/src/index.ts +++ b/packages/decap-cms-lib-util/src/index.ts @@ -34,6 +34,7 @@ import { getPreviewStatus, PreviewState, requestWithBackoff, + getDefaultBranchName, throwOnConflictingBranches, } from './API'; import { @@ -148,6 +149,7 @@ export const DecapCmsLibUtil = { contentKeyFromBranch, blobToFileObj, requestWithBackoff, + getDefaultBranchName, allEntriesByFolder, AccessTokenError, throwOnConflictingBranches, @@ -204,6 +206,7 @@ export { contentKeyFromBranch, blobToFileObj, requestWithBackoff, + getDefaultBranchName, allEntriesByFolder, AccessTokenError, throwOnConflictingBranches,