feat(backend-github): GitHub GraphQL API support (#2456)

* add GitHub GraphQL api initial support

* support mutiple backends for e2e tests - initial commit

* add github backend e2e test (currently skipped), fix bugs per tests

* refactor e2e tests, add fork workflow tests, support fork workflow in GraphQL api

* remove log message that might contain authentication token

* return empty error when commit is not found when using GraphQL (align with base class)

* disable github backend tests

* fix bugs introduced after rebase of GraphQL and OpenAuthoring features

* test: update tests per openAuthoring changes, split tests into multiple files

* fix: pass in headers for pagination requests, avoid async iterator as it requires a polyfill on old browsers

* test(e2e): disable github backend tests
This commit is contained in:
Erez Rokah 2019-09-03 21:56:20 +03:00 committed by Shawn Erquhart
parent 083a336ba4
commit ece136c92e
34 changed files with 3103 additions and 529 deletions

View File

@ -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"
}
}
}

2
.gitignore vendored
View File

@ -15,3 +15,5 @@ cypress/screenshots
/coverage/
.cache
*.log
.env
.temp/

View File

@ -2,3 +2,4 @@ dist/
bin/
public/
.cache/
packages/netlify-cms-backend-github/src/fragmentTypes.js

View File

@ -1,4 +1,5 @@
{
"baseUrl": "http://localhost:8080",
"projectId": "dzqjxb"
"projectId": "dzqjxb",
"testFiles": "*spec*.js"
}

View File

@ -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);
});
});
});

View File

@ -0,0 +1,5 @@
import fixture from './github/editorial_workflow';
describe.skip('Github Backend Editorial Workflow - GraphQL API', () => {
fixture({ use_graphql: true });
});

View File

@ -0,0 +1,5 @@
import fixture from './github/open_authoring';
describe.skip('Github Backend Editorial Workflow - GraphQL API - Open Authoring', () => {
fixture({ use_graphql: true });
});

View File

@ -0,0 +1,5 @@
import fixture from './github/editorial_workflow';
describe.skip('Github Backend Editorial Workflow - REST API', () => {
fixture({ use_graphql: false });
});

View File

@ -0,0 +1,5 @@
import fixture from './github/open_authoring';
describe.skip('Github Backend Editorial Workflow - REST API - Open Authoring', () => {
fixture({ use_graphql: false });
});

View File

@ -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);
});
});

View File

@ -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);
});
}

View File

@ -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',
};

View File

@ -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);
});
}

193
cypress/plugins/github.js Normal file
View File

@ -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,
};

View File

@ -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;
},
});
};

View File

@ -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,
};

292
cypress/utils/steps.js Normal file
View File

@ -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,
};

View File

@ -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

View File

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<title>Netlify CMS Development Test</title>
</head>
<body>
<script src="dist/netlify-cms.js"></script>
<script>
var PostPreview = createClass({
render: function() {
var entry = this.props.entry;
return h(
'div',
{},
h('div', { className: 'cover' }, h('h1', {}, entry.getIn(['data', 'title']))),
h('p', {}, h('small', {}, 'Written ' + entry.getIn(['data', 'date']))),
h('div', { className: 'text' }, this.props.widgetFor('body')),
);
},
});
var PagePreview = createClass({
render: function() {
var entry = this.props.entry;
return h(
'div',
{},
h('div', { className: 'cover' }, h('h1', {}, entry.getIn(['data', 'title']))),
h('p', {}, h('small', {}, 'Written ' + entry.getIn(['data', 'date']))),
h('div', { className: 'text' }, this.props.widgetFor('body')),
);
},
});
CMS.registerPreviewTemplate('posts', PostPreview);
CMS.registerPreviewTemplate('pages', PagePreview);
</script>
</body>
</html>

View File

@ -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' }

File diff suppressed because one or more lines are too long

View File

@ -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",

View File

@ -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"
},

View File

@ -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!');
}
},
);
});

View File

@ -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,10 +225,7 @@ export default class API {
cache: 'no-store',
};
if (!this.useOpenAuthoring) {
return this.request(`${this.repoURL}/contents/${key}.json`, metadataRequestOptions)
.then(response => JSON.parse(response))
.catch(err => {
const errorHandler = err => {
if (err.message === 'Not Found') {
console.log(
'%c %s does not have metadata',
@ -236,29 +234,22 @@ export default class API {
);
}
throw err;
});
};
if (!this.useOpenAuthoring) {
return this.request(`${this.repoURL}/contents/${key}.json`, metadataRequestOptions)
.then(response => JSON.parse(response))
.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);
});
}
readFile(path, sha, { branch = this.branch, repoURL = this.repoURL } = {}) {
if (sha) {
return this.getBlob(sha);
} else {
retrieveContent(path, branch, repoURL) {
return this.request(`${repoURL}/contents/${path}`, {
headers: { Accept: 'application/vnd.github.VERSION.raw' },
params: { ref: branch },
@ -276,6 +267,19 @@ export default class API {
throw error;
});
}
readFile(path, sha, { branch = this.branch, repoURL = this.repoURL } = {}) {
if (sha) {
return this.getBlob(sha);
} else {
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 } = {}) {
@ -284,9 +288,7 @@ export default class API {
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 (<rev>:<path>) 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);
let pr;
if (this.useOpenAuthoring) {
await this.createBranch(branchName, commitResponse.sha);
const pr = this.useOpenAuthoring
? undefined
: await this.createPR(options.commitMessage, branchName);
} 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.',

View File

@ -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;
}
}

File diff suppressed because one or more lines are too long

View File

@ -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
}
}
}
`;

View File

@ -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,

View File

@ -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}
`;

View File

@ -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}
`;

View File

@ -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;
};

View File

@ -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,

306
yarn.lock
View File

@ -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==