From 9d1bcec20047cc7b11d97856893d01c2a971e8c7 Mon Sep 17 00:00:00 2001 From: Kanad Gupta Date: Tue, 26 Jul 2022 12:01:19 -0500 Subject: [PATCH] feat: changelog endpoints (#543) * refactor: make `pushDoc` more generic ... so we can reuse it for Changelog! :rocket: No functional changes were made, docs tests should still pass. * chore: camelcase filePath, grammar tweaks * test: split out fixtures * chore: add changelog command category, rearrange * feat: initial pass at changelog commands * test: add test suite let's hope this is right lol * docs: update README.md * test: use fixtures in changelog subdir --- README.md | 20 + .../existing-docs/not-a-markdown-file | 0 .../changelogs/existing-docs/simple-doc.md | 4 + .../existing-docs/subdir/another-doc.md | 4 + .../changelogs/failure-docs/fail-doc.md | 1 + .../changelogs/failure-docs/new-doc.md | 6 + .../changelogs/new-docs/new-doc.md | 6 + .../changelogs/slug-docs/new-doc-slug.md | 7 + __tests__/cmds/changelogs.test.js | 631 ++++++++++++++++++ src/cmds/changelogs/index.js | 81 +++ src/cmds/changelogs/single.js | 56 ++ src/lib/commands.js | 16 +- 12 files changed, 826 insertions(+), 6 deletions(-) create mode 100644 __tests__/__fixtures__/changelogs/existing-docs/not-a-markdown-file create mode 100644 __tests__/__fixtures__/changelogs/existing-docs/simple-doc.md create mode 100644 __tests__/__fixtures__/changelogs/existing-docs/subdir/another-doc.md create mode 100644 __tests__/__fixtures__/changelogs/failure-docs/fail-doc.md create mode 100644 __tests__/__fixtures__/changelogs/failure-docs/new-doc.md create mode 100644 __tests__/__fixtures__/changelogs/new-docs/new-doc.md create mode 100644 __tests__/__fixtures__/changelogs/slug-docs/new-doc-slug.md create mode 100644 __tests__/cmds/changelogs.test.js create mode 100644 src/cmds/changelogs/index.js create mode 100644 src/cmds/changelogs/single.js diff --git a/README.md b/README.md index efadb933b..be5dff9fa 100644 --- a/README.md +++ b/README.md @@ -171,6 +171,26 @@ rdme docs:edit --version={project-version} rdme docs:single path-to-markdown-file --version={project-version} ``` +### Changelogs + +#### Syncing a Folder of Markdown to ReadMe + +The Markdown files will require YAML front matter with certain ReadMe documentation attributes. Check out [our docs](https://docs.readme.com/docs/rdme#markdown-file-setup) for more info on setting up your front matter. + +Passing in a path to a directory will also sync any Markdown files that are located in subdirectories. + +```sh +rdme changelogs path-to-markdown-files +``` + +This command also has a dry run mode, which can be useful for initial setup and debugging. You can read more about dry run mode [in our docs](https://docs.readme.com/docs/rdme#dry-run-mode). + +#### Syncing a Single Markdown File to ReadMe + +```sh +rdme changelogs:single path-to-markdown-file +``` + ### Versions #### Get All Versions Associated With Your Project diff --git a/__tests__/__fixtures__/changelogs/existing-docs/not-a-markdown-file b/__tests__/__fixtures__/changelogs/existing-docs/not-a-markdown-file new file mode 100644 index 000000000..e69de29bb diff --git a/__tests__/__fixtures__/changelogs/existing-docs/simple-doc.md b/__tests__/__fixtures__/changelogs/existing-docs/simple-doc.md new file mode 100644 index 000000000..c55d504a0 --- /dev/null +++ b/__tests__/__fixtures__/changelogs/existing-docs/simple-doc.md @@ -0,0 +1,4 @@ +--- +title: This is the changelog title +--- +Body diff --git a/__tests__/__fixtures__/changelogs/existing-docs/subdir/another-doc.md b/__tests__/__fixtures__/changelogs/existing-docs/subdir/another-doc.md new file mode 100644 index 000000000..2d95d7732 --- /dev/null +++ b/__tests__/__fixtures__/changelogs/existing-docs/subdir/another-doc.md @@ -0,0 +1,4 @@ +--- +title: This is another changelog title +--- +Another body diff --git a/__tests__/__fixtures__/changelogs/failure-docs/fail-doc.md b/__tests__/__fixtures__/changelogs/failure-docs/fail-doc.md new file mode 100644 index 000000000..e8eba3dc1 --- /dev/null +++ b/__tests__/__fixtures__/changelogs/failure-docs/fail-doc.md @@ -0,0 +1 @@ +Body diff --git a/__tests__/__fixtures__/changelogs/failure-docs/new-doc.md b/__tests__/__fixtures__/changelogs/failure-docs/new-doc.md new file mode 100644 index 000000000..1a8070bac --- /dev/null +++ b/__tests__/__fixtures__/changelogs/failure-docs/new-doc.md @@ -0,0 +1,6 @@ +--- +type: added +title: This is the changelog title +--- + +Body diff --git a/__tests__/__fixtures__/changelogs/new-docs/new-doc.md b/__tests__/__fixtures__/changelogs/new-docs/new-doc.md new file mode 100644 index 000000000..1a8070bac --- /dev/null +++ b/__tests__/__fixtures__/changelogs/new-docs/new-doc.md @@ -0,0 +1,6 @@ +--- +type: added +title: This is the changelog title +--- + +Body diff --git a/__tests__/__fixtures__/changelogs/slug-docs/new-doc-slug.md b/__tests__/__fixtures__/changelogs/slug-docs/new-doc-slug.md new file mode 100644 index 000000000..a53bd8ca2 --- /dev/null +++ b/__tests__/__fixtures__/changelogs/slug-docs/new-doc-slug.md @@ -0,0 +1,7 @@ +--- +type: added +title: This is the changelog title +slug: marc-actually-wrote-a-test +--- + +Body diff --git a/__tests__/cmds/changelogs.test.js b/__tests__/cmds/changelogs.test.js new file mode 100644 index 000000000..f8ecc0cc1 --- /dev/null +++ b/__tests__/cmds/changelogs.test.js @@ -0,0 +1,631 @@ +const nock = require('nock'); +const chalk = require('chalk'); +const fs = require('fs'); +const path = require('path'); +const crypto = require('crypto'); +const frontMatter = require('gray-matter'); + +const APIError = require('../../src/lib/apiError'); +const getApiNock = require('../get-api-nock'); + +const ChangelogsCommand = require('../../src/cmds/changelogs'); +const SingleChangelogCommand = require('../../src/cmds/changelogs/single'); + +const changelogs = new ChangelogsCommand(); +const changelogsSingle = new SingleChangelogCommand(); + +const fixturesBaseDir = '__fixtures__/changelogs'; +const fullFixturesDir = `${__dirname}./../${fixturesBaseDir}`; +const key = 'API_KEY'; + +function hashFileContents(contents) { + return crypto.createHash('sha1').update(contents).digest('hex'); +} + +describe('rdme changelogs', () => { + beforeAll(() => nock.disableNetConnect()); + + afterAll(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(changelogs.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + }); + + it('should error if no folder provided', () => { + return expect(changelogs.run({ key, version: '1.0.0' })).rejects.toThrow( + 'No folder provided. Usage `rdme changelogs [options]`.' + ); + }); + + it('should error if the argument isnt a folder', () => { + return expect(changelogs.run({ key, version: '1.0.0', folder: 'not-a-folder' })).rejects.toThrow( + "ENOENT: no such file or directory, scandir 'not-a-folder'" + ); + }); + + it('should error if the folder contains no markdown files', () => { + return expect(changelogs.run({ key, version: '1.0.0', folder: '.github/workflows' })).rejects.toThrow( + 'We were unable to locate Markdown files in .github/workflows.' + ); + }); + + describe('existing changelogs', () => { + let simpleDoc; + let anotherDoc; + + beforeEach(() => { + let fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); + simpleDoc = { + slug: 'simple-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + + fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/subdir/another-doc.md')); + anotherDoc = { + slug: 'another-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + }); + + it('should fetch changelog and merge with what is returned', () => { + expect.assertions(1); + + const getMocks = getApiNock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) + .get('/api/v1/changelogs/another-doc') + .basicAuth({ user: key }) + .reply(200, { slug: anotherDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const updateMocks = getApiNock() + .put('/api/v1/changelogs/simple-doc', { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + lastUpdatedHash: simpleDoc.hash, + ...simpleDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + }) + .put('/api/v1/changelogs/another-doc', { + slug: anotherDoc.slug, + body: anotherDoc.doc.content, + lastUpdatedHash: anotherDoc.hash, + ...anotherDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { slug: anotherDoc.slug, body: anotherDoc.doc.content }); + + return changelogs.run({ folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key }).then(updatedDocs => { + // All changelogs should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `✏️ successfully updated 'simple-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, + `✏️ successfully updated 'another-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/subdir/another-doc.md`, + ].join('\n') + ); + + getMocks.done(); + updateMocks.done(); + }); + }); + + it('should return changelog update info for dry run', () => { + expect.assertions(1); + + const getMocks = getApiNock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }) + .get('/api/v1/changelogs/another-doc') + .basicAuth({ user: key }) + .reply(200, { slug: anotherDoc.slug, lastUpdatedHash: 'anOldHash' }); + + return changelogs + .run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key }) + .then(updatedDocs => { + // All changelogs should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `🎭 dry run! This will update 'simple-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( + simpleDoc.doc.data + )}`, + `🎭 dry run! This will update 'another-doc' with contents from __tests__/${fixturesBaseDir}/existing-docs/subdir/another-doc.md with the following metadata: ${JSON.stringify( + anotherDoc.doc.data + )}`, + ].join('\n') + ); + + getMocks.done(); + }); + }); + + it('should not send requests for changelogs that have not changed', () => { + expect.assertions(1); + + const getMocks = getApiNock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) + .get('/api/v1/changelogs/another-doc') + .basicAuth({ user: key }) + .reply(200, { slug: anotherDoc.slug, lastUpdatedHash: anotherDoc.hash }); + + return changelogs.run({ folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key }).then(skippedDocs => { + expect(skippedDocs).toBe( + [ + '`simple-doc` was not updated because there were no changes.', + '`another-doc` was not updated because there were no changes.', + ].join('\n') + ); + + getMocks.done(); + }); + }); + + it('should adjust "no changes" message if in dry run', () => { + expect.assertions(1); + + const getMocks = getApiNock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }) + .get('/api/v1/changelogs/another-doc') + .basicAuth({ user: key }) + .reply(200, { slug: anotherDoc.slug, lastUpdatedHash: anotherDoc.hash }); + + return changelogs + .run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/existing-docs`, key }) + .then(skippedDocs => { + expect(skippedDocs).toBe( + [ + '🎭 dry run! `simple-doc` will not be updated because there were no changes.', + '🎭 dry run! `another-doc` will not be updated because there were no changes.', + ].join('\n') + ); + + getMocks.done(); + }); + }); + }); + + describe('new changelogs', () => { + it('should create new changelog', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/changelogs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + await expect(changelogs.run({ folder: `./__tests__/${fixturesBaseDir}/new-docs`, key })).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from __tests__/${fixturesBaseDir}/new-docs/new-doc.md` + ); + + getMock.done(); + postMock.done(); + }); + + it('should return creation info for dry run', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/changelogs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + await expect( + changelogs.run({ dryRun: true, folder: `./__tests__/${fixturesBaseDir}/new-docs`, key }) + ).resolves.toBe( + `🎭 dry run! This will create 'new-doc' with contents from __tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( + doc.data + )}` + ); + + getMock.done(); + }); + + it('should fail if any changelogs are invalid', async () => { + const folder = 'failure-docs'; + const slug = 'fail-doc'; + const slugTwo = 'new-doc'; + + const errorObject = { + error: 'CHANGELOG_INVALID', + message: "We couldn't save this changelog (Changelog title cannot be blank).", + suggestion: 'Make sure all the data is correct, and the body is valid Markdown.', + docs: 'fake-metrics-uuid', + help: "If you need help, email support@readme.io and include the following link to your API log: 'fake-metrics-uuid'.", + }; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + const docTwo = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slugTwo}.md`))); + + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + const hashTwo = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slugTwo}.md`))); + + const getMocks = getApiNock() + .get(`/api/v1/changelogs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }) + .get(`/api/v1/changelogs/${slugTwo}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slugTwo}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMocks = getApiNock() + .post('/api/v1/changelogs', { slug: slugTwo, body: docTwo.content, ...docTwo.data, lastUpdatedHash: hashTwo }) + .basicAuth({ user: key }) + .reply(201, { + metadata: { image: [], title: '', description: '' }, + title: 'This is the changelog title', + slug: slugTwo, + body: 'Body', + }) + .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(400, errorObject); + + const fullDirectory = `__tests__/${fixturesBaseDir}/${folder}`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${fullDirectory}/${slug}.md`)}:\n\n${errorObject.message}`, + }; + + await expect(changelogs.run({ folder: `./${fullDirectory}`, key })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMocks.done(); + postMocks.done(); + }); + }); + + describe('slug metadata', () => { + it('should use provided slug', async () => { + const slug = 'new-doc-slug'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/changelogs/${doc.data.slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + await expect(changelogs.run({ folder: `./__tests__/${fixturesBaseDir}/slug-docs`, key })).resolves.toBe( + `🌱 successfully created 'marc-actually-wrote-a-test' with contents from __tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` + ); + + getMock.done(); + postMock.done(); + }); + }); +}); + +describe('rdme changelogs:single', () => { + beforeAll(() => nock.disableNetConnect()); + + afterAll(() => nock.cleanAll()); + + it('should error if no api key provided', () => { + return expect(changelogsSingle.run({})).rejects.toThrow('No project API key provided. Please use `--key`.'); + }); + + it('should error if no file path provided', () => { + return expect(changelogsSingle.run({ key, version: '1.0.0' })).rejects.toThrow( + 'No file path provided. Usage `rdme changelogs:single [options]`.' + ); + }); + + it('should error if the argument is not a markdown file', async () => { + await expect(changelogsSingle.run({ key, version: '1.0.0', filePath: 'not-a-markdown-file' })).rejects.toThrow( + 'The file path specified is not a markdown file.' + ); + }); + + describe('new changelogs', () => { + it('should create new changelog', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/changelogs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug, body: doc.content, ...doc.data }); + + await expect( + changelogsSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) + ).resolves.toBe( + `🌱 successfully created 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md` + ); + + getMock.done(); + postMock.done(); + }); + + it('should return creation info for dry run', async () => { + const slug = 'new-doc'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/new-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/changelogs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + await expect( + changelogsSingle.run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/new-docs/new-doc.md`, key }) + ).resolves.toBe( + `🎭 dry run! This will create 'new-doc' with contents from ./__tests__/${fixturesBaseDir}/new-docs/new-doc.md with the following metadata: ${JSON.stringify( + doc.data + )}` + ); + + getMock.done(); + }); + + it('should fail if the changelog is invalid', async () => { + const folder = 'failure-docs'; + const slug = 'fail-doc'; + + const errorObject = { + error: 'CHANGELOG_INVALID', + message: "We couldn't save this changelog (Changelog title cannot be blank).", + suggestion: 'Make sure all the data is correct, and the body is valid Markdown.', + docs: 'fake-metrics-uuid', + help: "If you need help, email support@readme.io and include the following link to your API log: 'fake-metrics-uuid'.", + }; + + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/${folder}/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/changelogs/${slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(400, errorObject); + + const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, + }; + + await expect(changelogsSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMock.done(); + postMock.done(); + }); + + it('should fail if some other error when retrieving page slug', async () => { + const slug = 'fail-doc'; + + const errorObject = { + error: 'INTERNAL_ERROR', + message: 'Unknown error (yikes)', + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }; + + const getMock = getApiNock().get(`/api/v1/changelogs/${slug}`).basicAuth({ user: key }).reply(500, errorObject); + + const filePath = `./__tests__/${fixturesBaseDir}/failure-docs/fail-doc.md`; + + const formattedErrorObject = { + ...errorObject, + message: `Error uploading ${chalk.underline(`${filePath}`)}:\n\n${errorObject.message}`, + }; + + await expect(changelogsSingle.run({ filePath: `${filePath}`, key })).rejects.toStrictEqual( + new APIError(formattedErrorObject) + ); + + getMock.done(); + }); + }); + + describe('slug metadata', () => { + it('should use provided slug', async () => { + const slug = 'new-doc-slug'; + const doc = frontMatter(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + const hash = hashFileContents(fs.readFileSync(path.join(fullFixturesDir, `/slug-docs/${slug}.md`))); + + const getMock = getApiNock() + .get(`/api/v1/changelogs/${doc.data.slug}`) + .basicAuth({ user: key }) + .reply(404, { + error: 'CHANGELOG_NOTFOUND', + message: `The changelog with the slug '${slug}' couldn't be found`, + suggestion: '...a suggestion to resolve the issue...', + help: 'If you need help, email support@readme.io and mention log "fake-metrics-uuid".', + }); + + const postMock = getApiNock() + .post('/api/v1/changelogs', { slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }) + .basicAuth({ user: key }) + .reply(201, { slug: doc.data.slug, body: doc.content, ...doc.data, lastUpdatedHash: hash }); + + await expect( + changelogsSingle.run({ filePath: `./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md`, key }) + ).resolves.toBe( + `🌱 successfully created 'marc-actually-wrote-a-test' with contents from ./__tests__/${fixturesBaseDir}/slug-docs/new-doc-slug.md` + ); + + getMock.done(); + postMock.done(); + }); + }); + + describe('existing changelogs', () => { + let simpleDoc; + + beforeEach(() => { + const fileContents = fs.readFileSync(path.join(fullFixturesDir, '/existing-docs/simple-doc.md')); + simpleDoc = { + slug: 'simple-doc', + doc: frontMatter(fileContents), + hash: hashFileContents(fileContents), + }; + }); + + it('should fetch changelog and merge with what is returned', () => { + const getMock = getApiNock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + const updateMock = getApiNock() + .put('/api/v1/changelogs/simple-doc', { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + lastUpdatedHash: simpleDoc.hash, + ...simpleDoc.doc.data, + }) + .basicAuth({ user: key }) + .reply(200, { + slug: simpleDoc.slug, + body: simpleDoc.doc.content, + }); + + return changelogsSingle + .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(updatedDocs => { + expect(updatedDocs).toBe( + `✏️ successfully updated 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md` + ); + + getMock.done(); + updateMock.done(); + }); + }); + + it('should return changelog update info for dry run', () => { + expect.assertions(1); + + const getMock = getApiNock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: 'anOldHash' }); + + return changelogsSingle + .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(updatedDocs => { + // All changelogs should have been updated because their hashes from the GET request were different from what they + // are currently. + expect(updatedDocs).toBe( + [ + `🎭 dry run! This will update 'simple-doc' with contents from ./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md with the following metadata: ${JSON.stringify( + simpleDoc.doc.data + )}`, + ].join('\n') + ); + + getMock.done(); + }); + }); + + it('should not send requests for changelogs that have not changed', () => { + expect.assertions(1); + + const getMock = getApiNock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); + + return changelogsSingle + .run({ filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(skippedDocs => { + expect(skippedDocs).toBe('`simple-doc` was not updated because there were no changes.'); + + getMock.done(); + }); + }); + + it('should adjust "no changes" message if in dry run', () => { + const getMock = getApiNock() + .get('/api/v1/changelogs/simple-doc') + .basicAuth({ user: key }) + .reply(200, { slug: simpleDoc.slug, lastUpdatedHash: simpleDoc.hash }); + + return changelogsSingle + .run({ dryRun: true, filePath: `./__tests__/${fixturesBaseDir}/existing-docs/simple-doc.md`, key }) + .then(skippedDocs => { + expect(skippedDocs).toBe('🎭 dry run! `simple-doc` will not be updated because there were no changes.'); + + getMock.done(); + }); + }); + }); +}); diff --git a/src/cmds/changelogs/index.js b/src/cmds/changelogs/index.js new file mode 100644 index 000000000..a69508285 --- /dev/null +++ b/src/cmds/changelogs/index.js @@ -0,0 +1,81 @@ +const chalk = require('chalk'); +const config = require('config'); +const fs = require('fs'); +const path = require('path'); + +const { debug } = require('../../lib/logger'); +const pushDoc = require('../../lib/pushDoc'); + +module.exports = class ChangelogsCommand { + constructor() { + this.command = 'changelogs'; + this.usage = 'changelogs [options]'; + this.description = 'Sync a folder of Markdown files to your ReadMe project as Changelog posts.'; + this.category = 'changelogs'; + this.position = 1; + + this.hiddenArgs = ['folder']; + this.args = [ + { + name: 'key', + type: String, + description: 'Project API key', + }, + { + name: 'folder', + type: String, + defaultOption: true, + }, + { + name: 'dryRun', + type: Boolean, + description: 'Runs the command without creating/updating any changelogs in ReadMe. Useful for debugging.', + }, + ]; + } + + async run(opts) { + const { dryRun, folder, key } = opts; + + debug(`command: ${this.command}`); + debug(`opts: ${JSON.stringify(opts)}`); + + if (!key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + + if (!folder) { + return Promise.reject(new Error(`No folder provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); + } + + // Find the files to sync + const readdirRecursive = folderToSearch => { + const filesInFolder = fs.readdirSync(folderToSearch, { withFileTypes: true }); + const files = filesInFolder + .filter(fileHandle => fileHandle.isFile()) + .map(fileHandle => path.join(folderToSearch, fileHandle.name)); + const folders = filesInFolder.filter(fileHandle => fileHandle.isDirectory()); + const subFiles = [].concat( + ...folders.map(fileHandle => readdirRecursive(path.join(folderToSearch, fileHandle.name))) + ); + return [...files, ...subFiles]; + }; + + // Strip out non-markdown files + const files = readdirRecursive(folder).filter(file => file.endsWith('.md') || file.endsWith('.markdown')); + + debug(`number of files: ${files.length}`); + + if (!files.length) { + return Promise.reject(new Error(`We were unable to locate Markdown files in ${folder}.`)); + } + + const updatedDocs = await Promise.all( + files.map(async filename => { + return pushDoc(key, undefined, dryRun, filename, this.category); + }) + ); + + return chalk.green(updatedDocs.join('\n')); + } +}; diff --git a/src/cmds/changelogs/single.js b/src/cmds/changelogs/single.js new file mode 100644 index 000000000..d20d0a4fa --- /dev/null +++ b/src/cmds/changelogs/single.js @@ -0,0 +1,56 @@ +const chalk = require('chalk'); +const config = require('config'); + +const { debug } = require('../../lib/logger'); +const pushDoc = require('../../lib/pushDoc'); + +module.exports = class SingleChangelogCommand { + constructor() { + this.command = 'changelogs:single'; + this.usage = 'changelogs:single [options]'; + this.description = 'Sync a single Markdown file to your ReadMe project as a Changelog post.'; + this.category = 'changelogs'; + this.position = 3; + + this.hiddenArgs = ['filePath']; + this.args = [ + { + name: 'key', + type: String, + description: 'Project API key', + }, + { + name: 'filePath', + type: String, + defaultOption: true, + }, + { + name: 'dryRun', + type: Boolean, + description: 'Runs the command without creating/updating any changelogs in ReadMe. Useful for debugging.', + }, + ]; + } + + async run(opts) { + const { dryRun, filePath, key } = opts; + debug(`command: ${this.command}`); + debug(`opts: ${JSON.stringify(opts)}`); + + if (!key) { + return Promise.reject(new Error('No project API key provided. Please use `--key`.')); + } + + if (!filePath) { + return Promise.reject(new Error(`No file path provided. Usage \`${config.get('cli')} ${this.usage}\`.`)); + } + + if (filePath.endsWith('.md') === false || !filePath.endsWith('.markdown') === false) { + return Promise.reject(new Error('The file path specified is not a markdown file.')); + } + + const createdDoc = await pushDoc(key, undefined, dryRun, filePath, this.category); + + return chalk.green(createdDoc); + } +}; diff --git a/src/lib/commands.js b/src/lib/commands.js index d83f89cce..6548078f5 100644 --- a/src/lib/commands.js +++ b/src/lib/commands.js @@ -68,10 +68,6 @@ exports.list = () => { exports.getCategories = () => { return { - admin: { - description: 'Administration', - commands: [], - }, apis: { description: 'Upload OpenAPI/Swagger definitions', commands: [], @@ -80,14 +76,22 @@ exports.getCategories = () => { description: 'Documentation', commands: [], }, - versions: { - description: 'Versions', + changelogs: { + description: 'Changelog', commands: [], }, categories: { description: 'Categories', commands: [], }, + versions: { + description: 'Versions', + commands: [], + }, + admin: { + description: 'Administration', + commands: [], + }, utilities: { description: 'Other useful commands', commands: [],