diff --git a/.eslintrc b/.eslintrc index 11ead53f..7a27f928 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,14 +1,12 @@ { "parser": "babel-eslint", - "extends": [ - "eslint:recommended", - "plugin:react/recommended" - ], + "extends": ["eslint:recommended", "plugin:react/recommended", "plugin:cypress/recommended"], "env": { "es6": true, "browser": true, "node": true, - "jest": true + "jest": true, + "cypress/globals": true }, "globals": { "NETLIFY_CMS_VERSION": false, @@ -25,12 +23,10 @@ "emotion/import-from-emotion": "error", "emotion/styled-import": "error" }, - "plugins": [ - "emotion", - ], + "plugins": ["emotion", "cypress"], "settings": { "react": { - "version": "detect", - }, - }, + "version": "detect" + } + } } diff --git a/.gitignore b/.gitignore index a8c1129f..4415ed2d 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,5 @@ cypress/screenshots /coverage/ .cache *.log +.env +.temp/ \ No newline at end of file diff --git a/.prettierignore b/.prettierignore index 485fb2a0..3b3bb211 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,4 +1,5 @@ dist/ bin/ public/ -.cache/ \ No newline at end of file +.cache/ +packages/netlify-cms-backend-github/src/fragmentTypes.js \ No newline at end of file diff --git a/cypress.json b/cypress.json index 081f6554..d3527dbe 100644 --- a/cypress.json +++ b/cypress.json @@ -1,4 +1,5 @@ { "baseUrl": "http://localhost:8080", - "projectId": "dzqjxb" + "projectId": "dzqjxb", + "testFiles": "*spec*.js" } diff --git a/cypress/integration/editorial_workflow_spec.js b/cypress/integration/editorial_workflow_spec.js deleted file mode 100644 index 890cd0f5..00000000 --- a/cypress/integration/editorial_workflow_spec.js +++ /dev/null @@ -1,349 +0,0 @@ -import '../utils/dismiss-local-backup'; - -describe('Editorial Workflow', () => { - const workflowStatus = { draft: 'Drafts', review: 'In Review', ready: 'Ready' }; - const editorStatus = { draft: 'Draft', review: 'In review', ready: 'Ready' }; - const entry1 = { title: 'first title', body: 'first body' }; - const entry2 = { title: 'second title', body: 'second body' }; - const entry3 = { title: 'third title', body: 'third body' }; - const setting1 = { limit: 10, author: 'John Doe' }; - const setting2 = { name: 'Andrew Wommack', description: 'A Gospel Teacher' }; - const notifications = { - saved: 'Entry saved', - published: 'Entry published', - updated: 'Entry status updated', - deletedUnpublished: 'Unpublished changes deleted', - error: { - missingField: "Oops, you've missed a required field. Please complete before saving.", - }, - validation: { - range: { - fieldLabel: 'Number of posts on frontpage', - message: 'Number of posts on frontpage must be between 1 and 10.', - }, - }, - }; - - describe('Test Backend', () => { - function login() { - cy.viewport(1200, 1200); - cy.visit('/'); - cy.contains('button', 'Login').click(); - } - - function createPost({ title, body }) { - cy.contains('a', 'New Post').click(); - cy.get('input') - .first() - .type(title); - cy.get('[data-slate-editor]') - .click() - .type(body); - cy.get('input') - .first() - .click(); - cy.contains('button', 'Save').click(); - assertNotification(notifications.saved); - } - - function validateObjectFields({ limit, author }) { - cy.get('a[href^="#/collections/settings"]').click(); - cy.get('a[href^="#/collections/settings/entries/general"]').click(); - cy.get('input[type=number]').type(limit); - cy.contains('button', 'Save').click(); - assertNotification(notifications.error.missingField); - cy.contains('label', 'Default Author').click(); - cy.focused().type(author); - cy.contains('button', 'Save').click(); - assertNotification(notifications.saved); - } - - function validateNestedObjectFields({ limit, author }) { - cy.get('a[href^="#/collections/settings"]').click(); - cy.get('a[href^="#/collections/settings/entries/general"]').click(); - cy.contains('label', 'Default Author').click(); - cy.focused().type(author); - cy.contains('button', 'Save').click(); - assertNotification(notifications.error.missingField); - cy.get('input[type=number]').type(limit + 1); - cy.contains('button', 'Save').click(); - assertFieldValidationError(notifications.validation.range); - cy.get('input[type=number]') - .clear() - .type(-1); - cy.contains('button', 'Save').click(); - assertFieldValidationError(notifications.validation.range); - cy.get('input[type=number]') - .clear() - .type(limit); - cy.contains('button', 'Save').click(); - assertNotification(notifications.saved); - } - - function validateListFields({ name, description }) { - cy.get('a[href^="#/collections/settings"]').click(); - cy.get('a[href^="#/collections/settings/entries/authors"]').click(); - cy.contains('button', 'Add').click(); - cy.contains('button', 'Save').click(); - assertNotification(notifications.error.missingField); - cy.get('input') - .eq(2) - .type(name); - cy.get('[data-slate-editor]') - .eq(2) - .type(description); - cy.contains('button', 'Save').click(); - } - - function exitEditor() { - cy.contains('a[href^="#/collections/"]', 'Writing in').click(); - } - - function deleteEntryInEditor() { - cy.contains('button', 'Delete').click(); - assertNotification(notifications.deletedUnpublished); - } - - function assertEntryDeleted(entry) { - if (Array.isArray(entry)) { - const titles = entry.map(e => e.title); - cy.get('a h2').each((el, idx) => { - expect(titles).not.to.include(el.text()); - }); - } else { - cy.get('a h2').each((el, idx) => { - expect(entry.title).not.to.equal(el.text()); - }); - } - } - - function createAndDeletePost(entry) { - createPost(entry); - deleteEntryInEditor(); - } - - function createPostAndExit(entry) { - createPost(entry); - exitEditor(); - } - - function createPublishedPost(entry) { - createPost(entry); - updateWorkflowStatusInEditor(editorStatus.ready); - publishInEditor(); - } - - function createPublishedPostAndExit(entry) { - createPublishedPost(entry); - exitEditor(); - } - - function validateObjectFieldsAndExit(setting) { - validateObjectFields(setting); - exitEditor(); - } - - function validateNestedObjectFieldsAndExit(setting) { - validateNestedObjectFields(setting); - exitEditor(); - } - - function validateListFieldsAndExit(setting) { - validateListFields(setting); - exitEditor(); - } - - function goToWorkflow() { - cy.contains('a', 'Workflow').click(); - } - - function goToCollections() { - cy.contains('a', 'Content').click(); - } - - function updateWorkflowStatus({ title }, fromColumnHeading, toColumnHeading) { - cy.contains('h2', fromColumnHeading) - .parent() - .contains('a', title) - .trigger('dragstart', { - dataTransfer: {}, - force: true, - }); - cy.contains('h2', toColumnHeading) - .parent() - .trigger('drop', { - dataTransfer: {}, - force: true, - }); - assertNotification(notifications.updated); - } - - function assertWorkflowStatus({ title }, status) { - cy.contains('h2', status) - .parent() - .contains('a', title); - } - - function updateWorkflowStatusInEditor(newStatus) { - cy.contains('[role="button"]', 'Set status').as('setStatusButton'); - cy.get('@setStatusButton') - .parent() - .within(() => { - cy.get('@setStatusButton').click(); - cy.contains('[role="menuitem"] span', newStatus).click(); - }); - assertNotification(notifications.updated); - } - - function assertWorkflowStatusInEditor(status) { - cy.contains('[role="button"]', 'Set status').as('setStatusButton'); - cy.get('@setStatusButton') - .parent() - .within(() => { - cy.get('@setStatusButton').click(); - cy.contains('[role="menuitem"] span', status) - .parent() - .within(() => { - cy.get('svg'); - }); - cy.get('@setStatusButton').click(); - }); - } - - function publishWorkflowEntry({ title }) { - cy.contains('h2', workflowStatus.ready) - .parent() - .within(() => { - cy.contains('a', title) - .parent() - .within(() => { - cy.contains('button', 'Publish new entry').click({ force: true }); - }); - }); - assertNotification(notifications.published); - } - - function assertPublishedEntry(entry) { - if (Array.isArray(entry)) { - const entries = entry.reverse(); - cy.get('a h2').then(els => { - cy.wrap(els.slice(0, entries.length)).each((el, idx) => { - cy.wrap(el).contains(entries[idx].title); - }); - }); - } else { - cy.get('a h2') - .first() - .contains(entry.title); - } - } - - function assertNotification(message) { - if (Array.isArray(message)) { - const messages = message.reverse(); - cy.get('.notif__container div') - .should('have.length.of', messages.length) - .each((el, idx) => { - cy.wrap(el) - .contains(messages[idx]) - .invoke('hide'); - }); - } else { - cy.get('.notif__container').within(() => { - cy.contains(message).invoke('hide'); - }); - } - } - - function assertFieldValidationError({ message, fieldLabel }) { - cy.contains('label', fieldLabel) - .siblings('ul[class*=ControlErrorsList]') - .contains(message); - } - - function assertOnCollectionsPage() { - cy.url().should('contain', '/#/collections/posts'); - cy.contains('h2', 'Collections'); - } - - it('successfully loads', () => { - login(); - }); - - it('can create an entry', () => { - login(); - createPostAndExit(entry1); - }); - - it('can validate object fields', () => { - login(); - validateObjectFieldsAndExit(setting1); - }); - - it('can validate fields nested in an object field', () => { - login(); - validateNestedObjectFieldsAndExit(setting1); - }); - - it('can validate list fields', () => { - login(); - validateListFieldsAndExit(setting2); - }); - - it('can publish an editorial workflow entry', () => { - login(); - createPostAndExit(entry1); - goToWorkflow(); - updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); - publishWorkflowEntry(entry1); - }); - - it('can change workflow status', () => { - login(); - createPostAndExit(entry1); - goToWorkflow(); - updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.review); - updateWorkflowStatus(entry1, workflowStatus.review, workflowStatus.ready); - updateWorkflowStatus(entry1, workflowStatus.ready, workflowStatus.review); - updateWorkflowStatus(entry1, workflowStatus.review, workflowStatus.draft); - updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); - }); - - it('can change status on and publish multiple entries', () => { - login(); - createPostAndExit(entry1); - createPostAndExit(entry2); - createPostAndExit(entry3); - goToWorkflow(); - updateWorkflowStatus(entry3, workflowStatus.draft, workflowStatus.ready); - updateWorkflowStatus(entry2, workflowStatus.draft, workflowStatus.ready); - updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); - publishWorkflowEntry(entry3); - publishWorkflowEntry(entry2); - publishWorkflowEntry(entry1); - goToCollections(); - assertPublishedEntry([entry3, entry2, entry1]); - }); - - it('can delete an entry', () => { - login(); - createPost(entry1); - deleteEntryInEditor(); - assertOnCollectionsPage(); - assertEntryDeleted(entry1); - }); - - it('can update workflow status from within the editor', () => { - login(); - createPost(entry1); - assertWorkflowStatusInEditor(editorStatus.draft); - updateWorkflowStatusInEditor(editorStatus.review); - assertWorkflowStatusInEditor(editorStatus.review); - updateWorkflowStatusInEditor(editorStatus.ready); - assertWorkflowStatusInEditor(editorStatus.ready); - exitEditor(); - goToWorkflow(); - assertWorkflowStatus(entry1, workflowStatus.ready); - }); - }); -}); diff --git a/cypress/integration/editorial_workflow_spec_github_backend_graphql.js b/cypress/integration/editorial_workflow_spec_github_backend_graphql.js new file mode 100644 index 00000000..a0be721b --- /dev/null +++ b/cypress/integration/editorial_workflow_spec_github_backend_graphql.js @@ -0,0 +1,5 @@ +import fixture from './github/editorial_workflow'; + +describe.skip('Github Backend Editorial Workflow - GraphQL API', () => { + fixture({ use_graphql: true }); +}); diff --git a/cypress/integration/editorial_workflow_spec_github_backend_graphql_open_authoring.js b/cypress/integration/editorial_workflow_spec_github_backend_graphql_open_authoring.js new file mode 100644 index 00000000..6f953725 --- /dev/null +++ b/cypress/integration/editorial_workflow_spec_github_backend_graphql_open_authoring.js @@ -0,0 +1,5 @@ +import fixture from './github/open_authoring'; + +describe.skip('Github Backend Editorial Workflow - GraphQL API - Open Authoring', () => { + fixture({ use_graphql: true }); +}); diff --git a/cypress/integration/editorial_workflow_spec_github_backend_rest.js b/cypress/integration/editorial_workflow_spec_github_backend_rest.js new file mode 100644 index 00000000..cb255003 --- /dev/null +++ b/cypress/integration/editorial_workflow_spec_github_backend_rest.js @@ -0,0 +1,5 @@ +import fixture from './github/editorial_workflow'; + +describe.skip('Github Backend Editorial Workflow - REST API', () => { + fixture({ use_graphql: false }); +}); diff --git a/cypress/integration/editorial_workflow_spec_github_backend_rest_open_authoring.js b/cypress/integration/editorial_workflow_spec_github_backend_rest_open_authoring.js new file mode 100644 index 00000000..4a547cad --- /dev/null +++ b/cypress/integration/editorial_workflow_spec_github_backend_rest_open_authoring.js @@ -0,0 +1,5 @@ +import fixture from './github/open_authoring'; + +describe.skip('Github Backend Editorial Workflow - REST API - Open Authoring', () => { + fixture({ use_graphql: false }); +}); diff --git a/cypress/integration/editorial_workflow_spec_test_backend.js b/cypress/integration/editorial_workflow_spec_test_backend.js new file mode 100644 index 00000000..192b0e3e --- /dev/null +++ b/cypress/integration/editorial_workflow_spec_test_backend.js @@ -0,0 +1,126 @@ +import '../utils/dismiss-local-backup'; +import { + login, + createPost, + createPostAndExit, + exitEditor, + goToWorkflow, + goToCollections, + updateWorkflowStatus, + publishWorkflowEntry, + assertWorkflowStatusInEditor, + assertPublishedEntry, + deleteEntryInEditor, + assertOnCollectionsPage, + assertEntryDeleted, + assertWorkflowStatus, + updateWorkflowStatusInEditor, + validateObjectFieldsAndExit, + validateNestedObjectFieldsAndExit, + validateListFieldsAndExit, +} from '../utils/steps'; +import { setting1, setting2, workflowStatus, editorStatus } from '../utils/constants'; + +const entry1 = { + title: 'first title', + body: 'first body', +}; +const entry2 = { + title: 'second title', + body: 'second body', +}; +const entry3 = { + title: 'third title', + body: 'third body', +}; + +describe('Test Backend Editorial Workflow', () => { + after(() => { + cy.task('restoreDefaults'); + }); + + before(() => { + Cypress.config('defaultCommandTimeout', 4000); + cy.task('setupBackend', { backend: 'test' }); + }); + + it('successfully loads', () => { + login(); + }); + + it('can create an entry', () => { + login(); + createPostAndExit(entry1); + }); + + it('can validate object fields', () => { + login(); + validateObjectFieldsAndExit(setting1); + }); + + it('can validate fields nested in an object field', () => { + login(); + validateNestedObjectFieldsAndExit(setting1); + }); + + it('can validate list fields', () => { + login(); + validateListFieldsAndExit(setting2); + }); + + it('can publish an editorial workflow entry', () => { + login(); + createPostAndExit(entry1); + goToWorkflow(); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entry1); + }); + + it('can change workflow status', () => { + login(); + createPostAndExit(entry1); + goToWorkflow(); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.review); + updateWorkflowStatus(entry1, workflowStatus.review, workflowStatus.ready); + updateWorkflowStatus(entry1, workflowStatus.ready, workflowStatus.review); + updateWorkflowStatus(entry1, workflowStatus.review, workflowStatus.draft); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); + }); + + it('can change status on and publish multiple entries', () => { + login(); + createPostAndExit(entry1); + createPostAndExit(entry2); + createPostAndExit(entry3); + goToWorkflow(); + updateWorkflowStatus(entry3, workflowStatus.draft, workflowStatus.ready); + updateWorkflowStatus(entry2, workflowStatus.draft, workflowStatus.ready); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entry3); + publishWorkflowEntry(entry2); + publishWorkflowEntry(entry1); + goToCollections(); + assertPublishedEntry([entry3, entry2, entry1]); + }); + + it('can delete an entry', () => { + login(); + createPost(entry1); + deleteEntryInEditor(); + assertOnCollectionsPage(); + assertEntryDeleted(entry1); + }); + + it('can update workflow status from within the editor', () => { + login(); + createPost(entry1); + assertWorkflowStatusInEditor(editorStatus.draft); + updateWorkflowStatusInEditor(editorStatus.review); + assertWorkflowStatusInEditor(editorStatus.review); + updateWorkflowStatusInEditor(editorStatus.ready); + assertWorkflowStatusInEditor(editorStatus.ready); + exitEditor(); + goToWorkflow(); + assertWorkflowStatus(entry1, workflowStatus.ready); + }); +}); diff --git a/cypress/integration/github/editorial_workflow.js b/cypress/integration/github/editorial_workflow.js new file mode 100644 index 00000000..ae09688a --- /dev/null +++ b/cypress/integration/github/editorial_workflow.js @@ -0,0 +1,117 @@ +import '../../utils/dismiss-local-backup'; +import { + login, + createPost, + createPostAndExit, + updateExistingPostAndExit, + exitEditor, + goToWorkflow, + goToCollections, + updateWorkflowStatus, + publishWorkflowEntry, + assertWorkflowStatusInEditor, + assertPublishedEntry, + deleteEntryInEditor, + assertOnCollectionsPage, + assertEntryDeleted, + assertWorkflowStatus, + updateWorkflowStatusInEditor, +} from '../../utils/steps'; +import { workflowStatus, editorStatus } from '../../utils/constants'; +import { entry1, entry2, entry3 } from './entries'; + +export default function({ use_graphql }) { + let taskResult = { data: {} }; + + const backend = 'github'; + + before(() => { + Cypress.config('taskTimeout', 1200000); + Cypress.config('defaultCommandTimeout', 60000); + cy.task('setupBackend', { backend }).then(data => { + taskResult.data = data; + }); + + cy.task('updateBackendOptions', { backend, options: { use_graphql, open_authoring: false } }); + }); + + after(() => { + cy.task('teardownBackend', { backend, ...taskResult.data }); + cy.task('restoreDefaults'); + }); + + afterEach(() => { + cy.task('teardownBackendTest', { backend, ...taskResult.data }); + }); + + it('successfully loads', () => { + login(taskResult.data.user); + }); + + it('can create an entry', () => { + login(taskResult.data.user); + createPostAndExit(entry1); + }); + + it('can update an entry', () => { + login(taskResult.data.user); + createPostAndExit(entry1); + updateExistingPostAndExit(entry1, entry2); + }); + + it('can publish an editorial workflow entry', () => { + login(taskResult.data.user); + createPostAndExit(entry1); + goToWorkflow(); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entry1); + }); + + it('can change workflow status', () => { + login(taskResult.data.user); + createPostAndExit(entry1); + goToWorkflow(); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.review); + updateWorkflowStatus(entry1, workflowStatus.review, workflowStatus.ready); + updateWorkflowStatus(entry1, workflowStatus.ready, workflowStatus.review); + updateWorkflowStatus(entry1, workflowStatus.review, workflowStatus.draft); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); + }); + + it('can change status on and publish multiple entries', () => { + login(taskResult.data.user); + createPostAndExit(entry1); + createPostAndExit(entry2); + createPostAndExit(entry3); + goToWorkflow(); + updateWorkflowStatus(entry3, workflowStatus.draft, workflowStatus.ready); + updateWorkflowStatus(entry2, workflowStatus.draft, workflowStatus.ready); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entry3); + publishWorkflowEntry(entry2); + publishWorkflowEntry(entry1); + goToCollections(); + assertPublishedEntry([entry3, entry2, entry1]); + }); + + it('can delete an entry', () => { + login(taskResult.data.user); + createPost(entry1); + deleteEntryInEditor(); + assertOnCollectionsPage(); + assertEntryDeleted(entry1); + }); + + it('can update workflow status from within the editor', () => { + login(taskResult.data.user); + createPost(entry1); + assertWorkflowStatusInEditor(editorStatus.draft); + updateWorkflowStatusInEditor(editorStatus.review); + assertWorkflowStatusInEditor(editorStatus.review); + updateWorkflowStatusInEditor(editorStatus.ready); + assertWorkflowStatusInEditor(editorStatus.ready); + exitEditor(); + goToWorkflow(); + assertWorkflowStatus(entry1, workflowStatus.ready); + }); +} diff --git a/cypress/integration/github/entries.js b/cypress/integration/github/entries.js new file mode 100644 index 00000000..d27a2a41 --- /dev/null +++ b/cypress/integration/github/entries.js @@ -0,0 +1,21 @@ +export const entry1 = { + title: 'first title', + body: 'first body', + description: 'first description', + category: 'first category', + tags: 'tag1', +}; +export const entry2 = { + title: 'second title', + body: 'second body', + description: 'second description', + category: 'second category', + tags: 'tag2', +}; +export const entry3 = { + title: 'third title', + body: 'third body', + description: 'third description', + category: 'third category', + tags: 'tag3', +}; diff --git a/cypress/integration/github/open_authoring.js b/cypress/integration/github/open_authoring.js new file mode 100644 index 00000000..6c94dae1 --- /dev/null +++ b/cypress/integration/github/open_authoring.js @@ -0,0 +1,99 @@ +import '../../utils/dismiss-local-backup'; +import { + login, + createPostAndExit, + updateExistingPostAndExit, + goToWorkflow, + deleteWorkflowEntry, + updateWorkflowStatus, + publishWorkflowEntry, +} from '../../utils/steps'; +import { workflowStatus } from '../../utils/constants'; +import { entry1, entry2 } from './entries'; + +export default function({ use_graphql }) { + let taskResult = { data: {} }; + + const backend = 'github'; + + before(() => { + Cypress.config('taskTimeout', 1200000); + Cypress.config('defaultCommandTimeout', 60000); + cy.task('setupBackend', { backend }).then(data => { + taskResult.data = data; + }); + cy.task('updateBackendOptions', { backend, options: { use_graphql, open_authoring: true } }); + }); + + after(() => { + cy.task('teardownBackend', { backend, ...taskResult.data }); + cy.task('restoreDefaults'); + }); + + afterEach(() => { + cy.task('teardownBackendTest', { backend, ...taskResult.data }); + }); + + it('successfully loads', () => { + login(taskResult.data.user); + }); + + it('can create an entry', () => { + login(taskResult.data.user); + createPostAndExit(entry1); + }); + + it('can update an entry', () => { + login(taskResult.data.user); + createPostAndExit(entry1); + updateExistingPostAndExit(entry1, entry2); + }); + + it('can publish an editorial workflow entry', () => { + login(taskResult.data.user); + createPostAndExit(entry1); + goToWorkflow(); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.ready); + publishWorkflowEntry(entry1); + }); + + it('successfully forks repository and loads', () => { + login(taskResult.data.forkUser); + }); + + it('can create an entry on fork', () => { + login(taskResult.data.forkUser); + createPostAndExit(entry1); + }); + + it('can update a draft entry on fork', () => { + login(taskResult.data.user); + createPostAndExit(entry1); + updateExistingPostAndExit(entry1, entry2); + }); + + it('can change entry status from fork', () => { + login(taskResult.data.forkUser); + createPostAndExit(entry1); + goToWorkflow(); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.review); + }); + + it('can delete review entry from fork', () => { + login(taskResult.data.forkUser); + createPostAndExit(entry1); + goToWorkflow(); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.review); + deleteWorkflowEntry(entry1); + }); + + it('can return entry to draft and delete it', () => { + login(taskResult.data.forkUser); + createPostAndExit(entry1); + goToWorkflow(); + updateWorkflowStatus(entry1, workflowStatus.draft, workflowStatus.review); + + updateWorkflowStatus(entry1, workflowStatus.review, workflowStatus.draft); + deleteWorkflowEntry(entry1); + }); +} diff --git a/cypress/plugins/github.js b/cypress/plugins/github.js new file mode 100644 index 00000000..2c6518da --- /dev/null +++ b/cypress/plugins/github.js @@ -0,0 +1,193 @@ +const Octokit = require('@octokit/rest'); +const fs = require('fs-extra'); +const path = require('path'); +const simpleGit = require('simple-git/promise'); + +const GIT_SSH_COMMAND = 'ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no'; + +function getGitHubClient(token) { + const client = new Octokit({ + auth: `token ${token}`, + baseUrl: 'https://api.github.com', + }); + return client; +} + +function getEnvs() { + const { + GITHUB_REPO_OWNER: owner, + GITHUB_REPO_NAME: repo, + GITHUB_REPO_TOKEN: token, + GITHUB_OPEN_AUTHORING_OWNER: forkOwner, + GITHUB_OPEN_AUTHORING_TOKEN: forkToken, + } = process.env; + if (!owner || !repo || !token || !forkOwner || !forkToken) { + throw new Error( + 'Please set GITHUB_REPO_OWNER, GITHUB_REPO_NAME, GITHUB_REPO_TOKEN, GITHUB_OPEN_AUTHORING_OWNER, GITHUB_OPEN_AUTHORING_TOKEN environment variables', + ); + } + return { owner, repo, token, forkOwner, forkToken }; +} + +async function prepareTestGitHubRepo() { + const { owner, repo, token } = getEnvs(); + + // postfix a random string to avoid collisions + const postfix = Math.random() + .toString(32) + .substring(2); + const testRepoName = `${repo}-${Date.now()}-${postfix}`; + + const client = getGitHubClient(token); + + console.log('Creating repository', testRepoName); + await client.repos.createForAuthenticatedUser({ + name: testRepoName, + }); + + const tempDir = path.join('.temp', testRepoName); + await fs.remove(tempDir); + let git = simpleGit().env({ ...process.env, GIT_SSH_COMMAND }); + + const repoUrl = `git@github.com:${owner}/${repo}.git`; + + console.log('Cloning repository', repoUrl); + await git.clone(repoUrl, tempDir); + git = simpleGit(tempDir).env({ ...process.env, GIT_SSH_COMMAND }); + + console.log('Pushing to new repository', testRepoName); + + await git.removeRemote('origin'); + await git.addRemote( + 'origin', + `https://${token}:x-oauth-basic@github.com/${owner}/${testRepoName}`, + ); + await git.push(['-u', 'origin', 'master']); + + return { owner, repo: testRepoName, tempDir }; +} + +async function getAuthenticatedUser(token) { + const client = getGitHubClient(token); + const { data: user } = await client.users.getAuthenticated(); + return { ...user, token, backendName: 'github' }; +} + +async function getUser() { + const { token } = getEnvs(); + return getAuthenticatedUser(token); +} + +async function getForkUser() { + const { forkToken } = getEnvs(); + return getAuthenticatedUser(forkToken); +} + +async function deleteRepositories({ owner, repo, tempDir }) { + const { forkOwner, token, forkToken } = getEnvs(); + + const errorHandler = e => { + if (e.status !== 404) { + throw e; + } + }; + + console.log('Deleting repository', `${owner}/${repo}`); + await fs.remove(tempDir); + + let client = getGitHubClient(token); + await client.repos + .delete({ + owner, + repo, + }) + .catch(errorHandler); + + console.log('Deleting forked repository', `${forkOwner}/${repo}`); + client = getGitHubClient(forkToken); + await client.repos + .delete({ + owner: forkOwner, + repo, + }) + .catch(errorHandler); +} + +async function resetOriginRepo({ owner, repo, tempDir }) { + console.log('Resetting origin repo:', `${owner}/repo`); + const { token } = getEnvs(); + const client = getGitHubClient(token); + + const { data: prs } = await client.pulls.list({ + repo, + owner, + state: 'open', + }); + const numbers = prs.map(pr => pr.number); + console.log('Closing prs:', numbers); + await Promise.all( + numbers.map(pull_number => + client.pulls.update({ + owner, + repo, + pull_number, + }), + ), + ); + + const { data: branches } = await client.repos.listBranches({ owner, repo }); + const refs = branches.filter(b => b.name !== 'master').map(b => `heads/${b.name}`); + + console.log('Deleting refs', refs); + await Promise.all( + refs.map(ref => + client.git.deleteRef({ + owner, + repo, + ref, + }), + ), + ); + + console.log('Resetting master'); + const git = simpleGit(tempDir).env({ ...process.env, GIT_SSH_COMMAND }); + await git.push(['--force', 'origin', 'master']); + console.log('Done resetting origin repo:', `${owner}/repo`); +} + +async function resetForkedRepo({ repo }) { + const { forkToken, forkOwner } = getEnvs(); + const client = getGitHubClient(forkToken); + + const { data: repos } = await client.repos.list(); + if (repos.some(r => r.name === repo)) { + console.log('Resetting forked repo:', `${forkOwner}/${repo}`); + const { data: branches } = await client.repos.listBranches({ owner: forkOwner, repo }); + const refs = branches.filter(b => b.name !== 'master').map(b => `heads/${b.name}`); + + console.log('Deleting refs', refs); + await Promise.all( + refs.map(ref => + client.git.deleteRef({ + owner: forkOwner, + repo, + ref, + }), + ), + ); + console.log('Done resetting forked repo:', `${forkOwner}/repo`); + } +} + +async function resetRepositories({ owner, repo, tempDir }) { + await resetOriginRepo({ owner, repo, tempDir }); + await resetForkedRepo({ repo }); +} + +module.exports = { + prepareTestGitHubRepo, + deleteRepositories, + getUser, + getForkUser, + resetRepositories, +}; diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js index fd170fba..e515b70e 100644 --- a/cypress/plugins/index.js +++ b/cypress/plugins/index.js @@ -10,8 +10,106 @@ // This function is called when a project is opened or re-opened (e.g. due to // the project's config changing) +require('dotenv').config(); +const fs = require('fs-extra'); +const path = require('path'); +const yaml = require('js-yaml'); +const { + prepareTestGitHubRepo, + deleteRepositories, + getUser, + getForkUser, + resetRepositories, +} = require('./github'); -module.exports = (on, config) => { - // `on` is used to hook into various events Cypress emits - // `config` is the resolved Cypress config +const devTestDirectory = path.join(__dirname, '..', '..', 'dev-test'); +const backendsDirectory = path.join(devTestDirectory, 'backends'); + +async function copyBackendFiles(backend) { + for (let file of ['config.yml', 'index.html']) { + await fs.copyFile( + path.join(backendsDirectory, backend, file), + path.join(devTestDirectory, file), + ); + } } + +async function updateConfig(configModifier) { + const configFile = path.join(devTestDirectory, 'config.yml'); + const configContent = await fs.readFile(configFile); + const config = yaml.safeLoad(configContent); + await configModifier(config); + await fs.writeFileSync(configFile, yaml.safeDump(config)); + return null; +} + +async function setupGitHub() { + const [user, forkUser, repoData] = await Promise.all([ + getUser(), + getForkUser(), + prepareTestGitHubRepo(), + ]); + + await updateConfig(config => { + config.backend.repo = `${repoData.owner}/${repoData.repo}`; + }); + + return { ...repoData, user, forkUser }; +} + +async function teardownGitHub(taskData) { + await deleteRepositories(taskData); + return null; +} + +async function teardownBackendTest(taskData) { + await resetRepositories(taskData); + return null; +} + +module.exports = async on => { + // `on` is used to hook into various events Cypress emits + on('task', { + async setupBackend({ backend }) { + console.log('Preparing environment for backend', backend); + await copyBackendFiles(backend); + + if (backend === 'github') { + return await setupGitHub(); + } + + return null; + }, + async teardownBackend(taskData) { + const { backend } = taskData; + console.log('Tearing down backend', backend); + if (backend === 'github') { + return await teardownGitHub(taskData); + } + + return null; + }, + async teardownBackendTest(taskData) { + const { backend } = taskData; + console.log('Tearing down single test for backend', backend); + if (backend === 'github') { + return await teardownBackendTest(taskData); + } + }, + async updateBackendOptions({ backend, options }) { + console.log('Updating backend', backend, 'with options', options); + if (backend === 'github') { + return await updateConfig(config => { + config.backend = { ...config.backend, ...options }; + }); + } + return null; + }, + async restoreDefaults() { + console.log('Restoring defaults'); + await copyBackendFiles('test'); + + return null; + }, + }); +}; diff --git a/cypress/utils/constants.js b/cypress/utils/constants.js new file mode 100644 index 00000000..d152168a --- /dev/null +++ b/cypress/utils/constants.js @@ -0,0 +1,27 @@ +const workflowStatus = { draft: 'Drafts', review: 'In Review', ready: 'Ready' }; +const editorStatus = { draft: 'Draft', review: 'In review', ready: 'Ready' }; +const setting1 = { limit: 10, author: 'John Doe' }; +const setting2 = { name: 'Andrew Wommack', description: 'A Gospel Teacher' }; +const notifications = { + saved: 'Entry saved', + published: 'Entry published', + updated: 'Entry status updated', + deletedUnpublished: 'Unpublished changes deleted', + error: { + missingField: "Oops, you've missed a required field. Please complete before saving.", + }, + validation: { + range: { + fieldLabel: 'Number of posts on frontpage', + message: 'Number of posts on frontpage must be between 1 and 10.', + }, + }, +}; + +module.exports = { + workflowStatus, + editorStatus, + setting1, + setting2, + notifications, +}; diff --git a/cypress/utils/steps.js b/cypress/utils/steps.js new file mode 100644 index 00000000..7899b65a --- /dev/null +++ b/cypress/utils/steps.js @@ -0,0 +1,292 @@ +const { notifications, workflowStatus } = require('./constants'); + +function login(user) { + cy.viewport(1200, 1200); + if (user) { + cy.visit('/', { + onBeforeLoad: () => { + window.localStorage.setItem('netlify-cms-user', JSON.stringify(user)); + }, + }); + } else { + cy.visit('/'); + cy.contains('button', 'Login').click(); + } +} + +function assertNotification(message) { + if (Array.isArray(message)) { + console.log(message); + const messages = message.reverse(); + cy.get('.notif__container div') + .should('have.length.of', messages.length) + .each((el, idx) => { + cy.wrap(el) + .contains(messages[idx]) + .invoke('hide'); + }); + } else { + cy.get('.notif__container').within(() => { + cy.contains(message).invoke('hide'); + }); + } +} + +function exitEditor() { + cy.contains('a[href^="#/collections/"]', 'Writing in').click(); +} + +function goToWorkflow() { + cy.contains('a', 'Workflow').click(); +} + +function goToCollections() { + cy.contains('a', 'Content').click(); +} + +function updateWorkflowStatus({ title }, fromColumnHeading, toColumnHeading) { + cy.contains('h2', fromColumnHeading) + .parent() + .contains('a', title) + .trigger('dragstart', { + dataTransfer: {}, + force: true, + }); + cy.contains('h2', toColumnHeading) + .parent() + .trigger('drop', { + dataTransfer: {}, + force: true, + }); + assertNotification(notifications.updated); +} + +function publishWorkflowEntry({ title }) { + cy.contains('h2', workflowStatus.ready) + .parent() + .within(() => { + cy.contains('a', title) + .parent() + .within(() => { + cy.contains('button', 'Publish new entry').click({ force: true }); + }); + }); + assertNotification(notifications.published); +} + +function deleteWorkflowEntry({ title }) { + cy.contains('a', title) + .parent() + .within(() => { + cy.contains('button', 'Delete new entry').click({ force: true }); + }); + + assertNotification(notifications.deletedUnpublished); +} + +function assertWorkflowStatusInEditor(status) { + cy.contains('[role="button"]', 'Set status').as('setStatusButton'); + cy.get('@setStatusButton') + .parent() + .within(() => { + cy.get('@setStatusButton').click(); + cy.contains('[role="menuitem"] span', status) + .parent() + .within(() => { + cy.get('svg'); + }); + cy.get('@setStatusButton').click(); + }); +} + +function assertPublishedEntry(entry) { + if (Array.isArray(entry)) { + const entries = entry.reverse(); + cy.get('a h2').then(els => { + cy.wrap(els.slice(0, entries.length)).each((el, idx) => { + cy.wrap(el).contains(entries[idx].title); + }); + }); + } else { + cy.get('a h2') + .first() + .contains(entry.title); + } +} + +function deleteEntryInEditor() { + cy.contains('button', 'Delete').click(); + assertNotification(notifications.deletedUnpublished); +} + +function assertOnCollectionsPage() { + cy.url().should('contain', '/#/collections/posts'); + cy.contains('h2', 'Collections'); +} + +function assertEntryDeleted(entry) { + if (Array.isArray(entry)) { + const titles = entry.map(e => e.title); + cy.get('a h2').each(el => { + expect(titles).not.to.include(el.text()); + }); + } else { + cy.get('a h2').each(el => { + expect(entry.title).not.to.equal(el.text()); + }); + } +} + +function assertWorkflowStatus({ title }, status) { + cy.contains('h2', status) + .parent() + .contains('a', title); +} + +function updateWorkflowStatusInEditor(newStatus) { + cy.contains('[role="button"]', 'Set status').as('setStatusButton'); + cy.get('@setStatusButton') + .parent() + .within(() => { + cy.get('@setStatusButton').click(); + cy.contains('[role="menuitem"] span', newStatus).click(); + }); + assertNotification(notifications.updated); +} + +function populateEntry(entry) { + const keys = Object.keys(entry); + for (let key of keys) { + const value = entry[key]; + if (key === 'body') { + cy.get('[data-slate-editor]') + .click() + .clear() + .type(value); + } else { + cy.get(`[id^="${key}-field"]`) + .clear() + .type(value); + } + } + + cy.get('input') + .first() + .click(); + cy.contains('button', 'Save').click(); + assertNotification(notifications.saved); +} + +function createPost(entry) { + cy.contains('a', 'New Post').click(); + populateEntry(entry); +} + +function createPostAndExit(entry) { + createPost(entry); + exitEditor(); +} + +function updateExistingPostAndExit(fromEntry, toEntry) { + goToWorkflow(); + cy.contains('h2', fromEntry.title) + .parent() + .click({ force: true }); + populateEntry(toEntry); + exitEditor(); + goToWorkflow(); + cy.contains('h2', toEntry.title); +} + +function validateObjectFields({ limit, author }) { + cy.get('a[href^="#/collections/settings"]').click(); + cy.get('a[href^="#/collections/settings/entries/general"]').click(); + cy.get('input[type=number]').type(limit); + cy.contains('button', 'Save').click(); + assertNotification(notifications.error.missingField); + cy.contains('label', 'Default Author').click(); + cy.focused().type(author); + cy.contains('button', 'Save').click(); + assertNotification(notifications.saved); +} + +function validateNestedObjectFields({ limit, author }) { + cy.get('a[href^="#/collections/settings"]').click(); + cy.get('a[href^="#/collections/settings/entries/general"]').click(); + cy.contains('label', 'Default Author').click(); + cy.focused().type(author); + cy.contains('button', 'Save').click(); + assertNotification(notifications.error.missingField); + cy.get('input[type=number]').type(limit + 1); + cy.contains('button', 'Save').click(); + assertFieldValidationError(notifications.validation.range); + cy.get('input[type=number]') + .clear() + .type(-1); + cy.contains('button', 'Save').click(); + assertFieldValidationError(notifications.validation.range); + cy.get('input[type=number]') + .clear() + .type(limit); + cy.contains('button', 'Save').click(); + assertNotification(notifications.saved); +} + +function validateListFields({ name, description }) { + cy.get('a[href^="#/collections/settings"]').click(); + cy.get('a[href^="#/collections/settings/entries/authors"]').click(); + cy.contains('button', 'Add').click(); + cy.contains('button', 'Save').click(); + assertNotification(notifications.error.missingField); + cy.get('input') + .eq(2) + .type(name); + cy.get('[data-slate-editor]') + .eq(2) + .type(description); + cy.contains('button', 'Save').click(); +} + +function validateObjectFieldsAndExit(setting) { + validateObjectFields(setting); + exitEditor(); +} + +function validateNestedObjectFieldsAndExit(setting) { + validateNestedObjectFields(setting); + exitEditor(); +} + +function validateListFieldsAndExit(setting) { + validateListFields(setting); + exitEditor(); +} + +function assertFieldValidationError({ message, fieldLabel }) { + cy.contains('label', fieldLabel) + .siblings('ul[class*=ControlErrorsList]') + .contains(message); +} + +module.exports = { + login, + createPost, + createPostAndExit, + updateExistingPostAndExit, + exitEditor, + goToWorkflow, + goToCollections, + updateWorkflowStatus, + publishWorkflowEntry, + deleteWorkflowEntry, + assertWorkflowStatusInEditor, + assertPublishedEntry, + deleteEntryInEditor, + assertOnCollectionsPage, + assertEntryDeleted, + assertWorkflowStatus, + updateWorkflowStatusInEditor, + validateObjectFieldsAndExit, + validateNestedObjectFieldsAndExit, + validateListFieldsAndExit, +}; diff --git a/dev-test/backends/github/config.yml b/dev-test/backends/github/config.yml new file mode 100644 index 00000000..08a7bac2 --- /dev/null +++ b/dev-test/backends/github/config.yml @@ -0,0 +1,65 @@ +backend: + name: github + branch: master + repo: owner/repo + +publish_mode: editorial_workflow +media_folder: static/media +public_folder: /media +collections: + - name: posts + label: Posts + label_singular: 'Post' + folder: content/posts + create: true + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' + preview_path: 'posts/{{slug}}/index.html' + fields: + - label: Template + name: template + widget: hidden + default: post + - label: Title + name: title + widget: string + - label: Draft + name: draft + widget: boolean + default: true + - label: Publish Date + name: date + widget: datetime + - label: Description + name: description + widget: text + - label: Category + name: category + widget: string + - label: Body + name: body + widget: markdown + - label: Tags + name: tags + widget: list + - name: pages + label: Pages + label_singular: 'Page' + folder: content/pages + create: true + slug: '{{slug}}' + preview_path: 'pages/{{slug}}/index.html' + fields: + - label: Template + name: template + widget: hidden + default: page + - label: Title + name: title + widget: string + - label: Draft + name: draft + widget: boolean + default: true + - label: Body + name: body + widget: markdown diff --git a/dev-test/backends/github/index.html b/dev-test/backends/github/index.html new file mode 100644 index 00000000..103dbb27 --- /dev/null +++ b/dev-test/backends/github/index.html @@ -0,0 +1,41 @@ + + + + + + Netlify CMS Development Test + + + + + + diff --git a/dev-test/backends/test/config.yml b/dev-test/backends/test/config.yml new file mode 100644 index 00000000..3da9ab1f --- /dev/null +++ b/dev-test/backends/test/config.yml @@ -0,0 +1,231 @@ +backend: + name: test-repo + +site_url: https://example.com + +publish_mode: editorial_workflow +media_folder: assets/uploads + +collections: # A list of collections the CMS should be able to edit + - name: 'posts' # Used in routes, ie.: /admin/collections/:slug/edit + label: 'Posts' # Used in the UI + label_singular: 'Post' # Used in the UI, ie: "New Post" + description: > + The description is a great place for tone setting, high level information, and editing + guidelines that are specific to a collection. + folder: '_posts' + slug: '{{year}}-{{month}}-{{day}}-{{slug}}' + summary: '{{title}} -- {{year}}/{{month}}/{{day}}' + create: true # Allow users to create new documents in this collection + fields: # The fields each document in this collection have + - { label: 'Title', name: 'title', widget: 'string', tagname: 'h1' } + - { + label: 'Publish Date', + name: 'date', + widget: 'datetime', + dateFormat: 'YYYY-MM-DD', + timeFormat: 'HH:mm', + format: 'YYYY-MM-DD HH:mm', + } + - label: 'Cover Image' + name: 'image' + widget: 'image' + required: false + tagname: '' + + - { label: 'Body', name: 'body', widget: 'markdown', hint: 'Main content goes here.' } + meta: + - { label: 'SEO Description', name: 'description', widget: 'text' } + + - name: 'faq' # Used in routes, ie.: /admin/collections/:slug/edit + label: 'FAQ' # Used in the UI + folder: '_faqs' + create: true # Allow users to create new documents in this collection + fields: # The fields each document in this collection have + - { label: 'Question', name: 'title', widget: 'string', tagname: 'h1' } + - { label: 'Answer', name: 'body', widget: 'markdown' } + + - name: 'settings' + label: 'Settings' + delete: false # Prevent users from deleting documents in this collection + editor: + preview: false + files: + - name: 'general' + label: 'Site Settings' + file: '_data/settings.json' + description: 'General Site Settings' + fields: + - { label: 'Global title', name: 'site_title', widget: 'string' } + - label: 'Post Settings' + name: posts + widget: 'object' + fields: + - { + label: 'Number of posts on frontpage', + name: front_limit, + widget: number, + min: 1, + max: 10, + } + - { label: 'Default Author', name: author, widget: string } + - { label: 'Default Thumbnail', name: thumb, widget: image, class: 'thumb', required: false } + + - name: 'authors' + label: 'Authors' + file: '_data/authors.yml' + description: 'Author descriptions' + fields: + - name: authors + label: Authors + label_singular: 'Author' + widget: list + fields: + - { label: 'Name', name: 'name', widget: 'string', hint: 'First and Last' } + - { label: 'Description', name: 'description', widget: 'markdown' } + + - name: 'kitchenSink' # all the things in one entry, for documentation and quick testing + label: 'Kitchen Sink' + folder: '_sink' + create: true + fields: + - label: 'Related Post' + name: 'post' + widget: 'relationKitchenSinkPost' + collection: 'posts' + displayFields: ['title', 'date'] + searchFields: ['title', 'body'] + valueField: 'title' + - { label: 'Title', name: 'title', widget: 'string' } + - { label: 'Boolean', name: 'boolean', widget: 'boolean', default: true } + - { label: 'Map', name: 'map', widget: 'map' } + - { label: 'Text', name: 'text', widget: 'text', hint: 'Plain text, not markdown' } + - { label: 'Number', name: 'number', widget: 'number', hint: 'To infinity and beyond!' } + - { label: 'Markdown', name: 'markdown', widget: 'markdown' } + - { label: 'Datetime', name: 'datetime', widget: 'datetime' } + - { label: 'Date', name: 'date', widget: 'date' } + - { label: 'Image', name: 'image', widget: 'image' } + - { label: 'File', name: 'file', widget: 'file' } + - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } + - { + label: 'Select multiple', + name: 'select_multiple', + widget: 'select', + options: ['a', 'b', 'c'], + multiple: true, + } + - { label: 'Hidden', name: 'hidden', widget: 'hidden', default: 'hidden' } + - label: 'Object' + name: 'object' + widget: 'object' + fields: + - label: 'Related Post' + name: 'post' + widget: 'relationKitchenSinkPost' + collection: 'posts' + searchFields: ['title', 'body'] + valueField: 'title' + - { label: 'String', name: 'string', widget: 'string' } + - { label: 'Boolean', name: 'boolean', widget: 'boolean', default: false } + - { label: 'Text', name: 'text', widget: 'text' } + - { label: 'Number', name: 'number', widget: 'number' } + - { label: 'Markdown', name: 'markdown', widget: 'markdown' } + - { label: 'Datetime', name: 'datetime', widget: 'datetime' } + - { label: 'Date', name: 'date', widget: 'date' } + - { label: 'Image', name: 'image', widget: 'image' } + - { label: 'File', name: 'file', widget: 'file' } + - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } + - label: 'List' + name: 'list' + widget: 'list' + fields: + - { label: 'String', name: 'string', widget: 'string' } + - { label: 'Boolean', name: 'boolean', widget: 'boolean' } + - { label: 'Text', name: 'text', widget: 'text' } + - { label: 'Number', name: 'number', widget: 'number' } + - { label: 'Markdown', name: 'markdown', widget: 'markdown' } + - { label: 'Datetime', name: 'datetime', widget: 'datetime' } + - { label: 'Date', name: 'date', widget: 'date' } + - { label: 'Image', name: 'image', widget: 'image' } + - { label: 'File', name: 'file', widget: 'file' } + - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } + - label: 'Object' + name: 'object' + widget: 'object' + fields: + - { label: 'String', name: 'string', widget: 'string' } + - { label: 'Boolean', name: 'boolean', widget: 'boolean' } + - { label: 'Text', name: 'text', widget: 'text' } + - { label: 'Number', name: 'number', widget: 'number' } + - { label: 'Markdown', name: 'markdown', widget: 'markdown' } + - { label: 'Datetime', name: 'datetime', widget: 'datetime' } + - { label: 'Date', name: 'date', widget: 'date' } + - { label: 'Image', name: 'image', widget: 'image' } + - { label: 'File', name: 'file', widget: 'file' } + - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } + - label: 'List' + name: 'list' + widget: 'list' + fields: + - label: 'Related Post' + name: 'post' + widget: 'relationKitchenSinkPost' + collection: 'posts' + searchFields: ['title', 'body'] + valueField: 'title' + - { label: 'String', name: 'string', widget: 'string' } + - { label: 'Boolean', name: 'boolean', widget: 'boolean' } + - { label: 'Text', name: 'text', widget: 'text' } + - { label: 'Number', name: 'number', widget: 'number' } + - { label: 'Markdown', name: 'markdown', widget: 'markdown' } + - { label: 'Datetime', name: 'datetime', widget: 'datetime' } + - { label: 'Date', name: 'date', widget: 'date' } + - { label: 'Image', name: 'image', widget: 'image' } + - { label: 'File', name: 'file', widget: 'file' } + - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } + - { label: 'Hidden', name: 'hidden', widget: 'hidden', default: 'hidden' } + - label: 'Object' + name: 'object' + widget: 'object' + fields: + - { label: 'String', name: 'string', widget: 'string' } + - { label: 'Boolean', name: 'boolean', widget: 'boolean' } + - { label: 'Text', name: 'text', widget: 'text' } + - { label: 'Number', name: 'number', widget: 'number' } + - { label: 'Markdown', name: 'markdown', widget: 'markdown' } + - { label: 'Datetime', name: 'datetime', widget: 'datetime' } + - { label: 'Date', name: 'date', widget: 'date' } + - { label: 'Image', name: 'image', widget: 'image' } + - { label: 'File', name: 'file', widget: 'file' } + - { + label: 'Select', + name: 'select', + widget: 'select', + options: ['a', 'b', 'c'], + } + - label: 'Typed List' + name: 'typed_list' + widget: 'list' + types: + - label: 'Type 1 Object' + name: 'type_1_object' + widget: 'object' + fields: + - { label: 'String', name: 'string', widget: 'string' } + - { label: 'Boolean', name: 'boolean', widget: 'boolean' } + - { label: 'Text', name: 'text', widget: 'text' } + - label: 'Type 2 Object' + name: 'type_2_object' + widget: 'object' + fields: + - { label: 'Number', name: 'number', widget: 'number' } + - { label: 'Select', name: 'select', widget: 'select', options: ['a', 'b', 'c'] } + - { label: 'Datetime', name: 'datetime', widget: 'datetime' } + - { label: 'Markdown', name: 'markdown', widget: 'markdown' } + - label: 'Type 3 Object' + name: 'type_3_object' + widget: 'object' + fields: + - { label: 'Date', name: 'date', widget: 'date' } + - { label: 'Image', name: 'image', widget: 'image' } + - { label: 'File', name: 'file', widget: 'file' } diff --git a/dev-test/backends/test/index.html b/dev-test/backends/test/index.html new file mode 100644 index 00000000..742b8e62 --- /dev/null +++ b/dev-test/backends/test/index.html @@ -0,0 +1,208 @@ + + + + + + Netlify CMS Development Test + + + + + + + + diff --git a/package.json b/package.json index 1e975eed..960ba48f 100644 --- a/package.json +++ b/package.json @@ -72,6 +72,7 @@ "@babel/plugin-proposal-object-rest-spread": "^7.3.4", "@babel/preset-env": "^7.3.4", "@babel/preset-react": "^7.0.0", + "@octokit/rest": "^16.28.7", "all-contributors-cli": "^6.0.0", "babel-core": "^7.0.0-bridge.0", "babel-eslint": "^10.0.1", @@ -89,17 +90,21 @@ "copy-webpack-plugin": "^5.0.1", "cross-env": "^5.2.0", "css-loader": "^3.0.0", - "cypress": "^3.1.5", + "cypress": "^3.4.1", "dom-testing-library": "^4.0.0", + "dotenv": "^8.0.0", "eslint": "^5.15.1", + "eslint-plugin-cypress": "^2.6.0", "eslint-plugin-emotion": "^10.0.7", "eslint-plugin-react": "^7.12.4", "friendly-errors-webpack-plugin": "^1.7.0", + "fs-extra": "^8.1.0", "http-server": "^0.11.1", "jest": "^24.5.0", "jest-cli": "^24.5.0", "jest-dom": "^3.1.3", "jest-emotion": "^10.0.9", + "js-yaml": "^3.13.1", "ncp": "^2.0.0", "nock": "^10.0.4", "node-fetch": "^2.3.0", @@ -108,6 +113,7 @@ "react-test-renderer": "^16.8.4", "react-testing-library": "^7.0.0", "rimraf": "^2.6.3", + "simple-git": "^1.124.0", "start-server-and-test": "^1.7.11", "stylelint": "^9.10.1", "stylelint-config-recommended": "^2.1.0", diff --git a/packages/netlify-cms-backend-github/package.json b/packages/netlify-cms-backend-github/package.json index 4a71c87d..c11252ff 100644 --- a/packages/netlify-cms-backend-github/package.json +++ b/packages/netlify-cms-backend-github/package.json @@ -17,10 +17,17 @@ "scripts": { "develop": "yarn build:esm --watch", "build": "cross-env NODE_ENV=production webpack", - "build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore **/__tests__ --root-mode upward" + "build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore **/__tests__ --root-mode upward", + "createFragmentTypes": "node scripts/createFragmentTypes.js" }, "dependencies": { + "apollo-cache-inmemory": "^1.6.2", + "apollo-client": "^2.6.3", + "apollo-link-context": "^1.0.18", + "apollo-link-http": "^1.5.15", "common-tags": "^1.8.0", + "graphql": "^14.4.2", + "graphql-tag": "^2.10.1", "js-base64": "^2.5.1", "semaphore": "^1.1.0" }, diff --git a/packages/netlify-cms-backend-github/scripts/createFragmentTypes.js b/packages/netlify-cms-backend-github/scripts/createFragmentTypes.js new file mode 100644 index 00000000..c60835a9 --- /dev/null +++ b/packages/netlify-cms-backend-github/scripts/createFragmentTypes.js @@ -0,0 +1,48 @@ +const fetch = require('node-fetch'); +const fs = require('fs'); +const path = require('path'); + +const API_HOST = process.env.GITHUB_HOST || 'https://api.github.com'; +const API_TOKEN = process.env.GITHUB_API_TOKEN; + +if (!API_TOKEN) { + throw new Error('Missing environment variable GITHUB_API_TOKEN'); +} + +fetch(`${API_HOST}/graphql`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `bearer ${API_TOKEN}` }, + body: JSON.stringify({ + variables: {}, + query: ` + { + __schema { + types { + kind + name + possibleTypes { + name + } + } + } + } + `, + }), +}) + .then(result => result.json()) + .then(result => { + // here we're filtering out any type information unrelated to unions or interfaces + const filteredData = result.data.__schema.types.filter(type => type.possibleTypes !== null); + result.data.__schema.types = filteredData; + fs.writeFile( + path.join(__dirname, '..', 'src', 'fragmentTypes.js'), + `module.exports = ${JSON.stringify(result.data)}`, + err => { + if (err) { + console.error('Error writing fragmentTypes file', err); + } else { + console.log('Fragment types successfully extracted!'); + } + }, + ); + }); diff --git a/packages/netlify-cms-backend-github/src/API.js b/packages/netlify-cms-backend-github/src/API.js index 2353161e..9815ad33 100644 --- a/packages/netlify-cms-backend-github/src/API.js +++ b/packages/netlify-cms-backend-github/src/API.js @@ -3,7 +3,7 @@ import semaphore from 'semaphore'; import { find, flow, get, hasIn, initial, last, partial, result, uniq } from 'lodash'; import { map } from 'lodash/fp'; import { - getPaginatedRequestIterator, + getAllResponses, APIError, EditorialWorkflowError, filterPromisesWith, @@ -21,15 +21,19 @@ export default class API { this.api_root = config.api_root || 'https://api.github.com'; this.token = config.token || false; this.branch = config.branch || 'master'; - this.originRepo = config.originRepo; this.useOpenAuthoring = config.useOpenAuthoring; this.repo = config.repo || ''; + this.originRepo = config.originRepo || this.repo; this.repoURL = `/repos/${this.repo}`; - this.originRepoURL = this.originRepo && `/repos/${this.originRepo}`; + // when not in 'useOpenAuthoring' mode originRepoURL === repoURL + this.originRepoURL = `/repos/${this.originRepo}`; this.merge_method = config.squash_merges ? 'squash' : 'merge'; this.initialWorkflowStatus = config.initialWorkflowStatus; } + static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Netlify CMS'; + static DEFAULT_PR_BODY = 'Automatically generated by Netlify CMS'; + user() { if (!this._userPromise) { this._userPromise = this.request('/user'); @@ -113,13 +117,10 @@ export default class API { } async requestAllPages(url, options = {}) { + const headers = this.requestHeaders(options.headers || {}); const processedURL = this.urlFor(url, options); - const pagesIterator = getPaginatedRequestIterator(processedURL, options); - const pagesToParse = []; - for await (const page of pagesIterator) { - pagesToParse.push(this.parseResponse(page)); - } - const pages = await Promise.all(pagesToParse); + const allResponses = await getAllResponses(processedURL, { ...options, headers }); + const pages = await Promise.all(allResponses.map(res => this.parseResponse(res))); return [].concat(...pages); } @@ -224,34 +225,46 @@ export default class API { cache: 'no-store', }; + const errorHandler = err => { + if (err.message === 'Not Found') { + console.log( + '%c %s does not have metadata', + 'line-height: 30px;text-align: center;font-weight: bold', + key, + ); + } + throw err; + }; + if (!this.useOpenAuthoring) { return this.request(`${this.repoURL}/contents/${key}.json`, metadataRequestOptions) .then(response => JSON.parse(response)) - .catch(err => { - if (err.message === 'Not Found') { - console.log( - '%c %s does not have metadata', - 'line-height: 30px;text-align: center;font-weight: bold', - key, - ); - } - throw err; - }); + .catch(errorHandler); } const [user, repo] = key.split('/'); return this.request(`/repos/${user}/${repo}/contents/${key}.json`, metadataRequestOptions) .then(response => JSON.parse(response)) - .catch(err => { - if (err.message === 'Not Found') { - console.log( - '%c %s does not have metadata', - 'line-height: 30px;text-align: center;font-weight: bold', - key, - ); - } - throw err; - }); + .catch(errorHandler); + }); + } + + retrieveContent(path, branch, repoURL) { + return this.request(`${repoURL}/contents/${path}`, { + headers: { Accept: 'application/vnd.github.VERSION.raw' }, + params: { ref: branch }, + cache: 'no-store', + }).catch(error => { + if (hasIn(error, 'message.errors') && find(error.message.errors, { code: 'too_large' })) { + const dir = path + .split('/') + .slice(0, -1) + .join('/'); + return this.listFiles(dir, { repoURL, branch }) + .then(files => files.find(file => file.path === path)) + .then(file => this.getBlob(file.sha, { repoURL })); + } + throw error; }); } @@ -259,34 +272,23 @@ export default class API { if (sha) { return this.getBlob(sha); } else { - return this.request(`${repoURL}/contents/${path}`, { - headers: { Accept: 'application/vnd.github.VERSION.raw' }, - params: { ref: branch }, - cache: 'no-store', - }).catch(error => { - if (hasIn(error, 'message.errors') && find(error.message.errors, { code: 'too_large' })) { - const dir = path - .split('/') - .slice(0, -1) - .join('/'); - return this.listFiles(dir, { repoURL, branch }) - .then(files => files.find(file => file.path === path)) - .then(file => this.getBlob(file.sha, { repoURL })); - } - throw error; - }); + return this.retrieveContent(path, branch, repoURL); } } + retrieveBlob(sha, repoURL) { + return this.request(`${repoURL}/git/blobs/${sha}`, { + headers: { Accept: 'application/vnd.github.VERSION.raw' }, + }); + } + getBlob(sha, { repoURL = this.repoURL } = {}) { return localForage.getItem(`gh.${sha}`).then(cached => { if (cached) { return cached; } - return this.request(`${repoURL}/git/blobs/${sha}`, { - headers: { Accept: 'application/vnd.github.VERSION.raw' }, - }).then(result => { + return this.retrieveBlob(sha, repoURL).then(result => { localForage.setItem(`gh.${sha}`, result); return result; }); @@ -335,7 +337,7 @@ export default class API { isUnpublishedEntryModification(path, branch) { return this.readFile(path, null, { branch, - repoURL: this.useOpenAuthoring ? this.originRepoURL : this.repoURL, + repoURL: this.originRepoURL, }) .then(() => true) .catch(err => { @@ -385,7 +387,7 @@ export default class API { // closed or not and update the status accordingly. if (prMetadata) { const { number: prNumber } = prMetadata; - const originPRInfo = await this.request(`${this.originRepoURL}/pulls/${prNumber}`); + const originPRInfo = await this.getPullRequest(prNumber); const { state: currentState, merged_at: mergedAt } = originPRInfo; if (currentState === 'closed' && mergedAt) { // The PR has been merged; delete the unpublished entry @@ -453,9 +455,8 @@ export default class API { * concept of entry "status". Useful for things like deploy preview links. */ async getStatuses(sha) { - const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; try { - const resp = await this.request(`${repoURL}/commits/${sha}/status`); + const resp = await this.request(`${this.originRepoURL}/commits/${sha}/status`); return resp.statuses; } catch (err) { if (err && err.message && err.message === 'Ref not found') { @@ -517,25 +518,33 @@ export default class API { }); } - deleteFile(path, message, options = {}) { - if (this.useOpenAuthoring) { - return Promise.reject('Cannot delete published entries as an Open Authoring user!'); - } - const branch = options.branch || this.branch; - const pathArray = path.split('/'); - const filename = last(pathArray); - const directory = initial(pathArray).join('/'); - const fileDataPath = encodeURIComponent(directory); - const fileDataURL = `${this.repoURL}/git/trees/${branch}:${fileDataPath}`; - const fileURL = `${this.repoURL}/contents/${path}`; - + getFileSha(path, branch) { /** * We need to request the tree first to get the SHA. We use extended SHA-1 * syntax (:) to get a blob from a tree without having to recurse * through the tree. */ + + const pathArray = path.split('/'); + const filename = last(pathArray); + const directory = initial(pathArray).join('/'); + const fileDataPath = encodeURIComponent(directory); + const fileDataURL = `${this.repoURL}/git/trees/${branch}:${fileDataPath}`; + return this.request(fileDataURL, { cache: 'no-store' }).then(resp => { const { sha } = resp.tree.find(file => file.path === filename); + return sha; + }); + } + + deleteFile(path, message, options = {}) { + if (this.useOpenAuthoring) { + return Promise.reject('Cannot delete published entries as an Open Authoring user!'); + } + + const branch = options.branch || this.branch; + + return this.getFileSha(path, branch).then(sha => { const opts = { method: 'DELETE', params: { sha, message, branch } }; if (this.commitAuthor) { opts.params.author = { @@ -543,10 +552,16 @@ export default class API { date: new Date().toISOString(), }; } + const fileURL = `${this.repoURL}/contents/${path}`; return this.request(fileURL, opts); }); } + async createBranchAndPullRequest(branchName, sha, commitMessage) { + await this.createBranch(branchName, sha); + return this.createPR(commitMessage, branchName); + } + async editorialWorkflowGit(fileTree, entry, filesList, options) { const contentKey = this.generateContentKey(options.collectionName, entry.slug); const branchName = this.generateBranchName(contentKey); @@ -557,10 +572,18 @@ export default class API { const branchData = await this.getBranch(); const changeTree = await this.updateTree(branchData.commit.sha, '/', fileTree); const commitResponse = await this.commit(options.commitMessage, changeTree); - await this.createBranch(branchName, commitResponse.sha); - const pr = this.useOpenAuthoring - ? undefined - : await this.createPR(options.commitMessage, branchName); + + let pr; + if (this.useOpenAuthoring) { + await this.createBranch(branchName, commitResponse.sha); + } else { + pr = await this.createBranchAndPullRequest( + branchName, + commitResponse.sha, + options.commitMessage, + ); + } + const user = await userPromise; return this.storeMetadata(contentKey, { type: 'PR', @@ -610,6 +633,9 @@ export default class API { if (pr) { return this.rebasePullRequest(pr.number, branchName, contentKey, metadata, commit); + } else if (this.useOpenAuthoring) { + // if a PR hasn't been created yet for the forked repo, just patch the branch + await this.patchBranch(branchName, commit.sha, { force: true }); } return this.storeMetadata(contentKey, updatedMetadata); @@ -629,8 +655,10 @@ export default class API { * Get the published branch and create new commits over it. If the pull * request is up to date, no rebase will occur. */ - const baseBranch = await this.getBranch(); - const commits = await this.getPullRequestCommits(prNumber, head); + const [baseBranch, commits] = await Promise.all([ + this.getBranch(), + this.getPullRequestCommits(prNumber, head), + ]); /** * Sometimes the list of commits for a pull request isn't updated @@ -731,16 +759,14 @@ export default class API { * Get a pull request by PR number. */ getPullRequest(prNumber) { - const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; - return this.request(`${repoURL}/pulls/${prNumber} }`); + return this.request(`${this.originRepoURL}/pulls/${prNumber} }`); } /** * Get the list of commits for a given pull request. */ getPullRequestCommits(prNumber) { - const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; - return this.request(`${repoURL}/pulls/${prNumber}/commits`); + return this.request(`${this.originRepoURL}/pulls/${prNumber}/commits`); } /** @@ -779,7 +805,7 @@ export default class API { const { pr: prMetadata } = metadata; if (prMetadata) { const { number: prNumber } = prMetadata; - const originPRInfo = await this.request(`${this.originRepoURL}/pulls/${prNumber}`); + const originPRInfo = await this.getPullRequest(prNumber); const { state } = originPRInfo; if (state === 'open' && status === 'draft') { await this.closePR(prMetadata); @@ -800,7 +826,7 @@ export default class API { if (!prMetadata && status === 'pending_review') { const branchName = this.generateBranchName(contentKey); - const commitMessage = metadata.commitMessage || 'Automatically generated by Netlify CMS'; + const commitMessage = metadata.commitMessage || API.DEFAULT_COMMIT_MESSAGE; const { number, head } = await this.createPR(commitMessage, branchName); return this.storeMetadata(contentKey, { ...metadata, @@ -883,21 +909,23 @@ export default class API { return this.deleteRef('heads', branchName); } - async createPR(title, head, base = this.branch) { - const body = 'Automatically generated by Netlify CMS'; - const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; + async createPR(title, head) { const headReference = this.useOpenAuthoring ? `${(await this.user()).login}:${head}` : head; - return this.request(`${repoURL}/pulls`, { + return this.request(`${this.originRepoURL}/pulls`, { method: 'POST', - body: JSON.stringify({ title, body, head: headReference, base }), + body: JSON.stringify({ + title, + body: API.DEFAULT_PR_BODY, + head: headReference, + base: this.branch, + }), }); } async openPR(pullRequest) { const { number } = pullRequest; - const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; console.log('%c Re-opening PR', 'line-height: 30px;text-align: center;font-weight: bold'); - return this.request(`${repoURL}/pulls/${number}`, { + return this.request(`${this.originRepoURL}/pulls/${number}`, { method: 'PATCH', body: JSON.stringify({ state: 'open', @@ -905,11 +933,10 @@ export default class API { }); } - closePR(pullrequest) { - const prNumber = pullrequest.number; - const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; + closePR(pullRequest) { + const { number } = pullRequest; console.log('%c Deleting PR', 'line-height: 30px;text-align: center;font-weight: bold'); - return this.request(`${repoURL}/pulls/${prNumber}`, { + return this.request(`${this.originRepoURL}/pulls/${number}`, { method: 'PATCH', body: JSON.stringify({ state: 'closed', @@ -918,11 +945,9 @@ export default class API { } mergePR(pullrequest, objects) { - const headSha = pullrequest.head; - const prNumber = pullrequest.number; - const repoURL = this.useOpenAuthoring ? this.originRepoURL : this.repoURL; + const { head: headSha, number } = pullrequest; console.log('%c Merging PR', 'line-height: 30px;text-align: center;font-weight: bold'); - return this.request(`${repoURL}/pulls/${prNumber}/merge`, { + return this.request(`${this.originRepoURL}/pulls/${number}/merge`, { method: 'PUT', body: JSON.stringify({ commit_message: 'Automatically generated. Merged on Netlify CMS.', diff --git a/packages/netlify-cms-backend-github/src/GraphQLAPI.js b/packages/netlify-cms-backend-github/src/GraphQLAPI.js new file mode 100644 index 00000000..d324f7d9 --- /dev/null +++ b/packages/netlify-cms-backend-github/src/GraphQLAPI.js @@ -0,0 +1,627 @@ +import { ApolloClient } from 'apollo-client'; +import { + InMemoryCache, + defaultDataIdFromObject, + IntrospectionFragmentMatcher, +} from 'apollo-cache-inmemory'; +import { createHttpLink } from 'apollo-link-http'; +import { setContext } from 'apollo-link-context'; +import { APIError, EditorialWorkflowError } from 'netlify-cms-lib-util'; +import introspectionQueryResultData from './fragmentTypes'; +import API from './API'; +import * as queries from './queries'; +import * as mutations from './mutations'; + +const NO_CACHE = 'no-cache'; +const CACHE_FIRST = 'cache-first'; + +const TREE_ENTRY_TYPE_TO_MODE = { + blob: '100644', + tree: '040000', + commit: '160000', +}; + +const fragmentMatcher = new IntrospectionFragmentMatcher({ + introspectionQueryResultData, +}); + +export default class GraphQLAPI extends API { + constructor(config) { + super(config); + + const [repoParts, originRepoParts] = [this.repo.split('/'), this.originRepo.split('/')]; + this.repo_owner = repoParts[0]; + this.repo_name = repoParts[1]; + + this.origin_repo_owner = originRepoParts[0]; + this.origin_repo_name = originRepoParts[1]; + + this.client = this.getApolloClient(); + } + + getApolloClient() { + const authLink = setContext((_, { headers }) => { + return { + headers: { + ...headers, + authorization: this.token ? `token ${this.token}` : '', + }, + }; + }); + const httpLink = createHttpLink({ uri: `${this.api_root}/graphql` }); + return new ApolloClient({ + link: authLink.concat(httpLink), + cache: new InMemoryCache({ fragmentMatcher }), + defaultOptions: { + watchQuery: { + fetchPolicy: NO_CACHE, + errorPolicy: 'ignore', + }, + query: { + fetchPolicy: NO_CACHE, + errorPolicy: 'all', + }, + }, + }); + } + + reset() { + return this.client.resetStore(); + } + + async getRepository(owner, name) { + const { data } = await this.query({ + query: queries.repository, + variables: { owner, name }, + fetchPolicy: CACHE_FIRST, // repository id doesn't change + }); + return data.repository; + } + + query(options = {}) { + return this.client.query(options).catch(error => { + throw new APIError(error.message, 500, 'GitHub'); + }); + } + + mutate(options = {}) { + return this.client.mutate(options).catch(error => { + throw new APIError(error.message, 500, 'GitHub'); + }); + } + + async hasWriteAccess() { + const { repo_owner: owner, repo_name: name } = this; + try { + const { data } = await this.query({ + query: queries.repoPermission, + variables: { owner, name }, + fetchPolicy: CACHE_FIRST, // we can assume permission doesn't change often + }); + // https://developer.github.com/v4/enum/repositorypermission/ + const { viewerPermission } = data.repository; + return ['ADMIN', 'MAINTAIN', 'WRITE'].includes(viewerPermission); + } catch (error) { + console.error('Problem fetching repo data from GitHub'); + throw error; + } + } + + async user() { + const { data } = await this.query({ + query: queries.user, + fetchPolicy: CACHE_FIRST, // we can assume user details don't change often + }); + return data.viewer; + } + + async retrieveBlobObject(owner, name, expression, options = {}) { + const { data } = await this.query({ + query: queries.blob, + variables: { owner, name, expression }, + ...options, + }); + // https://developer.github.com/v4/object/blob/ + if (data.repository.object) { + const { is_binary, text } = data.repository.object; + return { is_null: false, is_binary, text }; + } else { + return { is_null: true }; + } + } + + getOwnerAndNameFromRepoUrl(repoURL) { + let { repo_owner: owner, repo_name: name } = this; + + if (repoURL === this.originRepoURL) { + ({ origin_repo_owner: owner, origin_repo_name: name } = this); + } + + return { owner, name }; + } + + async retrieveContent(path, branch, repoURL) { + const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL); + const { is_null, is_binary, text } = await this.retrieveBlobObject( + owner, + name, + `${branch}:${path}`, + ); + if (is_null) { + throw new APIError('Not Found', 404, 'GitHub'); + } else if (!is_binary) { + return text; + } else { + return super.retrieveContent(path, branch, repoURL); + } + } + + async retrieveBlob(sha, repoURL) { + const { owner, name } = this.getOwnerAndNameFromRepoUrl(repoURL); + const { is_null, is_binary, text } = await this.retrieveBlobObject( + owner, + name, + sha, + { fetchPolicy: CACHE_FIRST }, // blob sha is derived from file content + ); + + if (is_null) { + throw new APIError('Not Found', 404, 'GitHub'); + } else if (!is_binary) { + return text; + } else { + return super.retrieveBlob(sha); + } + } + + async getStatuses(sha) { + const { origin_repo_owner: owner, origin_repo_name: name } = this; + const { data } = await this.query({ query: queries.statues, variables: { owner, name, sha } }); + if (data.repository.object) { + const { status } = data.repository.object; + const { contexts } = status || { contexts: [] }; + return contexts; + } else { + return []; + } + } + + async listFiles(path) { + const { repo_owner: owner, repo_name: name } = this; + const { data } = await this.query({ + query: queries.files, + variables: { owner, name, expression: `${this.branch}:${path}` }, + }); + + if (data.repository.object) { + const files = data.repository.object.entries.map(e => ({ + ...e, + path: `${path}/${e.name}`, + download_url: `https://raw.githubusercontent.com/${this.repo}/${this.branch}/${path}/${e.name}`, + size: e.blob && e.blob.size, + })); + return files; + } else { + throw new APIError('Not Found', 404, 'GitHub'); + } + } + + async listUnpublishedBranches() { + if (this.useOpenAuthoring) { + return super.listUnpublishedBranches(); + } + console.log( + '%c Checking for Unpublished entries', + 'line-height: 30px;text-align: center;font-weight: bold', + ); + const { repo_owner: owner, repo_name: name } = this; + const { data } = await this.query({ + query: queries.unpublishedPrBranches, + variables: { owner, name }, + }); + const { nodes } = data.repository.refs; + if (nodes.length > 0) { + const branches = []; + nodes.forEach(({ associatedPullRequests }) => { + associatedPullRequests.nodes.forEach(({ headRef }) => { + branches.push({ ref: `${headRef.prefix}${headRef.name}` }); + }); + }); + return branches; + } else { + console.log( + '%c No Unpublished entries', + 'line-height: 30px;text-align: center;font-weight: bold', + ); + throw new APIError('Not Found', 404, 'GitHub'); + } + } + + async readUnpublishedBranchFile(contentKey) { + // retrieveMetadata(contentKey) rejects in case of no metadata + const metaData = await this.retrieveMetadata(contentKey).catch(() => null); + if (metaData && metaData.objects && metaData.objects.entry && metaData.objects.entry.path) { + const { path } = metaData.objects.entry; + const { repo_owner: headOwner, repo_name: headRepoName } = this; + const { origin_repo_owner: baseOwner, origin_repo_name: baseRepoName } = this; + + const { data } = await this.query({ + query: queries.unpublishedBranchFile, + variables: { + headOwner, + headRepoName, + headExpression: `${metaData.branch}:${path}`, + baseOwner, + baseRepoName, + baseExpression: `${this.branch}:${path}`, + }, + }); + if (!data.head.object) { + throw new EditorialWorkflowError('content is not under editorial workflow', true); + } + const result = { + metaData, + fileData: data.head.object.text, + isModification: !!data.base.object, + }; + return result; + } else { + throw new EditorialWorkflowError('content is not under editorial workflow', true); + } + } + + getBranchQualifiedName(branch) { + return `refs/heads/${branch}`; + } + + getBranchQuery(branch) { + const { repo_owner: owner, repo_name: name } = this; + + return { + query: queries.branch, + variables: { + owner, + name, + qualifiedName: this.getBranchQualifiedName(branch), + }, + }; + } + + async getBranch(branch = this.branch) { + // don't cache base branch to always get the latest data + const fetchPolicy = branch === this.branch ? NO_CACHE : CACHE_FIRST; + const { data } = await this.query({ + ...this.getBranchQuery(branch), + fetchPolicy, + }); + return data.repository.branch; + } + + async patchRef(type, name, sha, opts = {}) { + if (type !== 'heads') { + return super.patchRef(type, name, sha, opts); + } + + const force = opts.force || false; + + const branch = await this.getBranch(name); + const { data } = await this.mutate({ + mutation: mutations.updateBranch, + variables: { + input: { oid: sha, refId: branch.id, force }, + }, + }); + return data.updateRef.branch; + } + + async deleteBranch(branchName) { + const branch = await this.getBranch(branchName); + const { data } = await this.mutate({ + mutation: mutations.deleteBranch, + variables: { + deleteRefInput: { refId: branch.id }, + }, + update: store => store.data.delete(defaultDataIdFromObject(branch)), + }); + + return data.deleteRef; + } + + getPullRequestQuery(number) { + const { origin_repo_owner: owner, origin_repo_name: name } = this; + + return { + query: queries.pullRequest, + variables: { owner, name, number }, + }; + } + + async getPullRequest(number) { + const { data } = await this.query({ + ...this.getPullRequestQuery(number), + fetchPolicy: CACHE_FIRST, + }); + + // https://developer.github.com/v4/enum/pullrequeststate/ + // GraphQL state: [CLOSED, MERGED, OPEN] + // REST API state: [closed, open] + const state = data.repository.pullRequest.state === 'OPEN' ? 'open' : 'closed'; + return { + ...data.repository.pullRequest, + state, + }; + } + + getPullRequestAndBranchQuery(branch, number) { + const { repo_owner: owner, repo_name: name } = this; + const { origin_repo_owner: origin_owner, origin_repo_name: origin_name } = this; + + return { + query: queries.pullRequestAndBranch, + variables: { + owner, + name, + origin_owner, + origin_name, + number, + qualifiedName: this.getBranchQualifiedName(branch), + }, + }; + } + + async getPullRequestAndBranch(branch, number) { + const { data } = await this.query({ + ...this.getPullRequestAndBranchQuery(branch, number), + fetchPolicy: CACHE_FIRST, + }); + + const { repository, origin } = data; + return { branch: repository.branch, pullRequest: origin.pullRequest }; + } + + async openPR({ number }) { + const pullRequest = await this.getPullRequest(number); + + const { data } = await this.mutate({ + mutation: mutations.reopenPullRequest, + variables: { + reopenPullRequestInput: { pullRequestId: pullRequest.id }, + }, + update: (store, { data: mutationResult }) => { + const { pullRequest } = mutationResult.reopenPullRequest; + const pullRequestData = { repository: { ...pullRequest.repository, pullRequest } }; + + store.writeQuery({ + ...this.getPullRequestQuery(pullRequest.number), + data: pullRequestData, + }); + }, + }); + + return data.closePullRequest; + } + + async closePR({ number }) { + const pullRequest = await this.getPullRequest(number); + + const { data } = await this.mutate({ + mutation: mutations.closePullRequest, + variables: { + closePullRequestInput: { pullRequestId: pullRequest.id }, + }, + update: (store, { data: mutationResult }) => { + const { pullRequest } = mutationResult.closePullRequest; + const pullRequestData = { repository: { ...pullRequest.repository, pullRequest } }; + + store.writeQuery({ + ...this.getPullRequestQuery(pullRequest.number), + data: pullRequestData, + }); + }, + }); + + return data.closePullRequest; + } + + async deleteUnpublishedEntry(collectionName, slug) { + try { + const contentKey = this.generateContentKey(collectionName, slug); + const branchName = this.generateBranchName(contentKey); + + const metadata = await this.retrieveMetadata(contentKey); + if (metadata && metadata.pr) { + const { branch, pullRequest } = await this.getPullRequestAndBranch( + branchName, + metadata.pr.number, + ); + + const { data } = await this.mutate({ + mutation: mutations.closePullRequestAndDeleteBranch, + variables: { + deleteRefInput: { refId: branch.id }, + closePullRequestInput: { pullRequestId: pullRequest.id }, + }, + update: store => { + store.data.delete(defaultDataIdFromObject(branch)); + store.data.delete(defaultDataIdFromObject(pullRequest)); + }, + }); + + return data.closePullRequest; + } else { + return await this.deleteBranch(branchName); + } + } catch (e) { + const { graphQLErrors } = e; + if (graphQLErrors && graphQLErrors.length > 0) { + const branchNotFound = graphQLErrors.some(e => e.type === 'NOT_FOUND'); + if (branchNotFound) { + return; + } + } + throw e; + } + } + + async createPR(title, head) { + const [repository, headReference] = await Promise.all([ + this.getRepository(this.origin_repo_owner, this.origin_repo_name), + this.useOpenAuthoring ? `${(await this.user()).login}:${head}` : head, + ]); + const { data } = await this.mutate({ + mutation: mutations.createPullRequest, + variables: { + createPullRequestInput: { + baseRefName: this.branch, + body: API.DEFAULT_PR_BODY, + title, + headRefName: headReference, + repositoryId: repository.id, + }, + }, + update: (store, { data: mutationResult }) => { + const { pullRequest } = mutationResult.createPullRequest; + const pullRequestData = { repository: { ...pullRequest.repository, pullRequest } }; + + store.writeQuery({ + ...this.getPullRequestQuery(pullRequest.number), + data: pullRequestData, + }); + }, + }); + const { pullRequest } = data.createPullRequest; + return { ...pullRequest, head: { sha: pullRequest.headRefOid } }; + } + + async createBranch(branchName, sha) { + const repository = await this.getRepository(this.repo_owner, this.repo_name); + const { data } = await this.mutate({ + mutation: mutations.createBranch, + variables: { + createRefInput: { + name: this.getBranchQualifiedName(branchName), + oid: sha, + repositoryId: repository.id, + }, + }, + update: (store, { data: mutationResult }) => { + const { branch } = mutationResult.createRef; + const branchData = { repository: { ...branch.repository, branch } }; + + store.writeQuery({ + ...this.getBranchQuery(branchName), + data: branchData, + }); + }, + }); + const { branch } = data.createRef; + return branch; + } + + async createBranchAndPullRequest(branchName, sha, title) { + const repository = await this.getRepository(this.origin_repo_owner, this.origin_repo_name); + const { data } = await this.mutate({ + mutation: mutations.createBranchAndPullRequest, + variables: { + createRefInput: { + name: this.getBranchQualifiedName(branchName), + oid: sha, + repositoryId: repository.id, + }, + createPullRequestInput: { + baseRefName: this.branch, + body: API.DEFAULT_PR_BODY, + title, + headRefName: branchName, + repositoryId: repository.id, + }, + }, + update: (store, { data: mutationResult }) => { + const { branch } = mutationResult.createRef; + const { pullRequest } = mutationResult.createPullRequest; + const branchData = { repository: { ...branch.repository, branch } }; + const pullRequestData = { + repository: { ...pullRequest.repository, branch }, + origin: { ...pullRequest.repository, pullRequest }, + }; + + store.writeQuery({ + ...this.getBranchQuery(branchName), + data: branchData, + }); + + store.writeQuery({ + ...this.getPullRequestAndBranchQuery(branchName, pullRequest.number), + data: pullRequestData, + }); + }, + }); + const { pullRequest } = data.createPullRequest; + return { ...pullRequest, head: { sha: pullRequest.headRefOid } }; + } + + async getTree(sha) { + if (!sha) { + return Promise.resolve({ tree: [] }); + } + + const { repo_owner: owner, repo_name: name } = this; + const variables = { + owner, + name, + sha, + }; + + // sha can be either for a commit or a tree + const [commitTree, tree] = await Promise.all([ + this.client.query({ + query: queries.commitTree, + variables, + fetchPolicy: CACHE_FIRST, + }), + this.client.query({ + query: queries.tree, + variables, + fetchPolicy: CACHE_FIRST, + }), + ]); + + let entries = null; + + if (commitTree.data.repository.commit.tree) { + entries = commitTree.data.repository.commit.tree.entries; + } + + if (tree.data.repository.tree.entries) { + entries = tree.data.repository.tree.entries; + } + + if (entries) { + return { tree: entries.map(e => ({ ...e, mode: TREE_ENTRY_TYPE_TO_MODE[e.type] })) }; + } + + return Promise.reject('Could not get tree'); + } + + async getPullRequestCommits(number) { + const { origin_repo_owner: owner, origin_repo_name: name } = this; + const { data } = await this.query({ + query: queries.pullRequestCommits, + variables: { owner, name, number }, + }); + const { nodes } = data.repository.pullRequest.commits; + const commits = nodes.map(n => ({ ...n.commit, parents: n.commit.parents.nodes })); + + return commits; + } + + async getFileSha(path, branch) { + const { repo_owner: owner, repo_name: name } = this; + const { data } = await this.query({ + query: queries.fileSha, + variables: { owner, name, expression: `${branch}:${path}` }, + }); + + return data.repository.file.sha; + } +} diff --git a/packages/netlify-cms-backend-github/src/fragmentTypes.js b/packages/netlify-cms-backend-github/src/fragmentTypes.js new file mode 100644 index 00000000..2a654d96 --- /dev/null +++ b/packages/netlify-cms-backend-github/src/fragmentTypes.js @@ -0,0 +1 @@ +module.exports = {"__schema":{"types":[{"kind":"INTERFACE","name":"Node","possibleTypes":[{"name":"AddedToProjectEvent"},{"name":"App"},{"name":"AssignedEvent"},{"name":"BaseRefChangedEvent"},{"name":"BaseRefForcePushedEvent"},{"name":"Blob"},{"name":"Bot"},{"name":"BranchProtectionRule"},{"name":"ClosedEvent"},{"name":"CodeOfConduct"},{"name":"CommentDeletedEvent"},{"name":"Commit"},{"name":"CommitComment"},{"name":"CommitCommentThread"},{"name":"ConvertedNoteToIssueEvent"},{"name":"CrossReferencedEvent"},{"name":"DemilestonedEvent"},{"name":"DeployKey"},{"name":"DeployedEvent"},{"name":"Deployment"},{"name":"DeploymentEnvironmentChangedEvent"},{"name":"DeploymentStatus"},{"name":"ExternalIdentity"},{"name":"Gist"},{"name":"GistComment"},{"name":"HeadRefDeletedEvent"},{"name":"HeadRefForcePushedEvent"},{"name":"HeadRefRestoredEvent"},{"name":"Issue"},{"name":"IssueComment"},{"name":"Label"},{"name":"LabeledEvent"},{"name":"Language"},{"name":"License"},{"name":"LockedEvent"},{"name":"Mannequin"},{"name":"MarketplaceCategory"},{"name":"MarketplaceListing"},{"name":"MentionedEvent"},{"name":"MergedEvent"},{"name":"Milestone"},{"name":"MilestonedEvent"},{"name":"MovedColumnsInProjectEvent"},{"name":"Organization"},{"name":"OrganizationIdentityProvider"},{"name":"OrganizationInvitation"},{"name":"PinnedEvent"},{"name":"Project"},{"name":"ProjectCard"},{"name":"ProjectColumn"},{"name":"PublicKey"},{"name":"PullRequest"},{"name":"PullRequestCommit"},{"name":"PullRequestCommitCommentThread"},{"name":"PullRequestReview"},{"name":"PullRequestReviewComment"},{"name":"PullRequestReviewThread"},{"name":"PushAllowance"},{"name":"Reaction"},{"name":"ReadyForReviewEvent"},{"name":"Ref"},{"name":"ReferencedEvent"},{"name":"RegistryPackage"},{"name":"RegistryPackageDependency"},{"name":"RegistryPackageFile"},{"name":"RegistryPackageTag"},{"name":"RegistryPackageVersion"},{"name":"Release"},{"name":"ReleaseAsset"},{"name":"RemovedFromProjectEvent"},{"name":"RenamedTitleEvent"},{"name":"ReopenedEvent"},{"name":"Repository"},{"name":"RepositoryInvitation"},{"name":"RepositoryTopic"},{"name":"ReviewDismissalAllowance"},{"name":"ReviewDismissedEvent"},{"name":"ReviewRequest"},{"name":"ReviewRequestRemovedEvent"},{"name":"ReviewRequestedEvent"},{"name":"SavedReply"},{"name":"SecurityAdvisory"},{"name":"SponsorsListing"},{"name":"Sponsorship"},{"name":"Status"},{"name":"StatusContext"},{"name":"SubscribedEvent"},{"name":"Tag"},{"name":"Team"},{"name":"Topic"},{"name":"TransferredEvent"},{"name":"Tree"},{"name":"UnassignedEvent"},{"name":"UnlabeledEvent"},{"name":"UnlockedEvent"},{"name":"UnpinnedEvent"},{"name":"UnsubscribedEvent"},{"name":"User"},{"name":"UserBlockedEvent"},{"name":"UserContentEdit"},{"name":"UserStatus"}]},{"kind":"INTERFACE","name":"UniformResourceLocatable","possibleTypes":[{"name":"Bot"},{"name":"ClosedEvent"},{"name":"Commit"},{"name":"CrossReferencedEvent"},{"name":"Gist"},{"name":"Issue"},{"name":"Mannequin"},{"name":"MergedEvent"},{"name":"Milestone"},{"name":"Organization"},{"name":"PullRequest"},{"name":"PullRequestCommit"},{"name":"ReadyForReviewEvent"},{"name":"Release"},{"name":"Repository"},{"name":"RepositoryTopic"},{"name":"ReviewDismissedEvent"},{"name":"User"}]},{"kind":"INTERFACE","name":"Actor","possibleTypes":[{"name":"Bot"},{"name":"Mannequin"},{"name":"Organization"},{"name":"User"}]},{"kind":"INTERFACE","name":"RegistryPackageOwner","possibleTypes":[{"name":"Organization"},{"name":"Repository"},{"name":"User"}]},{"kind":"INTERFACE","name":"ProjectOwner","possibleTypes":[{"name":"Organization"},{"name":"Repository"},{"name":"User"}]},{"kind":"INTERFACE","name":"Closable","possibleTypes":[{"name":"Issue"},{"name":"Milestone"},{"name":"Project"},{"name":"PullRequest"}]},{"kind":"INTERFACE","name":"Updatable","possibleTypes":[{"name":"CommitComment"},{"name":"GistComment"},{"name":"Issue"},{"name":"IssueComment"},{"name":"Project"},{"name":"PullRequest"},{"name":"PullRequestReview"},{"name":"PullRequestReviewComment"}]},{"kind":"UNION","name":"ProjectCardItem","possibleTypes":[{"name":"Issue"},{"name":"PullRequest"}]},{"kind":"INTERFACE","name":"Assignable","possibleTypes":[{"name":"Issue"},{"name":"PullRequest"}]},{"kind":"INTERFACE","name":"Comment","possibleTypes":[{"name":"CommitComment"},{"name":"GistComment"},{"name":"Issue"},{"name":"IssueComment"},{"name":"PullRequest"},{"name":"PullRequestReview"},{"name":"PullRequestReviewComment"}]},{"kind":"INTERFACE","name":"UpdatableComment","possibleTypes":[{"name":"CommitComment"},{"name":"GistComment"},{"name":"Issue"},{"name":"IssueComment"},{"name":"PullRequest"},{"name":"PullRequestReview"},{"name":"PullRequestReviewComment"}]},{"kind":"INTERFACE","name":"Labelable","possibleTypes":[{"name":"Issue"},{"name":"PullRequest"}]},{"kind":"INTERFACE","name":"Lockable","possibleTypes":[{"name":"Issue"},{"name":"PullRequest"}]},{"kind":"INTERFACE","name":"RegistryPackageSearch","possibleTypes":[{"name":"Organization"},{"name":"User"}]},{"kind":"INTERFACE","name":"RepositoryOwner","possibleTypes":[{"name":"Organization"},{"name":"User"}]},{"kind":"INTERFACE","name":"MemberStatusable","possibleTypes":[{"name":"Organization"},{"name":"Team"}]},{"kind":"INTERFACE","name":"ProfileOwner","possibleTypes":[{"name":"Organization"},{"name":"User"}]},{"kind":"UNION","name":"PinnableItem","possibleTypes":[{"name":"Gist"},{"name":"Repository"}]},{"kind":"INTERFACE","name":"Starrable","possibleTypes":[{"name":"Gist"},{"name":"Repository"},{"name":"Topic"}]},{"kind":"INTERFACE","name":"RepositoryInfo","possibleTypes":[{"name":"Repository"}]},{"kind":"INTERFACE","name":"GitObject","possibleTypes":[{"name":"Blob"},{"name":"Commit"},{"name":"Tag"},{"name":"Tree"}]},{"kind":"INTERFACE","name":"RepositoryNode","possibleTypes":[{"name":"CommitComment"},{"name":"CommitCommentThread"},{"name":"Issue"},{"name":"IssueComment"},{"name":"PullRequest"},{"name":"PullRequestCommitCommentThread"},{"name":"PullRequestReview"},{"name":"PullRequestReviewComment"}]},{"kind":"INTERFACE","name":"Subscribable","possibleTypes":[{"name":"Commit"},{"name":"Issue"},{"name":"PullRequest"},{"name":"Repository"},{"name":"Team"}]},{"kind":"INTERFACE","name":"Deletable","possibleTypes":[{"name":"CommitComment"},{"name":"GistComment"},{"name":"IssueComment"},{"name":"PullRequestReview"},{"name":"PullRequestReviewComment"}]},{"kind":"INTERFACE","name":"Reactable","possibleTypes":[{"name":"CommitComment"},{"name":"Issue"},{"name":"IssueComment"},{"name":"PullRequest"},{"name":"PullRequestReview"},{"name":"PullRequestReviewComment"}]},{"kind":"INTERFACE","name":"GitSignature","possibleTypes":[{"name":"GpgSignature"},{"name":"SmimeSignature"},{"name":"UnknownSignature"}]},{"kind":"UNION","name":"RequestedReviewer","possibleTypes":[{"name":"User"},{"name":"Team"},{"name":"Mannequin"}]},{"kind":"UNION","name":"PullRequestTimelineItem","possibleTypes":[{"name":"Commit"},{"name":"CommitCommentThread"},{"name":"PullRequestReview"},{"name":"PullRequestReviewThread"},{"name":"PullRequestReviewComment"},{"name":"IssueComment"},{"name":"ClosedEvent"},{"name":"ReopenedEvent"},{"name":"SubscribedEvent"},{"name":"UnsubscribedEvent"},{"name":"MergedEvent"},{"name":"ReferencedEvent"},{"name":"CrossReferencedEvent"},{"name":"AssignedEvent"},{"name":"UnassignedEvent"},{"name":"LabeledEvent"},{"name":"UnlabeledEvent"},{"name":"MilestonedEvent"},{"name":"DemilestonedEvent"},{"name":"RenamedTitleEvent"},{"name":"LockedEvent"},{"name":"UnlockedEvent"},{"name":"DeployedEvent"},{"name":"DeploymentEnvironmentChangedEvent"},{"name":"HeadRefDeletedEvent"},{"name":"HeadRefRestoredEvent"},{"name":"HeadRefForcePushedEvent"},{"name":"BaseRefForcePushedEvent"},{"name":"ReviewRequestedEvent"},{"name":"ReviewRequestRemovedEvent"},{"name":"ReviewDismissedEvent"},{"name":"UserBlockedEvent"}]},{"kind":"UNION","name":"Closer","possibleTypes":[{"name":"Commit"},{"name":"PullRequest"}]},{"kind":"UNION","name":"ReferencedSubject","possibleTypes":[{"name":"Issue"},{"name":"PullRequest"}]},{"kind":"UNION","name":"Assignee","possibleTypes":[{"name":"Bot"},{"name":"Mannequin"},{"name":"Organization"},{"name":"User"}]},{"kind":"UNION","name":"MilestoneItem","possibleTypes":[{"name":"Issue"},{"name":"PullRequest"}]},{"kind":"UNION","name":"RenamedTitleSubject","possibleTypes":[{"name":"Issue"},{"name":"PullRequest"}]},{"kind":"UNION","name":"PullRequestTimelineItems","possibleTypes":[{"name":"PullRequestCommit"},{"name":"PullRequestCommitCommentThread"},{"name":"PullRequestReview"},{"name":"PullRequestReviewThread"},{"name":"PullRequestRevisionMarker"},{"name":"BaseRefChangedEvent"},{"name":"BaseRefForcePushedEvent"},{"name":"DeployedEvent"},{"name":"DeploymentEnvironmentChangedEvent"},{"name":"HeadRefDeletedEvent"},{"name":"HeadRefForcePushedEvent"},{"name":"HeadRefRestoredEvent"},{"name":"MergedEvent"},{"name":"ReviewDismissedEvent"},{"name":"ReviewRequestedEvent"},{"name":"ReviewRequestRemovedEvent"},{"name":"ReadyForReviewEvent"},{"name":"IssueComment"},{"name":"CrossReferencedEvent"},{"name":"AddedToProjectEvent"},{"name":"AssignedEvent"},{"name":"ClosedEvent"},{"name":"CommentDeletedEvent"},{"name":"ConvertedNoteToIssueEvent"},{"name":"DemilestonedEvent"},{"name":"LabeledEvent"},{"name":"LockedEvent"},{"name":"MentionedEvent"},{"name":"MilestonedEvent"},{"name":"MovedColumnsInProjectEvent"},{"name":"PinnedEvent"},{"name":"ReferencedEvent"},{"name":"RemovedFromProjectEvent"},{"name":"RenamedTitleEvent"},{"name":"ReopenedEvent"},{"name":"SubscribedEvent"},{"name":"TransferredEvent"},{"name":"UnassignedEvent"},{"name":"UnlabeledEvent"},{"name":"UnlockedEvent"},{"name":"UserBlockedEvent"},{"name":"UnpinnedEvent"},{"name":"UnsubscribedEvent"}]},{"kind":"UNION","name":"IssueOrPullRequest","possibleTypes":[{"name":"Issue"},{"name":"PullRequest"}]},{"kind":"UNION","name":"IssueTimelineItem","possibleTypes":[{"name":"Commit"},{"name":"IssueComment"},{"name":"CrossReferencedEvent"},{"name":"ClosedEvent"},{"name":"ReopenedEvent"},{"name":"SubscribedEvent"},{"name":"UnsubscribedEvent"},{"name":"ReferencedEvent"},{"name":"AssignedEvent"},{"name":"UnassignedEvent"},{"name":"LabeledEvent"},{"name":"UnlabeledEvent"},{"name":"UserBlockedEvent"},{"name":"MilestonedEvent"},{"name":"DemilestonedEvent"},{"name":"RenamedTitleEvent"},{"name":"LockedEvent"},{"name":"UnlockedEvent"},{"name":"TransferredEvent"}]},{"kind":"UNION","name":"IssueTimelineItems","possibleTypes":[{"name":"IssueComment"},{"name":"CrossReferencedEvent"},{"name":"AddedToProjectEvent"},{"name":"AssignedEvent"},{"name":"ClosedEvent"},{"name":"CommentDeletedEvent"},{"name":"ConvertedNoteToIssueEvent"},{"name":"DemilestonedEvent"},{"name":"LabeledEvent"},{"name":"LockedEvent"},{"name":"MentionedEvent"},{"name":"MilestonedEvent"},{"name":"MovedColumnsInProjectEvent"},{"name":"PinnedEvent"},{"name":"ReferencedEvent"},{"name":"RemovedFromProjectEvent"},{"name":"RenamedTitleEvent"},{"name":"ReopenedEvent"},{"name":"SubscribedEvent"},{"name":"TransferredEvent"},{"name":"UnassignedEvent"},{"name":"UnlabeledEvent"},{"name":"UnlockedEvent"},{"name":"UserBlockedEvent"},{"name":"UnpinnedEvent"},{"name":"UnsubscribedEvent"}]},{"kind":"UNION","name":"ReviewDismissalAllowanceActor","possibleTypes":[{"name":"User"},{"name":"Team"}]},{"kind":"UNION","name":"PushAllowanceActor","possibleTypes":[{"name":"User"},{"name":"Team"}]},{"kind":"UNION","name":"PermissionGranter","possibleTypes":[{"name":"Organization"},{"name":"Repository"},{"name":"Team"}]},{"kind":"INTERFACE","name":"Sponsorable","possibleTypes":[{"name":"User"}]},{"kind":"INTERFACE","name":"Contribution","possibleTypes":[{"name":"CreatedCommitContribution"},{"name":"CreatedIssueContribution"},{"name":"CreatedPullRequestContribution"},{"name":"CreatedPullRequestReviewContribution"},{"name":"CreatedRepositoryContribution"},{"name":"JoinedGitHubContribution"},{"name":"RestrictedContribution"}]},{"kind":"UNION","name":"CreatedRepositoryOrRestrictedContribution","possibleTypes":[{"name":"CreatedRepositoryContribution"},{"name":"RestrictedContribution"}]},{"kind":"UNION","name":"CreatedIssueOrRestrictedContribution","possibleTypes":[{"name":"CreatedIssueContribution"},{"name":"RestrictedContribution"}]},{"kind":"UNION","name":"CreatedPullRequestOrRestrictedContribution","possibleTypes":[{"name":"CreatedPullRequestContribution"},{"name":"RestrictedContribution"}]},{"kind":"UNION","name":"SearchResultItem","possibleTypes":[{"name":"Issue"},{"name":"PullRequest"},{"name":"Repository"},{"name":"User"},{"name":"Organization"},{"name":"MarketplaceListing"},{"name":"App"}]},{"kind":"UNION","name":"CollectionItemContent","possibleTypes":[{"name":"Repository"},{"name":"Organization"},{"name":"User"}]}]}} \ No newline at end of file diff --git a/packages/netlify-cms-backend-github/src/fragments.js b/packages/netlify-cms-backend-github/src/fragments.js new file mode 100644 index 00000000..042ad745 --- /dev/null +++ b/packages/netlify-cms-backend-github/src/fragments.js @@ -0,0 +1,76 @@ +import gql from 'graphql-tag'; + +export const repository = gql` + fragment RepositoryParts on Repository { + id + } +`; + +export const blobWithText = gql` + fragment BlobWithTextParts on Blob { + id + text + is_binary: isBinary + } +`; + +export const object = gql` + fragment ObjectParts on GitObject { + id + sha: oid + } +`; + +export const branch = gql` + fragment BranchParts on Ref { + commit: target { + ...ObjectParts + } + id + name + repository { + ...RepositoryParts + } + } + ${object} + ${repository} +`; + +export const pullRequest = gql` + fragment PullRequestParts on PullRequest { + id + baseRefName + body + headRefName + headRefOid + number + state + title + merged_at: mergedAt + repository { + ...RepositoryParts + } + } + ${repository} +`; + +export const treeEntry = gql` + fragment TreeEntryParts on TreeEntry { + path: name + sha: oid + type + mode + } +`; + +export const fileEntry = gql` + fragment FileEntryParts on TreeEntry { + name + sha: oid + blob: object { + ... on Blob { + size: byteSize + } + } + } +`; diff --git a/packages/netlify-cms-backend-github/src/implementation.js b/packages/netlify-cms-backend-github/src/implementation.js index 446dea28..0f7c1602 100644 --- a/packages/netlify-cms-backend-github/src/implementation.js +++ b/packages/netlify-cms-backend-github/src/implementation.js @@ -4,6 +4,7 @@ import semaphore from 'semaphore'; import { stripIndent } from 'common-tags'; import AuthenticationPage from './AuthenticationPage'; import API from './API'; +import GraphQLAPI from './GraphQLAPI'; const MAX_CONCURRENT_DOWNLOADS = 10; @@ -59,12 +60,13 @@ export default class GitHub { } this.originRepo = config.getIn(['backend', 'repo'], ''); } else { - this.repo = config.getIn(['backend', 'repo'], ''); + this.repo = this.originRepo = config.getIn(['backend', 'repo'], ''); } this.branch = config.getIn(['backend', 'branch'], 'master').trim(); this.api_root = config.getIn(['backend', 'api_root'], 'https://api.github.com'); this.token = ''; this.squash_merges = config.getIn(['backend', 'squash_merges']); + this.use_graphql = config.getIn(['backend', 'use_graphql']); } authComponent() { @@ -155,11 +157,12 @@ export default class GitHub { async authenticate(state) { this.token = state.token; - this.api = new API({ + const apiCtor = this.use_graphql ? GraphQLAPI : API; + this.api = new apiCtor({ token: this.token, branch: this.branch, repo: this.repo, - originRepo: this.useOpenAuthoring ? this.originRepo : undefined, + originRepo: this.originRepo, api_root: this.api_root, squash_merges: this.squash_merges, useOpenAuthoring: this.useOpenAuthoring, @@ -191,6 +194,9 @@ export default class GitHub { logout() { this.token = null; + if (typeof this.api.reset === 'function') { + return this.api.reset(); + } return; } @@ -243,7 +249,7 @@ export default class GitHub { // Fetches a single entry. getEntry(collection, slug, path) { - const repoURL = `/repos/${this.useOpenAuthoring ? this.originRepo : this.repo}`; + const repoURL = `/repos/${this.originRepo}`; return this.api.readFile(path, null, { repoURL }).then(data => ({ file: { path }, data, diff --git a/packages/netlify-cms-backend-github/src/mutations.js b/packages/netlify-cms-backend-github/src/mutations.js new file mode 100644 index 00000000..24bcdff9 --- /dev/null +++ b/packages/netlify-cms-backend-github/src/mutations.js @@ -0,0 +1,109 @@ +import gql from 'graphql-tag'; +import * as fragments from './fragments'; + +// updateRef only works for branches at the moment +export const updateBranch = gql` + mutation updateRef($input: UpdateRefInput!) { + updateRef(input: $input) { + branch: ref { + ...BranchParts + } + } + } + ${fragments.branch} +`; + +// deleteRef only works for branches at the moment +const deleteRefMutationPart = ` +deleteRef(input: $deleteRefInput) { + clientMutationId +} +`; +export const deleteBranch = gql` + mutation deleteRef($deleteRefInput: DeleteRefInput!) { + ${deleteRefMutationPart} + } +`; + +const closePullRequestMutationPart = ` +closePullRequest(input: $closePullRequestInput) { + clientMutationId + pullRequest { + ...PullRequestParts + } +} +`; + +export const closePullRequest = gql` + mutation closePullRequestAndDeleteBranch($closePullRequestInput: ClosePullRequestInput!) { + ${closePullRequestMutationPart} + } + ${fragments.pullRequest} +`; + +export const closePullRequestAndDeleteBranch = gql` + mutation closePullRequestAndDeleteBranch( + $closePullRequestInput: ClosePullRequestInput! + $deleteRefInput: DeleteRefInput! + ) { + ${closePullRequestMutationPart} + ${deleteRefMutationPart} + } + ${fragments.pullRequest} +`; + +const createPullRequestMutationPart = ` + createPullRequest(input: $createPullRequestInput) { + clientMutationId + pullRequest { + ...PullRequestParts + } +} + `; + +export const createPullRequest = gql` + mutation createPullRequest($createPullRequestInput: CreatePullRequestInput!) { + ${createPullRequestMutationPart} + } + ${fragments.pullRequest} +`; + +export const createBranch = gql` + mutation createBranch($createRefInput: CreateRefInput!) { + createRef(input: $createRefInput) { + branch: ref { + ...BranchParts + } + } + } + ${fragments.branch} +`; + +// createRef only works for branches at the moment +export const createBranchAndPullRequest = gql` + mutation createBranchAndPullRequest( + $createRefInput: CreateRefInput! + $createPullRequestInput: CreatePullRequestInput! + ) { + createRef(input: $createRefInput) { + branch: ref { + ...BranchParts + } + } + ${createPullRequestMutationPart} + } + ${fragments.branch} + ${fragments.pullRequest} +`; + +export const reopenPullRequest = gql` + mutation reopenPullRequest($reopenPullRequestInput: ReopenPullRequestInput!) { + reopenPullRequest(input: $reopenPullRequestInput) { + clientMutationId + pullRequest { + ...PullRequestParts + } + } + } + ${fragments.pullRequest} +`; diff --git a/packages/netlify-cms-backend-github/src/queries.js b/packages/netlify-cms-backend-github/src/queries.js new file mode 100644 index 00000000..e560cbb7 --- /dev/null +++ b/packages/netlify-cms-backend-github/src/queries.js @@ -0,0 +1,274 @@ +import gql from 'graphql-tag'; +import * as fragments from './fragments'; + +export const repoPermission = gql` + query repoPermission($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + viewerPermission + } + } + ${fragments.repository} +`; + +export const user = gql` + query { + viewer { + id + avatar_url: avatarUrl + name + login + } + } +`; + +export const blob = gql` + query blob($owner: String!, $name: String!, $expression: String!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + object(expression: $expression) { + ... on Blob { + ...BlobWithTextParts + } + } + } + } + ${fragments.repository} + ${fragments.blobWithText} +`; + +export const unpublishedBranchFile = gql` + query unpublishedBranchFile( + $headOwner: String! + $headRepoName: String! + $headExpression: String! + $baseOwner: String! + $baseRepoName: String! + $baseExpression: String! + ) { + head: repository(owner: $headOwner, name: $headRepoName) { + ...RepositoryParts + object(expression: $headExpression) { + ... on Blob { + ...BlobWithTextParts + } + } + } + base: repository(owner: $baseOwner, name: $baseRepoName) { + ...RepositoryParts + object(expression: $baseExpression) { + ... on Blob { + id + oid + } + } + } + } + ${fragments.repository} + ${fragments.blobWithText} +`; + +export const statues = gql` + query statues($owner: String!, $name: String!, $sha: GitObjectID!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + object(oid: $sha) { + ...ObjectParts + ... on Commit { + status { + id + contexts { + id + context + state + target_url: targetUrl + } + } + } + } + } + } + ${fragments.repository} + ${fragments.object} +`; + +export const files = gql` + query files($owner: String!, $name: String!, $expression: String!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + object(expression: $expression) { + ...ObjectParts + ... on Tree { + entries { + ...FileEntryParts + } + } + } + } + } + ${fragments.repository} + ${fragments.object} + ${fragments.fileEntry} +`; + +export const unpublishedPrBranches = gql` + query unpublishedPrBranches($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + refs(refPrefix: "refs/heads/cms/", last: 50) { + nodes { + id + associatedPullRequests(last: 50, states: OPEN) { + nodes { + id + headRef { + id + name + prefix + } + } + } + } + } + } + } + ${fragments.repository} +`; + +const branchQueryPart = ` +branch: ref(qualifiedName: $qualifiedName) { + ...BranchParts +} +`; + +export const branch = gql` + query branch($owner: String!, $name: String!, $qualifiedName: String!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + ${branchQueryPart} + } + } + ${fragments.repository} + ${fragments.branch} +`; + +export const repository = gql` + query repository($owner: String!, $name: String!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + } + } + ${fragments.repository} +`; + +const pullRequestQueryPart = ` +pullRequest(number: $number) { + ...PullRequestParts +} +`; + +export const pullRequest = gql` + query pullRequest($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + id + ${pullRequestQueryPart} + } + } + ${fragments.pullRequest} +`; + +export const pullRequestAndBranch = gql` + query pullRequestAndBranch($owner: String!, $name: String!, $origin_owner: String!, $origin_name: String!, $qualifiedName: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + ${branchQueryPart} + } + origin: repository(owner: $origin_owner, name: $origin_name) { + ...RepositoryParts + ${pullRequestQueryPart} + } + } + ${fragments.repository} + ${fragments.branch} + ${fragments.pullRequest} +`; + +export const commitTree = gql` + query commitTree($owner: String!, $name: String!, $sha: GitObjectID!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + commit: object(oid: $sha) { + ...ObjectParts + ... on Commit { + tree { + ...ObjectParts + entries { + ...TreeEntryParts + } + } + } + } + } + } + ${fragments.repository} + ${fragments.object} + ${fragments.treeEntry} +`; + +export const tree = gql` + query tree($owner: String!, $name: String!, $sha: GitObjectID!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + tree: object(oid: $sha) { + ...ObjectParts + ... on Tree { + entries { + ...TreeEntryParts + } + } + } + } + } + ${fragments.repository} + ${fragments.object} + ${fragments.treeEntry} +`; + +export const pullRequestCommits = gql` + query pullRequestCommits($owner: String!, $name: String!, $number: Int!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + pullRequest(number: $number) { + id + commits(last: 100) { + nodes { + id + commit { + ...ObjectParts + parents(last: 100) { + nodes { + ...ObjectParts + } + } + } + } + } + } + } + } + ${fragments.repository} + ${fragments.object} +`; + +export const fileSha = gql` + query fileSha($owner: String!, $name: String!, $expression: String!) { + repository(owner: $owner, name: $name) { + ...RepositoryParts + file: object(expression: $expression) { + ...ObjectParts + } + } + } + ${fragments.repository} + ${fragments.object} +`; diff --git a/packages/netlify-cms-lib-util/src/backendUtil.js b/packages/netlify-cms-lib-util/src/backendUtil.js index a04ca2e6..9b011357 100644 --- a/packages/netlify-cms-lib-util/src/backendUtil.js +++ b/packages/netlify-cms-lib-util/src/backendUtil.js @@ -56,22 +56,24 @@ export const parseLinkHeader = flow([ fromPairs, ]); -export const getPaginatedRequestIterator = (url, options = {}, linkHeaderRelName = 'next') => { - let req = unsentRequest.fromFetchArguments(url, options); - const next = async () => { - if (!req) { - return { done: true }; - } +export const getAllResponses = async (url, options = {}, linkHeaderRelName = 'next') => { + const maxResponses = 30; + let responseCount = 1; + let req = unsentRequest.fromFetchArguments(url, options); + + const pageResponses = []; + + while (req && responseCount < maxResponses) { const pageResponse = await unsentRequest.performRequest(req); const linkHeader = pageResponse.headers.get('Link'); const nextURL = linkHeader && parseLinkHeader(linkHeader)[linkHeaderRelName]; - req = nextURL && unsentRequest.fromURL(nextURL); - return { value: pageResponse }; - }; - return { - [Symbol.asyncIterator]: () => ({ - next, - }), - }; + + const { headers = {} } = options; + req = nextURL && unsentRequest.fromFetchArguments(nextURL, { headers }); + pageResponses.push(pageResponse); + responseCount++; + } + + return pageResponses; }; diff --git a/packages/netlify-cms-lib-util/src/index.js b/packages/netlify-cms-lib-util/src/index.js index 3e5b03b8..41559eaa 100644 --- a/packages/netlify-cms-lib-util/src/index.js +++ b/packages/netlify-cms-lib-util/src/index.js @@ -19,7 +19,7 @@ import { import unsentRequest from './unsentRequest'; import { filterByPropExtension, - getPaginatedRequestIterator, + getAllResponses, parseLinkHeader, parseResponse, responseParser, @@ -72,7 +72,7 @@ export { unsentRequest, filterByPropExtension, parseLinkHeader, - getPaginatedRequestIterator, + getAllResponses, parseResponse, responseParser, loadScript, diff --git a/yarn.lock b/yarn.lock index a091a09e..9ac2022a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1857,6 +1857,19 @@ once "^1.4.0" universal-user-agent "^2.1.0" +"@octokit/request@^5.0.0": + version "5.0.2" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-5.0.2.tgz#59a920451f24811c016ddc507adcc41aafb2dca5" + integrity sha512-z1BQr43g4kOL4ZrIVBMHwi68Yg9VbkRUyuAgqCp1rU3vbYa69+2gIld/+gHclw15bJWQnhqqyEb7h5a5EqgZ0A== + dependencies: + "@octokit/endpoint" "^5.1.0" + "@octokit/request-error" "^1.0.1" + deprecation "^2.0.0" + is-plain-object "^3.0.0" + node-fetch "^2.3.0" + once "^1.4.0" + universal-user-agent "^3.0.0" + "@octokit/rest@^16.16.0": version "16.28.2" resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.28.2.tgz#3fc3b8700046ab29ab1e2a4bdf49f89e94f7ba27" @@ -1876,6 +1889,25 @@ universal-user-agent "^2.0.0" url-template "^2.0.8" +"@octokit/rest@^16.28.7": + version "16.28.7" + resolved "https://registry.yarnpkg.com/@octokit/rest/-/rest-16.28.7.tgz#a2c2db5b318da84144beba82d19c1a9dbdb1a1fa" + integrity sha512-cznFSLEhh22XD3XeqJw51OLSfyL2fcFKUO+v2Ep9MTAFfFLS1cK1Zwd1yEgQJmJoDnj4/vv3+fGGZweG+xsbIA== + dependencies: + "@octokit/request" "^5.0.0" + "@octokit/request-error" "^1.0.2" + atob-lite "^2.0.0" + before-after-hook "^2.0.0" + btoa-lite "^1.0.0" + deprecation "^2.0.0" + lodash.get "^4.4.2" + lodash.set "^4.3.2" + lodash.uniq "^4.5.0" + octokit-pagination-methods "^1.1.0" + once "^1.4.0" + universal-user-agent "^3.0.0" + url-template "^2.0.8" + "@sheerun/mutationobserver-shim@^0.3.2": version "0.3.2" resolved "https://registry.yarnpkg.com/@sheerun/mutationobserver-shim/-/mutationobserver-shim-0.3.2.tgz#8013f2af54a2b7d735f71560ff360d3a8176a87b" @@ -1971,6 +2003,11 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-12.0.10.tgz#51babf9c7deadd5343620055fc8aff7995c8b031" integrity sha512-LcsGbPomWsad6wmMNv7nBLw7YYYyfdYcz6xryKYQhx89c3XXan+8Q6AJ43G5XDIaklaVkK3mE4fCb0SBvMiPSQ== +"@types/node@>=6": + version "12.6.8" + resolved "https://registry.yarnpkg.com/@types/node/-/node-12.6.8.tgz#e469b4bf9d1c9832aee4907ba8a051494357c12c" + integrity sha512-aX+gFgA5GHcDi89KG5keey2zf0WfZk/HAQotEamsK2kbey+8yGKcson0hbK8E+v0NArlCJQCqMP161YhV6ZXLg== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.yarnpkg.com/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -2026,6 +2063,11 @@ resolved "https://registry.yarnpkg.com/@types/yargs/-/yargs-12.0.12.tgz#45dd1d0638e8c8f153e87d296907659296873916" integrity sha512-SOhuU4wNBxhhTHxYaiG5NY4HBhDIDnJF60GU+2LqHAdKKer86//e4yg69aENCtQ04n0ovz+tq2YPME5t5yp4pw== +"@types/zen-observable@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@types/zen-observable/-/zen-observable-0.8.0.tgz#8b63ab7f1aa5321248aad5ac890a485656dcea4d" + integrity sha512-te5lMAWii1uEJ4FwLjzdlbw3+n0FZNOvFXHxQDKeT0dilh7HOzdMzV2TrJVUzq8ep7J4Na8OUYPRLSQkJHAlrg== + "@webassemblyjs/ast@1.8.5": version "1.8.5" resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.8.5.tgz#51b1c5fe6576a34953bf4b253df9f0d490d9e359" @@ -2172,6 +2214,21 @@ "@webassemblyjs/wast-parser" "1.8.5" "@xtuc/long" "4.2.2" +"@wry/context@^0.4.0": + version "0.4.4" + resolved "https://registry.yarnpkg.com/@wry/context/-/context-0.4.4.tgz#e50f5fa1d6cfaabf2977d1fda5ae91717f8815f8" + integrity sha512-LrKVLove/zw6h2Md/KZyWxIkFM6AoyKp71OqpH9Hiip1csjPVoD3tPxlbQUNxEnHENks3UGgNpSBCAfq9KWuag== + dependencies: + "@types/node" ">=6" + tslib "^1.9.3" + +"@wry/equality@^0.1.2": + version "0.1.9" + resolved "https://registry.yarnpkg.com/@wry/equality/-/equality-0.1.9.tgz#b13e18b7a8053c6858aa6c85b54911fb31e3a909" + integrity sha512-mB6ceGjpMGz1ZTza8HYnrPGos2mC6So4NhS1PtZ8s4Qt0K7fBiIGhpSxUbQmhwcSWE3no+bYxmI2OL6KuXYmoQ== + dependencies: + tslib "^1.9.3" + "@xtuc/ieee754@^1.2.0": version "1.2.0" resolved "https://registry.yarnpkg.com/@xtuc/ieee754/-/ieee754-1.2.0.tgz#eef014a3145ae477a1cbc00cd1e552336dceb790" @@ -2353,6 +2410,85 @@ anymatch@^2.0.0: micromatch "^3.1.4" normalize-path "^2.1.1" +apollo-cache-inmemory@^1.6.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/apollo-cache-inmemory/-/apollo-cache-inmemory-1.6.2.tgz#bbf2e4e1eacdf82b2d526f5c2f3b37e5acee3c5e" + integrity sha512-AyCl3PGFv5Qv1w4N9vlg63GBPHXgMCekZy5mhlS042ji0GW84uTySX+r3F61ZX3+KM1vA4m9hQyctrEGiv5XjQ== + dependencies: + apollo-cache "^1.3.2" + apollo-utilities "^1.3.2" + optimism "^0.9.0" + ts-invariant "^0.4.0" + tslib "^1.9.3" + +apollo-cache@1.3.2, apollo-cache@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/apollo-cache/-/apollo-cache-1.3.2.tgz#df4dce56240d6c95c613510d7e409f7214e6d26a" + integrity sha512-+KA685AV5ETEJfjZuviRTEImGA11uNBp/MJGnaCvkgr+BYRrGLruVKBv6WvyFod27WEB2sp7SsG8cNBKANhGLg== + dependencies: + apollo-utilities "^1.3.2" + tslib "^1.9.3" + +apollo-client@^2.6.3: + version "2.6.3" + resolved "https://registry.yarnpkg.com/apollo-client/-/apollo-client-2.6.3.tgz#9bb2d42fb59f1572e51417f341c5f743798d22db" + integrity sha512-DS8pmF5CGiiJ658dG+mDn8pmCMMQIljKJSTeMNHnFuDLV0uAPZoeaAwVFiAmB408Ujqt92oIZ/8yJJAwSIhd4A== + dependencies: + "@types/zen-observable" "^0.8.0" + apollo-cache "1.3.2" + apollo-link "^1.0.0" + apollo-utilities "1.3.2" + symbol-observable "^1.0.2" + ts-invariant "^0.4.0" + tslib "^1.9.3" + zen-observable "^0.8.0" + +apollo-link-context@^1.0.18: + version "1.0.18" + resolved "https://registry.yarnpkg.com/apollo-link-context/-/apollo-link-context-1.0.18.tgz#9e700e3314da8ded50057fee0a18af2bfcedbfc3" + integrity sha512-aG5cbUp1zqOHHQjAJXG7n/izeMQ6LApd/whEF5z6qZp5ATvcyfSNkCfy3KRJMMZZ3iNfVTs6jF+IUA8Zvf+zeg== + dependencies: + apollo-link "^1.2.12" + tslib "^1.9.3" + +apollo-link-http-common@^0.2.14: + version "0.2.14" + resolved "https://registry.yarnpkg.com/apollo-link-http-common/-/apollo-link-http-common-0.2.14.tgz#d3a195c12e00f4e311c417f121181dcc31f7d0c8" + integrity sha512-v6mRU1oN6XuX8beVIRB6OpF4q1ULhSnmy7ScnHnuo1qV6GaFmDcbdvXqxIkAV1Q8SQCo2lsv4HeqJOWhFfApOg== + dependencies: + apollo-link "^1.2.12" + ts-invariant "^0.4.0" + tslib "^1.9.3" + +apollo-link-http@^1.5.15: + version "1.5.15" + resolved "https://registry.yarnpkg.com/apollo-link-http/-/apollo-link-http-1.5.15.tgz#106ab23bb8997bd55965d05855736d33119652cf" + integrity sha512-epZFhCKDjD7+oNTVK3P39pqWGn4LEhShAoA1Q9e2tDrBjItNfviiE33RmcLcCURDYyW5JA6SMgdODNI4Is8tvQ== + dependencies: + apollo-link "^1.2.12" + apollo-link-http-common "^0.2.14" + tslib "^1.9.3" + +apollo-link@^1.0.0, apollo-link@^1.2.12: + version "1.2.12" + resolved "https://registry.yarnpkg.com/apollo-link/-/apollo-link-1.2.12.tgz#014b514fba95f1945c38ad4c216f31bcfee68429" + integrity sha512-fsgIAXPKThyMVEMWQsUN22AoQI+J/pVXcjRGAShtk97h7D8O+SPskFinCGEkxPeQpE83uKaqafB2IyWdjN+J3Q== + dependencies: + apollo-utilities "^1.3.0" + ts-invariant "^0.4.0" + tslib "^1.9.3" + zen-observable-ts "^0.8.19" + +apollo-utilities@1.3.2, apollo-utilities@^1.3.0, apollo-utilities@^1.3.2: + version "1.3.2" + resolved "https://registry.yarnpkg.com/apollo-utilities/-/apollo-utilities-1.3.2.tgz#8cbdcf8b012f664cd6cb5767f6130f5aed9115c9" + integrity sha512-JWNHj8XChz7S4OZghV6yc9FNnzEXj285QYp/nLNh943iObycI5GTDO3NGR9Dth12LRrSFMeDOConPfPln+WGfg== + dependencies: + "@wry/equality" "^0.1.2" + fast-json-stable-stringify "^2.0.0" + ts-invariant "^0.4.0" + tslib "^1.9.3" + aproba@^1.0.3, aproba@^1.1.1: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" @@ -2905,6 +3041,11 @@ before-after-hook@^1.4.0: resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-1.4.0.tgz#2b6bf23dca4f32e628fd2747c10a37c74a4b484d" integrity sha512-l5r9ir56nda3qu14nAXIlyq1MmUSs0meCIaFAh8HwkFwP1F8eToOuS3ah2VAHHcY04jaYD7FpJC5JTXHYRbkzg== +before-after-hook@^2.0.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/before-after-hook/-/before-after-hook-2.1.0.tgz#b6c03487f44e24200dd30ca5e6a1979c5d2fb635" + integrity sha512-IWIbu7pMqyw3EAJHzzHbWa85b6oud/yfKYg5rqB5hNE8CeMi3nX+2C2sj0HswfblST86hpVEOAb9x34NZd6P7A== + big.js@^3.1.3: version "3.2.0" resolved "https://registry.yarnpkg.com/big.js/-/big.js-3.2.0.tgz#a5fc298b81b9e0dca2e458824784b65c52ba588e" @@ -4102,10 +4243,10 @@ cyclist@~0.2.2: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-0.2.2.tgz#1b33792e11e914a2fd6d6ed6447464444e5fa640" integrity sha1-GzN5LhHpFKL9bW7WRHRkRE5fpkA= -cypress@^3.1.5: - version "3.3.1" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-3.3.1.tgz#8a127b1d9fa74bff21f111705abfef58d595fdef" - integrity sha512-JIo47ZD9P3jAw7oaK7YKUoODzszJbNw41JmBrlMMiupHOlhmXvZz75htuo7mfRFPC9/1MDQktO4lX/V2+a6lGQ== +cypress@^3.4.1: + version "3.4.1" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-3.4.1.tgz#ca2e4e9864679da686c6a6189603efd409664c30" + integrity sha512-1HBS7t9XXzkt6QHbwfirWYty8vzxNMawGj1yI+Fu6C3/VZJ8UtUngMW6layqwYZzLTZV8tiDpdCNBypn78V4Dg== dependencies: "@cypress/listr-verbose-renderer" "0.4.1" "@cypress/xvfb" "1.2.4" @@ -4120,20 +4261,19 @@ cypress@^3.1.5: execa "0.10.0" executable "4.1.1" extract-zip "1.6.7" - fs-extra "4.0.1" + fs-extra "5.0.0" getos "3.1.1" - glob "7.1.3" is-ci "1.2.1" is-installed-globally "0.1.0" lazy-ass "1.6.0" listr "0.12.0" - lodash "4.17.11" + lodash "4.17.15" log-symbols "2.2.0" minimist "1.2.0" moment "2.24.0" ramda "0.24.1" request "2.88.0" - request-progress "0.4.0" + request-progress "3.0.0" supports-color "5.5.0" tmp "0.1.0" url "0.11.0" @@ -4574,6 +4714,11 @@ dot-prop@^4.1.1, dot-prop@^4.2.0: dependencies: is-obj "^1.0.0" +dotenv@^8.0.0: + version "8.0.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-8.0.0.tgz#ed310c165b4e8a97bb745b0a9d99c31bda566440" + integrity sha512-30xVGqjLjiUOArT4+M5q9sYdvuR4riM6yK9wMcas9Vbp6zZa+ocC9dp6QoftuhTPhFAiLK/0C5Ni2nou/Bk8lg== + duplexer@^0.1.1, duplexer@~0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" @@ -4848,6 +4993,13 @@ eslint-plugin-babel@^5.3.0: dependencies: eslint-rule-composer "^0.3.0" +eslint-plugin-cypress@^2.6.0: + version "2.6.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-2.6.0.tgz#c726dd1a312cd5234de94765ca79718a14edf0ef" + integrity sha512-/YmvhYFqjFLYw7llDl56U9KW+Z25TZJjDofT47aUnAFRRvOhj1y2nxJzZ1ZTnqBO1ja8gwRhtdT7LfTLg9ANJw== + dependencies: + globals "^11.12.0" + eslint-plugin-emotion@^10.0.7: version "10.0.7" resolved "https://registry.yarnpkg.com/eslint-plugin-emotion/-/eslint-plugin-emotion-10.0.7.tgz#e14d54a2f23c9701d66851a87a5eb35adb10c2a7" @@ -5577,13 +5729,13 @@ from@~0: resolved "https://registry.yarnpkg.com/from/-/from-0.1.7.tgz#83c60afc58b9c56997007ed1a768b3ab303a44fe" integrity sha1-g8YK/Fi5xWmXAH7Rp2izqzA6RP4= -fs-extra@4.0.1: - version "4.0.1" - resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-4.0.1.tgz#7fc0c6c8957f983f57f306a24e5b9ddd8d0dd880" - integrity sha1-f8DGyJV/mD9X8waiTlud3Y0N2IA= +fs-extra@5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-5.0.0.tgz#414d0110cdd06705734d055652c5411260c31abd" + integrity sha512-66Pm4RYbjzdyeuqudYqhFiNBbCIuI9kgRqLPSHIlXHidW8NIQtVdkM1yeZ4lXwuhbTETv3EUGMNHAAw6hiundQ== dependencies: graceful-fs "^4.1.2" - jsonfile "^3.0.0" + jsonfile "^4.0.0" universalify "^0.1.0" fs-extra@^7.0.0: @@ -5595,6 +5747,15 @@ fs-extra@^7.0.0: jsonfile "^4.0.0" universalify "^0.1.0" +fs-extra@^8.1.0: + version "8.1.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-8.1.0.tgz#49d43c45a88cd9677668cb7be1b46efdb8d2e1c0" + integrity sha512-yhlQgA6mnOJUKOsRUFsgJdQCvkKhcz8tlZG5HBQfReYZy46OwLcY+Zia0mtdHsOo9y/hP+CxMN0TU9QxoOtG4g== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^4.0.0" + universalify "^0.1.0" + fs-minipass@^1.2.5: version "1.2.6" resolved "https://registry.yarnpkg.com/fs-minipass/-/fs-minipass-1.2.6.tgz#2c5cc30ded81282bfe8a0d7c7c1853ddeb102c07" @@ -5815,18 +5976,6 @@ glob-to-regexp@^0.3.0: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.3.0.tgz#8c5a1494d2066c570cc3bfe4496175acc4d502ab" integrity sha1-jFoUlNIGbFcMw7/kSWF1rMTVAqs= -glob@7.1.3: - version "7.1.3" - resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.3.tgz#3960832d3f1574108342dafd3a67b332c0969df1" - integrity sha512-vcfuiIxogLV4DlGBHIUOwI0IbrJ8HWPc4MU7HzviGeNho/UJDfi6B5p3sHeWIQ0KGIU0Jpxi5ZHxemQfLkkAwQ== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.0.4" - once "^1.3.0" - path-is-absolute "^1.0.0" - glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4: version "7.1.4" resolved "https://registry.yarnpkg.com/glob/-/glob-7.1.4.tgz#aa608a2f6c577ad357e1ae5a5c26d9a8d1969255" @@ -5890,7 +6039,7 @@ global@^4.3.0: min-document "^2.19.0" process "^0.11.10" -globals@^11.1.0, globals@^11.7.0: +globals@^11.1.0, globals@^11.12.0, globals@^11.7.0: version "11.12.0" resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== @@ -5974,6 +6123,23 @@ graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6 resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" integrity sha512-6uHUhOPEBgQ24HM+r6b/QwWfZq+yiFcipKFrOFiBEnWdy5sdzYoi+pJeQaPI5qOLRFqWmAXUPQNsielzdLoecA== +graceful-fs@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.2.0.tgz#8d8fdc73977cb04104721cb53666c1ca64cd328b" + integrity sha512-jpSvDPV4Cq/bgtpndIWbI5hmYxhQGHPC4d4cqBPb4DLniCfhJokdXhwhaDuLBGLQdvvRum/UiX6ECVIPvDXqdg== + +graphql-tag@^2.10.1: + version "2.10.1" + resolved "https://registry.yarnpkg.com/graphql-tag/-/graphql-tag-2.10.1.tgz#10aa41f1cd8fae5373eaf11f1f67260a3cad5e02" + integrity sha512-jApXqWBzNXQ8jYa/HLkZJaVw9jgwNqZkywa2zfFn16Iv1Zb7ELNHkJaXHR7Quvd5SIGsy6Ny7SUKATgnu05uEg== + +graphql@^14.4.2: + version "14.4.2" + resolved "https://registry.yarnpkg.com/graphql/-/graphql-14.4.2.tgz#553a7d546d524663eda49ed6df77577be3203ae3" + integrity sha512-6uQadiRgnpnSS56hdZUSvFrVcQ6OF9y6wkxJfKquFtHlnl7+KSuWwSJsdwiK1vybm1HgcdbpGkCpvhvsVQ0UZQ== + dependencies: + iterall "^1.2.2" + gray-matter@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/gray-matter/-/gray-matter-4.0.2.tgz#9aa379e3acaf421193fce7d2a28cebd4518ac454" @@ -7072,6 +7238,11 @@ istanbul-reports@^2.1.1: dependencies: handlebars "^4.1.2" +iterall@^1.2.2: + version "1.2.2" + resolved "https://registry.yarnpkg.com/iterall/-/iterall-1.2.2.tgz#92d70deb8028e0c39ff3164fdbf4d8b088130cd7" + integrity sha512-yynBb1g+RFUPY64fTrFv7nsjRrENBQJaX2UL+2Szc9REFrSNm1rpSXHGzhmAy7a9uv3vlvgBlXnf9RqmPH1/DA== + jest-changed-files@^24.8.0: version "24.8.0" resolved "https://registry.yarnpkg.com/jest-changed-files/-/jest-changed-files-24.8.0.tgz#7e7eb21cf687587a85e50f3d249d1327e15b157b" @@ -7601,13 +7772,6 @@ json5@^2.1.0: dependencies: minimist "^1.2.0" -jsonfile@^3.0.0: - version "3.0.1" - resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-3.0.1.tgz#a5ecc6f65f53f662c4415c7675a0331d0992ec66" - integrity sha1-pezG9l9T9mLEQVx2daAzHQmS7GY= - optionalDependencies: - graceful-fs "^4.1.6" - jsonfile@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/jsonfile/-/jsonfile-4.0.0.tgz#8771aae0799b64076b76640fca058f9c10e33ecb" @@ -7952,7 +8116,12 @@ lodash.uniq@^4.5.0: resolved "https://registry.yarnpkg.com/lodash.uniq/-/lodash.uniq-4.5.0.tgz#d0225373aeb652adc1bc82e4945339a842754773" integrity sha1-0CJTc662Uq3BvILklFM5qEJ1R3M= -lodash@4.17.11, lodash@^4.1.1, lodash@^4.11.2, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1: +lodash@4.17.15: + version "4.17.15" + resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548" + integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A== + +lodash@^4.1.1, lodash@^4.11.2, lodash@^4.17.10, lodash@^4.17.11, lodash@^4.17.4, lodash@^4.17.5, lodash@^4.2.1: version "4.17.11" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.11.tgz#b39ea6229ef607ecd89e2c8df12536891cac9b8d" integrity sha512-cQKh8igo5QUhZ7lg38DYWAxMvjSAKG0A8wGSVimP07SIUEK2UO+arSRKbRZWtelMtN5V0Hkwh5ryOto/SshYIg== @@ -8593,11 +8762,6 @@ node-addon-api@^1.6.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-1.6.3.tgz#3998d4593e2dca2ea82114670a4eb003386a9fe1" integrity sha512-FXWH6mqjWgU8ewuahp4spec8LkroFZK2NicOv6bNwZC3kcwZUI8LeZdG80UzTSLLhK4T7MsgNwlYDVRlDdfTDg== -node-eta@^0.1.1: - version "0.1.1" - resolved "https://registry.yarnpkg.com/node-eta/-/node-eta-0.1.1.tgz#4066109b39371c761c72b7ebda9a9ea0a5de121f" - integrity sha1-QGYQmzk3HHYccrfr2pqeoKXeEh8= - node-fetch-npm@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/node-fetch-npm/-/node-fetch-npm-2.0.2.tgz#7258c9046182dca345b4208eda918daf33697ff7" @@ -9027,6 +9191,13 @@ opn@^5.5.0: dependencies: is-wsl "^1.1.0" +optimism@^0.9.0: + version "0.9.6" + resolved "https://registry.yarnpkg.com/optimism/-/optimism-0.9.6.tgz#5621195486b294c3bfc518d17ac47767234b029f" + integrity sha512-bWr/ZP32UgFCQAoSkz33XctHwpq2via2sBvGvO5JIlrU8gaiM0LvoKj3QMle9LWdSKlzKik8XGSerzsdfYLNxA== + dependencies: + "@wry/context" "^0.4.0" + optimist@0.6.x, optimist@^0.6.1: version "0.6.1" resolved "https://registry.yarnpkg.com/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" @@ -10754,13 +10925,12 @@ replace-ext@1.0.0: resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb" integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs= -request-progress@0.4.0: - version "0.4.0" - resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-0.4.0.tgz#c1954e39086aa85269c5660bcee0142a6a70d7e7" - integrity sha1-wZVOOQhqqFJpxWYLzuAUKmpw1+c= +request-progress@3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe" + integrity sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4= dependencies: - node-eta "^0.1.1" - throttleit "^0.0.2" + throttleit "^1.0.0" request-promise-core@1.1.2: version "1.1.2" @@ -11200,6 +11370,13 @@ signal-exit@^3.0.0, signal-exit@^3.0.2: resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" integrity sha1-tf3AjxKH6hF4Yo5BXiUTK3NkbG0= +simple-git@^1.124.0: + version "1.124.0" + resolved "https://registry.yarnpkg.com/simple-git/-/simple-git-1.124.0.tgz#10a73cc1af303832b5c11720d4256e134fba35ca" + integrity sha512-ks9mBoO4ODQy/xGLC8Cc+YDvj/hho/IKgPhi6h5LI/sA+YUdHc3v0DEoHzM29VmulubpGCxMJUSFmyXNsjNMEA== + dependencies: + debug "^4.0.1" + simple-html-tokenizer@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/simple-html-tokenizer/-/simple-html-tokenizer-0.1.1.tgz#05c2eec579ffffe145a030ac26cfea61b980fabe" @@ -11963,7 +12140,7 @@ symbol-observable@1.0.1: resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.0.1.tgz#8340fc4702c3122df5d22288f88283f513d3fdd4" integrity sha1-g0D8RwLDEi310iKI+IKD9RPT/dQ= -symbol-observable@^1.2.0: +symbol-observable@^1.0.2, symbol-observable@^1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804" integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ== @@ -12085,10 +12262,10 @@ throat@^4.0.0: resolved "https://registry.yarnpkg.com/throat/-/throat-4.1.0.tgz#89037cbc92c56ab18926e6ba4cbb200e15672a6a" integrity sha1-iQN8vJLFarGJJua6TLsgDhVnKmo= -throttleit@^0.0.2: - version "0.0.2" - resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-0.0.2.tgz#cfedf88e60c00dd9697b61fdd2a8343a9b680eaf" - integrity sha1-z+34jmDADdlpe2H90qg0OptoDq8= +throttleit@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c" + integrity sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw= through2@^2.0.0, through2@^2.0.2: version "2.0.5" @@ -12309,7 +12486,14 @@ truncate-utf8-bytes@^1.0.0: dependencies: utf8-byte-length "^1.0.1" -tslib@^1.9.0: +ts-invariant@^0.4.0: + version "0.4.4" + resolved "https://registry.yarnpkg.com/ts-invariant/-/ts-invariant-0.4.4.tgz#97a523518688f93aafad01b0e80eb803eb2abd86" + integrity sha512-uEtWkFM/sdZvRNNDL3Ehu4WVpwaulhwQszV8mrtcdeE8nN00BV9mAmQ88RkrBhFgl9gMgvjJLAQcZbnPXI9mlA== + dependencies: + tslib "^1.9.3" + +tslib@^1.9.0, tslib@^1.9.3: version "1.10.0" resolved "https://registry.yarnpkg.com/tslib/-/tslib-1.10.0.tgz#c3c19f95973fb0a62973fb09d90d961ee43e5c8a" integrity sha512-qOebF53frne81cf0S9B41ByenJ3/IuH8yJKngAX35CmiZySA0khhkovshKK+jGCaMnVomla7gVlIcc3EvKPbTQ== @@ -12566,6 +12750,13 @@ universal-user-agent@^2.0.0, universal-user-agent@^2.1.0: dependencies: os-name "^3.0.0" +universal-user-agent@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/universal-user-agent/-/universal-user-agent-3.0.0.tgz#4cc88d68097bffd7ac42e3b7c903e7481424b4b9" + integrity sha512-T3siHThqoj5X0benA5H0qcDnrKGXzU8TKoX15x/tQHw1hQBvIEBHjxQ2klizYsqBOO/Q+WuxoQUihadeeqDnoA== + dependencies: + os-name "^3.0.0" + universalify@^0.1.0: version "0.1.2" resolved "https://registry.yarnpkg.com/universalify/-/universalify-0.1.2.tgz#b646f69be3942dabcecc9d6639c80dc105efaa66" @@ -13222,3 +13413,16 @@ yauzl@2.4.1: integrity sha1-lSj0QtqxsihOWLQ3m7GU4i4MQAU= dependencies: fd-slicer "~1.0.1" + +zen-observable-ts@^0.8.19: + version "0.8.19" + resolved "https://registry.yarnpkg.com/zen-observable-ts/-/zen-observable-ts-0.8.19.tgz#c094cd20e83ddb02a11144a6e2a89706946b5694" + integrity sha512-u1a2rpE13G+jSzrg3aiCqXU5tN2kw41b+cBZGmnc+30YimdkKiDj9bTowcB41eL77/17RF/h+393AuVgShyheQ== + dependencies: + tslib "^1.9.3" + zen-observable "^0.8.0" + +zen-observable@^0.8.0: + version "0.8.14" + resolved "https://registry.yarnpkg.com/zen-observable/-/zen-observable-0.8.14.tgz#d33058359d335bc0db1f0af66158b32872af3bf7" + integrity sha512-kQz39uonEjEESwh+qCi83kcC3rZJGh4mrZW7xjkSQYXkq//JZHTtKo+6yuVloTgMtzsIWOJrjIrKvk/dqm0L5g==