diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 860c78f33..a4b987b23 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,14 +14,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [18, 20] + node: [20] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: node-version: ${{ matrix.node }} - run: node --version - - run: npm install + - run: npm ci - run: npm test env: MOCHA_THROW_DEPRECATION: false @@ -29,7 +29,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node: [18, 20] + node: [20] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 @@ -46,8 +46,8 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: - node-version: 18 - - run: npm install + node-version: 20 + - run: npm ci - run: npm test env: MOCHA_THROW_DEPRECATION: false @@ -57,8 +57,8 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: - node-version: 18 - - run: npm install + node-version: 20 + - run: npm ci - run: npm run lint docs: runs-on: ubuntu-latest @@ -66,8 +66,8 @@ jobs: - uses: actions/checkout@v4 - uses: actions/setup-node@v3 with: - node-version: 18 - - run: npm install + node-version: 20 + - run: npm ci - run: npm run docs - uses: JustinBeckwith/linkinator-action@v1 with: @@ -81,6 +81,6 @@ jobs: ref: main - uses: actions/setup-node@v3 with: - node-version: 18 + node-version: 20 - run: npm install --save googleapis/release-please#${{ github.ref }} - run: npm run build diff --git a/.kokoro/release/publish.cfg b/.kokoro/release/publish.cfg index fa524b4d3..a49549d79 100644 --- a/.kokoro/release/publish.cfg +++ b/.kokoro/release/publish.cfg @@ -30,7 +30,7 @@ build_file: "release-please/.kokoro/trampoline_v2.sh" # Configure the docker image for kokoro-trampoline. env_vars: { key: "TRAMPOLINE_IMAGE" - value: "gcr.io/cloud-devrel-kokoro-resources/node:18-user" + value: "gcr.io/cloud-devrel-kokoro-resources/node:22-user" } env_vars: { diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 89b579972..35d87d129 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "17.3.0" + ".": "17.6.0" } \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 30f4f3fc4..9ec6ce78a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ [1]: https://www.npmjs.com/package/release-please?activeTab=versions +## [17.6.0](https://github.com/googleapis/release-please/compare/v17.5.2...v17.6.0) (2026-04-13) + + +### Features + +* **yoshi-java-monorepo:** update library version in librarian.yaml ([#2750](https://github.com/googleapis/release-please/issues/2750)) ([8cd3491](https://github.com/googleapis/release-please/commit/8cd3491874fcb44bcaad4ba4999402deed181d24)) + + +### Bug Fixes + +* use GitHub API for updating files ([#2751](https://github.com/googleapis/release-please/issues/2751)) ([e53fa6d](https://github.com/googleapis/release-please/commit/e53fa6d9b6c0ab4896168b95ea3eab56221ccf55)) + +## [17.5.2](https://github.com/googleapis/release-please/compare/v17.5.1...v17.5.2) (2026-04-10) + + +### Bug Fixes + +* limit git fetch to cloneDepth config ([#2744](https://github.com/googleapis/release-please/issues/2744)) ([90c4293](https://github.com/googleapis/release-please/commit/90c4293b7aa567bef0d5679d330a83dda25548c3)) + +## [17.5.1](https://github.com/googleapis/release-please/compare/v17.5.0...v17.5.1) (2026-04-09) + + +### Bug Fixes + +* adding no-verify option in Git operations ([#2742](https://github.com/googleapis/release-please/issues/2742)) ([51d0c84](https://github.com/googleapis/release-please/commit/51d0c84a71803f17f3a50800f43a1c097ea4b122)) + +## [17.5.0](https://github.com/googleapis/release-please/compare/v17.4.1...v17.5.0) (2026-04-09) + + +### Features + +* add include-commit-authors option to include author info in changelogs ([#2628](https://github.com/googleapis/release-please/issues/2628)) ([77b12ad](https://github.com/googleapis/release-please/commit/77b12adf477fc4de4dea6fead96bc58a9f241be9)), closes [#1716](https://github.com/googleapis/release-please/issues/1716) +* create Scm abstraction ([#2729](https://github.com/googleapis/release-please/issues/2729)) ([8c5e2ae](https://github.com/googleapis/release-please/commit/8c5e2ae31e8f4eda506fd372eba3c342e7a9ef22)) + +## [17.4.1](https://github.com/googleapis/release-please/compare/v17.4.0...v17.4.1) (2026-04-08) + + +### Bug Fixes + +* do not attempt to create pull request when no changes detected ([#2722](https://github.com/googleapis/release-please/issues/2722)) ([9ecabc4](https://github.com/googleapis/release-please/commit/9ecabc40a724e1ba64158844fc9b27ee5d6ed0a2)) + +## [17.4.0](https://github.com/googleapis/release-please/compare/v17.3.0...v17.4.0) (2026-04-06) + + +### Features + +* **java-yoshi-mono-repo:** look for Version.java files ([#2730](https://github.com/googleapis/release-please/issues/2730)) ([5126fee](https://github.com/googleapis/release-please/commit/5126feefac4cee1fb02142e2f515fda4d9f345d4)) +* resolve Dependabot security alerts ([#2709](https://github.com/googleapis/release-please/issues/2709)) ([7f2e4ec](https://github.com/googleapis/release-please/commit/7f2e4ec0fee60fb14f279218102ea8b07032b956)) + ## [17.3.0](https://github.com/googleapis/release-please/compare/v17.2.1...v17.3.0) (2026-02-18) diff --git a/__snapshots__/cli.js b/__snapshots__/cli.js index 426623100..0936e541f 100644 --- a/__snapshots__/cli.js +++ b/__snapshots__/cli.js @@ -25,6 +25,12 @@ Options: --repo-url GitHub URL to generate release for [required] --dry-run Prepare but do not take action [boolean] [default: false] + --local Whether to use local clone + [boolean] [default: false] + --local-path Path to local clone directory. If not set, uses + a temporary directory. [string] + --local-clone-depth Depth of local clone. Defaults to the entire + repo. [number] --include-v-in-tags include "v" in tag versions [boolean] [default: true] --monorepo-tags include library name in tags and release @@ -97,6 +103,11 @@ Options: on [string] --repo-url GitHub URL to generate release for [required] --dry-run Prepare but do not take action[boolean] [default: false] + --local Whether to use local clone [boolean] [default: false] + --local-path Path to local clone directory. If not set, uses a + temporary directory. [string] + --local-clone-depth Depth of local clone. Defaults to the entire repo. + [number] --label comma-separated list of labels to add to from release PR [default: "autorelease: pending"] --skip-labeling skip application of labels to pull requests @@ -138,6 +149,11 @@ Options: on [string] --repo-url GitHub URL to generate release for [required] --dry-run Prepare but do not take action[boolean] [default: false] + --local Whether to use local clone [boolean] [default: false] + --local-path Path to local clone directory. If not set, uses a + temporary directory. [string] + --local-clone-depth Depth of local clone. Defaults to the entire repo. + [number] --draft mark release as a draft. no tag is created but tag_name and target_commitish are associated with the release for future tag creation upon "un-drafting" the release. @@ -187,6 +203,12 @@ Options: --repo-url GitHub URL to generate release for[required] --dry-run Prepare but do not take action [boolean] [default: false] + --local Whether to use local clone + [boolean] [default: false] + --local-path Path to local clone directory. If not set, + uses a temporary directory. [string] + --local-clone-depth Depth of local clone. Defaults to the entire + repo. [number] --release-as override the semantically determined release version [string] --bump-minor-pre-major should we bump the semver minor prior to the diff --git a/__snapshots__/default-changelog-notes.js b/__snapshots__/default-changelog-notes.js index 0ebca5644..dea5dd0d6 100644 --- a/__snapshots__/default-changelog-notes.js +++ b/__snapshots__/default-changelog-notes.js @@ -218,6 +218,33 @@ exports['DefaultChangelogNotes buildNotes with commit parsing should handle mult * upgrade to Node 7 ([8916be7](https://github.com/googleapis/java-asset/commit/8916be74596394c27516696b957fd0d7)) ` +exports['DefaultChangelogNotes buildNotes with commit parsing should include commit authors when enabled with username 1'] = ` +## [1.2.3](https://github.com/googleapis/java-asset/compare/v1.2.2...v1.2.3) (1983-10-10) + + +### Features + +* some feature ([@testuser](https://github.com/testuser)) ([sha1](https://github.com/googleapis/java-asset/commit/sha1)) +` + +exports['DefaultChangelogNotes buildNotes with commit parsing should include commit authors when enabled without username 1'] = ` +## [1.2.3](https://github.com/googleapis/java-asset/compare/v1.2.2...v1.2.3) (1983-10-10) + + +### Features + +* some feature (Test User) ([sha1](https://github.com/googleapis/java-asset/commit/sha1)) +` + +exports['DefaultChangelogNotes buildNotes with commit parsing should not include commit authors when disabled 1'] = ` +## [1.2.3](https://github.com/googleapis/java-asset/compare/v1.2.2...v1.2.3) (1983-10-10) + + +### Features + +* some feature ([sha1](https://github.com/googleapis/java-asset/commit/sha1)) +` + exports['DefaultChangelogNotes buildNotes with commit parsing should not include content two newlines after BREAKING CHANGE 1'] = ` ## [1.2.3](https://github.com/googleapis/java-asset/compare/v1.2.2...v1.2.3) (1983-10-10) diff --git a/docs/cli.md b/docs/cli.md index be1993ff4..f714d6d22 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -62,6 +62,7 @@ Extra options: | `--changelog-type` | [`ChangelogType`](/docs/customizing.md#changelog-types) | Strategy for building the changelog contents. Defaults to `default` | | `--changelog-sections` | `string` | Comma-separated list of commit scopes to show in changelog headings | | `--changelog-host` | `string` | Host for commit hyperlinks in the changelog. Defaults to `https://github.com` | +| `--include-commit-authors` | `boolean` | Include commit authors in changelog entries (e.g., `(@username)`). Defaults to `false` | | `--pull-request-title-pattern` | `string` | Override the pull request title pattern. Defaults to `chore${scope}: release${component} ${version}` | | `--pull-request-header` | `string` | Override the pull request header. Defaults to `:robot: I have created a release *beep* *boop*` | | `--pull-request-footer` | `string` | Override the pull request footer. Defaults to `This PR was generated with Release Please. See documentation.` | @@ -112,6 +113,7 @@ need to specify your release options: | `--changelog-type` | [`ChangelogType`](/docs/customizing.md#changelog-types) | Strategy for building the changelog contents. Defaults to `default` | | `--changelog-sections` | `string` | Comma-separated list of commit scopes to show in changelog headings | | `--changelog-host` | `string` | Host for commit hyperlinks in the changelog. Defaults to `https://github.com` | +| `--include-commit-authors` | `boolean` | Include commit authors in changelog entries (e.g., `(@username)`). Defaults to `false` | | `--monorepo-tags` | boolean | Add prefix to tags and branches, allowing multiple libraries to be released from the same repository | | `--pull-request-title-pattern` | `string` | Override the pull request title pattern. Defaults to `chore${scope}: release${component} ${version}` | | `--pull-request-header` | `string` | Override the pull request header. Defaults to `:robot: I have created a release *beep* *boop*` | diff --git a/docs/manifest-releaser.md b/docs/manifest-releaser.md index 630395848..62221a474 100644 --- a/docs/manifest-releaser.md +++ b/docs/manifest-releaser.md @@ -189,6 +189,11 @@ defaults (those are documented in comments) // absence defaults to https://github.com "changelog-host": "https://example.com", + // include commit authors in changelog entries + // when true, appends (@username) or author name to each entry + // absence defaults to false + "include-commit-authors": true, + // when `manifest-release` creates GitHub Releases per package, create // those as "Draft" releases (which can later be manually published). // absence defaults to false and Releases are created as already Published. diff --git a/package-lock.json b/package-lock.json index 9d640329c..889abc819 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "release-please", - "version": "17.3.0", + "version": "17.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "release-please", - "version": "17.3.0", + "version": "17.6.0", "license": "Apache-2.0", "dependencies": { "@conventional-commits/parser": "^0.4.1", @@ -70,10 +70,10 @@ "nock": "^13.0.0", "node-fetch": "^2.6.0", "sinon": "18.0.1", - "snap-shot-it": "^7.0.0" + "snap-shot-it": "^7.9.10" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" } }, "node_modules/@babel/code-frame": { @@ -210,10 +210,11 @@ } }, "node_modules/@eslint/eslintrc/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -235,9 +236,9 @@ } }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -288,10 +289,11 @@ "dev": true }, "node_modules/@eslint/eslintrc/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -343,9 +345,9 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -354,10 +356,11 @@ } }, "node_modules/@humanwhocodes/config-array/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -1198,9 +1201,10 @@ } }, "node_modules/@xmldom/xmldom": { - "version": "0.8.6", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.6.tgz", - "integrity": "sha512-uRjjusqpoqfmRkTaNuLJ2VohVr67Q5YwDATW3VU7PfzTj6IRaihGrYI7zckGZjxQPBIp63nfvJbM+Yu5ICh0Bg==", + "version": "0.8.12", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.12.tgz", + "integrity": "sha512-9k/gHF6n/pAi/9tqr3m3aqkuiNosYTurLLUtc7xQ9sxB/wm7WPygCv8GYa6mS0fLJEHhqMC1ATYhz++U/lRHqg==", + "license": "MIT", "engines": { "node": ">=10.0.0" } @@ -1238,15 +1242,16 @@ } }, "node_modules/ajv": { - "version": "8.12.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.12.0.tgz", - "integrity": "sha512-sRu1kpcO9yLtYxBKvqfTeh9KzZEwO3STyX1HT+4CaDzC6HpTGYhIhPIzj9XuKU7KYDwnaeh5hcOwjy1QuJzBPA==", + "version": "8.18.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz", + "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, + "license": "MIT", "dependencies": { - "fast-deep-equal": "^3.1.1", + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", - "require-from-string": "^2.0.2", - "uri-js": "^4.2.2" + "require-from-string": "^2.0.2" }, "funding": { "type": "github", @@ -1426,9 +1431,9 @@ "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, "node_modules/brace-expansion": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", - "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.3.tgz", + "integrity": "sha512-MCV/fYJEbqx68aE58kv2cA/kiky1G8vux3OR6/jbS+jIMe/6fJWa0DTzJU7dqijOWYwHi1t29FlfYI9uytqlpA==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0" @@ -1873,11 +1878,12 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", "dependencies": { - "ms": "2.1.2" + "ms": "^2.1.3" }, "engines": { "node": ">=6.0" @@ -2271,9 +2277,9 @@ } }, "node_modules/eslint-plugin-node/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2306,10 +2312,11 @@ } }, "node_modules/eslint-plugin-node/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2388,10 +2395,11 @@ } }, "node_modules/eslint/node_modules/ajv": { - "version": "6.12.6", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", - "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", + "integrity": "sha512-IWrosm/yrn43eiKqkfkHis7QioDleaXQHdDVPKg0FSwwd/DuvyX79TZnFOnYpB7dcsFAMmtFztZuXPDvSePkFw==", "dev": true, + "license": "MIT", "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", @@ -2413,9 +2421,9 @@ } }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "dev": true, "license": "MIT", "dependencies": { @@ -2502,10 +2510,11 @@ "dev": true }, "node_modules/eslint/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", "dev": true, + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -2673,6 +2682,23 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.0.tgz", + "integrity": "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fastq": { "version": "1.15.0", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.15.0.tgz", @@ -2759,10 +2785,11 @@ } }, "node_modules/flatted": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.7.tgz", - "integrity": "sha512-5nqDSxl8nn5BSNxyR3n4I6eDmbolI6WT+QqR547RwxQapgjQBmtktdP+HTBb/a/zLsbzERTONyUB5pefh5TtjQ==", - "dev": true + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" }, "node_modules/folktale": { "version": "2.3.2", @@ -2889,9 +2916,9 @@ } }, "node_modules/glob/node_modules/brace-expansion": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", - "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.13.tgz", + "integrity": "sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==", "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -2899,9 +2926,10 @@ } }, "node_modules/glob/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" }, @@ -3027,12 +3055,13 @@ } }, "node_modules/handlebars": { - "version": "4.7.7", - "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.7.tgz", - "integrity": "sha512-aAcXm5OAfE/8IXkcZvCepKU3VzW1/39Fb5ZuqMtgI/hT8X2YgoMvBY5dLhq/cpOvw7Lk1nK/UF71aLG/ZnVYRA==", + "version": "4.7.9", + "resolved": "https://registry.npmjs.org/handlebars/-/handlebars-4.7.9.tgz", + "integrity": "sha512-4E71E0rpOaQuJR2A3xDZ+GM1HyWYv1clR58tC8emQNeQe3RH7MAzSbat+V0wG78LQBo6m6bzSG/L4pBuCsgnUQ==", + "license": "MIT", "dependencies": { "minimist": "^1.2.5", - "neo-async": "^2.6.0", + "neo-async": "^2.6.2", "source-map": "^0.6.1", "wordwrap": "^1.0.0" }, @@ -3598,9 +3627,9 @@ } }, "node_modules/lodash": { - "version": "4.17.23", - "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz", - "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==", + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "dev": true, "license": "MIT" }, @@ -3770,9 +3799,10 @@ } }, "node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "version": "5.1.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz", + "integrity": "sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw==", + "license": "ISC", "dependencies": { "brace-expansion": "^2.0.1" }, @@ -3869,24 +3899,6 @@ "wrap-ansi": "^7.0.0" } }, - "node_modules/mocha/node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, "node_modules/mocha/node_modules/diff": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/diff/-/diff-5.2.2.tgz", @@ -3930,12 +3942,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/mocha/node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true - }, "node_modules/mocha/node_modules/supports-color": { "version": "8.1.1", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", @@ -3978,9 +3984,10 @@ } }, "node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" }, "node_modules/mute-stream": { "version": "0.0.8", @@ -4333,10 +4340,11 @@ } }, "node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", "dev": true, + "license": "MIT", "engines": { "node": ">=8.6" }, @@ -4466,16 +4474,6 @@ "url": "https://opencollective.com/ramda" } }, - "node_modules/randombytes": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", - "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "safe-buffer": "^5.1.0" - } - }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -4762,27 +4760,6 @@ "npm": ">=2.0.0" } }, - "node_modules/safe-buffer": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", - "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/feross" - }, - { - "type": "patreon", - "url": "https://www.patreon.com/feross" - }, - { - "type": "consulting", - "url": "https://feross.org/support" - } - ], - "license": "MIT" - }, "node_modules/safer-buffer": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", @@ -4804,13 +4781,13 @@ } }, "node_modules/serialize-javascript": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", - "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-7.0.5.tgz", + "integrity": "sha512-F4LcB0UqUl1zErq+1nYEEzSHJnIwb3AF2XWB94b+afhrekOUijwooAYqFyRbjYkm2PAKBabx6oYv/xDxNi8IBw==", "dev": true, "license": "BSD-3-Clause", - "dependencies": { - "randombytes": "^2.1.0" + "engines": { + "node": ">=20.0.0" } }, "node_modules/shebang-command": { @@ -4936,6 +4913,7 @@ "integrity": "sha512-pYAIzeRo8J6KPEaJ0VWOh5Pzkbw/RetuzehGM7QRRX5he4fPHx2rdKMB256ehJCkX+XRQm16eZLqLNS8RSZXZw==", "deprecated": "Debug versions >=3.2.0 <3.2.7 || >=4 <4.3.1 have a low-severity ReDos regression when used in a Node.js environment. It is recommended you upgrade to 3.2.7 or 4.3.1. (https://github.com/visionmedia/debug/issues/797)", "dev": true, + "license": "MIT", "dependencies": { "ms": "^2.1.1" } @@ -4993,6 +4971,7 @@ "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.1.tgz", "integrity": "sha512-doEwdvm4PCeK4K3RQN2ZC2BYUBaxwLARCqZmMjtF8a51J2Rb0xpVloFRnCODwqjpwnAoao4pelN8l3RJdv3gRQ==", "dev": true, + "license": "MIT", "dependencies": { "ms": "2.1.2" }, @@ -5005,6 +4984,13 @@ } } }, + "node_modules/snap-shot-core/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true, + "license": "MIT" + }, "node_modules/snap-shot-core/node_modules/ramda": { "version": "0.27.1", "resolved": "https://registry.npmjs.org/ramda/-/ramda-0.27.1.tgz", @@ -5033,6 +5019,31 @@ "node": ">=6" } }, + "node_modules/snap-shot-it/node_modules/debug": { + "version": "4.3.4", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", + "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "2.1.2" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/snap-shot-it/node_modules/ms": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", + "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==", + "dev": true, + "license": "MIT" + }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -5244,12 +5255,13 @@ } }, "node_modules/test-exclude/node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", + "version": "9.0.9", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.9.tgz", + "integrity": "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==", "dev": true, + "license": "ISC", "dependencies": { - "brace-expansion": "^2.0.1" + "brace-expansion": "^2.0.2" }, "engines": { "node": ">=16 || 14 >=14.17" @@ -5660,11 +5672,18 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yaml": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.3.1.tgz", - "integrity": "sha512-2eHWfjaoXgTBC2jNM1LRef62VQa0umtvRiDSk6HSzW7RvS5YtkabJrwYLLEKWBc8a5U2PTSCs+dJjUTJdlHsWQ==", + "version": "2.8.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz", + "integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, "engines": { - "node": ">= 14" + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" } }, "node_modules/yargs": { diff --git a/package.json b/package.json index 4ca443faa..661e8e4ec 100644 --- a/package.json +++ b/package.json @@ -1,11 +1,11 @@ { "name": "release-please", - "version": "17.3.0", + "version": "17.6.0", "description": "generate release PRs based on the conventionalcommits.org spec", "main": "./build/src/index.js", "bin": "./build/src/bin/release-please.js", "scripts": { - "test": "cross-env ENVIRONMENT=test LC_ALL=en c8 mocha --node-option no-experimental-fetch --recursive --timeout=5000 build/test", + "test": "cross-env ENVIRONMENT=test LC_ALL=en c8 mocha --recursive --timeout=5000 build/test", "docs": "echo add docs tests", "test:snap": "cross-env SNAPSHOT_UPDATE=1 LC_ALL=en npm test", "clean": "gts clean", @@ -63,7 +63,7 @@ "nock": "^13.0.0", "node-fetch": "^2.6.0", "sinon": "18.0.1", - "snap-shot-it": "^7.0.0" + "snap-shot-it": "^7.9.10" }, "dependencies": { "@conventional-commits/parser": "^0.4.1", @@ -99,9 +99,10 @@ "yargs": "^17.0.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=20.0.0" }, "overrides": { - "tmp": "0.2.5" + "tmp": "0.2.5", + "serialize-javascript": "^7.0.5" } } diff --git a/src/bin/release-please.ts b/src/bin/release-please.ts index e07069379..d1eaf4d70 100644 --- a/src/bin/release-please.ts +++ b/src/bin/release-please.ts @@ -16,7 +16,8 @@ import {coerceOption} from '../util/coerce-option'; import * as yargs from 'yargs'; -import {GitHub, GH_API_URL, GH_GRAPHQL_URL} from '../github'; +import {GitHub} from '../github'; +import {GH_API_URL, GH_GRAPHQL_URL} from '../github-api'; import {Manifest, ManifestOptions, ROOT_PROJECT_PATH} from '../manifest'; import {ChangelogSection, buildChangelogSections} from '../changelog-notes'; import {logger, setLogger, CheckpointLogger} from '../util/logger'; @@ -30,6 +31,8 @@ import { } from '../factory'; import {Bootstrapper} from '../bootstrapper'; import {createPatch} from 'diff'; +import {Scm} from '../scm'; +import {LocalGitHub} from '../local-github'; // eslint-disable-next-line @typescript-eslint/no-var-requires const parseGithubRepoUrl = require('parse-github-repo-url'); @@ -49,6 +52,9 @@ interface GitHubArgs { apiUrl?: string; graphqlUrl?: string; fork?: boolean; + local?: boolean; + localPath?: string; + localCloneDepth?: number; // deprecated in favor of targetBranch defaultBranch?: string; @@ -187,6 +193,20 @@ function gitHubOptions(yargs: yargs.Argv): yargs.Argv { type: 'boolean', default: false, }) + .option('local', { + describe: 'Whether to use local clone', + type: 'boolean', + default: false, + }) + .option('local-path', { + describe: + 'Path to local clone directory. If not set, uses a temporary directory.', + type: 'string', + }) + .option('local-clone-depth', { + describe: 'Depth of local clone. Defaults to the entire repo.', + type: 'number', + }) .middleware(_argv => { const argv = _argv as GitHubArgs; // allow secrets to be loaded from file path @@ -817,16 +837,29 @@ const debugConfigCommand: yargs.CommandModule<{}, DebugConfigArgs> = { }, }; -async function buildGitHub(argv: GitHubArgs): Promise { +async function buildGitHub(argv: GitHubArgs): Promise { const [owner, repo] = parseGithubRepoUrl(argv.repoUrl); - const github = await GitHub.create({ - owner, - repo, - token: argv.token!, - apiUrl: argv.apiUrl, - graphqlUrl: argv.graphqlUrl, - }); - return github; + if (argv.local) { + const localGitHub = await LocalGitHub.create({ + owner, + repo, + token: argv.token!, + apiUrl: argv.apiUrl, + graphqlUrl: argv.graphqlUrl, + localRepoPath: argv.localPath, + cloneDepth: argv.localCloneDepth, + }); + return localGitHub; + } else { + const github = await GitHub.create({ + owner, + repo, + token: argv.token!, + apiUrl: argv.apiUrl, + graphqlUrl: argv.graphqlUrl, + }); + return github; + } } export const parser = yargs diff --git a/src/bootstrapper.ts b/src/bootstrapper.ts index 6c31a33ad..ccf38b151 100644 --- a/src/bootstrapper.ts +++ b/src/bootstrapper.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GitHub} from './github'; +import {Scm} from './scm'; import { DEFAULT_RELEASE_PLEASE_MANIFEST, DEFAULT_RELEASE_PLEASE_CONFIG, @@ -30,13 +30,13 @@ interface BootstrapPullRequest extends PullRequest { } export class Bootstrapper { - private github: GitHub; + private github: Scm; private targetBranch: string; private manifestFile: string; private configFile: string; private initialVersion: Version; constructor( - github: GitHub, + github: Scm, targetBranch: string, manifestFile: string = DEFAULT_RELEASE_PLEASE_MANIFEST, configFile: string = DEFAULT_RELEASE_PLEASE_CONFIG, diff --git a/src/changelog-notes.ts b/src/changelog-notes.ts index 5d688ad76..7fefe85ed 100644 --- a/src/changelog-notes.ts +++ b/src/changelog-notes.ts @@ -24,6 +24,7 @@ export interface BuildNotesOptions { targetBranch: string; changelogSections?: ChangelogSection[]; commits?: Commit[]; + includeCommitAuthors?: boolean; } export interface ChangelogNotes { diff --git a/src/changelog-notes/default.ts b/src/changelog-notes/default.ts index e3e47f6b6..28789004d 100644 --- a/src/changelog-notes/default.ts +++ b/src/changelog-notes/default.ts @@ -84,9 +84,17 @@ export class DefaultChangelogNotes implements ChangelogNotes { context.repository ) ); + let subject = htmlEscape(commit.bareMessage); + // Append author info if enabled and author is available + if (options.includeCommitAuthors && commit.author) { + const authorDisplay = commit.author.username + ? `@${commit.author.username}` + : commit.author.name; + subject = `${subject} (${authorDisplay})`; + } return { body: '', // commit.body, - subject: htmlEscape(commit.bareMessage), + subject, type: commit.type, scope: commit.scope, notes, diff --git a/src/changelog-notes/github.ts b/src/changelog-notes/github.ts index a79ddeeab..2b0a87edc 100644 --- a/src/changelog-notes/github.ts +++ b/src/changelog-notes/github.ts @@ -14,11 +14,11 @@ import {ChangelogNotes, BuildNotesOptions} from '../changelog-notes'; import {ConventionalCommit} from '../commit'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; export class GitHubChangelogNotes implements ChangelogNotes { - private github: GitHub; - constructor(github: GitHub) { + private github: Scm; + constructor(github: Scm) { this.github = github; } async buildNotes( diff --git a/src/commit.ts b/src/commit.ts index 904b24c0f..bd0d5a800 100644 --- a/src/commit.ts +++ b/src/commit.ts @@ -25,11 +25,18 @@ import * as parser from '@conventional-commits/parser'; // eslint-disable-next-line @typescript-eslint/no-var-requires const conventionalCommitsFilter = require('conventional-commits-filter'); +export interface CommitAuthor { + name: string; + email?: string; + username?: string; +} + export interface Commit { sha: string; message: string; files?: string[]; pullRequest?: PullRequest; + author?: CommitAuthor; } export interface ConventionalCommit extends Commit { diff --git a/src/factories/changelog-notes-factory.ts b/src/factories/changelog-notes-factory.ts index 9113137b3..75942a53f 100644 --- a/src/factories/changelog-notes-factory.ts +++ b/src/factories/changelog-notes-factory.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {ChangelogNotes, ChangelogSection} from '../changelog-notes'; import {GitHubChangelogNotes} from '../changelog-notes/github'; import {DefaultChangelogNotes} from '../changelog-notes/default'; @@ -22,7 +22,7 @@ export type ChangelogNotesType = string; export interface ChangelogNotesFactoryOptions { type: ChangelogNotesType; - github: GitHub; + github: Scm; changelogSections?: ChangelogSection[]; commitPartial?: string; headerPartial?: string; diff --git a/src/factories/plugin-factory.ts b/src/factories/plugin-factory.ts index 5f2756e51..fc7754912 100644 --- a/src/factories/plugin-factory.ts +++ b/src/factories/plugin-factory.ts @@ -19,7 +19,7 @@ import { SentenceCasePluginConfig, GroupPriorityPluginConfig, } from '../manifest'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {ManifestPlugin} from '../plugin'; import {LinkedVersions} from '../plugins/linked-versions'; import {CargoWorkspace} from '../plugins/cargo-workspace'; @@ -34,7 +34,7 @@ import {WorkspacePluginOptions} from '../plugins/workspace'; export interface PluginFactoryOptions { type: PluginType; - github: GitHub; + github: Scm; targetBranch: string; repositoryConfig: RepositoryConfig; manifestPath: string; diff --git a/src/factories/versioning-strategy-factory.ts b/src/factories/versioning-strategy-factory.ts index ba69b0714..c2484813b 100644 --- a/src/factories/versioning-strategy-factory.ts +++ b/src/factories/versioning-strategy-factory.ts @@ -18,7 +18,7 @@ import {AlwaysBumpPatch} from '../versioning-strategies/always-bump-patch'; import {AlwaysBumpMinor} from '../versioning-strategies/always-bump-minor'; import {AlwaysBumpMajor} from '../versioning-strategies/always-bump-major'; import {ServicePackVersioningStrategy} from '../versioning-strategies/service-pack'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {ConfigurationError} from '../errors'; import {PrereleaseVersioningStrategy} from '../versioning-strategies/prerelease'; @@ -30,7 +30,7 @@ export interface VersioningStrategyFactoryOptions { bumpPatchForMinorPreMajor?: boolean; prereleaseType?: string; prerelease?: boolean; - github: GitHub; + github: Scm; } export type VersioningStrategyBuilder = ( diff --git a/src/factory.ts b/src/factory.ts index 025cf1e48..44f828505 100644 --- a/src/factory.ts +++ b/src/factory.ts @@ -15,7 +15,7 @@ import {ConfigurationError} from './errors'; import {buildChangelogNotes} from './factories/changelog-notes-factory'; import {buildVersioningStrategy} from './factories/versioning-strategy-factory'; -import {GitHub} from './github'; +import {Scm} from './scm'; import {ReleaserConfig} from './manifest'; import {BaseStrategyOptions} from './strategies/base'; import {Bazel} from './strategies/bazel'; @@ -61,7 +61,7 @@ export type ReleaseType = string; export type ReleaseBuilder = (options: BaseStrategyOptions) => Strategy; export interface StrategyFactoryOptions extends ReleaserConfig { - github: GitHub; + github: Scm; path?: string; targetBranch?: string; } diff --git a/src/github-api.ts b/src/github-api.ts new file mode 100644 index 000000000..8c72c1032 --- /dev/null +++ b/src/github-api.ts @@ -0,0 +1,977 @@ +// Copyright 2026 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 {Octokit} from '@octokit/rest'; +import {request} from '@octokit/request'; +import {RequestError} from '@octokit/request-error'; +import {Logger} from 'code-suggester/build/src/types'; + +import {PullRequest} from './pull-request'; +import {Repository} from './repository'; +import {Release} from './release'; +import { + ScmRelease, + ScmReleaseIteratorOptions, + ScmReleaseOptions, + ScmCommitIteratorOptions, +} from './scm'; +import { + GitHubAPIError, + DuplicateReleaseError, + ConfigurationError, +} from './errors'; +import {logger as defaultLogger} from './util/logger'; + +import {graphql} from '@octokit/graphql'; +import {HttpsProxyAgent} from 'https-proxy-agent'; +import {HttpProxyAgent} from 'http-proxy-agent'; + +export const GH_API_URL = 'https://api.github.com'; +export const GH_GRAPHQL_URL = 'https://api.github.com'; + +export type OctokitType = InstanceType; + +// Extract some types from the `request` package. +type RequestBuilderType = typeof request; +type DefaultFunctionType = RequestBuilderType['defaults']; +type RequestFunctionType = ReturnType; + +export interface OctokitAPIs { + graphql: Function; + request: RequestFunctionType; + octokit: OctokitType; +} + +export interface ProxyOption { + host: string; + port: number; +} + +export interface GitHubCreateOptions { + owner: string; + repo: string; + defaultBranch?: string; + apiUrl?: string; + graphqlUrl?: string; + octokitAPIs?: OctokitAPIs; + token?: string; + logger?: Logger; + proxy?: ProxyOption; + fetch?: any; +} + +export interface GitHubApiOptions { + repository: Repository; + octokitAPIs: OctokitAPIs; + logger?: Logger; +} + +export interface GraphQLCommit { + sha: string; + message: string; + associatedPullRequests: { + nodes: GraphQLPullRequest[]; + }; +} + +export interface GraphQLPullRequest { + number: number; + title: string; + body: string; + baseRefName: string; + headRefName: string; + labels: { + nodes: { + name: string; + }[]; + }; + mergeCommit?: { + oid: string; + }; + files: { + nodes: { + path: string; + }[]; + pageInfo: { + hasNextPage: boolean; + }; + }; +} + +export interface PullRequestHistory { + pageInfo: { + hasNextPage: boolean; + endCursor: string | undefined; + }; + data: PullRequest[]; +} + +export interface CommitHistory { + pageInfo: { + hasNextPage: boolean; + endCursor: string | undefined; + }; + data: ScmRelease[]; // Wait, ScmRelease? Let's check CommitHistory in github.ts +} + +export type CommitIteratorOptions = ScmCommitIteratorOptions; +export interface GraphQLRelease { + name: string; + tag: { + name: string; + }; + tagCommit: { + oid: string; + }; + url: string; + description: string; + isDraft: boolean; +} + +export interface ReleaseHistory { + pageInfo: { + hasNextPage: boolean; + endCursor: string | undefined; + }; + data: ScmRelease[]; +} + +export type ReleaseIteratorOptions = ScmReleaseIteratorOptions; + +export const MAX_SLEEP_SECONDS = 20; +export const MAX_ISSUE_BODY_SIZE = 65536; + +export class GitHubApi { + readonly repository: Repository; + readonly octokitAPIs: OctokitAPIs; + octokit: OctokitType; + private graphql: Function; + private logger: Logger; + + constructor(options: GitHubApiOptions) { + this.repository = options.repository; + this.octokitAPIs = options.octokitAPIs; + this.octokit = options.octokitAPIs.octokit; + this.graphql = options.octokitAPIs.graphql; + this.logger = options.logger ?? defaultLogger; + } + + static createDefaultAgent(baseUrl: string, defaultProxy?: ProxyOption) { + if (!defaultProxy) { + return undefined; + } + + const {host, port} = defaultProxy; + if (new URL(baseUrl).protocol.replace(':', '') === 'http') { + return new HttpProxyAgent(`http://${host}:${port}`); + } else { + return new HttpsProxyAgent(`https://${host}:${port}`); + } + } + + static async create(options: GitHubCreateOptions): Promise { + const apiUrl = options.apiUrl ?? GH_API_URL; + const graphqlUrl = options.graphqlUrl ?? GH_GRAPHQL_URL; + const releasePleaseVersion = require('../../package.json').version; + const apis = options.octokitAPIs ?? { + octokit: new Octokit({ + baseUrl: apiUrl, + auth: options.token, + request: { + agent: this.createDefaultAgent(apiUrl, options.proxy), + fetch: options.fetch, + }, + }), + request: request.defaults({ + baseUrl: apiUrl, + headers: { + 'user-agent': `release-please/${releasePleaseVersion}`, + Authorization: `token ${options.token}`, + }, + fetch: options.fetch, + }), + graphql: graphql.defaults({ + baseUrl: graphqlUrl, + request: { + agent: this.createDefaultAgent(graphqlUrl, options.proxy), + fetch: options.fetch, + }, + headers: { + 'user-agent': `release-please/${releasePleaseVersion}`, + Authorization: `token ${options.token}`, + 'content-type': 'application/vnd.github.v3+json', + }, + }), + }; + const opts = { + repository: { + owner: options.owner, + repo: options.repo, + defaultBranch: + options.defaultBranch ?? + (await GitHubApi.defaultBranch( + options.owner, + options.repo, + apis.octokit + )), + }, + octokitAPIs: apis, + logger: options.logger, + }; + return new GitHubApi(opts); + } + + static async defaultBranch( + owner: string, + repo: string, + octokit: OctokitType + ): Promise { + const {data} = await octokit.repos.get({ + repo, + owner, + }); + return data.default_branch; + } + + private graphqlRequest = wrapAsync( + async ( + opts: { + [key: string]: string | number | null | undefined; + }, + options?: { + maxRetries?: number; + } + ) => { + let maxRetries = options?.maxRetries ?? 5; + let seconds = 1; + while (maxRetries >= 0) { + try { + const response = await this.graphql(opts); + if (response) { + return response; + } + this.logger.trace('no GraphQL response, retrying'); + } catch (err) { + if ((err as GitHubAPIError).status !== 502) { + throw err; + } + if (maxRetries === 0) { + this.logger.warn('ran out of retries and response is required'); + throw err; + } + this.logger.info( + `received 502 error, ${maxRetries} attempts remaining` + ); + } + maxRetries -= 1; + if (maxRetries >= 0) { + this.logger.trace(`sleeping ${seconds} seconds`); + await sleepInMs(1000 * seconds); + seconds = Math.min(seconds * 2, MAX_SLEEP_SECONDS); + } + } + this.logger.trace('ran out of retries'); + return undefined; + } + ); + + /** + * Iterate through merged pull requests with a max number of results scanned. + * + * @param {string} targetBranch Target branch of commit. + * @param {string} status The status of the pull request. Defaults to 'MERGED'. + * @param {number} maxResults Limit the number of results searched. Defaults to + * unlimited. + * @param {boolean} includeFiles Whether to fetch the list of files included in + * the pull request. Defaults to `true`. + * @yields {PullRequest} + * @throws {GitHubAPIError} on an API error + */ + async *pullRequestIterator( + targetBranch: string, + status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', + maxResults: number = Number.MAX_SAFE_INTEGER, + includeFiles = true + ): AsyncGenerator { + const generator = includeFiles + ? this.pullRequestIteratorWithFiles(targetBranch, status, maxResults) + : this.pullRequestIteratorWithoutFiles(targetBranch, status, maxResults); + for await (const pullRequest of generator) { + yield pullRequest; + } + } + + /** + * Helper implementation of pullRequestIterator that includes files via + * the graphQL API. + * + * @param {string} targetBranch The base branch of the pull request + * @param {string} status The status of the pull request + * @param {number} maxResults Limit the number of results searched + */ + private async *pullRequestIteratorWithFiles( + targetBranch: string, + status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', + maxResults: number = Number.MAX_SAFE_INTEGER + ): AsyncGenerator { + let cursor: string | undefined = undefined; + let results = 0; + while (results < maxResults) { + const response: PullRequestHistory | null = + await this.pullRequestsGraphQL(targetBranch, status, cursor); + // no response usually means we ran out of results + if (!response) { + break; + } + for (let i = 0; i < response.data.length; i++) { + results += 1; + yield response.data[i]; + } + if (!response.pageInfo.hasNextPage) { + break; + } + cursor = response.pageInfo.endCursor; + } + } + + /** + * Helper implementation of pullRequestIterator that excludes files + * via the REST API. + * + * @param {string} targetBranch The base branch of the pull request + * @param {string} status The status of the pull request + * @param {number} maxResults Limit the number of results searched + */ + private async *pullRequestIteratorWithoutFiles( + targetBranch: string, + status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', + maxResults: number = Number.MAX_SAFE_INTEGER + ): AsyncGenerator { + const statusMap: Record = { + OPEN: 'open', + CLOSED: 'closed', + MERGED: 'closed', + }; + let results = 0; + for await (const {data: pulls} of this.octokit.paginate.iterator( + 'GET /repos/{owner}/{repo}/pulls', + { + state: statusMap[status], + owner: this.repository.owner, + repo: this.repository.repo, + base: targetBranch, + sort: 'updated', + direction: 'desc', + } + )) { + for (const pull of pulls) { + // The REST API does not have an option for "merged" + // pull requests - they are closed with a `merged_at` timestamp + if (status !== 'MERGED' || pull.merged_at) { + results += 1; + yield { + headBranchName: pull.head.ref, + baseBranchName: pull.base.ref, + number: pull.number, + title: pull.title, + body: pull.body || '', + labels: pull.labels.map((label: any) => label.name), + files: [], + sha: pull.merge_commit_sha || undefined, + }; + if (results >= maxResults) { + break; + } + } + } + + if (results >= maxResults) { + break; + } + } + } + + /** + * Return a list of merged pull requests. The list is not guaranteed to be sorted + * by merged_at, but is generally most recent first. + * + * @param {string} targetBranch - Base branch of the pull request. Defaults to + * the configured default branch. + * @param {number} page - Page of results. Defaults to 1. + * @param {number} perPage - Number of results per page. Defaults to 100. + * @returns {PullRequestHistory | null} - List of merged pull requests + * @throws {GitHubAPIError} on an API error + */ + private async pullRequestsGraphQL( + targetBranch: string, + states: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', + cursor?: string + ): Promise { + this.logger.debug( + `Fetching ${states} pull requests on branch ${targetBranch} with cursor ${cursor}` + ); + const response = await this.graphqlRequest({ + query: `query mergedPullRequests($owner: String!, $repo: String!, $num: Int!, $maxFilesChanged: Int, $targetBranch: String!, $states: [PullRequestState!], $cursor: String) { + repository(owner: $owner, name: $repo) { + pullRequests(first: $num, after: $cursor, baseRefName: $targetBranch, states: $states, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + number + title + baseRefName + headRefName + labels(first: 10) { + nodes { + name + } + } + body + mergeCommit { + oid + } + files(first: $maxFilesChanged) { + nodes { + path + } + pageInfo { + endCursor + hasNextPage + } + } + } + pageInfo { + endCursor + hasNextPage + } + } + } + }`, + cursor, + owner: this.repository.owner, + repo: this.repository.repo, + num: 25, + targetBranch, + states, + maxFilesChanged: 64, + }); + if (!response?.repository?.pullRequests) { + this.logger.warn( + `Could not find merged pull requests for branch ${targetBranch} - it likely does not exist.` + ); + return null; + } + const pullRequests = (response.repository.pullRequests.nodes || + []) as GraphQLPullRequest[]; + return { + pageInfo: response.repository.pullRequests.pageInfo, + data: pullRequests.map(pullRequest => { + return { + sha: pullRequest.mergeCommit?.oid, // already filtered non-merged + number: pullRequest.number, + baseBranchName: pullRequest.baseRefName, + headBranchName: pullRequest.headRefName, + labels: (pullRequest.labels?.nodes || []).map(l => l.name), + title: pullRequest.title, + body: pullRequest.body + '', + files: (pullRequest.files?.nodes || []).map(node => node.path), + }; + }), + }; + } + + /** + * Iterate through releases with a max number of results scanned. + * + * @param {ReleaseIteratorOptions} options Query options + * @param {number} options.maxResults Limit the number of results scanned. + * Defaults to unlimited. + * @yields {ScmRelease} + * @throws {GitHubAPIError} on an API error + */ + async *releaseIterator(options: ReleaseIteratorOptions = {}) { + const maxResults = options.maxResults ?? Number.MAX_SAFE_INTEGER; + let results = 0; + let cursor: string | undefined = undefined; + while (true) { + const response: ReleaseHistory | null = await this.releaseGraphQL(cursor); + if (!response) { + break; + } + for (let i = 0; i < response.data.length; i++) { + if ((results += 1) > maxResults) { + break; + } + yield response.data[i]; + } + if (results > maxResults || !response.pageInfo.hasNextPage) { + break; + } + cursor = response.pageInfo.endCursor; + } + } + + private async releaseGraphQL( + cursor?: string + ): Promise { + this.logger.debug(`Fetching releases with cursor ${cursor}`); + const response = await this.graphqlRequest({ + query: `query releases($owner: String!, $repo: String!, $num: Int!, $cursor: String) { + repository(owner: $owner, name: $repo) { + releases(first: $num, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) { + nodes { + name + tag { + name + } + tagCommit { + oid + } + url + description + isDraft + } + pageInfo { + endCursor + hasNextPage + } + } + } + }`, + cursor, + owner: this.repository.owner, + repo: this.repository.repo, + num: 25, + }); + if (!response?.repository?.releases?.nodes?.length) { + this.logger.warn('Could not find releases.'); + return null; + } + const releases = response.repository.releases.nodes as GraphQLRelease[]; + return { + pageInfo: response.repository.releases.pageInfo, + data: releases + .filter(release => !!release.tagCommit) + .map(release => { + if (!release.tag || !release.tagCommit) { + this.logger.debug(release); + } + return { + name: release.name || undefined, + tagName: release.tag ? release.tag.name : 'unknown', + sha: release.tagCommit.oid, + notes: release.description, + url: release.url, + draft: release.isDraft, + } as ScmRelease; + }), + } as ReleaseHistory; + } + + createPullRequest = wrapAsync( + async ( + pullRequest: PullRequest, + targetBranch: string, + options?: {draft?: boolean} + ) => { + const pullResponseData = ( + await this.octokit.pulls.create({ + owner: this.repository.owner, + repo: this.repository.repo, + title: pullRequest.title, + head: `${this.repository.owner}:${pullRequest.headBranchName}`, + base: targetBranch, + body: pullRequest.body, + maintainer_can_modify: true, + draft: !!options?.draft, + }) + ).data; + + this.logger.info( + `Successfully opened pull request available at url: ${pullResponseData.html_url}.` + ); + return await this.getPullRequest(pullResponseData.number); + } + ); + + /** + * Fetch a pull request given the pull number + * @param {number} number The pull request number + * @returns {PullRequest} + */ + getPullRequest = wrapAsync(async (number: number): Promise => { + const response = await this.octokit.pulls.get({ + owner: this.repository.owner, + repo: this.repository.repo, + pull_number: number, + }); + return { + headBranchName: response.data.head.ref, + baseBranchName: response.data.base.ref, + number: response.data.number, + title: response.data.title, + body: response.data.body || '', + files: [], + labels: response.data.labels + .map((label: any) => label.name) + .filter((name: any) => !!name) as string[], + }; + }); + + updatePullRequest = wrapAsync( + async (number: number, title: string, body: string) => { + const response = await this.octokit.pulls.update({ + owner: this.repository.owner, + repo: this.repository.repo, + pull_number: number, + title, + body, + state: 'open', + }); + return { + headBranchName: response.data.head.ref, + baseBranchName: response.data.base.ref, + number: response.data.number, + title: response.data.title, + body: response.data.body || '', + files: [], + labels: response.data.labels + .map((label: any) => label.name) + .filter((name: any) => !!name) as string[], + }; + } + ); + + /** + * Create a GitHub release + * + * @param {Release} release Release parameters + * @param {ScmReleaseOptions} options Release option parameters + * @throws {DuplicateReleaseError} if the release tag already exists + * @throws {GitHubAPIError} on other API errors + */ + createRelease = wrapAsync( + async ( + release: Release, + options: ScmReleaseOptions = {} + ): Promise => { + if (options.forceTag) { + try { + await this.octokit.git.createRef({ + owner: this.repository.owner, + repo: this.repository.repo, + ref: `refs/tags/${release.tag.toString()}`, + sha: release.sha, + }); + } catch (err) { + // ignore if tag already exists + if ((err as RequestError).status === 422) { + this.logger.debug( + `Tag ${release.tag.toString()} already exists, skipping tag creation` + ); + } else { + throw err; + } + } + } + const resp = await this.octokit.repos.createRelease({ + name: release.name, + owner: this.repository.owner, + repo: this.repository.repo, + tag_name: release.tag.toString(), + body: release.notes, + draft: !!options.draft, + prerelease: !!options.prerelease, + target_commitish: release.sha, + }); + return { + id: resp.data.id, + name: resp.data.name || undefined, + tagName: resp.data.tag_name, + sha: resp.data.target_commitish, + notes: + resp.data.body_text || + resp.data.body || + resp.data.body_html || + undefined, + url: resp.data.html_url, + draft: resp.data.draft, + uploadUrl: resp.data.upload_url, + }; + }, + e => { + if (e instanceof RequestError) { + if ( + e.status === 422 && + GitHubAPIError.parseErrors(e).some(error => { + return error.code === 'already_exists'; + }) + ) { + throw new DuplicateReleaseError(e, 'tagName'); + } + } + } + ); + + /** + * Makes a comment on a issue/pull request. + * + * @param {string} comment - The body of the comment to post. + * @param {number} number - The issue or pull request number. + * @throws {GitHubAPIError} on an API error + */ + commentOnIssue = wrapAsync( + async (comment: string, number: number): Promise => { + this.logger.debug( + `adding comment to https://github.com/${this.repository.owner}/${this.repository.repo}/issues/${number}` + ); + const resp = await this.octokit.issues.createComment({ + owner: this.repository.owner, + repo: this.repository.repo, + issue_number: number, + body: comment, + }); + return resp.data.html_url; + } + ); + + /** + * Removes labels from an issue/pull request. + * + * @param {string[]} labels The labels to remove. + * @param {number} number The issue/pull request number. + */ + removeIssueLabels = wrapAsync( + async (labels: string[], number: number): Promise => { + if (labels.length === 0) { + return; + } + this.logger.debug(`removing labels: ${labels} from issue/pull ${number}`); + await Promise.all( + labels.map(label => + this.octokit.issues.removeLabel({ + owner: this.repository.owner, + repo: this.repository.repo, + issue_number: number, + name: label, + }) + ) + ); + } + ); + + /** + * Adds label to an issue/pull request. + * + * @param {string[]} labels The labels to add. + * @param {number} number The issue/pull request number. + */ + addIssueLabels = wrapAsync( + async (labels: string[], number: number): Promise => { + if (labels.length === 0) { + return; + } + this.logger.debug(`adding labels: ${labels} from issue/pull ${number}`); + await this.octokit.issues.addLabels({ + owner: this.repository.owner, + repo: this.repository.repo, + issue_number: number, + labels, + }); + } + ); + + /** + * Generate release notes from GitHub at tag + * @param {string} tagName Name of new release tag + * @param {string} targetCommitish Target commitish for new tag + * @param {string} previousTag Optional. Name of previous tag to analyze commits since + */ + async generateReleaseNotes( + tagName: string, + targetCommitish: string, + previousTag?: string + ): Promise { + const resp = await this.octokit.repos.generateReleaseNotes({ + owner: this.repository.owner, + repo: this.repository.repo, + tag_name: tagName, + previous_tag_name: previousTag, + target_commitish: targetCommitish, + }); + return resp.data.body; + } + + /** + * Create a single file on a new branch based on an existing + * branch. This will force-push to that branch. + * @param {string} filename Filename with path in the repository + * @param {string} contents Contents of the file + * @param {string} newBranchName Name of the new branch + * @param {string} baseBranchName Name of the base branch (where + * new branch is forked from) + * @returns {string} HTML URL of the new file + */ + async createFileOnNewBranch( + filename: string, + contents: string, + newBranchName: string, + baseBranchName: string + ): Promise { + // create or update new branch to match base branch + await this.forkBranch(newBranchName, baseBranchName); + + // use the single file upload API + const { + data: {content}, + } = await this.octokit.repos.createOrUpdateFileContents({ + owner: this.repository.owner, + repo: this.repository.repo, + path: filename, + // contents need to be base64 encoded + content: Buffer.from(contents, 'binary').toString('base64'), + message: 'Saving release notes', + branch: newBranchName, + }); + return content?.html_url || ''; + } + + /** + * Fork a branch from a base branch. + */ + private async forkBranch( + targetBranchName: string, + baseBranchName: string + ): Promise { + const baseBranchSha = await this.getBranchSha(baseBranchName); + if (!baseBranchSha) { + throw new ConfigurationError( + `Unable to find base branch: ${baseBranchName}`, + 'core', + `${this.repository.owner}/${this.repository.repo}` + ); + } + if (await this.getBranchSha(targetBranchName)) { + const branchSha = await this.updateBranchSha( + targetBranchName, + baseBranchSha + ); + this.logger.debug( + `Updated ${targetBranchName} to match ${baseBranchName} at ${branchSha}` + ); + return branchSha; + } else { + const branchSha = await this.createNewBranch( + targetBranchName, + baseBranchSha + ); + this.logger.debug( + `Created ${targetBranchName} from ${baseBranchName} at ${branchSha}` + ); + return branchSha; + } + } + + /** + * Helper to fetch the SHA of a branch + */ + private async getBranchSha(branchName: string): Promise { + this.logger.debug(`Looking up SHA for branch: ${branchName}`); + try { + const { + data: { + object: {sha}, + }, + } = await this.octokit.git.getRef({ + owner: this.repository.owner, + repo: this.repository.repo, + ref: `heads/${branchName}`, + }); + this.logger.debug(`SHA for branch: ${sha}`); + return sha; + } catch (e) { + if (e instanceof RequestError && e.status === 404) { + this.logger.debug(`Branch: ${branchName} does not exist`); + return undefined; + } + throw e; + } + } + + /** + * Helper to create a new branch from a given SHA. + */ + private async createNewBranch( + branchName: string, + branchSha: string + ): Promise { + this.logger.debug(`Creating new branch: ${branchName} at ${branchSha}`); + const { + data: { + object: {sha}, + }, + } = await this.octokit.git.createRef({ + owner: this.repository.owner, + repo: this.repository.repo, + ref: `refs/heads/${branchName}`, + sha: branchSha, + }); + this.logger.debug(`New branch: ${branchName} at ${sha}`); + return sha; + } + + /** + * Helper to update branch SHA. + */ + private async updateBranchSha( + branchName: string, + branchSha: string + ): Promise { + this.logger.debug(`Updating branch ${branchName} to ${branchSha}`); + const { + data: { + object: {sha}, + }, + } = await this.octokit.git.updateRef({ + owner: this.repository.owner, + repo: this.repository.repo, + ref: `heads/${branchName}`, + sha: branchSha, + force: true, + }); + this.logger.debug(`Updated branch: ${branchName} to ${sha}`); + return sha; + } +} + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export const wrapAsync = , V>( + fn: (...args: T) => Promise, + errorHandler?: (e: Error) => void +) => { + return async (...args: T): Promise => { + try { + return await fn(...args); + } catch (e) { + if (errorHandler) { + errorHandler(e as GitHubAPIError); + } + if (e instanceof RequestError) { + throw new GitHubAPIError(e); + } + throw e; + } + }; +}; + +export const sleepInMs = (ms: number) => + new Promise(resolve => setTimeout(resolve, ms)); diff --git a/src/github.ts b/src/github.ts index d99e61b16..07035fd04 100644 --- a/src/github.ts +++ b/src/github.ts @@ -12,25 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {createPullRequest} from 'code-suggester'; import {PullRequest} from './pull-request'; import {Commit} from './commit'; import {Octokit} from '@octokit/rest'; import {request} from '@octokit/request'; -import {graphql} from '@octokit/graphql'; import {RequestError} from '@octokit/request-error'; -import { - GitHubAPIError, - DuplicateReleaseError, - FileNotFoundError, - ConfigurationError, -} from './errors'; +import {createPullRequest as suggesterCreatePullRequest} from 'code-suggester'; +import {GitHubAPIError, FileNotFoundError} from './errors'; const MAX_ISSUE_BODY_SIZE = 65536; const MAX_SLEEP_SECONDS = 20; -export const GH_API_URL = 'https://api.github.com'; -export const GH_GRAPHQL_URL = 'https://api.github.com'; + type OctokitType = InstanceType; import {logger as defaultLogger} from './util/logger'; @@ -39,6 +32,7 @@ import {ReleasePullRequest} from './release-pull-request'; import {Update} from './update'; import {Release} from './release'; import {ROOT_PROJECT_PATH} from './manifest'; +import {GitHubApi, GitHubCreateOptions} from './github-api'; import {signoffCommitMessage} from './util/signoff-commit-message'; import { RepositoryFileCache, @@ -47,10 +41,19 @@ import { FileNotFoundError as MissingFileError, } from '@google-automations/git-file-utils'; import {Logger} from 'code-suggester/build/src/types'; -import {HttpsProxyAgent} from 'https-proxy-agent'; -import {HttpProxyAgent} from 'http-proxy-agent'; -import {PullRequestOverflowHandler} from './util/pull-request-overflow-handler'; import {mergeUpdates} from './updaters/composite'; +import { + Scm, + ScmChangeSet, + ScmCommitIteratorOptions, + ScmReleaseIteratorOptions, + ScmTagIteratorOptions, + ScmCreatePullRequestOptions, + ScmReleaseOptions, + ScmRelease, + ScmTag, + ScmUpdatePullRequestOptions, +} from './scm'; // Extract some types from the `request` package. type RequestBuilderType = typeof request; @@ -68,29 +71,20 @@ export interface GitHubOptions { logger?: Logger; } -interface ProxyOption { - host: string; - port: number; -} +type CommitFilter = (commit: Commit) => boolean; -interface GitHubCreateOptions { - owner: string; - repo: string; - defaultBranch?: string; - apiUrl?: string; - graphqlUrl?: string; - octokitAPIs?: OctokitAPIs; - token?: string; - logger?: Logger; - proxy?: ProxyOption; - fetch?: any; +interface GraphQLCommitAuthor { + name?: string; + email?: string; + user?: { + login: string; + } | null; } -type CommitFilter = (commit: Commit) => boolean; - interface GraphQLCommit { sha: string; message: string; + author?: GraphQLCommitAuthor; associatedPullRequests: { nodes: GraphQLPullRequest[]; }; @@ -120,19 +114,6 @@ interface GraphQLPullRequest { }; } -interface GraphQLRelease { - name: string; - tag: { - name: string; - }; - tagCommit: { - oid: string; - }; - url: string; - description: string; - isDraft: boolean; -} - interface CommitHistory { pageInfo: { hasNextPage: boolean; @@ -141,184 +122,49 @@ interface CommitHistory { data: Commit[]; } -interface PullRequestHistory { - pageInfo: { - hasNextPage: boolean; - endCursor: string | undefined; - }; - data: PullRequest[]; -} - -interface ReleaseHistory { - pageInfo: { - hasNextPage: boolean; - endCursor: string | undefined; - }; - data: GitHubRelease[]; -} - -interface CommitIteratorOptions { - maxResults?: number; - backfillFiles?: boolean; - batchSize?: number; -} - -interface ReleaseIteratorOptions { - maxResults?: number; -} - -interface TagIteratorOptions { - maxResults?: number; -} +type CommitIteratorOptions = ScmCommitIteratorOptions; +type ReleaseIteratorOptions = ScmReleaseIteratorOptions; +type TagIteratorOptions = ScmTagIteratorOptions; -export interface ReleaseOptions { - draft?: boolean; - prerelease?: boolean; - forceTag?: boolean; -} +export type ReleaseOptions = ScmReleaseOptions; +export type GitHubRelease = ScmRelease; +export type GitHubTag = ScmTag; +export type ChangeSet = ScmChangeSet; -export interface GitHubRelease { - id: number; - name?: string; - tagName: string; - sha: string; - notes?: string; - url: string; - draft?: boolean; - uploadUrl?: string; -} +type CreatePullRequestOptions = ScmCreatePullRequestOptions; -export interface GitHubTag { - name: string; - sha: string; -} - -interface FileDiff { - readonly mode: '100644' | '100755' | '040000' | '160000' | '120000'; - readonly content: string | null; - readonly originalContent: string | null; -} -export type ChangeSet = Map; - -interface CreatePullRequestOptions { - fork?: boolean; - draft?: boolean; -} - -export class GitHub { +export class GitHub implements Scm { readonly repository: Repository; private octokit: OctokitType; - private request: RequestFunctionType; private graphql: Function; private fileCache: RepositoryFileCache; private logger: Logger; + private gitHubApi: GitHubApi; private constructor(options: GitHubOptions) { this.repository = options.repository; this.octokit = options.octokitAPIs.octokit; - this.request = options.octokitAPIs.request; this.graphql = options.octokitAPIs.graphql; this.fileCache = new RepositoryFileCache(this.octokit, this.repository); this.logger = options.logger ?? defaultLogger; + this.gitHubApi = new GitHubApi({ + repository: this.repository, + octokitAPIs: options.octokitAPIs, + logger: this.logger, + }); } - static createDefaultAgent(baseUrl: string, defaultProxy?: ProxyOption) { - if (!defaultProxy) { - return undefined; - } - - const {host, port} = defaultProxy; - if (new URL(baseUrl).protocol.replace(':', '') === 'http') { - return new HttpProxyAgent(`http://${host}:${port}`); - } else { - return new HttpsProxyAgent(`https://${host}:${port}`); - } + getGitHubApi(): GitHubApi { + return this.gitHubApi; } - /** - * Build a new GitHub client with auto-detected default branch. - * - * @param {GitHubCreateOptions} options Configuration options - * @param {string} options.owner The repository owner. - * @param {string} options.repo The repository name. - * @param {string} options.defaultBranch Optional. The repository's default branch. - * Defaults to the value fetched via the API. - * @param {string} options.apiUrl Optional. The base url of the GitHub API. - * @param {string} options.graphqlUrl Optional. The base url of the GraphQL API. - * @param {OctokitAPISs} options.octokitAPIs Optional. Override the internal - * client instances with a pre-authenticated instance. - * @param {string} token Optional. A GitHub API token used for authentication. - */ static async create(options: GitHubCreateOptions): Promise { - const apiUrl = options.apiUrl ?? GH_API_URL; - const graphqlUrl = options.graphqlUrl ?? GH_GRAPHQL_URL; - const releasePleaseVersion = require('../../package.json').version; - const apis = options.octokitAPIs ?? { - octokit: new Octokit({ - baseUrl: apiUrl, - auth: options.token, - request: { - agent: this.createDefaultAgent(apiUrl, options.proxy), - fetch: options.fetch, - }, - }), - request: request.defaults({ - baseUrl: apiUrl, - headers: { - 'user-agent': `release-please/${releasePleaseVersion}`, - Authorization: `token ${options.token}`, - }, - fetch: options.fetch, - }), - graphql: graphql.defaults({ - baseUrl: graphqlUrl, - request: { - agent: this.createDefaultAgent(graphqlUrl, options.proxy), - fetch: options.fetch, - }, - headers: { - 'user-agent': `release-please/${releasePleaseVersion}`, - Authorization: `token ${options.token}`, - 'content-type': 'application/vnd.github.v3+json', - }, - }), - }; - const opts = { - repository: { - owner: options.owner, - repo: options.repo, - defaultBranch: - options.defaultBranch ?? - (await GitHub.defaultBranch( - options.owner, - options.repo, - apis.octokit - )), - }, - octokitAPIs: apis, + const gitHubApi = await GitHubApi.create(options); + return new GitHub({ + repository: gitHubApi.repository, + octokitAPIs: gitHubApi.octokitAPIs, logger: options.logger, - }; - return new GitHub(opts); - } - - /** - * Returns the default branch for a given repository. - * - * @param {string} owner The GitHub repository owner - * @param {string} repo The GitHub repository name - * @param {OctokitType} octokit An authenticated octokit instance - * @returns {string} Name of the default branch - */ - static async defaultBranch( - owner: string, - repo: string, - octokit: OctokitType - ): Promise { - const {data} = await octokit.repos.get({ - repo, - owner, }); - return data.default_branch; } /** @@ -435,6 +281,13 @@ export class GitHub { } sha: oid message + author { + name + email + user { + login + } + } } pageInfo { hasNextPage @@ -493,6 +346,13 @@ export class GitHub { const commit: Commit = { sha: graphCommit.sha, message: graphCommit.message, + author: graphCommit.author + ? { + name: graphCommit.author.name || 'Unknown', + email: graphCommit.author.email, + username: graphCommit.author.user?.login, + } + : undefined, }; const mergePullRequest = graphCommit.associatedPullRequests.nodes.find( pr => { @@ -651,189 +511,12 @@ export class GitHub { maxResults: number = Number.MAX_SAFE_INTEGER, includeFiles = true ): AsyncGenerator { - const generator = includeFiles - ? this.pullRequestIteratorWithFiles(targetBranch, status, maxResults) - : this.pullRequestIteratorWithoutFiles(targetBranch, status, maxResults); - for await (const pullRequest of generator) { - yield pullRequest; - } - } - - /** - * Helper implementation of pullRequestIterator that includes files via - * the graphQL API. - * - * @param {string} targetBranch The base branch of the pull request - * @param {string} status The status of the pull request - * @param {number} maxResults Limit the number of results searched - */ - private async *pullRequestIteratorWithFiles( - targetBranch: string, - status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', - maxResults: number = Number.MAX_SAFE_INTEGER - ): AsyncGenerator { - let cursor: string | undefined = undefined; - let results = 0; - while (results < maxResults) { - const response: PullRequestHistory | null = - await this.pullRequestsGraphQL(targetBranch, status, cursor); - // no response usually means we ran out of results - if (!response) { - break; - } - for (let i = 0; i < response.data.length; i++) { - results += 1; - yield response.data[i]; - } - if (!response.pageInfo.hasNextPage) { - break; - } - cursor = response.pageInfo.endCursor; - } - } - - /** - * Helper implementation of pullRequestIterator that excludes files - * via the REST API. - * - * @param {string} targetBranch The base branch of the pull request - * @param {string} status The status of the pull request - * @param {number} maxResults Limit the number of results searched - */ - private async *pullRequestIteratorWithoutFiles( - targetBranch: string, - status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', - maxResults: number = Number.MAX_SAFE_INTEGER - ): AsyncGenerator { - const statusMap: Record = { - OPEN: 'open', - CLOSED: 'closed', - MERGED: 'closed', - }; - let results = 0; - for await (const {data: pulls} of this.octokit.paginate.iterator( - 'GET /repos/{owner}/{repo}/pulls', - { - state: statusMap[status], - owner: this.repository.owner, - repo: this.repository.repo, - base: targetBranch, - sort: 'updated', - direction: 'desc', - } - )) { - for (const pull of pulls) { - // The REST API does not have an option for "merged" - // pull requests - they are closed with a `merged_at` timestamp - if (status !== 'MERGED' || pull.merged_at) { - results += 1; - yield { - headBranchName: pull.head.ref, - baseBranchName: pull.base.ref, - number: pull.number, - title: pull.title, - body: pull.body || '', - labels: pull.labels.map(label => label.name), - files: [], - sha: pull.merge_commit_sha || undefined, - }; - if (results >= maxResults) { - break; - } - } - } - - if (results >= maxResults) { - break; - } - } - } - - /** - * Return a list of merged pull requests. The list is not guaranteed to be sorted - * by merged_at, but is generally most recent first. - * - * @param {string} targetBranch - Base branch of the pull request. Defaults to - * the configured default branch. - * @param {number} page - Page of results. Defaults to 1. - * @param {number} perPage - Number of results per page. Defaults to 100. - * @returns {PullRequestHistory | null} - List of merged pull requests - * @throws {GitHubAPIError} on an API error - */ - private async pullRequestsGraphQL( - targetBranch: string, - states: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', - cursor?: string - ): Promise { - this.logger.debug( - `Fetching ${states} pull requests on branch ${targetBranch} with cursor ${cursor}` - ); - const response = await this.graphqlRequest({ - query: `query mergedPullRequests($owner: String!, $repo: String!, $num: Int!, $maxFilesChanged: Int, $targetBranch: String!, $states: [PullRequestState!], $cursor: String) { - repository(owner: $owner, name: $repo) { - pullRequests(first: $num, after: $cursor, baseRefName: $targetBranch, states: $states, orderBy: {field: CREATED_AT, direction: DESC}) { - nodes { - number - title - baseRefName - headRefName - labels(first: 10) { - nodes { - name - } - } - body - mergeCommit { - oid - } - files(first: $maxFilesChanged) { - nodes { - path - } - pageInfo { - endCursor - hasNextPage - } - } - } - pageInfo { - endCursor - hasNextPage - } - } - } - }`, - cursor, - owner: this.repository.owner, - repo: this.repository.repo, - num: 25, + yield* this.gitHubApi.pullRequestIterator( targetBranch, - states, - maxFilesChanged: 64, - }); - if (!response?.repository?.pullRequests) { - this.logger.warn( - `Could not find merged pull requests for branch ${targetBranch} - it likely does not exist.` - ); - return null; - } - const pullRequests = (response.repository.pullRequests.nodes || - []) as GraphQLPullRequest[]; - return { - pageInfo: response.repository.pullRequests.pageInfo, - data: pullRequests.map(pullRequest => { - return { - sha: pullRequest.mergeCommit?.oid, // already filtered non-merged - number: pullRequest.number, - baseBranchName: pullRequest.baseRefName, - headBranchName: pullRequest.headRefName, - labels: (pullRequest.labels?.nodes || []).map(l => l.name), - title: pullRequest.title, - body: pullRequest.body + '', - files: (pullRequest.files?.nodes || []).map(node => node.path), - }; - }), - }; + status, + maxResults, + includeFiles + ); } /** @@ -846,82 +529,7 @@ export class GitHub { * @throws {GitHubAPIError} on an API error */ async *releaseIterator(options: ReleaseIteratorOptions = {}) { - const maxResults = options.maxResults ?? Number.MAX_SAFE_INTEGER; - let results = 0; - let cursor: string | undefined = undefined; - while (true) { - const response: ReleaseHistory | null = await this.releaseGraphQL(cursor); - if (!response) { - break; - } - for (let i = 0; i < response.data.length; i++) { - if ((results += 1) > maxResults) { - break; - } - yield response.data[i]; - } - if (results > maxResults || !response.pageInfo.hasNextPage) { - break; - } - cursor = response.pageInfo.endCursor; - } - } - - private async releaseGraphQL( - cursor?: string - ): Promise { - this.logger.debug(`Fetching releases with cursor ${cursor}`); - const response = await this.graphqlRequest({ - query: `query releases($owner: String!, $repo: String!, $num: Int!, $cursor: String) { - repository(owner: $owner, name: $repo) { - releases(first: $num, after: $cursor, orderBy: {field: CREATED_AT, direction: DESC}) { - nodes { - name - tag { - name - } - tagCommit { - oid - } - url - description - isDraft - } - pageInfo { - endCursor - hasNextPage - } - } - } - }`, - cursor, - owner: this.repository.owner, - repo: this.repository.repo, - num: 25, - }); - if (!response.repository.releases.nodes.length) { - this.logger.warn('Could not find releases.'); - return null; - } - const releases = response.repository.releases.nodes as GraphQLRelease[]; - return { - pageInfo: response.repository.releases.pageInfo, - data: releases - .filter(release => !!release.tagCommit) - .map(release => { - if (!release.tag || !release.tagCommit) { - this.logger.debug(release); - } - return { - name: release.name || undefined, - tagName: release.tag ? release.tag.name : 'unknown', - sha: release.tagCommit.oid, - notes: release.description, - url: release.url, - draft: release.isDraft, - } as GitHubRelease; - }), - } as ReleaseHistory; + yield* this.gitHubApi.releaseIterator(options); } /** @@ -1066,6 +674,7 @@ export class GitHub { prefix ); } + /** * Returns a list of paths to all files matching a glob pattern. * @@ -1089,52 +698,6 @@ export class GitHub { } ); - /** - * Open a pull request - * - * @deprecated This logic is handled by the Manifest class now as it - * can be more complicated if the release notes are too big - * @param {ReleasePullRequest} releasePullRequest Pull request data to update - * @param {string} targetBranch The base branch of the pull request - * @param {GitHubPR} options The pull request options - * @throws {GitHubAPIError} on an API error - */ - async createReleasePullRequest( - releasePullRequest: ReleasePullRequest, - targetBranch: string, - options?: { - signoffUser?: string; - fork?: boolean; - skipLabeling?: boolean; - } - ): Promise { - let message = releasePullRequest.title.toString(); - if (options?.signoffUser) { - message = signoffCommitMessage(message, options.signoffUser); - } - const pullRequestLabels: string[] = options?.skipLabeling - ? [] - : releasePullRequest.labels; - return await this.createPullRequest( - { - headBranchName: releasePullRequest.headRefName, - baseBranchName: targetBranch, - number: -1, - title: releasePullRequest.title.toString(), - body: releasePullRequest.body.toString().slice(0, MAX_ISSUE_BODY_SIZE), - labels: pullRequestLabels, - files: [], - }, - targetBranch, - message, - releasePullRequest.updates, - { - fork: options?.fork, - draft: releasePullRequest.draft, - } - ); - } - /** * Open a pull request * @@ -1145,57 +708,53 @@ export class GitHub { * @param {CreatePullRequestOptions} options The pull request options * @throws {GitHubAPIError} on an API error */ - createPullRequest = wrapAsync( - async ( - pullRequest: PullRequest, - targetBranch: string, - message: string, - updates: Update[], - options?: CreatePullRequestOptions - ): Promise => { - // Update the files for the release if not already supplied - const changes = await this.buildChangeSet(updates, targetBranch); - const prNumber = await createPullRequest(this.octokit, changes, { - upstreamOwner: this.repository.owner, - upstreamRepo: this.repository.repo, + async createPullRequest( + pullRequest: PullRequest, + targetBranch: string, + message: string, + updates: Update[], + options?: CreatePullRequestOptions + ): Promise { + const changes = await this.buildChangeSet(updates, targetBranch); + const prNumber = await suggesterCreatePullRequest(this.octokit, changes, { + upstreamOwner: this.repository.owner, + upstreamRepo: this.repository.repo, + title: pullRequest.title, + branch: pullRequest.headBranchName, + description: pullRequest.body, + primary: targetBranch, + force: true, + fork: !!options?.fork, + message, + logger: this.logger, + draft: !!options?.draft, + labels: pullRequest.labels, + }); + if (prNumber === 0) { + this.logger.warn( + 'no code changes detected, skipping pull request creation' + ); + return { + headBranchName: pullRequest.headBranchName, + baseBranchName: targetBranch, + number: 0, title: pullRequest.title, - branch: pullRequest.headBranchName, - description: pullRequest.body, - primary: targetBranch, - force: true, - fork: !!options?.fork, - message, - logger: this.logger, - draft: !!options?.draft, + body: pullRequest.body, labels: pullRequest.labels, - }); - return await this.getPullRequest(prNumber); + files: [], + }; } - ); + return await this.getPullRequest(prNumber); + } /** * Fetch a pull request given the pull number * @param {number} number The pull request number * @returns {PullRequest} */ - getPullRequest = wrapAsync(async (number: number): Promise => { - const response = await this.octokit.pulls.get({ - owner: this.repository.owner, - repo: this.repository.repo, - pull_number: number, - }); - return { - headBranchName: response.data.head.ref, - baseBranchName: response.data.base.ref, - number: response.data.number, - title: response.data.title, - body: response.data.body || '', - files: [], - labels: response.data.labels - .map(label => label.name) - .filter(name => !!name) as string[], - }; - }); + async getPullRequest(number: number): Promise { + return await this.gitHubApi.getPullRequest(number); + } /** * Update a pull request's title and body. @@ -1208,75 +767,51 @@ export class GitHub { * @param {PullRequestOverflowHandler} options.pullRequestOverflowHandler Optional. * Handles extra large pull request body messages. */ - updatePullRequest = wrapAsync( - async ( - number: number, - releasePullRequest: ReleasePullRequest, - targetBranch: string, - options?: { - signoffUser?: string; - fork?: boolean; - pullRequestOverflowHandler?: PullRequestOverflowHandler; - } - ): Promise => { - // Update the files for the release if not already supplied - const changes = await this.buildChangeSet( - releasePullRequest.updates, - targetBranch + async updatePullRequest( + number: number, + releasePullRequest: ReleasePullRequest, + targetBranch: string, + options?: ScmUpdatePullRequestOptions + ): Promise { + const changes = await this.buildChangeSet( + releasePullRequest.updates, + targetBranch + ); + + let message = releasePullRequest.title.toString(); + if (options?.signoffUser) { + message = signoffCommitMessage(message, options.signoffUser); + } + const title = releasePullRequest.title.toString(); + const body = ( + options?.pullRequestOverflowHandler + ? await options.pullRequestOverflowHandler.handleOverflow( + releasePullRequest + ) + : releasePullRequest.body + ) + .toString() + .slice(0, MAX_ISSUE_BODY_SIZE); + const prNumber = await suggesterCreatePullRequest(this.octokit, changes, { + upstreamOwner: this.repository.owner, + upstreamRepo: this.repository.repo, + title, + branch: releasePullRequest.headRefName, + description: body, + primary: targetBranch, + force: true, + fork: options?.fork === false ? false : true, + message, + logger: this.logger, + draft: releasePullRequest.draft, + }); + if (prNumber !== number) { + this.logger.warn( + `updated code for ${prNumber}, but update requested for ${number}` ); - let message = releasePullRequest.title.toString(); - if (options?.signoffUser) { - message = signoffCommitMessage(message, options.signoffUser); - } - const title = releasePullRequest.title.toString(); - const body = ( - options?.pullRequestOverflowHandler - ? await options.pullRequestOverflowHandler.handleOverflow( - releasePullRequest - ) - : releasePullRequest.body - ) - .toString() - .slice(0, MAX_ISSUE_BODY_SIZE); - const prNumber = await createPullRequest(this.octokit, changes, { - upstreamOwner: this.repository.owner, - upstreamRepo: this.repository.repo, - title, - branch: releasePullRequest.headRefName, - description: body, - primary: targetBranch, - force: true, - fork: options?.fork === false ? false : true, - message, - logger: this.logger, - draft: releasePullRequest.draft, - }); - if (prNumber !== number) { - this.logger.warn( - `updated code for ${prNumber}, but update requested for ${number}` - ); - } - const response = await this.octokit.pulls.update({ - owner: this.repository.owner, - repo: this.repository.repo, - pull_number: number, - title: releasePullRequest.title.toString(), - body, - state: 'open', - }); - return { - headBranchName: response.data.head.ref, - baseBranchName: response.data.base.ref, - number: response.data.number, - title: response.data.title, - body: response.data.body || '', - files: [], - labels: response.data.labels - .map(label => label.name) - .filter(name => !!name) as string[], - }; } - ); + return this.gitHubApi.updatePullRequest(number, title, body); + } /** * Given a set of proposed updates, build a changeset to suggest. @@ -1388,68 +923,12 @@ export class GitHub { * @throws {DuplicateReleaseError} if the release tag already exists * @throws {GitHubAPIError} on other API errors */ - createRelease = wrapAsync( - async ( - release: Release, - options: ReleaseOptions = {} - ): Promise => { - if (options.forceTag) { - try { - await this.octokit.git.createRef({ - owner: this.repository.owner, - repo: this.repository.repo, - ref: `refs/tags/${release.tag.toString()}`, - sha: release.sha, - }); - } catch (err) { - // ignore if tag already exists - if ((err as RequestError).status === 422) { - this.logger.debug( - `Tag ${release.tag.toString()} already exists, skipping tag creation` - ); - } else { - throw err; - } - } - } - const resp = await this.octokit.repos.createRelease({ - name: release.name, - owner: this.repository.owner, - repo: this.repository.repo, - tag_name: release.tag.toString(), - body: release.notes, - draft: !!options.draft, - prerelease: !!options.prerelease, - target_commitish: release.sha, - }); - return { - id: resp.data.id, - name: resp.data.name || undefined, - tagName: resp.data.tag_name, - sha: resp.data.target_commitish, - notes: - resp.data.body_text || - resp.data.body || - resp.data.body_html || - undefined, - url: resp.data.html_url, - draft: resp.data.draft, - uploadUrl: resp.data.upload_url, - }; - }, - e => { - if (e instanceof RequestError) { - if ( - e.status === 422 && - GitHubAPIError.parseErrors(e).some(error => { - return error.code === 'already_exists'; - }) - ) { - throw new DuplicateReleaseError(e, 'tagName'); - } - } - } - ); + async createRelease( + release: Release, + options: ReleaseOptions = {} + ): Promise { + return await this.gitHubApi.createRelease(release, options); + } /** * Makes a comment on a issue/pull request. @@ -1458,20 +937,9 @@ export class GitHub { * @param {number} number - The issue or pull request number. * @throws {GitHubAPIError} on an API error */ - commentOnIssue = wrapAsync( - async (comment: string, number: number): Promise => { - this.logger.debug( - `adding comment to https://github.com/${this.repository.owner}/${this.repository.repo}/issues/${number}` - ); - const resp = await this.octokit.issues.createComment({ - owner: this.repository.owner, - repo: this.repository.repo, - issue_number: number, - body: comment, - }); - return resp.data.html_url; - } - ); + async commentOnIssue(comment: string, number: number): Promise { + return await this.gitHubApi.commentOnIssue(comment, number); + } /** * Removes labels from an issue/pull request. @@ -1479,24 +947,9 @@ export class GitHub { * @param {string[]} labels The labels to remove. * @param {number} number The issue/pull request number. */ - removeIssueLabels = wrapAsync( - async (labels: string[], number: number): Promise => { - if (labels.length === 0) { - return; - } - this.logger.debug(`removing labels: ${labels} from issue/pull ${number}`); - await Promise.all( - labels.map(label => - this.octokit.issues.removeLabel({ - owner: this.repository.owner, - repo: this.repository.repo, - issue_number: number, - name: label, - }) - ) - ); - } - ); + async removeIssueLabels(labels: string[], number: number): Promise { + return await this.gitHubApi.removeIssueLabels(labels, number); + } /** * Adds label to an issue/pull request. @@ -1504,20 +957,9 @@ export class GitHub { * @param {string[]} labels The labels to add. * @param {number} number The issue/pull request number. */ - addIssueLabels = wrapAsync( - async (labels: string[], number: number): Promise => { - if (labels.length === 0) { - return; - } - this.logger.debug(`adding labels: ${labels} from issue/pull ${number}`); - await this.octokit.issues.addLabels({ - owner: this.repository.owner, - repo: this.repository.repo, - issue_number: number, - labels, - }); - } - ); + async addIssueLabels(labels: string[], number: number): Promise { + return await this.gitHubApi.addIssueLabels(labels, number); + } /** * Generate release notes from GitHub at tag @@ -1530,14 +972,11 @@ export class GitHub { targetCommitish: string, previousTag?: string ): Promise { - const resp = await this.octokit.repos.generateReleaseNotes({ - owner: this.repository.owner, - repo: this.repository.repo, - tag_name: tagName, - previous_tag_name: previousTag, - target_commitish: targetCommitish, - }); - return resp.data.body; + return await this.gitHubApi.generateReleaseNotes( + tagName, + targetCommitish, + previousTag + ); } /** @@ -1556,151 +995,12 @@ export class GitHub { newBranchName: string, baseBranchName: string ): Promise { - // create or update new branch to match base branch - await this.forkBranch(newBranchName, baseBranchName); - - // use the single file upload API - const { - data: {content}, - } = await this.octokit.repos.createOrUpdateFileContents({ - owner: this.repository.owner, - repo: this.repository.repo, - path: filename, - // contents need to be base64 encoded - content: Buffer.from(contents, 'binary').toString('base64'), - message: 'Saving release notes', - branch: newBranchName, - }); - - if (!content?.html_url) { - throw new Error( - `Failed to write to file: ${filename} on branch: ${newBranchName}` - ); - } - - return content.html_url; - } - - /** - * Helper to fetch the SHA of a branch - * @param {string} branchName The name of the branch - * @return {string | undefined} Returns the SHA of the branch - * or undefined if it can't be found. - */ - private async getBranchSha(branchName: string): Promise { - this.logger.debug(`Looking up SHA for branch: ${branchName}`); - try { - const { - data: { - object: {sha}, - }, - } = await this.octokit.git.getRef({ - owner: this.repository.owner, - repo: this.repository.repo, - ref: `heads/${branchName}`, - }); - this.logger.debug(`SHA for branch: ${sha}`); - return sha; - } catch (e) { - if (e instanceof RequestError && e.status === 404) { - this.logger.debug(`Branch: ${branchName} does not exist`); - return undefined; - } - throw e; - } - } - - /** - * Helper to fork a branch from an existing branch. Uses `force` so - * it will overwrite the contents of `targetBranchName` to match - * the current contents of `baseBranchName`. - * - * @param {string} targetBranchName The name of the new forked branch - * @param {string} baseBranchName The base branch from which to fork. - * @returns {string} The branch SHA - * @throws {ConfigurationError} if the base branch cannot be found. - */ - private async forkBranch( - targetBranchName: string, - baseBranchName: string - ): Promise { - const baseBranchSha = await this.getBranchSha(baseBranchName); - if (!baseBranchSha) { - // this is highly unlikely to be thrown as we will have - // already attempted to read from the branch - throw new ConfigurationError( - `Unable to find base branch: ${baseBranchName}`, - 'core', - `${this.repository.owner}/${this.repository.repo}` - ); - } - // see if newBranchName exists - if (await this.getBranchSha(targetBranchName)) { - // branch already exists, update it to the match the base branch - const branchSha = await this.updateBranchSha( - targetBranchName, - baseBranchSha - ); - this.logger.debug( - `Updated ${targetBranchName} to match ${baseBranchName} at ${branchSha}` - ); - return branchSha; - } else { - // branch does not exist, create a new branch from the base branch - const branchSha = await this.createNewBranch( - targetBranchName, - baseBranchSha - ); - this.logger.debug( - `Forked ${targetBranchName} from ${baseBranchName} at ${branchSha}` - ); - return branchSha; - } - } - - /** - * Helper to create a new branch from a given SHA. - * @param {string} branchName The new branch name - * @param {string} branchSha The SHA of the branch - * @returns {string} The SHA of the new branch - */ - private async createNewBranch( - branchName: string, - branchSha: string - ): Promise { - this.logger.debug(`Creating new branch: ${branchName} at ${branchSha}`); - const { - data: { - object: {sha}, - }, - } = await this.octokit.git.createRef({ - owner: this.repository.owner, - repo: this.repository.repo, - ref: `refs/heads/${branchName}`, - sha: branchSha, - }); - this.logger.debug(`New branch: ${branchName} at ${sha}`); - return sha; - } - - private async updateBranchSha( - branchName: string, - branchSha: string - ): Promise { - this.logger.debug(`Updating branch ${branchName} to ${branchSha}`); - const { - data: { - object: {sha}, - }, - } = await this.octokit.git.updateRef({ - owner: this.repository.owner, - repo: this.repository.repo, - ref: `heads/${branchName}`, - sha: branchSha, - force: true, - }); - this.logger.debug(`Updated branch: ${branchName} to ${sha}`); - return sha; + return await this.gitHubApi.createFileOnNewBranch( + filename, + contents, + newBranchName, + baseBranchName + ); } } diff --git a/src/index.ts b/src/index.ts index d98b479cd..b87305679 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,7 +23,7 @@ export { } from './manifest'; export {ReleasePullRequest} from './release-pull-request'; export {PullRequest} from './pull-request'; -export {Commit, ConventionalCommit} from './commit'; +export {Commit, CommitAuthor, ConventionalCommit} from './commit'; export {Strategy} from './strategy'; export {BaseStrategyOptions, BuildUpdatesOptions} from './strategies/base'; export { @@ -63,5 +63,5 @@ export const configSchema = require('../../schemas/config.json'); export const manifestSchema = require('../../schemas/manifest.json'); // x-release-please-start-version -export const VERSION = '17.3.0'; +export const VERSION = '17.6.0'; // x-release-please-end diff --git a/src/local-github.ts b/src/local-github.ts new file mode 100644 index 000000000..0ee726d35 --- /dev/null +++ b/src/local-github.ts @@ -0,0 +1,996 @@ +// Copyright 2026 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 * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import * as child_process from 'child_process'; +import * as util from 'util'; +import * as readline from 'readline'; +import {createPullRequest as suggesterCreatePullRequest} from 'code-suggester'; + +const execFile = util.promisify(child_process.execFile); +const mkdtemp = fs.promises.mkdtemp; + +import { + Scm, + ScmRelease, + ScmTag, + ScmCommitIteratorOptions, + ScmReleaseIteratorOptions, + ScmTagIteratorOptions, + ScmCreatePullRequestOptions, + ScmUpdatePullRequestOptions, + ScmReleaseOptions, + ScmChangeSet, +} from './scm'; +import {FileNotFoundError} from './errors'; +import {Repository} from './repository'; +import {ROOT_PROJECT_PATH} from './manifest'; +import {Commit} from './commit'; +import {PullRequest} from './pull-request'; +import {ReleasePullRequest} from './release-pull-request'; +import {Update} from './update'; +import {Release} from './release'; +import { + GitHubFileContents, + DEFAULT_FILE_MODE, +} from '@google-automations/git-file-utils'; +import {mergeUpdates} from './updaters/composite'; +import { + GitHubApi, + MAX_ISSUE_BODY_SIZE, + GitHubCreateOptions, +} from './github-api'; +import {Logger} from 'code-suggester/build/src/types'; +import {logger as defaultLogger} from './util/logger'; + +export interface LocalGitHubCreateOptions extends GitHubCreateOptions { + cloneDepth?: number; + localRepoPath?: string; +} + +/** + * LocalGitHub implements the Scm interface using a local git clone + * where possible, and falling back to the GitHub API for other operations. + */ +export class LocalGitHub implements Scm { + readonly repository: Repository; + private cloneDir: string; + private gitHubApi: GitHubApi; + private logger: Logger; + + constructor( + repository: Repository, + gitHubApi: GitHubApi, + cloneDir: string, + options?: {logger?: Logger} + ) { + this.repository = repository; + this.gitHubApi = gitHubApi; + this.cloneDir = cloneDir; + this.logger = options?.logger ?? defaultLogger; + } + + static async create(options: LocalGitHubCreateOptions): Promise { + const gitHubApi = await GitHubApi.create(options); + const logger = options.logger ?? defaultLogger; + + let repoDir: string; + if (options.localRepoPath) { + repoDir = options.localRepoPath; + let isGitRepo = false; + try { + await execFile('git', ['rev-parse', '--is-inside-work-tree'], { + cwd: repoDir, + }); + isGitRepo = true; + } catch (err) { + isGitRepo = false; + } + + if (!isGitRepo) { + logger.info( + `Path ${repoDir} is not a git clone. Cloning repository...` + ); + const url = `https://github.com/${gitHubApi.repository.owner}/${gitHubApi.repository.repo}.git`; + const args = ['clone', '--', url, repoDir]; + if (options.cloneDepth) { + args.splice(1, 0, '--depth', options.cloneDepth.toString()); + } + logger.debug(`Executing: git ${args.join(' ')}`); + await execFile('git', args); + } else { + logger.info(`Using existing local repository at ${repoDir}...`); + } + + const branch = gitHubApi.repository.defaultBranch; + const fetchArgs = ['fetch', 'origin']; + if (options.cloneDepth) { + fetchArgs.push('--depth', options.cloneDepth.toString()); + } + logger.debug(`Executing: git ${fetchArgs.join(' ')}`); + await execFile('git', fetchArgs, {cwd: repoDir}); + + logger.debug(`Executing: git checkout ${branch}`); + await execFile('git', ['checkout', branch], {cwd: repoDir}); + + logger.debug(`Executing: git reset --hard origin/${branch}`); + await execFile('git', ['reset', '--hard', `origin/${branch}`], { + cwd: repoDir, + }); + } else { + const tempDir = await mkdtemp(path.join(os.tmpdir(), 'release-please-')); + logger.info(`Cloning repository to ${tempDir}...`); + const url = `https://github.com/${gitHubApi.repository.owner}/${gitHubApi.repository.repo}.git`; + + const args = ['clone', '--', url, tempDir]; + if (options.cloneDepth) { + args.splice(1, 0, '--depth', options.cloneDepth.toString()); + } + + logger.debug(`Executing: git ${args.join(' ')}`); + await execFile('git', args); + repoDir = tempDir; + } + + return new LocalGitHub(gitHubApi.repository, gitHubApi, repoDir, { + logger: options.logger, + }); + } + + /** + * Fetch the contents of a file from the configured branch + * + * @param {string} path The path to the file in the repository + * @returns {GitHubFileContents} + * @throws {GitHubAPIError} on other API errors + */ + async getFileContents(path: string): Promise { + return await this.getFileContentsOnBranch( + path, + this.repository.defaultBranch + ); + } + + private async execGitStream( + args: string[], + callback: (line: string) => void + ): Promise { + return new Promise((resolve, reject) => { + const child = child_process.spawn('git', args, {cwd: this.cloneDir}); + let stderr = ''; + child.stderr.on('data', data => { + stderr += data; + }); + + const rl = readline.createInterface({ + input: child.stdout, + crlfDelay: Infinity, + }); + + rl.on('line', callback); + + child.on('close', code => { + if (code !== 0) { + reject(new Error(`Command failed ${code}: ${stderr}`)); + } else { + resolve(); + } + }); + }); + } + + private async ensureRef(ref: string): Promise { + try { + await execFile('git', ['rev-parse', '--verify', ref], { + cwd: this.cloneDir, + }); + return ref; + } catch (err) { + this.logger.debug( + `Ref ${ref} not found locally, trying to fetch from origin...` + ); + try { + await execFile('git', ['fetch', 'origin', '--', ref], { + cwd: this.cloneDir, + }); + return 'FETCH_HEAD'; + } catch (fetchErr) { + throw err; // Throw original error if fetch fails + } + } + } + + /** + * Fetch the contents of a file + * + * @param {string} path The path to the file in the repository + * @param {string} branch The branch to fetch from + * @returns {GitHubFileContents} + * @throws {FileNotFoundError} if the file cannot be found + * @throws {GitHubAPIError} on other API errors + */ + async getFileContentsOnBranch( + path: string, + branch: string + ): Promise { + this.logger.debug( + `Fetching file contents for file ${path} on branch ${branch}` + ); + + const ref = await this.ensureRef(branch); + const lsTreeResult = await execFile('git', ['ls-tree', ref, path], { + cwd: this.cloneDir, + }); + + if (!lsTreeResult.stdout.trim()) { + throw new FileNotFoundError(path); + } + + const [info] = lsTreeResult.stdout.split('\t'); + const [mode, , sha] = info.split(' '); + + const {stdout} = await execFile('git', ['show', `${ref}:${path}`], { + cwd: this.cloneDir, + maxBuffer: 100 * 1024 * 1024, + }); + + return { + content: Buffer.from(stdout).toString('base64'), + parsedContent: stdout, + sha, + mode, + }; + } + + async getFileJson(path: string, branch: string): Promise { + const content = await this.getFileContentsOnBranch(path, branch); + return JSON.parse(content.parsedContent); + } + + /** + * Returns a list of paths to all files with a given name. + * + * If a prefix is specified, only return paths that match + * the provided prefix. + * + * @param filename The name of the file to find + * @param prefix Optional path prefix used to filter results + * @returns {string[]} List of file paths + * @throws {GitHubAPIError} on an API error + */ + async findFilesByFilename( + filename: string, + prefix?: string + ): Promise { + return this.findFilesByFilenameAndRef( + filename, + this.repository.defaultBranch, + prefix + ); + } + + /** + * Returns a list of paths to all files with a given name. + * + * If a prefix is specified, only return paths that match + * the provided prefix. + * + * @param filename The name of the file to find + * @param ref Git reference to search files in + * @param prefix Optional path prefix used to filter results + * @throws {GitHubAPIError} on an API error + */ + async findFilesByFilenameAndRef( + filename: string, + ref: string, + prefix?: string + ): Promise { + this.logger.debug( + `Looking in local clone for file ${filename} with ref ${ref} and prefix '${prefix}'` + ); + + let normalizedPrefix = prefix + ? prefix.replace(/^[/\\]/, '').replace(/[/\\]$/, '') + : ''; + if (normalizedPrefix === ROOT_PROJECT_PATH) { + normalizedPrefix = ''; + } + + const treePath = normalizedPrefix ? `${normalizedPrefix}/` : '.'; + + const resolvedRef = await this.ensureRef(ref); + this.logger.trace( + `Executing stream: git ls-tree -r --name-only ${resolvedRef} ${treePath}` + ); + const matchedPaths: string[] = []; + await this.execGitStream( + ['ls-tree', '-r', '--name-only', resolvedRef, treePath], + line => { + const trimmed = line.trim(); + if (trimmed && path.posix.basename(trimmed) === filename) { + matchedPaths.push(trimmed); + } + } + ); + + if (normalizedPrefix) { + return matchedPaths + .map(p => { + if (p === normalizedPrefix) return ''; + if (p.startsWith(`${normalizedPrefix}/`)) { + return p.slice(normalizedPrefix.length + 1); + } + return p; + }) + .filter(p => p !== ''); + } + return matchedPaths; + } + + /** + * Returns a list of paths to all files matching a glob pattern. + * + * If a prefix is specified, only return paths that match + * the provided prefix. + * + * @param glob The glob to match + * @param prefix Optional path prefix used to filter results + * @returns {string[]} List of file paths + * @throws {GitHubAPIError} on an API error + */ + async findFilesByGlob(glob: string, prefix?: string): Promise { + return this.findFilesByGlobAndRef( + glob, + this.repository.defaultBranch, + prefix + ); + } + + /** + * Returns a list of paths to all files matching a glob pattern. + * + * If a prefix is specified, only return paths that match + * the provided prefix. + * + * @param glob The glob to match + * @param ref Git reference to search files in + * @param prefix Optional path prefix used to filter results + * @throws {GitHubAPIError} on an API error + */ + async findFilesByGlobAndRef( + glob: string, + ref: string, + prefix?: string + ): Promise { + this.logger.debug( + `Looking in local clone for file matching glob ${glob} with ref ${ref} and prefix '${prefix}'` + ); + + let normalizedPrefix = prefix + ? prefix.replace(/^[/\\]/, '').replace(/[/\\]$/, '') + : ''; + if (normalizedPrefix === ROOT_PROJECT_PATH) { + normalizedPrefix = ''; + } + + const treePath = normalizedPrefix ? `${normalizedPrefix}/` : '.'; + + const resolvedRef = await this.ensureRef(ref); + const files: string[] = []; + const dirs = new Set(); + await this.execGitStream( + ['ls-tree', '-r', '--name-only', resolvedRef, treePath], + line => { + const trimmed = line.trim(); + if (trimmed) { + files.push(trimmed); + let dir = path.posix.dirname(trimmed); + while (dir !== '.' && dir !== '/') { + dirs.add(dir); + dir = path.posix.dirname(dir); + } + } + } + ); + + const allPaths = [...files, ...dirs]; + + // Make paths relative to prefix if provided + let relativePaths = allPaths; + if (normalizedPrefix) { + relativePaths = allPaths + .map(p => { + if (p === normalizedPrefix) return ''; + if (p.startsWith(`${normalizedPrefix}/`)) { + return p.slice(normalizedPrefix.length + 1); + } + return p; + }) + .filter(p => p !== ''); + } + + const regex = globToRegex(glob); + return relativePaths.filter(p => regex.test(p)); + } + + /** + * Returns a list of paths to all files with a given file + * extension. + * + * If a prefix is specified, only return paths that match + * the provided prefix. + * + * @param extension The file extension used to filter results. + * Example: `js`, `java` + * @param prefix Optional path prefix used to filter results + * @returns {string[]} List of file paths + * @throws {GitHubAPIError} on an API error + */ + async findFilesByExtension( + extension: string, + prefix?: string + ): Promise { + return this.findFilesByExtensionAndRef( + extension, + this.repository.defaultBranch, + prefix + ); + } + + /** + * Returns a list of paths to all files with a given file + * extension. + * + * If a prefix is specified, only return paths that match + * the provided prefix. + * + * @param extension The file extension used to filter results. + * Example: `js`, `java` + * @param ref Git reference to search files in + * @param prefix Optional path prefix used to filter results + * @returns {string[]} List of file paths + * @throws {GitHubAPIError} on an API error + */ + async findFilesByExtensionAndRef( + extension: string, + ref: string, + prefix?: string + ): Promise { + this.logger.debug( + `Looking in local clone for file matching extension ${extension} with ref ${ref} and prefix '${prefix}'` + ); + + let normalizedPrefix = prefix + ? prefix.replace(/^[/\\]/, '').replace(/[/\\]$/, '') + : ''; + if (normalizedPrefix === ROOT_PROJECT_PATH) { + normalizedPrefix = ''; + } + + const treePath = normalizedPrefix ? `${normalizedPrefix}/` : '.'; + + const resolvedRef = await this.ensureRef(ref); + const matchedPaths: string[] = []; + await this.execGitStream( + ['ls-tree', '-r', '--name-only', resolvedRef, treePath], + line => { + const trimmed = line.trim(); + if (trimmed && trimmed.endsWith(`.${extension}`)) { + matchedPaths.push(trimmed); + } + } + ); + + if (normalizedPrefix) { + return matchedPaths + .map(p => { + if (p === normalizedPrefix) return ''; + if (p.startsWith(`${normalizedPrefix}/`)) { + return p.slice(normalizedPrefix.length + 1); + } + return p; + }) + .filter(p => p !== ''); + } + return matchedPaths; + } + + /** + * Returns the list of commits to the default branch after the provided filter + * query has been satified. + * + * @param {string} targetBranch Target branch of commit + * @param {CommitFilter} filter Callback function that returns whether a + * commit/pull request matches certain criteria + * @param {CommitIteratorOptions} options Query options + * @param {number} options.maxResults Limit the number of results searched. + * Defaults to unlimited. + * @param {boolean} options.backfillFiles If set, use the REST API for + * fetching the list of touched files in this commit. Defaults to `false`. + * @returns {Commit[]} List of commits to current branch + * @throws {GitHubAPIError} on an API error + */ + async commitsSince( + targetBranch: string, + filter: (commit: Commit) => boolean, + options?: ScmCommitIteratorOptions + ): Promise { + const commits: Commit[] = []; + const generator = this.mergeCommitIterator(targetBranch, options); + for await (const commit of generator) { + if (filter(commit)) { + break; + } + commits.push(commit); + } + return commits; + } + + /** + * Iterate through commit history with a max number of results scanned. + * + * @param {string} targetBranch target branch of commit + * @param {CommitIteratorOptions} options Query options + * @param {number} options.maxResults Limit the number of results searched. + * Defaults to unlimited. + * @param {boolean} options.backfillFiles If set, use the REST API for + * fetching the list of touched files in this commit. Defaults to `false`. + * @yields {Commit} + * @throws {GitHubAPIError} on an API error + */ + async *mergeCommitIterator( + targetBranch: string, + options?: ScmCommitIteratorOptions + ): AsyncGenerator { + this.logger.debug( + `Looking in local clone for commits on branch ${targetBranch}` + ); + + const backfillFiles = options?.backfillFiles ?? true; + + let format = '---COMMIT_START---%n%H%n%B'; + if (backfillFiles) { + format += '%n---FILES_START---'; + } + + const ref = await this.ensureRef(targetBranch); + const args = ['log', ref, `--pretty=format:${format}`]; + if (backfillFiles) { + args.push('--name-only'); + } + if (options?.maxResults) { + args.push('-n', options.maxResults.toString()); + } + + const {stdout} = await execFile('git', args, { + cwd: this.cloneDir, + maxBuffer: 100 * 1024 * 1024, + }); + + const blocks = stdout.split('---COMMIT_START---\n'); + for (const block of blocks) { + if (!block.trim()) continue; + + let commitInfo = block; + let files: string[] = []; + + if (backfillFiles) { + const parts = block.split('\n---FILES_START---\n'); + commitInfo = parts[0]; + if (parts[1]) { + files = parts[1] + .split('\n') + .map((f: string) => f.trim()) + .filter((f: string) => f); + } + } + + const lines = commitInfo.split('\n'); + const sha = lines[0].trim(); + const message = lines.slice(1).join('\n').trim(); + + if (!sha) continue; + + const commit: Commit = { + sha, + message, + files: backfillFiles ? files : undefined, + }; + + const subject = lines[1] ? lines[1].trim() : ''; + let prNumber: number | undefined; + let headBranchName = ''; + + const squashMatch = subject.match(/\s\(#(\d+)\)$/); + const mergeMatch = subject.match(/^Merge pull request #(\d+) from (.*)$/); + + if (squashMatch) { + prNumber = parseInt(squashMatch[1], 10); + } else if (mergeMatch) { + prNumber = parseInt(mergeMatch[1], 10); + headBranchName = mergeMatch[2].trim(); + } + + if (prNumber) { + commit.pullRequest = { + sha, + number: prNumber, + title: subject.replace(/\s\(#(\d+)\)$/, ''), + body: message, + labels: [], + files: backfillFiles ? files : [], + baseBranchName: targetBranch, + headBranchName, + }; + } + + yield commit; + } + } + + /** + * Iterate through merged pull requests with a max number of results scanned. + * + * @param {string} targetBranch The base branch of the pull request + * @param {string} status The status of the pull request + * @param {number} maxResults Limit the number of results searched. Defaults to + * unlimited. + * @param {boolean} includeFiles Whether to fetch the list of files included in + * the pull request. Defaults to `true`. + * @yields {PullRequest} + * @throws {GitHubAPIError} on an API error + */ + async *pullRequestIterator( + targetBranch: string, + status?: 'OPEN' | 'CLOSED' | 'MERGED', + maxResults?: number, + includeFiles?: boolean + ): AsyncGenerator { + yield* this.gitHubApi.pullRequestIterator( + targetBranch, + status, + maxResults, + includeFiles + ); + } + + /** + * Iterate through releases with a max number of results scanned. + * + * @param {ReleaseIteratorOptions} options Query options + * @param {number} options.maxResults Limit the number of results searched. + * Defaults to unlimited. + * @yields {GitHubRelease} + * @throws {GitHubAPIError} on an API error + */ + async *releaseIterator( + options?: ScmReleaseIteratorOptions + ): AsyncGenerator { + yield* this.gitHubApi.releaseIterator(options); + } + + /** + * Iterate through tags with a max number of results scanned. + * + * @param {TagIteratorOptions} options Query options + * @param {number} options.maxResults Limit the number of results searched. + * Defaults to unlimited. + * @yields {GitHubTag} + * @throws {GitHubAPIError} on an API error + */ + async *tagIterator( + options?: ScmTagIteratorOptions + ): AsyncGenerator { + const {stdout} = await execFile( + 'git', + [ + 'for-each-ref', + '--sort=-version:refname', + 'refs/tags', + '--format=%(refname:short)|%(objectname)|%(*objectname)', + ], + {cwd: this.cloneDir} + ); + + const maxResults = options?.maxResults || Number.MAX_SAFE_INTEGER; + let results = 0; + + for (const line of stdout.split('\n')) { + if (!line) continue; + const [name, objectSha, commitSha] = line.split('|'); + const sha = commitSha || objectSha; + if (sha) { + yield {name, sha}; + results++; + if (results >= maxResults) break; + } + } + } + + /** + * Open a pull request + * + * @param {PullRequest} pullRequest Pull request data to update + * @param {string} targetBranch The base branch of the pull request + * @param {string} message The commit message for the commit + * @param {Update[]} updates The files to update + * @param {CreatePullRequestOptions} options The pull request options + * @throws {GitHubAPIError} on an API error + */ + async createPullRequest( + pullRequest: PullRequest, + targetBranch: string, + message: string, + updates: Update[], + options?: ScmCreatePullRequestOptions + ): Promise { + const changes = await this.buildChangeSet(updates, targetBranch); + const prNumber = await suggesterCreatePullRequest( + this.gitHubApi.octokit, + changes, + { + upstreamOwner: this.repository.owner, + upstreamRepo: this.repository.repo, + title: pullRequest.title, + branch: pullRequest.headBranchName, + description: pullRequest.body, + primary: targetBranch, + force: true, + fork: !!options?.fork, + message, + logger: this.logger, + draft: !!options?.draft, + labels: pullRequest.labels, + } + ); + if (prNumber === 0) { + this.logger.warn( + 'no code changes detected, skipping pull request creation' + ); + return { + headBranchName: pullRequest.headBranchName, + baseBranchName: targetBranch, + number: 0, + title: pullRequest.title, + body: pullRequest.body, + labels: pullRequest.labels, + files: [], + }; + } + return await this.getPullRequest(prNumber); + } + + /** + * Update a pull request's title and body. + * @param {number} number The pull request number + * @param {ReleasePullRequest} releasePullRequest Pull request data to update + * @param {string} targetBranch The target branch of the pull request + * @param {string} options.signoffUser Optional. Commit signoff message + * @param {boolean} options.fork Optional. Whether to open the pull request from + * a fork or not. Defaults to `false` + * @param {PullRequestOverflowHandler} options.pullRequestOverflowHandler Optional. + * Handles extra large pull request body messages. + */ + async updatePullRequest( + number: number, + pullRequest: ReleasePullRequest, + targetBranch: string, + options?: ScmUpdatePullRequestOptions + ): Promise { + const changes = await this.buildChangeSet( + pullRequest.updates, + targetBranch + ); + const message = pullRequest.title.toString(); + const title = pullRequest.title.toString(); + const body = ( + options?.pullRequestOverflowHandler + ? await options.pullRequestOverflowHandler.handleOverflow(pullRequest) + : pullRequest.body + ) + .toString() + .slice(0, MAX_ISSUE_BODY_SIZE); + + const prNumber = await suggesterCreatePullRequest( + this.gitHubApi.octokit, + changes, + { + upstreamOwner: this.repository.owner, + upstreamRepo: this.repository.repo, + title, + branch: pullRequest.headRefName, + description: body, + primary: targetBranch, + force: true, + fork: options?.fork === false ? false : true, + message, + logger: this.logger, + draft: pullRequest.draft, + } + ); + if (prNumber !== number) { + this.logger.warn( + `updated code for ${prNumber}, but update requested for ${number}` + ); + } + return this.gitHubApi.updatePullRequest(number, title, body); + } + + async getPullRequest(number: number): Promise { + return await this.gitHubApi.getPullRequest(number); + } + + /** + * Create a GitHub release + * + * @param {Release} release Release parameters + * @param {ReleaseOptions} options Release option parameters + * @throws {DuplicateReleaseError} if the release tag already exists + * @throws {GitHubAPIError} on other API errors + */ + async createRelease( + release: Release, + options?: ScmReleaseOptions + ): Promise { + return await this.gitHubApi.createRelease(release, options); + } + + /** + * Makes a comment on a issue/pull request. + * + * @param {string} comment - The body of the comment to post. + * @param {number} number - The issue or pull request number. + * @throws {GitHubAPIError} on an API error + */ + async commentOnIssue(comment: string, number: number): Promise { + return await this.gitHubApi.commentOnIssue(comment, number); + } + + /** + * Removes labels from an issue/pull request. + * + * @param {string[]} labels The labels to remove. + * @param {number} number The issue/pull request number. + */ + async removeIssueLabels(labels: string[], number: number): Promise { + return await this.gitHubApi.removeIssueLabels(labels, number); + } + + /** + * Adds label to an issue/pull request. + * + * @param {string[]} labels The labels to add. + * @param {number} number The issue/pull request number. + */ + async addIssueLabels(labels: string[], number: number): Promise { + return await this.gitHubApi.addIssueLabels(labels, number); + } + + /** + * Generate release notes from GitHub at tag + * @param {string} tagName Name of new release tag + * @param {string} targetCommitish Target commitish for new tag + * @param {string} previousTag Optional. Name of previous tag to analyze commits since + */ + async generateReleaseNotes( + tagName: string, + targetCommitish: string, + previousTag?: string + ): Promise { + return await this.gitHubApi.generateReleaseNotes( + tagName, + targetCommitish, + previousTag + ); + } + + /** + * Create a single file on a new branch based on an existing + * branch. This will force-push to that branch. + * @param {string} filename Filename with path in the repository + * @param {string} contents Contents of the file + * @param {string} newBranchName Name of the new branch + * @param {string} baseBranchName Name of the base branch (where + * new branch is forked from) + * @returns {string} HTML URL of the new file + */ + async createFileOnNewBranch( + filename: string, + contents: string, + newBranchName: string, + baseBranchName: string + ): Promise { + return await this.gitHubApi.createFileOnNewBranch( + filename, + contents, + newBranchName, + baseBranchName + ); + } + + /** + * Given a set of proposed updates, build a changeset to suggest. + * + * @param {Update[]} updates The proposed updates + * @param {string} defaultBranch The target branch + * @return {Changes} The changeset to suggest. + * @throws {GitHubAPIError} on an API error + */ + async buildChangeSet( + updates: Update[], + defaultBranch: string + ): Promise { + const mergedUpdates = mergeUpdates(updates); + const changes = new Map(); + for (const update of mergedUpdates) { + let content: GitHubFileContents | undefined; + try { + content = await this.getFileContentsOnBranch( + update.path, + defaultBranch + ); + } catch (err) { + if (!(err instanceof FileNotFoundError)) throw err; + if (!update.createIfMissing) { + console.warn(`file ${update.path} did not exist`); + continue; + } + } + const newContents = update.updater.updateContent( + content ? content.parsedContent : undefined + ); + if (newContents) { + changes.set(update.path, { + content: newContents, + originalContent: content ? content.parsedContent : null, + mode: content ? content.mode : DEFAULT_FILE_MODE, + }); + } + } + return changes; + } +} + +function globToRegex(glob: string): RegExp { + let reg = ''; + let i = 0; + while (i < glob.length) { + const c = glob[i]; + if (c === '*') { + if (i + 1 < glob.length && glob[i + 1] === '*') { + if (i + 2 < glob.length && glob[i + 2] === '/') { + reg += '(?:.*\\/)?'; + i += 2; + } else { + reg += '.*'; + i++; + } + } else { + reg += '[^/]*'; + } + } else if (c === '?') { + reg += '[^/]'; + } else if ( + ['.', '+', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\'].includes(c) + ) { + reg += '\\' + c; + } else { + reg += c; + } + i++; + } + return new RegExp(`^${reg}$`); +} diff --git a/src/manifest.ts b/src/manifest.ts index f0b46a05b..8ea11ffed 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -13,7 +13,7 @@ // limitations under the License. import {ChangelogSection} from './changelog-notes'; -import {GitHub, GitHubRelease, GitHubTag} from './github'; +import {Scm, ScmRelease, ScmTag} from './scm'; import {Version, VersionsMap} from './version'; import {Commit, parseConventionalCommits} from './commit'; import {PullRequest} from './pull-request'; @@ -132,6 +132,7 @@ export interface ReleaserConfig { changelogPath?: string; changelogType?: ChangelogNotesType; changelogHost?: string; + includeCommitAuthors?: boolean; // Ruby-only versionFile?: string; @@ -179,6 +180,7 @@ interface ReleaserConfigJson { 'include-v-in-release-name'?: boolean; 'changelog-type'?: ChangelogNotesType; 'changelog-host'?: string; + 'include-commit-authors'?: boolean; 'pull-request-title-pattern'?: string; 'pull-request-header'?: string; 'pull-request-footer'?: string; @@ -294,7 +296,7 @@ const DEFAULT_COMMIT_BATCH_SIZE = 10; export const MANIFEST_PULL_REQUEST_TITLE_PATTERN = 'chore: release ${branch}'; -export interface CreatedRelease extends GitHubRelease { +export interface CreatedRelease extends ScmRelease { id: number; path: string; version: string; @@ -306,7 +308,7 @@ export interface CreatedRelease extends GitHubRelease { export class Manifest { private repository: Repository; - private github: GitHub; + private github: Scm; readonly repositoryConfig: RepositoryConfig; readonly releasedVersions: ReleasedVersions; private targetBranch: string; @@ -365,7 +367,7 @@ export class Manifest { * pull request. Defaults to `[autorelease: tagged]` */ constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, releasedVersions: ReleasedVersions, @@ -431,7 +433,7 @@ export class Manifest { * @returns {Manifest} */ static async fromManifest( - github: GitHub, + github: Scm, targetBranch: string, configFile: string = DEFAULT_RELEASE_PLEASE_CONFIG, manifestFile: string = DEFAULT_RELEASE_PLEASE_MANIFEST, @@ -487,7 +489,7 @@ export class Manifest { * @returns {Manifest} */ static async fromConfig( - github: GitHub, + github: Scm, targetBranch: string, config: ReleaserConfig, manifestOptions?: ManifestOptions, @@ -904,8 +906,8 @@ export class Manifest { return releasesByPath; } - private async getAllTags(): Promise> { - const allTags: Record = {}; + private async getAllTags(): Promise> { + const allTags: Record = {}; for await (const tag of this.github.tagIterator()) { allTags[tag.name] = tag; } @@ -1395,6 +1397,7 @@ function extractReleaserConfig( changelogSections: config['changelog-sections'], changelogPath: config['changelog-path'], changelogHost: config['changelog-host'], + includeCommitAuthors: config['include-commit-authors'], releaseAs: config['release-as'], skipGithubRelease: config['skip-github-release'], skipChangelog: config['skip-changelog'], @@ -1437,7 +1440,7 @@ function extractReleaserConfig( * @param {string} releaseAs Optional. Override release-as and use the given version */ async function parseConfig( - github: GitHub, + github: Scm, configFile: string, branch: string, onlyPath?: string, @@ -1491,7 +1494,7 @@ async function parseConfig( * @throws {ConfigurationError} if missing the manifest config file */ async function fetchManifestConfig( - github: GitHub, + github: Scm, configFile: string, branch: string ): Promise { @@ -1524,7 +1527,7 @@ async function fetchManifestConfig( * @returns {Record} */ async function parseReleasedVersions( - github: GitHub, + github: Scm, manifestFile: string, branch: string ): Promise { @@ -1549,7 +1552,7 @@ async function parseReleasedVersions( * @throws {ConfigurationError} if missing the manifest config file */ async function fetchReleasedVersions( - github: GitHub, + github: Scm, manifestFile: string, branch: string ): Promise> { @@ -1592,7 +1595,7 @@ function isPublishedVersion(strategy: Strategy, version: Version): boolean { * @param {string} prefix Limit the release to a specific component. */ async function latestReleaseVersion( - github: GitHub, + github: Scm, targetBranch: string, releaseFilter: (version: Version) => boolean, config: ReleaserConfig, @@ -1752,6 +1755,8 @@ function mergeReleaserConfig( changelogPath: pathConfig.changelogPath ?? defaultConfig.changelogPath, changelogHost: pathConfig.changelogHost ?? defaultConfig.changelogHost, changelogType: pathConfig.changelogType ?? defaultConfig.changelogType, + includeCommitAuthors: + pathConfig.includeCommitAuthors ?? defaultConfig.includeCommitAuthors, releaseAs: pathConfig.releaseAs ?? defaultConfig.releaseAs, skipGithubRelease: pathConfig.skipGithubRelease ?? defaultConfig.skipGithubRelease, diff --git a/src/plugin.ts b/src/plugin.ts index f7973626f..74048846b 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GitHub} from './github'; +import {Scm} from './scm'; import {CandidateReleasePullRequest, RepositoryConfig} from './manifest'; import {Strategy} from './strategy'; import {Commit, ConventionalCommit} from './commit'; @@ -26,12 +26,12 @@ import {logger as defaultLogger, Logger} from './util/logger'; * or update existing files. */ export abstract class ManifestPlugin { - readonly github: GitHub; + readonly github: Scm; readonly targetBranch: string; readonly repositoryConfig: RepositoryConfig; protected logger: Logger; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, logger: Logger = defaultLogger diff --git a/src/plugins/group-priority.ts b/src/plugins/group-priority.ts index e0677fa47..00e0dac7b 100644 --- a/src/plugins/group-priority.ts +++ b/src/plugins/group-priority.ts @@ -13,7 +13,7 @@ // limitations under the License. import {ManifestPlugin} from '../plugin'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {RepositoryConfig, CandidateReleasePullRequest} from '../manifest'; /** @@ -33,7 +33,7 @@ export class GroupPriority extends ManifestPlugin { * @param {string[]} groups List of group names ordered with highest priority first */ constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, groups: string[] diff --git a/src/plugins/linked-versions.ts b/src/plugins/linked-versions.ts index f3d059bf7..96edbc0a6 100644 --- a/src/plugins/linked-versions.ts +++ b/src/plugins/linked-versions.ts @@ -14,7 +14,7 @@ import {ManifestPlugin} from '../plugin'; import {RepositoryConfig, CandidateReleasePullRequest} from '../manifest'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {Logger} from '../util/logger'; import {Strategy} from '../strategy'; import {Commit, parseConventionalCommits} from '../commit'; @@ -41,7 +41,7 @@ export class LinkedVersions extends ManifestPlugin { readonly merge: boolean; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, groupName: string, diff --git a/src/plugins/maven-workspace.ts b/src/plugins/maven-workspace.ts index 0586ec2d6..f08cad3ad 100644 --- a/src/plugins/maven-workspace.ts +++ b/src/plugins/maven-workspace.ts @@ -33,7 +33,7 @@ import {PullRequestTitle} from '../util/pull-request-title'; import {PullRequestBody} from '../util/pull-request-body'; import {BranchName} from '../util/branch-name'; import {logger as defaultLogger, Logger} from '../util/logger'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {JavaSnapshot} from '../versioning-strategies/java-snapshot'; import {AlwaysBumpPatch} from '../versioning-strategies/always-bump-patch'; import {ConventionalCommit} from '../commit'; @@ -79,7 +79,7 @@ const XPATH_PROJECT_DEPENDENCY_MANAGEMENT_DEPENDENCIES = export class MavenWorkspace extends WorkspacePlugin { readonly considerAllArtifacts: boolean; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, options: MavenWorkspacePluginOptions = {} diff --git a/src/plugins/merge.ts b/src/plugins/merge.ts index 811ac3f72..18c476ac9 100644 --- a/src/plugins/merge.ts +++ b/src/plugins/merge.ts @@ -24,7 +24,7 @@ import {PullRequestBody, ReleaseData} from '../util/pull-request-body'; import {BranchName} from '../util/branch-name'; import {Update} from '../update'; import {mergeUpdates} from '../updaters/composite'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; export interface MergeOptions { pullRequestTitlePattern?: string; @@ -50,7 +50,7 @@ export class Merge extends ManifestPlugin { private forceMerge: boolean; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, options: MergeOptions = {} @@ -129,7 +129,7 @@ export class Merge extends ManifestPlugin { candidates.map(candidate => candidate.config.releaseType) ); const releaseType = - releaseTypes.size === 1 ? releaseTypes.values().next().value : 'simple'; + releaseTypes.size === 1 ? releaseTypes.values().next().value! : 'simple'; return [ { path: ROOT_PROJECT_PATH, diff --git a/src/plugins/node-workspace.ts b/src/plugins/node-workspace.ts index 7424ab64d..eec1a2e71 100644 --- a/src/plugins/node-workspace.ts +++ b/src/plugins/node-workspace.ts @@ -12,7 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {CandidateReleasePullRequest, RepositoryConfig} from '../manifest'; import {PackageLockJson} from '../updaters/node/package-lock-json'; import {Version, VersionsMap} from '../version'; @@ -77,7 +77,7 @@ export class NodeWorkspace extends WorkspacePlugin { readonly updatePeerDependencies: boolean; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, options: NodeWorkspaceOptions = {} diff --git a/src/plugins/sentence-case.ts b/src/plugins/sentence-case.ts index f257706cc..80f70fb39 100644 --- a/src/plugins/sentence-case.ts +++ b/src/plugins/sentence-case.ts @@ -13,7 +13,7 @@ // limitations under the License. import {ManifestPlugin} from '../plugin'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {RepositoryConfig} from '../manifest'; import {ConventionalCommit} from '../commit'; @@ -27,7 +27,7 @@ const SPECIAL_WORDS = ['gRPC', 'npm']; export class SentenceCase extends ManifestPlugin { specialWords: Set; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, specialWords?: Array diff --git a/src/plugins/workspace.ts b/src/plugins/workspace.ts index 2386377dc..5e58b73b7 100644 --- a/src/plugins/workspace.ts +++ b/src/plugins/workspace.ts @@ -22,7 +22,7 @@ import { import {logger as defaultLogger, Logger} from '../util/logger'; import {VersionsMap, Version} from '../version'; import {Merge} from './merge'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {ReleasePleaseManifest} from '../updaters/release-please-manifest'; export type DependencyGraph = Map>; @@ -59,7 +59,7 @@ export abstract class WorkspacePlugin extends ManifestPlugin { private manifestPath: string; private merge: boolean; constructor( - github: GitHub, + github: Scm, targetBranch: string, repositoryConfig: RepositoryConfig, options: WorkspacePluginOptions = {} diff --git a/src/scm.ts b/src/scm.ts new file mode 100644 index 000000000..73e114460 --- /dev/null +++ b/src/scm.ts @@ -0,0 +1,169 @@ +// Copyright 2026 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 {Repository} from './repository'; +import {Commit} from './commit'; +import {PullRequest} from './pull-request'; +import {ReleasePullRequest} from './release-pull-request'; +import {Update} from './update'; +import {Release} from './release'; +import {GitHubFileContents} from '@google-automations/git-file-utils'; +import {PullRequestOverflowHandler} from './util/pull-request-overflow-handler'; + +export interface ScmFileDiff { + readonly mode: '100644' | '100755' | '040000' | '160000' | '120000'; + readonly content: string | null; + readonly originalContent: string | null; +} + +export type ScmChangeSet = Map; + +export interface ScmCommitIteratorOptions { + maxResults?: number; + backfillFiles?: boolean; + batchSize?: number; +} + +export interface ScmReleaseIteratorOptions { + maxResults?: number; +} + +export interface ScmTagIteratorOptions { + maxResults?: number; +} + +export interface ScmCreatePullRequestOptions { + fork?: boolean; + draft?: boolean; +} + +export interface ScmUpdatePullRequestOptions { + signoffUser?: string; + fork?: boolean; + pullRequestOverflowHandler?: PullRequestOverflowHandler; +} + +export interface ScmReleaseOptions { + draft?: boolean; + prerelease?: boolean; + forceTag?: boolean; +} + +export interface ScmRelease { + id: number; + name?: string; + tagName: string; + sha: string; + notes?: string; + url: string; + draft?: boolean; + uploadUrl?: string; +} + +export interface ScmTag { + name: string; + sha: string; +} + +export interface Scm { + readonly repository: Repository; + + getFileContents(path: string): Promise; + getFileContentsOnBranch( + path: string, + branch: string + ): Promise; + getFileJson(path: string, branch: string): Promise; + + findFilesByFilename(filename: string, prefix?: string): Promise; + findFilesByFilenameAndRef( + filename: string, + ref: string, + prefix?: string + ): Promise; + findFilesByGlob(glob: string, prefix?: string): Promise; + findFilesByGlobAndRef( + glob: string, + ref: string, + prefix?: string + ): Promise; + findFilesByExtension(extension: string, prefix?: string): Promise; + findFilesByExtensionAndRef( + extension: string, + ref: string, + prefix?: string + ): Promise; + + commitsSince( + targetBranch: string, + filter: (commit: Commit) => boolean, + options?: ScmCommitIteratorOptions + ): Promise; + mergeCommitIterator( + targetBranch: string, + options?: ScmCommitIteratorOptions + ): AsyncGenerator; + pullRequestIterator( + targetBranch: string, + status?: 'OPEN' | 'CLOSED' | 'MERGED', + maxResults?: number, + includeFiles?: boolean + ): AsyncGenerator; + releaseIterator( + options?: ScmReleaseIteratorOptions + ): AsyncGenerator; + tagIterator( + options?: ScmTagIteratorOptions + ): AsyncGenerator; + + createPullRequest( + pullRequest: PullRequest, + targetBranch: string, + message: string, + updates: Update[], + options?: ScmCreatePullRequestOptions + ): Promise; + updatePullRequest( + number: number, + pullRequest: ReleasePullRequest, + targetBranch: string, + options?: ScmUpdatePullRequestOptions + ): Promise; + getPullRequest(number: number): Promise; + + createRelease( + release: Release, + options?: ScmReleaseOptions + ): Promise; + commentOnIssue(comment: string, number: number): Promise; + removeIssueLabels(labels: string[], number: number): Promise; + addIssueLabels(labels: string[], number: number): Promise; + + generateReleaseNotes( + tagName: string, + targetCommitish: string, + previousTag?: string + ): Promise; + createFileOnNewBranch( + filename: string, + contents: string, + newBranchName: string, + baseBranchName: string + ): Promise; + + buildChangeSet( + updates: Update[], + defaultBranch: string + ): Promise; +} diff --git a/src/strategies/base.ts b/src/strategies/base.ts index 952c666cc..a5437db08 100644 --- a/src/strategies/base.ts +++ b/src/strategies/base.ts @@ -13,7 +13,7 @@ // limitations under the License. import {Strategy, BuildReleaseOptions, BumpReleaseOptions} from '../strategy'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {VersioningStrategy} from '../versioning-strategy'; import {Repository} from '../repository'; import {ChangelogNotes, ChangelogSection} from '../changelog-notes'; @@ -57,7 +57,7 @@ export interface BaseStrategyOptions { path?: string; bumpMinorPreMajor?: boolean; bumpPatchForMinorPreMajor?: boolean; - github: GitHub; + github: Scm; component?: string; packageName?: string; versioningStrategy?: VersioningStrategy; @@ -88,6 +88,7 @@ export interface BaseStrategyOptions { initialVersion?: string; extraLabels?: string[]; dateFormat?: string; + includeCommitAuthors?: boolean; } /** @@ -96,7 +97,7 @@ export interface BaseStrategyOptions { */ export abstract class BaseStrategy implements Strategy { readonly path: string; - protected github: GitHub; + protected github: Scm; protected logger: Logger; protected component?: string; private packageName?: string; @@ -120,6 +121,7 @@ export abstract class BaseStrategy implements Strategy { readonly extraFiles: ExtraFile[]; readonly extraLabels: string[]; protected dateFormat: string; + protected includeCommitAuthors?: boolean; readonly changelogNotes: ChangelogNotes; @@ -158,6 +160,7 @@ export abstract class BaseStrategy implements Strategy { this.initialVersion = options.initialVersion; this.extraLabels = options.extraLabels || []; this.dateFormat = options.dateFormat || DEFAULT_DATE_FORMAT; + this.includeCommitAuthors = options.includeCommitAuthors; } /** @@ -232,6 +235,7 @@ export abstract class BaseStrategy implements Strategy { targetBranch: this.targetBranch, changelogSections: this.changelogSections, commits: commits, + includeCommitAuthors: this.includeCommitAuthors, }); } diff --git a/src/strategies/java-yoshi-mono-repo.ts b/src/strategies/java-yoshi-mono-repo.ts index ab6d32d21..07c1c7f0e 100644 --- a/src/strategies/java-yoshi-mono-repo.ts +++ b/src/strategies/java-yoshi-mono-repo.ts @@ -30,6 +30,7 @@ import { import {ConventionalCommit} from '../commit'; import {Java, JavaBuildUpdatesOption} from './java'; import {JavaUpdate} from '../updaters/java/java-update'; +import {LibrarianYamlUpdater} from '../updaters/java/librarian-yaml'; import {filterCommits} from '../util/filter-commits'; export class JavaYoshiMonoRepo extends Java { @@ -130,6 +131,16 @@ export class JavaYoshiMonoRepo extends Java { this.targetBranch, this.path ); + const versionFilesSearch = this.github.findFilesByFilenameAndRef( + 'Version.java', + this.targetBranch, + this.path + ); + const librarianFilesSearch = this.github.findFilesByFilenameAndRef( + 'librarian.yaml', + this.targetBranch, + this.path + ); const pomFiles = await pomFilesSearch; pomFiles.forEach(path => { @@ -183,6 +194,31 @@ export class JavaYoshiMonoRepo extends Java { }); }); + const versionFiles = await versionFilesSearch; + versionFiles.forEach(path => { + updates.push({ + path: this.addPath(path), + createIfMissing: false, + updater: new JavaUpdate({ + version, + versionsMap, + isSnapshot: options.isSnapshot, + }), + }); + }); + + const librarianFiles = await librarianFilesSearch; + librarianFiles.forEach(path => { + updates.push({ + path: this.addPath(path), + createIfMissing: false, + updater: new LibrarianYamlUpdater({ + version, + versionsMap, + }), + }); + }); + this.extraFiles.forEach(extraFile => { if (typeof extraFile === 'object') { return; diff --git a/src/updaters/java/librarian-yaml.ts b/src/updaters/java/librarian-yaml.ts new file mode 100644 index 000000000..38eb92323 --- /dev/null +++ b/src/updaters/java/librarian-yaml.ts @@ -0,0 +1,99 @@ +// Copyright 2026 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 {DefaultUpdater} from '../default'; +import * as yaml from 'yaml'; +import {logger as defaultLogger, Logger} from '../../util/logger'; + +export interface JavaModule { + distribution_name_override: string; + [key: string]: any; +} + +export interface LibrarianLibrary { + name: string; + version: string; + java: JavaModule; + [key: string]: any; +} + +export interface LibrarianYamlSchema { + libraries: LibrarianLibrary[]; + [key: string]: any; +} + +/** + * Updates a librarian.yaml file. + */ +export class LibrarianYamlUpdater extends DefaultUpdater { + specialArtifacts: ReadonlyMap = new Map([ + ['google-cloud-java', 'google-cloud-java'], + ]); + /** + * Given initial file contents, return updated contents. + * @param {string} content The initial content + * @returns {string} The updated content + */ + updateContent(content: string, logger: Logger = defaultLogger): string { + if (!this.versionsMap) { + logger.warn('missing versions map'); + return content; + } + + // Use yaml package to make sure librarian.yaml is not reformatted because + // we use different tool to format librarian.yaml. + const doc = yaml.parseDocument(content); + if (!doc || doc.errors.length > 0) { + logger.warn('Invalid yaml, cannot be parsed'); + return content; + } + + const libraries = doc.get('libraries'); + if (!libraries || !yaml.isSeq(libraries)) { + return content; + } + + let modified = false; + for (const library of libraries.items) { + if (!yaml.isMap(library)) continue; + + const artifactID = this.findArtifactID( + library.toJSON() as LibrarianLibrary + ); + if (this.versionsMap.has(artifactID)) { + const newVersion = this.versionsMap.get(artifactID); + if (newVersion && library.get('version') !== newVersion.toString()) { + library.set('version', newVersion.toString()); + modified = true; + } + } + } + + if (modified) { + return doc.toString({lineWidth: 0}); + } + return content; + } + + findArtifactID(library: LibrarianLibrary): string { + const artifact = this.specialArtifacts.get(library.name); + if (artifact) { + return artifact; + } + if (library.java && library.java.distribution_name_override) { + return library.java.distribution_name_override.split(':')[1]; + } + return `google-cloud-${library.name}`; + } +} diff --git a/src/util/pull-request-overflow-handler.ts b/src/util/pull-request-overflow-handler.ts index 43bc85245..1d364b7d7 100644 --- a/src/util/pull-request-overflow-handler.ts +++ b/src/util/pull-request-overflow-handler.ts @@ -13,7 +13,7 @@ // limitations under the License. import {PullRequestBody} from './pull-request-body'; -import {GitHub} from '../github'; +import {Scm} from '../scm'; import {PullRequest} from '../pull-request'; import {Logger, logger as defaultLogger} from './logger'; import {URL} from 'url'; @@ -62,9 +62,9 @@ export interface PullRequestOverflowHandler { export class FilePullRequestOverflowHandler implements PullRequestOverflowHandler { - private github: GitHub; + private github: Scm; private logger: Logger; - constructor(github: GitHub, logger: Logger = defaultLogger) { + constructor(github: Scm, logger: Logger = defaultLogger) { this.github = github; this.logger = logger; } diff --git a/test/changelog-notes/default-changelog-notes.ts b/test/changelog-notes/default-changelog-notes.ts index ffb9baa9d..db10273b3 100644 --- a/test/changelog-notes/default-changelog-notes.ts +++ b/test/changelog-notes/default-changelog-notes.ts @@ -290,6 +290,90 @@ describe('DefaultChangelogNotes', () => { expect(notes).to.is.string; safeSnapshot(notes); }); + it('should include commit authors when enabled with username', async () => { + const commits = [ + { + sha: 'sha1', + message: 'feat: some feature', + files: ['path1/file1.txt'], + type: 'feat', + scope: null, + bareMessage: 'some feature', + notes: [], + references: [], + breaking: false, + author: { + name: 'Test User', + email: 'test@example.com', + username: 'testuser', + }, + }, + ]; + const changelogNotes = new DefaultChangelogNotes(); + const notes = await changelogNotes.buildNotes(commits, { + ...notesOptions, + includeCommitAuthors: true, + }); + expect(notes).to.is.string; + expect(notes).to.include('@testuser'); + safeSnapshot(notes); + }); + it('should include commit authors when enabled without username', async () => { + const commits = [ + { + sha: 'sha1', + message: 'feat: some feature', + files: ['path1/file1.txt'], + type: 'feat', + scope: null, + bareMessage: 'some feature', + notes: [], + references: [], + breaking: false, + author: { + name: 'Test User', + email: 'test@example.com', + }, + }, + ]; + const changelogNotes = new DefaultChangelogNotes(); + const notes = await changelogNotes.buildNotes(commits, { + ...notesOptions, + includeCommitAuthors: true, + }); + expect(notes).to.is.string; + expect(notes).to.include('Test User'); + safeSnapshot(notes); + }); + it('should not include commit authors when disabled', async () => { + const commits = [ + { + sha: 'sha1', + message: 'feat: some feature', + files: ['path1/file1.txt'], + type: 'feat', + scope: null, + bareMessage: 'some feature', + notes: [], + references: [], + breaking: false, + author: { + name: 'Test User', + email: 'test@example.com', + username: 'testuser', + }, + }, + ]; + const changelogNotes = new DefaultChangelogNotes(); + const notes = await changelogNotes.buildNotes(commits, { + ...notesOptions, + includeCommitAuthors: false, + }); + expect(notes).to.is.string; + expect(notes).to.not.include('@testuser'); + expect(notes).to.not.include('Test User'); + safeSnapshot(notes); + }); // it('ignores reverted commits', async () => { // const commits = [buildCommitFromFixture('multiple-messages')]; // const changelogNotes = new DefaultChangelogNotes(); diff --git a/test/github.ts b/test/github.ts index 5bb64d62d..e25ec03ed 100644 --- a/test/github.ts +++ b/test/github.ts @@ -21,8 +21,10 @@ import {readFileSync} from 'fs'; import {resolve} from 'path'; import * as snapshot from 'snap-shot-it'; import * as sinon from 'sinon'; +import * as codeSuggester from 'code-suggester'; -import {GH_API_URL, GitHub, GitHubRelease} from '../src/github'; +import {GitHub, GitHubRelease} from '../src/github'; +import {GitHubApi, GH_API_URL} from '../src/github-api'; import {PullRequest} from '../src/pull-request'; import {TagName} from '../src/util/tag-name'; import {Version} from '../src/version'; @@ -35,8 +37,6 @@ import { import {fail} from 'assert'; import {PullRequestBody} from '../src/util/pull-request-body'; import {PullRequestTitle} from '../src/util/pull-request-title'; -import * as codeSuggester from 'code-suggester'; -import {RawContent} from '../src/updaters/raw-content'; import {ReleasePleaseManifest} from '../src/updaters/release-please-manifest'; import {HttpsProxyAgent} from 'https-proxy-agent'; import {HttpProxyAgent} from 'http-proxy-agent'; @@ -102,12 +102,12 @@ describe('GitHub', () => { }); it('default agent is undefined when no proxy option passed ', () => { - expect(GitHub.createDefaultAgent('test_url')).eq(undefined); + expect(GitHubApi.createDefaultAgent('test_url')).eq(undefined); }); it('should return a https agent', () => { expect( - GitHub.createDefaultAgent(GH_API_URL, { + GitHubApi.createDefaultAgent(GH_API_URL, { host: 'http://proxy.com', port: 3000, }) @@ -116,7 +116,7 @@ describe('GitHub', () => { it('should throw error when baseUrl is an invalid url', () => { expect(() => { - GitHub.createDefaultAgent('invalid_url', { + GitHubApi.createDefaultAgent('invalid_url', { host: 'http://proxy.com', port: 3000, }); @@ -125,7 +125,7 @@ describe('GitHub', () => { it('should return a http agent', () => { expect( - GitHub.createDefaultAgent('http://www.github.com', { + GitHubApi.createDefaultAgent('http://www.github.com', { host: 'http://proxy.com', port: 3000, }) @@ -1012,133 +1012,30 @@ describe('GitHub', () => { }); }); - describe('createReleasePullRequest', () => { - it('should update file', async () => { + describe('createPullRequest', () => { + it('should not call getPullRequest when no code changes detected', async () => { const createPullRequestStub = sandbox .stub(codeSuggester, 'createPullRequest') - .resolves(1); - sandbox - .stub(github, 'getFileContentsOnBranch') - .withArgs('existing-file', 'main') - .resolves({ - sha: 'abc123', - content: 'somecontent', - parsedContent: 'somecontent', - mode: '100644', - }); - sandbox.stub(github, 'getPullRequest').withArgs(1).resolves({ - title: 'created title', - headBranchName: 'release-please--branches--main', - baseBranchName: 'main', - number: 1, - body: 'some body', - labels: [], - files: [], - }); - const pullRequest = await github.createReleasePullRequest( - { - title: PullRequestTitle.ofTargetBranch('main'), - body: new PullRequestBody([]), - labels: [], - headRefName: 'release-please--branches--main', - draft: false, - updates: [ - { - path: 'existing-file', - createIfMissing: false, - updater: new RawContent('some content'), - }, - ], - }, - 'main' - ); - expect(pullRequest.number).to.eql(1); - sinon.assert.calledOnce(createPullRequestStub); - const changes = createPullRequestStub.getCall(0).args[1]; - expect(changes).to.not.be.undefined; - expect(changes!.size).to.eql(1); - expect(changes!.get('existing-file')).to.not.be.undefined; - }); - it('should handle missing files', async () => { - const createPullRequestStub = sandbox - .stub(codeSuggester, 'createPullRequest') - .resolves(1); - sandbox - .stub(github, 'getFileContentsOnBranch') - .withArgs('missing-file', 'main') - .rejects(new FileNotFoundError('missing-file')); - sandbox.stub(github, 'getPullRequest').withArgs(1).resolves({ - title: 'created title', - headBranchName: 'release-please--branches--main', - baseBranchName: 'main', - number: 1, - body: 'some body', - labels: [], - files: [], - }); - const pullRequest = await github.createReleasePullRequest( - { - title: PullRequestTitle.ofTargetBranch('main'), - body: new PullRequestBody([]), - labels: [], - headRefName: 'release-please--branches--main', - draft: false, - updates: [ - { - path: 'missing-file', - createIfMissing: false, - updater: new RawContent('some content'), - }, - ], - }, - 'main' - ); - expect(pullRequest.number).to.eql(1); - sinon.assert.calledOnce(createPullRequestStub); - const changes = createPullRequestStub.getCall(0).args[1]; - expect(changes).to.not.be.undefined; - expect(changes!.size).to.eql(0); - }); - it('should create missing file', async () => { - const createPullRequestStub = sandbox - .stub(codeSuggester, 'createPullRequest') - .resolves(1); - sandbox - .stub(github, 'getFileContentsOnBranch') - .withArgs('missing-file', 'main') - .rejects(new FileNotFoundError('missing-file')); - sandbox.stub(github, 'getPullRequest').withArgs(1).resolves({ - title: 'created title', - headBranchName: 'release-please--branches--main', - baseBranchName: 'main', - number: 1, - body: 'some body', - labels: [], - files: [], - }); - const pullRequest = await github.createReleasePullRequest( + .resolves(0); + const getPullRequestStub = sandbox.stub(github, 'getPullRequest'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const pullRequest = await (github as any).createPullRequest( { - title: PullRequestTitle.ofTargetBranch('main'), - body: new PullRequestBody([]), - labels: [], - headRefName: 'release-please--branches--main', - draft: false, - updates: [ - { - path: 'missing-file', - createIfMissing: true, - updater: new RawContent('some content'), - }, - ], + headBranchName: 'release-please--branches--main', + baseBranchName: 'main', + title: 'Release v1.0.0', + body: 'Release body', + labels: ['release-please'], }, - 'main' + 'main', + 'commit message', + [] ); - expect(pullRequest.number).to.eql(1); + + expect(pullRequest.number).to.eql(0); sinon.assert.calledOnce(createPullRequestStub); - const changes = createPullRequestStub.getCall(0).args[1]; - expect(changes).to.not.be.undefined; - expect(changes!.size).to.eql(1); - expect(changes!.get('missing-file')).to.not.be.undefined; + sinon.assert.notCalled(getPullRequestStub); }); }); diff --git a/test/local-github.ts b/test/local-github.ts new file mode 100644 index 000000000..1f35f5599 --- /dev/null +++ b/test/local-github.ts @@ -0,0 +1,166 @@ +// Copyright 2026 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 {expect} from 'chai'; +import {describe, it, before} from 'mocha'; +import {LocalGitHub} from '../src/local-github'; + +describe('LocalGitHub', () => { + let localGitHub: LocalGitHub; + + before(async () => { + localGitHub = await LocalGitHub.create({ + owner: 'googleapis', + repo: 'release-please', + defaultBranch: 'main', + cloneDepth: 100, + }); + }); + + describe('getFileContentsOnBranch', () => { + it('reads file content correctly', async () => { + const contents = await localGitHub.getFileContentsOnBranch( + 'package.json', + 'main' + ); + expect(contents).to.not.be.undefined; + expect(contents.parsedContent).to.include('"name": "release-please"'); + expect(contents.sha).to.not.be.undefined; + }); + + it('reads file content correctly from a branch', async () => { + const contents = await localGitHub.getFileContentsOnBranch( + 'package.json', + '12.x' + ); + expect(contents).to.not.be.undefined; + expect(contents.parsedContent).to.include('"name": "release-please"'); + expect(contents.sha).to.not.be.undefined; + }); + + it('reads file content correctly from a tag', async () => { + const contents = await localGitHub.getFileContentsOnBranch( + 'package.json', + 'v17.4.0' + ); + expect(contents).to.not.be.undefined; + expect(contents.parsedContent).to.include('"name": "release-please"'); + expect(contents.sha).to.not.be.undefined; + }); + + it('throws FileNotFoundError when file does not exist', async () => { + try { + await localGitHub.getFileContentsOnBranch( + 'non-existent-file.txt', + 'main' + ); + throw new Error('Expected FileNotFoundError to be thrown'); + } catch (err) { + const error = err as Error; + expect(error.name).to.equal('FileNotFoundError'); + } + }); + + it('throws FileNotFoundError when file does not exist on a branch', async () => { + try { + await localGitHub.getFileContentsOnBranch( + 'non-existent-file.txt', + '12.x' + ); + throw new Error('Expected FileNotFoundError to be thrown'); + } catch (err) { + const error = err as Error; + expect(error.name).to.equal('FileNotFoundError'); + } + }); + }); + + describe('findFilesByFilenameAndRef', () => { + it('finds files by filename', async () => { + const files = await localGitHub.findFilesByFilenameAndRef( + 'package.json', + 'main' + ); + expect(files).to.include('package.json'); + }); + + it('finds files by filename on a branch', async () => { + const files = await localGitHub.findFilesByFilenameAndRef( + 'package.json', + '12.x' + ); + expect(files).to.include('package.json'); + }); + }); + + describe('findFilesByGlobAndRef', () => { + it('finds files by glob', async () => { + const files = await localGitHub.findFilesByGlobAndRef('*.json', 'main'); + expect(files).to.include('package.json'); + }); + + it('finds files by glob on a branch', async () => { + const files = await localGitHub.findFilesByGlobAndRef('*.json', '12.x'); + expect(files).to.include('package.json'); + }); + }); + + describe('findFilesByExtensionAndRef', () => { + it('finds files by extension', async () => { + const files = await localGitHub.findFilesByExtensionAndRef( + 'json', + 'main' + ); + expect(files).to.include('package.json'); + }); + + it('finds files by extension on a branch', async () => { + const files = await localGitHub.findFilesByExtensionAndRef( + 'json', + '12.x' + ); + expect(files).to.include('package.json'); + }); + }); + + describe('mergeCommitIterator', () => { + it('iterates over commits', async () => { + const generator = localGitHub.mergeCommitIterator('main', { + maxResults: 5, + }); + const commits = []; + for await (const commit of generator) { + commits.push(commit); + } + expect(commits.length).to.be.greaterThan(0); + expect(commits.length).to.be.lessThanOrEqual(5); + expect(commits[0].sha).to.not.be.undefined; + expect(commits[0].message).to.not.be.undefined; + }); + }); + + describe('tagIterator', () => { + it('iterates over tags', async () => { + const generator = localGitHub.tagIterator({maxResults: 5}); + const tags = []; + for await (const tag of generator) { + tags.push(tag); + } + expect(tags.length).to.be.greaterThan(0); + expect(tags.length).to.be.lessThanOrEqual(5); + expect(tags[0].name).to.not.be.undefined; + expect(tags[0].sha).to.not.be.undefined; + }); + }); +}); diff --git a/test/manifest.ts b/test/manifest.ts index eb34cb944..2906eb5e7 100644 --- a/test/manifest.ts +++ b/test/manifest.ts @@ -3776,15 +3776,18 @@ describe('Manifest', () => { .resolves(buildGitHubFileRaw('some-content')); stubSuggesterWithSnapshot(sandbox, this.test!.fullTitle()); mockPullRequests(github, []); - sandbox.stub(github, 'getPullRequest').withArgs(22).resolves({ - number: 22, - title: 'pr title1', - body: 'pr body1', - headBranchName: 'release-please/branches/main', - baseBranchName: 'main', - labels: [], - files: [], - }); + sandbox + .stub((github as any).gitHubApi, 'getPullRequest') + .withArgs(22) + .resolves({ + number: 22, + title: 'pr title1', + body: 'pr body1', + headBranchName: 'release-please/branches/main', + baseBranchName: 'main', + labels: [], + files: [], + }); const manifest = new Manifest( github, 'main', @@ -3840,7 +3843,7 @@ describe('Manifest', () => { .resolves(buildGitHubFileRaw('some-content-2')); mockPullRequests(github, []); sandbox - .stub(github, 'getPullRequest') + .stub((github as any).gitHubApi, 'getPullRequest') .withArgs(123) .resolves({ number: 123, @@ -3968,15 +3971,18 @@ describe('Manifest', () => { .resolves(buildGitHubFileRaw('some-content')); stubSuggesterWithSnapshot(sandbox, this.test!.fullTitle()); mockPullRequests(github, []); - sandbox.stub(github, 'getPullRequest').withArgs(22).resolves({ - number: 22, - title: 'pr title1', - body: 'pr body1', - headBranchName: 'release-please/branches/main', - baseBranchName: 'main', - labels: [], - files: [], - }); + sandbox + .stub((github as any).gitHubApi, 'getPullRequest') + .withArgs(22) + .resolves({ + number: 22, + title: 'pr title1', + body: 'pr body1', + headBranchName: 'release-please/branches/main', + baseBranchName: 'main', + labels: [], + files: [], + }); const manifest = new Manifest( github, 'main', @@ -4031,15 +4037,18 @@ describe('Manifest', () => { .resolves(buildGitHubFileRaw('some-content')); stubSuggesterWithSnapshot(sandbox, this.test!.fullTitle()); mockPullRequests(github, []); - sandbox.stub(github, 'getPullRequest').withArgs(22).resolves({ - number: 22, - title: 'pr title1', - body: 'pr body1', - headBranchName: 'release-please/branches/main', - baseBranchName: 'main', - labels: [], - files: [], - }); + sandbox + .stub((github as any).gitHubApi, 'getPullRequest') + .withArgs(22) + .resolves({ + number: 22, + title: 'pr title1', + body: 'pr body1', + headBranchName: 'release-please/branches/main', + baseBranchName: 'main', + labels: [], + files: [], + }); const manifest = new Manifest( github, 'main', diff --git a/test/strategies/java-yoshi-mono-repo.ts b/test/strategies/java-yoshi-mono-repo.ts index dd0a578cb..978f3f33f 100644 --- a/test/strategies/java-yoshi-mono-repo.ts +++ b/test/strategies/java-yoshi-mono-repo.ts @@ -27,6 +27,7 @@ import {TagName} from '../../src/util/tag-name'; import {Version} from '../../src/version'; import {Changelog} from '../../src/updaters/changelog'; import {JavaUpdate} from '../../src/updaters/java/java-update'; +import {LibrarianYamlUpdater} from '../../src/updaters/java/librarian-yaml'; import {VersionsManifest} from '../../src/updaters/java/versions-manifest'; import {CompositeUpdater} from '../../src/updaters/composite'; @@ -258,6 +259,12 @@ describe('JavaYoshiMonoRepo', () => { findFilesStub .withArgs('README.md', 'main', '.') .resolves(['path1/README.md', 'path2/README.md']); + findFilesStub + .withArgs('Version.java', 'main', '.') + .resolves(['path1/Version.java']); + findFilesStub + .withArgs('librarian.yaml', 'main', '.') + .resolves(['path1/librarian.yaml']); const getFileContentsStub = sandbox.stub( github, 'getFileContentsOnBranch' @@ -285,6 +292,8 @@ describe('JavaYoshiMonoRepo', () => { assertHasUpdate(updates, 'versions.txt', VersionsManifest); assertHasUpdate(updates, 'path1/README.md', JavaUpdate); assertHasUpdate(updates, 'path2/README.md', JavaUpdate); + assertHasUpdate(updates, 'path1/Version.java', JavaUpdate); + assertHasUpdate(updates, 'path1/librarian.yaml', LibrarianYamlUpdater); }); it('finds and updates extra files', async () => { @@ -333,6 +342,12 @@ describe('JavaYoshiMonoRepo', () => { findFilesStub .withArgs('README.md', 'main', '.') .resolves(['path1/README.md', 'path2/README.md']); + findFilesStub + .withArgs('Version.java', 'main', '.') + .resolves(['path1/Version.java']); + findFilesStub + .withArgs('librarian.yaml', 'main', '.') + .resolves(['path1/librarian.yaml']); const getFileContentsStub = sandbox.stub( github, 'getFileContentsOnBranch' @@ -362,6 +377,8 @@ describe('JavaYoshiMonoRepo', () => { assertHasUpdate(updates, 'versions.txt', VersionsManifest); assertHasUpdate(updates, 'path1/README.md', JavaUpdate); assertHasUpdate(updates, 'path2/README.md', JavaUpdate); + assertHasUpdate(updates, 'path1/Version.java', JavaUpdate); + assertHasUpdate(updates, 'path1/librarian.yaml', LibrarianYamlUpdater); }); it('updates changelog.json', async () => { diff --git a/test/updaters/librarian-yaml.ts b/test/updaters/librarian-yaml.ts new file mode 100644 index 000000000..3de383bd0 --- /dev/null +++ b/test/updaters/librarian-yaml.ts @@ -0,0 +1,118 @@ +// Copyright 2026 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 {describe, it} from 'mocha'; +import {expect} from 'chai'; +import {LibrarianYamlUpdater} from '../../src/updaters/java/librarian-yaml'; +import {Version} from '../../src/version'; + +const oldContent = `language: java +sources: + googleapis: + commit: cd090841ab172574e740c214c99df00aef9c0dee + sha256: 08e4b7744dc23b6e3320a3f1d05db9f40853aaf1089d06bfb8d79044b7a66f21 +default: + java: + libraries_bom_version: 26.79.0 +libraries: + - name: google-cloud-java + version: 1.84.0 + skip_generate: true + - name: shopping-css + version: 0.58.0 + apis: + - path: google/shopping/css/v1 + java: + api_description_override: The CSS API is used to manage your CSS and control your CSS Products portfolio + non_cloud_api: true + distribution_name_override: com.google.shopping:google-shopping-css + name_pretty_override: CSS API + java_apis: + - additional_protos: + - google/cloud/common_resources.proto + path: google/shopping/css/v1 + product_documentation_override: https://developers.google.com/comparison-shopping-services/api + - name: secretmanager + version: 2.1.0 + java: + api_description_override: allows you to encrypt, store, manage, and audit infrastructure and application-level secrets. +`; + +const updatedContent = `language: java +sources: + googleapis: + commit: cd090841ab172574e740c214c99df00aef9c0dee + sha256: 08e4b7744dc23b6e3320a3f1d05db9f40853aaf1089d06bfb8d79044b7a66f21 +default: + java: + libraries_bom_version: 26.79.0 +libraries: + - name: google-cloud-java + version: 1.85.0 + skip_generate: true + - name: shopping-css + version: 0.59.0 + apis: + - path: google/shopping/css/v1 + java: + api_description_override: The CSS API is used to manage your CSS and control your CSS Products portfolio + non_cloud_api: true + distribution_name_override: com.google.shopping:google-shopping-css + name_pretty_override: CSS API + java_apis: + - additional_protos: + - google/cloud/common_resources.proto + path: google/shopping/css/v1 + product_documentation_override: https://developers.google.com/comparison-shopping-services/api + - name: secretmanager + version: 2.2.0 + java: + api_description_override: allows you to encrypt, store, manage, and audit infrastructure and application-level secrets. +`; + +describe('LibrarianYamlUpdater', () => { + it('updates librarian.yaml version based on versionsMap', () => { + const versionsMap = new Map(); + versionsMap.set('google-shopping-css', Version.parse('0.59.0')); + versionsMap.set('google-cloud-secretmanager', Version.parse('2.2.0')); + versionsMap.set('google-cloud-java', Version.parse('1.85.0')); + + const updater = new LibrarianYamlUpdater({ + version: Version.parse('1.0.0'), // Unused + versionsMap, + }); + const newContent = updater.updateContent(oldContent); + // Compare the content to verify librarian.yaml is not reformatted. + expect(newContent).to.eq(updatedContent); + }); + + it('returns original content if versionsMap is missing', () => { + const updater = new LibrarianYamlUpdater({ + version: Version.parse('1.0.0'), + }); + const newContent = updater.updateContent(oldContent); + expect(newContent).to.equal(oldContent); + }); + + it('returns original content if no libraries match versionsMap', () => { + const versionsMap = new Map(); + versionsMap.set('non-existent', Version.parse('1.0.0')); + const updater = new LibrarianYamlUpdater({ + version: Version.parse('1.0.0'), + versionsMap, + }); + const newContent = updater.updateContent(oldContent); + expect(newContent).to.equal(oldContent); + }); +});