diff --git a/packages/netlify-cms-backend-bitbucket/src/API.js b/packages/netlify-cms-backend-bitbucket/src/API.js index 77059ed8..efe54d29 100644 --- a/packages/netlify-cms-backend-bitbucket/src/API.js +++ b/packages/netlify-cms-backend-bitbucket/src/API.js @@ -45,7 +45,7 @@ export default class API { p => p.catch(err => Promise.reject(new APIError(err.message, null, 'BitBucket'))), ])(req); - user = () => this.request('/user'); + user = () => this.requestJSON('/user'); hasWriteAccess = async () => { const response = await this.request(this.repoURL); diff --git a/packages/netlify-cms-backend-bitbucket/src/implementation.js b/packages/netlify-cms-backend-bitbucket/src/implementation.js index b85bda12..316e3e82 100644 --- a/packages/netlify-cms-backend-bitbucket/src/implementation.js +++ b/packages/netlify-cms-backend-bitbucket/src/implementation.js @@ -72,8 +72,7 @@ export default class BitbucketBackend { api_root: this.api_root, }); - const user = await this.api.user(); - const isCollab = await this.api.hasWriteAccess(user).catch(error => { + const isCollab = await this.api.hasWriteAccess().catch(error => { error.message = stripIndent` Repo "${this.repo}" not found. @@ -89,8 +88,16 @@ export default class BitbucketBackend { throw new Error('Your BitBucket user account does not have access to this repo.'); } + const user = await this.api.user(); + // Authorized user - return { ...user, token: state.token, refresh_token: state.refresh_token }; + return { + ...user, + name: user.display_name, + login: user.username, + token: state.token, + refresh_token: state.refresh_token, + }; } getRefreshedAccessToken() { diff --git a/packages/netlify-cms-backend-git-gateway/src/implementation.js b/packages/netlify-cms-backend-git-gateway/src/implementation.js index 43f4ec3f..7e3dfe58 100644 --- a/packages/netlify-cms-backend-git-gateway/src/implementation.js +++ b/packages/netlify-cms-backend-git-gateway/src/implementation.js @@ -174,6 +174,7 @@ export default class GitGateway { if (!(await this.api.hasWriteAccess())) { throw new Error("You don't have sufficient permissions to access Netlify CMS"); } + return { name: userData.name, login: userData.email }; }); } restoreUser() { diff --git a/packages/netlify-cms-backend-gitlab/src/implementation.js b/packages/netlify-cms-backend-gitlab/src/implementation.js index 45395272..c15564ad 100644 --- a/packages/netlify-cms-backend-gitlab/src/implementation.js +++ b/packages/netlify-cms-backend-gitlab/src/implementation.js @@ -66,7 +66,7 @@ export default class GitLab { } // Authorized user - return { ...user, token: state.token }; + return { ...user, login: user.username, token: state.token }; } logout() { diff --git a/packages/netlify-cms-core/src/backend.js b/packages/netlify-cms-core/src/backend.js index c1a88c45..3e8074d5 100644 --- a/packages/netlify-cms-core/src/backend.js +++ b/packages/netlify-cms-core/src/backend.js @@ -1,5 +1,4 @@ import { attempt, flatten, isError, trimStart, trimEnd, flow, partialRight, uniq } from 'lodash'; -import { Map } from 'immutable'; import { stripIndent } from 'common-tags'; import fuzzy from 'fuzzy'; import { resolveFormat } from 'Formats/formats'; @@ -17,6 +16,7 @@ import { import { createEntry } from 'ValueObjects/Entry'; import { sanitizeSlug } from 'Lib/urlHelper'; import { getBackend } from 'Lib/registry'; +import { commitMessageFormatter } from 'Lib/backendHelper'; import { localForage, Cursor, @@ -100,34 +100,6 @@ function slugFormatter(collection, entryData, slugConfig) { return processSlug(template, new Date(), identifier, entryData); } -const commitMessageTemplates = Map({ - create: 'Create {{collection}} “{{slug}}”', - update: 'Update {{collection}} “{{slug}}”', - delete: 'Delete {{collection}} “{{slug}}”', - uploadMedia: 'Upload “{{path}}”', - deleteMedia: 'Delete “{{path}}”', -}); - -const commitMessageFormatter = (type, config, { slug, path, collection }) => { - const templates = commitMessageTemplates.merge( - config.getIn(['backend', 'commit_messages'], Map()), - ); - const messageTemplate = templates.get(type); - return messageTemplate.replace(/\{\{([^}]+)\}\}/g, (_, variable) => { - switch (variable) { - case 'slug': - return slug; - case 'path': - return path; - case 'collection': - return collection.get('label_singular') || collection.get('label'); - default: - console.warn(`Ignoring unknown variable “${variable}” in commit message template.`); - return ''; - } - }); -}; - const extractSearchFields = searchFields => entry => searchFields.reduce((acc, field) => { let nestedFields = field.split('.'); @@ -662,11 +634,19 @@ export class Backend { }; } - const commitMessage = commitMessageFormatter(newEntry ? 'create' : 'update', config, { - collection, - slug: entryObj.slug, - path: entryObj.path, - }); + const user = await this.currentUser(); + const commitMessage = commitMessageFormatter( + newEntry ? 'create' : 'update', + config, + { + collection, + slug: entryObj.slug, + path: entryObj.path, + authorLogin: user.login, + authorName: user.name, + }, + user.useOpenAuthoring, + ); const useWorkflow = config.getIn(['publish_mode']) === EDITORIAL_WORKFLOW; @@ -689,26 +669,58 @@ export class Backend { return this.implementation.persistEntry(entryObj, MediaFiles, opts).then(() => entryObj.slug); } - persistMedia(config, file) { + async persistMedia(config, file) { + const user = await this.currentUser(); const options = { - commitMessage: commitMessageFormatter('uploadMedia', config, { path: file.path }), + commitMessage: commitMessageFormatter( + 'uploadMedia', + config, + { + path: file.path, + authorLogin: user.login, + authorName: user.name, + }, + user.useOpenAuthoring, + ), }; return this.implementation.persistMedia(file, options); } - deleteEntry(config, collection, slug) { + async deleteEntry(config, collection, slug) { const path = selectEntryPath(collection, slug); if (!selectAllowDeletion(collection)) { throw new Error('Not allowed to delete entries in this collection'); } - const commitMessage = commitMessageFormatter('delete', config, { collection, slug, path }); + const user = await this.currentUser(); + const commitMessage = commitMessageFormatter( + 'delete', + config, + { + collection, + slug, + path, + authorLogin: user.login, + authorName: user.name, + }, + user.useOpenAuthoring, + ); return this.implementation.deleteFile(path, commitMessage, { collection, slug }); } - deleteMedia(config, path) { - const commitMessage = commitMessageFormatter('deleteMedia', config, { path }); + async deleteMedia(config, path) { + const user = await this.currentUser(); + const commitMessage = commitMessageFormatter( + 'deleteMedia', + config, + { + path, + authorLogin: user.login, + authorName: user.name, + }, + user.useOpenAuthoring, + ); return this.implementation.deleteFile(path, commitMessage); } diff --git a/packages/netlify-cms-core/src/lib/__tests__/backendHelper.spec.js b/packages/netlify-cms-core/src/lib/__tests__/backendHelper.spec.js new file mode 100644 index 00000000..8763f065 --- /dev/null +++ b/packages/netlify-cms-core/src/lib/__tests__/backendHelper.spec.js @@ -0,0 +1,164 @@ +import { Map } from 'immutable'; +import { commitMessageFormatter } from '../backendHelper'; + +jest.spyOn(console, 'warn').mockImplementation(() => {}); + +describe('commitMessageFormatter', () => { + const config = { + getIn: jest.fn(), + }; + + const collection = { + get: jest.fn().mockReturnValue('Collection'), + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should return default commit message on create', () => { + expect( + commitMessageFormatter('create', config, { slug: 'doc-slug', path: 'file-path', collection }), + ).toEqual('Create Collection “doc-slug”'); + }); + + it('should return default commit message on create', () => { + collection.get.mockReturnValueOnce(undefined); + collection.get.mockReturnValueOnce('Collections'); + + expect( + commitMessageFormatter('update', config, { slug: 'doc-slug', path: 'file-path', collection }), + ).toEqual('Update Collections “doc-slug”'); + }); + + it('should return default commit message on delete', () => { + expect( + commitMessageFormatter('delete', config, { slug: 'doc-slug', path: 'file-path', collection }), + ).toEqual('Delete Collection “doc-slug”'); + }); + + it('should return default commit message on uploadMedia', () => { + expect( + commitMessageFormatter('uploadMedia', config, { + slug: 'doc-slug', + path: 'file-path', + collection, + }), + ).toEqual('Upload “file-path”'); + }); + + it('should return default commit message on deleteMedia', () => { + expect( + commitMessageFormatter('deleteMedia', config, { + slug: 'doc-slug', + path: 'file-path', + collection, + }), + ).toEqual('Delete “file-path”'); + }); + + it('should log warning on unknown variable', () => { + config.getIn.mockReturnValueOnce( + Map({ + create: 'Create {{collection}} “{{slug}}” with "{{unknown variable}}"', + }), + ); + expect( + commitMessageFormatter('create', config, { + slug: 'doc-slug', + path: 'file-path', + collection, + }), + ).toEqual('Create Collection “doc-slug” with ""'); + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + 'Ignoring unknown variable “unknown variable” in commit message template.', + ); + }); + + it('should return custom commit message on update', () => { + config.getIn.mockReturnValueOnce( + Map({ + update: 'Custom commit message', + }), + ); + + expect( + commitMessageFormatter('update', config, { + slug: 'doc-slug', + path: 'file-path', + collection, + }), + ).toEqual('Custom commit message'); + }); + + it('should return custom open authoring message', () => { + config.getIn.mockReturnValueOnce( + Map({ + openAuthoring: '{{author-login}} - {{author-name}}: {{message}}', + }), + ); + + expect( + commitMessageFormatter( + 'create', + config, + { + slug: 'doc-slug', + path: 'file-path', + collection, + authorLogin: 'user-login', + authorName: 'Test User', + }, + true, + ), + ).toEqual('user-login - Test User: Create Collection “doc-slug”'); + }); + + it('should use empty values if "authorLogin" and "authorName" are missing in open authoring message', () => { + config.getIn.mockReturnValueOnce( + Map({ + openAuthoring: '{{author-login}} - {{author-name}}: {{message}}', + }), + ); + + expect( + commitMessageFormatter( + 'create', + config, + { + slug: 'doc-slug', + path: 'file-path', + collection, + }, + true, + ), + ).toEqual(' - : Create Collection “doc-slug”'); + }); + + it('should log warning on unknown variable in open authoring template', () => { + config.getIn.mockReturnValueOnce( + Map({ + openAuthoring: '{{author-email}}: {{message}}', + }), + ); + + commitMessageFormatter( + 'create', + config, + { + slug: 'doc-slug', + path: 'file-path', + collection, + authorLogin: 'user-login', + authorName: 'Test User', + }, + true, + ); + + expect(console.warn).toHaveBeenCalledTimes(1); + expect(console.warn).toHaveBeenCalledWith( + 'Ignoring unknown variable “author-email” in open authoring message template.', + ); + }); +}); diff --git a/packages/netlify-cms-core/src/lib/backendHelper.js b/packages/netlify-cms-core/src/lib/backendHelper.js new file mode 100644 index 00000000..5f013d1e --- /dev/null +++ b/packages/netlify-cms-core/src/lib/backendHelper.js @@ -0,0 +1,57 @@ +import { Map } from 'immutable'; + +const commitMessageTemplates = Map({ + create: 'Create {{collection}} “{{slug}}”', + update: 'Update {{collection}} “{{slug}}”', + delete: 'Delete {{collection}} “{{slug}}”', + uploadMedia: 'Upload “{{path}}”', + deleteMedia: 'Delete “{{path}}”', + openAuthoring: '{{message}}', +}); + +const variableRegex = /\{\{([^}]+)\}\}/g; + +export const commitMessageFormatter = ( + type, + config, + { slug, path, collection, authorLogin, authorName }, + isOpenAuthoring, +) => { + const templates = commitMessageTemplates.merge( + config.getIn(['backend', 'commit_messages'], Map()), + ); + + const commitMessage = templates.get(type).replace(variableRegex, (_, variable) => { + switch (variable) { + case 'slug': + return slug; + case 'path': + return path; + case 'collection': + return collection.get('label_singular') || collection.get('label'); + default: + console.warn(`Ignoring unknown variable “${variable}” in commit message template.`); + return ''; + } + }); + + if (!isOpenAuthoring) { + return commitMessage; + } + + const message = templates.get('openAuthoring').replace(variableRegex, (_, variable) => { + switch (variable) { + case 'message': + return commitMessage; + case 'author-login': + return authorLogin || ''; + case 'author-name': + return authorName || ''; + default: + console.warn(`Ignoring unknown variable “${variable}” in open authoring message template.`); + return ''; + } + }); + + return message; +}; diff --git a/website/content/docs/beta-features.md b/website/content/docs/beta-features.md index d31589a7..d258b8ff 100644 --- a/website/content/docs/beta-features.md +++ b/website/content/docs/beta-features.md @@ -240,6 +240,7 @@ backend: delete: Delete {{collection}} “{{slug}}” uploadMedia: Upload “{{path}}” deleteMedia: Delete “{{path}}” + openAuthoring: '{{message}}' ``` Netlify CMS generates the following commit types: @@ -251,6 +252,7 @@ Commit type | When is it triggered? | Available template tags `delete` | An exising entry is deleted | `slug`, `path`, `collection` `uploadMedia` | A media file is uploaded | `path` `deleteMedia` | A media file is deleted | `path` +`openAuthoring` | A commit is made via a forked repository | `message`, `author-login`, `author-name` Template tags produce the following output: @@ -259,3 +261,9 @@ Template tags produce the following output: - `{{collection}}`: the name of the collection containing the entry changed - `{{path}}`: the full path to the file changed + +- `{{message}}`: the relevant message based on the current change (e.g. the `create` message when an entry is created) + +- `{{author-login}}`: the login/username of the author + +- `{{author-name}}`: the full name of the author (might be empty based on the user's profile)