feat: allow setting editorial workflow PR label prefix (#4181)

This commit is contained in:
Kancer (Nilay) Gökırmak 2020-09-06 20:13:46 +02:00 committed by GitHub
parent c0fc423040
commit 6b8fa3fc45
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
18 changed files with 170 additions and 44 deletions

View File

@ -41,6 +41,7 @@ interface Config {
hasWriteAccess?: () => Promise<boolean>; hasWriteAccess?: () => Promise<boolean>;
squashMerges: boolean; squashMerges: boolean;
initialWorkflowStatus: string; initialWorkflowStatus: string;
cmsLabelPrefix: string;
} }
interface CommitAuthor { interface CommitAuthor {
@ -203,6 +204,7 @@ export default class API {
commitAuthor?: CommitAuthor; commitAuthor?: CommitAuthor;
mergeStrategy: string; mergeStrategy: string;
initialWorkflowStatus: string; initialWorkflowStatus: string;
cmsLabelPrefix: string;
constructor(config: Config) { constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://api.bitbucket.org/2.0'; this.apiRoot = config.apiRoot || 'https://api.bitbucket.org/2.0';
@ -214,6 +216,7 @@ export default class API {
this.repoURL = this.repo ? `/repositories/${this.repo}` : ''; this.repoURL = this.repo ? `/repositories/${this.repo}` : '';
this.mergeStrategy = config.squashMerges ? 'squash' : 'merge_commit'; this.mergeStrategy = config.squashMerges ? 'squash' : 'merge_commit';
this.initialWorkflowStatus = config.initialWorkflowStatus; this.initialWorkflowStatus = config.initialWorkflowStatus;
this.cmsLabelPrefix = config.cmsLabelPrefix;
} }
buildRequest = (req: ApiRequest) => { buildRequest = (req: ApiRequest) => {
@ -554,7 +557,7 @@ export default class API {
}), }),
}); });
// use comments for status labels // use comments for status labels
await this.addPullRequestComment(pullRequest, statusToLabel(status)); await this.addPullRequestComment(pullRequest, statusToLabel(status, this.cmsLabelPrefix));
} }
async getDifferences(source: string, destination: string = this.branch) { async getDifferences(source: string, destination: string = this.branch) {
@ -656,7 +659,7 @@ export default class API {
pullRequests.values.map(pr => this.getPullRequestLabel(pr.id)), pullRequests.values.map(pr => this.getPullRequestLabel(pr.id)),
); );
return pullRequests.values.filter((_, index) => isCMSLabel(labels[index])); return pullRequests.values.filter((_, index) => isCMSLabel(labels[index], this.cmsLabelPrefix));
} }
async getBranchPullRequest(branch: string) { async getBranchPullRequest(branch: string) {
@ -686,7 +689,7 @@ export default class API {
const pullRequest = await this.getBranchPullRequest(branch); const pullRequest = await this.getBranchPullRequest(branch);
const diffs = await this.getDifferences(branch); const diffs = await this.getDifferences(branch);
const label = await this.getPullRequestLabel(pullRequest.id); const label = await this.getPullRequestLabel(pullRequest.id);
const status = labelToStatus(label); const status = labelToStatus(label, this.cmsLabelPrefix);
const updatedAt = pullRequest.updated_on; const updatedAt = pullRequest.updated_on;
return { return {
collection, collection,
@ -705,7 +708,7 @@ export default class API {
const branch = branchFromContentKey(contentKey); const branch = branchFromContentKey(contentKey);
const pullRequest = await this.getBranchPullRequest(branch); const pullRequest = await this.getBranchPullRequest(branch);
await this.addPullRequestComment(pullRequest, statusToLabel(newStatus)); await this.addPullRequestComment(pullRequest, statusToLabel(newStatus, this.cmsLabelPrefix));
} }
async mergePullRequest(pullRequest: BitBucketPullRequest) { async mergePullRequest(pullRequest: BitBucketPullRequest) {

View File

@ -78,6 +78,7 @@ export default class BitbucketBackend implements Implementation {
authenticator?: NetlifyAuthenticator; authenticator?: NetlifyAuthenticator;
_mediaDisplayURLSem?: Semaphore; _mediaDisplayURLSem?: Semaphore;
squashMerges: boolean; squashMerges: boolean;
cmsLabelPrefix: string;
previewContext: string; previewContext: string;
largeMediaURL: string; largeMediaURL: string;
_largeMediaClientPromise?: Promise<GitLfsClient>; _largeMediaClientPromise?: Promise<GitLfsClient>;
@ -113,6 +114,7 @@ export default class BitbucketBackend implements Implementation {
this.token = ''; this.token = '';
this.mediaFolder = config.media_folder; this.mediaFolder = config.media_folder;
this.squashMerges = config.backend.squash_merges || false; this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.previewContext = config.backend.preview_context || ''; this.previewContext = config.backend.preview_context || '';
this.lock = asyncLock(); this.lock = asyncLock();
this.authType = config.backend.auth_type || ''; this.authType = config.backend.auth_type || '';
@ -166,6 +168,7 @@ export default class BitbucketBackend implements Implementation {
branch: this.branch, branch: this.branch,
repo: this.repo, repo: this.repo,
squashMerges: this.squashMerges, squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus, initialWorkflowStatus: this.options.initialWorkflowStatus,
}); });
} }
@ -189,6 +192,7 @@ export default class BitbucketBackend implements Implementation {
repo: this.repo, repo: this.repo,
apiRoot: this.apiRoot, apiRoot: this.apiRoot,
squashMerges: this.squashMerges, squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus, initialWorkflowStatus: this.options.initialWorkflowStatus,
}); });

