Skip to content

Commit 61dadcc

Browse files
bytrangleerezrokahmartinjagodicdemshy
authored andcommitted
Fix: Set correct branch when it's not specified in the config (#5844)
* 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 <[email protected]> Co-authored-by: Martin Jagodic <[email protected]> Co-authored-by: Anze Demsar <[email protected]>
1 parent ac6e8f0 commit 61dadcc

File tree

7 files changed

+228
-2
lines changed

7 files changed

+228
-2
lines changed

packages/decap-cms-backend-bitbucket/src/implementation.ts

+26-1
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,8 @@ type BitbucketStatusComponent = {
6060
status: string;
6161
};
6262

63+
const { fetchWithTimeout: fetch } = unsentRequest;
64+
6365
// Implementation wrapper class
6466
export default class BitbucketBackend implements Implementation {
6567
lock: AsyncLock;
@@ -72,6 +74,7 @@ export default class BitbucketBackend implements Implementation {
7274
initialWorkflowStatus: string;
7375
};
7476
repo: string;
77+
isBranchConfigured: boolean;
7578
branch: string;
7679
apiRoot: string;
7780
baseUrl: string;
@@ -111,6 +114,7 @@ export default class BitbucketBackend implements Implementation {
111114

112115
this.repo = config.backend.repo || '';
113116
this.branch = config.backend.branch || 'master';
117+
this.isBranchConfigured = config.backend.branch ? true : false;
114118
this.apiRoot = config.backend.api_root || 'https://api.bitbucket.org/2.0';
115119
this.baseUrl = config.base_url || '';
116120
this.siteId = config.site_id || '';
@@ -190,6 +194,18 @@ export default class BitbucketBackend implements Implementation {
190194

191195
async authenticate(state: Credentials) {
192196
this.token = state.token as string;
197+
if (!this.isBranchConfigured) {
198+
const repo = await fetch(`${this.apiRoot}/repositories/${this.repo}`, {
199+
headers: {
200+
Authorization: `token ${this.token}`,
201+
},
202+
})
203+
.then(res => res.json())
204+
.catch(() => null);
205+
if (repo) {
206+
this.branch = repo.mainbranch.name;
207+
}
208+
}
193209
this.refreshToken = state.refresh_token;
194210
this.api = new API({
195211
requestFunction: this.apiRequestFunction,
@@ -216,7 +232,16 @@ export default class BitbucketBackend implements Implementation {
216232
if (!isCollab) {
217233
throw new Error('Your BitBucket user account does not have access to this repo.');
218234
}
219-
235+
// if (!this.isBranchConfigured) {
236+
// const defaultBranchName = await getDefaultBranchName({
237+
// backend: 'bitbucket',
238+
// repo: this.repo,
239+
// token: this.token,
240+
// });
241+
// if (defaultBranchName) {
242+
// this.branch = defaultBranchName;
243+
// }
244+
// }
220245
const user = await this.api.user();
221246

222247
// Authorized user

packages/decap-cms-backend-github/src/implementation.tsx

+21-1
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ export default class GitHub implements Implementation {
6969
initialWorkflowStatus: string;
7070
};
7171
originRepo: string;
72+
isBranchConfigured: boolean;
7273
repo?: string;
7374
openAuthoringEnabled: boolean;
7475
useOpenAuthoring?: boolean;
@@ -106,7 +107,7 @@ export default class GitHub implements Implementation {
106107
}
107108

108109
this.api = this.options.API || null;
109-
110+
this.isBranchConfigured = config.backend.branch ? true : false;
110111
this.openAuthoringEnabled = config.backend.open_authoring || false;
111112
if (this.openAuthoringEnabled) {
112113
if (!this.options.useWorkflow) {
@@ -320,6 +321,18 @@ export default class GitHub implements Implementation {
320321

321322
async authenticate(state: Credentials) {
322323
this.token = state.token as string;
324+
// Query the default branch name when the `branch` property is missing
325+
// in the config file
326+
if (!this.isBranchConfigured) {
327+
const repoInfo = await fetch(`${this.apiRoot}/repos/${this.originRepo}`, {
328+
headers: { Authorization: `token ${this.token}` },
329+
})
330+
.then(res => res.json())
331+
.catch(() => null);
332+
if (repoInfo && repoInfo.default_branch) {
333+
this.branch = repoInfo.default_branch;
334+
}
335+
}
323336
const apiCtor = this.useGraphql ? GraphQLAPI : API;
324337
this.api = new apiCtor({
325338
token: this.token,
@@ -354,6 +367,13 @@ export default class GitHub implements Implementation {
354367
throw new Error('Your GitHub user account does not have access to this repo.');
355368
}
356369

370+
// if (!this.isBranchConfigured) {
371+
// const defaultBranchName = await this.api.getDefaultBranchName()
372+
// if (defaultBranchName) {
373+
// this.branch = defaultBranchName;
374+
// }
375+
// }
376+
357377
// Authorized user
358378
return { ...user, token: state.token as string, useOpenAuthoring: this.useOpenAuthoring };
359379
}

packages/decap-cms-backend-gitlab/src/__tests__/gitlab.spec.js

+10
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ const resp = {
109109
access_level: 30,
110110
},
111111
},
112+
default_branch: 'main',
112113
},
113114
readOnly: {
114115
permissions: {
@@ -194,7 +195,16 @@ describe('gitlab backend', () => {
194195
.reply(200, userResponse || resp.user.success);
195196

196197
api
198+
// The `authenticate` method of the API class from netlify-cms-backend-gitlab
199+
// calls the same endpoint twice for gettng a single project.
200+
// First time through `this.api.hasWriteAccess()
201+
// Second time through the method `getDefaultBranchName` from lib-util
202+
// As a result, we need to repeat the same response twice.
203+
// Otherwise, we'll get an error: "No match for request to
204+
// https://gitlab.com/api/v4"
205+
197206
.get(expectedRepoUrl)
207+
.times(2)
198208
.query(true)
199209
.reply(200, projectResponse || resp.project.success);
200210
}

packages/decap-cms-backend-gitlab/src/implementation.ts

+13
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import {
2121
allEntriesByFolder,
2222
filterByExtension,
2323
branchFromContentKey,
24+
getDefaultBranchName,
2425
} from 'decap-cms-lib-util';
2526

2627
import AuthenticationPage from './AuthenticationPage';
@@ -53,6 +54,7 @@ export default class GitLab implements Implementation {
5354
initialWorkflowStatus: string;
5455
};
5556
repo: string;
57+
isBranchConfigured: boolean;
5658
branch: string;
5759
apiRoot: string;
5860
token: string | null;
@@ -84,6 +86,7 @@ export default class GitLab implements Implementation {
8486

8587
this.repo = config.backend.repo || '';
8688
this.branch = config.backend.branch || 'master';
89+
this.isBranchConfigured = config.backend.branch ? true : false;
8790
this.apiRoot = config.backend.api_root || 'https://gitlab.com/api/v4';
8891
this.token = '';
8992
this.squashMerges = config.backend.squash_merges || false;
@@ -150,6 +153,16 @@ export default class GitLab implements Implementation {
150153
throw new Error('Your GitLab user account does not have access to this repo.');
151154
}
152155

156+
if (!this.isBranchConfigured) {
157+
const defaultBranchName = await getDefaultBranchName({
158+
backend: 'gitlab',
159+
repo: this.repo,
160+
token: this.token,
161+
});
162+
if (defaultBranchName) {
163+
this.branch = defaultBranchName;
164+
}
165+
}
153166
// Authorized user
154167
return { ...user, login: user.username, token: state.token as string };
155168
}

packages/decap-cms-lib-util/src/API.ts

+154
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,26 @@ class RateLimitError extends Error {
4040
}
4141
}
4242

43+
async function parseJsonResponse(response: Response) {
44+
const json = await response.json();
45+
if (!response.ok) {
46+
return Promise.reject(json);
47+
}
48+
return json;
49+
}
50+
51+
export function parseResponse(response: Response) {
52+
const contentType = response.headers.get('Content-Type');
53+
if (contentType && contentType.match(/json/)) {
54+
return parseJsonResponse(response);
55+
}
56+
const textPromise = response.text().then(text => {
57+
if (!response.ok) return Promise.reject(text);
58+
return text;
59+
});
60+
return textPromise;
61+
}
62+
4363
export async function requestWithBackoff(
4464
api: API,
4565
req: ApiRequest,
@@ -96,6 +116,140 @@ export async function requestWithBackoff(
96116
}
97117
}
98118

119+
// Options is an object which contains all the standard network request properties
120+
// for modifying HTTP requests and may contains `params` property
121+
122+
type Param = string | number;
123+
124+
type ParamObject = Record<string, Param>;
125+
126+
type HeaderObj = Record<string, string>;
127+
128+
type HeaderConfig = {
129+
headers?: HeaderObj;
130+
token?: string | undefined;
131+
};
132+
133+
type Backend = 'github' | 'gitlab' | 'bitbucket';
134+
135+
// RequestConfig contains all the standard properties of a Request object and
136+
// several custom properties:
137+
// - "headers" property is an object whose properties and values are string types
138+
// - `token` property to allow passing tokens for users using a private repo.
139+
// - `params` property for customizing response
140+
// - `backend`(compulsory) to specify which backend to be used: Github, Gitlab etc.
141+
142+
type RequestConfig = Omit<RequestInit, 'headers'> &
143+
HeaderConfig & {
144+
backend: Backend;
145+
params?: ParamObject;
146+
};
147+
148+
export const apiRoots = {
149+
github: 'https://api.github.com',
150+
gitlab: 'https://gitlab.com/api/v4',
151+
bitbucket: 'https://api.bitbucket.org/2.0',
152+
};
153+
154+
export const endpointConstants = {
155+
singleRepo: {
156+
bitbucket: '/repositories',
157+
github: '/repos',
158+
gitlab: '/projects',
159+
},
160+
};
161+
162+
const api = {
163+
buildRequest(req: ApiRequest) {
164+
return req;
165+
},
166+
};
167+
168+
function constructUrlWithParams(url: string, params?: ParamObject) {
169+
if (params) {
170+
const paramList = [];
171+
for (const key in params) {
172+
paramList.push(`${key}=${encodeURIComponent(params[key])}`);
173+
}
174+
if (paramList.length) {
175+
url += `?${paramList.join('&')}`;
176+
}
177+
}
178+
return url;
179+
}
180+
181+
async function constructRequestHeaders(headerConfig: HeaderConfig) {
182+
const { token, headers } = headerConfig;
183+
const baseHeaders: HeaderObj = { 'Content-Type': 'application/json; charset=utf-8', ...headers };
184+
if (token) {
185+
baseHeaders['Authorization'] = `token ${token}`;
186+
}
187+
return Promise.resolve(baseHeaders);
188+
}
189+
190+
function handleRequestError(error: FetchError, responseStatus: number, backend: Backend) {
191+
throw new APIError(error.message, responseStatus, backend);
192+
}
193+
194+
export async function apiRequest(
195+
path: string,
196+
config: RequestConfig,
197+
parser = (response: Response) => parseResponse(response),
198+
) {
199+
const { token, backend, ...props } = config;
200+
const options = { cache: 'no-cache', ...props };
201+
const headers = await constructRequestHeaders({ headers: options.headers || {}, token });
202+
const baseUrl = apiRoots[backend];
203+
const url = constructUrlWithParams(`${baseUrl}${path}`, options.params);
204+
let responseStatus = 500;
205+
try {
206+
const req = unsentRequest.fromFetchArguments(url, {
207+
...options,
208+
headers,
209+
}) as unknown as ApiRequest;
210+
const response = await requestWithBackoff(api, req);
211+
responseStatus = response.status;
212+
const parsedResponse = await parser(response);
213+
return parsedResponse;
214+
} catch (error) {
215+
return handleRequestError(error, responseStatus, backend);
216+
}
217+
}
218+
219+
export async function getDefaultBranchName(configs: {
220+
backend: Backend;
221+
repo: string;
222+
token?: string;
223+
}) {
224+
let apiPath;
225+
const { token, backend, repo } = configs;
226+
switch (backend) {
227+
case 'gitlab': {
228+
apiPath = `/projects/${encodeURIComponent(repo)}`;
229+
break;
230+
}
231+
case 'bitbucket': {
232+
apiPath = `/repositories/${repo}`;
233+
break;
234+
}
235+
default: {
236+
apiPath = `/repos/${repo}`;
237+
}
238+
}
239+
const repoInfo = await apiRequest(apiPath, { token, backend });
240+
let defaultBranchName;
241+
if (backend === 'bitbucket') {
242+
const {
243+
mainbranch: { name },
244+
} = repoInfo;
245+
defaultBranchName = name;
246+
} else {
247+
const { default_branch } = repoInfo;
248+
defaultBranchName = default_branch;
249+
}
250+
return defaultBranchName;
251+
}
252+
99253
export async function readFile(
100254
id: string | null | undefined,
101255
fetchContent: () => Promise<string | Blob>,

packages/decap-cms-lib-util/src/__tests__/api.spec.js

+1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import * as api from '../API';
2+
23
describe('Api', () => {
34
describe('getPreviewStatus', () => {
45
it('should return preview status on matching context', () => {

packages/decap-cms-lib-util/src/index.ts

+3
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
getPreviewStatus,
3535
PreviewState,
3636
requestWithBackoff,
37+
getDefaultBranchName,
3738
throwOnConflictingBranches,
3839
} from './API';
3940
import {
@@ -148,6 +149,7 @@ export const DecapCmsLibUtil = {
148149
contentKeyFromBranch,
149150
blobToFileObj,
150151
requestWithBackoff,
152+
getDefaultBranchName,
151153
allEntriesByFolder,
152154
AccessTokenError,
153155
throwOnConflictingBranches,
@@ -204,6 +206,7 @@ export {
204206
contentKeyFromBranch,
205207
blobToFileObj,
206208
requestWithBackoff,
209+
getDefaultBranchName,
207210
allEntriesByFolder,
208211
AccessTokenError,
209212
throwOnConflictingBranches,

0 commit comments

Comments
 (0)