diff --git a/.github/renovate.json5 b/.github/renovate.json5 new file mode 100644 index 0000000..5110561 --- /dev/null +++ b/.github/renovate.json5 @@ -0,0 +1,86 @@ +{ + extends: [ + ':semanticCommits', + ':semanticCommitTypeAll(deps)', + ':semanticCommitScopeDisabled', + ':dependencyDashboard', + ':approveMajorUpdates', + ':automergeMinor', + ':automergeLinters', + ':automergeTesters', + ':automergeTypes', + ':maintainLockFilesWeekly', + ':enableVulnerabilityAlerts', + 'helpers:pinGitHubActionDigests', + ], + packageRules: [ + { + groupName: 'linters', + matchUpdateTypes: [ + 'minor', + 'patch', + ], + matchDepNames: [ + 'golangci/golangci-lint', + ], + automerge: true, + }, + { + groupName: 'testing', + matchUpdateTypes: [ + 'minor', + 'patch', + ], + matchDepNames: [ + 'github.com/stretchr/testify', + ], + automerge: true, + }, + { + groupName: 'github-actions', + matchUpdateTypes: [ + 'minor', + 'patch', + ], + matchDepTypes: [ + 'action', + ], + automerge: true, + }, + { + groupName: 'gitlab-ci', + matchUpdateTypes: [ + 'minor', + 'patch', + ], + matchPackageNames: [ + 'registry.gitlab.com/gitlab-org/release-cli', + ], + automerge: true, + }, + ], + customManagers: [ + { + customType: 'regex', + managerFilePatterns: [ + '/.+\\.ya?ml$/', + ], + matchStrings: [ + ': (?.+) # renovate: datasource=(?[a-z-]+) depName=(?[^\\s]+)(?: lookupName=(?[^\\s]+))?(?: versioning=(?[a-z-]+))?(?: extractVersion=(?[^\\s]+))?', + ], + }, + { + customType: 'regex', + managerFilePatterns: [ + '/.+\\.toml$/' + ], + matchStrings: [ + '= "(?.+)" # renovate: datasource=(?[a-z-]+) depName=(?[^\\s]+)(?: lookupName=(?[^\\s]+))?(?: versioning=(?[a-z-]+))?(?: extractVersion=(?[^\\s]+))?', + ], + } + ], + postUpdateOptions: [ + 'gomodUpdateImportPaths', + 'gomodTidy', + ], +} diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 38a1f4c..a5f2854 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -2,58 +2,76 @@ name: ci on: push: - branches: [main] + branches: [ main ] pull_request: - jobs: lint: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 - name: Run golangci-lint - uses: golangci/golangci-lint-action@v6 + uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8 with: - version: v1.60.1 # renovate: datasource=github-releases depName=golangci/golangci-lint + install-mode: none args: --timeout 5m - test: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 - name: Run tests run: go test -v -race -coverpkg=./... -coverprofile=coverage.txt ./... - name: Upload results to Codecov - uses: codecov/codecov-action@v4 + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5 with: token: ${{ secrets.CODECOV_TOKEN }} + flags: unit + + test-e2e-forgejo: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version-file: go.mod + + # We can not use "jobs..services". + # We want to mount the config file, which is only available after "Checkout". + - name: Start Forgejo + working-directory: test/e2e/forgejo + run: docker compose up --wait + + - name: Run tests + run: go test -tags e2e_forgejo -v -race -coverpkg=./... -coverprofile=coverage.txt ./test/e2e/forgejo + + + - name: Upload results to Codecov + uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: e2e go-mod-tidy: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 - name: Run go mod tidy run: go mod tidy diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 908f3ad..fcafe52 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -8,33 +8,30 @@ jobs: deploy: runs-on: ubuntu-latest permissions: - contents: write # To push a branch - pages: write # To push to a GitHub Pages site + contents: write # To push a branch + pages: write # To push to a GitHub Pages site id-token: write # To update the deployment status + steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: lfs: "true" - - name: Install latest mdbook - run: | - tag=$(curl 'https://api.github.com/repos/rust-lang/mdbook/releases/latest' | jq -r '.tag_name') - url="https://github.com/rust-lang/mdbook/releases/download/${tag}/mdbook-${tag}-x86_64-unknown-linux-gnu.tar.gz" - mkdir mdbook - curl -sSL $url | tar -xz --directory=./mdbook - echo `pwd`/mdbook >> $GITHUB_PATH + + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 + - name: Build Book - run: | - # This assumes your book is in the root of your repository. - # Just add a `cd` here if you need to change to another directory. - cd docs - mdbook build + working-directory: docs + run: mdbook build + - name: Setup Pages - uses: actions/configure-pages@v4 + uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5 + - name: Upload artifact - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4 with: # Upload entire repository - path: 'docs/book' + path: "docs/book" + - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4 diff --git a/.github/workflows/mirror.yaml b/.github/workflows/mirror.yaml index 352ed37..940d149 100644 --- a/.github/workflows/mirror.yaml +++ b/.github/workflows/mirror.yaml @@ -11,10 +11,12 @@ jobs: REMOTE: mirror steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: # Need all to fetch all tags so we can push them fetch-depth: 0 + # Required so they can be pushed too + lfs: true - name: Add Remote env: diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8e08b7a..7da1f8a 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -14,12 +14,16 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 + + - name: Prepare ko + run: | + echo "${{ github.token }}" | ko login ghcr.io --username "dummy" --password-stdin + + repo=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]') + echo "KO_DOCKER_REPO=ghcr.io/${repo}" + echo "KO_DOCKER_REPO=ghcr.io/${repo}" >> $GITHUB_ENV - - uses: ko-build/setup-ko@v0.7 - run: ko build --bare --tags ${{ github.ref_name }} github.com/apricote/releaser-pleaser/cmd/rp diff --git a/.github/workflows/releaser-pleaser.yaml b/.github/workflows/releaser-pleaser.yaml index 543c557..8d1e62b 100644 --- a/.github/workflows/releaser-pleaser.yaml +++ b/.github/workflows/releaser-pleaser.yaml @@ -2,7 +2,7 @@ name: releaser-pleaser on: push: - branches: [main] + branches: [ main ] # Using pull_request_target to avoid tainting the actual release PR with code from open feature pull requests pull_request_target: types: @@ -10,7 +10,14 @@ on: - labeled - unlabeled -permissions: {} +# Only one job needs to run at a time, if a new job is started there is probably new data to include in the response, so +# it does not make sense to finish the previous job. This also helps with "data-race conflicts", where a human changes +# the PR description but releaser-pleaser was already running and overwrites the humans changes. +concurrency: + group: releaser-pleaser + cancel-in-progress: true + +permissions: { } jobs: releaser-pleaser: @@ -18,23 +25,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5 with: ref: main - - name: Set up Go - uses: actions/setup-go@v5 - with: - go-version-file: go.mod + - uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3 # Build container image from current commit and replace image ref in `action.yml` # Without this, any new flags in `action.yml` would break the job in this repository until the new # version is released. But a new version can only be released if this job works. - - uses: ko-build/setup-ko@v0.7 - - run: ko build --bare --local --tags ci github.com/apricote/releaser-pleaser/cmd/rp + - run: ko build --bare --local --platform linux/amd64 --tags ci github.com/apricote/releaser-pleaser/cmd/rp - - run: mkdir -p .github/actions/releaser-pleaser - - run: "sed -i 's|image: .*$|image: ghcr.io/apricote/releaser-pleaser:ci|g' action.yml" + - run: "sed -i 's|image: .*$|image: docker://ko.local:ci|g' action.yml" # Dogfood the action to make sure it works for users. - name: releaser-pleaser diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 742be9d..5530600 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -1,4 +1,4 @@ -stages: [release] +stages: [ release ] # For the GitLab CI/CD component to be usable, it needs to be published in # the CI/CD catalog. This happens on new releases. @@ -6,7 +6,7 @@ stages: [release] # and create a corresponding GitLab Release. create-release: stage: release - image: registry.gitlab.com/gitlab-org/release-cli:latest + image: registry.gitlab.com/gitlab-org/release-cli:v0.24.0 script: echo "Creating release $CI_COMMIT_TAG" rules: - if: $CI_COMMIT_TAG diff --git a/.golangci.yaml b/.golangci.yaml index b3e717d..b66f0a5 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -1,14 +1,38 @@ +version: "2" linters: - presets: - - bugs - - error - - import - - metalinter - - module - - unused - enable: + - asasalint + - asciicheck + - bidichk + - bodyclose + - contextcheck + - durationcheck + - errchkjson + - errorlint + - exhaustive + - gocheckcompilerdirectives + - gochecksumtype + - gocritic + - gomoddirectives + - gomodguard + - gosec + - gosmopolitan + - loggercheck + - makezero + - musttag + - nilerr + - nilnesserr + - noctx + - protogetter + - reassign + - recvcheck + - rowserrcheck + - spancheck + - sqlclosecheck - testifylint + - unparam + - zerologlint + - revive disable: # preset error @@ -18,10 +42,23 @@ linters: # preset import - depguard -linters-settings: - gci: - sections: - - standard - - default - - localmodule + settings: + revive: + rules: + - name: exported + disabled: true + gomoddirectives: + replace-allow-list: + - codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 + +formatters: + enable: + - gci + - goimports + settings: + gci: + sections: + - standard + - default + - localmodule diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f187be..2ba0bc8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,132 @@ # Changelog +## [v0.7.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.7.1) + +### Bug Fixes + +- using code blocks within release-notes (#275) +- no html escaping for changelog template (#277) + +## [v0.7.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.7.0) + +### Highlights :sparkles: + +#### Update version in `package.json` + +Thanks to @Mattzi it is now possible to use `releaser-pleaser` in Javascript/Node.js projects with a `package.json` file. + +You can enable this with the option `updaters: packagejson` in the GitHub Actions / GitLab CI/CD config. + +All updaters, including the defaults `changelog` and `generic` can now be enabled and disabled through this field. You can find a full list in the [documentation](https://apricote.github.io/releaser-pleaser/reference/updaters.html). + +### Features + +- add updater for package.json (#213) +- highlight breaking changes in release notes (#234) + +### Bug Fixes + +- filter out empty updaters in input (#235) +- **github**: duplicate release pr when process is stopped at wrong moment (#236) + +## [v0.6.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.6.1) + +### Bug Fixes + +- **gitlab**: support fast-forward merges (#210) + +## [v0.6.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.6.0) + +### ✨ Highlights + +#### Reduced resource usage + +`releaser-pleaser` now uses less resources: + +- It now skips pushing changes to the release pull request if they are only a rebase. +- The configurations for GitHub Actions and GitLab CI/CD now makes sure that only a single job is running at the same time. On GitHub unnecessary/duplicate jobs are also automatically aborted. +- It handles the stop signals from the CI environment and tries to exit quickly. + +\```yaml +concurrency: +group: releaser-pleaser +cancel-in-progress: true +\``` + +#### Avoid losing manual edits to release pull request + +Before, releaser-pleaser was prone to overwriting user changes to the release pull request if they were made after releaser-pleaser already started running. There is now an additional check right before submitting the changes to see if the description changed, and retry if it did. + +#### Proper commit authorship + +Before, the release commits were created by `releaser-pleaser <>`. This was ugly to look at. We now check for details on the API user used to talk to the forge, and use that users details instead as the commit author. The committer is still `releaser-pleaser`. + +### Features + +- real user as commit author (#187) +- avoid pushing release branch only for rebasing (#114) +- colorize log output (#195) +- graceful shutdown when CI job is cancelled (#196) +- detect changed pull request description and retry process (#197) +- run one job concurrently to reduce chance of conflicts (#198) + +### Bug Fixes + +- crash when running in repo without any tags (#190) + +## [v0.5.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.5.1) + +### Bug Fixes + +- invalid version for subsequent pre-releases (#174) + +## [v0.5.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.5.0) + +### Features + +- **gitlab**: make job dependencies configurable and run immediately (#101) +- **github**: mark pre-releases correctly (#110) + +### Bug Fixes + +- use commits with slightly invalid messages in release notes (#105) +- create CHANGELOG.md if it does not exist (#108) + +## [v0.4.2](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.2) + +### Bug Fixes + +- **action**: container image reference used wrong syntax (#96) + +## [v0.4.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.1) + +### Bug Fixes + +- **gitlab**: release not created when release pr was squashed (#86) + +## [v0.4.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.0) + +### ✨ Highlights + +#### GitLab Support + +You can now use `releaser-pleaser` with projects hosted on GitLab.com and self-managed GitLab installations. Check out the new [tutorial](https://apricote.github.io/releaser-pleaser/tutorials/gitlab.html) to get started. + +### Features + +- add support for GitLab repositories (#49) +- add shell to container image (#59) +- **gitlab**: add CI/CD component (#55) +- **changelog**: omit version heading in forge release notes +- **gitlab**: support self-managed instances (#75) + +### Bug Fixes + +- **parser**: continue on unparsable commit message (#48) +- **cli**: command name in help output (#52) +- **parser**: invalid handling of empty lines (#53) +- multiple extra-files are not evaluated properly (#61) + ## [v0.4.0-beta.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.0-beta.1) ### Features @@ -43,6 +170,7 @@ You can now edit the message for a pull request after merging by adding a \```rp - **cli**: show release PR url in log messages (#44) ## [v0.2.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.2.0) + ### Features - update version references in any files (#14) @@ -55,6 +183,7 @@ You can now edit the message for a pull request after merging by adding a \```rp - **action**: invalid quoting for extra-files arg (#25) ## [v0.2.0-beta.2](https://github.com/apricote/releaser-pleaser/releases/tag/v0.2.0-beta.2) + ### Features - update version references in any files (#14) @@ -65,6 +194,7 @@ You can now edit the message for a pull request after merging by adding a \```rp - **ci**: ko pipeline permissions (#23) ## [v0.2.0-beta.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.2.0-beta.1) + ### Features - update version references in any files (#14) @@ -74,13 +204,14 @@ You can now edit the message for a pull request after merging by adding a \```rp - **ci**: building release image fails (#21) ## [v0.2.0-beta.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.2.0-beta.0) + ### Features - update version references in any files (#14) ## [v0.1.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.1.0) -### This is the first release ever, so it also includes a lot of other functionality. +### This is the first release ever, so it also includes a lot of other functionality. ### Features diff --git a/README.md b/README.md index 6c282dd..2a70ea4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # releaser-pleaser -`releaser-pleaser` is a tool designed to automate versioning and changelog management for your projects. Building on the concepts of [`release-please`](https://github.com/googleapis/release-please), it streamlines the release process through GitHub Actions or GitLab CI. +

+ releaser-pleaser is a tool designed to automate versioning and changelog management for your projects. Building on the concepts of release-please, it streamlines the release process through GitHub Actions or GitLab CI. +

+ +

+ Badge: Documentation + Badge: Stable Release + Badge: License GPL-3.0 +

