From 93291293d750e65c71028d7946b3707e983ebb46 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 May 2022 16:51:05 +0200 Subject: [PATCH 1/3] feat: add "notes" command --- README.md | 24 ++++ package.json | 2 +- src/commands/__test__/notes.test.ts | 120 +++++++++++++++++ src/commands/__test__/show.test.ts | 33 +++-- src/commands/notes.ts | 149 ++++++++++++++++++++++ src/commands/publish.ts | 18 +-- src/commands/show.ts | 2 +- src/index.ts | 4 + src/utils/git/__test__/getCommits.test.ts | 55 +++++++- src/utils/git/commit.ts | 32 ++++- src/utils/git/createRelease.ts | 4 +- src/utils/git/getCommits.ts | 21 ++- src/utils/git/getLatestRelease.ts | 8 +- 13 files changed, 429 insertions(+), 43 deletions(-) create mode 100644 src/commands/__test__/notes.test.ts create mode 100644 src/commands/notes.ts diff --git a/README.md b/README.md index 5343f5b..80eeb9d 100644 --- a/README.md +++ b/README.md @@ -157,6 +157,30 @@ Publishes a new version of the package. release publish ``` +### `notes` + +Generates release notes and creates a new GitHub release for the given release tag. + +This command is designed to recover from a partially failed release process, as well as to generate changelogs for old releases. + +- This command requires an existing (merged) release tag; +- This command accepts past release tags; +- This command has no effect if a GitHub release for the given tag already exists. + +#### Arguments + +| Argument name | Type | Description | +| ------------- | -------- | ------------------------ | +| `tag` | `string` | Tag name of the release. | + +#### Example + +```sh +# Generate release notes and create a GitHub release +# for the release tag "v1.0.3". +release notes v1.0.3 +``` + ### `show` Displays information about a particular release. diff --git a/package.json b/package.json index 28fab79..14b395e 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "bin" ], "scripts": { - "start": "npm run build build -w", + "start": "npm run build -- -w", "build": "tsc", "test": "jest --runInBand", "prerelease": "npm run build && npm test", diff --git a/src/commands/__test__/notes.test.ts b/src/commands/__test__/notes.test.ts new file mode 100644 index 0000000..21c7f0b --- /dev/null +++ b/src/commands/__test__/notes.test.ts @@ -0,0 +1,120 @@ +import { MockedRequest, ResponseResolver, rest, RestContext } from 'msw' +import { Notes } from '../notes' +import { commit } from '../../utils/git/commit' +import { testEnvironment } from '../../../test/env' +import { execAsync } from '../../utils/execAsync' + +const { setup, reset, cleanup, api, log } = testEnvironment('notes') +let gitHubReleaseHandler: jest.Mock = jest.fn< + ReturnType, + Parameters> +>((req, res, ctx) => { + return res( + ctx.status(201), + ctx.json({ + html_url: '/releases/1', + }), + ) +}) + +beforeAll(async () => { + await setup() +}) + +beforeEach(() => { + api.use( + rest.post( + 'https://api.github.com/repos/:owner/:repo/releases', + gitHubReleaseHandler, + ), + ) +}) + +afterEach(async () => { + await reset() +}) + +afterAll(async () => { + await cleanup() +}) + +it('creates a GitHub release for ???', async () => { + // Preceding (previous) release. + await commit({ + message: `feat: long-ago published`, + allowEmpty: true, + }) + const prevReleaseCommit = await commit({ + message: `chore(release): v0.1.0`, + allowEmpty: true, + }) + await execAsync('git tag v0.1.0') + + // Relevant release. + const fixCommit = await commit({ + message: `fix: relevant fix`, + allowEmpty: true, + }) + await commit({ + message: `docs: not worthy of release notes`, + allowEmpty: true, + }) + const featCommit = await commit({ + message: `feat: relevant feature`, + allowEmpty: true, + }) + const releaseCommit = await commit({ + message: `chore(release): v0.2.0`, + allowEmpty: true, + date: new Date('2005-04-07T22:13:13'), + }) + await execAsync(`git tag v0.2.0`) + + // Future release. + await commit({ + message: `fix: other that`, + allowEmpty: true, + }) + await commit({ + message: `chore(release): v0.2.1`, + allowEmpty: true, + }) + await execAsync(`git tag v0.2.1`) + + const notes = new Notes( + { + script: 'exit 0', + }, + { + _: ['', '0.2.0'], + }, + ) + await notes.run() + + expect(log.info).toHaveBeenCalledWith( + 'creating GitHub release for version "v0.2.0" in "octocat/test"...', + ) + + expect(log.info).toHaveBeenCalledWith( + `found release tag "v0.2.0" (${releaseCommit.hash})`, + ) + expect(log.info).toHaveBeenCalledWith( + `found preceding release "v0.1.0" (${prevReleaseCommit.hash})`, + ) + + // Must generate correct release notes. + expect(log.info).toHaveBeenCalledWith(`generated release notes: +## v0.2.0 (07/04/2005) + +### Features + +- relevant feature (${featCommit.hash}) + +### Bug Fixes + +- relevant fix (${fixCommit.hash})`) + + // Must create a new GitHub release. + expect(gitHubReleaseHandler).toHaveBeenCalledTimes(1) + expect(log.info).toHaveBeenCalledWith('created GitHub release: /releases/1') +}) diff --git a/src/commands/__test__/show.test.ts b/src/commands/__test__/show.test.ts index a9633b0..25b7445 100644 --- a/src/commands/__test__/show.test.ts +++ b/src/commands/__test__/show.test.ts @@ -4,6 +4,7 @@ import { execAsync } from '../../utils/execAsync' import { getTag } from '../../utils/git/getTag' import { testEnvironment } from '../../../test/env' import { mockConfig } from '../../../test/fixtures' +import { commit } from '../../utils/git/commit' const { setup, reset, cleanup, api, log } = testEnvironment('show') @@ -20,7 +21,7 @@ afterAll(async () => { }) it('exits given repository without any releases', async () => { - const show = new Show(mockConfig(), { _: [] }) + const show = new Show(mockConfig(), { _: [''] }) await expect(show.run()).rejects.toThrow( 'Failed to retrieve release tag: repository has no releases.', @@ -139,16 +140,18 @@ it('displays info for implicit unpublished release', async () => { ), ) - await execAsync('git commit -m "chore: release v1.2.3" --allow-empty') + const releaseCommit = await commit({ + message: 'chore(release): v1.2.3', + allowEmpty: true, + }) await execAsync(`git tag v1.2.3`) - const pointer = await getTag('v1.2.3') - const show = new Show(mockConfig(), { _: [] }) + const show = new Show(mockConfig(), { _: [''] }) await show.run() expect(log.info).toHaveBeenCalledWith('found tag "v1.2.3"!') expect(log.info).toHaveBeenCalledWith( - expect.stringContaining(`commit ${pointer!.hash}`), + expect.stringContaining(`commit ${releaseCommit.hash}`), ) expect(log.info).toHaveBeenCalledWith( `release status: ${ReleaseStatus.Unpublished}`, @@ -173,16 +176,18 @@ it('displays info for explicit draft release', async () => { ), ) - await execAsync('git commit -m "chore: release v1.2.3" --allow-empty') + const releaseCommit = await commit({ + message: 'chore(release): v1.2.3', + allowEmpty: true, + }) await execAsync(`git tag v1.2.3`) - const pointer = await getTag('v1.2.3') - const show = new Show(mockConfig(), { _: [] }) + const show = new Show(mockConfig(), { _: [''] }) await show.run() expect(log.info).toHaveBeenCalledWith('found tag "v1.2.3"!') expect(log.info).toHaveBeenCalledWith( - expect.stringContaining(`commit ${pointer!.hash}`), + expect.stringContaining(`commit ${releaseCommit.hash}`), ) expect(log.info).toHaveBeenCalledWith( `release status: ${ReleaseStatus.Draft}`, @@ -204,16 +209,18 @@ it('displays info for explicit public release', async () => { ), ) - await execAsync('git commit -m "chore: release v1.2.3" --allow-empty') + const releaseCommit = await commit({ + message: 'chore(release): v1.2.3', + allowEmpty: true, + }) await execAsync(`git tag v1.2.3`) - const pointer = await getTag('v1.2.3') - const show = new Show(mockConfig(), { _: [] }) + const show = new Show(mockConfig(), { _: [''] }) await show.run() expect(log.info).toHaveBeenCalledWith('found tag "v1.2.3"!') expect(log.info).toHaveBeenCalledWith( - expect.stringContaining(`commit ${pointer!.hash}`), + expect.stringContaining(`commit ${releaseCommit.hash}`), ) expect(log.info).toHaveBeenCalledWith( `release status: ${ReleaseStatus.Public}`, diff --git a/src/commands/notes.ts b/src/commands/notes.ts new file mode 100644 index 0000000..3f42b23 --- /dev/null +++ b/src/commands/notes.ts @@ -0,0 +1,149 @@ +import { format, invariant } from 'outvariant' +import type { BuilderCallback } from 'yargs' +import type { ReleaseContext } from '../utils/createContext' +import { demandGitHubToken } from '../utils/env' +import { createRelease } from '../utils/git/createRelease' +import { Command } from '../Command' +import { getInfo } from '../utils/git/getInfo' +import { parseCommits, ParsedCommitWithHash } from '../utils/git/parseCommits' +import { getReleaseNotes, toMarkdown } from '../utils/getReleaseNotes' +import { getCommits } from '../utils/git/getCommits' +import { getTag } from '../utils/git/getTag' +import { getCommit } from '../utils/git/getCommit' +import { byReleaseVersion } from '../utils/git/getLatestRelease' +import { getTags } from '../utils/git/getTags' + +interface Argv { + _: [path: string, tag: string] +} + +export class Notes extends Command { + static command = 'notes' + static description = + 'Generate GitHub release notes for the given release version.' + + static builder: BuilderCallback<{}, Argv> = (yargs) => { + return yargs.usage('$ notes [tag]').positional('tag', { + type: 'string', + desciption: 'Release tag', + demandOption: true, + }) + } + + public run = async () => { + await demandGitHubToken().catch((error) => { + this.log.error(error.message) + process.exit(1) + }) + + const [, tagInput] = this.argv._ + const tagName = tagInput.startsWith('v') ? tagInput : `v${tagInput}` + const version = tagInput.replace(/^v/, '') + + const repo = await getInfo() + + this.log.info( + format( + 'creating GitHub release for version "%s" in "%s/%s"...', + tagName, + repo.owner, + repo.name, + ), + ) + + // Retrieve the information about the given release version. + const tagPointer = await getTag(tagName) + invariant( + tagPointer, + 'Failed to create GitHub release: unknown tag "%s". Please make sure you are providing an existing release tag.', + tagName, + ) + + this.log.info( + format('found release tag "%s" (%s)', tagPointer.tag, tagPointer.hash), + ) + + const releaseCommit = await getCommit(tagPointer.hash) + invariant( + releaseCommit, + 'Failed to create GitHub release: unable to retrieve the commit by tag "%s" (%s).', + tagPointer.tag, + tagPointer.hash, + ) + + /** + * @fixme Pretty-print the release commit instead of logging a commit object. + */ + this.log.info(format('found release commit:\n%o', releaseCommit)) + + // Retrieve the pointer to the previous release. + const tags = await getTags().then((tags) => { + return tags.sort(byReleaseVersion) + }) + + const tagReleaseIndex = tags.indexOf(tagPointer.tag) + const previousReleaseTag = tags[tagReleaseIndex + 1] + + const previousRelease = previousReleaseTag + ? await getTag(previousReleaseTag) + : undefined + + if (previousRelease?.hash) { + this.log.info( + format( + 'found preceding release "%s" (%s)', + previousRelease.tag, + previousRelease.hash, + ), + ) + } else { + this.log.info( + format( + 'found no released preceding "%s": analyzing all commits until "%s"...', + tagPointer.tag, + tagPointer.hash, + ), + ) + } + + // Get commits list between the given release and the previous release. + const commits = await getCommits({ + since: previousRelease?.hash, + until: tagPointer.hash, + }).then(parseCommits) + + const context: ReleaseContext = { + repo, + nextRelease: { + version, + tag: tagPointer.tag, + publishedAt: releaseCommit.author.date, + }, + latestRelease: previousRelease, + } + + // Generate release notes for the commits. + const releaseNotes = await Notes.generateReleaseNotes(context, commits) + this.log.info(format('generated release notes:\n%s', releaseNotes)) + + // Create GitHub release. + const releaseUrl = await Notes.createRelease(context, releaseNotes) + this.log.info(format('created GitHub release: %s', releaseUrl)) + } + + static async generateReleaseNotes( + context: ReleaseContext, + commits: ParsedCommitWithHash[], + ): Promise { + const releaseNotes = await getReleaseNotes(commits) + const markdown = toMarkdown(context, releaseNotes) + return markdown + } + + static async createRelease( + context: ReleaseContext, + notes: string, + ): Promise { + return createRelease(context, notes) + } +} diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 62c232e..814bf06 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -14,14 +14,13 @@ import { getTags } from '../utils/git/getTags' import { execAsync } from '../utils/execAsync' import { commit } from '../utils/git/commit' import { createTag } from '../utils/git/createTag' -import { getReleaseNotes, toMarkdown } from '../utils/getReleaseNotes' -import { createRelease } from '../utils/git/createRelease' import { push } from '../utils/git/push' import { getReleaseRefs } from '../utils/getReleaseRefs' import { parseCommits, ParsedCommitWithHash } from '../utils/git/parseCommits' import { createComment } from '../utils/github/createComment' import { createReleaseComment } from '../utils/createReleaseComment' import { demandGitHubToken } from '../utils/env' +import { Notes } from './notes' interface Argv { dryRun?: boolean @@ -87,7 +86,7 @@ export class Publish extends Command { } const rawCommits = await getCommits({ - after: latestRelease?.hash, + since: latestRelease?.hash, }) this.log.info( @@ -275,6 +274,10 @@ export class Publish extends Command { commitResult.error, ) + this.log.info( + format('created a release commit at "%s"!', commitResult.data.hash), + ) + this.revertQueue.push(async () => { this.log.info('reverting the release commit...') @@ -338,11 +341,10 @@ export class Publish extends Command { private async generateReleaseNotes( commits: ParsedCommitWithHash[], ): Promise { - const releaseNotes = await getReleaseNotes(commits) - const markdown = toMarkdown(this.context, releaseNotes) - this.log.info(`generated release notes:\n\n${markdown}\n`) + const releaseNotes = await Notes.generateReleaseNotes(this.context, commits) + this.log.info(`generated release notes:\n\n${releaseNotes}\n`) - return markdown + return releaseNotes } /** @@ -374,7 +376,7 @@ export class Publish extends Command { return '#' } - const releaseUrl = await createRelease(this.context, releaseNotes) + const releaseUrl = await Notes.createRelease(this.context, releaseNotes) this.log.info(format('created release: %s', releaseUrl)) return releaseUrl diff --git a/src/commands/show.ts b/src/commands/show.ts index a6ba69d..7b3120a 100644 --- a/src/commands/show.ts +++ b/src/commands/show.ts @@ -11,7 +11,7 @@ import { execAsync } from '../utils/execAsync' import { demandGitHubToken } from '../utils/env' interface Argv { - tag?: string + _: [path: string, tag?: string] } export enum ReleaseStatus { diff --git a/src/index.ts b/src/index.ts index 09a7cad..a3d44ef 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,6 +4,7 @@ import { getConfig } from './utils/getConfig' // Commands. import { Show } from './commands/show' import { Publish } from './commands/publish' +import { Notes } from './commands/notes' const config = getConfig() @@ -12,6 +13,9 @@ yargs .command(Publish.command, Publish.description, Publish.builder, (argv) => new Publish(config, argv).run(), ) + .command(Notes.command, Notes.description, Notes.builder, (argv) => { + return new Notes(config, argv).run() + }) .command(Show.command, Show.description, Show.builder, (argv) => new Show(config, argv).run(), ) diff --git a/src/utils/git/__test__/getCommits.test.ts b/src/utils/git/__test__/getCommits.test.ts index bc2a2cf..74c7068 100644 --- a/src/utils/git/__test__/getCommits.test.ts +++ b/src/utils/git/__test__/getCommits.test.ts @@ -16,23 +16,67 @@ afterAll(async () => { await cleanup() }) -it('returns commits from the given "after" commit hash', async () => { +it('returns commits since the given commit', async () => { await execAsync(`git commit -m 'one' --allow-empty`) await execAsync(`git commit -m 'two' --allow-empty`) const secondCommitHash = await execAsync(`git log --pretty=format:'%H' -n 1`) await execAsync(`git commit -m 'three' --allow-empty`) const thirdCommitHash = await execAsync(`git log --pretty=format:'%H' -n 1`) - expect(await getCommits({ after: secondCommitHash.trim() })).toEqual([ + expect(await getCommits({ since: secondCommitHash.trim() })).toEqual([ expect.objectContaining({ subject: 'three', hash: thirdCommitHash.trim(), - body: '', }), ]) }) -it(`returns all commits if not given the "after" commit hash`, async () => { +it('returns commits until the given commit', async () => { + await execAsync(`git commit -m 'one' --allow-empty`) + const oneCommitHash = await execAsync(`git log --pretty=format:%H -n 1`) + await execAsync(`git commit -m 'two' --allow-empty`) + const secondCommitHash = await execAsync(`git log --pretty=format:%H -n 1`) + await execAsync(`git commit -m 'three' --allow-empty`) + + expect(await getCommits({ until: secondCommitHash.trim() })).toEqual([ + expect.objectContaining({ + subject: 'one', + hash: oneCommitHash.trim(), + }), + expect.objectContaining({ + subject: 'chore(test): initial commit', + hash: expect.any(String), + }), + ]) +}) + +it('returns commits within the range', async () => { + await execAsync(`git commit -m 'one' --allow-empty`) + await execAsync(`git commit -m 'two' --allow-empty`) + const secondCommitHash = await execAsync(`git log --pretty=format:'%H' -n 1`) + await execAsync(`git commit -m 'three' --allow-empty`) + const thirdCommitHash = await execAsync(`git log --pretty=format:'%H' -n 1`) + await execAsync(`git commit -m 'four' --allow-empty`) + const fourthCommitHash = await execAsync(`git log --pretty=format:'%H' -n 1`) + + expect( + await getCommits({ + since: secondCommitHash.trim(), + until: fourthCommitHash.trim(), + }), + ).toEqual([ + expect.objectContaining({ + subject: 'four', + hash: fourthCommitHash.trim(), + }), + expect.objectContaining({ + subject: 'three', + hash: thirdCommitHash.trim(), + }), + ]) +}) + +it('returns all commits when called without any range', async () => { await execAsync(`git commit -m 'one' --allow-empty`) const firstCommitHash = await execAsync(`git log --pretty=format:'%H' -n 1`) await execAsync(`git commit -m 'two' --allow-empty`) @@ -44,17 +88,14 @@ it(`returns all commits if not given the "after" commit hash`, async () => { expect.objectContaining({ subject: 'three', hash: thirdCommitHash.trim(), - body: '', }), expect.objectContaining({ subject: 'two', hash: secondCommitHash.trim(), - body: '', }), expect.objectContaining({ subject: 'one', hash: firstCommitHash.trim(), - body: '', }), // This is the initial commit created by "initGit". expect.objectContaining({ diff --git a/src/utils/git/commit.ts b/src/utils/git/commit.ts index f60f129..ef7ab22 100644 --- a/src/utils/git/commit.ts +++ b/src/utils/git/commit.ts @@ -1,11 +1,35 @@ +import type { Commit } from 'git-log-parser' import { execAsync } from '../execAsync' +import { getCommit } from './getCommit' +import { parseCommits, ParsedCommitWithHash } from './parseCommits' export interface CommitOptions { - files: string[] message: string + files?: string[] + allowEmpty?: boolean + date?: Date } -export async function commit({ files, message }: CommitOptions): Promise { - await execAsync(`git add ${files.join(' ')}`) - await execAsync(`git commit -m '${message}'`) +export async function commit({ + files, + message, + allowEmpty, + date, +}: CommitOptions): Promise { + if (files) { + await execAsync(`git add ${files.join(' ')}`) + } + + const args: string[] = [ + `-m "${message}"`, + allowEmpty ? '--allow-empty' : '', + date ? `--date "${date.toISOString()}"` : '', + ] + + await execAsync(`git commit ${args.join(' ')}`) + const hash = await execAsync('git log --pretty=format:%H -n 1') + const commit = (await getCommit(hash)) as Commit + + const [commitInfo] = await parseCommits([commit]) + return commitInfo } diff --git a/src/utils/git/createRelease.ts b/src/utils/git/createRelease.ts index dccafe4..0a6fc9b 100644 --- a/src/utils/git/createRelease.ts +++ b/src/utils/git/createRelease.ts @@ -17,7 +17,9 @@ export async function createRelease( ): Promise { const { repo } = context - log.info('creating a new release at "%s/%s"...', repo.owner, repo.name) + log.info( + format('creating a new release at "%s/%s"...', repo.owner, repo.name), + ) const response = await fetch( `https://api.github.com/repos/${repo.owner}/${repo.name}/releases`, diff --git a/src/utils/git/getCommits.ts b/src/utils/git/getCommits.ts index 2237e8f..c9130c3 100644 --- a/src/utils/git/getCommits.ts +++ b/src/utils/git/getCommits.ts @@ -3,21 +3,32 @@ import * as gitLogParser from 'git-log-parser' import { execAsync } from '../execAsync' interface GetCommitsOptions { - after?: string + since?: string + until?: string } -export function getCommits({ after }: GetCommitsOptions = {}): Promise< - gitLogParser.Commit[] -> { +/** + * Return the list of parsed commits within the given range. + */ +export function getCommits({ + since, + until = 'HEAD', +}: GetCommitsOptions = {}): Promise { Object.assign(gitLogParser.fields, { hash: 'H', message: 'B', }) + const range: string = since ? `${since}..${until}` : until + + // When only the "until" commit is specified, skip the first commit. + const skip = range === until && until !== 'HEAD' ? 1 : undefined + return getStream.array( gitLogParser.parse( { - _: after ? `${after}..HEAD` : '', + _: range, + skip, }, { cwd: execAsync.contextOptions.cwd, diff --git a/src/utils/git/getLatestRelease.ts b/src/utils/git/getLatestRelease.ts index 3bae898..0b1e088 100644 --- a/src/utils/git/getLatestRelease.ts +++ b/src/utils/git/getLatestRelease.ts @@ -1,12 +1,14 @@ import * as semver from 'semver' import { getTag, TagPointer } from './getTag' +export function byReleaseVersion(left: string, right: string): number { + return semver.rcompare(left, right) +} + export async function getLatestRelease( tags: string[], ): Promise { - const allTags = tags.sort((left, right) => { - return semver.rcompare(left, right) - }) + const allTags = tags.sort(byReleaseVersion) const [latestTag] = allTags if (!latestTag) { From 88fa4c337ea6f229bc5fc0db2dd5d596137b01c5 Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 May 2022 17:16:34 +0200 Subject: [PATCH 2/3] feat(notes): check for existing github release --- src/commands/__test__/notes.test.ts | 50 ++++++++++++++++++- src/commands/__test__/publish.test.ts | 10 ++-- src/commands/notes.ts | 30 ++++++++--- src/commands/publish.ts | 3 +- .../createGitHubRelease.ts} | 18 +++---- src/utils/github/getGitHubRelease.ts | 36 +++++++++++++ test/env.ts | 1 + 7 files changed, 124 insertions(+), 24 deletions(-) rename src/utils/{git/createRelease.ts => github/createGitHubRelease.ts} (82%) create mode 100644 src/utils/github/getGitHubRelease.ts diff --git a/src/commands/__test__/notes.test.ts b/src/commands/__test__/notes.test.ts index 21c7f0b..7a0ff41 100644 --- a/src/commands/__test__/notes.test.ts +++ b/src/commands/__test__/notes.test.ts @@ -3,8 +3,10 @@ import { Notes } from '../notes' import { commit } from '../../utils/git/commit' import { testEnvironment } from '../../../test/env' import { execAsync } from '../../utils/execAsync' +import { GitHubRelease } from '../../utils/github/getGitHubRelease' const { setup, reset, cleanup, api, log } = testEnvironment('notes') + let gitHubReleaseHandler: jest.Mock = jest.fn< ReturnType, Parameters> @@ -38,7 +40,16 @@ afterAll(async () => { await cleanup() }) -it('creates a GitHub release for ???', async () => { +it('creates a GitHub release for a past release', async () => { + api.use( + rest.get( + 'https://api.github.com/repos/:owner/:repo/releases/tags/:tag', + (req, res, ctx) => { + return res(ctx.status(404)) + }, + ), + ) + // Preceding (previous) release. await commit({ message: `feat: long-ago published`, @@ -118,3 +129,40 @@ it('creates a GitHub release for ???', async () => { expect(gitHubReleaseHandler).toHaveBeenCalledTimes(1) expect(log.info).toHaveBeenCalledWith('created GitHub release: /releases/1') }) + +it('skips creating a GitHub release if the given release already exists', async () => { + api.use( + rest.get( + 'https://api.github.com/repos/:owner/:repo/releases/tags/:tag', + (req, res, ctx) => { + return res( + ctx.json({ + html_url: '/releases/1', + }), + ) + }, + ), + ) + + const notes = new Notes( + { + script: 'exit 0', + }, + { + _: ['', '1.0.0'], + }, + ) + await notes.run() + + expect(log.warn).toHaveBeenCalledWith( + 'found existing GitHub release for "v1.0.0": /releases/1', + ) + expect(log.info).not.toHaveBeenCalledWith( + 'creating GitHub release for version "v1.0.0" in "octocat/test"', + ) + expect(log.info).not.toHaveBeenCalledWith( + expect.stringContaining('created GitHub release:'), + ) + + expect(process.exit).toHaveBeenCalledWith(1) +}) diff --git a/src/commands/__test__/publish.test.ts b/src/commands/__test__/publish.test.ts index 0d636c3..8448e89 100644 --- a/src/commands/__test__/publish.test.ts +++ b/src/commands/__test__/publish.test.ts @@ -2,7 +2,7 @@ import * as fileSystem from 'fs' import { ResponseResolver, rest } from 'msw' import { log } from '../../logger' import { Publish } from '../publish' -import type { CreateReleaseResponse } from '../../utils/git/createRelease' +import type { GitHubRelease } from '../../utils/github/getGitHubRelease' import { testEnvironment } from '../../../test/env' import { execAsync } from '../../utils/execAsync' @@ -22,7 +22,7 @@ afterAll(async () => { it('publishes the next minor version', async () => { api.use( - rest.post( + rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( @@ -91,7 +91,7 @@ module.exports = { it('releases a new version after an existing version', async () => { api.use( - rest.post( + rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( @@ -163,7 +163,7 @@ it('comments on relevant github issues', async () => { const commentsCreated = new Map() api.use( - rest.post( + rest.post( 'https://api.github.com/repos/:owner/:repo/releases', (req, res, ctx) => { return res( @@ -226,7 +226,7 @@ it('supports dry-run mode', async () => { return res(ctx.status(500)) }) api.use( - rest.post( + rest.post( 'https://api.github.com/repos/:owner/:repo/releases', gitHubReleaseHandler, ), diff --git a/src/commands/notes.ts b/src/commands/notes.ts index 3f42b23..000bd60 100644 --- a/src/commands/notes.ts +++ b/src/commands/notes.ts @@ -2,7 +2,7 @@ import { format, invariant } from 'outvariant' import type { BuilderCallback } from 'yargs' import type { ReleaseContext } from '../utils/createContext' import { demandGitHubToken } from '../utils/env' -import { createRelease } from '../utils/git/createRelease' +import { createGitHubRelease } from '../utils/github/createGitHubRelease' import { Command } from '../Command' import { getInfo } from '../utils/git/getInfo' import { parseCommits, ParsedCommitWithHash } from '../utils/git/parseCommits' @@ -12,6 +12,10 @@ import { getTag } from '../utils/git/getTag' import { getCommit } from '../utils/git/getCommit' import { byReleaseVersion } from '../utils/git/getLatestRelease' import { getTags } from '../utils/git/getTags' +import { + getGitHubRelease, + GitHubRelease, +} from '../utils/github/getGitHubRelease' interface Argv { _: [path: string, tag: string] @@ -36,11 +40,25 @@ export class Notes extends Command { process.exit(1) }) + const repo = await getInfo() + const [, tagInput] = this.argv._ const tagName = tagInput.startsWith('v') ? tagInput : `v${tagInput}` const version = tagInput.replace(/^v/, '') - const repo = await getInfo() + // Check if there's an existing GitHub release for the given tag. + const existingRelease = await getGitHubRelease(tagName) + + if (existingRelease) { + this.log.warn( + format( + 'found existing GitHub release for "%s": %s', + tagName, + existingRelease.html_url, + ), + ) + return process.exit(1) + } this.log.info( format( @@ -127,8 +145,8 @@ export class Notes extends Command { this.log.info(format('generated release notes:\n%s', releaseNotes)) // Create GitHub release. - const releaseUrl = await Notes.createRelease(context, releaseNotes) - this.log.info(format('created GitHub release: %s', releaseUrl)) + const release = await Notes.createRelease(context, releaseNotes) + this.log.info(format('created GitHub release: %s', release.html_url)) } static async generateReleaseNotes( @@ -143,7 +161,7 @@ export class Notes extends Command { static async createRelease( context: ReleaseContext, notes: string, - ): Promise { - return createRelease(context, notes) + ): Promise { + return createGitHubRelease(context, notes) } } diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 814bf06..a7e990f 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -376,7 +376,8 @@ export class Publish extends Command { return '#' } - const releaseUrl = await Notes.createRelease(this.context, releaseNotes) + const release = await Notes.createRelease(this.context, releaseNotes) + const { html_url: releaseUrl } = release this.log.info(format('created release: %s', releaseUrl)) return releaseUrl diff --git a/src/utils/git/createRelease.ts b/src/utils/github/createGitHubRelease.ts similarity index 82% rename from src/utils/git/createRelease.ts rename to src/utils/github/createGitHubRelease.ts index 0a6fc9b..a2ac85c 100644 --- a/src/utils/git/createRelease.ts +++ b/src/utils/github/createGitHubRelease.ts @@ -1,20 +1,17 @@ -import { log } from '../../logger' import fetch from 'node-fetch' import { format } from 'outvariant' -import type { ReleaseContext } from 'utils/createContext' - -export interface CreateReleaseResponse { - html_url: string -} +import type { ReleaseContext } from '../createContext' +import type { GitHubRelease } from './getGitHubRelease' +import { log } from '../../logger' /** * Create a new GitHub release with the given release notes. * @return {string} The URL of the newly created release. */ -export async function createRelease( +export async function createGitHubRelease( context: ReleaseContext, notes: string, -): Promise { +): Promise { const { repo } = context log.info( @@ -26,6 +23,7 @@ export async function createRelease( { method: 'POST', headers: { + Accept: 'application/json', Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, 'Content-Type': 'application/json', }, @@ -53,7 +51,5 @@ export async function createRelease( ) } - const data = (await response.json()) as CreateReleaseResponse - - return data.html_url + return response.json() } diff --git a/src/utils/github/getGitHubRelease.ts b/src/utils/github/getGitHubRelease.ts new file mode 100644 index 0000000..c9831f9 --- /dev/null +++ b/src/utils/github/getGitHubRelease.ts @@ -0,0 +1,36 @@ +import { invariant } from 'outvariant' +import fetch from 'node-fetch' +import { getInfo } from '../git/getInfo' + +export interface GitHubRelease { + html_url: string +} + +export async function getGitHubRelease( + tag: string, +): Promise { + const repo = await getInfo() + + const response = await fetch( + `https://api.github.com/repos/${repo.owner}/${repo.name}/releases/tags/${tag}`, + { + headers: { + Accept: 'application/json', + Authorization: `token ${process.env.GITHUB_TOKEN}`, + }, + }, + ) + + if (response.status === 404) { + return undefined + } + + invariant( + response.ok, + 'Failed to fetch GitHub release for tag "%s": server responded with %d.\n\n%s', + tag, + response.status, + ) + + return response.json() +} diff --git a/test/env.ts b/test/env.ts index b9b9dad..146f4ba 100644 --- a/test/env.ts +++ b/test/env.ts @@ -52,6 +52,7 @@ export function testEnvironment(testName: string): TestEnvironment { git, log, async setup() { + jest.spyOn(process, 'exit') jest.spyOn(log, 'info').mockImplementation() jest.spyOn(log, 'warn').mockImplementation() jest.spyOn(log, 'error').mockImplementation() From 3c91b654e9da603855cd651f4a7a04dd4cbecffa Mon Sep 17 00:00:00 2001 From: Artem Zakharchenko Date: Sun, 8 May 2022 17:20:01 +0200 Subject: [PATCH 3/3] fix(notes): remove the release commit log --- src/commands/notes.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/commands/notes.ts b/src/commands/notes.ts index 000bd60..f1ec70e 100644 --- a/src/commands/notes.ts +++ b/src/commands/notes.ts @@ -89,11 +89,6 @@ export class Notes extends Command { tagPointer.hash, ) - /** - * @fixme Pretty-print the release commit instead of logging a commit object. - */ - this.log.info(format('found release commit:\n%o', releaseCommit)) - // Retrieve the pointer to the previous release. const tags = await getTags().then((tags) => { return tags.sort(byReleaseVersion)