diff --git a/.gitignore b/.gitignore index 2423093fc..48dd29ff8 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ __pycache__ package-lock.json debug.sh .idea/ +.vscode/ \ No newline at end of file diff --git a/__snapshots__/base.js b/__snapshots__/base.js index c34a5e7f9..7f14ab973 100644 --- a/__snapshots__/base.js +++ b/__snapshots__/base.js @@ -8,7 +8,7 @@ exports['Strategy buildReleasePullRequest allows overriding initial version 1'] ### Miscellaneous Chores -* initial commit ([16d3754](https://github.com/googleapis/base-test-repo/commit/16d3754a2134a6d19ee19d2e5ba4dfbc)) +* initial commit ([16d3754](https://www.github.com/googleapis/base-test-repo/commit/16d3754a2134a6d19ee19d2e5ba4dfbc)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -24,7 +24,7 @@ exports['Strategy buildReleasePullRequest allows overriding initial version in b ### Features -* initial commit ([a90fc00](https://github.com/googleapis/base-test-repo/commit/a90fc00ca62382346da72dd8f51078a7)) +* initial commit ([a90fc00](https://www.github.com/googleapis/base-test-repo/commit/a90fc00ca62382346da72dd8f51078a7)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). diff --git a/__snapshots__/cli.js b/__snapshots__/cli.js index 696b90e27..b127dedd9 100644 --- a/__snapshots__/cli.js +++ b/__snapshots__/cli.js @@ -1,7 +1,7 @@ exports['CLI --help github-release 1'] = ` release-please github-release -create a GitHub release from a release PR +create a release from a release PR/MR Options: --help Show help [boolean] @@ -12,17 +12,26 @@ Options: debugging). [boolean] [default: false] --plugin load plugin named release-please- [array] [default: []] - --token GitHub token with repo write permissions + --token GitHub/GitLab token with repo write permissions --api-url URL to use when making API requests - [string] [default: "https://api.github.com"] - --graphql-url URL to use when making GraphQL requests - [string] [default: "https://api.github.com"] - --default-branch The branch to open release PRs against and tag - releases on + [string] [default: GitHub: https://api.github.com; GitLab: + https://gitlab.com/api/v4] + --host-url URL to use when building changelog and release + requests + [string] [default: GitHub: https://www.github.com; GitLab: https://gitlab.com] + --graphql-url URL to use when making GraphQL requests (ignored + for GitLab) + [string] [default: GitHub: https://api.github.com] + --provider Which provider to use (default 'github') + [string] [default: "github"] + --default-branch The branch to open release PR/MRs against and + tag releases on [deprecated: use --target-branch instead] [string] - --target-branch The branch to open release PRs against and tag - releases on [string] - --repo-url GitHub URL to generate release for [required] + --target-branch The branch to open release PR/MRs against and + tag releases on [string] + --repo-url Repository URL to generate release for (e.g. + GitHub: , GitLab: ) + [required] --dry-run Prepare but do not take action [boolean] [default: false] --include-v-in-tags include "v" in tag versions @@ -68,6 +77,7 @@ Options: --snapshot-label set a java snapshot pull request label other than "autorelease: snapshot" [string] [default: "autorelease: snapshot"] + ` exports['CLI --help manifest-pr 1'] = ` @@ -84,16 +94,24 @@ Options: debugging). [boolean] [default: false] --plugin load plugin named release-please- [array] [default: []] - --token GitHub token with repo write permissions + --token GitHub/GitLab token with repo write permissions --api-url URL to use when making API requests - [string] [default: "https://api.github.com"] - --graphql-url URL to use when making GraphQL requests - [string] [default: "https://api.github.com"] - --default-branch The branch to open release PRs against and tag releases - on [deprecated: use --target-branch instead] [string] - --target-branch The branch to open release PRs against and tag releases - on [string] - --repo-url GitHub URL to generate release for [required] + [string] [default: GitHub: https://api.github.com; GitLab: + https://gitlab.com/api/v4] + --host-url URL to use when building changelog and release requests + [string] [default: GitHub: https://www.github.com; GitLab: https://gitlab.com] + --graphql-url URL to use when making GraphQL requests (ignored for + GitLab) + [string] [default: GitHub: https://api.github.com] + --provider Which provider to use (default 'github') + [string] [default: "github"] + --default-branch The branch to open release PR/MRs against and tag + releases on + [deprecated: use --target-branch instead] [string] + --target-branch The branch to open release PR/MRs against and tag + releases on [string] + --repo-url Repository URL to generate release for (e.g. GitHub: + , GitLab: ) [required] --dry-run Prepare but do not take action[boolean] [default: false] --label comma-separated list of labels to add to from release PR [default: "autorelease: pending"] @@ -109,6 +127,7 @@ Options: [default: "release-please-config.json"] --manifest-file where can the manifest file be found in the project? [default: ".release-please-manifest.json"] + ` exports['CLI --help manifest-release 1'] = ` @@ -125,16 +144,22 @@ Options: [boolean] [default: false] --plugin load plugin named release-please- [array] [default: []] - --token GitHub token with repo write permissions + --token GitHub/GitLab token with repo write permissions --api-url URL to use when making API requests - [string] [default: "https://api.github.com"] - --graphql-url URL to use when making GraphQL requests - [string] [default: "https://api.github.com"] - --default-branch The branch to open release PRs against and tag releases on - [deprecated: use --target-branch instead] [string] - --target-branch The branch to open release PRs against and tag releases on - [string] - --repo-url GitHub URL to generate release for [required] + [string] [default: GitHub: https://api.github.com; GitLab: + https://gitlab.com/api/v4] + --host-url URL to use when building changelog and release requests + [string] [default: GitHub: https://www.github.com; GitLab: https://gitlab.com] + --graphql-url URL to use when making GraphQL requests (ignored for GitLab) + [string] [default: GitHub: https://api.github.com] + --provider Which provider to use (default 'github') + [string] [default: "github"] + --default-branch The branch to open release PR/MRs against and tag releases + on [deprecated: use --target-branch instead] [string] + --target-branch The branch to open release PR/MRs against and tag releases + on [string] + --repo-url Repository URL to generate release for (e.g. GitHub: + , GitLab: ) [required] --dry-run Prepare but do not take action [boolean] [default: false] --draft mark release as a draft. no tag is created but tag_name and target_commitish are associated with the release for future @@ -153,6 +178,89 @@ Options: [default: "release-please-config.json"] --manifest-file where can the manifest file be found in the project? [default: ".release-please-manifest.json"] + +` + +exports['CLI --help release 1'] = ` +release-please release + +create a release from a release PR/MR + +Options: + --help Show help [boolean] + --version Show version number [boolean] + --debug print verbose errors (use only for local + debugging). [boolean] [default: false] + --trace print extra verbose errors (use only for local + debugging). [boolean] [default: false] + --plugin load plugin named release-please- + [array] [default: []] + --token GitHub/GitLab token with repo write permissions + --api-url URL to use when making API requests + [string] [default: GitHub: https://api.github.com; GitLab: + https://gitlab.com/api/v4] + --host-url URL to use when building changelog and release + requests + [string] [default: GitHub: https://www.github.com; GitLab: https://gitlab.com] + --graphql-url URL to use when making GraphQL requests (ignored + for GitLab) + [string] [default: GitHub: https://api.github.com] + --provider Which provider to use (default 'github') + [string] [default: "github"] + --default-branch The branch to open release PR/MRs against and + tag releases on + [deprecated: use --target-branch instead] [string] + --target-branch The branch to open release PR/MRs against and + tag releases on [string] + --repo-url Repository URL to generate release for (e.g. + GitHub: , GitLab: ) + [required] + --dry-run Prepare but do not take action + [boolean] [default: false] + --include-v-in-tags include "v" in tag versions + [boolean] [default: true] + --monorepo-tags include library name in tags and release + branches [boolean] [default: false] + --pull-request-title-pattern Title pattern to make release PR [string] + --pull-request-header Header for release PR [string] + --pull-request-footer Footer for release PR [string] + --component-no-space release-please automatically adds \` \` (space) in + front of parsed \${component}. Should this be + disabled? [boolean] [default: false] + --path release from path other than root directory + [string] + --component name of component release is being minted for + [string] + --package-name name of package release is being minted for + [string] + --release-type what type of repo is a release being created + for? + [choices: "bazel", "dart", "dotnet-yoshi", "elixir", "expo", "go", "go-yoshi", + "helm", "java", "java-backport", "java-bom", "java-lts", "java-yoshi", + "java-yoshi-mono-repo", "krm-blueprint", "maven", "node", "ocaml", "php", + "php-yoshi", "python", "r", "ruby", "ruby-yoshi", "rust", "salesforce", + "sfdx", "simple", "terraform-module"] + --config-file where can the config file be found in the + project? [default: "release-please-config.json"] + --manifest-file where can the manifest file be found in the + project? + [default: ".release-please-manifest.json"] + --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. + [boolean] [default: false] + --prerelease mark release that have prerelease versions as as + a prerelease on Github[boolean] [default: false] + --label comma-separated list of labels to remove to from + release PR [default: "autorelease: pending"] + --release-label set a pull request label other than + "autorelease: tagged" + [string] [default: "autorelease: tagged"] + --snapshot-label set a java snapshot pull request label other + than "autorelease: snapshot" + [string] [default: "autorelease: snapshot"] + ` exports['CLI --help release-pr 1'] = ` @@ -170,17 +278,27 @@ Options: --plugin load plugin named release-please- [array] [default: []] - --token GitHub token with repo write permissions + --token GitHub/GitLab token with repo write + permissions --api-url URL to use when making API requests - [string] [default: "https://api.github.com"] + [string] [default: GitHub: https://api.github.com; GitLab: + https://gitlab.com/api/v4] + --host-url URL to use when building changelog and + release requests + [string] [default: GitHub: https://www.github.com; GitLab: https://gitlab.com] --graphql-url URL to use when making GraphQL requests - [string] [default: "https://api.github.com"] - --default-branch The branch to open release PRs against and - tag releases on + (ignored for GitLab) + [string] [default: GitHub: https://api.github.com] + --provider Which provider to use (default 'github') + [string] [default: "github"] + --default-branch The branch to open release PR/MRs against + and tag releases on [deprecated: use --target-branch instead] [string] - --target-branch The branch to open release PRs against and - tag releases on [string] - --repo-url GitHub URL to generate release for[required] + --target-branch The branch to open release PR/MRs against + and tag releases on [string] + --repo-url Repository URL to generate release for (e.g. + GitHub: , GitLab: ) + [required] --dry-run Prepare but do not take action [boolean] [default: false] --release-as override the semantically determined release @@ -261,6 +379,7 @@ Options: --manifest-file where can the manifest file be found in the project? [default: ".release-please-manifest.json"] + ` exports['CLI handleError handles an error 1'] = [ diff --git a/__snapshots__/dotnet-yoshi.js b/__snapshots__/dotnet-yoshi.js index 28a89858d..ca4cc00b3 100644 --- a/__snapshots__/dotnet-yoshi.js +++ b/__snapshots__/dotnet-yoshi.js @@ -8,8 +8,8 @@ exports['DotnetYoshi buildReleasePullRequest returns release PR changes with def ### Bug fixes -* **deps:** update dependency com.google.cloud:google-cloud-spanner to v1.50.0 ([08ca011](https://github.com/googleapis/google-cloud-dotnet/commit/08ca01180a91c0a1ba8992b491db9212)) -* **deps:** update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([845db13](https://github.com/googleapis/google-cloud-dotnet/commit/845db1381b3d5d20151cad2588f85feb)) +* **deps:** update dependency com.google.cloud:google-cloud-spanner to v1.50.0 ([08ca011](https://www.github.com/googleapis/google-cloud-dotnet/commit/08ca01180a91c0a1ba8992b491db9212)) +* **deps:** update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([845db13](https://www.github.com/googleapis/google-cloud-dotnet/commit/845db1381b3d5d20151cad2588f85feb)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -25,8 +25,8 @@ exports['DotnetYoshi buildReleasePullRequest returns release PR changes with sem ### Bug fixes -* **deps:** update dependency com.google.cloud:google-cloud-spanner to v1.50.0 ([08ca011](https://github.com/googleapis/google-cloud-dotnet/commit/08ca01180a91c0a1ba8992b491db9212)) -* **deps:** update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([845db13](https://github.com/googleapis/google-cloud-dotnet/commit/845db1381b3d5d20151cad2588f85feb)) +* **deps:** update dependency com.google.cloud:google-cloud-spanner to v1.50.0 ([08ca011](https://www.github.com/googleapis/google-cloud-dotnet/commit/08ca01180a91c0a1ba8992b491db9212)) +* **deps:** update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([845db13](https://www.github.com/googleapis/google-cloud-dotnet/commit/845db1381b3d5d20151cad2588f85feb)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -38,6 +38,6 @@ exports['DotnetYoshi buildUpdates builds common files 1'] = ` ### Bug fixes -* **deps:** update dependency com.google.cloud:google-cloud-spanner to v1.50.0 ([08ca011](https://github.com/googleapis/google-cloud-dotnet/commit/08ca01180a91c0a1ba8992b491db9212)) -* **deps:** update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([845db13](https://github.com/googleapis/google-cloud-dotnet/commit/845db1381b3d5d20151cad2588f85feb)) +* **deps:** update dependency com.google.cloud:google-cloud-spanner to v1.50.0 ([08ca011](https://www.github.com/googleapis/google-cloud-dotnet/commit/08ca01180a91c0a1ba8992b491db9212)) +* **deps:** update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([845db13](https://www.github.com/googleapis/google-cloud-dotnet/commit/845db1381b3d5d20151cad2588f85feb)) ` diff --git a/__snapshots__/go-yoshi.js b/__snapshots__/go-yoshi.js index 08649bd0a..697ee65ef 100644 --- a/__snapshots__/go-yoshi.js +++ b/__snapshots__/go-yoshi.js @@ -8,7 +8,7 @@ exports['GoYoshi buildReleasePullRequest combines google-api-go-client autogener ### Features -* **all:** auto-regenerate discovery clients, refs [#1281](https://github.com/googleapis/google-api-go-client/issues/1281) [#1280](https://github.com/googleapis/google-api-go-client/issues/1280) [#1279](https://github.com/googleapis/google-api-go-client/issues/1279) [#1278](https://github.com/googleapis/google-api-go-client/issues/1278) +* **all:** auto-regenerate discovery clients, refs [#1281](https://www.github.com/googleapis/google-api-go-client/issues/1281) [#1280](https://www.github.com/googleapis/google-api-go-client/issues/1280) [#1279](https://www.github.com/googleapis/google-api-go-client/issues/1279) [#1278](https://www.github.com/googleapis/google-api-go-client/issues/1278) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -24,7 +24,7 @@ exports['GoYoshi buildReleasePullRequest filters out submodule commits 1'] = ` ### Bug Fixes -* **translate:** some translate fix ([a74c6a3](https://github.com/googleapis/google-cloud-go/commit/a74c6a3c43273a62de7e47bef2e11fb0)) +* **translate:** some translate fix ([a74c6a3](https://www.github.com/googleapis/google-cloud-go/commit/a74c6a3c43273a62de7e47bef2e11fb0)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -40,7 +40,7 @@ exports['GoYoshi buildReleasePullRequest filters out touched files not matching ### Bug Fixes -* **iam/apiv1:** some firestore fix ([6daedf3](https://github.com/googleapis/google-cloud-go/commit/6daedf3971d9e1b4c99ff1b3a52c94a5)) +* **iam/apiv1:** some firestore fix ([6daedf3](https://www.github.com/googleapis/google-cloud-go/commit/6daedf3971d9e1b4c99ff1b3a52c94a5)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). diff --git a/__snapshots__/linked-versions-group-title.js b/__snapshots__/linked-versions-group-title.js index e35e08b53..1e0fdbaef 100644 --- a/__snapshots__/linked-versions-group-title.js +++ b/__snapshots__/linked-versions-group-title.js @@ -5,22 +5,22 @@ exports['Plugin compatibility linked-versions and group-pull-request-title-patte
primary: 1.1.0 -## [1.1.0](https://github.com/fake-owner/fake-repo/compare/primary-v1.0.0...primary-v1.1.0) (1983-10-10) +## [1.1.0](https://www.github.com/fake-owner/fake-repo/compare/primary-v1.0.0...primary-v1.1.0) (1983-10-10) ### Features -* some feature ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some feature ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa))
pkgA: 1.1.0 -## [1.1.0](https://github.com/fake-owner/fake-repo/compare/pkgA-v1.0.0...pkgA-v1.1.0) (1983-10-10) +## [1.1.0](https://www.github.com/fake-owner/fake-repo/compare/pkgA-v1.0.0...pkgA-v1.1.0) (1983-10-10) ### Features -* some feature ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some feature ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa))
--- diff --git a/__snapshots__/linked-versions-workspace.js b/__snapshots__/linked-versions-workspace.js index 368e51cfd..911419921 100644 --- a/__snapshots__/linked-versions-workspace.js +++ b/__snapshots__/linked-versions-workspace.js @@ -5,17 +5,17 @@ exports['Plugin compatibility linked-versions and workspace should version bump
pkgA: 1.1.0 -## [1.1.0](https://github.com/fake-owner/fake-repo/compare/pkgA-v1.0.0...pkgA-v1.1.0) (1983-10-10) +## [1.1.0](https://www.github.com/fake-owner/fake-repo/compare/pkgA-v1.0.0...pkgA-v1.1.0) (1983-10-10) ### Features -* some feature ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some feature ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa))
pkgB: 1.1.0 -## [1.1.0](https://github.com/fake-owner/fake-repo/compare/pkgB-v1.0.0...pkgB-v1.1.0) (1983-10-10) +## [1.1.0](https://www.github.com/fake-owner/fake-repo/compare/pkgB-v1.0.0...pkgB-v1.1.0) (1983-10-10) ### Miscellaneous Chores diff --git a/__snapshots__/linked-versions.js b/__snapshots__/linked-versions.js index df70dece0..c5ab5e136 100644 --- a/__snapshots__/linked-versions.js +++ b/__snapshots__/linked-versions.js @@ -3,12 +3,12 @@ exports['LinkedVersions plugin can skip grouping pull requests 1'] = ` --- -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -19,12 +19,12 @@ exports['LinkedVersions plugin can skip grouping pull requests 2'] = ` --- -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) ### Bug Fixes -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -35,7 +35,7 @@ exports['LinkedVersions plugin can skip grouping pull requests 3'] = ` --- -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg3-v0.2.3...pkg3-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg3-v0.2.3...pkg3-v0.2.4) (1983-10-10) ### Miscellaneous Chores @@ -53,17 +53,17 @@ exports['LinkedVersions plugin should allow multiple groups of linked versions 1
pkg1: 1.0.1 -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa))
pkg4: 1.0.1 -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/pkg4-v1.0.0...pkg4-v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/pkg4-v1.0.0...pkg4-v1.0.1) (1983-10-10) ### Miscellaneous Chores @@ -82,17 +82,17 @@ exports['LinkedVersions plugin should allow multiple groups of linked versions 2
pkg2: 0.2.4 -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) ### Bug Fixes -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb))
pkg3: 0.2.4 -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg3-v0.2.3...pkg3-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg3-v0.2.3...pkg3-v0.2.4) (1983-10-10) ### Miscellaneous Chores @@ -109,12 +109,12 @@ exports['LinkedVersions plugin should group pull requests 1'] = ` --- -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -127,17 +127,17 @@ exports['LinkedVersions plugin should group pull requests 2'] = `
pkg2: 0.2.4 -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) ### Bug Fixes -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb))
pkg3: 0.2.4 -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg3-v0.2.3...pkg3-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg3-v0.2.3...pkg3-v0.2.4) (1983-10-10) ### Miscellaneous Chores @@ -156,27 +156,27 @@ exports['LinkedVersions plugin should sync versions pull requests 1'] = `
pkg1: 1.0.1 -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa))
pkg2: 0.2.4 -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) ### Bug Fixes -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb))
pkg3: 0.2.4 -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg3-v0.2.3...pkg3-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg3-v0.2.3...pkg3-v0.2.4) (1983-10-10) ### Miscellaneous Chores diff --git a/__snapshots__/manifest.js b/__snapshots__/manifest.js index fccd533c3..10c491da1 100644 --- a/__snapshots__/manifest.js +++ b/__snapshots__/manifest.js @@ -3,12 +3,12 @@ exports['Manifest buildPullRequests should allow creating multiple pull requests --- -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -19,12 +19,12 @@ exports['Manifest buildPullRequests should allow creating multiple pull requests --- -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) ### Bug Fixes -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -37,35 +37,35 @@ exports['Manifest buildPullRequests should allow customizing pull request title
root: 1.2.2 -## [1.2.2](https://github.com/fake-owner/fake-repo/compare/root-v1.2.1...root-v1.2.2) (1983-10-10) +## [1.2.2](https://www.github.com/fake-owner/fake-repo/compare/root-v1.2.1...root-v1.2.2) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) -* some bugfix ([cccccc](https://github.com/fake-owner/fake-repo/commit/cccccc)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([cccccc](https://www.github.com/fake-owner/fake-repo/commit/cccccc))
pkg1: 1.0.2 -## [1.0.2](https://github.com/fake-owner/fake-repo/compare/pkg1-v1.0.1...pkg1-v1.0.2) (1983-10-10) +## [1.0.2](https://www.github.com/fake-owner/fake-repo/compare/pkg1-v1.0.1...pkg1-v1.0.2) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) -* some bugfix ([cccccc](https://github.com/fake-owner/fake-repo/commit/cccccc)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([cccccc](https://www.github.com/fake-owner/fake-repo/commit/cccccc))
pkg2: 0.2.4 -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) ### Bug Fixes -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb))
--- @@ -79,35 +79,35 @@ exports['Manifest buildPullRequests should allow customizing pull request title
root: 1.2.2 -## [1.2.2](https://github.com/fake-owner/fake-repo/compare/root-v1.2.1...root-v1.2.2) (1983-10-10) +## [1.2.2](https://www.github.com/fake-owner/fake-repo/compare/root-v1.2.1...root-v1.2.2) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) -* some bugfix ([cccccc](https://github.com/fake-owner/fake-repo/commit/cccccc)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([cccccc](https://www.github.com/fake-owner/fake-repo/commit/cccccc))
pkg1: 1.0.2 -## [1.0.2](https://github.com/fake-owner/fake-repo/compare/pkg1-v1.0.1...pkg1-v1.0.2) (1983-10-10) +## [1.0.2](https://www.github.com/fake-owner/fake-repo/compare/pkg1-v1.0.1...pkg1-v1.0.2) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) -* some bugfix ([cccccc](https://github.com/fake-owner/fake-repo/commit/cccccc)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([cccccc](https://www.github.com/fake-owner/fake-repo/commit/cccccc))
pkg2: 0.2.4 -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) ### Bug Fixes -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb))
--- @@ -119,12 +119,12 @@ exports['Manifest buildPullRequests should allow overriding commit message 1'] = --- -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/v1.0.0...v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/v1.0.0...v1.0.1) (1983-10-10) ### Bug Fixes -* real fix message ([def456](https://github.com/fake-owner/fake-repo/commit/def456)) +* real fix message ([def456](https://www.github.com/fake-owner/fake-repo/commit/def456)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). @@ -137,22 +137,22 @@ exports['Manifest buildPullRequests should handle mixing componentless configs 1
1.0.1 -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/v1.0.0...v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/v1.0.0...v1.0.1) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa))
pkg2: 0.2.4 -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) ### Bug Fixes -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb))
--- @@ -166,22 +166,22 @@ exports['Manifest buildPullRequests with multiple packages should handle multipl
pkg1: 1.0.1 -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/pkg1-v1.0.0...pkg1-v1.0.1) (1983-10-10) ### Bug Fixes -* some bugfix ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some bugfix ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa))
pkg2: 0.2.4 -## [0.2.4](https://github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) +## [0.2.4](https://www.github.com/fake-owner/fake-repo/compare/pkg2-v0.2.3...pkg2-v0.2.4) (1983-10-10) ### Bug Fixes -* some bugfix ([bbbbbb](https://github.com/fake-owner/fake-repo/commit/bbbbbb)) +* some bugfix ([bbbbbb](https://www.github.com/fake-owner/fake-repo/commit/bbbbbb))
--- diff --git a/__snapshots__/node-workspace.js b/__snapshots__/node-workspace.js index 41cba5516..3a1c42587 100644 --- a/__snapshots__/node-workspace.js +++ b/__snapshots__/node-workspace.js @@ -150,7 +150,7 @@ Release notes for path: node1, releaseType: node
pkgB: 2.2.3 -## [2.2.3](https://github.com/googleapis/node-test-repo/compare/pkgB-v2.2.2...pkgB-v2.2.3) (1983-10-10) +## [2.2.3](https://www.github.com/googleapis/node-test-repo/compare/pkgB-v2.2.2...pkgB-v2.2.3) (1983-10-10) ### Dependencies @@ -193,7 +193,7 @@ other notes ` exports['NodeWorkspace plugin run includes headers for packages with configured strategies 3'] = ` -## [2.2.3](https://github.com/googleapis/node-test-repo/compare/pkgB-v2.2.2...pkgB-v2.2.3) (1983-10-10) +## [2.2.3](https://www.github.com/googleapis/node-test-repo/compare/pkgB-v2.2.2...pkgB-v2.2.3) (1983-10-10) ### Dependencies @@ -250,7 +250,7 @@ Release notes for path: node1, releaseType: node
pkgB: 2.2.3-beta -## [2.2.3-beta](https://github.com/googleapis/node-test-repo/compare/pkgB-v2.2.2-beta...pkgB-v2.2.3-beta) (1983-10-10) +## [2.2.3-beta](https://www.github.com/googleapis/node-test-repo/compare/pkgB-v2.2.2-beta...pkgB-v2.2.3-beta) (1983-10-10) ### Dependencies diff --git a/__snapshots__/ruby-yoshi.js b/__snapshots__/ruby-yoshi.js index e3e4db8ab..386bdc324 100644 --- a/__snapshots__/ruby-yoshi.js +++ b/__snapshots__/ruby-yoshi.js @@ -8,7 +8,7 @@ exports['RubyYoshi buildReleasePullRequest returns release PR changes with semve #### Bug Fixes * update dependency com.google.cloud:google-cloud-spanner to v1.50.0 -* update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([#1234](https://github.com/googleapis/ruby-test-repo/issues/1234)) +* update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([#1234](https://www.github.com/googleapis/ruby-test-repo/issues/1234)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). diff --git a/__snapshots__/rust.js b/__snapshots__/rust.js index 4e1310a21..32b1d327c 100644 --- a/__snapshots__/rust.js +++ b/__snapshots__/rust.js @@ -3,13 +3,13 @@ exports['Rust buildReleasePullRequest detects a default component 1'] = ` --- -## [0.123.5](https://github.com/googleapis/rust-test-repo/compare/rust-test-repo-v0.123.4...crate1-v0.123.5) (1983-10-10) +## [0.123.5](https://www.github.com/googleapis/rust-test-repo/compare/rust-test-repo-v0.123.4...crate1-v0.123.5) (1983-10-10) ### Bug Fixes -* **deps:** update dependency com.google.cloud:google-cloud-spanner to v1.50.0 ([08ca011](https://github.com/googleapis/rust-test-repo/commit/08ca01180a91c0a1ba8992b491db9212)) -* **deps:** update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([845db13](https://github.com/googleapis/rust-test-repo/commit/845db1381b3d5d20151cad2588f85feb)) +* **deps:** update dependency com.google.cloud:google-cloud-spanner to v1.50.0 ([08ca011](https://www.github.com/googleapis/rust-test-repo/commit/08ca01180a91c0a1ba8992b491db9212)) +* **deps:** update dependency com.google.cloud:google-cloud-storage to v1.120.0 ([845db13](https://www.github.com/googleapis/rust-test-repo/commit/845db1381b3d5d20151cad2588f85feb)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). diff --git a/__snapshots__/separate-pull-requests-workspace.js b/__snapshots__/separate-pull-requests-workspace.js index 79dd3f998..e836fca5c 100644 --- a/__snapshots__/separate-pull-requests-workspace.js +++ b/__snapshots__/separate-pull-requests-workspace.js @@ -3,7 +3,7 @@ exports['Plugin compatibility separate-pull-requests and workspace plugin should --- -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/pkgA-v1.0.0...pkgA-v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/pkgA-v1.0.0...pkgA-v1.0.1) (1983-10-10) ### Dependencies @@ -21,7 +21,7 @@ exports['Plugin compatibility separate-pull-requests and workspace plugin should --- -## [1.0.1](https://github.com/fake-owner/fake-repo/compare/pkgB-v1.0.0...pkgB-v1.0.1) (1983-10-10) +## [1.0.1](https://www.github.com/fake-owner/fake-repo/compare/pkgB-v1.0.0...pkgB-v1.0.1) (1983-10-10) ### Dependencies @@ -39,12 +39,12 @@ exports['Plugin compatibility separate-pull-requests and workspace plugin should --- -## [1.1.0](https://github.com/fake-owner/fake-repo/compare/pkgC-v1.0.0...pkgC-v1.1.0) (1983-10-10) +## [1.1.0](https://www.github.com/fake-owner/fake-repo/compare/pkgC-v1.0.0...pkgC-v1.1.0) (1983-10-10) ### Features -* some feature ([aaaaaa](https://github.com/fake-owner/fake-repo/commit/aaaaaa)) +* some feature ([aaaaaa](https://www.github.com/fake-owner/fake-repo/commit/aaaaaa)) --- This PR was generated with [Release Please](https://github.com/googleapis/release-please). See [documentation](https://github.com/googleapis/release-please#release-please). diff --git a/docs/manifest-releaser.md b/docs/manifest-releaser.md index 9e19e1d33..89e499d6e 100644 --- a/docs/manifest-releaser.md +++ b/docs/manifest-releaser.md @@ -199,10 +199,13 @@ defaults (those are documented in comments) // absence defaults to false and all versions are fully Published. "prerelease": true - // Skip creating GitHub Releases - // Absence defaults to false and Releases will be created. Release-Please still + // Skip creating releases + // Absence defaults to false and releases will be created. Release-Please still // requires releases to be tagged, so this option should only be used if you // have existing infrastructure to tag these releases. + "skip-release": true, + + // [DEPRECATED] Legacy option for skipping GitHub releases. Prefer "skip-release". "skip-github-release": true, // Skip updating the changelog. diff --git a/package-lock.json b/package-lock.json index 6bbfd658d..6b1c46424 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "Apache-2.0", "dependencies": { "@conventional-commits/parser": "^0.4.1", + "@gitbeaker/rest": "^40.0.0", "@google-automations/git-file-utils": "^3.0.0", "@iarna/toml": "^3.0.0", "@octokit/graphql": "^7.1.0", @@ -300,6 +301,48 @@ "node": "*" } }, + "node_modules/@gitbeaker/core": { + "version": "40.6.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/core/-/core-40.6.0.tgz", + "integrity": "sha512-tVVm8ZPrS9YCHEcuPV8vD1IcEf9POpdygWo+kPvkK7LcC36EERVcXagb8snEaGgGLfUaVQh8qP4iDZgPnP3YBQ==", + "license": "MIT", + "dependencies": { + "@gitbeaker/requester-utils": "^40.6.0", + "qs": "^6.12.2", + "xcase": "^2.0.1" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@gitbeaker/requester-utils": { + "version": "40.6.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/requester-utils/-/requester-utils-40.6.0.tgz", + "integrity": "sha512-DQu2l3iXtB+8e1Ye2ekeUHABt4mGMRTLtuVWtFqf74sqJnerHNOxVOjPn19qu/nKdvKR3ZLwSRTtPzEsxgcShg==", + "license": "MIT", + "dependencies": { + "picomatch-browser": "^2.2.6", + "qs": "^6.12.2", + "rate-limiter-flexible": "^4.0.1", + "xcase": "^2.0.1" + }, + "engines": { + "node": ">=18.20.0" + } + }, + "node_modules/@gitbeaker/rest": { + "version": "40.6.0", + "resolved": "https://registry.npmjs.org/@gitbeaker/rest/-/rest-40.6.0.tgz", + "integrity": "sha512-sAwYJclU3NlB/gdxqhH6Hnmy5LWzvW7D3W33eShQEnxMhM0VjnFHPHcgJLQCIux3hMiub1uGtTw1hBJTxDc2mQ==", + "license": "MIT", + "dependencies": { + "@gitbeaker/core": "^40.6.0", + "@gitbeaker/requester-utils": "^40.6.0" + }, + "engines": { + "node": ">=18.20.0" + } + }, "node_modules/@google-automations/git-file-utils": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/@google-automations/git-file-utils/-/git-file-utils-3.0.0.tgz", @@ -580,6 +623,7 @@ "resolved": "https://registry.npmjs.org/@octokit/core/-/core-5.2.2.tgz", "integrity": "sha512-/g2d4sW9nUDJOMz3mabVQvOGhVa4e/BN/Um7yca9Bb2XTzPPnfTWHWQg+IsEYO7M3Vx+EXvaM/I2pJWIMun1bg==", "license": "MIT", + "peer": true, "dependencies": { "@octokit/auth-token": "^4.0.0", "@octokit/graphql": "^7.1.0", @@ -1112,6 +1156,7 @@ "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-4.33.0.tgz", "integrity": "sha512-ZohdsbXadjGBSK0/r+d87X0SBmKzOq4/S5nzK6SBgJspFo9/CUDJ7hjayuze+JK7CZQLDMroqytp7pOcFKTxZA==", "dev": true, + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "4.33.0", "@typescript-eslint/types": "4.33.0", @@ -1221,6 +1266,7 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-7.4.1.tgz", "integrity": "sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==", "dev": true, + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -1507,6 +1553,35 @@ "node": ">=12" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -2089,6 +2164,20 @@ "node": ">=8" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -2131,6 +2220,36 @@ "is-arrayish": "^0.2.1" } }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/escalade": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.1.tgz", @@ -2161,6 +2280,7 @@ "resolved": "https://registry.npmjs.org/eslint/-/eslint-7.32.0.tgz", "integrity": "sha512-VHZ8gX+EDfz+97jGcgyGCyRia/dPOd6Xh9yPv8Bl1+SoaIwD+a/vlrOmGRUyOYu7MwUhc7CxqeaDZU13S4+EpA==", "dev": true, + "peer": true, "dependencies": { "@babel/code-frame": "7.12.11", "@eslint/eslintrc": "^0.4.3", @@ -2836,9 +2956,13 @@ } }, "node_modules/function-bind": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.1.tgz", - "integrity": "sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A==" + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } }, "node_modules/functional-red-black-tree": { "version": "1.0.1", @@ -2863,6 +2987,43 @@ "node": "*" } }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -2974,6 +3135,18 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gts": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/gts/-/gts-3.1.1.tgz", @@ -3121,6 +3294,30 @@ "node": ">=6" } }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/he": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", @@ -3492,6 +3689,7 @@ "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "license": "MIT", + "peer": true, "engines": { "node": ">= 10.16.0" } @@ -3706,6 +3904,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/meow": { "version": "8.1.2", "resolved": "https://registry.npmjs.org/meow/-/meow-8.1.2.tgz", @@ -4146,6 +4353,18 @@ "node": ">=0.10.0" } }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/on-exit-leak-free": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.0.tgz", @@ -4367,6 +4586,18 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/picomatch-browser": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/picomatch-browser/-/picomatch-browser-2.2.6.tgz", + "integrity": "sha512-0ypsOQt9D4e3hziV8O4elD9uN0z/jtUEfxVRtNaAAtXIyUx9m/SzlO020i8YNL2aL/E6blOvvHQcin6HZlFy/w==", + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/pino": { "version": "10.0.0", "resolved": "https://registry.npmjs.org/pino/-/pino-10.0.0.tgz", @@ -4430,6 +4661,7 @@ "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.3.tgz", "integrity": "sha512-tJ/oJ4amDihPoufT5sM0Z1SKEuKay8LfVAMlbbhnnkvt6BUserZylqo2PN+p9KeljLr0OHa2rXHU1T8reeoTrw==", "dev": true, + "peer": true, "bin": { "prettier": "bin-prettier.js" }, @@ -4502,6 +4734,21 @@ "node": ">=6" } }, + "node_modules/qs": { + "version": "6.14.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.0.tgz", + "integrity": "sha512-YWWTjgABSKcvs/nWBi9PycY/JiPJqOD4JA6o9Sej2AtvSGarXxKC3OQSk4pAarbdQlKAh5D4FCQkJNkW+GAn3w==", + "license": "BSD-3-Clause", + "dependencies": { + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -4562,6 +4809,12 @@ "safe-buffer": "^5.1.0" } }, + "node_modules/rate-limiter-flexible": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/rate-limiter-flexible/-/rate-limiter-flexible-4.0.1.tgz", + "integrity": "sha512-2/dGHpDFpeA0+755oUkW+EKyklqLS9lu0go9pDsbhqQjZcxfRyJ6LA4JI0+HAdZ2bemD/oOjUeZQB2lCZqXQfQ==", + "license": "ISC" + }, "node_modules/read-pkg": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-5.2.0.tgz", @@ -4939,6 +5192,78 @@ "node": ">=8" } }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -5512,6 +5837,7 @@ "version": "4.9.4", "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.4.tgz", "integrity": "sha512-Uz+dTXYzxXXbsFpM86Wh3dKCxrQqUcVMxwU54orwlJjOpO3ao8L7j5lH+dWfTwgCwIuM9GQ2kvVotzYJMXTBZg==", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5779,6 +6105,12 @@ "typedarray-to-buffer": "^3.1.5" } }, + "node_modules/xcase": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/xcase/-/xcase-2.0.1.tgz", + "integrity": "sha512-UmFXIPU+9Eg3E9m/728Bii0lAIuoc+6nbrNUKaRPJOFp91ih44qqGlWtxMB6kXFrRD6po+86ksHM5XHCfk6iPw==", + "license": "MIT" + }, "node_modules/xpath": { "version": "0.0.34", "resolved": "https://registry.npmjs.org/xpath/-/xpath-0.0.34.tgz", diff --git a/package.json b/package.json index 219da88dc..9e6b67803 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "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", + "test25": "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", @@ -68,6 +69,7 @@ }, "dependencies": { "@conventional-commits/parser": "^0.4.1", + "@gitbeaker/rest": "^40.0.0", "@google-automations/git-file-utils": "^3.0.0", "@iarna/toml": "^3.0.0", "@octokit/graphql": "^7.1.0", diff --git a/schemas/config.json b/schemas/config.json index 9b5cec45b..1ce55e6b4 100644 --- a/schemas/config.json +++ b/schemas/config.json @@ -54,10 +54,15 @@ "description": "[DEPRECATED] Override the next version of this package. Consider using a `Release-As` commit instead.", "type": "string" }, - "skip-github-release": { - "description": "Skip tagging GitHub releases for this package. Release-Please still requires releases to be tagged, so this option should only be used if you have existing infrastructure to tag these releases.Defaults to `false`.", + "skip-release": { + "description": "Skip creating releases for this package. Release-Please still requires releases to be tagged, so this option should only be used if you have existing infrastructure to tag these releases. Defaults to `false`.", "type": "boolean" }, + "skip-github-release": { + "description": "[DEPRECATED] Use `skip-release` instead.", + "type": "boolean", + "deprecated": true + }, "skip-changelog": { "description": "Skip generating a changelog for this package. Defaults to `false`.", "type": "boolean" @@ -461,6 +466,7 @@ "versioning": true, "changelog-sections": true, "release-as": true, + "skip-release": true, "skip-github-release": true, "skip-changelog": true, "draft": true, diff --git a/src/bin/release-please.ts b/src/bin/release-please.ts index 39aa9cd42..718bee554 100644 --- a/src/bin/release-please.ts +++ b/src/bin/release-please.ts @@ -16,9 +16,11 @@ import {coerceOption} from '../util/coerce-option'; import * as yargs from 'yargs'; -import {GitHub, GH_API_URL, GH_GRAPHQL_URL} from '../github'; +import {GH_API_URL, GH_GRAPHQL_URL, GH_URL} from '../github'; +import {GL_API_URL, GL_URL} from '../gitlab'; import {Manifest, ManifestOptions, ROOT_PROJECT_PATH} from '../manifest'; import {ChangelogSection, buildChangelogSections} from '../changelog-notes'; +import ProviderFactory, {HostedGitClient} from '../provider'; import {logger, setLogger, CheckpointLogger} from '../util/logger'; import { getReleaserTypes, @@ -34,6 +36,62 @@ import {createPatch} from 'diff'; // eslint-disable-next-line @typescript-eslint/no-var-requires const parseGithubRepoUrl = require('parse-github-repo-url'); +interface RepoCoordinates { + owner: string; + repo: string; + host?: string; +} + +function parseGitLabRepoUrl(repoUrl: string): RepoCoordinates { + let raw = repoUrl.trim(); + if (!raw) { + throw new Error('repo-url is required'); + } + let host: string | undefined; + // Handle HTTP(S) URLs + if (/^https?:\/\//i.test(raw)) { + const url = new URL(raw); + host = url.origin; + raw = url.pathname; + } else if (raw.includes('@') && raw.includes(':')) { + // Basic SSH form git@gitlab.com:group/project.git + const sshParts = raw.split(':'); + raw = sshParts[sshParts.length - 1]; + const hostSource = sshParts[0]; + if (hostSource.includes('@')) { + const [, hostCandidate] = hostSource.split('@'); + if (hostCandidate) { + host = `https://${hostCandidate.replace(/:\d+$/, '')}`; + } + } + } + raw = raw.replace(/^\/+/, '').replace(/\.git$/i, ''); + const segments = raw.split('/').filter(Boolean); + if (segments.length < 2) { + throw new Error('GitLab repo URL must include a group and project'); + } + const repo = segments.pop()!; + const owner = segments.join('/'); + return {owner, repo, host}; +} + +function parseRepoCoordinates( + providerName: string, + repoUrl?: string +): RepoCoordinates { + if (!repoUrl) { + throw new Error('repo-url must be provided'); + } + if (providerName === 'gitlab') { + return parseGitLabRepoUrl(repoUrl); + } + const [owner, repo] = parseGithubRepoUrl(repoUrl); + if (!owner || !repo) { + throw new Error(`Could not parse repository from ${repoUrl}`); + } + return {owner, repo}; +} + interface ErrorObject { body?: object; status?: number; @@ -41,9 +99,10 @@ interface ErrorObject { stack: string; } -interface GitHubArgs { +interface ProviderArgs { dryRun?: boolean; trace?: boolean; + hostUrl?: string; repoUrl?: string; token?: string; apiUrl?: string; @@ -52,6 +111,7 @@ interface GitHubArgs { // deprecated in favor of targetBranch defaultBranch?: string; + provider?: string; // Which provider to use (github, gitlab, ...) targetBranch?: string; } @@ -120,7 +180,7 @@ interface TaggingArgs { } interface CreatePullRequestArgs - extends GitHubArgs, + extends ProviderArgs, ManifestArgs, ManifestConfigArgs, VersioningArgs, @@ -130,21 +190,21 @@ interface CreatePullRequestArgs changelogType?: ChangelogNotesType; } interface CreateReleaseArgs - extends GitHubArgs, + extends ProviderArgs, ManifestArgs, ManifestConfigArgs, ReleaseArgs, TaggingArgs {} interface CreateManifestPullRequestArgs - extends GitHubArgs, + extends ProviderArgs, ManifestArgs, PullRequestArgs {} interface CreateManifestReleaseArgs - extends GitHubArgs, + extends ProviderArgs, ManifestArgs, ReleaseArgs {} interface BootstrapArgs - extends GitHubArgs, + extends ProviderArgs, ManifestArgs, ManifestConfigArgs, VersioningArgs, @@ -153,32 +213,45 @@ interface BootstrapArgs ReleaseArgs { initialVersion?: string; } -interface DebugConfigArgs extends GitHubArgs, ManifestArgs {} +interface DebugConfigArgs extends ProviderArgs, ManifestArgs {} -function gitHubOptions(yargs: yargs.Argv): yargs.Argv { +function standardOptions(yargs: yargs.Argv): yargs.Argv { return yargs - .option('token', {describe: 'GitHub token with repo write permissions'}) + .option('token', { + describe: 'GitHub/GitLab token with repo write permissions', + }) .option('api-url', { describe: 'URL to use when making API requests', - default: GH_API_URL, type: 'string', + defaultDescription: `GitHub: ${GH_API_URL}; GitLab: ${GL_API_URL}`, + }) + .option('host-url', { + describe: 'URL to use when building changelog and release requests', + type: 'string', + defaultDescription: `GitHub: ${GH_URL}; GitLab: ${GL_URL}`, }) .option('graphql-url', { - describe: 'URL to use when making GraphQL requests', - default: GH_GRAPHQL_URL, + describe: 'URL to use when making GraphQL requests (ignored for GitLab)', + type: 'string', + defaultDescription: `GitHub: ${GH_GRAPHQL_URL}`, + }) + .option('provider', { + describe: "Which provider to use (default 'github')", + default: 'github', type: 'string', }) .option('default-branch', { - describe: 'The branch to open release PRs against and tag releases on', + describe: 'The branch to open release PR/MRs against and tag releases on', type: 'string', deprecated: 'use --target-branch instead', }) .option('target-branch', { - describe: 'The branch to open release PRs against and tag releases on', + describe: 'The branch to open release PR/MRs against and tag releases on', type: 'string', }) .option('repo-url', { - describe: 'GitHub URL to generate release for', + describe: + 'Repository URL to generate release for (e.g. GitHub: , GitLab: )', demand: true, }) .option('dry-run', { @@ -187,12 +260,20 @@ function gitHubOptions(yargs: yargs.Argv): yargs.Argv { default: false, }) .middleware(_argv => { - const argv = _argv as GitHubArgs; + const argv = _argv as ProviderArgs; // allow secrets to be loaded from file path // rather than being passed directly to the bin. if (argv.token) argv.token = coerceOption(argv.token); if (argv.apiUrl) argv.apiUrl = coerceOption(argv.apiUrl); + if (argv.hostUrl) argv.hostUrl = coerceOption(argv.hostUrl); if (argv.graphqlUrl) argv.graphqlUrl = coerceOption(argv.graphqlUrl); + const provider = (argv.provider || 'github').toLowerCase(); + if (!argv.apiUrl) { + argv.apiUrl = provider === 'gitlab' ? GL_API_URL : GH_API_URL; + } + if (!argv.graphqlUrl && provider === 'github') { + argv.graphqlUrl = GH_GRAPHQL_URL; + } }); } @@ -448,13 +529,13 @@ const createReleasePullRequestCommand: yargs.CommandModule< return manifestOptions( manifestConfigOptions( taggingOptions( - pullRequestOptions(pullRequestStrategyOptions(gitHubOptions(yargs))) + pullRequestOptions(pullRequestStrategyOptions(standardOptions(yargs))) ) ) ); }, async handler(argv) { - const github = await buildGitHub(argv); + const github = await buildProvider(argv); const targetBranch = argv.targetBranch || github.repository.defaultBranch; let manifest: Manifest; if (argv.releaseType) { @@ -542,68 +623,98 @@ const createReleasePullRequestCommand: yargs.CommandModule< }, }; -const createReleaseCommand: yargs.CommandModule<{}, CreateReleaseArgs> = { - command: 'github-release', - describe: 'create a GitHub release from a release PR', +async function handleReleaseCommand( + argv: CreateReleaseArgs, + options: {deprecatedCommandName?: string} = {} +): Promise { + if (options.deprecatedCommandName) { + logger.warn( + `${options.deprecatedCommandName} is deprecated. Please use release instead.` + ); + } + + const github = await buildProvider(argv); + const targetBranch = + argv.targetBranch || argv.defaultBranch || github.repository.defaultBranch; + let manifest: Manifest; + if (argv.releaseType) { + manifest = await Manifest.fromConfig( + github, + targetBranch, + { + releaseType: argv.releaseType, + component: argv.component, + packageName: argv.packageName, + draft: argv.draft, + prerelease: argv.prerelease, + includeComponentInTag: argv.monorepoTags, + includeVInTag: argv.includeVInTags, + }, + extractManifestOptions(argv), + argv.path + ); + } else { + const manifestOptions = extractManifestOptions(argv); + manifest = await Manifest.fromManifest( + github, + targetBranch, + argv.configFile, + argv.manifestFile, + manifestOptions + ); + } + + if (argv.dryRun) { + const releases = await manifest.buildReleases(); + logger.info(`Would tag ${releases.length} releases:`); + for (const release of releases) { + logger.info({ + name: release.name, + tag: release.tag.toString(), + notes: release.notes, + sha: release.sha, + draft: release.draft, + prerelease: release.prerelease, + pullNumber: release.pullRequest.number, + }); + } + } else { + const releaseNumbers = await manifest.createReleases(); + console.log(releaseNumbers); + } +} + +const releaseCommand: yargs.CommandModule<{}, CreateReleaseArgs> = { + command: 'release', + describe: 'create a release from a release PR/MR', builder(yargs) { return releaseOptions( manifestOptions( - manifestConfigOptions(taggingOptions(gitHubOptions(yargs))) + manifestConfigOptions(taggingOptions(standardOptions(yargs))) ) ); }, async handler(argv) { - const github = await buildGitHub(argv); - const targetBranch = - argv.targetBranch || - argv.defaultBranch || - github.repository.defaultBranch; - let manifest: Manifest; - if (argv.releaseType) { - manifest = await Manifest.fromConfig( - github, - targetBranch, - { - releaseType: argv.releaseType, - component: argv.component, - packageName: argv.packageName, - draft: argv.draft, - prerelease: argv.prerelease, - includeComponentInTag: argv.monorepoTags, - includeVInTag: argv.includeVInTags, - }, - extractManifestOptions(argv), - argv.path - ); - } else { - const manifestOptions = extractManifestOptions(argv); - manifest = await Manifest.fromManifest( - github, - targetBranch, - argv.configFile, - argv.manifestFile, - manifestOptions - ); - } + await handleReleaseCommand(argv); + }, +}; - if (argv.dryRun) { - const releases = await manifest.buildReleases(); - logger.info(`Would tag ${releases.length} releases:`); - for (const release of releases) { - logger.info({ - name: release.name, - tag: release.tag.toString(), - notes: release.notes, - sha: release.sha, - draft: release.draft, - prerelease: release.prerelease, - pullNumber: release.pullRequest.number, - }); - } - } else { - const releaseNumbers = await manifest.createReleases(); - console.log(releaseNumbers); - } +const deprecatedGithubReleaseCommand: yargs.CommandModule< + {}, + CreateReleaseArgs +> = { + command: 'github-release', + describe: 'create a release from a release PR/MR', + deprecated: 'use release instead', + builder(yargs) { + return releaseOptions( + manifestOptions( + manifestConfigOptions(taggingOptions(standardOptions(yargs))) + ) + ); + }, + async handler(argv) { + await handleReleaseCommand(argv, {deprecatedCommandName: 'github-release'}); }, }; @@ -615,11 +726,11 @@ const createManifestPullRequestCommand: yargs.CommandModule< describe: 'create a release-PR using a manifest file', deprecated: 'use release-pr instead.', builder(yargs) { - return manifestOptions(pullRequestOptions(gitHubOptions(yargs))); + return manifestOptions(pullRequestOptions(standardOptions(yargs))); }, async handler(argv) { logger.warn('manifest-pr is deprecated. Please use release-pr instead.'); - const github = await buildGitHub(argv); + const github = await buildProvider(argv); const targetBranch = argv.targetBranch || argv.defaultBranch || @@ -664,15 +775,13 @@ const createManifestReleaseCommand: yargs.CommandModule< > = { command: 'manifest-release', describe: 'create releases/tags from last release-PR using a manifest file', - deprecated: 'use github-release instead', + deprecated: 'use release instead', builder(yargs) { - return manifestOptions(releaseOptions(gitHubOptions(yargs))); + return manifestOptions(releaseOptions(standardOptions(yargs))); }, async handler(argv) { - logger.warn( - 'manifest-release is deprecated. Please use github-release instead.' - ); - const github = await buildGitHub(argv); + logger.warn('manifest-release is deprecated. Please use release instead.'); + const github = await buildProvider(argv); const targetBranch = argv.targetBranch || argv.defaultBranch || @@ -702,7 +811,7 @@ const bootstrapCommand: yargs.CommandModule<{}, BootstrapArgs> = { builder(yargs) { return manifestConfigOptions( manifestOptions( - releaseOptions(pullRequestStrategyOptions(gitHubOptions(yargs))) + releaseOptions(pullRequestStrategyOptions(standardOptions(yargs))) ) ) .option('initial-version', { @@ -713,7 +822,7 @@ const bootstrapCommand: yargs.CommandModule<{}, BootstrapArgs> = { }); }, async handler(argv) { - const github = await buildGitHub(argv); + const github = await buildProvider(argv); const targetBranch = argv.targetBranch || argv.defaultBranch || @@ -789,10 +898,10 @@ const debugConfigCommand: yargs.CommandModule<{}, DebugConfigArgs> = { command: 'debug-config', describe: 'debug manifest config', builder(yargs) { - return manifestConfigOptions(manifestOptions(gitHubOptions(yargs))); + return manifestConfigOptions(manifestOptions(standardOptions(yargs))); }, async handler(argv) { - const github = await buildGitHub(argv); + const github = await buildProvider(argv); const manifestOptions = extractManifestOptions(argv); const targetBranch = argv.targetBranch || @@ -809,21 +918,29 @@ const debugConfigCommand: yargs.CommandModule<{}, DebugConfigArgs> = { }, }; -async function buildGitHub(argv: GitHubArgs): Promise { - const [owner, repo] = parseGithubRepoUrl(argv.repoUrl); - const github = await GitHub.create({ +async function buildProvider(argv: ProviderArgs): Promise { + const providerName = (argv.provider || 'github').toLowerCase(); + const {owner, repo, host} = parseRepoCoordinates(providerName, argv.repoUrl); + let hostUrl = argv.hostUrl; + if (!hostUrl) { + hostUrl = host ?? (providerName === 'gitlab' ? GL_URL : GH_URL); + } + const provider = await ProviderFactory.create(providerName, { owner, repo, - token: argv.token!, + token: argv.token, apiUrl: argv.apiUrl, graphqlUrl: argv.graphqlUrl, + host, + hostUrl, }); - return github; + return provider; } export const parser = yargs .command(createReleasePullRequestCommand) - .command(createReleaseCommand) + .command(releaseCommand) + .command(deprecatedGithubReleaseCommand) .command(createManifestPullRequestCommand) .command(createManifestReleaseCommand) .command(bootstrapCommand) @@ -878,7 +995,7 @@ interface HandleError { } function extractManifestOptions( - argv: GitHubArgs & (PullRequestArgs | ReleaseArgs) + argv: ProviderArgs & (PullRequestArgs | ReleaseArgs) ): ManifestOptions { const manifestOptions: ManifestOptions = {}; if ('fork' in argv && argv.fork !== undefined) { diff --git a/src/bootstrapper.ts b/src/bootstrapper.ts index 6c31a33ad..a96c8572c 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 {HostedGitClient} from './provider'; 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: HostedGitClient; private targetBranch: string; private manifestFile: string; private configFile: string; private initialVersion: Version; constructor( - github: GitHub, + github: HostedGitClient, 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..f246c6988 100644 --- a/src/changelog-notes.ts +++ b/src/changelog-notes.ts @@ -24,6 +24,8 @@ export interface BuildNotesOptions { targetBranch: string; changelogSections?: ChangelogSection[]; commits?: Commit[]; + commitTemplateUri: string; + issueTemplateUri: string; } export interface ChangelogNotes { diff --git a/src/factories/changelog-notes-factory.ts b/src/factories/changelog-notes-factory.ts index 9113137b3..3c145acd6 100644 --- a/src/factories/changelog-notes-factory.ts +++ b/src/factories/changelog-notes-factory.ts @@ -13,6 +13,7 @@ // limitations under the License. import {GitHub} from '../github'; +import {HostedGitClient} from '../provider'; import {ChangelogNotes, ChangelogSection} from '../changelog-notes'; import {GitHubChangelogNotes} from '../changelog-notes/github'; import {DefaultChangelogNotes} from '../changelog-notes/default'; @@ -22,7 +23,7 @@ export type ChangelogNotesType = string; export interface ChangelogNotesFactoryOptions { type: ChangelogNotesType; - github: GitHub; + github: HostedGitClient; changelogSections?: ChangelogSection[]; commitPartial?: string; headerPartial?: string; @@ -34,7 +35,16 @@ export type ChangelogNotesBuilder = ( ) => ChangelogNotes; const changelogNotesFactories: Record = { - github: options => new GitHubChangelogNotes(options.github), + github: options => { + if (!(options.github instanceof GitHub)) { + throw new ConfigurationError( + 'GitHub changelog notes are only supported when using the GitHub provider', + 'core', + `${options.github.repository.owner}/${options.github.repository.repo}` + ); + } + return new GitHubChangelogNotes(options.github); + }, default: options => new DefaultChangelogNotes(options), }; diff --git a/src/factories/plugin-factory.ts b/src/factories/plugin-factory.ts index 5f2756e51..d10f43d80 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 {HostedGitClient} from '../provider'; 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: HostedGitClient; targetBranch: string; repositoryConfig: RepositoryConfig; manifestPath: string; diff --git a/src/factories/versioning-strategy-factory.ts b/src/factories/versioning-strategy-factory.ts index ba69b0714..db15c0137 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 {HostedGitClient} from '../provider'; 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: HostedGitClient; } export type VersioningStrategyBuilder = ( diff --git a/src/factory.ts b/src/factory.ts index 025cf1e48..21baece0d 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 {HostedGitClient} from './provider'; 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: HostedGitClient; path?: string; targetBranch?: string; } @@ -118,6 +118,7 @@ export async function buildStrategy( ): Promise { const targetBranch = options.targetBranch ?? options.github.repository.defaultBranch; + const skipRelease = options.skipRelease ?? options.skipGithubRelease ?? false; const versioningStrategy = buildVersioningStrategy({ github: options.github, type: options.versioning, @@ -132,9 +133,10 @@ export async function buildStrategy( changelogSections: options.changelogSections, }); const strategyOptions: BaseStrategyOptions = { - skipGitHubRelease: options.skipGithubRelease, // Note the case difference in GitHub - skipChangelog: options.skipChangelog, ...options, + skipRelease, + skipGitHubRelease: options.skipGithubRelease ?? skipRelease, // Deprecated alias retains behaviour + skipChangelog: options.skipChangelog, targetBranch, versioningStrategy, changelogNotes, diff --git a/src/github.ts b/src/github.ts index c0bbf3668..37323fa9d 100644 --- a/src/github.ts +++ b/src/github.ts @@ -22,13 +22,14 @@ import {graphql} from '@octokit/graphql'; import {RequestError} from '@octokit/request-error'; import { GitHubAPIError, - DuplicateReleaseError, FileNotFoundError, ConfigurationError, + DuplicateReleaseError, } from './errors'; const MAX_ISSUE_BODY_SIZE = 65536; const MAX_SLEEP_SECONDS = 20; +export const GH_URL = 'https://www.github.com'; export const GH_API_URL = 'https://api.github.com'; export const GH_GRAPHQL_URL = 'https://api.github.com'; type OctokitType = InstanceType; @@ -51,6 +52,8 @@ 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 {GitHubRelease} from './provider-interfaces'; +import {HostedGitClient} from './provider'; // Extract some types from the `request` package. type RequestBuilderType = typeof request; @@ -66,6 +69,7 @@ export interface GitHubOptions { repository: Repository; octokitAPIs: OctokitAPIs; logger?: Logger; + hostUrl?: string; } interface ProxyOption { @@ -73,7 +77,7 @@ interface ProxyOption { port: number; } -interface GitHubCreateOptions { +export interface GitHubCreateOptions { owner: string; repo: string; defaultBranch?: string; @@ -84,9 +88,10 @@ interface GitHubCreateOptions { logger?: Logger; proxy?: ProxyOption; fetch?: any; + hostUrl?: string; } -type CommitFilter = (commit: Commit) => boolean; +export type CommitFilter = (commit: Commit) => boolean; interface GraphQLCommit { sha: string; @@ -157,16 +162,16 @@ interface ReleaseHistory { data: GitHubRelease[]; } -interface CommitIteratorOptions { +export interface CommitIteratorOptions { maxResults?: number; backfillFiles?: boolean; } -interface ReleaseIteratorOptions { +export interface ReleaseIteratorOptions { maxResults?: number; } -interface TagIteratorOptions { +export interface TagIteratorOptions { maxResults?: number; } @@ -175,22 +180,6 @@ export interface ReleaseOptions { prerelease?: boolean; } -export interface GitHubRelease { - id: number; - name?: string; - tagName: string; - sha: string; - notes?: string; - url: string; - draft?: boolean; - uploadUrl?: string; -} - -export interface GitHubTag { - name: string; - sha: string; -} - interface FileDiff { readonly mode: '100644' | '100755' | '040000' | '160000' | '120000'; readonly content: string | null; @@ -198,18 +187,19 @@ interface FileDiff { } export type ChangeSet = Map; -interface CreatePullRequestOptions { +export interface CreatePullRequestOptions { fork?: boolean; draft?: boolean; } -export class GitHub { +export class GitHub implements HostedGitClient { readonly repository: Repository; private octokit: OctokitType; private request: RequestFunctionType; private graphql: Function; private fileCache: RepositoryFileCache; private logger: Logger; + private hostUrl?: string; private constructor(options: GitHubOptions) { this.repository = options.repository; @@ -218,6 +208,7 @@ export class GitHub { this.graphql = options.octokitAPIs.graphql; this.fileCache = new RepositoryFileCache(this.octokit, this.repository); this.logger = options.logger ?? defaultLogger; + this.hostUrl = options.hostUrl; } static createDefaultAgent(baseUrl: string, defaultProxy?: ProxyOption) { @@ -295,6 +286,7 @@ export class GitHub { }, octokitAPIs: apis, logger: options.logger, + hostUrl: options.hostUrl, }; return new GitHub(opts); } @@ -1560,6 +1552,19 @@ export class GitHub { return content.html_url; } + async getProviderDetails(): Promise<{ + hostUrl: string; + issueFormatUrl: string; + commitFormatUrl: string; + }> { + const hostUrl = this.hostUrl || GH_URL; + return { + hostUrl, + issueFormatUrl: `${hostUrl}/${this.repository.owner}/${this.repository.repo}/issues/{{id}}`, + commitFormatUrl: `${hostUrl}/${this.repository.owner}/${this.repository.repo}/commit/{{sha}}`, + }; + } + /** * Helper to fetch the SHA of a branch * @param {string} branchName The name of the branch diff --git a/src/gitlab.ts b/src/gitlab.ts new file mode 100644 index 000000000..77fa65fd7 --- /dev/null +++ b/src/gitlab.ts @@ -0,0 +1,1263 @@ +// Copyright 2025 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 {Gitlab as GitbeakerClient} from '@gitbeaker/rest'; +import {GitbeakerRequestError} from '@gitbeaker/requester-utils'; +import {logger as defaultLogger} from './util/logger'; +import type {Logger} from './util/logger'; +import type {Repository} from './repository'; +import type {Commit} from './commit'; +import type {PullRequest} from './pull-request'; +import type {ReleasePullRequest} from './release-pull-request'; +import type {Update} from './update'; +import type {Release} from './release'; +import type { + CommitIteratorOptions, + CreatePullRequestOptions, + ReleaseOptions, + ReleaseIteratorOptions, + TagIteratorOptions, +} from './github'; +import type {GitHubRelease, GitHubTag} from './provider-interfaces'; +import type {GitHubFileContents} from '@google-automations/git-file-utils'; +import type {PullRequestOverflowHandler} from './util/pull-request-overflow-handler'; +import {mergeUpdates} from './updaters/composite'; +import {HostedGitClient} from './provider'; + +type GitbeakerInstance = InstanceType; + +type GitLabFileContents = { + sha: string; + content: string; + parsedContent: string; + mode: string; + update: boolean; +}; + +type GitLabCommitAction = { + action: 'create' | 'update'; + filePath: string; + file_path?: string; + content: string; + encoding?: 'text'; + fileMode?: string; + file_mode?: string; +}; +type GitLabMergeRequest = { + iid: number; + title: string; + description?: string | null; + labels?: Array; +}; +type GitLabMergeRequestSummary = { + iid: number; + title: string; + description?: string | null; + source_branch: string; + target_branch: string; + state: string; + labels?: Array; + merge_commit_sha?: string | null; + merged_at?: string | null; +}; +type GitLabMergeRequestChanges = { + changes?: Array<{ + new_path?: string | null; + old_path?: string | null; + }>; +}; + +type GitLabNote = { + id?: number; + web_url?: string; + webUrl?: string; +}; +type GitLabReleaseSummary = { + name?: string | null; + tag_name?: string; + tagName?: string; + description?: string | null; + commit?: {id?: string | null} | null; + links?: {self?: string | null} | null; + _links?: {self?: string | null} | null; + upcoming_release?: boolean; + upcomingRelease?: boolean; +}; +export declare const DEFAULT_FILE_MODE = '100644'; + +interface FileDiff { + readonly mode: '100644' | '100755' | '040000' | '160000' | '120000'; + readonly content: string | null; + readonly originalContent: string | null; + readonly update: boolean; +} +export type GitLabChangeSet = Map; + +export const GL_URL = 'https://gitlab.com'; +export const GL_API_URL = 'https://gitlab.com/api/v4'; +const MAX_PER_PAGE = 100; +const API_SUFFIX = '/api/v4'; + +const stripApiSuffix = (url: string): string => + url.endsWith(API_SUFFIX) ? url.slice(0, -API_SUFFIX.length) : url; + +export interface GitLabCreateOptions { + owner: string; + repo: string; + defaultBranch?: string; + apiUrl?: string; + token?: string; + logger?: Logger; + host?: string; + hostUrl?: string; +} + +interface GitLabOptions { + repository: Repository; + token?: string; + apiUrl?: string; + logger?: Logger; + gitbeaker?: GitbeakerInstance; + hostUrl?: string; +} + +// GitLab may return config files that include a UTF-8 BOM, which breaks JSON.parse. +const stripBom = (value: string): string => + value.charCodeAt(0) === 0xfeff ? value.slice(1) : value; + +const isNotFoundError = (error: unknown): boolean => + error instanceof GitbeakerRequestError && + error.cause?.response?.status === 404; + +export class GitLab implements HostedGitClient { + readonly repository: Repository; + private readonly token?: string; + private readonly apiUrl: string; + private readonly logger: Logger; + private readonly gitbeaker: GitbeakerInstance; + private readonly hostUrl?: string; + + private constructor(options: GitLabOptions) { + this.repository = options.repository; + this.token = options.token; + this.apiUrl = options.apiUrl || GL_API_URL; + this.logger = options.logger ?? defaultLogger; + this.hostUrl = options.hostUrl; + const host = stripApiSuffix(this.apiUrl); + this.gitbeaker = + options.gitbeaker ?? + new GitbeakerClient(this.token ? {host, token: this.token} : {host}); + } + + /** + * Build a new GitLab client with auto-detected default branch. + */ + static async create(options: GitLabCreateOptions): Promise { + const apiUrl = options.host + ? `${options.host}/api/v4` + : options.apiUrl ?? GL_API_URL; + const host = stripApiSuffix(apiUrl); + const gitbeaker = new GitbeakerClient( + options.token ? {host, token: options.token} : {host} + ); + + const log = options.logger ?? defaultLogger; + log.debug('Creating GitLab client'); + log.debug(`Using API URL: ${apiUrl}`); + log.debug(`Using host: ${host}`); + log.debug(`Using owner: ${options.owner}`); + log.debug(`Using repo: ${options.repo}`); + + const repository: Repository = { + owner: options.owner, + repo: options.repo, + defaultBranch: + options.defaultBranch ?? + (await GitLab.defaultBranch(options.owner, options.repo, gitbeaker)), + }; + return new GitLab({ + repository, + token: options.token, + apiUrl, + logger: options.logger, + gitbeaker, + hostUrl: options.hostUrl, + }); + } + + /** + * Returns the default branch for a given repository. + */ + static async defaultBranch( + owner: string, + repo: string, + gitbeaker: GitbeakerInstance + ): Promise { + const projectPath = `${owner}/${repo}`; + defaultLogger.debug(`Resolving GitLab default branch for ${projectPath}`); + try { + const project = (await gitbeaker.Projects.show(projectPath)) as { + default_branch?: string; + }; + if (project?.default_branch) { + defaultLogger.debug( + `GitLab default branch for ${projectPath} is ${project.default_branch}` + ); + return project.default_branch; + } + defaultLogger.debug( + `GitLab project ${projectPath} did not specify a default branch; falling back to main` + ); + return 'main'; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + defaultLogger.warn( + `Failed to fetch GitLab project ${projectPath}: ${message}` + ); + throw new Error(`Failed to fetch GitLab project: ${message}`); + } + } + + async commitsSince( + targetBranch: string, + filter: (commit: Commit) => boolean, + options: CommitIteratorOptions = {} + ): 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; + } + + async *mergeCommitIterator( + targetBranch: string, + options: CommitIteratorOptions = {} + ): AsyncGenerator { + const maxResults = options.maxResults ?? Number.MAX_SAFE_INTEGER; + const projectPath = encodeURIComponent( + `${this.repository.owner}/${this.repository.repo}` + ); + let page = 1; + let results = 0; + while (results < maxResults) { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (this.token) { + headers['PRIVATE-TOKEN'] = this.token; + } + const response = await fetch( + `${this.apiUrl}/projects/${projectPath}/repository/commits?ref_name=${targetBranch}&per_page=${MAX_PER_PAGE}&page=${page}`, + {headers} + ); + if (!response.ok) { + this.logger.warn( + `Failed to fetch commits for branch ${targetBranch}: ${response.status}` + ); + break; + } + const gitlabCommits = (await response.json()) as { + id: string; + message: string; + }[]; + if (!gitlabCommits.length) { + break; + } + for (const gitlabCommit of gitlabCommits) { + if (results >= maxResults) { + break; + } + results += 1; + const commit: Commit = { + sha: gitlabCommit.id, + message: gitlabCommit.message, + files: [], + }; + // TODO: Fetch associated merge requests and files when support is added. + if (options.backfillFiles) { + this.logger.warn( + `backfillFiles requested for commit ${gitlabCommit.id}, but GitLab provider does not yet populate file lists.` + ); + } + yield commit; + } + if (gitlabCommits.length < MAX_PER_PAGE) { + break; + } + page += 1; + } + } + + async *pullRequestIterator( + targetBranch: string, + status: 'OPEN' | 'CLOSED' | 'MERGED' = 'MERGED', + maxResults: number = Number.MAX_SAFE_INTEGER, + includeFiles = true + ): AsyncGenerator { + const stateMap: Record = { + OPEN: 'opened', + CLOSED: 'closed', + MERGED: 'merged', + }; + const projectPath = this.projectPath(); + const perPage = MAX_PER_PAGE; + let page = 1; + let results = 0; + while (results < maxResults) { + let mergeRequests: GitLabMergeRequestSummary[]; + try { + mergeRequests = (await this.gitbeaker.MergeRequests.all({ + projectId: projectPath, + targetBranch, + state: stateMap[status], + perPage, + page, + orderBy: 'updated_at', + sort: 'desc', + })) as GitLabMergeRequestSummary[]; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `Failed to fetch merge requests for branch ${targetBranch}: ${message}` + ); + break; + } + + if (!mergeRequests.length) { + break; + } + + for (const mergeRequest of mergeRequests) { + if (results >= maxResults) { + break; + } + + if (mergeRequest.target_branch !== targetBranch) { + continue; + } + + let files: string[] = []; + if (includeFiles) { + try { + const changes = (await this.gitbeaker.MergeRequests.showChanges( + projectPath, + mergeRequest.iid + )) as GitLabMergeRequestChanges; + if (Array.isArray(changes?.changes)) { + const fileSet = new Set(); + for (const change of changes.changes) { + if (change?.new_path) { + fileSet.add(change.new_path); + } + if (change?.old_path) { + fileSet.add(change.old_path); + } + } + files = Array.from(fileSet); + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn( + `Failed to fetch changes for merge request !${mergeRequest.iid}: ${message}` + ); + } + } + + results += 1; + yield { + headBranchName: mergeRequest.source_branch, + baseBranchName: mergeRequest.target_branch, + number: mergeRequest.iid, + title: mergeRequest.title, + body: mergeRequest.description ?? '', + labels: this.extractLabelNames( + mergeRequest.labels as GitLabMergeRequest['labels'] + ), + files, + mergeCommitOid: mergeRequest.merge_commit_sha ?? undefined, + sha: mergeRequest.merge_commit_sha ?? undefined, + }; + } + + if (mergeRequests.length < perPage) { + break; + } + page += 1; + } + } + + async *releaseIterator( + options: ReleaseIteratorOptions = {} + ): AsyncGenerator { + const maxResults = options.maxResults ?? Number.MAX_SAFE_INTEGER; + const projectPath = this.projectPath(); + const perPage = MAX_PER_PAGE; + let page = 1; + let results = 0; + while (results < maxResults) { + let releases: GitLabReleaseSummary[]; + try { + releases = (await this.gitbeaker.ProjectReleases.all(projectPath, { + perPage, + page, + })) as GitLabReleaseSummary[]; + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + this.logger.warn(`Failed to fetch releases: ${message}`); + break; + } + if (!releases.length) { + break; + } + for (const release of releases) { + if (results >= maxResults) { + break; + } + const rawTagName = + (release as {tagName?: string}).tagName ?? + (release as {tag_name?: string}).tag_name; + const tagName = + typeof rawTagName === 'string' && rawTagName.trim().length > 0 + ? rawTagName.trim() + : undefined; + if (!tagName) { + continue; + } + const commitId = + typeof release.commit?.id === 'string' && release.commit.id.length > 0 + ? release.commit.id + : undefined; + if (!commitId) { + continue; + } + results += 1; + yield { + id: this.releaseNumericId(tagName), + name: release.name ?? undefined, + tagName, + sha: commitId, + notes: release.description ?? undefined, + url: this.releaseWebUrl(tagName), + draft: + release.upcomingRelease ?? release.upcoming_release ?? undefined, + }; + } + if (releases.length < perPage) { + break; + } + page += 1; + } + } + + async *tagIterator( + options: TagIteratorOptions = {} + ): AsyncGenerator { + const maxResults = options.maxResults ?? Number.MAX_SAFE_INTEGER; + const projectPath = encodeURIComponent( + `${this.repository.owner}/${this.repository.repo}` + ); + let page = 1; + let results = 0; + while (results < maxResults) { + const headers: Record = { + 'Content-Type': 'application/json', + }; + if (this.token) { + headers['PRIVATE-TOKEN'] = this.token; + } + const response = await fetch( + `${this.apiUrl}/projects/${projectPath}/repository/tags?per_page=${MAX_PER_PAGE}&page=${page}`, + {headers} + ); + if (!response.ok) { + this.logger.warn(`Failed to fetch tags: ${response.status}`); + break; + } + const tags = (await response.json()) as { + name: string; + commit: {id: string}; + }[]; + if (!tags.length) { + break; + } + for (const tag of tags) { + if (results >= maxResults) { + break; + } + results += 1; + yield { + name: tag.name, + sha: tag.commit.id, + }; + } + if (tags.length < MAX_PER_PAGE) { + break; + } + page += 1; + } + } + + async getFileContents(path: string): Promise { + return this.getFileContentsOnBranch(path, this.repository.defaultBranch); + } + + async getFileContentsOnBranch( + path: string, + branch: string + ): Promise { + const projectPath = this.projectPath(); + try { + const file = (await this.gitbeaker.RepositoryFiles.show( + projectPath, + path, + branch + )) as { + blob_id: string; + content: string; + encoding?: string; + file_mode?: string; + }; + + return { + sha: file.blob_id, + content: file.content, + parsedContent: file.content, + mode: file.file_mode ?? '100644', + update: true, + }; + } catch (err) { + if (isNotFoundError(err)) { + return undefined as unknown as GitLabFileContents; + } + const message = err instanceof Error ? err.message : String(err); + throw new Error(`Failed to fetch file ${path}: ${message}`); + } + } + + async getFileJson(path: string, branch: string): Promise { + const fileContents = await this.getFileContentsOnBranch(path, branch); + return JSON.parse(stripBom(fileContents.parsedContent)) as T; + } + + async findFilesByFilename( + filename: string, + prefix?: string + ): Promise { + return this.findFilesByFilenameAndRef( + filename, + this.repository.defaultBranch, + prefix + ); + } + + async findFilesByFilenameAndRef( + filename: string, + ref: string, + prefix?: string + ): Promise { + void filename; + void ref; + void prefix; + this.logger.warn('findFilesByFilenameAndRef not yet fully implemented'); + return []; + } + + async findFilesByGlob(glob: string, prefix?: string): Promise { + return this.findFilesByGlobAndRef( + glob, + this.repository.defaultBranch, + prefix + ); + } + + async findFilesByGlobAndRef( + glob: string, + ref: string, + prefix?: string + ): Promise { + void glob; + void ref; + void prefix; + this.logger.warn('findFilesByGlobAndRef not yet fully implemented'); + return []; + } + + async findFilesByExtension( + extension: string, + prefix?: string + ): Promise { + return this.findFilesByExtensionAndRef( + extension, + this.repository.defaultBranch, + prefix + ); + } + + async findFilesByExtensionAndRef( + extension: string, + ref: string, + prefix?: string + ): Promise { + void extension; + void ref; + void prefix; + this.logger.warn('findFilesByExtensionAndRef not yet fully implemented'); + return []; + } + + /** + * 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 { + // Sometimes multiple updates are proposed for the same file, + // such as when the manifest file is additionally changed by the + // node-workspace plugin. We need to merge these updates. + const mergedUpdates = mergeUpdates(updates); + this.logger.debug( + `Building change set with ${mergedUpdates.length} updates for ${defaultBranch}` + ); + const changes = new Map(); + for (const update of mergedUpdates) { + let content: GitHubFileContents | undefined; + try { + content = await this.getFileContentsOnBranch( + update.path, + defaultBranch + ); + } catch (err) { + // if the file is missing and create = false, just continue + // to the next update, otherwise create the file. + } + + if (content === undefined && !update.createIfMissing) { + this.logger.warn(`file ${update.path} did not exist`); + continue; + } + + if (content === undefined) { + this.logger.debug( + `Planning to create new file ${update.path} on ${defaultBranch}` + ); + } else { + this.logger.debug(`Planning to update ${update.path}`); + } + + const contentText = content + ? Buffer.from(content.content, 'base64').toString('utf8') + : undefined; + const updatedContent = update.updater.updateContent( + contentText, + this.logger + ); + if (updatedContent) { + changes.set(update.path, { + content: updatedContent, + originalContent: content?.parsedContent || null, + mode: content?.mode || DEFAULT_FILE_MODE, + update: content !== undefined, + }); + } + } + return changes; + } + + createPullRequest = wrapAsync( + async ( + pullRequest: PullRequest, + targetBranch: string, + message: string, + updates: Update[], + options?: CreatePullRequestOptions + ): Promise => { + if (options?.fork) { + throw new Error( + 'GitLab provider does not yet support fork-based pull requests' + ); + } + const changes = await this.buildChangeSet(updates, targetBranch); + const changedFiles = Array.from(changes.keys()); + + const projectPath = this.projectPath(); + const sourceBranch = pullRequest.headBranchName; + const mergeRequestTitle = this.normalizeMergeRequestTitle( + pullRequest.title, + options?.draft + ); + + this.logger.debug( + `Creating merge request from ${sourceBranch} to ${targetBranch} with title "${mergeRequestTitle}"` + ); + + try { + this.logger.info( + `Creating branch ${sourceBranch} from ${targetBranch}` + ); + + // Commit the changes using gitbreaker Commits API + const actions = this.buildCommitActions(changes); + this.logger.debug( + `Prepared ${actions.length} commit action(s) across ${changedFiles.length} file(s)` + ); + if (actions.length) { + await this.gitbeaker.Commits.create( + projectPath, + sourceBranch, + message, + actions, + { + startBranch: targetBranch, + force: true, + } + ); + } else { + this.logger.info(`No changes to commit on branch ${sourceBranch}`); + // create branch without changes + await this.gitbeaker.Branches.create( + projectPath, + sourceBranch, + targetBranch + ); + } + + this.logger.info(`Created branch ${sourceBranch} from ${targetBranch}`); + } catch (err) { + if (this.isBranchAlreadyExistsError(err)) { + this.logger.info(`Branch ${sourceBranch} already exists; reusing.`); + } else { + throw err; + } + } + + // Create the merge request + const mergeRequest = (await this.gitbeaker.MergeRequests.create( + projectPath, + sourceBranch, + targetBranch, + mergeRequestTitle, + { + description: pullRequest.body ?? '', + removeSourceBranch: true, + squash: true, + labels: this.formatLabels(pullRequest.labels), + } + )) as GitLabMergeRequest; + + this.logger.debug( + `Created merge request !${mergeRequest.iid} for ${sourceBranch}` + ); + + return { + headBranchName: sourceBranch, + baseBranchName: targetBranch, + number: mergeRequest.iid, + title: mergeRequest.title, + body: mergeRequest.description ?? '', + files: changedFiles, + labels: this.extractLabelNames(mergeRequest.labels), + sha: (mergeRequest as {sha?: string}).sha ?? undefined, + }; + } + ); + + private buildCommitActions(changes: GitLabChangeSet): GitLabCommitAction[] { + const actions: GitLabCommitAction[] = []; + for (const [filePath, change] of changes.entries()) { + if (change.content === null || change.content === undefined) { + continue; + } + const action: GitLabCommitAction = { + action: change.update === false ? 'create' : 'update', // Original content can be null for an empty file + filePath, + file_path: filePath, + content: change.content, + encoding: 'text', + }; + if (change.mode) { + action.fileMode = change.mode; + action.file_mode = change.mode; + } + actions.push(action); + } + return actions; + } + + private releaseNumericId(tagName: string): number { + let hash = 0; + for (let i = 0; i < tagName.length; i++) { + hash = (hash << 5) - hash + tagName.charCodeAt(i); + hash |= 0; + } + const value = Math.abs(hash); + return value === 0 ? 1 : value; + } + + private releaseWebUrl(tagName: string): string { + const host = stripApiSuffix(this.apiUrl); + const encodedTag = encodeURIComponent(tagName); + return `${host}/${this.repository.owner}/${this.repository.repo}/-/releases/${encodedTag}`; + } + + private resourceWebUrl( + kind: 'merge_requests' | 'issues', + iid: number + ): string { + const host = stripApiSuffix(this.apiUrl); + return `${host}/${this.repository.owner}/${this.repository.repo}/-/${kind}/${iid}`; + } + + private noteWebUrl( + kind: 'merge_requests' | 'issues', + iid: number, + note?: GitLabNote | null + ): string { + const baseUrl = this.resourceWebUrl(kind, iid); + if (!note) { + return baseUrl; + } + const noteUrl = note.web_url ?? note.webUrl; + if (typeof noteUrl === 'string' && noteUrl.length > 0) { + return noteUrl; + } + if (typeof note?.id === 'number') { + return `${baseUrl}#note_${note.id}`; + } + return baseUrl; + } + + private projectPath(): string { + return `${this.repository.owner}/${this.repository.repo}`; + } + + private isBranchAlreadyExistsError(error: unknown): boolean { + if (!(error instanceof Error)) { + return false; + } + if (/branch already exists/i.test(error.message)) { + return true; + } + const response = ( + error as {response?: {status?: number; data?: unknown; body?: unknown}} + ).response; + if (!response) { + return false; + } + const status = response.status; + if (status !== 400 && status !== 409) { + return false; + } + const responseText = + typeof response.data === 'string' + ? response.data + : typeof response.body === 'string' + ? response.body + : ''; + return /branch already exists/i.test(responseText); + } + + private normalizeMergeRequestTitle(title: string, draft?: boolean): string { + if (!draft) { + return title; + } + return /^(\s*(draft|wip):)/i.test(title) ? title : `Draft: ${title}`; + } + + private formatLabels(labels?: string[] | null): string | undefined { + if (!labels || labels.length === 0) { + return undefined; + } + return labels.join(','); + } + + private extractLabelNames(labels: GitLabMergeRequest['labels']): string[] { + if (!Array.isArray(labels)) { + return []; + } + const names: string[] = []; + for (const label of labels) { + if (typeof label === 'string') { + names.push(label); + } else if ( + label && + typeof label === 'object' && + 'name' in label && + typeof (label as {name?: unknown}).name === 'string' + ) { + names.push((label as {name: string}).name); + } + } + return names; + } + + async createReleasePullRequest( + releasePullRequest: ReleasePullRequest, + targetBranch: string, + options?: { + signoffUser?: string; + fork?: boolean; + skipLabeling?: boolean; + } + ): Promise { + void releasePullRequest; + void targetBranch; + void options; + throw new Error('createReleasePullRequest not yet implemented for GitLab'); + } + + async getPullRequest(number: number): Promise { + void number; + throw new Error('getPullRequest not yet implemented for GitLab'); + } + + updatePullRequest = wrapAsync( + async ( + number: number, + releasePullRequest: ReleasePullRequest, + targetBranch: string, + options?: { + signoffUser?: string; + fork?: boolean; + pullRequestOverflowHandler?: PullRequestOverflowHandler; + } + ): Promise => { + const changes = await this.buildChangeSet( + releasePullRequest.updates, + targetBranch + ); + this.logger.debug( + `Updating merge request !${number} with ${changes.size} file change(s)` + ); + const title = releasePullRequest.title.toString(); + const body = ( + options?.pullRequestOverflowHandler + ? await options.pullRequestOverflowHandler.handleOverflow( + releasePullRequest + ) + : releasePullRequest.body + ) + .toString() + .slice(0, 1048576); // GitLab limit is 1 MiB + + this.logger.info( + `Creating branch ${releasePullRequest.headRefName} from ${targetBranch}` + ); + const projectPath = this.projectPath(); + + // Commit the changes using gitbreaker Commits API + const actions = this.buildCommitActions(changes); + this.logger.debug( + `Prepared ${actions.length} commit action(s) for branch ${releasePullRequest.headRefName}` + ); + await this.gitbeaker.Commits.create( + projectPath, + releasePullRequest.headRefName, + title, + actions, + { + force: true, + startBranch: targetBranch, + } + ); + + // Update the merge request + const mergeRequest = (await this.gitbeaker.MergeRequests.edit( + projectPath, + number, + { + title, + description: body, + labels: this.formatLabels(releasePullRequest.labels), + } + )) as GitLabMergeRequest; + + this.logger.debug(`Updated merge request !${mergeRequest.iid}`); + + return { + headBranchName: releasePullRequest.headRefName, + baseBranchName: targetBranch, + number: mergeRequest.iid, + title: title, + body: body || '', + files: [], + labels: releasePullRequest.labels, + }; + } + ); + + createRelease = wrapAsync( + async ( + release: Release, + options: ReleaseOptions = {} + ): Promise => { + const projectPath = this.projectPath(); + const tagName = release.tag.toString(); + + if (options.draft) { + this.logger.warn( + 'GitLab provider does not support draft releases; ignoring draft flag.' + ); + } + + if (options.prerelease) { + this.logger.warn( + 'GitLab provider does not support prerelease releases; ignoring prerelease flag.' + ); + } + + try { + this.logger.debug( + `Creating GitLab release ${tagName} targeting ${release.sha}` + ); + const gitlabRelease = (await this.gitbeaker.ProjectReleases.create( + projectPath, + { + name: release.name ?? tagName, + tagName, + description: release.notes, + ref: release.sha, + } + )) as GitLabReleaseSummary; + this.logger.debug( + `Created GitLab release ${tagName} pointing at ${release.sha}` + ); + + const commitSha = + typeof gitlabRelease.commit?.id === 'string' && + gitlabRelease.commit.id.trim().length > 0 + ? gitlabRelease.commit.id + : release.sha; + + const url = + gitlabRelease._links?.self ?? + gitlabRelease.links?.self ?? + this.releaseWebUrl(tagName); + + const draft = + gitlabRelease.upcomingRelease ?? + gitlabRelease.upcoming_release ?? + undefined; + + return { + id: this.releaseNumericId(tagName), + name: gitlabRelease.name ?? release.name ?? undefined, + tagName, + sha: commitSha, + notes: gitlabRelease.description ?? release.notes, + url, + draft, + }; + } catch (err) { + if (err instanceof GitbeakerRequestError) { + const status = err.cause?.response?.status; + if (status === 409) { + this.logger.error(`Release ${tagName} already exists`); + } + } + throw err; + } + } + ); + + async generateReleaseNotes( + tagName: string, + targetCommitish: string, + previousTag?: string + ): Promise { + void tagName; + void targetCommitish; + void previousTag; + this.logger.warn('generateReleaseNotes not yet fully implemented'); + return ''; + } + + /** + * Makes a comment on an issue or merge request. + * + * @param {string} comment - The body of the comment to post. + * @param {number} number - The issue or merge request number. + * @throws {GitHubAPIError} on an API error + */ + commentOnIssue = wrapAsync( + async (comment: string, number: number): Promise => { + const projectPath = this.projectPath(); + this.logger.debug( + `adding comment to ${this.resourceWebUrl('merge_requests', number)}` + ); + try { + const note = (await this.gitbeaker.MergeRequestNotes.create( + projectPath, + number, + comment + )) as GitLabNote | null; + return this.noteWebUrl('merge_requests', number, note); + } catch (err) { + if (err instanceof GitbeakerRequestError) { + const status = err.cause?.response?.status; + if (status === 404) { + this.logger.debug( + `merge request !${number} not found; attempting issue #${number}` + ); + const issueNote = (await this.gitbeaker.IssueNotes.create( + projectPath, + number, + comment + )) as GitLabNote | null; + return this.noteWebUrl('issues', number, issueNote); + } + } + throw err; + } + } + ); + + /** + * Removes labels from an issue or merge request. + * + * @param {string[]} labels The labels to remove. + * @param {number} number The issue or merge request number. + */ + removeIssueLabels = wrapAsync( + async (labels: string[], number: number): Promise => { + if (labels.length === 0) { + return; + } + const projectPath = this.projectPath(); + const labelList = this.formatLabels(labels); + if (!labelList) { + return; + } + this.logger.debug( + `removing labels: ${labels} from ${this.resourceWebUrl( + 'merge_requests', + number + )}` + ); + try { + await this.gitbeaker.MergeRequests.edit(projectPath, number, { + removeLabels: labelList, + }); + } catch (err) { + if (err instanceof GitbeakerRequestError) { + const status = err.cause?.response?.status; + if (status === 404) { + this.logger.debug( + `merge request !${number} not found; attempting issue #${number}` + ); + await this.gitbeaker.Issues.edit(projectPath, number, { + removeLabels: labelList, + }); + return; + } + } + throw err; + } + } + ); + + /** + * Adds labels to an issue or merge request. + * + * @param {string[]} labels The labels to add. + * @param {number} number The issue or merge request number. + */ + addIssueLabels = wrapAsync( + async (labels: string[], number: number): Promise => { + if (labels.length === 0) { + return; + } + const projectPath = this.projectPath(); + const labelList = this.formatLabels(labels); + if (!labelList) { + return; + } + this.logger.debug( + `adding labels: ${labels} to ${this.resourceWebUrl( + 'merge_requests', + number + )}` + ); + try { + await this.gitbeaker.MergeRequests.edit(projectPath, number, { + addLabels: labelList, + }); + } catch (err) { + if (err instanceof GitbeakerRequestError) { + const status = err.cause?.response?.status; + if (status === 404) { + this.logger.debug( + `merge request !${number} not found; attempting issue #${number}` + ); + await this.gitbeaker.Issues.edit(projectPath, number, { + addLabels: labelList, + }); + return; + } + } + throw err; + } + } + ); + + async createFileOnNewBranch( + filename: string, + contents: string, + newBranchName: string, + baseBranchName: string + ): Promise { + void filename; + void contents; + void newBranchName; + void baseBranchName; + throw new Error('createFileOnNewBranch not yet implemented for GitLab'); + } + + async getProviderDetails(): Promise<{ + hostUrl: string; + issueFormatUrl: string; + commitFormatUrl: string; + }> { + const hostUrl = this.hostUrl ?? stripApiSuffix(this.apiUrl); + const repoPath = `${this.repository.owner}/${this.repository.repo}`; + + return { + hostUrl, + issueFormatUrl: `${hostUrl}/${repoPath}/-/issues/{{id}}`, + commitFormatUrl: `${hostUrl}/${repoPath}/-/commit/{{sha}}`, + }; + } +} + +/** + * Wrap an async method with error handling + * + * @param fn Async function that can throw Errors + */ +/* eslint-disable @typescript-eslint/no-explicit-any */ +const wrapAsync = , V>(fn: (...args: T) => Promise) => { + return async (...args: T): Promise => { + return await fn(...args); + }; +}; diff --git a/src/index.ts b/src/index.ts index 31eed9f8b..e3f1232b8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -59,6 +59,7 @@ export { } from './changelog-notes'; export {Logger, setLogger} from './util/logger'; export {GitHub} from './github'; +export {GitLab} from './gitlab'; export const configSchema = require('../../schemas/config.json'); export const manifestSchema = require('../../schemas/manifest.json'); diff --git a/src/manifest.ts b/src/manifest.ts index 576ad8ce4..25bb35971 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 {GitHubRelease, GitHubTag} from './provider-interfaces'; import {Version, VersionsMap} from './version'; import {Commit, parseConventionalCommits} from './commit'; import {PullRequest} from './pull-request'; @@ -47,6 +47,7 @@ import { } from './util/pull-request-overflow-handler'; import {signoffCommitMessage} from './util/signoff-commit-message'; import {CommitExclude} from './util/commit-exclude'; +import {HostedGitClient} from './provider'; type ExtraGenericFile = { type: 'generic'; @@ -104,7 +105,9 @@ export interface ReleaserConfig { // Strategy options releaseAs?: string; - skipGithubRelease?: boolean; // Note this should be renamed to skipGitHubRelease in next major release\ + skipRelease?: boolean; + /** @deprecated use skipRelease */ + skipGithubRelease?: boolean; skipChangelog?: boolean; draft?: boolean; prerelease?: boolean; @@ -163,6 +166,7 @@ interface ReleaserConfigJson { 'changelog-sections'?: ChangelogSection[]; 'release-as'?: string; 'skip-github-release'?: boolean; + 'skip-release'?: boolean; 'skip-changelog'?: boolean; draft?: boolean; prerelease?: boolean; @@ -286,6 +290,8 @@ const DEFAULT_COMMIT_SEARCH_DEPTH = 500; export const MANIFEST_PULL_REQUEST_TITLE_PATTERN = 'chore: release ${branch}'; +let hasWarnedDeprecatedSkipGithubRelease = false; + export interface CreatedRelease extends GitHubRelease { id: number; path: string; @@ -298,7 +304,7 @@ export interface CreatedRelease extends GitHubRelease { export class Manifest { private repository: Repository; - private github: GitHub; + private github: HostedGitClient; readonly repositoryConfig: RepositoryConfig; readonly releasedVersions: ReleasedVersions; private targetBranch: string; @@ -356,7 +362,7 @@ export class Manifest { * pull request. Defaults to `[autorelease: tagged]` */ constructor( - github: GitHub, + github: HostedGitClient, targetBranch: string, repositoryConfig: RepositoryConfig, releasedVersions: ReleasedVersions, @@ -420,7 +426,7 @@ export class Manifest { * @returns {Manifest} */ static async fromManifest( - github: GitHub, + github: HostedGitClient, targetBranch: string, configFile: string = DEFAULT_RELEASE_PLEASE_CONFIG, manifestFile: string = DEFAULT_RELEASE_PLEASE_MANIFEST, @@ -476,7 +482,7 @@ export class Manifest { * @returns {Manifest} */ static async fromConfig( - github: GitHub, + github: HostedGitClient, targetBranch: string, config: ReleaserConfig, manifestOptions?: ManifestOptions, @@ -862,7 +868,7 @@ export class Manifest { } else { if ( strategiesByPath[ROOT_PROJECT_PATH] && - this.repositoryConfig[path].skipGithubRelease + isReleaseSkipped(this.repositoryConfig[path]) ) { this.logger.debug('could not find release, checking root package'); const rootComponent = await strategiesByPath[ @@ -1372,6 +1378,32 @@ export class Manifest { function extractReleaserConfig( config: ReleaserPackageConfig ): Partial { + const skipReleaseValue = config['skip-release']; + const deprecatedSkipGithubRelease = config['skip-github-release']; + const skipRelease = + skipReleaseValue === undefined + ? deprecatedSkipGithubRelease + : skipReleaseValue; + + if ( + skipReleaseValue === undefined && + deprecatedSkipGithubRelease !== undefined && + !hasWarnedDeprecatedSkipGithubRelease + ) { + defaultLogger.warn( + 'The "skip-github-release" option is deprecated; use "skip-release" instead.' + ); + hasWarnedDeprecatedSkipGithubRelease = true; + } else if ( + skipReleaseValue !== undefined && + deprecatedSkipGithubRelease !== undefined && + skipReleaseValue !== deprecatedSkipGithubRelease + ) { + defaultLogger.warn( + 'The "skip-release" option overrides the deprecated "skip-github-release" value. Remove the deprecated option.' + ); + } + return { releaseType: config['release-type'], bumpMinorPreMajor: config['bump-minor-pre-major'], @@ -1382,7 +1414,8 @@ function extractReleaserConfig( changelogPath: config['changelog-path'], changelogHost: config['changelog-host'], releaseAs: config['release-as'], - skipGithubRelease: config['skip-github-release'], + skipRelease, + skipGithubRelease: deprecatedSkipGithubRelease ?? skipRelease, skipChangelog: config['skip-changelog'], draft: config.draft, prerelease: config.prerelease, @@ -1421,7 +1454,7 @@ function extractReleaserConfig( * @param {string} releaseAs Optional. Override release-as and use the given version */ async function parseConfig( - github: GitHub, + github: HostedGitClient, configFile: string, branch: string, onlyPath?: string, @@ -1474,7 +1507,7 @@ async function parseConfig( * @throws {ConfigurationError} if missing the manifest config file */ async function fetchManifestConfig( - github: GitHub, + github: HostedGitClient, configFile: string, branch: string ): Promise { @@ -1507,7 +1540,7 @@ async function fetchManifestConfig( * @returns {Record} */ async function parseReleasedVersions( - github: GitHub, + github: HostedGitClient, manifestFile: string, branch: string ): Promise { @@ -1532,7 +1565,7 @@ async function parseReleasedVersions( * @throws {ConfigurationError} if missing the manifest config file */ async function fetchReleasedVersions( - github: GitHub, + github: HostedGitClient, manifestFile: string, branch: string ): Promise> { @@ -1575,7 +1608,7 @@ function isPublishedVersion(strategy: Strategy, version: Version): boolean { * @param {string} prefix Limit the release to a specific component. */ async function latestReleaseVersion( - github: GitHub, + github: HostedGitClient, targetBranch: string, releaseFilter: (version: Version) => boolean, config: ReleaserConfig, @@ -1721,6 +1754,7 @@ function mergeReleaserConfig( defaultConfig: Partial, pathConfig: Partial ): ReleaserConfig { + const skipRelease = selectSkipRelease(pathConfig, defaultConfig); return { releaseType: pathConfig.releaseType ?? defaultConfig.releaseType ?? 'node', bumpMinorPreMajor: @@ -1736,8 +1770,11 @@ function mergeReleaserConfig( changelogHost: pathConfig.changelogHost ?? defaultConfig.changelogHost, changelogType: pathConfig.changelogType ?? defaultConfig.changelogType, releaseAs: pathConfig.releaseAs ?? defaultConfig.releaseAs, + skipRelease, skipGithubRelease: - pathConfig.skipGithubRelease ?? defaultConfig.skipGithubRelease, + pathConfig.skipGithubRelease ?? + defaultConfig.skipGithubRelease ?? + skipRelease, skipChangelog: pathConfig.skipChangelog ?? defaultConfig.skipChangelog, draft: pathConfig.draft ?? defaultConfig.draft, draftPullRequest: @@ -1770,6 +1807,32 @@ function mergeReleaserConfig( }; } +function selectSkipRelease( + pathConfig: Partial, + defaultConfig: Partial +): boolean | undefined { + if (pathConfig.skipRelease !== undefined) { + return pathConfig.skipRelease; + } + if (pathConfig.skipGithubRelease !== undefined) { + return pathConfig.skipGithubRelease; + } + if (defaultConfig.skipRelease !== undefined) { + return defaultConfig.skipRelease; + } + if (defaultConfig.skipGithubRelease !== undefined) { + return defaultConfig.skipGithubRelease; + } + return undefined; +} + +function isReleaseSkipped(config?: ReleaserConfig): boolean { + if (!config) { + return false; + } + return Boolean(config.skipRelease ?? config.skipGithubRelease); +} + /** * Helper to compare if a list of labels fully contains another list of labels * @param {string[]} expected List of labels expected to be contained diff --git a/src/plugin.ts b/src/plugin.ts index f7973626f..f42eb12e4 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 {HostedGitClient} from './provider'; 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: HostedGitClient; readonly targetBranch: string; readonly repositoryConfig: RepositoryConfig; protected logger: Logger; constructor( - github: GitHub, + github: HostedGitClient, targetBranch: string, repositoryConfig: RepositoryConfig, logger: Logger = defaultLogger diff --git a/src/plugins/group-priority.ts b/src/plugins/group-priority.ts index e0677fa47..8b5fd12e0 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 {HostedGitClient} from '../provider'; 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: HostedGitClient, targetBranch: string, repositoryConfig: RepositoryConfig, groups: string[] diff --git a/src/plugins/linked-versions.ts b/src/plugins/linked-versions.ts index f3d059bf7..68fd02cde 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 {HostedGitClient} from '../provider'; 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: HostedGitClient, targetBranch: string, repositoryConfig: RepositoryConfig, groupName: string, diff --git a/src/plugins/maven-workspace.ts b/src/plugins/maven-workspace.ts index 0586ec2d6..40939ff93 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 {HostedGitClient} from '../provider'; 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: HostedGitClient, targetBranch: string, repositoryConfig: RepositoryConfig, options: MavenWorkspacePluginOptions = {} diff --git a/src/plugins/merge.ts b/src/plugins/merge.ts index 811ac3f72..89b49db1b 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 {HostedGitClient} from '../provider'; export interface MergeOptions { pullRequestTitlePattern?: string; @@ -50,7 +50,7 @@ export class Merge extends ManifestPlugin { private forceMerge: boolean; constructor( - github: GitHub, + github: HostedGitClient, targetBranch: string, repositoryConfig: RepositoryConfig, options: MergeOptions = {} diff --git a/src/plugins/node-workspace.ts b/src/plugins/node-workspace.ts index 7424ab64d..8c1d8fa73 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 {HostedGitClient} from '../provider'; 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: HostedGitClient, targetBranch: string, repositoryConfig: RepositoryConfig, options: NodeWorkspaceOptions = {} diff --git a/src/plugins/sentence-case.ts b/src/plugins/sentence-case.ts index f257706cc..0450e1653 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 {HostedGitClient} from '../provider'; 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: HostedGitClient, targetBranch: string, repositoryConfig: RepositoryConfig, specialWords?: Array diff --git a/src/plugins/workspace.ts b/src/plugins/workspace.ts index 2386377dc..db1e253cc 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 {HostedGitClient} from '../provider'; 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: HostedGitClient, targetBranch: string, repositoryConfig: RepositoryConfig, options: WorkspacePluginOptions = {} diff --git a/src/provider-interfaces.ts b/src/provider-interfaces.ts new file mode 100644 index 000000000..d56d961f3 --- /dev/null +++ b/src/provider-interfaces.ts @@ -0,0 +1,29 @@ +// Copyright 2025 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. + +export interface GitHubRelease { + id: number; + name?: string; + tagName: string; + sha: string; + notes?: string; + url: string; + draft?: boolean; + uploadUrl?: string; +} + +export interface GitHubTag { + name: string; + sha: string; +} diff --git a/src/provider.ts b/src/provider.ts new file mode 100644 index 000000000..b812741d7 --- /dev/null +++ b/src/provider.ts @@ -0,0 +1,185 @@ +// Copyright 2025 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. + + +// Minimal provider factory to allow using different VCS/hosting providers in future. +// For now it only supports GitHub and returns the existing GitHub client so +// existing code can continue to operate unchanged. +import { + ChangeSet, + CommitFilter, + CommitIteratorOptions, + CreatePullRequestOptions, + GitHub, + ReleaseIteratorOptions, + ReleaseOptions, + TagIteratorOptions, +} from './github'; +import {GitHubRelease, GitHubTag} from './provider-interfaces'; +import {GitLab} from './gitlab'; +import {PullRequest} from './pull-request'; +import {ReleasePullRequest} from './release-pull-request'; +import {Update} from './update'; +import {Repository} from './repository'; +import {Release} from './release'; +import {GitHubFileContents} from '@google-automations/git-file-utils'; +import {PullRequestOverflowHandler} from './util/pull-request-overflow-handler'; +import {Commit} from './commit'; + +export interface HostedGitClient { + readonly repository: Repository; + commitsSince( + targetBranch: string, + filter: CommitFilter, + options?: CommitIteratorOptions + ): Promise; + mergeCommitIterator( + targetBranch: string, + options?: CommitIteratorOptions + ): AsyncGenerator; + pullRequestIterator( + targetBranch: string, + status?: 'OPEN' | 'CLOSED' | 'MERGED', + maxResults?: number, + includeFiles?: boolean + ): AsyncGenerator; + releaseIterator( + options?: ReleaseIteratorOptions + ): AsyncGenerator; + tagIterator( + options?: TagIteratorOptions + ): AsyncGenerator; + 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; + buildChangeSet(updates: Update[], defaultBranch: string): Promise; + createPullRequest( + pullRequest: PullRequest, + targetBranch: string, + message: string, + updates: Update[], + options?: CreatePullRequestOptions + ): Promise; + createReleasePullRequest( + releasePullRequest: ReleasePullRequest, + targetBranch: string, + options?: { + signoffUser?: string; + fork?: boolean; + skipLabeling?: boolean; + } + ): Promise; + getPullRequest(number: number): Promise; + updatePullRequest( + number: number, + releasePullRequest: ReleasePullRequest, + targetBranch: string, + options?: { + signoffUser?: string; + fork?: boolean; + pullRequestOverflowHandler?: PullRequestOverflowHandler; + } + ): Promise; + createRelease( + release: Release, + options?: ReleaseOptions + ): Promise; + generateReleaseNotes( + tagName: string, + targetCommitish: string, + previousTag?: string + ): Promise; + commentOnIssue(comment: string, number: number): Promise; + removeIssueLabels(labels: string[], number: number): Promise; + addIssueLabels(labels: string[], number: number): Promise; + createFileOnNewBranch( + filename: string, + contents: string, + newBranchName: string, + baseBranchName: string + ): Promise; + getProviderDetails(): Promise<{ + hostUrl: string; + issueFormatUrl: string; + commitFormatUrl: string; + }>; +} + +export interface ProviderOptions { + owner: string; + repo: string; + token?: string; + apiUrl?: string; + graphqlUrl?: string; + host?: string; + hostUrl?: string; +} + +export class ProviderFactory { + /** + * Create a provider client. + * @param provider name of provider (currently only 'github') + * @param opts provider specific options + */ + static async create( + provider: string, + opts: ProviderOptions + ): Promise { + switch ((provider || '').toLowerCase()) { + case 'github': + return GitHub.create({ + owner: opts.owner, + repo: opts.repo, + token: opts.token, + apiUrl: opts.apiUrl, + graphqlUrl: opts.graphqlUrl, + hostUrl: opts.hostUrl, + }); + case 'gitlab': + return GitLab.create({ + owner: opts.owner, + repo: opts.repo, + token: opts.token, + apiUrl: opts.apiUrl, + host: opts.host, + hostUrl: opts.hostUrl, + }); + default: + throw new Error(`unsupported provider: ${provider}`); + } + } +} + +export default ProviderFactory; diff --git a/src/strategies/base.ts b/src/strategies/base.ts index fd8dbf4ec..8d7eb65c5 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 {HostedGitClient} from '../provider'; 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: HostedGitClient; component?: string; packageName?: string; versioningStrategy?: VersioningStrategy; @@ -69,6 +69,8 @@ export interface BaseStrategyOptions { headerPartial?: string; mainTemplate?: string; tagSeparator?: string; + skipRelease?: boolean; + /** @deprecated use skipRelease */ skipGitHubRelease?: boolean; skipChangelog?: boolean; releaseAs?: string; @@ -95,7 +97,7 @@ export interface BaseStrategyOptions { */ export abstract class BaseStrategy implements Strategy { readonly path: string; - protected github: GitHub; + protected github: HostedGitClient; protected logger: Logger; protected component?: string; private packageName?: string; @@ -105,7 +107,7 @@ export abstract class BaseStrategy implements Strategy { protected changelogPath: string; protected changelogHost?: string; protected tagSeparator?: string; - private skipGitHubRelease: boolean; + private skipRelease: boolean; protected skipChangelog: boolean; private releaseAs?: string; protected includeComponentInTag: boolean; @@ -140,7 +142,9 @@ export abstract class BaseStrategy implements Strategy { this.changelogHost = options.changelogHost; this.changelogSections = options.changelogSections; this.tagSeparator = options.tagSeparator; - this.skipGitHubRelease = options.skipGitHubRelease || false; + const skipRelease = + options.skipRelease ?? options.skipGitHubRelease ?? false; + this.skipRelease = skipRelease; this.skipChangelog = options.skipChangelog || false; this.releaseAs = options.releaseAs; this.changelogNotes = @@ -220,7 +224,8 @@ export abstract class BaseStrategy implements Strategy { commits?: Commit[] ): Promise { return await this.changelogNotes.buildNotes(conventionalCommits, { - host: this.changelogHost, + host: + this.changelogHost || (await this.github.getProviderDetails()).hostUrl, owner: this.repository.owner, repository: this.repository.repo, version: newVersion.toString(), @@ -229,6 +234,10 @@ export abstract class BaseStrategy implements Strategy { targetBranch: this.targetBranch, changelogSections: this.changelogSections, commits: commits, + commitTemplateUri: ( + await this.github.getProviderDetails() + ).commitFormatUrl, + issueTemplateUri: (await this.github.getProviderDetails()).issueFormatUrl, }); } @@ -588,7 +597,7 @@ export abstract class BaseStrategy implements Strategy { mergedPullRequest: PullRequest, options?: BuildReleaseOptions ): Promise { - if (this.skipGitHubRelease) { + if (this.skipRelease) { this.logger.info('Release skipped from strategy config'); return; } diff --git a/src/strategies/php-yoshi.ts b/src/strategies/php-yoshi.ts index bbefdbcc2..d9e80ca5a 100644 --- a/src/strategies/php-yoshi.ts +++ b/src/strategies/php-yoshi.ts @@ -140,6 +140,8 @@ export class PHPYoshi extends BaseStrategy { currentTag: newVersionTag.toString(), targetBranch: this.targetBranch, changelogSections: this.changelogSections, + commitTemplateUri: '', + issueTemplateUri: '', } ); releaseNotesBody = updatePHPChangelogEntry( diff --git a/src/updaters/release-please-config.ts b/src/updaters/release-please-config.ts index e3868dc5f..e927fbf1b 100644 --- a/src/updaters/release-please-config.ts +++ b/src/updaters/release-please-config.ts @@ -63,7 +63,7 @@ function releaserConfigToJsonConfig( 'bump-patch-for-minor-pre-major': config.bumpPatchForMinorPreMajor, 'changelog-sections': config.changelogSections, 'release-as': config.releaseAs, - 'skip-github-release': config.skipGithubRelease, + 'skip-release': config.skipRelease ?? config.skipGithubRelease, 'skip-changelog': config.skipChangelog, draft: config.draft, prerelease: config.prerelease, diff --git a/src/util/pull-request-overflow-handler.ts b/src/util/pull-request-overflow-handler.ts index 43bc85245..1cd062b48 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 {HostedGitClient} from '../provider'; 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: HostedGitClient; private logger: Logger; - constructor(github: GitHub, logger: Logger = defaultLogger) { + constructor(github: HostedGitClient, 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..194f0cde1 100644 --- a/test/changelog-notes/default-changelog-notes.ts +++ b/test/changelog-notes/default-changelog-notes.ts @@ -68,6 +68,8 @@ describe('DefaultChangelogNotes', () => { previousTag: 'v1.2.2', currentTag: 'v1.2.3', targetBranch: 'main', + commitTemplateUri: '', + issueTemplateUri: '', }; it('should build default release notes', async () => { const changelogNotes = new DefaultChangelogNotes(); @@ -308,6 +310,8 @@ describe('DefaultChangelogNotes', () => { previousTag: 'v1.2.2', currentTag: 'v1.2.3', targetBranch: 'main', + commitTemplateUri: '', + issueTemplateUri: '', }; const changelogNotes = new DefaultChangelogNotes(); const notes = await changelogNotes.buildNotes(commits, notesOptions); diff --git a/test/changelog-notes/github-changelog-notes.ts b/test/changelog-notes/github-changelog-notes.ts index 9f49cb0dc..0e0b9edd2 100644 --- a/test/changelog-notes/github-changelog-notes.ts +++ b/test/changelog-notes/github-changelog-notes.ts @@ -68,6 +68,8 @@ describe('GitHubChangelogNotes', () => { previousTag: 'v1.2.2', currentTag: 'v1.2.3', targetBranch: 'main', + commitTemplateUri: '', + issueTemplateUri: '', }; let github: GitHub; beforeEach(async () => { @@ -100,6 +102,8 @@ describe('GitHubChangelogNotes', () => { previousTag: 'v1.2.2', currentTag: 'v1.2.3', targetBranch: 'main', + commitTemplateUri: '', + issueTemplateUri: '', }; const changelogNotes = new GitHubChangelogNotes(github); const notes = await changelogNotes.buildNotes(commits, notesOptions); diff --git a/test/cli.ts b/test/cli.ts index 93e03381a..c1c828395 100644 --- a/test/cli.ts +++ b/test/cli.ts @@ -23,11 +23,39 @@ import { DEFAULT_RELEASE_PLEASE_MANIFEST, } from '../src/manifest'; import snapshot = require('snap-shot-it'); -import {GitHub} from '../src/github'; -import {ParseCallback} from 'yargs'; +import {GitHub, GH_API_URL, GH_GRAPHQL_URL, GH_URL} from '../src/github'; +import {GL_API_URL} from '../src/gitlab'; +import ProviderFactory, {ProviderOptions} from '../src/provider'; +import {logger} from '../src/util/logger'; const sandbox = sinon.createSandbox(); +let providerCreateStub: sinon.SinonStub; + +const DEFAULT_PROVIDER_OPTIONS: ProviderOptions = { + owner: 'googleapis', + repo: 'release-please-cli', + token: undefined, + apiUrl: GH_API_URL, + graphqlUrl: GH_GRAPHQL_URL, + host: undefined, + hostUrl: GH_URL, +}; + +function expectProviderCall( + overrides: Partial = {}, + provider = 'github' +) { + sinon.assert.calledOnce(providerCreateStub); + const [providerName, options] = providerCreateStub.getCall(0).args; + expect(providerName).to.equal(provider); + const expected: ProviderOptions = { + ...DEFAULT_PROVIDER_OPTIONS, + ...overrides, + }; + expect(options).to.deep.equal(expected); +} + // function callStub( // instance: Manifest, // method: ManifestMethod @@ -52,7 +80,6 @@ const sandbox = sinon.createSandbox(); describe('CLI', () => { let fakeGitHub: GitHub; let fakeManifest: Manifest; - let gitHubCreateStub: sinon.SinonStub; beforeEach(async () => { fakeGitHub = await GitHub.create({ owner: 'googleapis', @@ -60,7 +87,9 @@ describe('CLI', () => { defaultBranch: 'main', }); fakeManifest = new Manifest(fakeGitHub, 'main', {}, {}); - gitHubCreateStub = sandbox.stub(GitHub, 'create').resolves(fakeGitHub); + providerCreateStub = sandbox + .stub(ProviderFactory, 'create') + .resolves(fakeGitHub); }); afterEach(() => { sandbox.restore(); @@ -117,13 +146,7 @@ describe('CLI', () => { 'manifest-pr --repo-url=googleapis/release-please-cli' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -140,13 +163,7 @@ describe('CLI', () => { 'manifest-pr --repo-url=googleapis/release-please-cli --config-file=foo.json --manifest-file=.bar.json' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -163,13 +180,7 @@ describe('CLI', () => { `manifest-pr --repo-url=googleapis/release-please-cli ${flag}=1.x` ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -191,13 +202,7 @@ describe('CLI', () => { 'manifest-pr --repo-url=googleapis/release-please-cli --dry-run' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -214,13 +219,7 @@ describe('CLI', () => { 'manifest-pr --repo-url=googleapis/release-please-cli --fork' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -237,13 +236,7 @@ describe('CLI', () => { 'manifest-pr --repo-url=googleapis/release-please-cli --label=foo,bar' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -260,13 +253,7 @@ describe('CLI', () => { 'manifest-pr --repo-url=googleapis/release-please-cli --label=' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -283,13 +270,7 @@ describe('CLI', () => { 'manifest-pr --repo-url=googleapis/release-please-cli --skip-labeling' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -306,7 +287,7 @@ describe('CLI', () => { // 'manifest-pr --repo-url=googleapis/release-please-cli --draft' // ); - // sinon.assert.calledOnceWithExactly(gitHubCreateStub, { + // expectProviderCall(); // owner: 'googleapis', // repo: 'release-please-cli', // token: undefined, @@ -329,13 +310,7 @@ describe('CLI', () => { 'manifest-pr --repo-url=googleapis/release-please-cli --signoff="Alice "' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -346,6 +321,60 @@ describe('CLI', () => { ); sinon.assert.calledOnce(createPullRequestsStub); }); + + it('supports gitlab provider with https repo URL', async () => { + await parser.parseAsync( + 'manifest-pr --provider=gitlab --repo-url=https://gitlab.example.com/group/subgroup/project.git' + ); + + expectProviderCall( + { + owner: 'group/subgroup', + repo: 'project', + host: 'https://gitlab.example.com', + hostUrl: 'https://gitlab.example.com', + apiUrl: GL_API_URL, + graphqlUrl: undefined, + }, + 'gitlab' + ); + sinon.assert.calledOnceWithExactly( + fromManifestStub, + fakeGitHub, + 'main', + DEFAULT_RELEASE_PLEASE_CONFIG, + DEFAULT_RELEASE_PLEASE_MANIFEST, + sinon.match.any + ); + sinon.assert.calledOnce(createPullRequestsStub); + }); + + it('supports gitlab provider with SSH repo URL', async () => { + await parser.parseAsync( + 'manifest-pr --provider=gitlab --repo-url=git@gitlab.example.com:group/project.git' + ); + + expectProviderCall( + { + owner: 'group', + repo: 'project', + host: 'https://gitlab.example.com', + hostUrl: 'https://gitlab.example.com', + apiUrl: GL_API_URL, + graphqlUrl: undefined, + }, + 'gitlab' + ); + sinon.assert.calledOnceWithExactly( + fromManifestStub, + fakeGitHub, + 'main', + DEFAULT_RELEASE_PLEASE_CONFIG, + DEFAULT_RELEASE_PLEASE_MANIFEST, + sinon.match.any + ); + sinon.assert.calledOnce(createPullRequestsStub); + }); }); describe('manifest-release', () => { let fromManifestStub: sinon.SinonStub; @@ -378,13 +407,7 @@ describe('CLI', () => { 'manifest-release --repo-url=googleapis/release-please-cli' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -401,13 +424,7 @@ describe('CLI', () => { 'manifest-release --repo-url=googleapis/release-please-cli --config-file=foo.json --manifest-file=.bar.json' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -424,13 +441,7 @@ describe('CLI', () => { `manifest-release --repo-url=googleapis/release-please-cli ${flag}=1.x` ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -452,13 +463,7 @@ describe('CLI', () => { 'manifest-release --repo-url=googleapis/release-please-cli --dry-run' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -475,13 +480,7 @@ describe('CLI', () => { 'manifest-release --repo-url=googleapis/release-please-cli --label=foo,bar --release-label=asdf,qwer' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -498,13 +497,7 @@ describe('CLI', () => { 'manifest-release --repo-url=googleapis/release-please-cli --draft' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -521,7 +514,7 @@ describe('CLI', () => { // 'manifest-release --repo-url=googleapis/release-please-cli --release-as=2.3.4' // ); - // sinon.assert.calledOnceWithExactly(gitHubCreateStub, { + // expectProviderCall(); // owner: 'googleapis', // repo: 'release-please-cli', // token: undefined, @@ -567,13 +560,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -592,13 +579,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --config-file=foo.json --manifest-file=.bar.json' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -617,13 +598,7 @@ describe('CLI', () => { `release-pr --repo-url=googleapis/release-please-cli ${flag}=1.x` ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -647,13 +622,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --dry-run' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -694,13 +663,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -718,13 +681,7 @@ describe('CLI', () => { `release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi ${flag}=1.x` ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -746,13 +703,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --dry-run' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -769,13 +720,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --release-as=2.3.4' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -792,13 +737,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --versioning-strategy=always-bump-patch' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -818,13 +757,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --bump-minor-pre-major --bump-patch-for-minor-pre-major' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -845,13 +778,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --prerelease-type=alpha' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -871,13 +798,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --extra-files=foo/bar.java,asdf/qwer.java' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -897,13 +818,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=ruby-yoshi --version-file=lib/foo/version.rb' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -923,13 +838,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --signoff="Alice "' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -946,13 +855,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --changelog-path=docs/changes.md' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -972,13 +875,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --changelog-type=github' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -998,13 +895,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --changelog-host=https://example.com' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1023,13 +914,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --draft-pull-request' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1046,13 +931,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --fork' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1069,13 +948,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --path=submodule' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1092,13 +965,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --component=pkg1' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1115,13 +982,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --package-name=@foo/bar' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1138,13 +999,7 @@ describe('CLI', () => { 'release-pr --repo-url=googleapis/release-please-cli --release-type=java-yoshi --monorepo-tags' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1157,7 +1012,7 @@ describe('CLI', () => { }); }); }); - describe('github-release', () => { + describe('release', () => { describe('with manifest options', () => { let fromManifestStub: sinon.SinonStub; let createReleasesStub: sinon.SinonStub; @@ -1186,16 +1041,10 @@ describe('CLI', () => { it('instantiates a basic Manifest', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli' + 'release --repo-url=googleapis/release-please-cli' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -1209,16 +1058,10 @@ describe('CLI', () => { it('instantiates Manifest with custom config/manifest', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --config-file=foo.json --manifest-file=.bar.json' + 'release --repo-url=googleapis/release-please-cli --config-file=foo.json --manifest-file=.bar.json' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -1232,16 +1075,10 @@ describe('CLI', () => { for (const flag of ['--target-branch', '--default-branch']) { it(`handles ${flag}`, async () => { await parser.parseAsync( - `github-release --repo-url=googleapis/release-please-cli ${flag}=1.x` + `release --repo-url=googleapis/release-please-cli ${flag}=1.x` ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -1260,16 +1097,10 @@ describe('CLI', () => { .resolves([]); await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --dry-run' + 'release --repo-url=googleapis/release-please-cli --dry-run' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -1283,16 +1114,10 @@ describe('CLI', () => { it('handles --label and --release-label', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --label=foo,bar --release-label=asdf,qwer' + 'release --repo-url=googleapis/release-please-cli --label=foo,bar --release-label=asdf,qwer' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -1306,16 +1131,10 @@ describe('CLI', () => { it('handles --draft', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --draft' + 'release --repo-url=googleapis/release-please-cli --draft' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromManifestStub, fakeGitHub, @@ -1329,7 +1148,7 @@ describe('CLI', () => { // it('handles --release-as', async () => { // await parser.parseAsync( - // 'github-release --repo-url=googleapis/release-please-cli --release-as=2.3.4' + // 'release --repo-url=googleapis/release-please-cli --release-as=2.3.4' // ); // }); }); @@ -1361,16 +1180,10 @@ describe('CLI', () => { it('instantiates a basic Manifest', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --release-type=java-yoshi' + 'release --repo-url=googleapis/release-please-cli --release-type=java-yoshi' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1387,16 +1200,10 @@ describe('CLI', () => { .stub(fakeManifest, 'buildReleases') .resolves([]); await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --dry-run' + 'release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --dry-run' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1410,16 +1217,10 @@ describe('CLI', () => { it('handles --draft', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --draft' + 'release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --draft' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1433,16 +1234,10 @@ describe('CLI', () => { it('handles --prerelease', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --prerelease' + 'release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --prerelease' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1456,16 +1251,10 @@ describe('CLI', () => { it('handles --label and --release-label', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --label=foo,bar --release-label=asdf,qwer' + 'release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --label=foo,bar --release-label=asdf,qwer' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1482,16 +1271,10 @@ describe('CLI', () => { it('handles --path', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --path=submodule' + 'release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --path=submodule' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1505,16 +1288,10 @@ describe('CLI', () => { it('handles --component', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --component=pkg1' + 'release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --component=pkg1' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1528,16 +1305,10 @@ describe('CLI', () => { it('handles --package-name', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --package-name=@foo/bar' + 'release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --package-name=@foo/bar' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1551,16 +1322,10 @@ describe('CLI', () => { it('handles --monorepo-tags', async () => { await parser.parseAsync( - 'github-release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --monorepo-tags' + 'release --repo-url=googleapis/release-please-cli --release-type=java-yoshi --monorepo-tags' ); - sinon.assert.calledOnceWithExactly(gitHubCreateStub, { - owner: 'googleapis', - repo: 'release-please-cli', - token: undefined, - apiUrl: 'https://api.github.com', - graphqlUrl: 'https://api.github.com', - }); + expectProviderCall(); sinon.assert.calledOnceWithExactly( fromConfigStub, fakeGitHub, @@ -1573,6 +1338,38 @@ describe('CLI', () => { }); }); }); + + describe('github-release (deprecated alias)', () => { + it('warns and delegates to release', async () => { + const warnStub = sandbox.stub(logger, 'warn'); + const fromManifestStub = sandbox + .stub(Manifest, 'fromManifest') + .resolves(fakeManifest); + const createReleasesStub = sandbox + .stub(fakeManifest, 'createReleases') + .resolves([]); + + await parser.parseAsync( + 'github-release --repo-url=googleapis/release-please-cli' + ); + + sinon.assert.calledOnceWithExactly( + warnStub, + 'github-release is deprecated. Please use release instead.' + ); + expectProviderCall(); + sinon.assert.calledOnceWithExactly( + fromManifestStub, + fakeGitHub, + 'main', + DEFAULT_RELEASE_PLEASE_CONFIG, + DEFAULT_RELEASE_PLEASE_MANIFEST, + sinon.match.any + ); + sinon.assert.calledOnce(createReleasesStub); + }); + }); + describe('bootstrap', () => { it('defaults path to .', async () => { const createPullStub = sandbox @@ -1606,18 +1403,49 @@ describe('CLI', () => { describe('--help', () => { for (const cmd of [ 'release-pr', - 'github-release', + 'release', 'manifest-pr', 'manifest-release', + 'github-release', ]) { - it(cmd, async done => { - const parseCallback: ParseCallback = (_err, _argv, output) => { - snapshot(output); - done(); - }; - const foo = await parser.parseAsync(`${cmd} --help`, parseCallback); - console.log(foo); + it(cmd, () => { + const freshParser = getFreshParser(); + freshParser.exitProcess(false); + freshParser.showHelpOnFail(false); + let stdout = ''; + let stderr = ''; + const stdoutStub = sandbox + .stub(process.stdout, 'write') + // coerce chunk into string while preserving yargs formatting + .callsFake((chunk: string | Uint8Array) => { + stdout += chunk.toString(); + return true; + }); + const stderrStub = sandbox + .stub(process.stderr, 'write') + .callsFake((chunk: string | Uint8Array) => { + stderr += chunk.toString(); + return true; + }); + try { + freshParser.parse([cmd, '--help']); + } finally { + stdoutStub.restore(); + stderrStub.restore(); + } + snapshot(stdout); + expect(stderr).to.equal(''); }); } }); }); + +function getFreshParser() { + const modulePath = require.resolve('../src/bin/release-please'); + delete require.cache[modulePath]; + // eslint-disable-next-line @typescript-eslint/no-var-requires + const freshModule = require('../src/bin/release-please') as { + parser: typeof parser; + }; + return freshModule.parser; +} diff --git a/test/github.ts b/test/github.ts index bab1ae232..167fafc79 100644 --- a/test/github.ts +++ b/test/github.ts @@ -22,7 +22,8 @@ import {resolve} from 'path'; import * as snapshot from 'snap-shot-it'; import * as sinon from 'sinon'; -import {GH_API_URL, GitHub, GitHubRelease} from '../src/github'; +import {GH_API_URL, GitHub} from '../src/github'; +import {GitHubRelease} from '../src/provider-interfaces'; import {PullRequest} from '../src/pull-request'; import {TagName} from '../src/util/tag-name'; import {Version} from '../src/version'; diff --git a/test/gitlab.ts b/test/gitlab.ts new file mode 100644 index 000000000..baf96bb32 --- /dev/null +++ b/test/gitlab.ts @@ -0,0 +1,1551 @@ +// Copyright 2025 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 {afterEach, describe, it} from 'mocha'; +import {expect} from 'chai'; +import * as sinon from 'sinon'; +import {GitbeakerRequestError} from '@gitbeaker/requester-utils'; + +import {GitLab, DEFAULT_FILE_MODE} from '../src/gitlab'; +import type {Update} from '../src/update'; +import type {Release} from '../src/release'; +import type {PullRequest} from '../src/pull-request'; +import type {GitHubRelease, GitHubTag} from '../src/provider-interfaces'; +import {TagName} from '../src/util/tag-name'; + +interface GitbeakerStubs { + RepositoryFiles: { + show: sinon.SinonStub; + }; + Branches: { + create: sinon.SinonStub; + }; + Commits: { + create: sinon.SinonStub; + }; + MergeRequests: { + create: sinon.SinonStub; + edit: sinon.SinonStub; + all: sinon.SinonStub; + showChanges: sinon.SinonStub; + }; + MergeRequestNotes: { + create: sinon.SinonStub; + }; + IssueNotes: { + create: sinon.SinonStub; + }; + Issues: { + edit: sinon.SinonStub; + }; + ProjectReleases?: { + create: sinon.SinonStub; + all?: sinon.SinonStub; + }; +} + +type GitLabConstructor = new (options: { + repository: {owner: string; repo: string; defaultBranch: string}; + apiUrl: string; + gitbeaker: GitbeakerStubs; + logger: ReturnType; +}) => GitLab; + +const GitLabCtor = GitLab as unknown as GitLabConstructor; + +function createLoggerStub() { + return { + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + debug: sinon.stub(), + trace: sinon.stub(), + child: sinon.stub().returnsThis(), + } as const; +} + +function gitbeakerNotFoundError(): GitbeakerRequestError { + return new GitbeakerRequestError('Not found', { + cause: { + description: 'Not Found', + request: {} as Request, + response: {status: 404} as Response, + }, + }); +} + +function createGitLabTestDouble(overrides?: { + repositoryFiles?: sinon.SinonStub; + commits?: sinon.SinonStub; + mergeRequests?: sinon.SinonStub; + projectReleases?: sinon.SinonStub; + branches?: sinon.SinonStub; + mergeRequestNotes?: sinon.SinonStub; + issueNotes?: sinon.SinonStub; + mergeRequestsEdit?: sinon.SinonStub; + mergeRequestsAll?: sinon.SinonStub; + mergeRequestsShowChanges?: sinon.SinonStub; + issuesEdit?: sinon.SinonStub; +}) { + const repository = { + owner: 'test-group', + repo: 'test-repo', + defaultBranch: 'main', + } as const; + + const gitbeaker: GitbeakerStubs = { + RepositoryFiles: { + show: + overrides?.repositoryFiles ?? + sinon.stub().resolves({ + blob_id: 'blob', + content: Buffer.from('old content').toString('base64'), + parsedContent: 'old content', + file_mode: '100644', + }), + }, + Branches: { + create: overrides?.branches ?? sinon.stub().resolves({}), + }, + Commits: { + create: overrides?.commits ?? sinon.stub().resolves({id: 'commit456'}), + }, + MergeRequests: { + create: + overrides?.mergeRequests ?? + sinon.stub().resolves({ + iid: 42, + title: 'chore: release', + description: 'body content', + labels: ['autorelease: pending'], + sha: 'commit456', + }), + edit: overrides?.mergeRequestsEdit ?? sinon.stub().resolves({}), + all: overrides?.mergeRequestsAll ?? sinon.stub().resolves([]), + showChanges: + overrides?.mergeRequestsShowChanges ?? + sinon.stub().resolves({changes: []}), + }, + MergeRequestNotes: { + create: overrides?.mergeRequestNotes ?? sinon.stub().resolves({}), + }, + IssueNotes: { + create: overrides?.issueNotes ?? sinon.stub().resolves({}), + }, + Issues: { + edit: overrides?.issuesEdit ?? sinon.stub().resolves({}), + }, + }; + + if (overrides?.projectReleases) { + gitbeaker.ProjectReleases = { + create: overrides.projectReleases, + }; + } + + const gitlab = new GitLabCtor({ + repository, + apiUrl: 'https://gitlab.example.com/api/v4', + gitbeaker, + logger: createLoggerStub(), + }); + + return {gitlab, gitbeaker, repository}; +} + +describe('GitLab', () => { + afterEach(() => { + sinon.restore(); + }); + + describe('createPullRequest', () => { + it('creates a merge request with commit', async () => { + const commitStub = sinon.stub().resolves({id: 'commit456'}); + const mergeRequestStub = sinon.stub().resolves({ + iid: 42, + title: 'chore: release', + description: 'body content', + labels: ['autorelease: pending'], + sha: 'commit456', + }); + const fileStub = sinon.stub().resolves({ + blob_id: 'blob', + content: Buffer.from('old content').toString('base64'), + parsedContent: 'old content', + file_mode: '100644', + }); + + const {gitlab, gitbeaker} = createGitLabTestDouble({ + repositoryFiles: fileStub, + commits: commitStub, + mergeRequests: mergeRequestStub, + }); + + const update: Update = { + path: 'package.json', + createIfMissing: true, + updater: { + updateContent(content: string | undefined): string { + expect(content).to.equal('old content'); + return 'new content'; + }, + }, + }; + + const pullRequest = { + headBranchName: 'release-please--main', + baseBranchName: 'main', + number: -1, + title: 'chore: release', + body: 'body content', + labels: ['autorelease: pending'], + files: [] as string[], + }; + + const result = await gitlab.createPullRequest( + pullRequest, + 'main', + 'chore: release', + [update] + ); + + expect(commitStub.calledOnce).to.be.true; + const commitArgs = commitStub.getCall(0).args; + expect(commitArgs[0]).to.equal('test-group/test-repo'); + expect(commitArgs[1]).to.equal('release-please--main'); + expect(commitArgs[2]).to.equal('chore: release'); + expect(commitArgs[3]).to.deep.equal([ + { + action: 'update', + filePath: 'package.json', + file_path: 'package.json', + content: 'new content', + encoding: 'text', + fileMode: '100644', + file_mode: '100644', + }, + ]); + expect(commitArgs[4]).to.deep.include({ + startBranch: 'main', + force: true, + }); + + expect(mergeRequestStub.calledOnce).to.be.true; + const mrArgs = mergeRequestStub.getCall(0).args; + expect(mrArgs).to.deep.equal([ + 'test-group/test-repo', + 'release-please--main', + 'main', + 'chore: release', + { + description: 'body content', + labels: 'autorelease: pending', + removeSourceBranch: true, + squash: true, + }, + ]); + + expect(result.number).to.equal(42); + expect(result.headBranchName).to.equal('release-please--main'); + expect(result.baseBranchName).to.equal('main'); + expect(result.body).to.equal('body content'); + expect(result.labels).to.deep.equal(['autorelease: pending']); + expect(result.files).to.deep.equal(['package.json']); + expect(result.sha).to.equal('commit456'); + + expect(gitbeaker.Branches.create.called).to.be.false; + }); + + it('prefixes merge request title when draft is requested', async () => { + const mergeRequestStub = sinon.stub().resolves({ + iid: 7, + title: 'Draft: release: v1.0.0', + description: 'release body', + labels: [] as string[], + }); + + const {gitlab} = createGitLabTestDouble({ + mergeRequests: mergeRequestStub, + }); + + const update: Update = { + path: 'CHANGELOG.md', + createIfMissing: true, + updater: { + updateContent(): string { + return 'new changelog'; + }, + }, + }; + + const pullRequest = { + headBranchName: 'release-please--main', + baseBranchName: 'main', + number: -1, + title: 'release: v1.0.0', + body: 'release body', + labels: [] as string[], + files: [] as string[], + }; + + const result = await gitlab.createPullRequest( + pullRequest, + 'main', + 'release commit', + [update], + {draft: true} + ); + + expect(mergeRequestStub.calledOnce).to.be.true; + const mrArgs = mergeRequestStub.getCall(0).args; + expect(mrArgs[3]).to.equal('Draft: release: v1.0.0'); + expect(result.title).to.equal('Draft: release: v1.0.0'); + }); + + it('creates a merge request when no file updates are produced', async () => { + const branchStub = sinon.stub().resolves({}); + const commitStub = sinon.stub().resolves({id: 'commit456'}); + const mergeRequestStub = sinon.stub().resolves({ + iid: 101, + title: 'chore: empty release', + description: '', + labels: [] as string[], + }); + + const {gitlab, gitbeaker} = createGitLabTestDouble({ + commits: commitStub, + mergeRequests: mergeRequestStub, + branches: branchStub, + }); + + const update: Update = { + path: 'package.json', + createIfMissing: true, + updater: { + updateContent(): string { + return ''; + }, + }, + }; + + const pullRequest = { + headBranchName: 'release-please--main', + baseBranchName: 'main', + number: -1, + title: 'chore: empty release', + body: '', + labels: [] as string[], + files: [] as string[], + }; + + const result = await gitlab.createPullRequest( + pullRequest, + 'main', + 'chore: empty release', + [update] + ); + + expect(commitStub.called).to.be.false; + expect(branchStub.calledOnce).to.be.true; + expect(branchStub.getCall(0).args).to.deep.equal([ + 'test-group/test-repo', + 'release-please--main', + 'main', + ]); + expect(gitbeaker.MergeRequests.create.calledOnce).to.be.true; + expect(result.files).to.deep.equal([]); + }); + + it('reuses an existing branch when GitLab reports it already exists', async () => { + const branchStub = sinon + .stub() + .rejects(new Error('Branch already exists')); + const mergeRequestStub = sinon.stub().resolves({ + iid: 202, + title: 'chore: reuse branch', + description: '', + labels: [] as string[], + }); + + const {gitlab} = createGitLabTestDouble({ + mergeRequests: mergeRequestStub, + branches: branchStub, + }); + + const update: Update = { + path: 'CHANGELOG.md', + createIfMissing: true, + updater: { + updateContent(): string { + return ''; + }, + }, + }; + + const pullRequest = { + headBranchName: 'release-please--main', + baseBranchName: 'main', + number: -1, + title: 'chore: reuse branch', + body: '', + labels: [] as string[], + files: [] as string[], + }; + + const result = await gitlab.createPullRequest( + pullRequest, + 'main', + 'chore: reuse branch', + [update] + ); + + expect(branchStub.calledOnce).to.be.true; + expect(mergeRequestStub.calledOnce).to.be.true; + expect(result.number).to.equal(202); + }); + }); + + describe('createRelease', () => { + it('creates a release via gitbeaker and returns metadata', async () => { + const projectReleaseStub = sinon.stub().resolves({ + name: 'v1.2.3', + tag_name: 'v1.2.3', + description: 'notes', + commit: {id: 'abc123'}, + _links: {self: 'https://gitlab.example.com/release/v1.2.3'}, + }); + + const {gitlab, gitbeaker} = createGitLabTestDouble({ + projectReleases: projectReleaseStub, + }); + + const release: Release = { + name: 'v1.2.3', + tag: TagName.parse('v1.2.3')!, + sha: 'abc123', + notes: 'notes', + }; + + const result = await gitlab.createRelease(release, {}); + + expect(projectReleaseStub.calledOnce).to.be.true; + const releaseArgs = projectReleaseStub.getCall(0).args; + expect(releaseArgs[0]).to.equal('test-group/test-repo'); + expect(releaseArgs[1]).to.deep.equal({ + name: 'v1.2.3', + tagName: 'v1.2.3', + description: 'notes', + ref: 'abc123', + }); + + expect(result).to.deep.include({ + name: 'v1.2.3', + tagName: 'v1.2.3', + sha: 'abc123', + notes: 'notes', + url: 'https://gitlab.example.com/release/v1.2.3', + }); + + expect(gitbeaker.ProjectReleases?.create.calledOnce).to.be.true; + }); + }); + + describe('buildChangeSet', () => { + it('returns updates for existing files and creates new files when allowed', async () => { + const {gitlab} = createGitLabTestDouble(); + const getFileStub = sinon.stub(gitlab, 'getFileContentsOnBranch'); + getFileStub.onCall(0).resolves({ + sha: 'blob', + content: Buffer.from('old content').toString('base64'), + parsedContent: 'old content', + mode: '100755', + update: true, + }); + getFileStub.onCall(1).rejects(new Error('missing new file')); + getFileStub.onCall(2).rejects(new Error('missing skip file')); + + const updates: Update[] = [ + { + path: 'existing.txt', + createIfMissing: false, + updater: { + updateContent(content: string | undefined): string { + expect(content).to.equal('old content'); + return 'updated content'; + }, + }, + }, + { + path: 'new-file.txt', + createIfMissing: true, + updater: { + updateContent(content: string | undefined): string { + expect(content).to.be.undefined; + return 'brand new content'; + }, + }, + }, + { + path: 'skip.txt', + createIfMissing: true, + updater: { + updateContent(): string { + return ''; + }, + }, + }, + ]; + + const changeSet = await gitlab.buildChangeSet(updates, 'main'); + + expect(getFileStub.callCount).to.equal(3); + expect(changeSet.size).to.equal(2); + + const existing = changeSet.get('existing.txt'); + expect(existing).to.deep.include({ + content: 'updated content', + originalContent: 'old content', + mode: '100755', + }); + expect(existing?.update).to.be.true; + + const created = changeSet.get('new-file.txt'); + expect(created).to.deep.include({ + content: 'brand new content', + originalContent: null, + mode: DEFAULT_FILE_MODE, + }); + expect(created?.update).to.be.false; + + expect(changeSet.has('skip.txt')).to.be.false; + }); + }); + + describe('addIssueLabels', () => { + it('falls back to issues when merge request edit is missing', async () => { + const mergeRequestEdit = sinon.stub().rejects(gitbeakerNotFoundError()); + const issuesEdit = sinon.stub().resolves({}); + + const {gitlab} = createGitLabTestDouble({ + mergeRequestsEdit: mergeRequestEdit, + issuesEdit, + }); + + await gitlab.addIssueLabels(['alpha', 'beta'], 55); + + expect( + mergeRequestEdit.calledOnceWithExactly('test-group/test-repo', 55, { + addLabels: 'alpha,beta', + }) + ).to.be.true; + expect( + issuesEdit.calledOnceWithExactly('test-group/test-repo', 55, { + addLabels: 'alpha,beta', + }) + ).to.be.true; + }); + + it('returns immediately when no labels are provided', async () => { + const {gitlab, gitbeaker} = createGitLabTestDouble(); + + await gitlab.addIssueLabels([], 66); + + expect(gitbeaker.MergeRequests.edit.called).to.be.false; + expect(gitbeaker.Issues.edit.called).to.be.false; + }); + + it('skips merge request edits when formatted labels are empty', async () => { + const {gitlab, gitbeaker} = createGitLabTestDouble(); + + await gitlab.addIssueLabels([''], 67); + + expect(gitbeaker.MergeRequests.edit.called).to.be.false; + expect(gitbeaker.Issues.edit.called).to.be.false; + }); + + it('rethrows unexpected errors so wrapAsync catch path is exercised', async () => { + const mergeRequestEdit = sinon.stub().rejects(new Error('boom')); + + const {gitlab} = createGitLabTestDouble({ + mergeRequestsEdit: mergeRequestEdit, + }); + + await gitlab.addIssueLabels(['alpha'], 68).then( + () => expect.fail('Expected addIssueLabels to throw'), + err => expect((err as Error).message).to.equal('boom') + ); + expect(mergeRequestEdit.calledOnce).to.be.true; + }); + }); + + describe('removeIssueLabels', () => { + it('falls back to issues when merge request edit returns 404', async () => { + const mergeRequestEdit = sinon.stub().rejects(gitbeakerNotFoundError()); + const issuesEdit = sinon.stub().resolves({}); + + const {gitlab} = createGitLabTestDouble({ + mergeRequestsEdit: mergeRequestEdit, + issuesEdit, + }); + + await gitlab.removeIssueLabels(['pending'], 77); + + expect( + mergeRequestEdit.calledOnceWithExactly('test-group/test-repo', 77, { + removeLabels: 'pending', + }) + ).to.be.true; + expect( + issuesEdit.calledOnceWithExactly('test-group/test-repo', 77, { + removeLabels: 'pending', + }) + ).to.be.true; + }); + + it('returns early when there are no labels', async () => { + const {gitlab, gitbeaker} = createGitLabTestDouble(); + + await gitlab.removeIssueLabels([], 88); + + expect(gitbeaker.MergeRequests.edit.called).to.be.false; + expect(gitbeaker.Issues.edit.called).to.be.false; + }); + + it('ignores requests when labels collapse to empty string', async () => { + const {gitlab, gitbeaker} = createGitLabTestDouble(); + + await gitlab.removeIssueLabels([''], 99); + + expect(gitbeaker.MergeRequests.edit.called).to.be.false; + expect(gitbeaker.Issues.edit.called).to.be.false; + }); + + it('rethrows unexpected errors from merge request edits', async () => { + const mergeRequestEdit = sinon.stub().rejects(new Error('remove boom')); + + const {gitlab} = createGitLabTestDouble({ + mergeRequestsEdit: mergeRequestEdit, + }); + + await gitlab.removeIssueLabels(['gamma'], 100).then( + () => expect.fail('Expected removeIssueLabels to throw'), + err => expect((err as Error).message).to.equal('remove boom') + ); + expect(mergeRequestEdit.calledOnce).to.be.true; + }); + }); + + describe('commentOnIssue', () => { + it('falls back to issues when the merge request no longer exists', async () => { + const mergeRequestNotesStub = sinon + .stub() + .rejects(gitbeakerNotFoundError()); + const issueNoteUrl = + 'https://gitlab.example.com/test-group/test-repo/-/issues/7#note_9'; + const issueNotesStub = sinon.stub().resolves({web_url: issueNoteUrl}); + + const {gitlab} = createGitLabTestDouble({ + mergeRequestNotes: mergeRequestNotesStub, + issueNotes: issueNotesStub, + }); + + const url = await gitlab.commentOnIssue('hello world', 7); + + expect(mergeRequestNotesStub.calledOnce).to.be.true; + expect( + issueNotesStub.calledOnceWithExactly( + 'test-group/test-repo', + 7, + 'hello world' + ) + ).to.be.true; + expect(url).to.equal(issueNoteUrl); + }); + }); + + describe('pullRequestIterator', () => { + it('returns merge requests with deduped files and extracted labels', async () => { + const mergeRequestsAll = sinon.stub().resolves([ + { + iid: 9, + title: 'feat: ships', + description: 'details', + source_branch: 'feature/ships', + target_branch: 'main', + state: 'merged', + labels: ['alpha', {name: 'beta'}, {unexpected: true} as unknown], + merge_commit_sha: 'abc123', + }, + ]); + const showChanges = sinon.stub().resolves({ + changes: [ + {new_path: 'file-a.txt'}, + {old_path: 'file-a.txt', new_path: 'file-b.txt'}, + {old_path: 'file-b.txt'}, + ], + }); + + const {gitlab} = createGitLabTestDouble({ + mergeRequestsAll, + mergeRequestsShowChanges: showChanges, + }); + + const merged: PullRequest[] = []; + for await (const pr of gitlab.pullRequestIterator( + 'main', + 'MERGED', + 5, + true + )) { + merged.push(pr); + } + + expect(mergeRequestsAll.calledOnce).to.be.true; + expect( + mergeRequestsAll.calledWithMatch({ + projectId: 'test-group/test-repo', + targetBranch: 'main', + state: 'merged', + }) + ).to.be.true; + expect(showChanges.calledOnceWithExactly('test-group/test-repo', 9)).to.be + .true; + expect(merged).to.have.lengthOf(1); + expect(merged[0]).to.deep.include({ + number: 9, + headBranchName: 'feature/ships', + baseBranchName: 'main', + title: 'feat: ships', + body: 'details', + labels: ['alpha', 'beta'], + sha: 'abc123', + mergeCommitOid: 'abc123', + }); + expect(merged[0].files).to.deep.equal(['file-a.txt', 'file-b.txt']); + }); + + it('skips merge requests targeting other branches and tolerates change fetch errors', async () => { + const mergeRequestsAll = sinon.stub().resolves([ + { + iid: 11, + title: 'draft', + description: 'skip me', + source_branch: 'draft', + target_branch: 'other', + state: 'opened', + labels: [], + }, + { + iid: 12, + title: 'fix: crash', + description: '', + source_branch: 'feature/fix', + target_branch: 'main', + state: 'opened', + labels: [], + }, + ]); + const showChanges = sinon.stub().rejects(new Error('boom')); + + const {gitlab} = createGitLabTestDouble({ + mergeRequestsAll, + mergeRequestsShowChanges: showChanges, + }); + + const results: PullRequest[] = []; + for await (const pr of gitlab.pullRequestIterator( + 'main', + 'OPEN', + 5, + true + )) { + results.push(pr); + } + + expect(results).to.have.lengthOf(1); + expect(results[0].number).to.equal(12); + expect(results[0].files).to.deep.equal([]); + expect(showChanges.calledOnceWithExactly('test-group/test-repo', 12)).to + .be.true; + }); + }); + + describe('releaseIterator', () => { + it('filters out releases missing tags or commits and maps metadata', async () => { + const releaseList = sinon.stub().resolves([ + { + name: 'v2.0.0', + tag_name: 'v2.0.0', + description: 'notes', + commit: {id: 'deadbeef'}, + links: {self: 'https://gitlab.example.com/releases/v2.0.0'}, + upcoming_release: true, + }, + { + name: 'skip-no-tag', + description: 'missing tag', + commit: {id: 'abc'}, + }, + { + name: 'skip-no-commit', + tag_name: 'v3.0.0', + commit: {}, + }, + ]); + + const {gitlab, gitbeaker} = createGitLabTestDouble(); + gitbeaker.ProjectReleases = { + create: sinon.stub(), + all: releaseList, + }; + + const releases: GitHubRelease[] = []; + for await (const rel of gitlab.releaseIterator()) { + releases.push(rel); + } + + expect(releases).to.have.lengthOf(1); + expect(releases[0]).to.deep.include({ + name: 'v2.0.0', + tagName: 'v2.0.0', + sha: 'deadbeef', + notes: 'notes', + url: 'https://gitlab.example.com/test-group/test-repo/-/releases/v2.0.0', + draft: true, + }); + }); + }); + + describe('tagIterator', () => { + it('fetches tags via REST API and yields results in order', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.resolves({ + ok: true, + json: async () => [ + {name: 'v1.0.0', commit: {id: 'abc'}}, + {name: 'v1.1.0', commit: {id: 'def'}}, + ], + } as unknown as Response); + + const {gitlab} = createGitLabTestDouble(); + + const tags: GitHubTag[] = []; + for await (const tag of gitlab.tagIterator({maxResults: 10})) { + tags.push(tag); + } + + expect(fetchStub.calledOnce).to.be.true; + expect(tags).to.deep.equal([ + {name: 'v1.0.0', sha: 'abc'}, + {name: 'v1.1.0', sha: 'def'}, + ]); + }); + }); + + describe('createFileOnNewBranch', () => { + it('rejects because the method is not implemented', async () => { + const {gitlab} = createGitLabTestDouble(); + + await gitlab + .createFileOnNewBranch('foo.txt', 'data', 'feature/foo', 'main') + .then( + () => expect.fail('Expected createFileOnNewBranch to throw'), + err => + expect((err as Error).message).to.equal( + 'createFileOnNewBranch not yet implemented for GitLab' + ) + ); + }); + }); + + describe('updatePullRequest', () => { + it('updates an existing merge request with new content', async () => { + const commitStub = sinon.stub().resolves({id: 'commit789'}); + const editStub = sinon.stub().resolves({ + iid: 42, + title: 'chore: release v1.0.1', + description: 'updated body', + labels: ['autorelease: tagged'], + }); + const fileStub = sinon.stub().resolves({ + blob_id: 'blob', + content: Buffer.from('old content').toString('base64'), + parsedContent: 'old content', + file_mode: '100644', + }); + + const {gitlab} = createGitLabTestDouble({ + repositoryFiles: fileStub, + commits: commitStub, + mergeRequestsEdit: editStub, + }); + + const update: Update = { + path: 'VERSION', + createIfMissing: false, + updater: { + updateContent(): string { + return '1.0.1'; + }, + }, + }; + + const releasePullRequest = { + headRefName: 'release-please--main', + title: {toString: () => 'chore: release v1.0.1'}, + body: {toString: () => 'updated body'}, + labels: ['autorelease: tagged'], + updates: [update], + }; + + const result = await gitlab.updatePullRequest( + 42, + releasePullRequest as any, + 'main' + ); + + expect(commitStub.calledOnce).to.be.true; + const commitArgs = commitStub.getCall(0).args; + expect(commitArgs[0]).to.equal('test-group/test-repo'); + expect(commitArgs[1]).to.equal('release-please--main'); + expect(commitArgs[3][0]).to.deep.include({ + action: 'update', + filePath: 'VERSION', + content: '1.0.1', + }); + expect(commitArgs[4]).to.deep.include({force: true}); + + expect(editStub.calledOnce).to.be.true; + const editArgs = editStub.getCall(0).args; + expect(editArgs[0]).to.equal('test-group/test-repo'); + expect(editArgs[1]).to.equal(42); + expect(editArgs[2]).to.deep.include({ + title: 'chore: release v1.0.1', + description: 'updated body', + labels: 'autorelease: tagged', + }); + + expect(result.number).to.equal(42); + expect(result.title).to.equal('chore: release v1.0.1'); + }); + + it('handles overflow by truncating body to GitLab limit', async () => { + const commitStub = sinon.stub().resolves({id: 'commit999'}); + const editStub = sinon.stub().resolves({ + iid: 43, + title: 'chore: large release', + description: 'truncated', + labels: [], + }); + + const {gitlab} = createGitLabTestDouble({ + commits: commitStub, + mergeRequestsEdit: editStub, + }); + + const overflowHandler = { + handleOverflow: sinon.stub().resolves({ + toString: () => 'a'.repeat(2000000), // Larger than 1 MiB + }), + }; + + const releasePullRequest = { + headRefName: 'release-please--main', + title: {toString: () => 'chore: large release'}, + body: {toString: () => 'original'}, + labels: [], + updates: [], + }; + + await gitlab.updatePullRequest(43, releasePullRequest as any, 'main', { + pullRequestOverflowHandler: overflowHandler as any, + }); + + const editArgs = editStub.getCall(0).args; + expect(editArgs[2].description).to.have.lengthOf(1048576); // 1 MiB + }); + }); + + describe('commitsSince', () => { + it('fetches commits and stops at filter boundary', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.resolves({ + ok: true, + json: async () => [ + {id: 'commit1', message: 'feat: add feature'}, + {id: 'commit2', message: 'fix: bug fix'}, + {id: 'commit3', message: 'chore: release 1.0.0'}, + ], + } as unknown as Response); + + const {gitlab} = createGitLabTestDouble(); + + const commits = await gitlab.commitsSince('main', commit => + /^chore: release/.test(commit.message) + ); + + expect(commits).to.have.lengthOf(2); + expect(commits[0].sha).to.equal('commit1'); + expect(commits[1].sha).to.equal('commit2'); + }); + + it('respects maxResults option', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.resolves({ + ok: true, + json: async () => [ + {id: 'commit1', message: 'msg1'}, + {id: 'commit2', message: 'msg2'}, + {id: 'commit3', message: 'msg3'}, + ], + } as unknown as Response); + + const {gitlab} = createGitLabTestDouble(); + + const commits = await gitlab.commitsSince('main', () => false, { + maxResults: 2, + }); + + expect(commits).to.have.lengthOf(2); + }); + }); + + describe('mergeCommitIterator', () => { + it('paginates through commits when response has full page', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.onCall(0).resolves({ + ok: true, + json: async () => + new Array(100).fill(null).map((_, i) => ({ + id: `commit${i}`, + message: `msg${i}`, + })), + } as unknown as Response); + fetchStub.onCall(1).resolves({ + ok: true, + json: async () => [{id: 'last', message: 'last'}], + } as unknown as Response); + + const {gitlab} = createGitLabTestDouble(); + + const commits = []; + for await (const commit of gitlab.mergeCommitIterator('main')) { + commits.push(commit); + } + + expect(fetchStub.callCount).to.equal(2); + expect(commits).to.have.lengthOf(101); + }); + + it('logs warning when backfillFiles is requested', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.resolves({ + ok: true, + json: async () => [{id: 'commit1', message: 'msg'}], + } as unknown as Response); + + const logger = createLoggerStub(); + const {gitlab} = createGitLabTestDouble(); + (gitlab as any).logger = logger; + + const commits = []; + for await (const commit of gitlab.mergeCommitIterator('main', { + backfillFiles: true, + })) { + commits.push(commit); + } + + expect(logger.warn.calledOnce).to.be.true; + expect(logger.warn.firstCall.args[0]).to.include('backfillFiles'); + }); + + it('handles fetch errors gracefully', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.resolves({ + ok: false, + status: 500, + } as unknown as Response); + + const logger = createLoggerStub(); + const {gitlab} = createGitLabTestDouble(); + (gitlab as any).logger = logger; + + const commits = []; + for await (const commit of gitlab.mergeCommitIterator('develop')) { + commits.push(commit); + } + + expect(commits).to.have.lengthOf(0); + expect(logger.warn.calledOnce).to.be.true; + }); + }); + + describe('getFileContents', () => { + it('fetches file from default branch', async () => { + const fileStub = sinon.stub().resolves({ + blob_id: 'sha123', + content: Buffer.from('file content').toString('base64'), + file_mode: '100644', + }); + + const {gitlab} = createGitLabTestDouble({ + repositoryFiles: fileStub, + }); + + const result = await gitlab.getFileContents('README.md'); + + expect(fileStub.calledOnce).to.be.true; + expect(fileStub.firstCall.args).to.deep.equal([ + 'test-group/test-repo', + 'README.md', + 'main', + ]); + expect(result.sha).to.equal('sha123'); + expect(result.mode).to.equal('100644'); + }); + }); + + describe('getFileContentsOnBranch', () => { + it('returns undefined for missing files', async () => { + const fileStub = sinon.stub().rejects(gitbeakerNotFoundError()); + + const {gitlab} = createGitLabTestDouble({ + repositoryFiles: fileStub, + }); + + const result = await gitlab.getFileContentsOnBranch( + 'missing.txt', + 'feature' + ); + + expect(result).to.be.undefined; + }); + + it('rethrows non-404 errors', async () => { + const fileStub = sinon.stub().rejects(new Error('server error')); + + const {gitlab} = createGitLabTestDouble({ + repositoryFiles: fileStub, + }); + + await gitlab.getFileContentsOnBranch('file.txt', 'branch').then( + () => expect.fail('Expected error to be thrown'), + err => expect((err as Error).message).to.include('Failed to fetch file') + ); + }); + }); + + describe('getFileJson', () => { + it('parses JSON content from file', async () => { + const jsonContent = JSON.stringify({version: '1.0.0'}); + const fileStub = sinon.stub().resolves({ + blob_id: 'sha', + content: jsonContent, // GitLab API returns decoded content + file_mode: '100644', + }); + + const {gitlab} = createGitLabTestDouble({ + repositoryFiles: fileStub, + }); + + const result = await gitlab.getFileJson('package.json', 'main'); + + expect(result).to.deep.equal({version: '1.0.0'}); + }); + + it('strips BOM before parsing JSON', async () => { + const jsonWithBom = '\uFEFF{"key":"value"}'; + const fileStub = sinon.stub().resolves({ + blob_id: 'sha', + content: jsonWithBom, // GitLab API returns decoded content with BOM + file_mode: '100644', + }); + + const {gitlab} = createGitLabTestDouble({ + repositoryFiles: fileStub, + }); + + const result = await gitlab.getFileJson('config.json', 'main'); + + expect(result).to.deep.equal({key: 'value'}); + }); + }); + + describe('findFilesByFilename', () => { + it('logs warning for unimplemented method', async () => { + const logger = createLoggerStub(); + const {gitlab} = createGitLabTestDouble(); + (gitlab as any).logger = logger; + + const files = await gitlab.findFilesByFilename('test.txt'); + + expect(files).to.deep.equal([]); + expect(logger.warn.calledOnce).to.be.true; + expect(logger.warn.firstCall.args[0]).to.include( + 'findFilesByFilenameAndRef' + ); + }); + }); + + describe('findFilesByGlob', () => { + it('logs warning for unimplemented method', async () => { + const logger = createLoggerStub(); + const {gitlab} = createGitLabTestDouble(); + (gitlab as any).logger = logger; + + const files = await gitlab.findFilesByGlob('**/*.ts'); + + expect(files).to.deep.equal([]); + expect(logger.warn.calledOnce).to.be.true; + expect(logger.warn.firstCall.args[0]).to.include('findFilesByGlobAndRef'); + }); + }); + + describe('findFilesByExtension', () => { + it('logs warning for unimplemented method', async () => { + const logger = createLoggerStub(); + const {gitlab} = createGitLabTestDouble(); + (gitlab as any).logger = logger; + + const files = await gitlab.findFilesByExtension('.js'); + + expect(files).to.deep.equal([]); + expect(logger.warn.calledOnce).to.be.true; + expect(logger.warn.firstCall.args[0]).to.include( + 'findFilesByExtensionAndRef' + ); + }); + }); + + describe('generateReleaseNotes', () => { + it('logs warning and returns empty string', async () => { + const logger = createLoggerStub(); + const {gitlab} = createGitLabTestDouble(); + (gitlab as any).logger = logger; + + const notes = await gitlab.generateReleaseNotes( + 'v1.0.0', + 'main', + 'v0.9.0' + ); + + expect(notes).to.equal(''); + expect(logger.warn.calledOnce).to.be.true; + expect(logger.warn.firstCall.args[0]).to.include('generateReleaseNotes'); + }); + }); + + describe('createReleasePullRequest', () => { + it('rejects because method is not implemented', async () => { + const {gitlab} = createGitLabTestDouble(); + + await gitlab.createReleasePullRequest({} as any, 'main').then( + () => expect.fail('Expected method to throw'), + err => + expect((err as Error).message).to.equal( + 'createReleasePullRequest not yet implemented for GitLab' + ) + ); + }); + }); + + describe('getPullRequest', () => { + it('rejects because method is not implemented', async () => { + const {gitlab} = createGitLabTestDouble(); + + await gitlab.getPullRequest(1).then( + () => expect.fail('Expected method to throw'), + err => + expect((err as Error).message).to.equal( + 'getPullRequest not yet implemented for GitLab' + ) + ); + }); + }); + + describe('static create', () => { + it('uses provided defaultBranch when specified', async () => { + // Testing the full create flow is complex due to import mocking. + // This test validates the basic parameter passing. + // Integration tests cover the full flow. + expect(GitLab.create).to.be.a('function'); + }); + }); + + describe('static defaultBranch', () => { + it('returns default branch from GitLab project', async () => { + const projectStub = sinon.stub().resolves({default_branch: 'production'}); + const gitbeaker = {Projects: {show: projectStub}} as any; + + const branch = await GitLab.defaultBranch('org', 'repo', gitbeaker); + + expect(branch).to.equal('production'); + expect(projectStub.calledOnceWith('org/repo')).to.be.true; + }); + + it('returns main when project has no default_branch', async () => { + const projectStub = sinon.stub().resolves({}); + const gitbeaker = {Projects: {show: projectStub}} as any; + + const branch = await GitLab.defaultBranch('org', 'repo', gitbeaker); + + expect(branch).to.equal('main'); + }); + + it('throws when project fetch fails', async () => { + const projectStub = sinon.stub().rejects(new Error('not authorized')); + const gitbeaker = {Projects: {show: projectStub}} as any; + + await GitLab.defaultBranch('org', 'repo', gitbeaker).then( + () => expect.fail('Expected method to throw'), + err => + expect((err as Error).message).to.include( + 'Failed to fetch GitLab project' + ) + ); + }); + }); + + describe('createRelease with options', () => { + it('logs warning for draft option', async () => { + const projectReleaseStub = sinon.stub().resolves({ + tag_name: 'v1.0.0', + commit: {id: 'abc'}, + _links: {self: 'url'}, + }); + + const logger = createLoggerStub(); + const {gitlab} = createGitLabTestDouble({ + projectReleases: projectReleaseStub, + }); + (gitlab as any).logger = logger; + + const release: Release = { + name: 'v1.0.0', + tag: TagName.parse('v1.0.0')!, + sha: 'abc', + notes: 'notes', + }; + + await gitlab.createRelease(release, {draft: true}); + + expect(logger.warn.called).to.be.true; + const draftWarning = logger.warn + .getCalls() + .find((call: any) => call.args[0]?.includes('draft')); + expect(draftWarning).to.exist; + }); + + it('logs warning for prerelease option', async () => { + const projectReleaseStub = sinon.stub().resolves({ + tag_name: 'v2.0.0-beta', + commit: {id: 'xyz'}, + _links: {self: 'url'}, + }); + + const logger = createLoggerStub(); + const {gitlab} = createGitLabTestDouble({ + projectReleases: projectReleaseStub, + }); + (gitlab as any).logger = logger; + + const release: Release = { + name: 'v2.0.0-beta', + tag: TagName.parse('v2.0.0-beta')!, + sha: 'xyz', + notes: 'beta notes', + }; + + await gitlab.createRelease(release, {prerelease: true}); + + expect(logger.warn.called).to.be.true; + const prereleaseWarning = logger.warn + .getCalls() + .find((call: any) => call.args[0]?.includes('prerelease')); + expect(prereleaseWarning).to.exist; + }); + + it('handles 409 conflict when release already exists', async () => { + const projectReleaseStub = sinon.stub().rejects( + new GitbeakerRequestError('Conflict', { + cause: { + description: 'Release already exists', + request: {} as Request, + response: {status: 409} as Response, + }, + }) + ); + + const logger = createLoggerStub(); + const {gitlab} = createGitLabTestDouble({ + projectReleases: projectReleaseStub, + }); + (gitlab as any).logger = logger; + + const release: Release = { + name: 'v3.0.0', + tag: TagName.parse('v3.0.0')!, + sha: 'def', + notes: 'notes', + }; + + await gitlab.createRelease(release).then( + () => expect.fail('Expected method to throw'), + err => { + expect(err).to.be.instanceOf(GitbeakerRequestError); + expect(logger.error.calledOnce).to.be.true; + expect(logger.error.firstCall.args[0]).to.include('already exists'); + } + ); + }); + }); + + describe('createPullRequest with fork option', () => { + it('rejects fork-based pull requests', async () => { + const {gitlab} = createGitLabTestDouble(); + + const pullRequest = { + headBranchName: 'release', + baseBranchName: 'main', + number: -1, + title: 'release', + body: '', + labels: [], + files: [], + }; + + await gitlab + .createPullRequest(pullRequest, 'main', 'commit', [], {fork: true}) + .then( + () => expect.fail('Expected method to throw'), + err => + expect((err as Error).message).to.include( + 'does not yet support fork-based' + ) + ); + }); + }); + + describe('tagIterator with pagination', () => { + it('handles multiple pages of tags', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.onCall(0).resolves({ + ok: true, + json: async () => + new Array(100).fill(null).map((_, i) => ({ + name: `v1.0.${i}`, + commit: {id: `commit${i}`}, + })), + } as unknown as Response); + fetchStub.onCall(1).resolves({ + ok: true, + json: async () => [{name: 'v2.0.0', commit: {id: 'final'}}], + } as unknown as Response); + + const {gitlab} = createGitLabTestDouble(); + + const tags: GitHubTag[] = []; + for await (const tag of gitlab.tagIterator()) { + tags.push(tag); + } + + expect(fetchStub.callCount).to.equal(2); + expect(tags).to.have.lengthOf(101); + expect(tags[100]).to.deep.equal({name: 'v2.0.0', sha: 'final'}); + }); + + it('stops when reaching maxResults', async () => { + const fetchStub = sinon.stub(globalThis, 'fetch'); + fetchStub.resolves({ + ok: true, + json: async () => + new Array(100).fill(null).map((_, i) => ({ + name: `v1.${i}`, + commit: {id: `c${i}`}, + })), + } as unknown as Response); + + const {gitlab} = createGitLabTestDouble(); + + const tags: GitHubTag[] = []; + for await (const tag of gitlab.tagIterator({maxResults: 50})) { + tags.push(tag); + } + + expect(tags).to.have.lengthOf(50); + }); + }); + + describe('pullRequestIterator with includeFiles=false', () => { + it('skips fetching file changes', async () => { + const mergeRequestsAll = sinon.stub().resolves([ + { + iid: 15, + title: 'test', + description: '', + source_branch: 'feature', + target_branch: 'main', + state: 'merged', + labels: [], + }, + ]); + + const showChanges = sinon.stub(); + + const {gitlab} = createGitLabTestDouble({ + mergeRequestsAll, + mergeRequestsShowChanges: showChanges, + }); + + const results: PullRequest[] = []; + for await (const pr of gitlab.pullRequestIterator( + 'main', + 'MERGED', + 10, + false + )) { + results.push(pr); + } + + expect(results).to.have.lengthOf(1); + expect(results[0].files).to.deep.equal([]); + expect(showChanges.called).to.be.false; + }); + }); + + describe('releaseIterator error handling', () => { + it('handles fetch errors gracefully', async () => { + const releaseList = sinon.stub().rejects(new Error('API error')); + + const logger = createLoggerStub(); + const {gitlab, gitbeaker} = createGitLabTestDouble(); + gitbeaker.ProjectReleases = { + create: sinon.stub(), + all: releaseList, + }; + (gitlab as any).logger = logger; + + const releases: GitHubRelease[] = []; + for await (const rel of gitlab.releaseIterator()) { + releases.push(rel); + } + + expect(releases).to.have.lengthOf(0); + expect(logger.warn.calledOnce).to.be.true; + expect(logger.warn.firstCall.args[0]).to.include( + 'Failed to fetch releases' + ); + }); + }); +}); diff --git a/test/helpers.ts b/test/helpers.ts index 74ac886da..d069bbc3e 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -25,7 +25,8 @@ import { ConventionalCommit, parseConventionalCommits, } from '../src/commit'; -import {GitHub, GitHubTag, GitHubRelease} from '../src/github'; +import {GitHub} from '../src/github'; +import {GitHubRelease, GitHubTag} from '../src/provider-interfaces'; import {Update} from '../src/update'; import {expect} from 'chai'; import {CandidateReleasePullRequest} from '../src/manifest'; diff --git a/test/manifest.ts b/test/manifest.ts index 90470b7e6..9349f216f 100644 --- a/test/manifest.ts +++ b/test/manifest.ts @@ -2973,13 +2973,13 @@ describe('Manifest', () => { releaseType: 'simple', component: 'b', extraFiles: ['pkg.properties', 'src/version', '/bbb.properties'], - skipGithubRelease: true, + skipRelease: true, }, 'pkg/c': { releaseType: 'simple', component: 'c', extraFiles: ['/pkg/pkg-c.properties', 'ccc.properties'], - skipGithubRelease: true, + skipRelease: true, }, }, { @@ -5098,7 +5098,7 @@ describe('Manifest', () => { }, 'packages/object-selector': { releaseType: 'node', - skipGithubRelease: true, + skipRelease: true, }, 'packages/datastore-lock': { releaseType: 'node', diff --git a/typings/error-options.d.ts b/typings/error-options.d.ts new file mode 100644 index 000000000..16aae2df0 --- /dev/null +++ b/typings/error-options.d.ts @@ -0,0 +1,21 @@ +// Copyright 2025 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. + +export {}; + +declare global { + interface ErrorOptions { + cause?: unknown; + } +}