View File

@ -132,6 +132,7 @@ export default class GitGateway implements Implementation {
api?: GitHubAPI | GitLabAPI | BitBucketAPI; api?: GitHubAPI | GitLabAPI | BitBucketAPI;
branch: string; branch: string;
squashMerges: boolean; squashMerges: boolean;
cmsLabelPrefix: string;
mediaFolder: string; mediaFolder: string;
transformImages: boolean; transformImages: boolean;
gatewayUrl: string; gatewayUrl: string;
@ -159,6 +160,7 @@ export default class GitGateway implements Implementation {
this.config = config; this.config = config;
this.branch = config.backend.branch?.trim() || 'master'; this.branch = config.backend.branch?.trim() || 'master';
this.squashMerges = config.backend.squash_merges || false; this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.mediaFolder = config.media_folder; this.mediaFolder = config.media_folder;
this.transformImages = config.backend.use_large_media_transforms_in_media_library || true; this.transformImages = config.backend.use_large_media_transforms_in_media_library || true;
@ -332,6 +334,7 @@ export default class GitGateway implements Implementation {
tokenPromise: this.tokenPromise!, tokenPromise: this.tokenPromise!,
commitAuthor: pick(userData, ['name', 'email']), commitAuthor: pick(userData, ['name', 'email']),
squashMerges: this.squashMerges, squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus, initialWorkflowStatus: this.options.initialWorkflowStatus,
}; };

View File

@ -52,6 +52,7 @@ export interface Config {
originRepo?: string; originRepo?: string;
squashMerges: boolean; squashMerges: boolean;
initialWorkflowStatus: string; initialWorkflowStatus: string;
cmsLabelPrefix: string;
} }
interface TreeFile { interface TreeFile {
@ -132,8 +133,10 @@ type MediaFile = {
path: string; path: string;
}; };
const withCmsLabel = (pr: GitHubPull) => pr.labels.some(l => isCMSLabel(l.name)); const withCmsLabel = (pr: GitHubPull, cmsLabelPrefix: string) =>
const withoutCmsLabel = (pr: GitHubPull) => pr.labels.every(l => !isCMSLabel(l.name)); pr.labels.some(l => isCMSLabel(l.name, cmsLabelPrefix));
const withoutCmsLabel = (pr: GitHubPull, cmsLabelPrefix: string) =>
pr.labels.every(l => !isCMSLabel(l.name, cmsLabelPrefix));
const getTreeFiles = (files: GitHubCompareFiles) => { const getTreeFiles = (files: GitHubCompareFiles) => {
const treeFiles = files.reduce((arr, file) => { const treeFiles = files.reduce((arr, file) => {
@ -190,6 +193,7 @@ export default class API {
originRepoURL: string; originRepoURL: string;
mergeMethod: string; mergeMethod: string;
initialWorkflowStatus: string; initialWorkflowStatus: string;
cmsLabelPrefix: string;
_userPromise?: Promise<GitHubUser>; _userPromise?: Promise<GitHubUser>;
_metadataSemaphore?: Semaphore; _metadataSemaphore?: Semaphore;
@ -215,6 +219,7 @@ export default class API {
this.originRepoName = originRepoParts[1]; this.originRepoName = originRepoParts[1];
this.mergeMethod = config.squashMerges ? 'squash' : 'merge'; this.mergeMethod = config.squashMerges ? 'squash' : 'merge';
this.cmsLabelPrefix = config.cmsLabelPrefix;
this.initialWorkflowStatus = config.initialWorkflowStatus; this.initialWorkflowStatus = config.initialWorkflowStatus;
} }
@ -527,18 +532,18 @@ export default class API {
return { return {
head: { sha: data.commit.sha }, head: { sha: data.commit.sha },
number: MOCK_PULL_REQUEST, number: MOCK_PULL_REQUEST,
labels: [{ name: statusToLabel(this.initialWorkflowStatus) }], labels: [{ name: statusToLabel(this.initialWorkflowStatus, this.cmsLabelPrefix) }],
state: PullRequestState.Open, state: PullRequestState.Open,
} as GitHubPull; } as GitHubPull;
} catch (e) { } catch (e) {
throw new EditorialWorkflowError('content is not under editorial workflow', true); throw new EditorialWorkflowError('content is not under editorial workflow', true);
} }
} else { } else {
pullRequest.labels = pullRequest.labels.filter(l => !isCMSLabel(l.name)); pullRequest.labels = pullRequest.labels.filter(l => !isCMSLabel(l.name, this.cmsLabelPrefix));
const cmsLabel = const cmsLabel =
pullRequest.state === PullRequestState.Closed pullRequest.state === PullRequestState.Closed
? { name: statusToLabel(this.initialWorkflowStatus) } ? { name: statusToLabel(this.initialWorkflowStatus, this.cmsLabelPrefix) }
: { name: statusToLabel('pending_review') }; : { name: statusToLabel('pending_review', this.cmsLabelPrefix) };
pullRequest.labels.push(cmsLabel as Octokit.PullsGetResponseLabelsItem); pullRequest.labels.push(cmsLabel as Octokit.PullsGetResponseLabelsItem);
return pullRequest; return pullRequest;
@ -550,7 +555,9 @@ export default class API {
const pullRequests = await this.getPullRequests(branch, PullRequestState.All, () => true); const pullRequests = await this.getPullRequests(branch, PullRequestState.All, () => true);
return this.getOpenAuthoringPullRequest(branch, pullRequests); return this.getOpenAuthoringPullRequest(branch, pullRequests);
} else { } else {
const pullRequests = await this.getPullRequests(branch, PullRequestState.Open, withCmsLabel); const pullRequests = await this.getPullRequests(branch, PullRequestState.Open, pr =>
withCmsLabel(pr, this.cmsLabelPrefix),
);
if (pullRequests.length <= 0) { if (pullRequests.length <= 0) {
throw new EditorialWorkflowError('content is not under editorial workflow', true); throw new EditorialWorkflowError('content is not under editorial workflow', true);
} }
@ -579,8 +586,10 @@ export default class API {
const pullRequest = await this.getBranchPullRequest(branch); const pullRequest = await this.getBranchPullRequest(branch);
const { files } = await this.getDifferences(this.branch, pullRequest.head.sha); const { files } = await this.getDifferences(this.branch, pullRequest.head.sha);
const diffs = files.map(diffFromFile); const diffs = files.map(diffFromFile);
const label = pullRequest.labels.find(l => isCMSLabel(l.name)) as { name: string }; const label = pullRequest.labels.find(l => isCMSLabel(l.name, this.cmsLabelPrefix)) as {
const status = labelToStatus(label.name); name: string;
};
const status = labelToStatus(label.name, this.cmsLabelPrefix);
const updatedAt = pullRequest.updated_at; const updatedAt = pullRequest.updated_at;
return { return {
collection, collection,
@ -823,7 +832,7 @@ export default class API {
const pullRequests = await this.getPullRequests( const pullRequests = await this.getPullRequests(
undefined, undefined,
PullRequestState.Open, PullRequestState.Open,
pr => !pr.head.repo.fork && withoutCmsLabel(pr), pr => !pr.head.repo.fork && withoutCmsLabel(pr, this.cmsLabelPrefix),
); );
let prCount = 0; let prCount = 0;
for (const pr of pullRequests) { for (const pr of pullRequests) {
@ -838,10 +847,8 @@ export default class API {
prCount = prCount + 1; prCount = prCount + 1;
await this.migratePullRequest(pr, `${prCount} of ${pullRequests.length}`); await this.migratePullRequest(pr, `${prCount} of ${pullRequests.length}`);
} }
const cmsPullRequests = await this.getPullRequests( const cmsPullRequests = await this.getPullRequests(undefined, PullRequestState.Open, pr =>
undefined, withCmsLabel(pr, this.cmsLabelPrefix),
PullRequestState.Open,
withCmsLabel,
); );
branches = cmsPullRequests.map(pr => pr.head.ref); branches = cmsPullRequests.map(pr => pr.head.ref);
} }
@ -1098,8 +1105,10 @@ export default class API {
async setPullRequestStatus(pullRequest: GitHubPull, newStatus: string) { async setPullRequestStatus(pullRequest: GitHubPull, newStatus: string) {
const labels = [ const labels = [
...pullRequest.labels.filter(label => !isCMSLabel(label.name)).map(l => l.name), ...pullRequest.labels
statusToLabel(newStatus), .filter(label => !isCMSLabel(label.name, this.cmsLabelPrefix))
.map(l => l.name),
statusToLabel(newStatus, this.cmsLabelPrefix),
]; ];
await this.updatePullRequestLabels(pullRequest.number, labels); await this.updatePullRequestLabels(pullRequest.number, labels);
} }

View File

@ -51,6 +51,40 @@ describe('github API', () => {
})), })),
).resolves.toEqual({ prBaseBranch: 'gh-pages', labels: ['netlify-cms/draft'] }); ).resolves.toEqual({ prBaseBranch: 'gh-pages', labels: ['netlify-cms/draft'] });
}); });
it('should create PR with correct base branch name with custom prefix when publishing with editorial workflow', () => {
let prBaseBranch = null;
let labels = null;
const api = new API({
branch: 'gh-pages',
repo: 'owner/my-repo',
initialWorkflowStatus: 'draft',
cmsLabelPrefix: 'other/',
});
const responses = {
'/repos/owner/my-repo/branches/gh-pages': () => ({ commit: { sha: 'def' } }),
'/repos/owner/my-repo/git/trees/def': () => ({ tree: [] }),
'/repos/owner/my-repo/git/trees': () => ({}),
'/repos/owner/my-repo/git/commits': () => ({}),
'/repos/owner/my-repo/git/refs': () => ({}),
'/repos/owner/my-repo/pulls': req => {
prBaseBranch = JSON.parse(req.body).base;
return { head: { sha: 'cbd' }, labels: [], number: 1 };
},
'/repos/owner/my-repo/issues/1/labels': req => {
labels = JSON.parse(req.body).labels;
return {};
},
};
mockAPI(api, responses);
return expect(
api.editorialWorkflowGit([], { slug: 'entry', sha: 'abc' }, null, {}).then(() => ({
prBaseBranch,
labels,
})),
).resolves.toEqual({ prBaseBranch: 'gh-pages', labels: ['other/draft'] });
});
}); });
describe('updateTree', () => { describe('updateTree', () => {

View File

@ -72,6 +72,7 @@ export default class GitHub implements Implementation {
previewContext: string; previewContext: string;
token: string | null; token: string | null;
squashMerges: boolean; squashMerges: boolean;
cmsLabelPrefix: string;
useGraphql: boolean; useGraphql: boolean;
_currentUserPromise?: Promise<GitHubUser>; _currentUserPromise?: Promise<GitHubUser>;
_userIsOriginMaintainerPromises?: { _userIsOriginMaintainerPromises?: {
@ -111,6 +112,7 @@ export default class GitHub implements Implementation {
this.apiRoot = config.backend.api_root || 'https://api.github.com'; this.apiRoot = config.backend.api_root || 'https://api.github.com';
this.token = ''; this.token = '';
this.squashMerges = config.backend.squash_merges || false; this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.useGraphql = config.backend.use_graphql || false; this.useGraphql = config.backend.use_graphql || false;
this.mediaFolder = config.media_folder; this.mediaFolder = config.media_folder;
this.previewContext = config.backend.preview_context || ''; this.previewContext = config.backend.preview_context || '';
@ -297,6 +299,7 @@ export default class GitHub implements Implementation {
originRepo: this.originRepo, originRepo: this.originRepo,
apiRoot: this.apiRoot, apiRoot: this.apiRoot,
squashMerges: this.squashMerges, squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
useOpenAuthoring: this.useOpenAuthoring, useOpenAuthoring: this.useOpenAuthoring,
initialWorkflowStatus: this.options.initialWorkflowStatus, initialWorkflowStatus: this.options.initialWorkflowStatus,
}); });

View File

@ -41,6 +41,7 @@ export interface Config {
repo?: string; repo?: string;
squashMerges: boolean; squashMerges: boolean;
initialWorkflowStatus: string; initialWorkflowStatus: string;
cmsLabelPrefix: string;
} }
export interface CommitAuthor { export interface CommitAuthor {
@ -189,6 +190,7 @@ export default class API {
commitAuthor?: CommitAuthor; commitAuthor?: CommitAuthor;
squashMerges: boolean; squashMerges: boolean;
initialWorkflowStatus: string; initialWorkflowStatus: string;
cmsLabelPrefix: string;
constructor(config: Config) { constructor(config: Config) {
this.apiRoot = config.apiRoot || 'https://gitlab.com/api/v4'; this.apiRoot = config.apiRoot || 'https://gitlab.com/api/v4';
@ -198,6 +200,7 @@ export default class API {
this.repoURL = `/projects/${encodeURIComponent(this.repo)}`; this.repoURL = `/projects/${encodeURIComponent(this.repo)}`;
this.squashMerges = config.squashMerges; this.squashMerges = config.squashMerges;
this.initialWorkflowStatus = config.initialWorkflowStatus; this.initialWorkflowStatus = config.initialWorkflowStatus;
this.cmsLabelPrefix = config.cmsLabelPrefix;
} }
withAuthorizationHeaders = (req: ApiRequest) => { withAuthorizationHeaders = (req: ApiRequest) => {
@ -557,7 +560,9 @@ export default class API {
}); });
return mergeRequests.filter( return mergeRequests.filter(
mr => mr.source_branch.startsWith(CMS_BRANCH_PREFIX) && mr.labels.some(isCMSLabel), mr =>
mr.source_branch.startsWith(CMS_BRANCH_PREFIX) &&
mr.labels.some(l => isCMSLabel(l, this.cmsLabelPrefix)),
); );
} }
@ -658,8 +663,8 @@ export default class API {
return { id, path, newFile }; return { id, path, newFile };
}), }),
); );
const label = mergeRequest.labels.find(isCMSLabel) as string; const label = mergeRequest.labels.find(l => isCMSLabel(l, this.cmsLabelPrefix)) as string;
const status = labelToStatus(label); const status = labelToStatus(label, this.cmsLabelPrefix);
const updatedAt = mergeRequest.updated_at; const updatedAt = mergeRequest.updated_at;
return { return {
collection, collection,
@ -710,7 +715,7 @@ export default class API {
target_branch: this.branch, target_branch: this.branch,
title: commitMessage, title: commitMessage,
description: DEFAULT_PR_BODY, description: DEFAULT_PR_BODY,
labels: statusToLabel(status), labels: statusToLabel(status, this.cmsLabelPrefix),
// eslint-disable-next-line @typescript-eslint/camelcase // eslint-disable-next-line @typescript-eslint/camelcase
remove_source_branch: true, remove_source_branch: true,
squash: this.squashMerges, squash: this.squashMerges,
@ -771,8 +776,8 @@ export default class API {
const mergeRequest = await this.getBranchMergeRequest(branch); const mergeRequest = await this.getBranchMergeRequest(branch);
const labels = [ const labels = [
...mergeRequest.labels.filter(label => !isCMSLabel(label)), ...mergeRequest.labels.filter(label => !isCMSLabel(label, this.cmsLabelPrefix)),
statusToLabel(newStatus), statusToLabel(newStatus, this.cmsLabelPrefix),
]; ];
await this.updateMergeRequestLabels(mergeRequest, labels); await this.updateMergeRequestLabels(mergeRequest, labels);
} }

View File

@ -52,6 +52,7 @@ export default class GitLab implements Implementation {
apiRoot: string; apiRoot: string;
token: string | null; token: string | null;
squashMerges: boolean; squashMerges: boolean;
cmsLabelPrefix: string;
mediaFolder: string; mediaFolder: string;
previewContext: string; previewContext: string;
@ -79,6 +80,7 @@ export default class GitLab implements Implementation {
this.apiRoot = config.backend.api_root || 'https://gitlab.com/api/v4'; this.apiRoot = config.backend.api_root || 'https://gitlab.com/api/v4';
this.token = ''; this.token = '';
this.squashMerges = config.backend.squash_merges || false; this.squashMerges = config.backend.squash_merges || false;
this.cmsLabelPrefix = config.backend.cms_label_prefix || '';
this.mediaFolder = config.media_folder; this.mediaFolder = config.media_folder;
this.previewContext = config.backend.preview_context || ''; this.previewContext = config.backend.preview_context || '';
this.lock = asyncLock(); this.lock = asyncLock();
@ -117,6 +119,7 @@ export default class GitLab implements Implementation {
repo: this.repo, repo: this.repo,
apiRoot: this.apiRoot, apiRoot: this.apiRoot,
squashMerges: this.squashMerges, squashMerges: this.squashMerges,
cmsLabelPrefix: this.cmsLabelPrefix,
initialWorkflowStatus: this.options.initialWorkflowStatus, initialWorkflowStatus: this.options.initialWorkflowStatus,
}); });
const user = await this.api.user(); const user = await this.api.user();

View File

@ -50,6 +50,7 @@ export default class ProxyBackend implements Implementation {
mediaFolder: string; mediaFolder: string;
options: { initialWorkflowStatus?: string }; options: { initialWorkflowStatus?: string };
branch: string; branch: string;
cmsLabelPrefix?: string;
constructor(config: Config, options = {}) { constructor(config: Config, options = {}) {
if (!config.backend.proxy_url) { if (!config.backend.proxy_url) {
@ -60,6 +61,7 @@ export default class ProxyBackend implements Implementation {
this.proxyUrl = config.backend.proxy_url; this.proxyUrl = config.backend.proxy_url;
this.mediaFolder = config.media_folder; this.mediaFolder = config.media_folder;
this.options = options; this.options = options;
this.cmsLabelPrefix = config.backend.cms_label_prefix;
} }
isGitBackend() { isGitBackend() {
@ -146,7 +148,7 @@ export default class ProxyBackend implements Implementation {
try { try {
const entry: UnpublishedEntry = await this.request({ const entry: UnpublishedEntry = await this.request({
action: 'unpublishedEntry', action: 'unpublishedEntry',
params: { branch: this.branch, id, collection, slug }, params: { branch: this.branch, id, collection, slug, cmsLabelPrefix: this.cmsLabelPrefix },
}); });
return entry; return entry;
@ -190,6 +192,7 @@ export default class ProxyBackend implements Implementation {
entry, entry,
assets, assets,
options: { ...options, status: options.status || this.options.initialWorkflowStatus }, options: { ...options, status: options.status || this.options.initialWorkflowStatus },
cmsLabelPrefix: this.cmsLabelPrefix,
}, },
}); });
} }
@ -197,7 +200,13 @@ export default class ProxyBackend implements Implementation {
updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) { updateUnpublishedEntryStatus(collection: string, slug: string, newStatus: string) {
return this.request({ return this.request({
action: 'updateUnpublishedEntryStatus', action: 'updateUnpublishedEntryStatus',
params: { branch: this.branch, collection, slug, newStatus }, params: {
branch: this.branch,
collection,
slug,
newStatus,
cmsLabelPrefix: this.cmsLabelPrefix,
},
}); });
} }

View File

@ -213,6 +213,7 @@ declare module 'netlify-cms-core' {
site_domain?: string; site_domain?: string;
base_url?: string; base_url?: string;
auth_endpoint?: string; auth_endpoint?: string;
cms_label_prefix?: string;
} }
export interface CmsSlug { export interface CmsSlug {

View File

@ -78,6 +78,7 @@ const getConfigSchema = () => ({
examples: ['repo', 'public_repo'], examples: ['repo', 'public_repo'],
enum: ['repo', 'public_repo'], enum: ['repo', 'public_repo'],
}, },
cms_label_prefix: { type: 'string', minLength: 1 },
open_authoring: { type: 'boolean', examples: [true] }, open_authoring: { type: 'boolean', examples: [true] },
}, },
required: ['name'], required: ['name'],

View File

@ -2,10 +2,15 @@ export const CMS_BRANCH_PREFIX = 'cms';
export const DEFAULT_PR_BODY = 'Automatically generated by Netlify CMS'; export const DEFAULT_PR_BODY = 'Automatically generated by Netlify CMS';
export const MERGE_COMMIT_MESSAGE = 'Automatically generated. Merged on Netlify CMS.'; export const MERGE_COMMIT_MESSAGE = 'Automatically generated. Merged on Netlify CMS.';
const NETLIFY_CMS_LABEL_PREFIX = 'netlify-cms/'; const DEFAULT_NETLIFY_CMS_LABEL_PREFIX = 'netlify-cms/';
export const isCMSLabel = (label: string) => label.startsWith(NETLIFY_CMS_LABEL_PREFIX); const getLabelPrefix = (labelPrefix: string) => labelPrefix || DEFAULT_NETLIFY_CMS_LABEL_PREFIX;
export const labelToStatus = (label: string) => label.substr(NETLIFY_CMS_LABEL_PREFIX.length);
export const statusToLabel = (status: string) => `${NETLIFY_CMS_LABEL_PREFIX}${status}`; export const isCMSLabel = (label: string, labelPrefix: string) =>
label.startsWith(getLabelPrefix(labelPrefix));
export const labelToStatus = (label: string, labelPrefix: string) =>
label.substr(getLabelPrefix(labelPrefix).length);
export const statusToLabel = (status: string, labelPrefix: string) =>
`${getLabelPrefix(labelPrefix)}${status}`;
export const generateContentKey = (collectionName: string, slug: string) => export const generateContentKey = (collectionName: string, slug: string) =>
`${collectionName}/${slug}`; `${collectionName}/${slug}`;

View File

@ -19,23 +19,56 @@ describe('APIUtils', () => {
describe('isCMSLabel', () => { describe('isCMSLabel', () => {
it('should return true for CMS label', () => { it('should return true for CMS label', () => {
expect(apiUtils.isCMSLabel('netlify-cms/draft')).toBe(true); expect(apiUtils.isCMSLabel('netlify-cms/draft', 'netlify-cms/')).toBe(true);
}); });
it('should return false for non CMS label', () => { it('should return false for non CMS label', () => {
expect(apiUtils.isCMSLabel('other/label')).toBe(false); expect(apiUtils.isCMSLabel('other/label', 'netlify-cms/')).toBe(false);
});
it('should return true if the prefix not provided for CMS label', () => {
expect(apiUtils.isCMSLabel('netlify-cms/draft', '')).toBe(true);
});
it('should return false if a different prefix provided for CMS label', () => {
expect(apiUtils.isCMSLabel('netlify-cms/draft', 'other/')).toBe(false);
});
it('should return true for CMS label when undefined prefix is passed', () => {
expect(apiUtils.isCMSLabel('netlify-cms/draft', undefined)).toBe(true);
}); });
}); });
describe('labelToStatus', () => { describe('labelToStatus', () => {
it('should get status from label', () => { it('should get status from label when default prefix is passed', () => {
expect(apiUtils.labelToStatus('netlify-cms/draft')).toBe('draft'); expect(apiUtils.labelToStatus('netlify-cms/draft', 'netlify-cms/')).toBe('draft');
});
it('should get status from label when custom prefix is passed', () => {
expect(apiUtils.labelToStatus('other/draft', 'other/')).toBe('draft');
});
it('should get status from label when empty prefix is passed', () => {
expect(apiUtils.labelToStatus('netlify-cms/draft', '')).toBe('draft');
});
it('should get status from label when undefined prefix is passed', () => {
expect(apiUtils.labelToStatus('netlify-cms/draft', undefined)).toBe('draft');
}); });
}); });
describe('statusToLabel', () => { describe('statusToLabel', () => {
it('should generate label from status', () => { it('should generate label from status when default prefix is passed', () => {
expect(apiUtils.statusToLabel('draft')).toBe('netlify-cms/draft'); expect(apiUtils.statusToLabel('draft', 'netlify-cms/')).toBe('netlify-cms/draft');
});
it('should generate label from status when custom prefix is passed', () => {
expect(apiUtils.statusToLabel('draft', 'other/')).toBe('other/draft');
});
it('should generate label from status when empty prefix is passed', () => {
expect(apiUtils.statusToLabel('draft', '')).toBe('netlify-cms/draft');
});
it('should generate label from status when undefined prefix is passed', () => {
expect(apiUtils.statusToLabel('draft', undefined)).toBe('netlify-cms/draft');
}); });
}); });
}); });

