Skip to content

Commit

Permalink
Fix: Set correct branch when it's not specified in the config (#5844)
Browse files Browse the repository at this point in the history
* feat: add helper for fetching default branch from Github

* feat: add method for setting default branch

* fix: set default branch after user has authenticated successfully

* fix: format code

* feat: add unit test for getting default branch name

* feat: add helpers for parsing API responses

* feat(lib-util): add helper for constructing request headers

* feat(lib-util): add helper for constructing full URL for API request

* feat(lib-util): store base URLs for each backend

* feat(lib-util): add type annotation for the request config

This requestConfig object will be passed to a helper for making API request

* feat(lib-util): add helper for handle API request error

* feat(lib-util): add config for making api request

* feat(lib-util): add api request generator

* feat(lib-util): add helper for getting default branch name

Include switch clause to construct API urls for different backends

* feat(lib-util): export method for getting default branch name

* feat(gh-backend): add a boolean property to check if branch is configured

The property is needed so that we'll only set default branch when branch prop is missing in config

* feat(gh-backend): set prop `branch` as `master` when it's missing in config

This is needed so that this property won't be empty when authorization is revoked.

* feat(gh-backend): set branch name when it's missing in config

* feat(gitlab-backend): set branch when it's not in the config

* feat(bitbucket-backend): set branch when it's not specified in config

* feat(lib-util): allow token type to be undefined

Reason: Requesting information from a public repo doesn't require token

* fix: format codes

* feat(github): removed setDefaultBranch function
Reason: Default branch is already set when calling `authenticate` function

* feat(github): remove function for getting default branch

* fix (github): removed GithubRepo object because it was never used

* fix (gitlab test): Repeat response for getting project info 2 times

Reason: The endpoint for getting Gitlab project info is called twice.
Need to specify the number of times to repeat the same response as 2, or Nock will throw an error.

* fix(gitlab test): add property `default_branch` to project response

REASON: Getting a single project through `/projects/:id` returns an
object which contains `default_branch` property, among many other props.
The mock response needs to convey that.

* fix(gitlab test): reformat codes

* feat(lib util api): change function name

Change from `constructUrl` to `constructUrlWithParams` to indicate that
the returned url may contain query string

* feat(lib-util api): Change variable name for storing API roots

Change from `rootApi` to `apiRoots` to indicate that the varible contains
multiple values

* feat(lib-util api): Add varialbe for storing endpoint constants

* feat(lib-util api): Change the returned value for `getDefaultBranchName`

Reason: There's no `null` value for default_branch

* feat(api test): Import Nock module for mocking API requests

* feat(api test): Add default values for mocking API

Default values include: default branch name, mock tokens and mock repo slug

* feat(api test): Add mock response to getting a single repo

* feat(api test): Add function for itnercepting request to get single repo

* feat(api test): Add test for gettingDefaultBranchName

* feat(lib-util): reformat codes

* feat(jest config): add moduleNameMapper for GitHub and BitBucket

Required for the test that checks setDefaultBranchName is called in lib-util to work

* feat(lib-util test): return some modules from backend core for testing

* feat(lib-util test): add owner login value for Github's repo response

The authenticate method of Github API wrapper extracts owner.login from repo resp

* feat(lib-util test): change access level for Gitlab to 30

Reason: If access level is under 30, Gitlab package will throw error

* feat(lib-util test): add mock response for getting a user

The authenticate method of each backend requires this

* feat(lib-util test): add default config for backend field

* feat(lib-util test): rewrite function for mocking API

* feat(lib-util test): rewrite function for mocking request for repo

* test(lib-util): rewrite test for the function getDefaultBranchName

* test(lib-util): add function for resolving backend

* test(lib-util): import 'set' module from Lodash

* test(lib-util): add helper for constructing url path for each backend

* test(lib-util): add function for intercepting API request to authenticate

* test(lib-util): import each backend module

* test(lib-util): add tests that check each backend calls getDefaultBranchName

* style: format files

* fix: query branch name before setting Github API service

* fix: reformat implementation module of Github backend

* fix: remove importing of getDefaultBranchName from lib

