diff --git a/__snapshots__/github-release.js b/__snapshots__/github-release.js index 6b72d9dd8..764e747c2 100644 --- a/__snapshots__/github-release.js +++ b/__snapshots__/github-release.js @@ -146,3 +146,15 @@ exports['GitHubRelease createRelease creates releases for submodule in monorepo 'autorelease: tagged' ] } + +exports['GitHubRelease createRelease attempts to guess package name for submodule release 1'] = { + 'tag_name': '@google-cloud/foo-v1.0.3', + 'body': '\n* entry', + 'name': '@google-cloud/foo @google-cloud/foo-v1.0.3' +} + +exports['GitHubRelease createRelease attempts to guess package name for submodule release 2'] = { + 'labels': [ + 'autorelease: tagged' + ] +} diff --git a/src/github-release.ts b/src/github-release.ts index 5357c1f6b..b6569eb8d 100644 --- a/src/github-release.ts +++ b/src/github-release.ts @@ -14,6 +14,7 @@ import chalk = require('chalk'); import {checkpoint, CheckpointType} from './util/checkpoint'; +import {packageBranchPrefix} from './util/package-branch-prefix'; import {ReleasePRFactory} from './release-pr-factory'; import {GitHub, OctokitAPIs} from './github'; import {parse} from 'semver'; @@ -78,6 +79,19 @@ export class GitHubRelease { } async createRelease(): Promise { + // Attempt to lookup the package name from a well known location, such + // as package.json, if none is provided: + if (!this.packageName && this.releaseType) { + this.packageName = await ReleasePRFactory.class( + this.releaseType + ).lookupPackageName(this.gh, this.path); + } + if (this.packageName === undefined) { + throw Error( + `could not determine package name for release repo = ${this.repoUrl}` + ); + } + // In most configurations, createRelease() should be called close to when // a release PR is merged, e.g., a GitHub action that kicks off this // workflow on merge. For tis reason, we can pull a fairly small number of PRs: @@ -85,7 +99,9 @@ export class GitHubRelease { const gitHubReleasePR = await this.gh.findMergedReleasePR( this.labels, pageSize, - this.monorepoTags ? this.packageName : undefined + this.monorepoTags + ? packageBranchPrefix(this.packageName, this.releaseType) + : undefined ); if (!gitHubReleasePR) { checkpoint('no recent release PRs found', CheckpointType.Failure); @@ -111,24 +127,11 @@ export class GitHubRelease { `found release notes: \n---\n${chalk.grey(latestReleaseNotes)}\n---\n`, CheckpointType.Success ); - - // Attempt to lookup the package name from a well known location, such - // as package.json, if none is provided: - if (!this.packageName && this.releaseType) { - this.packageName = await ReleasePRFactory.class( - this.releaseType - ).lookupPackageName(this.gh); - } // Go uses '/' for a tag separator, rather than '-': let tagSeparator = '-'; if (this.releaseType) { tagSeparator = ReleasePRFactory.class(this.releaseType).tagSeparator(); } - if (this.packageName === undefined) { - throw Error( - `could not determine package name for release repo = ${this.repoUrl}` - ); - } const release = await this.gh.createRelease( this.packageName, diff --git a/src/github.ts b/src/github.ts index 01837f18d..c23191592 100644 --- a/src/github.ts +++ b/src/github.ts @@ -551,10 +551,12 @@ export class GitHub { async findMergedReleasePR( labels: string[], perPage = 100, - prefix: string | undefined = undefined, + branchPrefix: string | undefined = undefined, preRelease = true ): Promise { - prefix = prefix?.endsWith('-') ? prefix.replace(/-$/, '') : prefix; + branchPrefix = branchPrefix?.endsWith('-') + ? branchPrefix.replace(/-$/, '') + : branchPrefix; const baseLabel = await this.getBaseLabel(); const pullsResponse = (await this.request( `GET /repos/:owner/:repo/pulls?state=closed&per_page=${perPage}${ @@ -594,9 +596,9 @@ export class GitHub { // it's easiest/safest to just pull this out by string search. const version = match[2]; if (!version) continue; - if (prefix && match[1] !== prefix) { + if (branchPrefix && match[1] !== branchPrefix) { continue; - } else if (!prefix && match[1]) { + } else if (!branchPrefix && match[1]) { continue; } diff --git a/src/release-pr.ts b/src/release-pr.ts index 5a1efa46f..236e55448 100644 --- a/src/release-pr.ts +++ b/src/release-pr.ts @@ -23,6 +23,7 @@ type PullsListResponseItems = PromiseValue< import * as semver from 'semver'; import {checkpoint, CheckpointType} from './util/checkpoint'; +import {packageBranchPrefix} from './util/package-branch-prefix'; import {ConventionalCommits} from './conventional-commits'; import {GitHub, GitHubTag, OctokitAPIs} from './github'; import {Commit} from './graphql-to-commits'; @@ -98,6 +99,7 @@ export class ReleasePR { snapshot?: boolean; lastPackageVersion?: string; changelogSections?: []; + releaseType: string; constructor(options: ReleasePROptions) { this.bumpMinorPreMajor = options.bumpMinorPreMajor || false; @@ -123,6 +125,7 @@ export class ReleasePR { this.gh = this.gitHubInstance(options.octokitAPIs); this.changelogSections = options.changelogSections; + this.releaseType = options.releaseType; } async run(): Promise { @@ -186,8 +189,12 @@ export class ReleasePR { // A releaser can implement this method to automatically detect // the release name when creating a GitHub release, for instance by returning // name in package.json, or setup.py. - // eslint-disable-next-line @typescript-eslint/no-unused-vars - static async lookupPackageName(gh: GitHub): Promise { + static async lookupPackageName( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + gh: GitHub, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + path?: string + ): Promise { return Promise.resolve(undefined); } @@ -273,11 +280,10 @@ export class ReleasePR { const updates = options.updates; const version = options.version; const includePackageName = options.includePackageName; - // Do not include npm style @org/ prefixes in the branch name: - const branchPrefix = this.packageName.match(/^@[\w-]+\//) - ? this.packageName.split('/')[1] - : this.packageName; - + const branchPrefix = packageBranchPrefix( + this.packageName, + this.releaseType + ); const title = includePackageName ? `chore: release ${this.packageName} ${version}` : `chore: release ${version}`; @@ -321,13 +327,17 @@ export class ReleasePR { return changelogEntry.split('\n').length === 1; } - addPath(file: string) { - if (this.path === undefined) { + static addPathStatic(file: string, path?: string) { + if (path === undefined) { return file; } else { - const path = this.path.replace(/[/\\]$/, ''); + path = path.replace(/[/\\]$/, ''); file = file.replace(/^[/\\]/, ''); return `${path}/${file}`; } } + + addPath(file: string) { + return ReleasePR.addPathStatic(file, this.path); + } } diff --git a/src/releasers/node.ts b/src/releasers/node.ts index 61c980148..2f2bea46b 100644 --- a/src/releasers/node.ts +++ b/src/releasers/node.ts @@ -17,6 +17,7 @@ import {ReleasePR, ReleaseCandidate} from '../release-pr'; import {ConventionalCommits} from '../conventional-commits'; import {GitHub, GitHubTag, GitHubFileContents} from '../github'; import {checkpoint, CheckpointType} from '../util/checkpoint'; +import {packageBranchPrefix} from '../util/package-branch-prefix'; import {Update} from '../updaters/update'; import {Commit} from '../graphql-to-commits'; @@ -29,8 +30,18 @@ import {SamplesPackageJson} from '../updaters/samples-package-json'; export class Node extends ReleasePR { static releaserName = 'node'; protected async _run(): Promise { + // Make an effort to populate packageName from the contents of + // the package.json, rather than forcing this to be set: + const contents: GitHubFileContents = await this.gh.getFileContents( + this.addPath('package.json') + ); + const pkg = JSON.parse(contents.parsedContent); + if (pkg.name) this.packageName = pkg.name; + const latestTag: GitHubTag | undefined = await this.gh.latestTag( - this.monorepoTags ? `${this.packageName}-` : undefined + this.monorepoTags + ? `${packageBranchPrefix(this.packageName, 'node')}-` + : undefined ); const commits: Commit[] = await this.commits({ sha: latestTag ? latestTag.sha : undefined, @@ -68,14 +79,6 @@ export class Node extends ReleasePR { const updates: Update[] = []; - // Make an effort to populate packageName from the contents of - // the package.json, rather than forcing this to be set: - const contents: GitHubFileContents = await this.gh.getFileContents( - this.addPath('package.json') - ); - const pkg = JSON.parse(contents.parsedContent); - if (pkg.name) this.packageName = pkg.name; - updates.push( new PackageJson({ path: this.addPath('package-lock.json'), @@ -125,11 +128,14 @@ export class Node extends ReleasePR { // A releaser can implement this method to automatically detect // the release name when creating a GitHub release, for instance by returning // name in package.json, or setup.py. - static async lookupPackageName(gh: GitHub): Promise { + static async lookupPackageName( + gh: GitHub, + path?: string + ): Promise { // Make an effort to populate packageName from the contents of // the package.json, rather than forcing this to be set: const contents: GitHubFileContents = await gh.getFileContents( - 'package.json' + this.addPathStatic('package.json', path) ); const pkg = JSON.parse(contents.parsedContent); if (pkg.name) return pkg.name; diff --git a/src/util/package-branch-prefix.ts b/src/util/package-branch-prefix.ts new file mode 100644 index 000000000..2e26c4fd5 --- /dev/null +++ b/src/util/package-branch-prefix.ts @@ -0,0 +1,30 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// map from a packageName to the prefix used in release branch creation. +export function packageBranchPrefix(packageName: string, releaseType?: string) { + let branchPrefix: string; + switch (releaseType) { + case 'node': { + branchPrefix = packageName.match(/^@[\w-]+\//) + ? packageName.split('/')[1] + : packageName; + break; + } + default: { + branchPrefix = packageName; + } + } + return branchPrefix; +} diff --git a/test/github-release.ts b/test/github-release.ts index 1cbca0d71..42bbcce4c 100644 --- a/test/github-release.ts +++ b/test/github-release.ts @@ -16,6 +16,7 @@ import {readFileSync} from 'fs'; import {resolve} from 'path'; import * as snapshot from 'snap-shot-it'; import {describe, it} from 'mocha'; +import {expect} from 'chai'; import * as nock from 'nock'; import {strictEqual} from 'assert'; nock.disableNetConnect(); @@ -218,6 +219,72 @@ describe('GitHubRelease', () => { requests.done(); }); + it('attempts to guess package name for submodule release', async () => { + const release = new GitHubRelease({ + path: 'src/apis/foo', + label: 'autorelease: pending', + repoUrl: 'googleapis/foo', + apiUrl: 'https://api.github.com', + monorepoTags: true, + releaseType: 'node', + }); + const requests = nock('https://api.github.com') + // check for default branch + .get('/repos/googleapis/foo') + .reply(200, repoInfo) + .get( + '/repos/googleapis/foo/pulls?state=closed&per_page=25&sort=merged_at&direction=desc' + ) + .reply(200, [ + { + labels: [{name: 'autorelease: pending'}], + head: { + label: 'head:release-foo-v1.0.3', + }, + base: { + label: 'googleapis:main', + }, + number: 1, + merged_at: new Date().toISOString(), + }, + ]) + .get( + '/repos/googleapis/foo/contents/src%2Fapis%2Ffoo%2Fpackage.json?ref=refs/heads/main' + ) + .reply(200, { + content: Buffer.from('{"name": "@google-cloud/foo"}', 'utf8'), + }) + .get( + '/repos/googleapis/foo/contents/src%2Fapis%2Ffoo%2FCHANGELOG.md?ref=refs/heads/main' + ) + .reply(200, { + content: Buffer.from('#Changelog\n\n## v1.0.3\n\n* entry', 'utf8'), + }) + .post( + '/repos/googleapis/foo/releases', + (body: {[key: string]: string}) => { + snapshot(body); + return true; + } + ) + .reply(200, {tag_name: 'v1.0.3'}) + .post( + '/repos/googleapis/foo/issues/1/labels', + (body: {[key: string]: string}) => { + snapshot(body); + return true; + } + ) + .reply(200) + .delete( + '/repos/googleapis/foo/issues/1/labels/autorelease%3A%20pending' + ) + .reply(200); + const created = await release.createRelease(); + strictEqual(created!.tag_name, 'v1.0.3'); + requests.done(); + }); + it('attempts to guess package name for release', async () => { const release = new GitHubRelease({ label: 'autorelease: pending', @@ -260,7 +327,7 @@ describe('GitHubRelease', () => { return true; } ) - .reply(200, {tag_name: 'v1.0.2'}) + .reply(200, {tag_name: 'v1.0.3'}) .post( '/repos/googleapis/foo/issues/1/labels', (body: {[key: string]: string}) => { @@ -274,7 +341,53 @@ describe('GitHubRelease', () => { ) .reply(200); const created = await release.createRelease(); - strictEqual(created!.tag_name, 'v1.0.2'); + strictEqual(created!.tag_name, 'v1.0.3'); + requests.done(); + }); + + it('errors when no packageName (no lookupPackageName impl: python)', async () => { + const release = new GitHubRelease({ + label: 'autorelease: pending', + repoUrl: 'googleapis/foo', + apiUrl: 'https://api.github.com', + releaseType: 'python', + }); + let failed = true; + try { + await release.createRelease(); + failed = false; + } catch (error) { + expect(error.message).to.equal( + 'could not determine package name for release repo = googleapis/foo' + ); + } + expect(failed).to.be.true; + }); + + it('errors when no packageName (lookupPackageName impl: node)', async () => { + const release = new GitHubRelease({ + label: 'autorelease: pending', + repoUrl: 'googleapis/foo', + apiUrl: 'https://api.github.com', + releaseType: 'node', + }); + const requests = nock('https://api.github.com') + .get('/repos/googleapis/foo') + .reply(200, repoInfo) + .get('/repos/googleapis/foo/contents/package.json?ref=refs/heads/main') + .reply(200, { + content: Buffer.from('{"no-the-name": "@google-cloud/foo"}', 'utf8'), + }); + let failed = true; + try { + await release.createRelease(); + failed = false; + } catch (error) { + expect(error.message).to.equal( + 'could not determine package name for release repo = googleapis/foo' + ); + } + expect(failed).to.be.true; requests.done(); }); }); diff --git a/test/release-pr.ts b/test/release-pr.ts index 6102a2727..75491dcfd 100644 --- a/test/release-pr.ts +++ b/test/release-pr.ts @@ -415,4 +415,12 @@ describe('Release-PR', () => { expect(rp.openPROpts?.branch).to.equal('release-nodejs-v1.3.0'); }); }); + + describe('lookupPackageName', () => { + it('noop, child releasers need to implement', async () => { + const github = new GitHub({owner: 'googleapis', repo: 'node-test-repo'}); + const name = await ReleasePR.lookupPackageName(github); + expect(name).to.be.undefined; + }); + }); }); diff --git a/test/releasers/node.ts b/test/releasers/node.ts index 81a55ac1a..05d2cab46 100644 --- a/test/releasers/node.ts +++ b/test/releasers/node.ts @@ -14,7 +14,9 @@ import * as assert from 'assert'; import {describe, it, afterEach} from 'mocha'; +import {expect} from 'chai'; import * as nock from 'nock'; +import {GitHub} from '../../src/github'; import {Node} from '../../src/releasers/node'; import {readFileSync} from 'fs'; import {resolve} from 'path'; @@ -238,4 +240,53 @@ describe('Node', () => { assert.strictEqual(pr, undefined); }); }); + + describe('lookupPackageName', () => { + it('finds package name in package.json', async () => { + const github = new GitHub({owner: 'googleapis', repo: 'node-test-repo'}); + const packageContent = readFileSync( + resolve(fixturesPath, 'package.json'), + 'utf8' + ); + const req = nock('https://api.github.com') + .get('/repos/googleapis/node-test-repo') + // eslint-disable-next-line @typescript-eslint/no-var-requires + .reply(200, require('../../../test/fixtures/repo-get-1.json')) + .get( + '/repos/googleapis/node-test-repo/contents/package.json?ref=refs/heads/master' + ) + .reply(200, { + content: Buffer.from(packageContent, 'utf8').toString('base64'), + sha: 'abc123', + }); + const expectedPackageName = await Node.lookupPackageName(github); + expect(expectedPackageName).to.equal('node-test-repo'); + req.done(); + }); + + it('finds package name in submodule package.json', async () => { + const github = new GitHub({owner: 'googleapis', repo: 'node-test-repo'}); + const packageContent = readFileSync( + resolve(fixturesPath, 'package.json'), + 'utf8' + ); + const req = nock('https://api.github.com') + .get('/repos/googleapis/node-test-repo') + // eslint-disable-next-line @typescript-eslint/no-var-requires + .reply(200, require('../../../test/fixtures/repo-get-1.json')) + .get( + '/repos/googleapis/node-test-repo/contents/some-path%2Fpackage.json?ref=refs/heads/master' + ) + .reply(200, { + content: Buffer.from(packageContent, 'utf8').toString('base64'), + sha: 'abc123', + }); + const expectedPackageName = await Node.lookupPackageName( + github, + 'some-path' + ); + expect(expectedPackageName).to.equal('node-test-repo'); + req.done(); + }); + }); }); diff --git a/test/util/package-branch-prefix.ts b/test/util/package-branch-prefix.ts new file mode 100644 index 000000000..006c12359 --- /dev/null +++ b/test/util/package-branch-prefix.ts @@ -0,0 +1,43 @@ +// Copyright 2021 Google LLC +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +import {packageBranchPrefix} from '../../src/util/package-branch-prefix'; +import {describe, it} from 'mocha'; +import {expect} from 'chai'; + +describe('packageBranchPrefix', () => { + const inputs = [ + // for 'node' releaseType, npm style package names get '@scope/' removed + {releaseType: 'node', packageName: '@foo/bar', branchPrefix: 'bar'}, + {releaseType: 'node', packageName: '@foo-baz/bar', branchPrefix: 'bar'}, + // currently anything else goes untouched + {releaseType: 'node', packageName: 'foo/bar', branchPrefix: 'foo/bar'}, + {releaseType: 'node', packageName: 'foobar', branchPrefix: 'foobar'}, + {releaseType: 'node', packageName: '', branchPrefix: ''}, + {releaseType: 'python', packageName: 'foo/bar', branchPrefix: 'foo/bar'}, + {releaseType: 'python', packageName: 'foobar', branchPrefix: 'foobar'}, + {releaseType: 'python', packageName: '', branchPrefix: ''}, + {releaseType: undefined, packageName: 'foo/bar', branchPrefix: 'foo/bar'}, + {releaseType: undefined, packageName: 'foobar', branchPrefix: 'foobar'}, + {releaseType: undefined, packageName: '', branchPrefix: ''}, + ]; + inputs.forEach(input => { + const {releaseType, packageName, branchPrefix} = input; + it(`maps packageName(${packageName}) to branchPrefix(${branchPrefix}) for releaseType(${releaseType})`, async () => { + expect(packageBranchPrefix(packageName, releaseType)).to.equal( + branchPrefix + ); + }); + }); +});