diff --git a/package.json b/package.json index 95c4033..bfd2d84 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "dependencies": { "@open-draft/until": "^2.0.0", "@types/conventional-commits-parser": "^3.0.2", + "@types/issue-parser": "^3.0.1", "@types/node": "^16.11.27", "@types/node-fetch": "2.x", "@types/semver": "^7.3.9", @@ -35,6 +36,7 @@ "conventional-commits-parser": "^3.2.4", "get-stream": "^6.0.1", "git-log-parser": "^1.2.0", + "issue-parser": "^6.0.0", "node-fetch": "2.6.7", "outvariant": "^1.3.0", "pino": "^7.10.0", diff --git a/src/commands/publish.ts b/src/commands/publish.ts index 801c546..998db6b 100644 --- a/src/commands/publish.ts +++ b/src/commands/publish.ts @@ -17,6 +17,10 @@ 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 } from '../utils/git/parseCommits' +import { createComment } from '../utils/github/createComment' +import { createReleaseComment } from '../utils/createReleaseComment' export class Publish extends Command { static command = 'publish' @@ -50,7 +54,7 @@ export class Publish extends Command { const commits = await getCommits({ after: latestRelease?.hash, - }) + }).then(parseCommits) if (commits.length === 0) { log.warn('no commits since the latest release, skipping...') @@ -59,7 +63,7 @@ export class Publish extends Command { log.info('found %d new commit(s):', commits.length) for (const commit of commits) { - log.info('- %s (%s)', commit.subject, commit.hash) + log.info('- %s (%s)', commit.header, commit.hash) } // Get the next release type and version number. @@ -108,6 +112,7 @@ export class Publish extends Command { log.info(publishResult.data) log.info('published successfully!') + // The queue of actions to invoke if releasing fails. const revertQueue: Array<() => Promise> = [] const result = await until(async () => { @@ -115,7 +120,7 @@ export class Publish extends Command { const commitResult = await until(() => { return commit({ files: ['package.json'], - message: `chore: publish ${context.nextRelease.tag}`, + message: `chore(release): ${context.nextRelease.tag}`, }) }) @@ -183,6 +188,10 @@ export class Publish extends Command { ) log.info('pushed changes to "%s" (origin)!', repo.remote) + + return { + releaseUrl, + } }) if (result.error) { @@ -200,8 +209,31 @@ export class Publish extends Command { } log.error(result.error) - console.error(result.error) - process.exit(1) + throw result.error + } + + // Comment on each relevant GitHub issue. + const issueIds = await getReleaseRefs(commits) + const releaseCommentText = createReleaseComment({ + context, + releaseUrl: result.data.releaseUrl, + }) + + if (issueIds.size > 0) { + log.info('commenting on %d referenced issue(s)...', issueIds.size) + + const commentPromises = [] + for (const issueId of issueIds) { + commentPromises.push( + createComment(issueId, releaseCommentText).catch((error) => { + log.error('commenting on issue "%s" failed: %s', error.message) + }) + ) + } + + await Promise.allSettled(commentPromises) + } else { + log.info('no referenced issues, nothing to comment') } log.info('release "%s" completed!', context.nextRelease.tag) diff --git a/src/utils/__test__/createReleaseComment.test.ts b/src/utils/__test__/createReleaseComment.test.ts new file mode 100644 index 0000000..72ff070 --- /dev/null +++ b/src/utils/__test__/createReleaseComment.test.ts @@ -0,0 +1,50 @@ +import { createReleaseComment } from '../createReleaseComment' +import { testEnvironment } from '../../../test/env' +import { mockRepo } from '../../../test/fixtures' +import { createContext } from '../createContext' + +const { setup, reset, cleanup, fs } = testEnvironment('create-release-comment') + +beforeAll(async () => { + await setup() +}) + +afterEach(async () => { + await reset() +}) + +afterAll(async () => { + await cleanup() +}) + +it('creates a release comment out of given release context', async () => { + await fs.create({ + 'package.json': JSON.stringify({ + name: 'my-package', + }), + }) + + const comment = createReleaseComment({ + context: createContext({ + repo: mockRepo(), + nextRelease: { + version: '1.2.3', + publishedAt: new Date(), + }, + }), + releaseUrl: '/releases/1', + }) + + expect(comment).toBe(`## Released: v1.2.3 🎉 + +This has been released in v1.2.3! + +- 📄 [**Release notes**](/releases/1) +- 📦 [npm package](https://www.npmjs.com/package/my-package/v/1.2.3) + +Make sure to always update to the latest version (\`npm i my-package@latest\`) to get the newest features and bug fixes. + +--- + +_Predictable release automation by [@ossjs/release](https://github.com/ossjs/release)_.`) +}) diff --git a/src/utils/__test__/getNextReleaseType.test.ts b/src/utils/__test__/getNextReleaseType.test.ts index 929aa31..d03a266 100644 --- a/src/utils/__test__/getNextReleaseType.test.ts +++ b/src/utils/__test__/getNextReleaseType.test.ts @@ -1,40 +1,11 @@ -import * as gitLogParser from 'git-log-parser' +import { mockParsedCommit } from '../../../test/fixtures' import { getNextReleaseType } from '../getNextReleaseType' -function mockCommit( - initialValues: Partial = {} -): gitLogParser.Commit { - return { - commit: { - short: '', - long: '', - }, - subject: '', - body: '', - hash: '', - tree: { - short: '', - long: '', - }, - author: { - name: 'John Doe', - email: 'john@doe.com', - date: new Date(), - }, - committer: { - name: 'John Doe', - email: 'john@doe.com', - date: new Date(), - }, - ...initialValues, - } -} - it('returns "major" for a commit that contains a "BREAKING CHANGE" footnote', () => { expect( getNextReleaseType([ - mockCommit({ - subject: 'fix: stuff', + mockParsedCommit({ + header: 'fix: stuff', body: 'BREAKING CHANGE: This is a breaking change.', }), ]) @@ -44,19 +15,19 @@ it('returns "major" for a commit that contains a "BREAKING CHANGE" footnote', () it('returns "minor" for "feat" commits', () => { expect( getNextReleaseType([ - mockCommit({ - subject: 'feat: adds graphql support', + mockParsedCommit({ + header: 'feat: adds graphql support', }), ]) ).toBe('minor') expect( getNextReleaseType([ - mockCommit({ - subject: 'feat: adds graphql support', + mockParsedCommit({ + header: 'feat: adds graphql support', }), - mockCommit({ - subject: 'fix: fix stuff', + mockParsedCommit({ + header: 'fix: fix stuff', }), ]) ).toBe('minor') @@ -65,19 +36,19 @@ it('returns "minor" for "feat" commits', () => { it('returns patch for "fix" commits', () => { expect( getNextReleaseType([ - mockCommit({ - subject: 'fix: return signature', + mockParsedCommit({ + header: 'fix: return signature', }), ]) ).toBe('patch') expect( getNextReleaseType([ - mockCommit({ - subject: 'fix: return signature', + mockParsedCommit({ + header: 'fix: return signature', }), - mockCommit({ - subject: 'docs: mention stuff', + mockParsedCommit({ + header: 'docs: mention stuff', }), ]) ).toBe('patch') @@ -86,11 +57,11 @@ it('returns patch for "fix" commits', () => { it('returns null when no commits bump the version', () => { expect( getNextReleaseType([ - mockCommit({ - subject: 'chore: design better releases', + mockParsedCommit({ + header: 'chore: design better releases', }), - mockCommit({ - subject: 'docs: mention cli arguments', + mockParsedCommit({ + header: 'docs: mention cli arguments', }), ]) ).toBe(null) diff --git a/src/utils/__test__/getReleaseNotes.test.ts b/src/utils/__test__/getReleaseNotes.test.ts index bf8031f..0fa2e4f 100644 --- a/src/utils/__test__/getReleaseNotes.test.ts +++ b/src/utils/__test__/getReleaseNotes.test.ts @@ -1,21 +1,22 @@ -import type { Commit } from 'git-log-parser' import { getReleaseNotes, toMarkdown } from '../getReleaseNotes' -import { mockRepo } from '../../../test/fixtures' +import { mockCommit, mockRepo } from '../../../test/fixtures' import { createContext } from '../createContext' +import { parseCommits } from '../git/parseCommits' describe(getReleaseNotes, () => { it('groups commits by commit type', async () => { - const notes = await getReleaseNotes([ - { + const commits = await parseCommits([ + mockCommit({ subject: 'feat: support graphql', - }, - { + }), + mockCommit({ subject: 'fix(ui): remove unsupported styles', - }, - { + }), + mockCommit({ subject: 'chore: update dependencies', - }, - ] as Commit[]) + }), + ]) + const notes = await getReleaseNotes(commits) expect(Array.from(notes.keys())).toEqual(['feat', 'fix']) @@ -36,11 +37,12 @@ describe(getReleaseNotes, () => { }) it('includes issues references', async () => { - const notes = await getReleaseNotes([ - { + const commits = await parseCommits([ + mockCommit({ subject: 'feat(api): improves stuff (#1)', - }, - ] as Commit[]) + }), + ]) + const notes = await getReleaseNotes(commits) expect(Array.from(notes.keys())).toEqual(['feat']) @@ -70,37 +72,39 @@ describe(toMarkdown, () => { }) it('includes both issue and commit reference', async () => { - const notes = await getReleaseNotes([ - { + const commits = await parseCommits([ + mockCommit({ hash: 'abc123', subject: 'feat(api): improves stuff (#1)', - }, - ] as Commit[]) - + }), + ]) + const notes = await getReleaseNotes(commits) const markdown = toMarkdown(context, notes) expect(markdown).toContain('- **api:** improves stuff (#1) (abc123)') }) it('retains strict order of release sections', async () => { - const notes = await getReleaseNotes([ - { + const commits = await parseCommits([ + mockCommit({ hash: 'abc123', subject: 'fix: second bugfix', - }, - { + }), + mockCommit({ hash: 'def456', subject: 'fix: first bugfix', - }, - { + }), + mockCommit({ hash: 'fgh789', subject: 'feat: second feature', - }, - { + }), + mockCommit({ hash: 'xyz321', subject: 'feat: first feature', - }, - ] as Commit[]) + }), + ]) + + const notes = await getReleaseNotes(commits) const markdown = toMarkdown(context, notes) expect(markdown).toEqual(`\ diff --git a/src/utils/__test__/getReleaseRefs.test.ts b/src/utils/__test__/getReleaseRefs.test.ts new file mode 100644 index 0000000..72a2a45 --- /dev/null +++ b/src/utils/__test__/getReleaseRefs.test.ts @@ -0,0 +1,67 @@ +import { rest } from 'msw' +import { getReleaseRefs } from '../getReleaseRefs' +import { parseCommits } from '../git/parseCommits' +import { testEnvironment } from '../../../test/env' +import { mockCommit } from '../../../test/fixtures' + +const { setup, reset, cleanup, api } = testEnvironment('get-release-refs') + +beforeAll(async () => { + await setup() +}) + +afterEach(async () => { + await reset() +}) + +afterAll(async () => { + await cleanup() +}) + +it('extracts references from commit messages', async () => { + const issues: Record = { + '1': { + html_url: '/issues/1', + }, + '5': { + html_url: '/issues/5', + }, + '10': { + html_url: '/issues/10', + pull_request: [], + body: ` +This pull request references issues in its description. + +- Closes #1 +- Fixes #5 +`, + }, + } + + api.use( + rest.get( + 'https://api.github.com/repos/:owner/:repo/issues/:id', + (req, res, ctx) => { + return res(ctx.json(issues[req.params.id as string])) + } + ) + ) + + // Create a (squash) commit that references a closed pull request. + const commits = await parseCommits([ + mockCommit({ + subject: 'fix(ui): some stuff (#10)', + }), + ]) + const refs = await getReleaseRefs(commits) + + expect(refs).toEqual( + new Set([ + // Pull request id referenced in the squash commit message. + '10', + // Issue id referenced in the pull request description. + '1', + '5', + ]) + ) +}) diff --git a/src/utils/bumpPackageJson.ts b/src/utils/bumpPackageJson.ts index 6f0a2c6..1916222 100644 --- a/src/utils/bumpPackageJson.ts +++ b/src/utils/bumpPackageJson.ts @@ -1,14 +1,10 @@ import * as fs from 'fs' -import * as path from 'path' -import { execAsync } from './execAsync' +import { readPackageJson } from './readPackageJson' +import { writePackageJson } from './writePackageJson' export function bumpPackageJson(version: string): void { - const packageJsonPath = path.resolve( - execAsync.contextOptions.cwd!.toString(), - 'package.json' - ) - - const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) + const packageJson = readPackageJson() packageJson.version = version - fs.writeFileSync(packageJsonPath, JSON.stringify(packageJson, null, 2)) + + writePackageJson(packageJson) } diff --git a/src/utils/createReleaseComment.ts b/src/utils/createReleaseComment.ts new file mode 100644 index 0000000..0ec5c47 --- /dev/null +++ b/src/utils/createReleaseComment.ts @@ -0,0 +1,25 @@ +import type { ReleaseContext } from './createContext' +import { readPackageJson } from './readPackageJson' + +export interface ReleaseCommentInput { + context: ReleaseContext + releaseUrl: string +} + +export function createReleaseComment(input: ReleaseCommentInput): string { + const { context, releaseUrl } = input + const packageJson = readPackageJson() + + return `## Released: ${context.nextRelease.tag} 🎉 + +This has been released in ${context.nextRelease.tag}! + +- 📄 [**Release notes**](${releaseUrl}) +- 📦 [npm package](https://www.npmjs.com/package/${packageJson.name}/v/${context.nextRelease.version}) + +Make sure to always update to the latest version (\`npm i ${packageJson.name}@latest\`) to get the newest features and bug fixes. + +--- + +_Predictable release automation by [@ossjs/release](https://github.com/ossjs/release)_.` +} diff --git a/src/utils/getNextReleaseType.ts b/src/utils/getNextReleaseType.ts index ed168dd..d0dd642 100644 --- a/src/utils/getNextReleaseType.ts +++ b/src/utils/getNextReleaseType.ts @@ -1,8 +1,8 @@ import * as semver from 'semver' -import * as gitLogParser from 'git-log-parser' +import { ParsedCommitWithHash } from './git/parseCommits' export function getNextReleaseType( - commits: gitLogParser.Commit[] + commits: ParsedCommitWithHash[] ): semver.ReleaseType | null { const ranges: ['minor' | null, 'patch' | null] = [null, null] @@ -11,17 +11,17 @@ export function getNextReleaseType( * @fixme This message is not the only way to denote a breaking change. * @ see https://www.conventionalcommits.org/en/v1.0.0/#summary */ - if (commit.body.startsWith('BREAKING CHANGE:')) { + if (commit.body?.includes('BREAKING CHANGE:')) { return 'major' } switch (true) { - case commit.subject.startsWith('feat:'): { + case commit.header?.startsWith('feat:'): { ranges[0] = 'minor' break } - case commit.subject.startsWith('fix:'): { + case commit.header?.startsWith('fix:'): { ranges[1] = 'patch' break } diff --git a/src/utils/getReleaseNotes.ts b/src/utils/getReleaseNotes.ts index 6f4d67b..45ca2ae 100644 --- a/src/utils/getReleaseNotes.ts +++ b/src/utils/getReleaseNotes.ts @@ -1,65 +1,31 @@ -import { PassThrough } from 'node:stream' -import type { Commit } from 'git-log-parser' -import parseCommit from 'conventional-commits-parser' -import type { Commit as ParsedCommit } from 'conventional-commits-parser' import type { ReleaseContext } from './createContext' - -export type ParsedCommitWithHash = ParsedCommit & { - hash: string -} +import type { ParsedCommitWithHash } from './git/parseCommits' export type ReleaseNotes = Map> const IGNORE_COMMIT_TYPE = ['chore'] export async function getReleaseNotes( - commits: Commit[] + commits: ParsedCommitWithHash[] ): Promise { - const commitParser = parseCommit() - const through = new PassThrough() - - const commitMap: Record = {} - - for (const commit of commits) { - commitMap[commit.subject] = commit - through.write(commit.subject) - } - through.end() - const releaseNotes: ReleaseNotes = new Map< string, Set >() - return new Promise((resolve, reject) => { - through - .pipe(commitParser) - .on('error', reject) - .on('data', (parsedCommit: ParsedCommit) => { - const { type, header, merge } = parsedCommit - - if (!type || !header || merge || IGNORE_COMMIT_TYPE.includes(type)) { - return - } - - const originalCommit = commitMap[header] - - const parsedCommitWithHash: ParsedCommitWithHash = Object.assign( - {}, - parsedCommit, - { - hash: originalCommit.hash, - } - ) - - const nextCommits = - releaseNotes.get(type) || new Set() - releaseNotes.set(type, nextCommits.add(parsedCommitWithHash)) - }) - .on('end', () => { - resolve(releaseNotes) - }) - }) + for (const commit of commits) { + const { type, merge } = commit + + if (!type || merge || IGNORE_COMMIT_TYPE.includes(type)) { + continue + } + + const nextCommits = + releaseNotes.get(type) || new Set() + releaseNotes.set(type, nextCommits.add(commit)) + } + + return releaseNotes } export function toMarkdown( diff --git a/src/utils/getReleaseRefs.ts b/src/utils/getReleaseRefs.ts new file mode 100644 index 0000000..2778e4b --- /dev/null +++ b/src/utils/getReleaseRefs.ts @@ -0,0 +1,83 @@ +import fetch from 'node-fetch' +import createIssueParser from 'issue-parser' +import { getInfo, GitInfo } from './git/getInfo' +import type { ParsedCommitWithHash } from './git/parseCommits' + +const parser = createIssueParser('github') + +function extractIssueIds(text: string, repo: GitInfo): Set { + const ids = new Set() + const parsed = parser(text) + + for (const action of parsed.actions.close) { + if (action.slug == null || action.slug === `${repo.owner}/${repo.name}`) { + ids.add(action.issue) + } + } + + return ids +} + +export async function getReleaseRefs( + commits: ParsedCommitWithHash[] +): Promise> { + const repo = await getInfo() + const issueIds = new Set() + + for (const commit of commits) { + // Extract issue ids from the commit messages. + for (const ref of commit.references) { + if (ref.issue) { + issueIds.add(ref.issue) + } + } + + // Extract issue ids from the commit bodies. + if (commit.body) { + const bodyIssueIds = extractIssueIds(commit.body, repo) + bodyIssueIds.forEach((id) => issueIds.add(id)) + } + } + + // Fetch issue detail from each issue referenced in the commit message + // or commit body. Those may include pull request ids that reference + // other issues. + const issuesFromCommits = await Promise.all( + Array.from(issueIds).map(fetchIssue) + ) + + // Extract issue ids from the pull request descriptions. + for (const issue of issuesFromCommits) { + // Ignore regular issues as they may not close/fix other issues + // by reference (at least on GitHub). + if (!issue.pull_request) { + continue + } + + const descriptionIssueIds = extractIssueIds(issue.body, repo) + descriptionIssueIds.forEach((id) => issueIds.add(id)) + } + + return issueIds +} + +export interface IssueOrPullRequest { + html_url: string + pull_request: any + body: string +} + +async function fetchIssue(id: string): Promise { + const repo = await getInfo() + const response = await fetch( + `https://api.github.com/repos/${repo.owner}/${repo.name}/issues/${id}`, + { + headers: { + Authorization: `Bearer ${process.env.GITHUB_TOKEN}`, + }, + } + ) + const resource = (await response.json()) as Promise + + return resource +} diff --git a/src/utils/git/parseCommits.ts b/src/utils/git/parseCommits.ts new file mode 100644 index 0000000..bf2fc43 --- /dev/null +++ b/src/utils/git/parseCommits.ts @@ -0,0 +1,52 @@ +import { PassThrough } from 'stream' +import type { Commit } from 'git-log-parser' +import parseCommit from 'conventional-commits-parser' +import type { Commit as ParsedCommit } from 'conventional-commits-parser' + +export type ParsedCommitWithHash = ParsedCommit & { + hash: string +} + +export async function parseCommits( + commits: Commit[] +): Promise { + const through = new PassThrough() + const commitMap: Record = {} + + for (const commit of commits) { + const { subject } = commit + commitMap[subject] = commit + through.write(subject) + } + + through.end() + + const commitParser = parseCommit() + const results = await new Promise( + (resolve, reject) => { + const commits: ParsedCommitWithHash[] = [] + + through + .pipe(commitParser) + .on('error', (error) => reject(error)) + .on('data', (parsedCommit: ParsedCommit) => { + const { header } = parsedCommit + + if (!header) { + return + } + + const originalCommit = commitMap[header] + const commit: ParsedCommitWithHash = Object.assign({}, parsedCommit, { + hash: originalCommit.hash, + }) + commits.push(commit) + }) + .on('end', () => resolve(commits)) + } + ) + + through.destroy() + + return results +} diff --git a/src/utils/github/createComment.ts b/src/utils/github/createComment.ts new file mode 100644 index 0000000..b19d495 --- /dev/null +++ b/src/utils/github/createComment.ts @@ -0,0 +1,30 @@ +import fetch from 'node-fetch' +import { invariant } from 'outvariant' +import { getInfo } from '../git/getInfo' + +export async function createComment( + issueId: string, + body: string +): Promise { + const repo = await getInfo() + + const response = await fetch( + `https://api.github.com/repos/${repo.owner}/${repo.name}/issues/${issueId}/comments`, + { + method: 'POST', + headers: { + Authorization: `token ${process.env.GITHUB_TOKEN}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + body, + }), + } + ) + + invariant( + response.ok, + 'Failed to create GitHub comment for "%s" issue.', + issueId + ) +} diff --git a/src/utils/readPackageJson.ts b/src/utils/readPackageJson.ts new file mode 100644 index 0000000..6ebd52b --- /dev/null +++ b/src/utils/readPackageJson.ts @@ -0,0 +1,12 @@ +import * as fs from 'fs' +import * as path from 'path' +import { execAsync } from './execAsync' + +export function readPackageJson(): Record { + const packageJsonPath = path.resolve( + execAsync.contextOptions.cwd!.toString(), + 'package.json' + ) + + return JSON.parse(fs.readFileSync(packageJsonPath, 'utf8')) +} diff --git a/src/utils/writePackageJson.ts b/src/utils/writePackageJson.ts new file mode 100644 index 0000000..869224a --- /dev/null +++ b/src/utils/writePackageJson.ts @@ -0,0 +1,18 @@ +import * as fs from 'fs' +import * as path from 'path' +import { execAsync } from './execAsync' + +export function writePackageJson(nextContent: Record): void { + const packageJsonPath = path.resolve( + execAsync.contextOptions.cwd!.toString(), + 'package.json' + ) + + fs.writeFileSync( + packageJsonPath, + /** + * @fixme Do not alter the indentation. + */ + JSON.stringify(nextContent, null, 2) + ) +} diff --git a/test/commands/publish.test.ts b/test/commands/publish.test.ts new file mode 100644 index 0000000..2c5b1e1 --- /dev/null +++ b/test/commands/publish.test.ts @@ -0,0 +1,145 @@ +import { rest } from 'msw' +import { log } from '../../src/logger' +import { Publish } from '../../src/commands/publish' +import type { CreateReleaseResponse } from '../../src/utils/git/createRelease' +import { testEnvironment } from '../env' + +const { setup, reset, cleanup, fs, api } = testEnvironment('publish') + +beforeAll(async () => { + await setup() +}) + +afterEach(async () => { + await reset() +}) + +afterAll(async () => { + await cleanup() +}) + +it('publishes the next minor version', async () => { + api.use( + rest.post( + 'https://api.github.com/repos/:owner/:repo/releases', + (req, res, ctx) => { + return res( + ctx.status(201), + ctx.json({ + html_url: '/releases/1', + }) + ) + } + ) + ) + + await fs.create({ + 'package.json': JSON.stringify({ + name: 'test', + version: '0.0.0', + }), + 'tarn.config.js': ` +module.exports = { + script: 'echo "release script input: $RELEASE_VERSION"', +} + `, + }) + await fs.exec(`git add . && git commit -m 'feat: new things'`) + + const publish = new Publish({ + script: 'echo "release script input: $RELEASE_VERSION"', + }) + await publish.run() + + expect(log.error).not.toHaveBeenCalled() + + expect(log.info).toHaveBeenCalledWith('found %d new commit(s):', 2) + + // Must notify about the next version. + expect(log.info).toHaveBeenCalledWith( + 'next version: %s -> %s', + '0.0.0', + '0.1.0' + ) + + // The release script is provided with the environmental variables. + expect(log.info).toHaveBeenCalledWith('release script input: 0.1.0\n') + + // Must bump the "version" in package.json. + expect(JSON.parse(await fs.readFile('package.json', 'utf8'))).toHaveProperty( + 'version', + '0.1.0' + ) + + expect(await fs.exec('git log')).toHaveProperty( + 'stdout', + expect.stringContaining('chore(release): v0.1.0') + ) + + // Must create a new tag for the release. + expect(await fs.exec('git tag')).toHaveProperty( + 'stdout', + expect.stringContaining('0.1.0') + ) + + expect(log.info).toHaveBeenCalledWith('created release: %s', '/releases/1') +}) + +it('comments on relevant github issues', async () => { + const commentsCreated = new Map() + + api.use( + rest.post( + 'https://api.github.com/repos/:owner/:repo/releases', + (req, res, ctx) => { + return res( + ctx.status(201), + ctx.json({ + html_url: '/releases/1', + }) + ) + } + ), + rest.get( + 'https://api.github.com/repos/:owner/:repo/issues/:id', + (req, res, ctx) => { + return res(ctx.json({})) + } + ), + rest.post<{ body: string }>( + 'https://api.github.com/repos/:owner/:repo/issues/:id/comments', + (req, res, ctx) => { + commentsCreated.set(req.params.id as string, req.body.body) + return res(ctx.status(201)) + } + ) + ) + + await fs.create({ + 'package.json': JSON.stringify({ + name: 'test', + version: '0.0.0', + }), + 'tarn.config.js': ` +module.exports = { + script: 'echo "release script input: $RELEASE_VERSION"', +} + `, + }) + await fs.exec(`git add . && git commit -m 'feat: supports graphql (#10)'`) + + const publish = new Publish({ + script: 'echo "release script input: $RELEASE_VERSION"', + }) + await publish.run() + + expect(log.info).toHaveBeenCalledWith( + 'commenting on %d referenced issue(s)...', + 1 + ) + expect(commentsCreated).toEqual( + new Map([['10', expect.stringContaining('## Released: v0.1.0 🎉')]]) + ) + + expect(log.info).toHaveBeenCalledWith('release "%s" completed!', 'v0.1.0') +}) diff --git a/test/publish/show.test.ts b/test/commands/show.test.ts similarity index 100% rename from test/publish/show.test.ts rename to test/commands/show.test.ts diff --git a/test/fixtures.ts b/test/fixtures.ts index 8f421b5..b3854ab 100644 --- a/test/fixtures.ts +++ b/test/fixtures.ts @@ -1,5 +1,7 @@ -import { Config } from '../src/utils/getConfig' -import { GitInfo } from '../src/utils/git/getInfo' +import type { Commit } from 'git-log-parser' +import type { Config } from '../src/utils/getConfig' +import type { GitInfo } from '../src/utils/git/getInfo' +import { ParsedCommitWithHash } from '../src/utils/git/parseCommits' export function mockConfig(config: Partial = {}): Config { return { @@ -17,3 +19,48 @@ export function mockRepo(repo: Partial = {}): GitInfo { ...repo, } } + +export function mockCommit(commit: Partial = {}): Commit { + return { + body: '', + subject: '', + hash: '', + commit: { + long: '', + short: '', + }, + tree: { + long: '', + short: '', + }, + author: { + name: 'octocat', + email: 'octocat@github.com', + date: new Date(), + }, + committer: { + name: 'octocat', + email: 'octocat@github.com', + date: new Date(), + }, + ...commit, + } +} + +export function mockParsedCommit( + commit: Partial = {} +): ParsedCommitWithHash { + return { + subject: '', + merge: '', + mentions: [] as any, + references: [] as any, + footer: '', + header: '', + body: '', + hash: '', + notes: [] as any, + revert: null as any, + ...commit, + } +} diff --git a/test/publish/publish.test.ts b/test/publish/publish.test.ts deleted file mode 100644 index f32520d..0000000 --- a/test/publish/publish.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -import { createTeardown } from 'fs-teardown' -import { Git } from 'node-git-server' -import { rest } from 'msw' -import { setupServer } from 'msw/node' -import { log } from '../../src/logger' -import { createOrigin, initGit, startGitProvider } from '../utils' -import { Publish } from '../../src/commands/publish' -import { execAsync } from '../../src/utils/execAsync' -import type { CreateReleaseResponse } from '../../src/utils/git/createRelease' - -const server = setupServer( - rest.post( - 'https://api.github.com/repos/:owner/:repo/releases', - (req, res, ctx) => { - return res( - ctx.status(201), - ctx.json({ - html_url: '/releases/1', - }) - ) - } - ) -) - -const fsMock = createTeardown({ - rootDir: 'tarm/publish', - paths: { - 'git-provider': null, - }, -}) - -const origin = createOrigin() - -const gitProvider = new Git(fsMock.resolve('git-provider'), { - autoCreate: true, -}) - .on('push', (push) => push.accept()) - .on('fetch', (fetch) => fetch.accept()) - -beforeAll(async () => { - jest.spyOn(log, 'info').mockImplementation() - jest.spyOn(log, 'error') - - execAsync.mockContext({ - cwd: fsMock.resolve(), - }) - server.listen({ - onUnhandledRequest: 'error', - }) - await fsMock.prepare() - await startGitProvider(gitProvider, await origin.get()) -}) - -afterEach(async () => { - jest.resetAllMocks() - server.resetHandlers() - await fsMock.reset() -}) - -afterAll(async () => { - jest.restoreAllMocks() - execAsync.restoreContext() - server.close() - await fsMock.cleanup() - await gitProvider.close() -}) - -it('publishes the next minor version', async () => { - await fsMock.create({ - 'package.json': JSON.stringify({ - name: 'test', - version: '0.0.0', - }), - 'tarn.config.js': ` -module.exports = { - script: 'echo "release script input: $RELEASE_VERSION"', -} - `, - }) - await initGit(fsMock, origin.url) - await fsMock.exec(`git add . && git commit -m 'feat: new things'`) - - const publish = new Publish({ - script: 'echo "release script input: $RELEASE_VERSION"', - }) - await publish.run() - - expect(log.error).not.toHaveBeenCalled() - - expect(log.info).toHaveBeenCalledWith('found %d new commit(s):', 2) - - // Must notify about the next version. - expect(log.info).toHaveBeenCalledWith( - 'next version: %s -> %s', - '0.0.0', - '0.1.0' - ) - - // The release script is provided with the environmental variables. - expect(log.info).toHaveBeenCalledWith('release script input: 0.1.0\n') - - // Must bump the "version" in package.json. - expect( - JSON.parse(await fsMock.readFile('package.json', 'utf8')) - ).toHaveProperty('version', '0.1.0') - - expect(await fsMock.exec('git log')).toHaveProperty( - 'stdout', - expect.stringContaining('chore: publish v0.1.0') - ) - - // Must create a new tag for the release. - expect(await fsMock.exec('git tag')).toHaveProperty( - 'stdout', - expect.stringContaining('0.1.0') - ) - - expect(log.info).toHaveBeenCalledWith('created release: %s', '/releases/1') -}) diff --git a/yarn.lock b/yarn.lock index 67e2a16..7cf4105 100644 --- a/yarn.lock +++ b/yarn.lock @@ -621,6 +621,11 @@ dependencies: "@types/node" "*" +"@types/issue-parser@^3.0.1": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/issue-parser/-/issue-parser-3.0.1.tgz#05240316890ec37fef7cd64d19019ac95733c7a8" + integrity sha512-cdggbeJIxWoIB8CB57BvenONrQZcBuEf2uddxMRNIy2jgdcnSxnY71tQcNrxdqTG4VmQP5fdLLE9E+jCnMK0Fg== + "@types/istanbul-lib-coverage@*", "@types/istanbul-lib-coverage@^2.0.0", "@types/istanbul-lib-coverage@^2.0.1": version "2.0.4" resolved "https://registry.yarnpkg.com/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.4.tgz#8467d4b3c087805d63580480890791277ce35c44" @@ -1871,6 +1876,17 @@ isexe@^2.0.0: resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" integrity sha1-6PvzdNxVb/iUehDcsFctYz8s+hA= +issue-parser@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/issue-parser/-/issue-parser-6.0.0.tgz#b1edd06315d4f2044a9755daf85fdafde9b4014a" + integrity sha512-zKa/Dxq2lGsBIXQ7CUZWTHfvxPC2ej0KfO7fIPqLlHB9J2hJ7rGhZ5rilhuufylr4RXYPzJUeFjKxz305OsNlA== + dependencies: + lodash.capitalize "^4.2.1" + lodash.escaperegexp "^4.1.2" + lodash.isplainobject "^4.0.6" + lodash.isstring "^4.0.1" + lodash.uniqby "^4.7.0" + istanbul-lib-coverage@^3.0.0, istanbul-lib-coverage@^3.2.0: version "3.2.0" resolved "https://registry.yarnpkg.com/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.0.tgz#189e7909d0a39fa5a3dfad5b03f71947770191d3" @@ -2443,11 +2459,36 @@ locate-path@^5.0.0: dependencies: p-locate "^4.1.0" +lodash.capitalize@^4.2.1: + version "4.2.1" + resolved "https://registry.yarnpkg.com/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz#f826c9b4e2a8511d84e3aca29db05e1a4f3b72a9" + integrity sha1-+CbJtOKoUR2E46yinbBeGk87cqk= + +lodash.escaperegexp@^4.1.2: + version "4.1.2" + resolved "https://registry.yarnpkg.com/lodash.escaperegexp/-/lodash.escaperegexp-4.1.2.tgz#64762c48618082518ac3df4ccf5d5886dae20347" + integrity sha1-ZHYsSGGAglGKw99Mz11YhtriA0c= + +lodash.isplainobject@^4.0.6: + version "4.0.6" + resolved "https://registry.yarnpkg.com/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz#7c526a52d89b45c45cc690b88163be0497f550cb" + integrity sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs= + +lodash.isstring@^4.0.1: + version "4.0.1" + resolved "https://registry.yarnpkg.com/lodash.isstring/-/lodash.isstring-4.0.1.tgz#d527dfb5456eca7cc9bb95d5daeaf88ba54a5451" + integrity sha1-1SfftUVuynzJu5XV2ur4i6VKVFE= + lodash.memoize@4.x: version "4.1.2" resolved "https://registry.yarnpkg.com/lodash.memoize/-/lodash.memoize-4.1.2.tgz#bcc6c49a42a2840ed997f323eada5ecd182e0bfe" integrity sha1-vMbEmkKihA7Zl/Mj6tpezRguC/4= +lodash.uniqby@^4.7.0: + version "4.7.0" + resolved "https://registry.yarnpkg.com/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz#d99c07a669e9e6d24e1362dfe266c67616af1302" + integrity sha1-2ZwHpmnp5tJOE2Lf4mbGdhavEwI= + lodash@^4.17.14, lodash@^4.17.15, lodash@^4.17.21, lodash@^4.7.0: version "4.17.21" resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"