* fix: removed test for getDefaultBranchName in lib packages

* fix: removed unused vars in api test for lib package

* feat: retrieve default branch before creating Bitbucket AI instance

* fix: reformat codes in Bitbucket implementation module

* fix: add missing import

---------

Co-authored-by: Erez Rokah <erezrokah@users.noreply.github.com>
Co-authored-by: Martin Jagodic <jagodicmartin1@gmail.com>
Co-authored-by: Anze Demsar <anze.demsar@p-m.si>
  • Loading branch information
4 people committed Apr 3, 2024
1 parent c15463f commit c91a70f
Show file tree
Hide file tree
Showing 7 changed files with 228 additions and 2 deletions.
27 changes: 26 additions & 1 deletion packages/decap-cms-backend-bitbucket/src/implementation.ts
Expand Up @@ -60,6 +60,8 @@ type BitbucketStatusComponent = {
status: string;
};

const { fetchWithTimeout: fetch } = unsentRequest;

// Implementation wrapper class
export default class BitbucketBackend implements Implementation {
lock: AsyncLock;
Expand All @@ -72,6 +74,7 @@ export default class BitbucketBackend implements Implementation {
initialWorkflowStatus: string;
};
repo: string;
isBranchConfigured: boolean;
branch: string;
apiRoot: string;
baseUrl: string;
Expand Down Expand Up @@ -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 || '';
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down
22 changes: 21 additions & 1 deletion packages/decap-cms-backend-github/src/implementation.tsx
Expand Up @@ -69,6 +69,7 @@ export default class GitHub implements Implementation {
initialWorkflowStatus: string;
};
originRepo: string;
isBranchConfigured: boolean;
repo?: string;
openAuthoringEnabled: boolean;
useOpenAuthoring?: boolean;
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 };
}
Expand Down
10 changes: 10 additions & 0 deletions packages/decap-cms-backend-gitlab/src/__tests__/gitlab.spec.js
Expand Up @@ -109,6 +109,7 @@ const resp = {
access_level: 30,
},
},
default_branch: 'main',
},
readOnly: {
permissions: {
Expand Down Expand Up @@ -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);
}
Expand Down
13 changes: 13 additions & 0 deletions packages/decap-cms-backend-gitlab/src/implementation.ts
Expand Up @@ -21,6 +21,7 @@ import {
allEntriesByFolder,
filterByExtension,
branchFromContentKey,
getDefaultBranchName,
} from 'decap-cms-lib-util';

import AuthenticationPage from './AuthenticationPage';
Expand Down Expand Up @@ -53,6 +54,7 @@ export default class GitLab implements Implementation {
initialWorkflowStatus: string;
};
repo: string;
isBranchConfigured: boolean;
branch: string;
apiRoot: string;
token: string | null;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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 };
}
Expand Down
154 changes: 154 additions & 0 deletions packages/decap-cms-lib-util/src/API.ts
Expand Up @@ -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,
Expand Down Expand Up @@ -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<string, Param>;

type HeaderObj = Record<string, string>;

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<RequestInit, 'headers'> &
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<string | Blob>,
Expand Down
1 change: 1 addition & 0 deletions 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', () => {
Expand Down
3 changes: 3 additions & 0 deletions packages/decap-cms-lib-util/src/index.ts
Expand Up @@ -34,6 +34,7 @@ import {
getPreviewStatus,
PreviewState,
requestWithBackoff,
getDefaultBranchName,
throwOnConflictingBranches,
} from './API';
import {
Expand Down Expand Up @@ -148,6 +149,7 @@ export const DecapCmsLibUtil = {
contentKeyFromBranch,
blobToFileObj,
requestWithBackoff,
getDefaultBranchName,
allEntriesByFolder,
AccessTokenError,
throwOnConflictingBranches,
Expand Down Expand Up @@ -204,6 +206,7 @@ export {
contentKeyFromBranch,
blobToFileObj,
requestWithBackoff,
getDefaultBranchName,
allEntriesByFolder,
AccessTokenError,
throwOnConflictingBranches,
Expand Down

0 comments on commit c91a70f

Please sign in to comment.