## Features @@ -14,20 +22,20 @@ `releaser-pleaser` simplifies release management, allowing maintainers to focus on development while ensuring consistent and well-documented releases. -## Status - -This project is still under active development. You can not reasonably use it right now and not all features advertised above work. Keep your eyes open for any releases. - ## Relation to `release-please` -After using `release-please` for 1.5 years, I've found it to be the best tool for low-effort releases currently available. While I appreciate many of its features, I identified several additional capabilities that would significantly enhance my workflow. Although it might be possible to incorporate these features into `release-please`, I decided to channel my efforts into creating a new tool that specifically addresses my needs. +After using +`release-please` for 1.5 years, I've found it to be the best tool for low-effort releases currently available. While I appreciate many of its features, I identified several additional capabilities that would significantly enhance my workflow. Although it might be possible to incorporate these features into +`release-please`, I decided to channel my efforts into creating a new tool that specifically addresses my needs. Key differences in `releaser-pleaser` include: - Support for multiple forges (both GitHub and GitLab) - Better support for pre-releases -One notable limitation of `release-please` is its deep integration with the GitHub API, making the addition of support for other platforms (like GitLab) a substantial undertaking. `releaser-pleaser` aims to overcome this limitation by design, offering a more versatile solution for automated release management across different platforms and project requirements. +One notable limitation of +`release-please` is its deep integration with the GitHub API, making the addition of support for other platforms (like GitLab) a substantial undertaking. +`releaser-pleaser` aims to overcome this limitation by design, offering a more versatile solution for automated release management across different platforms and project requirements. ## License diff --git a/action.yml b/action.yml index 15c82df..5d25210 100644 --- a/action.yml +++ b/action.yml @@ -14,19 +14,24 @@ inputs: required: false default: ${{ github.token }} extra-files: - description: 'List of files that are scanned for version references.' + description: 'List of files that are scanned for version references by the generic updater.' + required: false + default: "" + updaters: + description: "List of updaters that are run. Default updaters can be removed by specifying them as -name. Multiple updaters should be concatenated with a comma. Default Updaters: changelog,generic" required: false default: "" # Remember to update docs/reference/github-action.md -outputs: {} +outputs: { } runs: using: 'docker' - image: ghcr.io/apricote/releaser-pleaser:v0.4.0-beta.1 # x-releaser-pleaser-version + image: docker://ghcr.io/apricote/releaser-pleaser:v0.7.1 # x-releaser-pleaser-version args: - run - --forge=github - --branch=${{ inputs.branch }} - --extra-files="${{ inputs.extra-files }}" + - --updaters="${{ inputs.updaters }}" env: GITHUB_TOKEN: "${{ inputs.token }}" GITHUB_USER: "oauth2" diff --git a/cmd/rp/cmd/root.go b/cmd/rp/cmd/root.go index 4da80e5..f2dd180 100644 --- a/cmd/rp/cmd/root.go +++ b/cmd/rp/cmd/root.go @@ -1,20 +1,29 @@ package cmd import ( + "context" "log/slog" "os" + "os/signal" "runtime/debug" + "syscall" "github.com/spf13/cobra" ) -var logger *slog.Logger +func NewRootCmd() *cobra.Command { + var cmd = &cobra.Command{ + Use: "rp", + Short: "", + Long: ``, + Version: version(), + SilenceUsage: true, // Makes it harder to find the actual error + SilenceErrors: true, // We log manually with slog + } -var rootCmd = &cobra.Command{ - Use: "rp", - Short: "", - Long: ``, - Version: version(), + cmd.AddCommand(newRunCommand()) + + return cmd } func version() string { @@ -39,15 +48,33 @@ func version() string { } func Execute() { - err := rootCmd.Execute() + // Behaviour when cancelling jobs: + // + // GitHub Actions: https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/canceling-a-workflow#steps-github-takes-to-cancel-a-workflow-run + // 1. SIGINT + // 2. Wait 7500ms + // 3. SIGTERM + // 4. Wait 2500ms + // 5. SIGKILL + // + // GitLab CI/CD: https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/4446 + // 1. SIGTERM + // 2. Wait ??? + // 3. SIGKILL + // + // We therefore need to listen on SIGINT and SIGTERM + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) + go func() { + // Make sure to stop listening on signals after receiving the first signal to hand control of the signal back + // to the runtime. The Go runtime implements a "force shutdown" if the signal is received again. + <-ctx.Done() + slog.InfoContext(ctx, "Received shutdown signal, stopping...") + stop() + }() + + err := NewRootCmd().ExecuteContext(ctx) if err != nil { + slog.ErrorContext(ctx, err.Error()) os.Exit(1) } } - -func init() { - logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - Level: slog.LevelDebug, - })) - -} diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index c29047b..3d575b0 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -2,6 +2,8 @@ package cmd import ( "fmt" + "log/slog" + "slices" "strings" "github.com/spf13/cobra" @@ -9,89 +11,130 @@ import ( rp "github.com/apricote/releaser-pleaser" "github.com/apricote/releaser-pleaser/internal/commitparser/conventionalcommits" "github.com/apricote/releaser-pleaser/internal/forge" + "github.com/apricote/releaser-pleaser/internal/forge/forgejo" "github.com/apricote/releaser-pleaser/internal/forge/github" "github.com/apricote/releaser-pleaser/internal/forge/gitlab" + "github.com/apricote/releaser-pleaser/internal/log" "github.com/apricote/releaser-pleaser/internal/updater" "github.com/apricote/releaser-pleaser/internal/versioning" ) -var runCmd = &cobra.Command{ - Use: "run", - RunE: run, -} +func newRunCommand() *cobra.Command { + var ( + flagForge string + flagBranch string + flagOwner string + flagRepo string + flagExtraFiles string + flagUpdaters []string -var ( - flagForge string - flagBranch string - flagOwner string - flagRepo string - flagExtraFiles string -) - -func init() { - rootCmd.AddCommand(runCmd) - - runCmd.PersistentFlags().StringVar(&flagForge, "forge", "", "") - runCmd.PersistentFlags().StringVar(&flagBranch, "branch", "main", "") - runCmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "") - runCmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "") - runCmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "") -} - -func run(cmd *cobra.Command, _ []string) error { - ctx := cmd.Context() - - var err error - - logger.DebugContext(ctx, "run called", - "forge", flagForge, - "branch", flagBranch, - "owner", flagOwner, - "repo", flagRepo, + flagAPIURL string + flagAPIToken string + flagUsername string ) - var f forge.Forge + var cmd = &cobra.Command{ + Use: "run", + RunE: func(cmd *cobra.Command, _ []string) error { + ctx := cmd.Context() + logger := log.GetLogger(cmd.ErrOrStderr()) - forgeOptions := forge.Options{ - Repository: flagRepo, - BaseBranch: flagBranch, + var err error + + logger.DebugContext(ctx, "run called", + "forge", flagForge, + "branch", flagBranch, + "owner", flagOwner, + "repo", flagRepo, + ) + + var f forge.Forge + + forgeOptions := forge.Options{ + Repository: flagRepo, + BaseBranch: flagBranch, + } + + switch flagForge { + case "gitlab": + logger.DebugContext(ctx, "using forge GitLab") + f, err = gitlab.New(logger, &gitlab.Options{ + Options: forgeOptions, + Path: fmt.Sprintf("%s/%s", flagOwner, flagRepo), + }) + if err != nil { + slog.ErrorContext(ctx, "failed to create client", "err", err) + return fmt.Errorf("failed to create gitlab client: %w", err) + } + case "github": + logger.DebugContext(ctx, "using forge GitHub") + f = github.New(logger, &github.Options{ + Options: forgeOptions, + Owner: flagOwner, + Repo: flagRepo, + }) + case "forgejo": + logger.DebugContext(ctx, "using forge Forgejo") + f, err = forgejo.New(logger, &forgejo.Options{ + Options: forgeOptions, + Owner: flagOwner, + Repo: flagRepo, + + APIURL: flagAPIURL, + APIToken: flagAPIToken, + Username: flagUsername, + }) + if err != nil { + logger.ErrorContext(ctx, "failed to create client", "err", err) + return fmt.Errorf("failed to create forgejo client: %w", err) + } + default: + return fmt.Errorf("unknown --forge: %s", flagForge) + } + + extraFiles := parseExtraFiles(flagExtraFiles) + + updaterNames := parseUpdaters(flagUpdaters) + updaters := []updater.Updater{} + for _, name := range updaterNames { + switch name { + case "generic": + updaters = append(updaters, updater.Generic(extraFiles)) + case "changelog": + updaters = append(updaters, updater.Changelog()) + case "packagejson": + updaters = append(updaters, updater.PackageJson()) + default: + return fmt.Errorf("unknown updater: %s", name) + } + } + + releaserPleaser := rp.New( + f, + logger, + flagBranch, + conventionalcommits.NewParser(logger), + versioning.SemVer, + extraFiles, + updaters, + ) + + return releaserPleaser.Run(ctx) + }, } - switch flagForge { - case "gitlab": - logger.DebugContext(ctx, "using forge GitLab") - f, err = gitlab.New(logger, &gitlab.Options{ - Options: forgeOptions, - Path: fmt.Sprintf("%s/%s", flagOwner, flagRepo), - }) - if err != nil { - logger.ErrorContext(ctx, "failed to create client", "err", err) - return fmt.Errorf("failed to create gitlab client: %w", err) - } - case "github": - logger.DebugContext(ctx, "using forge GitHub") - f = github.New(logger, &github.Options{ - Options: forgeOptions, - Owner: flagOwner, - Repo: flagRepo, - }) - default: - return fmt.Errorf("unknown --forge: %s", flagForge) - } + cmd.PersistentFlags().StringVar(&flagForge, "forge", "", "") + cmd.PersistentFlags().StringVar(&flagBranch, "branch", "main", "") + cmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "") + cmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "") + cmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "") + cmd.PersistentFlags().StringSliceVar(&flagUpdaters, "updaters", []string{}, "") - extraFiles := parseExtraFiles(flagExtraFiles) + cmd.PersistentFlags().StringVar(&flagAPIURL, "api-url", "", "") + cmd.PersistentFlags().StringVar(&flagAPIToken, "api-token", "", "") + cmd.PersistentFlags().StringVar(&flagUsername, "username", "", "") - releaserPleaser := rp.New( - f, - logger, - flagBranch, - conventionalcommits.NewParser(logger), - versioning.SemVerNextVersion, - extraFiles, - []updater.NewUpdater{updater.Generic}, - ) - - return releaserPleaser.Run(ctx) + return cmd } func parseExtraFiles(input string) []string { @@ -113,3 +156,26 @@ func parseExtraFiles(input string) []string { return extraFiles } + +func parseUpdaters(input []string) []string { + names := []string{"changelog", "generic"} + + for _, u := range input { + if u == "" { + continue + } + + if strings.HasPrefix(u, "-") { + name := u[1:] + names = slices.DeleteFunc(names, func(existingName string) bool { return existingName == name }) + } else { + names = append(names, u) + } + } + + // Make sure we only have unique updaters + slices.Sort(names) + names = slices.Compact(names) + + return names +} diff --git a/cmd/rp/cmd/run_test.go b/cmd/rp/cmd/run_test.go index d4cea7a..4c6ceff 100644 --- a/cmd/rp/cmd/run_test.go +++ b/cmd/rp/cmd/run_test.go @@ -57,3 +57,48 @@ dir/Chart.yaml"`, }) } } + +func Test_parseUpdaters(t *testing.T) { + tests := []struct { + name string + input []string + want []string + }{ + { + name: "empty", + input: []string{}, + want: []string{"changelog", "generic"}, + }, + { + name: "remove defaults", + input: []string{"-changelog", "-generic"}, + want: []string{}, + }, + { + name: "remove unknown is ignored", + input: []string{"-fooo"}, + want: []string{"changelog", "generic"}, + }, + { + name: "add new entry", + input: []string{"bar"}, + want: []string{"bar", "changelog", "generic"}, + }, + { + name: "duplicates are removed", + input: []string{"bar", "bar", "changelog"}, + want: []string{"bar", "changelog", "generic"}, + }, + { + name: "remove empty entries", + input: []string{""}, + want: []string{"changelog", "generic"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := parseUpdaters(tt.input) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/cmd/rp/main.go b/cmd/rp/main.go index 462629b..734709c 100644 --- a/cmd/rp/main.go +++ b/cmd/rp/main.go @@ -2,6 +2,7 @@ package main import ( "github.com/apricote/releaser-pleaser/cmd/rp/cmd" + _ "github.com/apricote/releaser-pleaser/internal/log" ) func main() { diff --git a/codecov.yaml b/codecov.yaml new file mode 100644 index 0000000..b52d0c4 --- /dev/null +++ b/codecov.yaml @@ -0,0 +1,2 @@ +ignore: + - "test" diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index 1125209..0a22df1 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -5,24 +5,27 @@ # Tutorials - [Getting started on GitHub](tutorials/github.md) -- [Getting started on GitLab]() +- [Getting started on GitLab](tutorials/gitlab.md) # Explanation - [Release Pull Request](explanation/release-pr.md) +- [Concurrency and Conflicts](explanation/concurrency-conflicts.md) # Guides - [Customizing Release Notes](guides/release-notes.md) - [Pre-releases](guides/pre-releases.md) - [Workflow Permissions on GitHub](guides/github-workflow-permissions.md) +- [Updating arbitrary files](guides/updating-arbitrary-files.md) # Reference - [Glossary](reference/glossary.md) - [Pull Request Options](reference/pr-options.md) - [GitHub Action](reference/github-action.md) -- [GitLab CI]() +- [GitLab CI/CD Component](reference/gitlab-cicd-component.md) +- [Updaters](reference/updaters.md) --- diff --git a/docs/explanation/concurrency-conflicts.md b/docs/explanation/concurrency-conflicts.md new file mode 100644 index 0000000..9d14eb7 --- /dev/null +++ b/docs/explanation/concurrency-conflicts.md @@ -0,0 +1,65 @@ +# Concurrency and Conflicts + +## Why + +`releaser-pleaser` works on the "shared global state" that is your project on GitHub/GitLab. Each execution reads from that state and makes changes to it. While `releaser-pleaser` is generally [idempotent](https://en.wikipedia.org/wiki/Idempotence), we still need to consider concurrent executions for two reasons: avoiding conflicts and saving resources. + +### Avoiding conflicts + +The [Release Pull Request](release-pr.md) is used by `releaser-pleaser` to show the current release. Users may update the PR description to add additional notes into the Changelog. + +When `releaser-pleaser` is running while the user modifies the Release Pull Request description, `releaser-pleaser` may overwrite the description afterward based on its outdated local copy of the pull request. + +### Saving resources + +While `releaser-pleaser` is idempotent, there is no benefit to running it multiple times in parallel. In the best case, `releaser-pleaser` could be stopped as soon as a new "change" that is relevant to it comes in and restarts based on that new state. + +## Measures taken + +### Concurrency limits in CI environments + +Our default configurations for [GitHub Actions](../tutorials/github.md) and [GitLab CI/CD](../tutorials/gitlab.md) try to limit concurrent `releaser-pleaser` jobs to a single one. + +#### GitHub Actions + +On GitHub Actions, we use a `concurrency.group` to restrict it to a single running job per repository. + +GitHub cancels the currently running job and any other pending ones when a new one is started. This makes sure that `releaser-pleaser` always works with the latest state. + +Users need to enable this in their workflow (included in our GitHub tutorial): + +```yaml +concurrency: + group: releaser-pleaser + cancel-in-progress: true +``` + +#### GitLab + +On GitLab CI/CD, we use a `resource_group: releaser-pleaser` in our GitLab CI/CD component to restrict it to a single running job per repository. This is part of the component YAML, so users do not need to set this manually. + +There is no easy way to cancel the running job, so we let it proceed and rely on the other measures to safely handle the data. Users can enable "auto-cancel redundant pipelines" if they want, but should consider the ramifications for the rest of their CI carefully before doing so. + +### Graceful shutdown + +When GitHub Actions and GitLab CI/CD cancel jobs, they first sent a signal to the running process (`SIGINT` on GitHub and `SIGTERM` on GitLab). We listen for these signals and initiate a shutdown of the process. This helps save resources by shutting down as fast as possible, but in a controlled manner. + +### Re-checking PR description for conflict + +When `releaser-pleaser` prepares the Release Pull Request, the first step is to check if there is an existing PR already opened. It then reads from this PR to learn if the user modified the release in some way ([Release Notes](../guides/release-notes.md#for-the-release), [Pre-releases](../guides/pre-releases.md)). Based on this, it prepares the commit and the next iteration of the Release Pull Request description. The last step is to update the Release Pull Request description. + +Depending on the time since the last release, a lot of API calls are made to learn about these changes; this can take between a few seconds and a few minutes. If the user makes any changes to the Release Pull Request in this time frame, they are not considered for the next iteration of the description. To make sure that we do not lose these changes, `releaser-pleaser` fetches the Release Pull Request description again right before updating it. In case it changed from the start of the process, the attempt is aborted, and the whole process is retried two times. + +This does not fully eliminate the potential for data loss, but reduces the time frame from multiple seconds (up to minutes) to a few hundred milliseconds. + +## Related Documentation + +- **Explanation** + - [Release Pull Request](release-pr.md) +- **Guide** + - [Pre-releases](../guides/pre-releases.md) + - [Customizing Release Notes](../guides/release-notes.md) +- **Tutorial** + - [Getting started on GitHub](../tutorials/github.md) + - [Getting started on GitLab](../tutorials/gitlab.md) + diff --git a/docs/guides/release-notes.md b/docs/guides/release-notes.md index d994294..599fb52 100644 --- a/docs/guides/release-notes.md +++ b/docs/guides/release-notes.md @@ -43,17 +43,17 @@ The release pull request description has text fields where maintainers can add t When you edit the description, make sure to put your desired content into the code blocks named `rp-prefix` and `rp-suffix`. Only the content of these blocks is considered. -> ```rp-prefix +> ~~~~rp-prefix > ### Prefix > > This will be shown as the Prefix. -> ``` +> ~~~~ > -> ```rp-suffix +> ~~~~rp-suffix > ### Suffix > > This will be shown as the Suffix. -> ``` +> ~~~~ To match the style of the auto-generated release notes, you should start any headings at level 3 (`### Title`). diff --git a/docs/guides/updating-arbitrary-files.md b/docs/guides/updating-arbitrary-files.md new file mode 100644 index 0000000..e7df9a5 --- /dev/null +++ b/docs/guides/updating-arbitrary-files.md @@ -0,0 +1,67 @@ +# Updating arbitrary files + +In some situations it makes sense to have the current version committed in files in the repository: + +- Documentation examples +- A source-code file that has the version for user agents and introspection +- Reference to a container image tag that is built from the repository + +`releaser-pleaser` can automatically update these references in the [Release PR](../explanation/release-pr.md). + +## Markers + +The line that needs to be updated must have the marker +`x-releaser-pleaser-version` somewhere after the version that should be updated. + +For example: + +```go +// version/version.go + +package version + +const Version = "v1.0.0" // x-releaser-pleaser-version +``` + +## Extra Files + +You need to tell `releaser-pleaser` which files it should update. This happens through the CI-specific configuration. + +### GitHub Action + +In the GitHub Action you can set the +`extra-files` input with a list of the files. They need to be formatted as a single multi-line string with one file path per line: + +```yaml +jobs: + releaser-pleaser: + steps: + - uses: apricote/releaser-pleaser@v0.4.0 + with: + extra-files: | + version.txt + version/version.go + docker-compose.yml +``` + +### GitLab CI/CD Component + +In the GitLab CI/CD Component you can set the +`extra-files` input with a list of files. They need to be formatted as a single multi-line string with one file path per line: + +```yaml +include: + - component: $CI_SERVER_FQDN/apricote/releaser-pleaser/run@v0.4.0 + inputs: + extra-files: | + version.txt + version/version.go + docker-compose.yml +``` + +## Related Documentation + +- **Reference** + - [GitHub Action](../reference/github-action.md#inputs) + - [GitLab CI/CD Component](../reference/gitlab-cicd-component.md#inputs) + - [Updaters](../reference/updaters.md#generic-updater) diff --git a/docs/reference/github-action.md b/docs/reference/github-action.md index eec9789..c5d595a 100644 --- a/docs/reference/github-action.md +++ b/docs/reference/github-action.md @@ -8,17 +8,20 @@ The action is available as `apricote/releaser-pleaser` on GitHub.com. The `apricote/releaser-pleaser` action is released together with `releaser-pleaser` and they share the version number. -The action does not support floating tags (e.g. `v1`) right now ([#31](https://github.com/apricote/releaser-pleaser/issues/31)). You have to use the full version or commit SHA instead: `apricote/releaser-pleaser@v0.2.0`. +The action does not support floating tags (e.g. +`v1`) right now ([#31](https://github.com/apricote/releaser-pleaser/issues/31)). You have to use the full version or commit SHA instead: +`apricote/releaser-pleaser@v0.2.0`. ## Inputs The following inputs are supported by the `apricote/releaser-pleaser` GitHub Action. -| Input | Description | Default | Example | -| ------------- | :----------------------------------------------------- | --------------: | -------------------------------------------------------------------: | -| `branch` | This branch is used as the target for releases. | `main` | `master` | -| `token` | GitHub token for creating and updating release PRs | `$GITHUB_TOKEN` | `${{secrets.RELEASER_PLEASER_TOKEN}}` | -| `extra-files` | List of files that are scanned for version references. | `""` |
version/version.go
deploy/deployment.yaml
| +| Input | Description | Default | Example | +|---------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------:|---------------------------------------------------------------------:| +| `branch` | This branch is used as the target for releases. | `main` | `master` | +| `token` | GitHub token for creating and updating release PRs | `$GITHUB_TOKEN` | `${{secrets.RELEASER_PLEASER_TOKEN}}` | +| `extra-files` | List of files that are scanned for version references by the generic updater. | `""` |
version/version.go
deploy/deployment.yaml
| +| `updaters` | List of updaters that are run. Default updaters can be removed by specifying them as -name. Multiple updaters should be concatenated with a comma. Default Updaters: changelog,generic | `""` | `-generic,packagejson` | ## Outputs diff --git a/docs/reference/gitlab-cicd-component.md b/docs/reference/gitlab-cicd-component.md new file mode 100644 index 0000000..3080fcf --- /dev/null +++ b/docs/reference/gitlab-cicd-component.md @@ -0,0 +1,28 @@ +# GitLab CI/CD Component + +## Reference + +The CI/CD component is available as `$CI_SERVER_FQDN/apricote/releaser-pleaser/run` on gitlab.com. + +It is being distributed through the CI/CD Catalog: [apricote/releaser-pleaser](https://gitlab.com/explore/catalog/apricote/releaser-pleaser). + +## Versions + +The `apricote/releaser-pleaser` action is released together with `releaser-pleaser` and they share the version number. + +The component does not support floating tags (e.g. +`v1`) right now ([#31](https://github.com/apricote/releaser-pleaser/issues/31)). You have to use the full version or commit SHA instead: +`apricote/releaser-pleaser@v0.4.0`. + +## Inputs + +The following inputs are supported by the component. + +| Input | Description | Default | Example | +|------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------:|---------------------------------------------------------------------:| +| `branch` | This branch is used as the target for releases. | `main` | `master` | +| `token` (**required**) | GitLab access token for creating and updating release PRs | | `$RELEASER_PLEASER_TOKEN` | +| `extra-files` | List of files that are scanned for version references by the generic updater. | `""` |
version/version.go
deploy/deployment.yaml
| +| `updaters` | List of updaters that are run. Default updaters can be removed by specifying them as -name. Multiple updaters should be concatenated with a comma. Default Updaters: changelog,generic | `""` | `-generic,packagejson` | +| `stage` | Stage the job runs in. Must exists. | `build` | `test` | +| `needs` | Other jobs the releaser-pleaser job depends on. | `[]` |
- validate-foo
- prepare-bar
| diff --git a/docs/reference/glossary.md b/docs/reference/glossary.md index 543f970..8966f55 100644 --- a/docs/reference/glossary.md +++ b/docs/reference/glossary.md @@ -2,17 +2,21 @@ ### Changelog -The Changelog is a file in the repository (`CHANGELOG.md`) that contains the [Release Notes](#release-notes) for every release of that repository. Usually, new releases are added at the top of the file. +The Changelog is a file in the repository ( +`CHANGELOG.md`) that contains the [Release Notes](#release-notes) for every release of that repository. Usually, new releases are added at the top of the file. ### Conventional Commits -[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) is a specification for commit messages. It is the only supported commit message schema in `releaser-pleaser`. Follow the link to learn more. +[Conventional Commits](https://www.conventionalcommits.org/en/v1.0.0/) is a specification for commit messages. It is the only supported commit message schema in +`releaser-pleaser`. Follow the link to learn more. ### Forge -A **forge** is a web-based collaborative software platform for both developing and sharing computer applications.[^wp-forge] +A **forge +** is a web-based collaborative software platform for both developing and sharing computer applications.[^wp-forge] -Right now only **GitHub** is supported. We plan to support **GitLab** in the future ([#4](https://github.com/apricote/releaser-pleaser/issues/4)). For other forges like Forgejo or Gitea, please open an issue and submit a pull request. +Right now only **GitHub** is supported. We plan to support **GitLab +** in the future ([#4](https://github.com/apricote/releaser-pleaser/issues/4)). For other forges like Forgejo or Gitea, please open an issue and submit a pull request. [^wp-forge]: Quote from [Wikipedia "Forge (software)"]() @@ -24,7 +28,8 @@ In `releaser-pleaser` Markdown is used for most texts. ### Pre-release -Pre-releases are a concept of [SemVer](#semantic-versioning-semver). They follow the normal versioning schema but use a suffix out of `-alpha.X`, `-beta.X` and `-rc.X`. +Pre-releases are a concept of [SemVer](#semantic-versioning-semver). They follow the normal versioning schema but use a suffix out of +`-alpha.X`, `-beta.X` and `-rc.X`. Pre-releases are not considered "stable" and are usually not recommended for most users. @@ -32,7 +37,9 @@ Learn more in the [Pre-releases](../guides/pre-releases.md) guide. ### Release Pull Request -A Release Pull Request is opened by `releaser-pleaser` whenever it finds releasable commits in your project. It proposes a new version number and the Changelog. Once it is merged, `releaser-pleaser` creates a matching release. +A Release Pull Request is opened by +`releaser-pleaser` whenever it finds releasable commits in your project. It proposes a new version number and the Changelog. Once it is merged, +`releaser-pleaser` creates a matching release. Learn more in the [Release Pull Request](../explanation/release-pr.md) explanation. @@ -44,4 +51,11 @@ Learn more in the [Release Notes customization](../guides/release-notes.md) guid ### Semantic Versioning (SemVer) -[Semantic Versioning](https://semver.org/) is a specification for version numbers. It is the only supported versioning schema in `releaser-pleaser`. Follow the link to learn more. +[Semantic Versioning](https://semver.org/) is a specification for version numbers. It is the only supported versioning schema in +`releaser-pleaser`. Follow the link to learn more. + +### Updater + +Updaters can update or create files that will be included in [Release Pull Request](#release-pull-request). Examples of Updaters are +`changelog` for `CHANGELOG.md`, `generic` that can update arbitrary files and +`packagejson` that knows how to update Node.JS `package.json` files. \ No newline at end of file diff --git a/docs/reference/pr-options.md b/docs/reference/pr-options.md index 266bd08..1b096d4 100644 --- a/docs/reference/pr-options.md +++ b/docs/reference/pr-options.md @@ -30,17 +30,17 @@ Any text in code blocks with these languages is being added to the start or end **Examples**: - ```rp-prefix + ~~~~rp-prefix #### Awesome new feature! This text is at the start of the release notes. - ``` + ~~~~ - ```rp-suffix + ~~~~rp-suffix #### Version Compatibility And this at the end. - ``` + ~~~~ ### Status diff --git a/docs/reference/updaters.md b/docs/reference/updaters.md new file mode 100644 index 0000000..6bafff4 --- /dev/null +++ b/docs/reference/updaters.md @@ -0,0 +1,33 @@ +# Updaters + +There are different updater for different purposes available. + +They each have a name and may be enabled by default. You can configure which updaters are used through the +`updaters` input on GitHub Actions and GitLab CI/CD. This is a comma-delimited list of updaters that should be enabled, for updaters that are enabled by default you can remove them by adding a minus before its name: + +``` +updaters: -generic,packagejson +``` + +## Changelog + +- **Name**: `changelog` +- **Default**: enabled + +This updater creates the `CHANGELOG.md` file and adds new release notes to it. + +## Generic Updater + +- **Name**: `generic` +- **Default**: enabled + +This updater can update any file and only needs a marker on the line. It is enabled by default. + +Learn more about this updater in ["Updating arbitrary files"](../guides/updating-arbitrary-files.md). + +## Node.js `package.json` Updater + +- **Name**: `packagejson` +- **Default**: disabled + +This updater can update the `version` field in Node.js `package.json` files. The updater is disabled by default. diff --git a/docs/tutorials/github.md b/docs/tutorials/github.md index 83f797c..02812b2 100644 --- a/docs/tutorials/github.md +++ b/docs/tutorials/github.md @@ -1,6 +1,6 @@ -# GitHub +# Getting started on GitHub -In this tutorial we show how to install `releaser-pleaser` in your GitHub project. +In this tutorial you will learn how to set up `releaser-pleaser` in your GitHub project with GitHub Actions. ## 1. Repository Settings @@ -44,6 +44,10 @@ on: - labeled - unlabeled +concurrency: + group: releaser-pleaser + cancel-in-progress: true + jobs: releaser-pleaser: runs-on: ubuntu-latest @@ -52,7 +56,7 @@ jobs: pull-requests: write steps: - name: releaser-pleaser - uses: apricote/releaser-pleaser@v0.2.0 + uses: apricote/releaser-pleaser@v0.4.0 ``` ## 3. Release Pull Request diff --git a/docs/tutorials/gitlab-access-token.png b/docs/tutorials/gitlab-access-token.png new file mode 100644 index 0000000..15d7619 --- /dev/null +++ b/docs/tutorials/gitlab-access-token.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:31b485bbe031443c4bfa0d39514dc7e5d524925aa877848def93ee40f69a1897 +size 146496 diff --git a/docs/tutorials/gitlab-settings-merge-method.png b/docs/tutorials/gitlab-settings-merge-method.png new file mode 100644 index 0000000..0ea01c7 --- /dev/null +++ b/docs/tutorials/gitlab-settings-merge-method.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b853625854582a66ab2438f11e6001a88bcb276225abed536ba68617bde324db +size 57583 diff --git a/docs/tutorials/gitlab-settings-squash.png b/docs/tutorials/gitlab-settings-squash.png new file mode 100644 index 0000000..e0c87a4 --- /dev/null +++ b/docs/tutorials/gitlab-settings-squash.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0ce9b9826229851e961ef55d91cb9ba91ca9ca4d955a932d9ff6b10d04788c29 +size 41048 diff --git a/docs/tutorials/gitlab.md b/docs/tutorials/gitlab.md new file mode 100644 index 0000000..2e5ed72 --- /dev/null +++ b/docs/tutorials/gitlab.md @@ -0,0 +1,94 @@ +# Getting started on GitLab + +In this tutorial you will learn how to set up `releaser-pleaser` in your GitLab project with GitLab CI. + +> In `releaser-pleaser` documentation we mostly use "Pull Request" (GitHub wording) instead of "Merge Request" (GitLab wording). The GitLab-specific pages are an exception and use "Merge Request". + +## 1. Project Settings + +### 1.1. Merge Requests + +`releaser-pleaser` requires _Fast-forward merges_ and _squashing_. With other merge options it can not reliably find the right merge request for every commit on `main`. + +Open your project settings to page _Merge Requests_: + +> `https://gitlab.com/YOUR-PATH/YOUR-PROJECT/-/settings/merge_requests` + +In the "Merge method" section select "Fast-forward merge": + +![Screenshot of the required merge method settings](./gitlab-settings-merge-method.png) + +In the "Squash commits when merging" section select "Require": + +![Screenshot of the required squash settings](./gitlab-settings-squash.png) + +## 2. API Access Token + +`releaser-pleaser` uses the GitLab API to create the [release merge request](../explanation/release-pr.md) and subsequent releases for you. The default `GITLAB_TOKEN` available in CI jobs does not have enough permissions for this, so we need to create an Access Token and make it available in a CI variable. + +## 2.1. Create Project Access Token + +Open your project settings to page _Access tokens_: + +> `https://gitlab.com/YOUR-PATH/YOUR-PROJECT/-/settings/access_tokens` + +Create a token with these settings: + +- **Name**: `releaser-pleaser` +- **Role**: `Maintainer` +- **Scopes**: `api`, `read_repository`, `write_repository` + +Copy the created token for the next step. + +![Screenshot of the access token settings](./gitlab-access-token.png) + +## 2.2. Save token in CI variable + +Open your project settings to page _CI/CD_: + +> `https://gitlab.com/YOUR-PATH/YOUR-PROJECT/-/settings/ci_cd` + +In the section "Variables" click on the "Add variable" button to open the form for a new variable. Use these settings to create the new variable: + +- **Type**: Variable +- **Visibility**: Masked +- **Flags**: Uncheck "Protect variable" if your `main` branch is not protected +- **Key**: `RELEASER_PLEASER_TOKEN` +- **Value**: The project access token from the previous step + +## 3. GitLab CI/CD + +`releaser-pleaser` is published as a [GitLab CI/CD Component](https://docs.gitlab.com/ee/ci/components/): https://gitlab.com/explore/catalog/apricote/releaser-pleaser + +Create or open your `.gitlab-ci.yml` and add the following include to your configuration: + +```yaml +stages: [build] + +include: + - component: $CI_SERVER_FQDN/apricote/releaser-pleaser/run@v0.4.0-beta.1 + inputs: + token: $RELEASER_PLEASER_TOKEN +``` + +> You can set the `stage` input if you want to run `releaser-pleaser` during a different stage. + +
+ +If you want to use `releaser-pleaser` on a self-managed GitLab instance, you need to mirror the GitLab.com component to your instance. See the official [GitLab documentation for details](https://docs.gitlab.com/ee/ci/components/#use-a-gitlabcom-component-in-a-self-managed-instance). + +
+ +## 4. Release Merge Request + +Once the `releaser-pleaser` job runs for the first time, you can check the logs to see what it did. +If you have releasable commits since the last tag, `releaser-pleaser` opens a release merge request for the proposed release. + +Once you merge this merge request, `releaser-pleaser` automatically creates a Git tag and GitLab Release with the proposed version and changelog. + +## Related Documentation + +- **Explanation** + - [Release Pull Request](../explanation/release-pr.md) +- **Reference** + - [GitLab CI/CD Component](../reference/gitlab-cicd-component.md) diff --git a/go.mod b/go.mod index 0175391..5653239 100644 --- a/go.mod +++ b/go.mod @@ -1,51 +1,59 @@ module github.com/apricote/releaser-pleaser -go 1.23.0 +go 1.24.0 + +toolchain go1.25.5 require ( + codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0 github.com/blang/semver/v4 v4.0.0 - github.com/go-git/go-git/v5 v5.12.0 - github.com/google/go-github/v63 v63.0.0 + github.com/go-git/go-billy/v5 v5.7.0 + github.com/go-git/go-git/v5 v5.16.4 + github.com/google/go-github/v74 v74.0.0 github.com/leodido/go-conventionalcommits v0.12.0 - github.com/spf13/cobra v1.8.1 - github.com/stretchr/testify v1.9.0 - github.com/teekennedy/goldmark-markdown v0.3.0 - github.com/xanzy/go-gitlab v0.109.0 - github.com/yuin/goldmark v1.7.4 + github.com/lmittmann/tint v1.1.2 + github.com/spf13/cobra v1.10.2 + github.com/stretchr/testify v1.11.1 + github.com/teekennedy/goldmark-markdown v0.5.1 + github.com/yuin/goldmark v1.7.16 + gitlab.com/gitlab-org/api/client-go v0.161.1 ) require ( dario.cat/mergo v1.0.1 // indirect + github.com/42wim/httpsig v1.2.3 // indirect github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProtonMail/go-crypto v1.0.0 // indirect - github.com/cloudflare/circl v1.4.0 // indirect - github.com/cyphar/filepath-securejoin v0.3.1 // indirect + github.com/ProtonMail/go-crypto v1.1.6 // indirect + github.com/cloudflare/circl v1.6.1 // indirect + github.com/cyphar/filepath-securejoin v0.4.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect + github.com/davidmz/go-pageant v1.0.2 // indirect github.com/emirpasic/gods v1.18.1 // indirect + github.com/go-fed/httpsig v1.1.0 // indirect github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect - github.com/go-git/go-billy/v5 v5.5.0 // indirect - github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect - github.com/golang/protobuf v1.5.3 // indirect + github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect github.com/google/go-querystring v1.1.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect - github.com/hashicorp/go-retryablehttp v0.7.7 // indirect + github.com/hashicorp/go-retryablehttp v0.7.8 // indirect + github.com/hashicorp/go-version v1.7.0 // indirect github.com/inconshreveable/mousetrap v1.1.0 // indirect github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect github.com/kevinburke/ssh_config v1.2.0 // indirect - github.com/pjbgf/sha1cd v0.3.0 // indirect + github.com/pjbgf/sha1cd v0.3.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - github.com/skeema/knownhosts v1.3.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect + github.com/skeema/knownhosts v1.3.1 // indirect + github.com/spf13/pflag v1.0.9 // indirect github.com/xanzy/ssh-agent v0.3.3 // indirect - golang.org/x/crypto v0.26.0 // indirect - golang.org/x/net v0.28.0 // indirect - golang.org/x/oauth2 v0.6.0 // indirect - golang.org/x/sys v0.24.0 // indirect - golang.org/x/time v0.3.0 // indirect - google.golang.org/appengine v1.6.7 // indirect - google.golang.org/protobuf v1.29.1 // indirect + golang.org/x/crypto v0.45.0 // indirect + golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect + golang.org/x/net v0.47.0 // indirect + golang.org/x/oauth2 v0.30.0 // indirect + golang.org/x/sys v0.38.0 // indirect + golang.org/x/time v0.12.0 // indirect gopkg.in/warnings.v0 v0.1.2 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) + +replace codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 => codeberg.org/apricote/forgejo-sdk/forgejo/v2 v2.1.2-0.20250615152743-47d3f0434561 diff --git a/go.sum b/go.sum index d2a734e..870cb83 100644 --- a/go.sum +++ b/go.sum @@ -1,62 +1,65 @@ +codeberg.org/apricote/forgejo-sdk/forgejo/v2 v2.1.2-0.20250615152743-47d3f0434561 h1:ZFGmrGQ7cd2mbSLrfjrj3COwPKFfKM6sDO/IsrGDW7w= +codeberg.org/apricote/forgejo-sdk/forgejo/v2 v2.1.2-0.20250615152743-47d3f0434561/go.mod h1:2i9GsyawlJtVMO5pTS/Om5uo2O3JN/eCjGWy5v15NGg= dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s= dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk= +github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs= +github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM= github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78= -github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0= +github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw= +github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8= github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio= github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs= github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM= github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ= -github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0= -github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA= -github.com/cloudflare/circl v1.4.0 h1:BV7h5MgrktNzytKmWjpOtdYrf0lkkbF8YMlBGPhJQrY= -github.com/cloudflare/circl v1.4.0/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU= -github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= -github.com/cyphar/filepath-securejoin v0.3.1 h1:1V7cHiaW+C+39wEfpH6XlLBQo3j/PciWFrgfCLS8XrE= -github.com/cyphar/filepath-securejoin v0.3.1/go.mod h1:F7i41x/9cBF7lzCrVsYs9fuzwRZm4NQsGTBdpp6mETc= +github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0= +github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs= +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s= +github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU= -github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM= +github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0= +github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE= +github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o= +github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE= github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc= github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= -github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE= -github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8= +github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c= +github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU= +github.com/go-fed/httpsig v1.1.0 h1:9M+hb0jkEICD8/cAiNqEB66R87tTINszBRTjwjQzWcI= +github.com/go-fed/httpsig v1.1.0/go.mod h1:RCMrTZvN1bJYtofsG4rd5NaO5obxQ5xBkdiS7xsT7bM= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI= github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic= -github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU= -github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow= +github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM= +github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4= github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII= -github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys= -github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE= -github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc= -github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= -github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= -github.com/golang/protobuf v1.5.3 h1:KhyjKVUg7Usr/dYsdSqoFveMYd5ko72D+zANwlG1mmg= -github.com/golang/protobuf v1.5.3/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y= +github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ= +github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= -github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= -github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= -github.com/google/go-github/v63 v63.0.0 h1:13xwK/wk9alSokujB9lJkuzdmQuVn2QCPeck76wR3nE= -github.com/google/go-github/v63 v63.0.0/go.mod h1:IqbcrgUmIcEaioWrGYei/09o+ge5vhffGOcxrO0AfmA= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM= +github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M= -github.com/hashicorp/go-retryablehttp v0.7.7 h1:C8hUCYzor8PIfXHa4UrZkU4VvK8o9ISHxT2Q8+VepXU= -github.com/hashicorp/go-retryablehttp v0.7.7/go.mod h1:pkQpWZeYWskR+D1tR2O5OcBFOxfA7DoAO6xtkuQnHTk= +github.com/hashicorp/go-retryablehttp v0.7.8 h1:ylXZWnqa7Lhqpk0L1P1LzDtGcCR0rPVUrx/c8Unxc48= +github.com/hashicorp/go-retryablehttp v0.7.8/go.mod h1:rjiScheydd+CxvumBsIrFKlx3iS0jrZ7LvzFGFmuKbw= +github.com/hashicorp/go-version v1.7.0 h1:5tqGy27NaOTB8yJKUZELlFAS/LTKJkrmONwQKeRZfjY= +github.com/hashicorp/go-version v1.7.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= @@ -72,119 +75,90 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/leodido/go-conventionalcommits v0.12.0 h1:pG01rl8Ze+mxnSSVB2wPdGASXyyU25EGwLUc0bWrmKc= github.com/leodido/go-conventionalcommits v0.12.0/go.mod h1:DW+n8pQb5w/c7Vba7iGOMS3rkbPqykVlnrDykGjlsJM= +github.com/lmittmann/tint v1.1.2 h1:2CQzrL6rslrsyjqLDwD11bZ5OpLBPU+g3G/r5LSfS8w= +github.com/lmittmann/tint v1.1.2/go.mod h1:HIS3gSy7qNwGCj+5oRjAutErFBl4BzdQP6cJZ0NfMwE= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= -github.com/onsi/gomega v1.27.10 h1:naR28SdDFlqrG6kScpT8VWpu1xWY5nJRCF3XaYyBjhI= -github.com/onsi/gomega v1.27.10/go.mod h1:RsS8tutOdbdgzbPtzzATp12yT7kM5I5aElG3evPbQ0M= -github.com/pjbgf/sha1cd v0.3.0 h1:4D5XXmUUBUl/xQ6IjCkEAbqXskkq/4O7LmGn0AqMDs4= -github.com/pjbgf/sha1cd v0.3.0/go.mod h1:nZ1rrWOcGJ5uZgEEVL1VUM9iRQiZvWdbZjkKyFzPPsI= +github.com/onsi/gomega v1.34.1 h1:EUMJIKUjM8sKjYbtxQI9A4z2o+rruxnzNvpknOXie6k= +github.com/onsi/gomega v1.34.1/go.mod h1:kU1QgUvBDLXBJq618Xvm2LUX6rSAfRaFRTcdOeDLwwY= +github.com/pjbgf/sha1cd v0.3.2 h1:a9wb0bp1oC2TGwStyn0Umc/IGKQnEgF0vVaZ8QF8eo4= +github.com/pjbgf/sha1cd v0.3.2/go.mod h1:zQWigSxVmsHEZow5qaLtPYxpcKMMQpa09ixqBxuCS6A= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rhysd/go-fakeio v1.0.0 h1:+TjiKCOs32dONY7DaoVz/VPOdvRkPfBkEyUDIpM8FQY= github.com/rhysd/go-fakeio v1.0.0/go.mod h1:joYxF906trVwp2JLrE4jlN7A0z6wrz8O6o1UjarbFzE= -github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= -github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= +github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= +github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8= github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY= -github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M= -github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM= -github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y= -github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= -github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8= +github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= -github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= -github.com/teekennedy/goldmark-markdown v0.3.0 h1:ik9/biVGCwGWFg8dQ3KVm2pQ/wiiG0whYiUcz9xH0W8= -github.com/teekennedy/goldmark-markdown v0.3.0/go.mod h1:kMhDz8La77A9UHvJGsxejd0QUflN9sS+QXCqnhmxmNo= -github.com/xanzy/go-gitlab v0.109.0 h1:RcRme5w8VpLXTSTTMZdVoQWY37qTJWg+gwdQl4aAttE= -github.com/xanzy/go-gitlab v0.109.0/go.mod h1:wKNKh3GkYDMOsGmnfuX+ITCmDuSDWFO0G+C4AygL9RY= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/teekennedy/goldmark-markdown v0.5.1 h1:2lIlJ3AcIwaD1wFl4dflJSJFMhRTKEsEj+asVsu6M/0= +github.com/teekennedy/goldmark-markdown v0.5.1/go.mod h1:so260mNSPELuRyynZY18719dRYlD+OSnAovqsyrOMOM= github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM= github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw= -github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= -github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg= -github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E= +github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE= +github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg= +gitlab.com/gitlab-org/api/client-go v0.161.1 h1:XX0EtVGL6cGEdNy9xnJ96CSciIzjCwAVsayItHY1YyU= +gitlab.com/gitlab-org/api/client-go v0.161.1/go.mod h1:YqKcnxyV9OPAL5U99mpwBVEgBPz1PK/3qwqq/3h6bao= +go.abhg.dev/goldmark/toc v0.11.0 h1:IRixVy3/yVPKvFBc37EeBPi8XLTXrtH6BYaonSjkF8o= +go.abhg.dev/goldmark/toc v0.11.0/go.mod h1:XMFIoI1Sm6dwF9vKzVDOYE/g1o5BmKXghLG8q/wJNww= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= -golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8= golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= -golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4= -golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU= -golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw= -golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54= -golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= -golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= -golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= -golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q= +golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8= +golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= -golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY= -golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= -golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= -golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE= -golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg= -golang.org/x/oauth2 v0.6.0 h1:Lh8GPgSKBfWSwFvtuWOfeI3aAAnbXTSutYxJiOJFgIw= -golang.org/x/oauth2 v0.6.0/go.mod h1:ycmewcwgD4Rpr3eZJLSB4Kyyljb3qDh40vJ8STE5HKw= -golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= -golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY= +golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU= +golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= +golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg= -golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc= +golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= -golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= -golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc= -golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= -golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= -golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU= -golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk= +golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= -golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= -golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= -golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= -golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc= -golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY= -golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= -golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= +golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM= +golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM= +golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE= +golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= -golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= -golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= -golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= -golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= -google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= -google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= -google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= -google.golang.org/protobuf v1.29.1 h1:7QBf+IK2gx70Ap/hDsOmam3GE0v9HicjfEdAxE62UoM= -google.golang.org/protobuf v1.29.1/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= diff --git a/internal/changelog/changelog.go b/internal/changelog/changelog.go index 6004829..dda751a 100644 --- a/internal/changelog/changelog.go +++ b/internal/changelog/changelog.go @@ -3,9 +3,9 @@ package changelog import ( "bytes" _ "embed" - "html/template" "log" "log/slog" + "text/template" "github.com/apricote/releaser-pleaser/internal/commitparser" "github.com/apricote/releaser-pleaser/internal/markdown" @@ -26,27 +26,37 @@ func init() { } } -func NewChangelogEntry(logger *slog.Logger, commits []commitparser.AnalyzedCommit, version, link, prefix, suffix string) (string, error) { - features := make([]commitparser.AnalyzedCommit, 0) - fixes := make([]commitparser.AnalyzedCommit, 0) +func DefaultTemplate() *template.Template { + return changelogTemplate +} - for _, commit := range commits { - switch commit.Type { - case "feat": - features = append(features, commit) - case "fix": - fixes = append(fixes, commit) - } +type Data struct { + Commits map[string][]commitparser.AnalyzedCommit + Version string + VersionLink string + Prefix string + Suffix string +} + +func New(commits map[string][]commitparser.AnalyzedCommit, version, versionLink, prefix, suffix string) Data { + return Data{ + Commits: commits, + Version: version, + VersionLink: versionLink, + Prefix: prefix, + Suffix: suffix, } +} +type Formatting struct { + HideVersionTitle bool +} + +func Entry(logger *slog.Logger, tpl *template.Template, data Data, formatting Formatting) (string, error) { var changelog bytes.Buffer - err := changelogTemplate.Execute(&changelog, map[string]any{ - "Features": features, - "Fixes": fixes, - "Version": version, - "VersionLink": link, - "Prefix": prefix, - "Suffix": suffix, + err := tpl.Execute(&changelog, map[string]any{ + "Data": data, + "Formatting": formatting, }) if err != nil { return "", err diff --git a/internal/changelog/changelog.md.tpl b/internal/changelog/changelog.md.tpl index 1f7dd42..0a73dd5 100644 --- a/internal/changelog/changelog.md.tpl +++ b/internal/changelog/changelog.md.tpl @@ -1,22 +1,24 @@ -## [{{.Version}}]({{.VersionLink}}) -{{- if .Prefix }} -{{ .Prefix }} +{{define "entry" -}} +- {{ if .BreakingChange}}**BREAKING**: {{end}}{{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}} +{{ end }} + +{{- if not .Formatting.HideVersionTitle }} +## [{{.Data.Version}}]({{.Data.VersionLink}}) {{ end -}} -{{- if (gt (len .Features) 0) }} +{{- if .Data.Prefix }} +{{ .Data.Prefix }} +{{ end -}} +{{- with .Data.Commits.feat }} ### Features -{{ range .Features -}} -- {{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}} -{{ end -}} +{{ range . -}}{{template "entry" .}}{{end}} {{- end -}} -{{- if (gt (len .Fixes) 0) }} +{{- with .Data.Commits.fix }} ### Bug Fixes -{{ range .Fixes -}} -- {{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}} -{{ end -}} +{{ range . -}}{{template "entry" .}}{{end}} {{- end -}} -{{- if .Suffix }} -{{ .Suffix }} +{{- if .Data.Suffix }} +{{ .Data.Suffix }} {{ end }} diff --git a/internal/changelog/changelog_test.go b/internal/changelog/changelog_test.go index e6582c7..c2bbf71 100644 --- a/internal/changelog/changelog_test.go +++ b/internal/changelog/changelog_test.go @@ -8,6 +8,7 @@ import ( "github.com/apricote/releaser-pleaser/internal/commitparser" "github.com/apricote/releaser-pleaser/internal/git" + "github.com/apricote/releaser-pleaser/internal/testdata" ) func ptr[T any](input T) *T { @@ -54,6 +55,23 @@ func Test_NewChangelogEntry(t *testing.T) { want: "## [1.0.0](https://example.com/1.0.0)\n\n### Features\n\n- Foobar!\n", wantErr: assert.NoError, }, + { + name: "single breaking change", + args: args{ + analyzedCommits: []commitparser.AnalyzedCommit{ + { + Commit: git.Commit{}, + Type: "feat", + Description: "Foobar!", + BreakingChange: true, + }, + }, + version: "1.0.0", + link: "https://example.com/1.0.0", + }, + want: "## [1.0.0](https://example.com/1.0.0)\n\n### Features\n\n- **BREAKING**: Foobar!\n", + wantErr: assert.NoError, + }, { name: "single fix", args: args{ @@ -126,16 +144,9 @@ func Test_NewChangelogEntry(t *testing.T) { }, version: "1.0.0", link: "https://example.com/1.0.0", - prefix: "### Breaking Changes", + prefix: testdata.MustReadFileString(t, "prefix.txt"), }, - want: `## [1.0.0](https://example.com/1.0.0) - -### Breaking Changes - -### Bug Fixes - -- Foobar! -`, + want: testdata.MustReadFileString(t, "changelog-entry-prefix.txt"), wantErr: assert.NoError, }, { @@ -150,25 +161,17 @@ func Test_NewChangelogEntry(t *testing.T) { }, version: "1.0.0", link: "https://example.com/1.0.0", - suffix: "### Compatibility\n\nThis version is compatible with flux-compensator v2.2 - v2.9.", + suffix: testdata.MustReadFileString(t, "suffix.txt"), }, - want: `## [1.0.0](https://example.com/1.0.0) - -### Bug Fixes - -- Foobar! - -### Compatibility - -This version is compatible with flux-compensator v2.2 - v2.9. -`, + want: testdata.MustReadFileString(t, "changelog-entry-suffix.txt"), wantErr: assert.NoError, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := NewChangelogEntry(slog.Default(), tt.args.analyzedCommits, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix) + data := New(commitparser.ByType(tt.args.analyzedCommits), tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix) + got, err := Entry(slog.Default(), DefaultTemplate(), data, Formatting{}) if !tt.wantErr(t, err) { return } diff --git a/internal/commitparser/commitparser.go b/internal/commitparser/commitparser.go index 484d733..023c6b4 100644 --- a/internal/commitparser/commitparser.go +++ b/internal/commitparser/commitparser.go @@ -15,3 +15,18 @@ type AnalyzedCommit struct { Scope *string BreakingChange bool } + +// ByType groups the Commits by the type field. Used by the Changelog. +func ByType(in []AnalyzedCommit) map[string][]AnalyzedCommit { + out := map[string][]AnalyzedCommit{} + + for _, commit := range in { + if out[commit.Type] == nil { + out[commit.Type] = make([]AnalyzedCommit, 0, 1) + } + + out[commit.Type] = append(out[commit.Type], commit) + } + + return out +} diff --git a/internal/commitparser/conventionalcommits/conventionalcommits.go b/internal/commitparser/conventionalcommits/conventionalcommits.go index d11970c..e00af34 100644 --- a/internal/commitparser/conventionalcommits/conventionalcommits.go +++ b/internal/commitparser/conventionalcommits/conventionalcommits.go @@ -35,8 +35,12 @@ func (c *Parser) Analyze(commits []git.Commit) ([]commitparser.AnalyzedCommit, e for _, commit := range commits { msg, err := c.machine.Parse([]byte(strings.TrimSpace(commit.Message))) if err != nil { - c.logger.Warn("failed to parse message of commit, skipping", "commit.hash", commit.Hash, "err", err) - continue + if msg == nil { + c.logger.Warn("failed to parse message of commit, skipping", "commit.hash", commit.Hash, "err", err) + continue + } + + c.logger.Warn("failed to parse message of commit fully, trying to use as much as possible", "commit.hash", commit.Hash, "err", err) } conventionalCommit, ok := msg.(*conventionalcommits.ConventionalCommit) @@ -44,6 +48,12 @@ func (c *Parser) Analyze(commits []git.Commit) ([]commitparser.AnalyzedCommit, e return nil, fmt.Errorf("unable to get ConventionalCommit from parser result: %T", msg) } + if conventionalCommit.Type == "" { + // Parsing broke before getting the type, can not use the commit + c.logger.Warn("commit type was not parsed, skipping", "commit.hash", commit.Hash, "err", err) + continue + } + commitVersionBump := conventionalCommit.VersionBump(conventionalcommits.DefaultStrategy) if commitVersionBump > conventionalcommits.UnknownVersion { // We only care about releasable commits diff --git a/internal/commitparser/conventionalcommits/conventionalcommits_test.go b/internal/commitparser/conventionalcommits/conventionalcommits_test.go index ddfbaf4..d301829 100644 --- a/internal/commitparser/conventionalcommits/conventionalcommits_test.go +++ b/internal/commitparser/conventionalcommits/conventionalcommits_test.go @@ -125,6 +125,24 @@ func TestAnalyzeCommits(t *testing.T) { }, wantErr: assert.NoError, }, + + { + name: "success with body", + commits: []git.Commit{ + { + Message: "feat: some thing (hz/fl!144)\n\nFixes #15\n\nDepends on !143", + }, + }, + expectedCommits: []commitparser.AnalyzedCommit{ + { + Commit: git.Commit{Message: "feat: some thing (hz/fl!144)\n\nFixes #15\n\nDepends on !143"}, + Type: "feat", + Description: "some thing (hz/fl!144)", + BreakingChange: false, + }, + }, + wantErr: assert.NoError, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/forge/forge.go b/internal/forge/forge.go index 0bd119a..3e4c25e 100644 --- a/internal/forge/forge.go +++ b/internal/forge/forge.go @@ -17,6 +17,9 @@ type Forge interface { GitAuth() transport.AuthMethod + // CommitAuthor returns the git author used for the release commit. It should be the user whose token is used to talk to the API. + CommitAuthor(context.Context) (git.Author, error) + // LatestTags returns the last stable tag created on the main branch. If there is a more recent pre-release tag, // that is also returned. If no tag is found, it returns nil. LatestTags(context.Context) (git.Releases, error) diff --git a/internal/forge/forgejo/forgejo.go b/internal/forge/forgejo/forgejo.go new file mode 100644 index 0000000..9b0c548 --- /dev/null +++ b/internal/forge/forgejo/forgejo.go @@ -0,0 +1,529 @@ +package forgejo + +import ( + "context" + "fmt" + "log/slog" + "slices" + "strings" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + "github.com/blang/semver/v4" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" + + "github.com/apricote/releaser-pleaser/internal/forge" + "github.com/apricote/releaser-pleaser/internal/git" + "github.com/apricote/releaser-pleaser/internal/pointer" + "github.com/apricote/releaser-pleaser/internal/releasepr" +) + +const () + +var _ forge.Forge = &Forgejo{} + +type Forgejo struct { + options *Options + + client *forgejo.Client + log *slog.Logger +} + +func (f *Forgejo) RepoURL() string { + return fmt.Sprintf("%s/%s/%s", f.options.APIURL, f.options.Owner, f.options.Repo) +} + +func (f *Forgejo) CloneURL() string { + return fmt.Sprintf("%s.git", f.RepoURL()) +} + +func (f *Forgejo) ReleaseURL(version string) string { + return fmt.Sprintf("%s/releases/tag/%s", f.RepoURL(), version) +} + +func (f *Forgejo) PullRequestURL(id int) string { + return fmt.Sprintf("%s/pulls/%d", f.RepoURL(), id) +} + +func (f *Forgejo) GitAuth() transport.AuthMethod { + return &http.BasicAuth{ + Username: f.options.Username, + Password: f.options.APIToken, + } +} + +func (f *Forgejo) CommitAuthor(ctx context.Context) (git.Author, error) { + f.log.DebugContext(ctx, "getting commit author from current token user") + + user, _, err := f.client.GetMyUserInfo() + if err != nil { + return git.Author{}, err + } + + // TODO: Same for other forges? + name := user.FullName + if name == "" { + name = user.UserName + } + + return git.Author{ + Name: name, + Email: user.Email, + }, nil +} + +func (f *Forgejo) LatestTags(ctx context.Context) (git.Releases, error) { + f.log.DebugContext(ctx, "listing all tags in forgejo repository") + + tags, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Tag, *forgejo.Response, error) { + return f.client.ListRepoTags(f.options.Owner, f.options.Repo, + forgejo.ListRepoTagsOptions{ListOptions: listOptions}, + ) + }) + if err != nil { + return git.Releases{}, err + } + + var releases git.Releases + + for _, fTag := range tags { + tag := &git.Tag{ + Hash: fTag.Commit.SHA, + Name: fTag.Name, + } + + version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v")) + if err != nil { + f.log.WarnContext( + ctx, "unable to parse tag as semver, skipping", + "tag.name", tag.Name, + "tag.hash", tag.Hash, + "error", err, + ) + continue + } + + if releases.Latest == nil { + releases.Latest = tag + } + if len(version.Pre) == 0 { + // Stable version tag + // We return once we have found the latest stable tag, not needed to look at every single tag. + releases.Stable = tag + break + } + } + + return releases, nil +} + +func (f *Forgejo) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) { + var repositoryCommits []*forgejo.Commit + var err error + if tag != nil { + repositoryCommits, err = f.commitsSinceTag(ctx, tag) + } else { + repositoryCommits, err = f.commitsSinceInit(ctx) + } + + if err != nil { + return nil, err + } + + var commits = make([]git.Commit, 0, len(repositoryCommits)) + for _, fCommit := range repositoryCommits { + commit := git.Commit{ + Hash: fCommit.SHA, + Message: fCommit.RepoCommit.Message, + } + commit.PullRequest, err = f.prForCommit(ctx, commit) + if err != nil { + return nil, fmt.Errorf("failed to check for commit pull request: %w", err) + } + + commits = append(commits, commit) + } + + return commits, nil +} + +func (f *Forgejo) commitsSinceTag(_ context.Context, tag *git.Tag) ([]*forgejo.Commit, error) { + head := f.options.BaseBranch + log := f.log.With("base", tag.Hash, "head", head) + log.Debug("comparing commits") + + compare, _, err := f.client.CompareCommits( + f.options.Owner, f.options.Repo, + tag.Hash, head) + if err != nil { + return nil, err + } + + return compare.Commits, nil +} + +func (f *Forgejo) commitsSinceInit(_ context.Context) ([]*forgejo.Commit, error) { + head := f.options.BaseBranch + log := f.log.With("head", head) + log.Debug("listing all commits") + + repositoryCommits, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.Commit, *forgejo.Response, error) { + return f.client.ListRepoCommits( + f.options.Owner, f.options.Repo, + forgejo.ListCommitOptions{ + ListOptions: listOptions, + SHA: f.options.BaseBranch, + }) + }) + if err != nil { + return nil, err + } + + return repositoryCommits, nil +} + +func (f *Forgejo) prForCommit(_ context.Context, commit git.Commit) (*git.PullRequest, error) { + // We naively look up the associated PR for each commit through the "List pull requests associated with a commit" + // endpoint. This requires len(commits) requests. + // Using the "List pull requests" endpoint might be faster, as it allows us to fetch 100 arbitrary PRs per request, + // but worst case we need to look up all PRs made in the repository ever. + + f.log.Debug("fetching pull requests associated with commit", "commit.hash", commit.Hash) + + pullRequest, _, err := f.client.GetCommitPullRequest( + f.options.Owner, f.options.Repo, + commit.Hash, + ) + if err != nil { + if strings.HasPrefix(err.Error(), "pull request does not exist") { + return nil, nil + } + + return nil, err + } + + return forgejoPRToPullRequest(pullRequest), nil +} + +func (f *Forgejo) EnsureLabelsExist(_ context.Context, labels []releasepr.Label) error { + f.log.Debug("fetching labels on repo") + fLabels, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) { + return f.client.ListRepoLabels( + f.options.Owner, f.options.Repo, + forgejo.ListLabelsOptions{ListOptions: listOptions}) + }) + if err != nil { + return err + } + + for _, label := range labels { + if !slices.ContainsFunc(fLabels, func(fLabel *forgejo.Label) bool { + return fLabel.Name == label.Name + }) { + f.log.Info("creating label in repository", "label.name", label.Name) + _, _, err = f.client.CreateLabel( + f.options.Owner, f.options.Repo, + forgejo.CreateLabelOption{ + Name: label.Name, + Color: label.Color, + Description: label.Description, + }, + ) + if err != nil { + return err + } + } + } + + return nil +} + +func (f *Forgejo) PullRequestForBranch(_ context.Context, branch string) (*releasepr.ReleasePullRequest, error) { + prs, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) { + return f.client.ListRepoPullRequests( + f.options.Owner, f.options.Repo, + forgejo.ListPullRequestsOptions{ + ListOptions: listOptions, + State: forgejo.StateOpen, + }, + ) + }, + ) + if err != nil { + return nil, err + } + + for _, pr := range prs { + if pr.Base.Ref == f.options.BaseBranch && pr.Head.Ref == branch { + return forgejoPRToReleasePullRequest(pr), nil + } + } + + return nil, nil +} + +func (f *Forgejo) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error { + fPR, _, err := f.client.CreatePullRequest( + f.options.Owner, f.options.Repo, + forgejo.CreatePullRequestOption{ + Title: pr.Title, + Head: pr.Head, + Base: f.options.BaseBranch, + Body: pr.Description, + }, + ) + if err != nil { + return err + } + + // TODO: String ID? + pr.ID = int(fPR.ID) + + err = f.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) UpdatePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error { + _, _, err := f.client.EditPullRequest( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.EditPullRequestOption{ + Title: pr.Title, + Body: pr.Description, + }, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) SetPullRequestLabels(_ context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error { + allLabels, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) { + return f.client.ListRepoLabels(f.options.Owner, f.options.Repo, forgejo.ListLabelsOptions{ListOptions: listOptions}) + }, + ) + if err != nil { + return err + } + + findLabel := func(labelName string) *forgejo.Label { + for _, fLabel := range allLabels { + if fLabel.Name == labelName { + return fLabel + } + } + + return nil + } + + for _, label := range remove { + fLabel := findLabel(label.Name) + if fLabel == nil { + return fmt.Errorf("unable to remove label %q, not found in API", label.Name) + } + + _, err = f.client.DeleteIssueLabel( + f.options.Owner, f.options.Repo, + int64(pr.ID), fLabel.ID, + ) + if err != nil { + return err + } + } + + addIDs := make([]int64, 0, len(add)) + for _, label := range add { + fLabel := findLabel(label.Name) + if fLabel == nil { + return fmt.Errorf("unable to add label %q, not found in API", label.Name) + } + + addIDs = append(addIDs, fLabel.ID) + } + + _, _, err = f.client.AddIssueLabels( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.IssueLabelsOption{Labels: addIDs}, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) ClosePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error { + _, _, err := f.client.EditPullRequest( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.EditPullRequestOption{ + State: pointer.Pointer(forgejo.StateClosed), + }, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) PendingReleases(_ context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) { + fPRs, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) { + return f.client.ListRepoPullRequests( + f.options.Owner, f.options.Repo, + forgejo.ListPullRequestsOptions{ + // Filtering by Label ID is possible in the API, but not implemented in the Go SDK. + State: forgejo.StateClosed, + ListOptions: listOptions, + }) + }) + if err != nil { + // "The target couldn't be found." means that the repo does not have pull requests activated. + return nil, err + } + + prs := make([]*releasepr.ReleasePullRequest, 0, len(fPRs)) + + for _, pr := range fPRs { + pending := slices.ContainsFunc(pr.Labels, func(l *forgejo.Label) bool { + return l.Name == pendingLabel.Name + }) + if !pending { + continue + } + + // pr.Merged is always nil :( + if !pr.HasMerged { + // Closed and not merged + continue + } + + prs = append(prs, forgejoPRToReleasePullRequest(pr)) + } + + return prs, nil +} + +func (f *Forgejo) CreateRelease(_ context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error { + // latest can not be set through the API + + _, _, err := f.client.CreateRelease( + f.options.Owner, f.options.Repo, + forgejo.CreateReleaseOption{ + TagName: title, + Target: commit.Hash, + Title: title, + Note: changelog, + IsPrerelease: preRelease, + }, + ) + if err != nil { + return err + } + + return nil +} + +func all[T any](f func(listOptions forgejo.ListOptions) ([]T, *forgejo.Response, error)) ([]T, error) { + results := make([]T, 0) + page := 1 + + for { + pageResults, resp, err := f(forgejo.ListOptions{Page: page}) + if err != nil { + return nil, err + } + + results = append(results, pageResults...) + + if page == resp.LastPage || resp.LastPage == 0 { + return results, nil + } + page = resp.NextPage + } +} + +func forgejoPRToPullRequest(pr *forgejo.PullRequest) *git.PullRequest { + return &git.PullRequest{ + ID: int(pr.ID), + Title: pr.Title, + Description: pr.Body, + } +} + +func forgejoPRToReleasePullRequest(pr *forgejo.PullRequest) *releasepr.ReleasePullRequest { + labels := make([]releasepr.Label, 0, len(pr.Labels)) + for _, label := range pr.Labels { + labelName := label.Name + if i := slices.IndexFunc(releasepr.KnownLabels, func(label releasepr.Label) bool { + return label.Name == labelName + }); i >= 0 { + labels = append(labels, releasepr.KnownLabels[i]) + } + } + + var releaseCommit *git.Commit + if pr.MergedCommitID != nil { + releaseCommit = &git.Commit{Hash: *pr.MergedCommitID} + } + + return &releasepr.ReleasePullRequest{ + PullRequest: *forgejoPRToPullRequest(pr), + Labels: labels, + + Head: pr.Head.Ref, + ReleaseCommit: releaseCommit, + } +} + +func (g *Options) autodiscover() { + // TODO +} + +func (g *Options) ClientOptions() []forgejo.ClientOption { + options := []forgejo.ClientOption{} + + if g.APIToken != "" { + options = append(options, forgejo.SetToken(g.APIToken)) + } + + return options +} + +type Options struct { + forge.Options + + Owner string + Repo string + + APIURL string + Username string + APIToken string +} + +func New(log *slog.Logger, options *Options) (*Forgejo, error) { + options.autodiscover() + + client, err := forgejo.NewClient(options.APIURL, options.ClientOptions()...) + if err != nil { + return nil, err + } + + client.SetUserAgent("releaser-pleaser") + + f := &Forgejo{ + options: options, + + client: client, + log: log.With("forge", "forgejo"), + } + + return f, nil +} diff --git a/internal/forge/github/github.go b/internal/forge/github/github.go index 363c306..d01e529 100644 --- a/internal/forge/github/github.go +++ b/internal/forge/github/github.go @@ -12,7 +12,7 @@ import ( "github.com/blang/semver/v4" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/google/go-github/v63/github" + "github.com/google/go-github/v74/github" "github.com/apricote/releaser-pleaser/internal/forge" "github.com/apricote/releaser-pleaser/internal/git" @@ -29,6 +29,13 @@ const ( EnvRepository = "GITHUB_REPOSITORY" ) +var ( + gitHubActionsBotAuthor = git.Author{ + Name: "github-actions[bot]", + Email: "41898282+github-actions[bot]@users.noreply.github.com", + } +) + var _ forge.Forge = &GitHub{} type GitHub struct { @@ -61,6 +68,22 @@ func (g *GitHub) GitAuth() transport.AuthMethod { } } +func (g *GitHub) CommitAuthor(ctx context.Context) (git.Author, error) { + g.log.DebugContext(ctx, "getting commit author from current token user") + + user, _, err := g.client.Users.Get(ctx, "") + if err != nil { + g.log.WarnContext(ctx, "failed to get commit author from API, using default github-actions[bot] user", "error", err) + + return gitHubActionsBotAuthor, nil + } + + return git.Author{ + Name: user.GetName(), + Email: user.GetEmail(), + }, nil +} + func (g *GitHub) LatestTags(ctx context.Context) (git.Releases, error) { g.log.DebugContext(ctx, "listing all tags in github repository") @@ -273,6 +296,13 @@ func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*rele } func (g *GitHub) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error { + // If the Pull Request is created without the labels releaser-pleaser will create a new PR in the run. The user may merge both and have duplicate entries in the changelog. + // We try to avoid this situation by checking for a cancelled context first, and then running both API calls without passing along any cancellations. + if ctx.Err() != nil { + return ctx.Err() + } + ctx = context.WithoutCancel(ctx) + ghPR, _, err := g.client.PullRequests.Create( ctx, g.options.Owner, g.options.Repo, &github.NewPullRequest{ @@ -286,7 +316,6 @@ func (g *GitHub) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePul return err } - // TODO: String ID? pr.ID = ghPR.GetNumber() err = g.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels) diff --git a/internal/forge/gitlab/gitlab.go b/internal/forge/gitlab/gitlab.go index 1394898..d710f41 100644 --- a/internal/forge/gitlab/gitlab.go +++ b/internal/forge/gitlab/gitlab.go @@ -11,7 +11,7 @@ import ( "github.com/blang/semver/v4" "github.com/go-git/go-git/v5/plumbing/transport" "github.com/go-git/go-git/v5/plumbing/transport/http" - "github.com/xanzy/go-gitlab" + gitlab "gitlab.com/gitlab-org/api/client-go" "github.com/apricote/releaser-pleaser/internal/forge" "github.com/apricote/releaser-pleaser/internal/git" @@ -24,8 +24,14 @@ const ( PRStateOpen = "opened" PRStateMerged = "merged" PRStateEventClose = "close" - EnvAPIToken = "GITLAB_TOKEN" // nolint:gosec // Not actually a hardcoded credential - EnvProjectPath = "CI_PROJECT_PATH" + + EnvAPIToken = "GITLAB_TOKEN" // nolint:gosec // Not actually a hardcoded credential + + // The following vars are from https://docs.gitlab.com/ee/ci/variables/predefined_variables.html + + EnvAPIURL = "CI_API_V4_URL" + EnvProjectURL = "CI_PROJECT_URL" + EnvProjectPath = "CI_PROJECT_PATH" ) type GitLab struct { @@ -36,19 +42,23 @@ type GitLab struct { } func (g *GitLab) RepoURL() string { + if g.options.ProjectURL != "" { + return g.options.ProjectURL + } + return fmt.Sprintf("https://gitlab.com/%s", g.options.Path) } func (g *GitLab) CloneURL() string { - return fmt.Sprintf("https://gitlab.com/%s.git", g.options.Path) + return fmt.Sprintf("%s.git", g.RepoURL()) } func (g *GitLab) ReleaseURL(version string) string { - return fmt.Sprintf("https://gitlab.com/%s/-/releases/%s", g.options.Path, version) + return fmt.Sprintf("%s/-/releases/%s", g.RepoURL(), version) } func (g *GitLab) PullRequestURL(id int) string { - return fmt.Sprintf("https://gitlab.com/%s/-/merge_requests/%d", g.options.Path, id) + return fmt.Sprintf("%s/-/merge_requests/%d", g.RepoURL(), id) } func (g *GitLab) GitAuth() transport.AuthMethod { @@ -59,6 +69,22 @@ func (g *GitLab) GitAuth() transport.AuthMethod { } } +func (g *GitLab) CommitAuthor(ctx context.Context) (git.Author, error) { + g.log.DebugContext(ctx, "getting commit author from current token user") + + user, _, err := g.client.Users.CurrentUser(gitlab.WithContext(ctx)) + if err != nil { + return git.Author{}, err + } + + // TODO: Return bot when nothing is returned? + + return git.Author{ + Name: user.Name, + Email: user.Email, + }, nil +} + func (g *GitLab) LatestTags(ctx context.Context) (git.Releases, error) { g.log.DebugContext(ctx, "listing all tags in gitlab repository") @@ -164,10 +190,10 @@ func (g *GitLab) prForCommit(ctx context.Context, commit git.Commit) (*git.PullR return nil, err } - var mergeRequest *gitlab.MergeRequest + var mergeRequest *gitlab.BasicMergeRequest for _, mr := range associatedMRs { - // We only look for the MR that has this commit set as the "merge commit" => The result of squashing this branch onto main - if mr.MergeCommitSHA == commit.Hash { + // We only look for the MR that has this commit set as the "merge/squash commit" => The result of squashing this branch onto main + if mr.MergeCommitSHA == commit.Hash || mr.SquashCommitSHA == commit.Hash || mr.SHA == commit.Hash { mergeRequest = mr break } @@ -305,7 +331,7 @@ func (g *GitLab) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePull } func (g *GitLab) PendingReleases(ctx context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) { - glMRs, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.MergeRequest, *gitlab.Response, error) { + glMRs, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.BasicMergeRequest, *gitlab.Response, error) { return g.client.MergeRequests.ListMergeRequests(&gitlab.ListMergeRequestsOptions{ State: pointer.Pointer(PRStateMerged), Labels: &gitlab.LabelOptions{pendingLabel.Name}, @@ -359,7 +385,7 @@ func all[T any](f func(listOptions gitlab.ListOptions) ([]T, *gitlab.Response, e } } -func gitlabMRToPullRequest(pr *gitlab.MergeRequest) *git.PullRequest { +func gitlabMRToPullRequest(pr *gitlab.BasicMergeRequest) *git.PullRequest { return &git.PullRequest{ ID: pr.IID, Title: pr.Title, @@ -367,7 +393,7 @@ func gitlabMRToPullRequest(pr *gitlab.MergeRequest) *git.PullRequest { } } -func gitlabMRToReleasePullRequest(pr *gitlab.MergeRequest) *releasepr.ReleasePullRequest { +func gitlabMRToReleasePullRequest(pr *gitlab.BasicMergeRequest) *releasepr.ReleasePullRequest { labels := make([]releasepr.Label, 0, len(pr.Labels)) for _, labelName := range pr.Labels { if i := slices.IndexFunc(releasepr.KnownLabels, func(label releasepr.Label) bool { @@ -377,9 +403,15 @@ func gitlabMRToReleasePullRequest(pr *gitlab.MergeRequest) *releasepr.ReleasePul } } + // Commit SHA is saved in either [MergeCommitSHA], [SquashCommitSHA] or [SHA] depending on which merge method was used. var releaseCommit *git.Commit - if pr.MergeCommitSHA != "" { + switch { + case pr.MergeCommitSHA != "": releaseCommit = &git.Commit{Hash: pr.MergeCommitSHA} + case pr.SquashCommitSHA != "": + releaseCommit = &git.Commit{Hash: pr.SquashCommitSHA} + case pr.MergedAt != nil && pr.SHA != "": + releaseCommit = &git.Commit{Hash: pr.SHA} } return &releasepr.ReleasePullRequest{ @@ -393,20 +425,41 @@ func gitlabMRToReleasePullRequest(pr *gitlab.MergeRequest) *releasepr.ReleasePul func (g *Options) autodiscover() { // Read settings from GitLab-CI env vars + if apiURL := os.Getenv(EnvAPIURL); apiURL != "" { + g.APIURL = apiURL + } + if apiToken := os.Getenv(EnvAPIToken); apiToken != "" { g.APIToken = apiToken } + if projectURL := os.Getenv(EnvProjectURL); projectURL != "" { + g.ProjectURL = projectURL + } + if projectPath := os.Getenv(EnvProjectPath); projectPath != "" { g.Path = projectPath } + +} + +func (g *Options) ClientOptions() []gitlab.ClientOptionFunc { + options := []gitlab.ClientOptionFunc{} + + if g.APIURL != "" { + options = append(options, gitlab.WithBaseURL(g.APIURL)) + } + + return options } type Options struct { forge.Options - Path string + ProjectURL string + Path string + APIURL string APIToken string } @@ -414,7 +467,7 @@ func New(log *slog.Logger, options *Options) (*GitLab, error) { log = log.With("forge", "gitlab") options.autodiscover() - client, err := gitlab.NewClient(options.APIToken) + client, err := gitlab.NewClient(options.APIToken, options.ClientOptions()...) if err != nil { return nil, err } diff --git a/internal/git/git.go b/internal/git/git.go index 09fd5c9..ad5c0a3 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -13,12 +13,11 @@ import ( "github.com/go-git/go-git/v5/plumbing" "github.com/go-git/go-git/v5/plumbing/object" "github.com/go-git/go-git/v5/plumbing/transport" - - "github.com/apricote/releaser-pleaser/internal/updater" ) const ( - remoteName = "origin" + remoteName = "origin" + newFilePermissions = 0o644 ) type Commit struct { @@ -44,6 +43,27 @@ type Releases struct { Stable *Tag } +type Author struct { + Name string + Email string +} + +func (a Author) signature(when time.Time) *object.Signature { + return &object.Signature{ + Name: a.Name, + Email: a.Email, + When: when, + } +} + +func (a Author) String() string { + return fmt.Sprintf("%s <%s>", a.Name, a.Email) +} + +var ( + committer = Author{Name: "releaser-pleaser", Email: ""} +) + func CloneRepo(ctx context.Context, logger *slog.Logger, cloneURL, branch string, auth transport.AuthMethod) (*Repository, error) { dir, err := os.MkdirTemp("", "releaser-pleaser.*") if err != nil { @@ -97,30 +117,31 @@ func (r *Repository) Checkout(_ context.Context, branch string) error { return nil } -func (r *Repository) UpdateFile(_ context.Context, path string, updaters []updater.Updater) error { +func (r *Repository) UpdateFile(_ context.Context, path string, create bool, updateHook func(string) (string, error)) error { worktree, err := r.r.Worktree() if err != nil { return err } - file, err := worktree.Filesystem.OpenFile(path, os.O_RDWR, 0) + fileFlags := os.O_RDWR + if create { + fileFlags |= os.O_CREATE + } + + file, err := worktree.Filesystem.OpenFile(path, fileFlags, newFilePermissions) if err != nil { return err } - defer file.Close() + defer file.Close() //nolint:errcheck content, err := io.ReadAll(file) if err != nil { return err } - updatedContent := string(content) - - for _, update := range updaters { - updatedContent, err = update(updatedContent) - if err != nil { - return fmt.Errorf("failed to run updater on file %s", path) - } + updatedContent, err := updateHook(string(content)) + if err != nil { + return fmt.Errorf("failed to run update hook on file %s", path) } err = file.Truncate(0) @@ -144,15 +165,17 @@ func (r *Repository) UpdateFile(_ context.Context, path string, updaters []updat return nil } -func (r *Repository) Commit(_ context.Context, message string) (Commit, error) { +func (r *Repository) Commit(_ context.Context, message string, author Author) (Commit, error) { worktree, err := r.r.Worktree() if err != nil { return Commit{}, err } + now := time.Now() + releaseCommitHash, err := worktree.Commit(message, &git.CommitOptions{ - Author: signature(), - Committer: signature(), + Author: author.signature(now), + Committer: committer.signature(now), }) if err != nil { return Commit{}, fmt.Errorf("failed to commit changes: %w", err) @@ -164,8 +187,27 @@ func (r *Repository) Commit(_ context.Context, message string) (Commit, error) { }, nil } -func (r *Repository) HasChangesWithRemote(ctx context.Context, branch string) (bool, error) { - remoteRef, err := r.r.Reference(plumbing.NewRemoteReferenceName(remoteName, branch), false) +// HasChangesWithRemote checks if the following two diffs are equal: +// +// - **Local**: remote/main..branch +// - **Remote**: (git merge-base remote/main remote/branch)..remote/branch +// +// This is done to avoid pushing when the only change would be a rebase of remote/branch onto the current remote/main. +func (r *Repository) HasChangesWithRemote(ctx context.Context, mainBranch, prBranch string) (bool, error) { + return r.hasChangesWithRemote(ctx, + plumbing.NewRemoteReferenceName(remoteName, mainBranch), + plumbing.NewBranchReferenceName(prBranch), + plumbing.NewRemoteReferenceName(remoteName, prBranch), + ) +} + +func (r *Repository) hasChangesWithRemote(ctx context.Context, mainBranchRef, localPRBranchRef, remotePRBranchRef plumbing.ReferenceName) (bool, error) { + commitOnRemoteMain, err := r.commitFromRef(mainBranchRef) + if err != nil { + return false, err + } + + commitOnRemotePRBranch, err := r.commitFromRef(remotePRBranchRef) if err != nil { if err.Error() == "reference not found" { // No remote branch means that there are changes @@ -175,29 +217,60 @@ func (r *Repository) HasChangesWithRemote(ctx context.Context, branch string) (b return false, err } - remoteCommit, err := r.r.CommitObject(remoteRef.Hash()) + currentRemotePRMergeBase, err := r.mergeBase(commitOnRemoteMain, commitOnRemotePRBranch) + if err != nil { + return false, err + } + if currentRemotePRMergeBase == nil { + // If there is no merge base something weird has happened with the + // remote main branch, and we should definitely push updates. + return false, nil + } + + remoteDiff, err := currentRemotePRMergeBase.PatchContext(ctx, commitOnRemotePRBranch) if err != nil { return false, err } - localRef, err := r.r.Reference(plumbing.NewBranchReferenceName(branch), false) + commitOnLocalPRBranch, err := r.commitFromRef(localPRBranchRef) if err != nil { return false, err } - localCommit, err := r.r.CommitObject(localRef.Hash()) + localDiff, err := commitOnRemoteMain.PatchContext(ctx, commitOnLocalPRBranch) if err != nil { return false, err } - diff, err := localCommit.PatchContext(ctx, remoteCommit) + return remoteDiff.String() != localDiff.String(), nil +} + +func (r *Repository) commitFromRef(refName plumbing.ReferenceName) (*object.Commit, error) { + ref, err := r.r.Reference(refName, false) if err != nil { - return false, err + return nil, err } - hasChanges := len(diff.FilePatches()) > 0 + commit, err := r.r.CommitObject(ref.Hash()) + if err != nil { + return nil, err + } - return hasChanges, nil + return commit, nil +} + +func (r *Repository) mergeBase(a, b *object.Commit) (*object.Commit, error) { + mergeBases, err := a.MergeBase(b) + if err != nil { + return nil, err + } + + if len(mergeBases) == 0 { + return nil, nil + } + + // :shrug: We dont really care which commit we pick, at worst we do an unnecessary push. + return mergeBases[0], nil } func (r *Repository) ForcePush(ctx context.Context, branch string) error { @@ -217,11 +290,3 @@ func (r *Repository) ForcePush(ctx context.Context, branch string) error { Auth: r.auth, }) } - -func signature() *object.Signature { - return &object.Signature{ - Name: "releaser-pleaser", - Email: "", - When: time.Now(), - } -} diff --git a/internal/git/git_test.go b/internal/git/git_test.go new file mode 100644 index 0000000..bf300df --- /dev/null +++ b/internal/git/git_test.go @@ -0,0 +1,174 @@ +package git + +import ( + "context" + "reflect" + "strconv" + "testing" + "time" + + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/stretchr/testify/assert" +) + +func TestAuthor_signature(t *testing.T) { + now := time.Now() + + tests := []struct { + author Author + want *object.Signature + }{ + {author: Author{Name: "foo", Email: "bar@example.com"}, want: &object.Signature{Name: "foo", Email: "bar@example.com", When: now}}, + {author: Author{Name: "bar", Email: "foo@example.com"}, want: &object.Signature{Name: "bar", Email: "foo@example.com", When: now}}, + } + for i, tt := range tests { + t.Run(strconv.FormatInt(int64(i), 10), func(t *testing.T) { + if got := tt.author.signature(now); !reflect.DeepEqual(got, tt.want) { + t.Errorf("signature() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestAuthor_String(t *testing.T) { + tests := []struct { + author Author + want string + }{ + {author: Author{Name: "foo", Email: "bar@example.com"}, want: "foo "}, + {author: Author{Name: "bar", Email: "foo@example.com"}, want: "bar "}, + } + for i, tt := range tests { + t.Run(strconv.FormatInt(int64(i), 10), func(t *testing.T) { + if got := tt.author.String(); !reflect.DeepEqual(got, tt.want) { + t.Errorf("String() = %v, want %v", got, tt.want) + } + }) + } +} + +const testMainBranch = "main" +const testPRBranch = "releaser-pleaser" + +func TestRepository_HasChangesWithRemote(t *testing.T) { + // go-git/v5 has a bug where it tries to delete the repo root dir (".") multiple times if there is no file left in it. + // this happens while switching branches in worktree.go rmFileAndDirsIfEmpty. + // TODO: Fix bug upstream + // For now I just make sure that there is always at least one file left in the dir by adding an empty "README.md" in the test util. + + mainBranchRef := plumbing.NewBranchReferenceName(testMainBranch) + localPRBranchRef := plumbing.NewBranchReferenceName(testPRBranch) + remotePRBranchRef := plumbing.NewBranchReferenceName("remote/" + testPRBranch) + + tests := []struct { + name string + repo TestRepo + want bool + wantErr assert.ErrorAssertionFunc + }{ + { + name: "no remote pr branch", + repo: WithTestRepo( + WithCommit( + "chore: release v1.0.0", + WithFile("VERSION", "v1.0.0"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(localPRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + ), + want: true, + wantErr: assert.NoError, + }, + { + name: "remote pr branch matches local", + repo: WithTestRepo( + WithCommit( + "chore: release v1.0.0", + WithFile("VERSION", "v1.0.0"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(remotePRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(localPRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + ), + want: false, + wantErr: assert.NoError, + }, + { + name: "remote pr only needs rebase", + repo: WithTestRepo( + WithCommit( + "chore: release v1.0.0", + WithFile("VERSION", "v1.0.0"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(remotePRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + WithCommit( + "feat: new feature on remote", + OnBranch(mainBranchRef), + WithFile("feature", "yes"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(localPRBranchRef), + WithFile("VERSION", "v1.1.0"), + ), + ), + want: false, + wantErr: assert.NoError, + }, + { + name: "needs update", + repo: WithTestRepo( + WithCommit( + "chore: release v1.0.0", + WithFile("VERSION", "v1.0.0"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(remotePRBranchRef), + WithFile("VERSION", "v1.1.0"), + WithFile("CHANGELOG.md", "Foo"), + ), + WithCommit( + "chore: release v1.1.0", + OnBranch(mainBranchRef), + AsNewBranch(localPRBranchRef), + WithFile("VERSION", "v1.1.0"), + WithFile("CHANGELOG.md", "FooBar"), + ), + ), + want: true, + wantErr: assert.NoError, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + repo := tt.repo(t) + got, err := repo.hasChangesWithRemote(context.Background(), mainBranchRef, localPRBranchRef, remotePRBranchRef) + if !tt.wantErr(t, err) { + return + } + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/internal/git/util_test.go b/internal/git/util_test.go new file mode 100644 index 0000000..eff947f --- /dev/null +++ b/internal/git/util_test.go @@ -0,0 +1,189 @@ +package git + +import ( + "fmt" + "io" + "log/slog" + "os" + "testing" + "time" + + "github.com/go-git/go-billy/v5/memfs" + "github.com/go-git/go-git/v5" + "github.com/go-git/go-git/v5/plumbing" + "github.com/go-git/go-git/v5/plumbing/object" + "github.com/go-git/go-git/v5/storage/memory" + "github.com/stretchr/testify/require" +) + +var ( + author = &object.Signature{ + Name: "releaser-pleaser", + When: time.Date(2020, 01, 01, 01, 01, 01, 01, time.UTC), + } +) + +type CommitOption func(*commitOptions) +type commitOptions struct { + cleanFiles bool + files []commitFile + tags []string + newRef plumbing.ReferenceName + parentRef plumbing.ReferenceName +} +type commitFile struct { + path string + content string +} + +type TestCommit func(*testing.T, *Repository) error +type TestRepo func(*testing.T) *Repository + +func WithCommit(message string, options ...CommitOption) TestCommit { + return func(t *testing.T, repo *Repository) error { + t.Helper() + + require.NotEmpty(t, message, "commit message is required") + + opts := &commitOptions{} + for _, opt := range options { + opt(opts) + } + + wt, err := repo.r.Worktree() + require.NoError(t, err) + + if opts.parentRef != "" { + checkoutOptions := &git.CheckoutOptions{} + + if opts.newRef != "" { + parentRef, err := repo.r.Reference(opts.parentRef, false) + require.NoError(t, err) + + checkoutOptions.Create = true + checkoutOptions.Hash = parentRef.Hash() + checkoutOptions.Branch = opts.newRef + } else { + checkoutOptions.Branch = opts.parentRef + } + + err = wt.Checkout(checkoutOptions) + require.NoError(t, err) + } + + // Yeet all files + if opts.cleanFiles { + files, err := wt.Filesystem.ReadDir(".") + require.NoError(t, err, "failed to get current files") + + for _, fileInfo := range files { + err = wt.Filesystem.Remove(fileInfo.Name()) + require.NoError(t, err, "failed to remove file %q", fileInfo.Name()) + } + } + + // Create new files + for _, fileInfo := range opts.files { + file, err := wt.Filesystem.Create(fileInfo.path) + require.NoError(t, err, "failed to create file %q", fileInfo.path) + + _, err = file.Write([]byte(fileInfo.content)) + _ = file.Close() + require.NoError(t, err, "failed to write content to file %q", fileInfo.path) + + _, err = wt.Add(fileInfo.path) + require.NoError(t, err, "failed to stage changes to file %q", fileInfo.path) + + } + + // Commit + commitHash, err := wt.Commit(message, &git.CommitOptions{ + All: true, + AllowEmptyCommits: true, + Author: author, + Committer: author, + }) + require.NoError(t, err, "failed to commit") + + // Create tags + for _, tagName := range opts.tags { + _, err = repo.r.CreateTag(tagName, commitHash, nil) + require.NoError(t, err, "failed to create tag %q", tagName) + } + + return nil + } +} + +func WithFile(path, content string) CommitOption { + return func(opts *commitOptions) { + opts.files = append(opts.files, commitFile{path: path, content: content}) + } +} + +// WithCleanFiles removes all previous files from the repo. Make sure to leave at least one file in the root +// directory when switching branches! +func WithCleanFiles() CommitOption { + return func(opts *commitOptions) { + opts.cleanFiles = true + } +} + +func AsNewBranch(ref plumbing.ReferenceName) CommitOption { + return func(opts *commitOptions) { + opts.newRef = ref + } +} + +func OnBranch(ref plumbing.ReferenceName) CommitOption { + return func(opts *commitOptions) { + opts.parentRef = ref + } +} + +func WithTag(name string) CommitOption { + return func(opts *commitOptions) { + opts.tags = append(opts.tags, name) + } +} + +// Can be useful to debug git issues by using it in a terminal +const useOnDiskTestRepository = false + +func WithTestRepo(commits ...TestCommit) TestRepo { + return func(t *testing.T) *Repository { + t.Helper() + + repo := &Repository{ + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } + + var err error + + initOptions := git.InitOptions{DefaultBranch: plumbing.Main} + + if useOnDiskTestRepository { + dir, err := os.MkdirTemp(os.TempDir(), "rp-test-repo-") + require.NoError(t, err, "failed to create temp directory") + + repo.r, err = git.PlainInitWithOptions(dir, &git.PlainInitOptions{InitOptions: initOptions}) + require.NoError(t, err, "failed to create fs repository") + + fmt.Printf("using temp directory: %s", dir) + } else { + repo.r, err = git.InitWithOptions(memory.NewStorage(), memfs.New(), initOptions) + require.NoError(t, err, "failed to create in-memory repository") + } + + // Make initial commit + err = WithCommit("chore: init", WithFile("README.md", "# git test util"))(t, repo) + require.NoError(t, err, "failed to create init commit") + + for i, commit := range commits { + err = commit(t, repo) + require.NoError(t, err, "failed to create commit %d", i) + } + + return repo + } +} diff --git a/internal/log/log.go b/internal/log/log.go new file mode 100644 index 0000000..32219fd --- /dev/null +++ b/internal/log/log.go @@ -0,0 +1,23 @@ +package log + +import ( + "io" + "log/slog" + "os" + "time" + + "github.com/lmittmann/tint" +) + +func GetLogger(w io.Writer) *slog.Logger { + return slog.New( + tint.NewHandler(w, &tint.Options{ + Level: slog.LevelDebug, + TimeFormat: time.RFC3339, + }), + ) +} + +func init() { + slog.SetDefault(GetLogger(os.Stderr)) +} diff --git a/internal/releasepr/releasepr.go b/internal/releasepr/releasepr.go index 22eac0a..4437786 100644 --- a/internal/releasepr/releasepr.go +++ b/internal/releasepr/releasepr.go @@ -98,7 +98,7 @@ func (pr *ReleasePullRequest) parseVersioningFlags(overrides ReleaseOverrides) R overrides.NextVersionType = versioning.NextVersionTypeAlpha case LabelReleasePending, LabelReleaseTagged: // These labels have no effect on the versioning. - break + continue } } diff --git a/internal/releasepr/releasepr.md.tpl b/internal/releasepr/releasepr.md.tpl index 6f74aa0..3242c5d 100644 --- a/internal/releasepr/releasepr.md.tpl +++ b/internal/releasepr/releasepr.md.tpl @@ -15,18 +15,18 @@ If you want to modify the proposed release, add you overrides here. You can lear This will be added to the start of the release notes. -```rp-prefix +~~~~rp-prefix {{- if .Overrides.Prefix }} {{ .Overrides.Prefix }}{{ end }} -``` +~~~~ ### Suffix / End This will be added to the end of the release notes. -```rp-suffix +~~~~rp-suffix {{- if .Overrides.Suffix }} {{ .Overrides.Suffix }}{{ end }} -``` +~~~~ diff --git a/internal/releasepr/releasepr_test.go b/internal/releasepr/releasepr_test.go index 09beaae..1f1111b 100644 --- a/internal/releasepr/releasepr_test.go +++ b/internal/releasepr/releasepr_test.go @@ -1,12 +1,12 @@ package releasepr import ( - "fmt" "testing" "github.com/stretchr/testify/assert" "github.com/apricote/releaser-pleaser/internal/git" + "github.com/apricote/releaser-pleaser/internal/testdata" "github.com/apricote/releaser-pleaser/internal/versioning" ) @@ -38,20 +38,24 @@ func TestReleasePullRequest_GetOverrides(t *testing.T) { name: "prefix in description", pr: ReleasePullRequest{ PullRequest: git.PullRequest{ - Description: "```rp-prefix\n## Foo\n\n- Cool thing\n```", + Description: testdata.MustReadFileString(t, "description-prefix.txt"), }, }, - want: ReleaseOverrides{Prefix: "## Foo\n\n- Cool thing"}, + want: ReleaseOverrides{ + Prefix: testdata.MustReadFileString(t, "prefix.txt"), + }, wantErr: assert.NoError, }, { name: "suffix in description", pr: ReleasePullRequest{ PullRequest: git.PullRequest{ - Description: "```rp-suffix\n## Compatibility\n\nNo compatibility guarantees.\n```", + Description: testdata.MustReadFileString(t, "description-suffix.txt"), }, }, - want: ReleaseOverrides{Suffix: "## Compatibility\n\nNo compatibility guarantees."}, + want: ReleaseOverrides{ + Suffix: testdata.MustReadFileString(t, "suffix.txt"), + }, wantErr: assert.NoError, }, } @@ -59,7 +63,7 @@ func TestReleasePullRequest_GetOverrides(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { got, err := tt.pr.GetOverrides() - if !tt.wantErr(t, err, fmt.Sprintf("GetOverrides()")) { + if !tt.wantErr(t, err, "GetOverrides()") { return } assert.Equalf(t, tt.want, got, "GetOverrides()") @@ -81,30 +85,10 @@ func TestReleasePullRequest_ChangelogText(t *testing.T) { wantErr: assert.NoError, }, { - name: "with section", - description: `# Foobar - - -This is the changelog - -## Awesome - -### New - -#### Changes - - -Suffix Things -`, - want: `This is the changelog - -## Awesome - -### New - -#### Changes -`, - wantErr: assert.NoError, + name: "with section", + description: testdata.MustReadFileString(t, "changelog.txt"), + want: testdata.MustReadFileString(t, "changelog-content.txt"), + wantErr: assert.NoError, }, } for _, tt := range tests { @@ -115,7 +99,7 @@ Suffix Things }, } got, err := pr.ChangelogText() - if !tt.wantErr(t, err, fmt.Sprintf("ChangelogText()")) { + if !tt.wantErr(t, err, "ChangelogText()") { return } assert.Equalf(t, tt.want, got, "ChangelogText()") @@ -179,75 +163,17 @@ func TestReleasePullRequest_SetDescription(t *testing.T) { name: "no overrides", changelogEntry: `## v1.0.0`, overrides: ReleaseOverrides{}, - want: ` -## v1.0.0 - - ---- - -
-

PR by releaser-pleaser 🤖

- -If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. - -## Release Notes - -### Prefix / Start - -This will be added to the start of the release notes. - -` + "```" + `rp-prefix -` + "```" + ` - -### Suffix / End - -This will be added to the end of the release notes. - -` + "```" + `rp-suffix -` + "```" + ` - -
-`, - wantErr: assert.NoError, + want: testdata.MustReadFileString(t, "description-no-overrides.txt"), + wantErr: assert.NoError, }, { name: "existing overrides", changelogEntry: `## v1.0.0`, overrides: ReleaseOverrides{ - Prefix: "This release is awesome!", - Suffix: "Fooo", + Prefix: testdata.MustReadFileString(t, "prefix.txt"), + Suffix: testdata.MustReadFileString(t, "suffix.txt"), }, - want: ` -## v1.0.0 - - ---- - -
-

PR by releaser-pleaser 🤖

- -If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. - -## Release Notes - -### Prefix / Start - -This will be added to the start of the release notes. - -` + "```" + `rp-prefix -This release is awesome! -` + "```" + ` - -### Suffix / End - -This will be added to the end of the release notes. - -` + "```" + `rp-suffix -Fooo -` + "```" + ` - -
-`, + want: testdata.MustReadFileString(t, "description-overrides.txt"), wantErr: assert.NoError, }, } diff --git a/internal/testdata/changelog-content.txt b/internal/testdata/changelog-content.txt new file mode 100644 index 0000000..3b95e44 --- /dev/null +++ b/internal/testdata/changelog-content.txt @@ -0,0 +1,7 @@ +This is the changelog + +## Awesome + +### New + +#### Changes diff --git a/internal/testdata/changelog-entry-prefix.txt b/internal/testdata/changelog-entry-prefix.txt new file mode 100644 index 0000000..186d04a --- /dev/null +++ b/internal/testdata/changelog-entry-prefix.txt @@ -0,0 +1,19 @@ +## [1.0.0](https://example.com/1.0.0) + +## Foo + +- Cool thing + +```go +// Some code example +func IsPositive(number int) error { + if number < 0 { + return fmt.Errorf("number %d is negative", number) + } + return nil +} +``` + +### Bug Fixes + +- Foobar! diff --git a/internal/testdata/changelog-entry-suffix.txt b/internal/testdata/changelog-entry-suffix.txt new file mode 100644 index 0000000..91bbb05 --- /dev/null +++ b/internal/testdata/changelog-entry-suffix.txt @@ -0,0 +1,9 @@ +## [1.0.0](https://example.com/1.0.0) + +### Bug Fixes + +- Foobar! + +## Compatibility + +This version is compatible with flux-compensator v2.2 - v2.9. diff --git a/internal/testdata/changelog.txt b/internal/testdata/changelog.txt new file mode 100644 index 0000000..f77bc5a --- /dev/null +++ b/internal/testdata/changelog.txt @@ -0,0 +1,13 @@ +# Foobar + + +This is the changelog + +## Awesome + +### New + +#### Changes + + +Suffix Things \ No newline at end of file diff --git a/internal/testdata/description-no-overrides.txt b/internal/testdata/description-no-overrides.txt new file mode 100644 index 0000000..8a98ae4 --- /dev/null +++ b/internal/testdata/description-no-overrides.txt @@ -0,0 +1,28 @@ + +## v1.0.0 + + +--- + +
+

PR by releaser-pleaser 🤖

+ +If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. + +## Release Notes + +### Prefix / Start + +This will be added to the start of the release notes. + +~~~~rp-prefix +~~~~ + +### Suffix / End + +This will be added to the end of the release notes. + +~~~~rp-suffix +~~~~ + +
diff --git a/internal/testdata/description-overrides.txt b/internal/testdata/description-overrides.txt new file mode 100644 index 0000000..5a1db7e --- /dev/null +++ b/internal/testdata/description-overrides.txt @@ -0,0 +1,44 @@ + +## v1.0.0 + + +--- + +
+

PR by releaser-pleaser 🤖

+ +If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. + +## Release Notes + +### Prefix / Start + +This will be added to the start of the release notes. + +~~~~rp-prefix +## Foo + +- Cool thing + +```go +// Some code example +func IsPositive(number int) error { + if number < 0 { + return fmt.Errorf("number %d is negative", number) + } + return nil +} +``` +~~~~ + +### Suffix / End + +This will be added to the end of the release notes. + +~~~~rp-suffix +## Compatibility + +This version is compatible with flux-compensator v2.2 - v2.9. +~~~~ + +
diff --git a/internal/testdata/description-prefix.txt b/internal/testdata/description-prefix.txt new file mode 100644 index 0000000..3a30166 --- /dev/null +++ b/internal/testdata/description-prefix.txt @@ -0,0 +1,41 @@ + +## v1.0.0 + + +--- + +
+

PR by releaser-pleaser 🤖

+ +If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. + +## Release Notes + +### Prefix / Start + +This will be added to the start of the release notes. + +~~~~rp-prefix +## Foo + +- Cool thing + +```go +// Some code example +func IsPositive(number int) error { + if number < 0 { + return fmt.Errorf("number %d is negative", number) + } + return nil +} +``` +~~~~ + +### Suffix / End + +This will be added to the end of the release notes. + +~~~~rp-suffix +~~~~ + +
diff --git a/internal/testdata/description-suffix.txt b/internal/testdata/description-suffix.txt new file mode 100644 index 0000000..d0a1596 --- /dev/null +++ b/internal/testdata/description-suffix.txt @@ -0,0 +1,31 @@ + +## v1.0.0 + + +--- + +
+

PR by releaser-pleaser 🤖

+ +If you want to modify the proposed release, add you overrides here. You can learn more about the options in the docs. + +## Release Notes + +### Prefix / Start + +This will be added to the start of the release notes. + +~~~~rp-prefix +~~~~ + +### Suffix / End + +This will be added to the end of the release notes. + +~~~~rp-suffix +## Compatibility + +This version is compatible with flux-compensator v2.2 - v2.9. +~~~~ + +
diff --git a/internal/testdata/prefix.txt b/internal/testdata/prefix.txt new file mode 100644 index 0000000..55271d3 --- /dev/null +++ b/internal/testdata/prefix.txt @@ -0,0 +1,13 @@ +## Foo + +- Cool thing + +```go +// Some code example +func IsPositive(number int) error { + if number < 0 { + return fmt.Errorf("number %d is negative", number) + } + return nil +} +``` \ No newline at end of file diff --git a/internal/testdata/suffix.txt b/internal/testdata/suffix.txt new file mode 100644 index 0000000..3fc6656 --- /dev/null +++ b/internal/testdata/suffix.txt @@ -0,0 +1,3 @@ +## Compatibility + +This version is compatible with flux-compensator v2.2 - v2.9. \ No newline at end of file diff --git a/internal/testdata/testdata.go b/internal/testdata/testdata.go new file mode 100644 index 0000000..28c87d1 --- /dev/null +++ b/internal/testdata/testdata.go @@ -0,0 +1,19 @@ +package testdata + +import ( + "embed" + "testing" +) + +//go:embed *.txt +var testdata embed.FS + +func MustReadFileString(t *testing.T, name string) string { + t.Helper() + + content, err := testdata.ReadFile(name) + if err != nil { + t.Fatal(err) + } + return string(content) +} diff --git a/internal/updater/changelog.go b/internal/updater/changelog.go index 8bdb9f6..a7c7506 100644 --- a/internal/updater/changelog.go +++ b/internal/updater/changelog.go @@ -14,7 +14,22 @@ var ( ChangelogUpdaterHeaderRegex = regexp.MustCompile(`^# Changelog\n`) ) -func Changelog(info ReleaseInfo) Updater { +func Changelog() Updater { + return changelog{} +} + +type changelog struct { +} + +func (c changelog) Files() []string { + return []string{ChangelogFile} +} + +func (c changelog) CreateNewFiles() bool { + return true +} + +func (c changelog) Update(info ReleaseInfo) func(content string) (string, error) { return func(content string) (string, error) { headerIndex := ChangelogUpdaterHeaderRegex.FindStringIndex(content) if headerIndex == nil && len(content) != 0 { diff --git a/internal/updater/changelog_test.go b/internal/updater/changelog_test.go index 917cd14..c0becb5 100644 --- a/internal/updater/changelog_test.go +++ b/internal/updater/changelog_test.go @@ -6,7 +6,15 @@ import ( "github.com/stretchr/testify/assert" ) -func TestChangelogUpdater_UpdateContent(t *testing.T) { +func TestChangelogUpdater_Files(t *testing.T) { + assert.Equal(t, []string{"CHANGELOG.md"}, Changelog().Files()) +} + +func TestChangelogUpdater_CreateNewFiles(t *testing.T) { + assert.True(t, Changelog().CreateNewFiles()) +} + +func TestChangelogUpdater_Update(t *testing.T) { tests := []updaterTestCase{ { name: "empty file", @@ -54,7 +62,7 @@ func TestChangelogUpdater_UpdateContent(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - runUpdaterTest(t, Changelog, tt) + runUpdaterTest(t, Changelog(), tt) }) } } diff --git a/internal/updater/generic.go b/internal/updater/generic.go index b8d73b0..11b21a4 100644 --- a/internal/updater/generic.go +++ b/internal/updater/generic.go @@ -7,7 +7,25 @@ import ( var GenericUpdaterSemVerRegex = regexp.MustCompile(`\d+\.\d+\.\d+(-[\w.]+)?(.*x-releaser-pleaser-version)`) -func Generic(info ReleaseInfo) Updater { +func Generic(files []string) Updater { + return generic{ + files: files, + } +} + +type generic struct { + files []string +} + +func (g generic) Files() []string { + return g.files +} + +func (g generic) CreateNewFiles() bool { + return false +} + +func (g generic) Update(info ReleaseInfo) func(content string) (string, error) { return func(content string) (string, error) { // We strip the "v" prefix to avoid adding/removing it from the users input. version := strings.TrimPrefix(info.Version, "v") diff --git a/internal/updater/generic_test.go b/internal/updater/generic_test.go index e0a8d1d..7c007a4 100644 --- a/internal/updater/generic_test.go +++ b/internal/updater/generic_test.go @@ -6,7 +6,15 @@ import ( "github.com/stretchr/testify/assert" ) -func TestGenericUpdater_UpdateContent(t *testing.T) { +func TestGenericUpdater_Files(t *testing.T) { + assert.Equal(t, []string{"foo.bar", "version.txt"}, Generic([]string{"foo.bar", "version.txt"}).Files()) +} + +func TestGenericUpdater_CreateNewFiles(t *testing.T) { + assert.False(t, Generic([]string{}).CreateNewFiles()) +} + +func TestGenericUpdater_Update(t *testing.T) { tests := []updaterTestCase{ { name: "single line", @@ -47,7 +55,7 @@ func TestGenericUpdater_UpdateContent(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - runUpdaterTest(t, Generic, tt) + runUpdaterTest(t, Generic([]string{"version.txt"}), tt) }) } } diff --git a/internal/updater/packagejson.go b/internal/updater/packagejson.go new file mode 100644 index 0000000..0fcb8a5 --- /dev/null +++ b/internal/updater/packagejson.go @@ -0,0 +1,39 @@ +package updater + +import ( + "regexp" + "strings" +) + +// PackageJson creates an updater that modifies the version field in package.json files +func PackageJson() Updater { + return packagejson{} +} + +type packagejson struct{} + +func (p packagejson) Files() []string { + return []string{"package.json"} +} + +func (p packagejson) CreateNewFiles() bool { + return false +} + +func (p packagejson) Update(info ReleaseInfo) func(content string) (string, error) { + return func(content string) (string, error) { + // We strip the "v" prefix to match npm versioning convention + version := strings.TrimPrefix(info.Version, "v") + + // Regex to match "version": "..." with flexible whitespace and quote styles + versionRegex := regexp.MustCompile(`("version"\s*:\s*)"[^"]*"`) + + // Check if the file contains a version field + if !versionRegex.MatchString(content) { + return content, nil + } + + // Replace the version value while preserving the original formatting + return versionRegex.ReplaceAllString(content, `${1}"`+version+`"`), nil + } +} diff --git a/internal/updater/packagejson_test.go b/internal/updater/packagejson_test.go new file mode 100644 index 0000000..9bff8b7 --- /dev/null +++ b/internal/updater/packagejson_test.go @@ -0,0 +1,62 @@ +package updater + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestPackageJsonUpdater_Files(t *testing.T) { + assert.Equal(t, []string{"package.json"}, PackageJson().Files()) +} + +func TestPackageJsonUpdater_CreateNewFiles(t *testing.T) { + assert.False(t, PackageJson().CreateNewFiles()) +} + +func TestPackageJsonUpdater_Update(t *testing.T) { + tests := []updaterTestCase{ + { + name: "simple package.json", + content: `{"name":"test","version":"1.0.0"}`, + info: ReleaseInfo{ + Version: "v2.0.5", + }, + want: `{"name":"test","version":"2.0.5"}`, + wantErr: assert.NoError, + }, + { + name: "complex package.json", + content: "{\n \"name\": \"test\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"foo\": \"^1.0.0\"\n }\n}", + info: ReleaseInfo{ + Version: "v2.0.0", + }, + want: "{\n \"name\": \"test\",\n \"version\": \"2.0.0\",\n \"dependencies\": {\n \"foo\": \"^1.0.0\"\n }\n}", + wantErr: assert.NoError, + }, + { + name: "invalid json", + content: `not json`, + info: ReleaseInfo{ + Version: "v2.0.0", + }, + want: `not json`, + wantErr: assert.NoError, + }, + { + name: "json without version", + content: `{"name":"test"}`, + info: ReleaseInfo{ + Version: "v2.0.0", + }, + want: `{"name":"test"}`, + wantErr: assert.NoError, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + runUpdaterTest(t, PackageJson(), tt) + }) + } +} diff --git a/internal/updater/updater.go b/internal/updater/updater.go index fb773b4..6e27f37 100644 --- a/internal/updater/updater.go +++ b/internal/updater/updater.go @@ -5,7 +5,11 @@ type ReleaseInfo struct { ChangelogEntry string } -type Updater func(string) (string, error) +type Updater interface { + Files() []string + CreateNewFiles() bool + Update(info ReleaseInfo) func(content string) (string, error) +} type NewUpdater func(ReleaseInfo) Updater diff --git a/internal/updater/updater_test.go b/internal/updater/updater_test.go index 0c0c40e..5a90936 100644 --- a/internal/updater/updater_test.go +++ b/internal/updater/updater_test.go @@ -15,10 +15,10 @@ type updaterTestCase struct { wantErr assert.ErrorAssertionFunc } -func runUpdaterTest(t *testing.T, constructor NewUpdater, tt updaterTestCase) { +func runUpdaterTest(t *testing.T, u Updater, tt updaterTestCase) { t.Helper() - got, err := constructor(tt.info)(tt.content) + got, err := u.Update(tt.info)(tt.content) if !tt.wantErr(t, err, fmt.Sprintf("Updater(%v, %v)", tt.content, tt.info)) { return } diff --git a/internal/versioning/semver.go b/internal/versioning/semver.go index 49dc019..07e0fdc 100644 --- a/internal/versioning/semver.go +++ b/internal/versioning/semver.go @@ -10,9 +10,11 @@ import ( "github.com/apricote/releaser-pleaser/internal/git" ) -var _ Strategy = SemVerNextVersion +var SemVer Strategy = semVer{} -func SemVerNextVersion(r git.Releases, versionBump VersionBump, nextVersionType NextVersionType) (string, error) { +type semVer struct{} + +func (s semVer) NextVersion(r git.Releases, versionBump VersionBump, nextVersionType NextVersionType) (string, error) { latest, err := parseSemverWithDefault(r.Latest) if err != nil { return "", fmt.Errorf("failed to parse latest version: %w", err) @@ -108,3 +110,16 @@ func parseSemverWithDefault(tag *git.Tag) (semver.Version, error) { return parsedVersion, nil } + +func (s semVer) IsPrerelease(version string) bool { + semVersion, err := parseSemverWithDefault(&git.Tag{Hash: "", Name: version}) + if err != nil { + return false + } + + if len(semVersion.Pre) > 0 { + return true + } + + return false +} diff --git a/internal/versioning/semver_test.go b/internal/versioning/semver_test.go index db22c88..936c258 100644 --- a/internal/versioning/semver_test.go +++ b/internal/versioning/semver_test.go @@ -10,7 +10,7 @@ import ( "github.com/apricote/releaser-pleaser/internal/git" ) -func TestReleases_NextVersion(t *testing.T) { +func TestSemVer_NextVersion(t *testing.T) { type args struct { releases git.Releases versionBump VersionBump @@ -326,7 +326,7 @@ func TestReleases_NextVersion(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := SemVerNextVersion(tt.args.releases, tt.args.versionBump, tt.args.nextVersionType) + got, err := SemVer.NextVersion(tt.args.releases, tt.args.versionBump, tt.args.nextVersionType) if !tt.wantErr(t, err, fmt.Sprintf("SemVerNextVersion(Releases(%v, %v), %v, %v)", tt.args.releases.Latest, tt.args.releases.Stable, tt.args.versionBump, tt.args.nextVersionType)) { return } @@ -388,3 +388,37 @@ func TestVersionBumpFromCommits(t *testing.T) { }) } } + +func TestSemVer_IsPrerelease(t *testing.T) { + tests := []struct { + name string + version string + want bool + }{ + { + name: "empty string", + version: "", + want: false, + }, + { + name: "stable version", + version: "v1.0.0", + want: false, + }, + { + name: "pre-release version", + version: "v1.0.0-rc.1+foo", + want: true, + }, + { + name: "invalid version", + version: "ajfkdafjdsfj", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equalf(t, tt.want, SemVer.IsPrerelease(tt.version), "IsSemverPrerelease(%v)", tt.version) + }) + } +} diff --git a/internal/versioning/versioning.go b/internal/versioning/versioning.go index 3bf8138..a4915ff 100644 --- a/internal/versioning/versioning.go +++ b/internal/versioning/versioning.go @@ -6,7 +6,10 @@ import ( "github.com/apricote/releaser-pleaser/internal/git" ) -type Strategy = func(git.Releases, VersionBump, NextVersionType) (string, error) +type Strategy interface { + NextVersion(git.Releases, VersionBump, NextVersionType) (string, error) + IsPrerelease(version string) bool +} type VersionBump conventionalcommits.VersionBump diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..4db342a --- /dev/null +++ b/mise.toml @@ -0,0 +1,25 @@ +[tools] +go = "1.25.5" +golangci-lint = "2.8.0" +goreleaser = "v2.9.0" +mdbook = "v0.5.2" # renovate: datasource=github-releases depName=rust-lang/mdbook +ko = "v0.18.1" # renovate: datasource=github-releases depName=ko-build/ko + +[settings] +# Experimental features are needed for the Go backend +experimental = true + +[tasks.lint] +run = "golangci-lint run" + +[tasks.test] +run = "go test -v -race ./..." + +[tasks.test-e2e] +run = "go test -tags e2e_forgejo -v -race ./test/e2e/forgejo" + +[tasks.e2e-forgejo-start] +run = "docker compose --project-directory ./test/e2e/forgejo up -d --wait" + +[tasks.e2e-forgejo-stop] +run = "docker compose --project-directory ./test/e2e/forgejo down" diff --git a/releaserpleaser.go b/releaserpleaser.go index a69ef9e..b72b85f 100644 --- a/releaserpleaser.go +++ b/releaserpleaser.go @@ -2,6 +2,7 @@ package rp import ( "context" + "errors" "fmt" "log/slog" @@ -18,23 +19,31 @@ const ( PullRequestBranchFormat = "releaser-pleaser--branches--%s" ) +const ( + PullRequestConflictAttempts = 3 +) + +var ( + ErrorPullRequestConflict = errors.New("conflict: pull request description was changed while releaser-pleaser was running") +) + type ReleaserPleaser struct { forge forge.Forge logger *slog.Logger targetBranch string commitParser commitparser.CommitParser - nextVersion versioning.Strategy + versioning versioning.Strategy extraFiles []string - updaters []updater.NewUpdater + updaters []updater.Updater } -func New(forge forge.Forge, logger *slog.Logger, targetBranch string, commitParser commitparser.CommitParser, versioningStrategy versioning.Strategy, extraFiles []string, updaters []updater.NewUpdater) *ReleaserPleaser { +func New(forge forge.Forge, logger *slog.Logger, targetBranch string, commitParser commitparser.CommitParser, versioningStrategy versioning.Strategy, extraFiles []string, updaters []updater.Updater) *ReleaserPleaser { return &ReleaserPleaser{ forge: forge, logger: logger, targetBranch: targetBranch, commitParser: commitParser, - nextVersion: versioningStrategy, + versioning: versioningStrategy, extraFiles: extraFiles, updaters: updaters, } @@ -57,7 +66,7 @@ func (rp *ReleaserPleaser) Run(ctx context.Context) error { return fmt.Errorf("failed to create pending releases: %w", err) } - err = rp.runReconcileReleasePR(ctx) + err = rp.runReconcileReleasePRWithRetries(ctx) if err != nil { return fmt.Errorf("failed to reconcile release pull request: %w", err) } @@ -117,15 +126,15 @@ func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *release return err } - changelog, err := pr.ChangelogText() + changelogText, err := pr.ChangelogText() if err != nil { return err } - // TODO: pre-release & latest + // TODO: Check if version should be marked latest logger.DebugContext(ctx, "Creating release on forge") - err = rp.forge.CreateRelease(ctx, *pr.ReleaseCommit, version, changelog, false, true) + err = rp.forge.CreateRelease(ctx, *pr.ReleaseCommit, version, changelogText, rp.versioning.IsPrerelease(version), true) if err != nil { return fmt.Errorf("failed to create release on forge: %w", err) } @@ -143,6 +152,36 @@ func (rp *ReleaserPleaser) createPendingRelease(ctx context.Context, pr *release return nil } +// runReconcileReleasePRWithRetries retries runReconcileReleasePR up to PullRequestConflictAttempts times, but only +// when a ErrorPullRequestConflict was encountered. +func (rp *ReleaserPleaser) runReconcileReleasePRWithRetries(ctx context.Context) error { + logger := rp.logger.With("method", "runReconcileReleasePRWithRetries", "totalAttempts", PullRequestConflictAttempts) + var err error + + for i := range PullRequestConflictAttempts { + logger := logger.With("attempt", i+1) + logger.DebugContext(ctx, "attempting runReconcileReleasePR") + + err = rp.runReconcileReleasePR(ctx) + if err != nil { + if errors.Is(err, ErrorPullRequestConflict) { + logger.WarnContext(ctx, "detected conflict while updating pull request description, retrying") + continue + } + + break + } + + break + } + + if err != nil { + return err + } + + return nil +} + func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { logger := rp.logger.With("method", "runReconcileReleasePR") @@ -163,7 +202,6 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { if err != nil { return err } - } releases, err := rp.forge.LatestTags(ctx) @@ -180,34 +218,16 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { logger.InfoContext(ctx, "no latest tag found") } - // By default, we want to show everything that has happened since the last stable release - lastReleaseCommit := releases.Stable - if releaseOverrides.NextVersionType.IsPrerelease() { - // if the new release will be a prerelease, - // only show changes since the latest release (stable or prerelease) - lastReleaseCommit = releases.Latest - } - - commits, err := rp.forge.CommitsSince(ctx, lastReleaseCommit) + // For stable releases, we want to consider all changes since the last stable release for version and changelog. + // For prereleases, we want to consider all changes... + // - since the last stable release for the version + // - since the latest release (stable or prerelease) for the changelog + analyzedCommitsForVersioning, err := rp.analyzedCommitsSince(ctx, releases.Stable) if err != nil { return err } - commits, err = parsePRBodyForCommitOverrides(commits) - if err != nil { - return err - } - - logger.InfoContext(ctx, "Found releasable commits", "length", len(commits)) - - analyzedCommits, err := rp.commitParser.Analyze(commits) - if err != nil { - return err - } - - logger.InfoContext(ctx, "Analyzed commits", "length", len(analyzedCommits)) - - if len(analyzedCommits) == 0 { + if len(analyzedCommitsForVersioning) == 0 { if pr != nil { logger.InfoContext(ctx, "closing existing pull requests, no commits available", "pr.id", pr.ID, "pr.title", pr.Title) err = rp.forge.ClosePullRequest(ctx, pr) @@ -221,14 +241,22 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { return nil } - versionBump := versioning.BumpFromCommits(analyzedCommits) + versionBump := versioning.BumpFromCommits(analyzedCommitsForVersioning) // TODO: Set version in release pr - nextVersion, err := rp.nextVersion(releases, versionBump, releaseOverrides.NextVersionType) + nextVersion, err := rp.versioning.NextVersion(releases, versionBump, releaseOverrides.NextVersionType) if err != nil { return err } logger.InfoContext(ctx, "next version", "version", nextVersion) + analyzedCommitsForChangelog := analyzedCommitsForVersioning + if releaseOverrides.NextVersionType.IsPrerelease() && releases.Latest != releases.Stable { + analyzedCommitsForChangelog, err = rp.analyzedCommitsSince(ctx, releases.Latest) + if err != nil { + return err + } + } + logger.DebugContext(ctx, "cloning repository", "clone.url", rp.forge.CloneURL()) repo, err := git.CloneRepo(ctx, logger, rp.forge.CloneURL(), rp.targetBranch, rp.forge.GitAuth()) if err != nil { @@ -243,7 +271,9 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { return err } - changelogEntry, err := changelog.NewChangelogEntry(logger, analyzedCommits, nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix) + changelogData := changelog.New(commitparser.ByType(analyzedCommitsForChangelog), nextVersion, rp.forge.ReleaseURL(nextVersion), releaseOverrides.Prefix, releaseOverrides.Suffix) + + changelogEntry, err := changelog.Entry(logger, changelog.DefaultTemplate(), changelogData, changelog.Formatting{}) if err != nil { return fmt.Errorf("failed to build changelog entry: %w", err) } @@ -251,29 +281,30 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { // Info for updaters info := updater.ReleaseInfo{Version: nextVersion, ChangelogEntry: changelogEntry} - err = repo.UpdateFile(ctx, updater.ChangelogFile, updater.WithInfo(info, updater.Changelog)) - if err != nil { - return fmt.Errorf("failed to update changelog file: %w", err) - } - - for _, path := range rp.extraFiles { - // TODO: Check for missing files - err = repo.UpdateFile(ctx, path, updater.WithInfo(info, rp.updaters...)) - if err != nil { - return fmt.Errorf("failed to run file updater: %w", err) + for _, u := range rp.updaters { + for _, file := range u.Files() { + err = repo.UpdateFile(ctx, file, u.CreateNewFiles(), u.Update(info)) + if err != nil { + return fmt.Errorf("failed to run updater %T: %w", u, err) + } } } + releaseCommitAuthor, err := rp.forge.CommitAuthor(ctx) + if err != nil { + return fmt.Errorf("failed to get commit author: %w", err) + } + releaseCommitMessage := fmt.Sprintf("chore(%s): release %s", rp.targetBranch, nextVersion) - releaseCommit, err := repo.Commit(ctx, releaseCommitMessage) + releaseCommit, err := repo.Commit(ctx, releaseCommitMessage, releaseCommitAuthor) if err != nil { return fmt.Errorf("failed to commit changes: %w", err) } - logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommit.Hash, "commit.message", releaseCommit.Message) + logger.InfoContext(ctx, "created release commit", "commit.hash", releaseCommit.Hash, "commit.message", releaseCommit.Message, "commit.author", releaseCommitAuthor) // Check if anything changed in comparison to the remote branch (if exists) - newReleasePRChanges, err := repo.HasChangesWithRemote(ctx, rpBranch) + newReleasePRChanges, err := repo.HasChangesWithRemote(ctx, rp.targetBranch, rpBranch) if err != nil { return err } @@ -289,9 +320,16 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { logger.InfoContext(ctx, "file content is already up-to-date in remote branch, skipping push") } + // We do not need the version title here. In the pull request the version is available from the title, and in the + // release on the Forge its usually in a heading somewhere above the text. + changelogEntryPullRequest, err := changelog.Entry(logger, changelog.DefaultTemplate(), changelogData, changelog.Formatting{HideVersionTitle: true}) + if err != nil { + return fmt.Errorf("failed to build pull request changelog entry: %w", err) + } + // Open/Update PR if pr == nil { - pr, err = releasepr.NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntry) + pr, err = releasepr.NewReleasePullRequest(rpBranch, rp.targetBranch, nextVersion, changelogEntryPullRequest) if err != nil { return err } @@ -302,13 +340,30 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { } logger.InfoContext(ctx, "opened pull request", "pr.title", pr.Title, "pr.id", pr.ID, "pr.url", rp.forge.PullRequestURL(pr.ID)) } else { + // Check if the pull request was updated while releaser-pleaser was running. + // This avoids a conflict where the user updated the PR while releaser-pleaser already pulled the info, and + // releaser-pleaser subsequently reverts the users changes. There is still a minimal time window for this to + // happen between us checking the PR again and submitting our changes. + + logger.DebugContext(ctx, "checking for conflict in pr description", "pr.id", pr.ID) + recheckPR, err := rp.forge.PullRequestForBranch(ctx, rpBranch) + if err != nil { + return err + } + if recheckPR == nil { + return fmt.Errorf("PR was deleted while releaser-pleaser was running") + } + if recheckPR.Description != pr.Description { + return ErrorPullRequestConflict + } + pr.SetTitle(rp.targetBranch, nextVersion) overrides, err := pr.GetOverrides() if err != nil { return err } - err = pr.SetDescription(changelogEntry, overrides) + err = pr.SetDescription(changelogEntryPullRequest, overrides) if err != nil { return err } @@ -322,3 +377,32 @@ func (rp *ReleaserPleaser) runReconcileReleasePR(ctx context.Context) error { return nil } + +func (rp *ReleaserPleaser) analyzedCommitsSince(ctx context.Context, since *git.Tag) ([]commitparser.AnalyzedCommit, error) { + logger := rp.logger.With("method", "analyzedCommitsSince") + + if since != nil { + logger = rp.logger.With("tag.hash", since.Hash, "tag.name", since.Name) + } + + commits, err := rp.forge.CommitsSince(ctx, since) + if err != nil { + return nil, err + } + + commits, err = parsePRBodyForCommitOverrides(commits) + if err != nil { + return nil, err + } + + logger.InfoContext(ctx, "Found releasable commits", "length", len(commits)) + + analyzedCommits, err := rp.commitParser.Analyze(commits) + if err != nil { + return nil, err + } + + logger.InfoContext(ctx, "Analyzed commits", "length", len(analyzedCommits)) + + return analyzedCommits, nil +} diff --git a/templates/run.yml b/templates/run.yml index bd762be..b8a6513 100644 --- a/templates/run.yml +++ b/templates/run.yml @@ -9,23 +9,43 @@ spec: description: "GitLab token for creating and updating release MRs." extra-files: - description: 'List of files that are scanned for version references.' + description: 'List of files that are scanned for version references by the generic updater.' + default: "" + + updaters: + description: "List of updaters that are run. Default updaters can be removed by specifying them as -name. Multiple updaters should be concatenated with a comma. Default Updaters: changelog,generic" default: "" stage: default: build description: 'Defines the build stage' + + needs: + default: [ ] + type: array + description: 'Dependencies of the created Job' # Remember to update docs/reference/gitlab-ci-component.md --- releaser-pleaser: stage: $[[ inputs.stage ]] + needs: $[[ inputs.needs ]] + rules: # There is no way to run a pipeline when the MR description is updated :( - if: $CI_COMMIT_BRANCH == "$[[ inputs.branch ]]" + + # If a newer releaser-pleaser job runs, this one may be cancelled without problem, releaser-pleaser is idempotent. + # This only works if the user enables "auto-cancel redundant pipelines", which we do tell them to, because this is + # intrusive and up to the user. + interruptible: true + + # No need to have multiple releaser-pleaser jobs running at the same time. They all act on the same global state. + resource_group: releaser-pleaser + image: - name: ghcr.io/apricote/releaser-pleaser:v0.4.0-beta.1 # x-releaser-pleaser-version - entrypoint: [""] + name: ghcr.io/apricote/releaser-pleaser:v0.7.1 # x-releaser-pleaser-version + entrypoint: [ "" ] variables: GITLAB_TOKEN: $[[ inputs.token ]] script: @@ -33,4 +53,5 @@ releaser-pleaser: rp run \ --forge=gitlab \ --branch=$[[ inputs.branch ]] \ - --extra-files="$[[ inputs.extra-files ]]" + --extra-files="$[[ inputs.extra-files ]]" \ + --updaters="$[[ inputs.updaters ]]" diff --git a/test/e2e/forge.go b/test/e2e/forge.go new file mode 100644 index 0000000..ea55545 --- /dev/null +++ b/test/e2e/forge.go @@ -0,0 +1,19 @@ +package e2e + +import ( + "context" + "testing" +) + +type TestForge interface { + Init(ctx context.Context, runID string) error + CreateRepo(t *testing.T, opts CreateRepoOpts) (*Repository, error) + + RunArguments() []string +} + +type CreateRepoOpts struct { + Name string + Description string + DefaultBranch string +} diff --git a/test/e2e/forgejo/app.ini b/test/e2e/forgejo/app.ini new file mode 100644 index 0000000..4a22d2e --- /dev/null +++ b/test/e2e/forgejo/app.ini @@ -0,0 +1,23 @@ +WORK_PATH = /data/gitea + +[database] +DB_TYPE = sqlite3 +PATH = /data/gitea/forgejo.db + +[security] +INSTALL_LOCK = true +SECRET_KEY = releaser-pleaser +INTERNAL_TOKEN = releaser-pleaser + +[service] +REGISTER_EMAIL_CONFIRM = false +ENABLE_NOTIFY_MAIL = false +DISABLE_REGISTRATION = true + +[server] +DOMAIN = localhost +HTTP_PORT = 3000 +ROOT_URL = http://localhost:3000/ + +[oauth2] +JWT_SECRET = rTD-FL2n_aBB6v4AOcr5lBvwgZ6PSr3HGZAuNH6nMu8 diff --git a/test/e2e/forgejo/compose.yaml b/test/e2e/forgejo/compose.yaml new file mode 100644 index 0000000..3f20c3a --- /dev/null +++ b/test/e2e/forgejo/compose.yaml @@ -0,0 +1,16 @@ +services: + forgejo: + image: codeberg.org/forgejo/forgejo:11 + ports: + - '3000:3000' + - '222:22' + volumes: + - data:/data/gitea + - ./app.ini:/data/gitea/conf/app.ini:ro + + healthcheck: + test: ["CMD", "curl", "localhost:3000/api/healthz"] + + +volumes: + data: diff --git a/test/e2e/forgejo/forge.go b/test/e2e/forgejo/forge.go new file mode 100644 index 0000000..c631cb8 --- /dev/null +++ b/test/e2e/forgejo/forge.go @@ -0,0 +1,113 @@ +package forgejo + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "strings" + "testing" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/apricote/releaser-pleaser/test/e2e" +) + +const ( + TestAPIURL = "http://localhost:3000" + + TestUserNameTemplate = "rp-%s" + TestUserPassword = "releaser-pleaser" + TestUserEmailTemplate = "releaser-pleaser-%s@example.com" + TestTokenName = "rp" + TestTokenScopes = "write:user,write:issue,write:repository" +) + +type TestForge struct { + username string + token string + client *forgejo.Client +} + +func (f *TestForge) Init(ctx context.Context, runID string) error { + if err := f.initUser(ctx, runID); err != nil { + return err + } + if err := f.initClient(ctx); err != nil { + return err + } + + return nil +} + +func (f *TestForge) initUser(ctx context.Context, runID string) error { + f.username = fmt.Sprintf(TestUserNameTemplate, runID) + + //gosec:disable G204 + if output, err := exec.CommandContext(ctx, + "docker", "compose", "exec", "--user=1000", "forgejo", + "forgejo", "admin", "user", "create", + "--username", f.username, + "--password", TestUserPassword, + "--email", fmt.Sprintf(TestUserEmailTemplate, runID), + "--must-change-password=false", + ).CombinedOutput(); err != nil { + slog.Debug("create forgejo user output", "output", output) + return fmt.Errorf("failed to create forgejo user: %w", err) + } + + //gosec:disable G204 + token, err := exec.CommandContext(ctx, + "docker", "compose", "exec", "--user=1000", "forgejo", + "forgejo", "admin", "user", "generate-access-token", + "--username", f.username, + "--token-name", TestTokenName, + "--scopes", TestTokenScopes, + "--raw", + ).Output() + if err != nil { + return fmt.Errorf("failed to create forgejo token: %w", err) + } + + f.token = strings.TrimSpace(string(token)) + + return nil +} + +func (f *TestForge) initClient(ctx context.Context) (err error) { + f.client, err = forgejo.NewClient(TestAPIURL, + forgejo.SetToken(f.token), + forgejo.SetUserAgent("releaser-pleaser-e2e-tests"), + forgejo.SetContext(ctx), + // forgejo.SetDebugMode(), + ) + return err +} + +func (f *TestForge) CreateRepo(t *testing.T, opts e2e.CreateRepoOpts) (*e2e.Repository, error) { + t.Helper() + + repo, _, err := f.client.CreateRepo(forgejo.CreateRepoOption{ + Name: opts.Name, + Description: opts.Description, + DefaultBranch: opts.DefaultBranch, + Readme: "Default", + AutoInit: true, + }) + if err != nil { + return nil, err + } + + return &e2e.Repository{ + Name: repo.Name, + }, nil +} + +func (f *TestForge) RunArguments() []string { + return []string{"--forge=forgejo", + fmt.Sprintf("--owner=%s", f.username), + fmt.Sprintf("--api-url=%s", TestAPIURL), + fmt.Sprintf("--api-token=%s", f.token), + fmt.Sprintf("--username=%s", f.username), + } +} diff --git a/test/e2e/forgejo/forgejo_test.go b/test/e2e/forgejo/forgejo_test.go new file mode 100644 index 0000000..b52504f --- /dev/null +++ b/test/e2e/forgejo/forgejo_test.go @@ -0,0 +1,39 @@ +//go:build e2e_forgejo + +package forgejo + +import ( + "context" + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/apricote/releaser-pleaser/test/e2e" +) + +var ( + f *e2e.Framework +) + +func TestMain(m *testing.M) { + ctx := context.Background() + + var err error + f, err = e2e.NewFramework(ctx, &TestForge{}) + if err != nil { + slog.Error("failed to set up test framework", "err", err) + } + + os.Exit(m.Run()) +} + +func TestCreateRepository(t *testing.T) { + _ = f.NewRepository(t, t.Name()) +} + +func TestRun(t *testing.T) { + repo := f.NewRepository(t, t.Name()) + require.NoError(t, f.Run(t, repo, []string{})) +} diff --git a/test/e2e/framework.go b/test/e2e/framework.go new file mode 100644 index 0000000..7352f14 --- /dev/null +++ b/test/e2e/framework.go @@ -0,0 +1,96 @@ +package e2e + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/apricote/releaser-pleaser/cmd/rp/cmd" +) + +const ( + TestDefaultBranch = "main" +) + +func randomString() string { + randomBytes := make([]byte, 4) + if _, err := rand.Read(randomBytes); err != nil { + panic(err) + } + return hex.EncodeToString(randomBytes) +} + +type Framework struct { + runID string + forge TestForge +} + +func NewFramework(ctx context.Context, forge TestForge) (*Framework, error) { + f := &Framework{ + runID: randomString(), + forge: forge, + } + + err := forge.Init(ctx, f.runID) + if err != nil { + return nil, err + } + + return f, nil +} + +type Repository struct { + Name string +} + +func (f *Framework) NewRepository(t *testing.T, name string) *Repository { + t.Helper() + + r := &Repository{ + Name: fmt.Sprintf("%s-%s-%s", name, f.runID, randomString()), + } + + repo, err := f.forge.CreateRepo(t, CreateRepoOpts{ + Name: r.Name, + Description: name, + DefaultBranch: TestDefaultBranch, + }) + require.NoError(t, err) + require.NotNil(t, repo) + + return r +} + +func (f *Framework) Run(t *testing.T, r *Repository, extraFiles []string) error { + t.Helper() + + ctx := t.Context() + + rootCmd := cmd.NewRootCmd() + rootCmd.SetArgs(append([]string{ + "run", + fmt.Sprintf("--repo=%s", r.Name), + fmt.Sprintf("--extra-files=%q", strings.Join(extraFiles, "\n")), + }, f.forge.RunArguments()...)) + + var stdout, stderr bytes.Buffer + + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&stderr) + + err := rootCmd.ExecuteContext(ctx) + + stdoutString := stdout.String() + stderrString := stderr.String() + + t.Log(stdoutString) + t.Log(stderrString) + + return err +}