View File

@ -89,6 +89,7 @@ export type Config = {
proxy_url?: string; proxy_url?: string;
auth_type?: string; auth_type?: string;
app_id?: string; app_id?: string;
cms_label_prefix?: string;
}; };
media_folder: string; media_folder: string;
base_url?: string; base_url?: string;

View File

@ -82,6 +82,7 @@ export const defaultSchema = ({ path = requiredString } = {}) => {
id: Joi.string().optional(), id: Joi.string().optional(),
collection: Joi.string().optional(), collection: Joi.string().optional(),
slug: Joi.string().optional(), slug: Joi.string().optional(),
cmsLabelPrefix: Joi.string().optional(),
}) })
.required(), .required(),
}, },
@ -120,6 +121,7 @@ export const defaultSchema = ({ path = requiredString } = {}) => {
is: 'persistEntry', is: 'persistEntry',
then: defaultParams then: defaultParams
.keys({ .keys({
cmsLabelPrefix: Joi.string().optional(),
entry: Joi.object({ entry: Joi.object({
slug: requiredString, slug: requiredString,
path, path,
@ -145,6 +147,7 @@ export const defaultSchema = ({ path = requiredString } = {}) => {
collection, collection,
slug, slug,
newStatus: requiredString, newStatus: requiredString,
cmsLabelPrefix: Joi.string().optional(),
}) })
.required(), .required(),
}, },

View File

@ -222,7 +222,7 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
break; break;
} }
case 'unpublishedEntry': { case 'unpublishedEntry': {
let { id, collection, slug } = body.params as UnpublishedEntryParams; let { id, collection, slug, cmsLabelPrefix } = body.params as UnpublishedEntryParams;
if (id) { if (id) {
({ collection, slug } = parseContentKey(id)); ({ collection, slug } = parseContentKey(id));
} }
@ -232,7 +232,7 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
if (branchExists) { if (branchExists) {
const diffs = await getDiffs(git, branch, cmsBranch); const diffs = await getDiffs(git, branch, cmsBranch);
const label = await git.raw(['config', branchDescription(cmsBranch)]); const label = await git.raw(['config', branchDescription(cmsBranch)]);
const status = label && labelToStatus(label.trim()); const status = label && labelToStatus(label.trim(), cmsLabelPrefix || '');
const unpublishedEntry = { const unpublishedEntry = {
collection, collection,
slug, slug,
@ -276,7 +276,7 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
break; break;
} }
case 'persistEntry': { case 'persistEntry': {
const { entry, assets, options } = body.params as PersistEntryParams; const { entry, assets, options, cmsLabelPrefix } = body.params as PersistEntryParams;
if (!options.useWorkflow) { if (!options.useWorkflow) {
await runOnBranch(git, branch, async () => { await runOnBranch(git, branch, async () => {
await commitEntry(git, repoPath, entry, assets, options.commitMessage); await commitEntry(git, repoPath, entry, assets, options.commitMessage);
@ -304,7 +304,7 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
// add status for new entries // add status for new entries
if (!branchExists) { if (!branchExists) {
const description = statusToLabel(options.status); const description = statusToLabel(options.status, cmsLabelPrefix || '');
await git.addConfig(branchDescription(cmsBranch), description); await git.addConfig(branchDescription(cmsBranch), description);
} }
}); });
@ -313,10 +313,15 @@ export const localGitMiddleware = ({ repoPath, logger }: GitOptions) => {
break; break;
} }
case 'updateUnpublishedEntryStatus': { case 'updateUnpublishedEntryStatus': {
const { collection, slug, newStatus } = body.params as UpdateUnpublishedEntryStatusParams; const {
collection,
slug,
newStatus,
cmsLabelPrefix,
} = body.params as UpdateUnpublishedEntryStatusParams;
const contentKey = generateContentKey(collection, slug); const contentKey = generateContentKey(collection, slug);
const cmsBranch = branchFromContentKey(contentKey); const cmsBranch = branchFromContentKey(contentKey);
const description = statusToLabel(newStatus); const description = statusToLabel(newStatus, cmsLabelPrefix || '');
await git.addConfig(branchDescription(cmsBranch), description); await git.addConfig(branchDescription(cmsBranch), description);
res.json({ message: `${branch} description was updated to ${description}` }); res.json({ message: `${branch} description was updated to ${description}` });
break; break;

View File

@ -20,6 +20,7 @@ export type UnpublishedEntryParams = {
id?: string; id?: string;
collection?: string; collection?: string;
slug?: string; slug?: string;
cmsLabelPrefix?: string;
}; };
export type UnpublishedEntryDataFileParams = { export type UnpublishedEntryDataFileParams = {
@ -45,6 +46,7 @@ export type UpdateUnpublishedEntryStatusParams = {
collection: string; collection: string;
slug: string; slug: string;
newStatus: string; newStatus: string;
cmsLabelPrefix?: string;
}; };
export type PublishUnpublishedEntryParams = { export type PublishUnpublishedEntryParams = {
@ -57,6 +59,7 @@ export type Entry = { slug: string; path: string; raw: string; newPath?: string
export type Asset = { path: string; content: string; encoding: 'base64' }; export type Asset = { path: string; content: string; encoding: 'base64' };
export type PersistEntryParams = { export type PersistEntryParams = {
cmsLabelPrefix?: string;
entry: Entry; entry: Entry;
assets: Asset[]; assets: Asset[];
options: { options: {

View File

@ -18,6 +18,7 @@ Individual backends should provide their own configuration documentation, but th
| `site_domain` | `location.hostname` (or `cms.netlify.com` when on `localhost`) | Sets the `site_id` query param sent to the API endpoint. Non-Netlify auth setups will often need to set this for local development to work properly. | | `site_domain` | `location.hostname` (or `cms.netlify.com` when on `localhost`) | Sets the `site_id` query param sent to the API endpoint. Non-Netlify auth setups will often need to set this for local development to work properly. |
| `base_url` | `https://api.netlify.com` (GitHub, Bitbucket) or `https://gitlab.com` (GitLab) | OAuth client hostname (just the base domain, no path). **Required** when using an external OAuth server or self-hosted GitLab. | | `base_url` | `https://api.netlify.com` (GitHub, Bitbucket) or `https://gitlab.com` (GitLab) | OAuth client hostname (just the base domain, no path). **Required** when using an external OAuth server or self-hosted GitLab. |
| `auth_endpoint` | `auth` (GitHub, Bitbucket) or `oauth/authorize` (GitLab) | Path to append to `base_url` for authentication requests. Optional. | | `auth_endpoint` | `auth` (GitHub, Bitbucket) or `oauth/authorize` (GitLab) | Path to append to `base_url` for authentication requests. Optional. |
| `cms_label_prefix` | `netlify-cms/` | Pull (or Merge) Requests label prefix when using editorial workflow. Optional. |
## Creating a New Backend ## Creating a New Backend