Compare commits

..

No commits in common. "main" and "v0.2.0-beta.2" have entirely different histories.

120 changed files with 3221 additions and 7037 deletions

1
.gitattributes vendored
View file

@ -1 +0,0 @@
*.png filter=lfs diff=lfs merge=lfs -text

View file

@ -1,86 +0,0 @@
{
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: [
': (?<currentValue>.+) # renovate: datasource=(?<datasource>[a-z-]+) depName=(?<depName>[^\\s]+)(?: lookupName=(?<packageName>[^\\s]+))?(?: versioning=(?<versioning>[a-z-]+))?(?: extractVersion=(?<extractVersion>[^\\s]+))?',
],
},
{
customType: 'regex',
managerFilePatterns: [
'/.+\\.toml$/'
],
matchStrings: [
'= "(?<currentValue>.+)" # renovate: datasource=(?<datasource>[a-z-]+) depName=(?<depName>[^\\s]+)(?: lookupName=(?<packageName>[^\\s]+))?(?: versioning=(?<versioning>[a-z-]+))?(?: extractVersion=(?<extractVersion>[^\\s]+))?',
],
}
],
postUpdateOptions: [
'gomodUpdateImportPaths',
'gomodTidy',
],
}

View file

@ -2,76 +2,58 @@ name: ci
on:
push:
branches: [ main ]
branches: [main]
pull_request:
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@v4
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run golangci-lint
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8
uses: golangci/golangci-lint-action@v6
with:
install-mode: none
version: v1.60.1 # renovate: datasource=github-releases depName=golangci/golangci-lint
args: --timeout 5m
test:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@v4
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run tests
run: go test -v -race -coverpkg=./... -coverprofile=coverage.txt ./...
- name: Upload results to Codecov
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
uses: codecov/codecov-action@v4
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.<job>.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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@v4
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- name: Run go mod tidy
run: go mod tidy

View file

@ -1,37 +0,0 @@
name: docs
on:
push:
branches:
- main
jobs:
deploy:
runs-on: ubuntu-latest
permissions:
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@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
lfs: "true"
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
- name: Build Book
working-directory: docs
run: mdbook build
- name: Setup Pages
uses: actions/configure-pages@983d7736d9b0ae728b81ab479565c72886d7745b # v5
- name: Upload artifact
uses: actions/upload-pages-artifact@7b1f4a764d45c48632c6b24a0339c27f5614fb0b # v4
with:
# Upload entire repository
path: "docs/book"
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4

View file

@ -1,30 +0,0 @@
name: mirror
on:
push:
branches: [main]
tags: ["*"]
jobs:
gitlab-com:
runs-on: ubuntu-latest
env:
REMOTE: mirror
steps:
- name: Checkout
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:
CLONE_URL: "https://releaser-pleaser:${{ secrets.GITLAB_COM_PUSH_TOKEN }}@gitlab.com/apricote/releaser-pleaser.git"
run: git remote add $REMOTE $CLONE_URL
- name: Push Branches
run: git push --force --all --verbose $REMOTE
- name: Push Tags
run: git push --force --tags --verbose $REMOTE

View file

@ -14,16 +14,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
uses: actions/checkout@v4
- 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
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version-file: go.mod
- uses: ko-build/setup-ko@v0.7
- run: ko build --bare --tags ${{ github.ref_name }} github.com/apricote/releaser-pleaser/cmd/rp

View file

@ -2,47 +2,23 @@ name: releaser-pleaser
on:
push:
branches: [ main ]
# Using pull_request_target to avoid tainting the actual release PR with code from open feature pull requests
pull_request_target:
branches: [main]
pull_request:
types:
- edited
- labeled
- unlabeled
# 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: { }
permissions: {}
jobs:
releaser-pleaser:
# TODO: if: push or pull_request.closed
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
with:
ref: main
uses: actions/checkout@v4
- 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.
- run: ko build --bare --local --platform linux/amd64 --tags ci github.com/apricote/releaser-pleaser/cmd/rp
- 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
uses: ./
with:
token: ${{ secrets.RELEASER_PLEASER_TOKEN }}
extra-files: |
action.yml
templates/run.yml

View file

@ -1,15 +0,0 @@
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.
# As the primary tagging happens on GitHub, we only react to pushed tags
# and create a corresponding GitLab Release.
create-release:
stage: release
image: registry.gitlab.com/gitlab-org/release-cli:v0.24.0
script: echo "Creating release $CI_COMMIT_TAG"
rules:
- if: $CI_COMMIT_TAG
release:
tag_name: "$CI_COMMIT_TAG"
description: "$CI_COMMIT_TAG_MESSAGE"

View file

@ -1,38 +1,14 @@
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
@ -42,23 +18,10 @@ linters:
# preset import
- depguard
settings:
revive:
rules:
- name: exported
disabled: true
linters-settings:
gci:
sections:
- standard
- default
- localmodule
gomoddirectives:
replace-allow-list:
- codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2
formatters:
enable:
- gci
- goimports
settings:
gci:
sections:
- standard
- default
- localmodule

View file

@ -1,6 +1,3 @@
defaultPlatforms:
- linux/arm64
- linux/amd64
# Need a shell for gitlab-ci
defaultBaseImage: cgr.dev/chainguard/busybox
- linux/amd64

View file

@ -1,189 +1,6 @@
# 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 &lt;&gt;`. 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
- add shell to container image (#59)
- **gitlab**: add CI/CD component (#55)
### Bug Fixes
- multiple extra-files are not evaluated properly (#61)
## [v0.4.0-beta.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.0-beta.0)
### Features
- add support for GitLab repositories (#49)
### Bug Fixes
- **parser**: continue on unparsable commit message (#48)
- **cli**: command name in help output (#52)
- **parser**: invalid handling of empty lines (#53)
## [v0.3.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.3.0)
### :sparkles: Highlights
#### Cleaner pre-release Release Notes
From now on if you create multiple pre-releases in a row, the release notes will only include changes since the last pre-release. Once you decide to create a stable release, the release notes will be in comparison to the last stable release.
#### Edit pull request after merging.
You can now edit the message for a pull request after merging by adding a \```rp-commits code block into the pull request body. Learn more in the [Release Notes Guide](https://apricote.github.io/releaser-pleaser/guides/release-notes.html#editing-the-release-notes).
### Features
- less repetitive entries for prerelease changelogs #37
- format markdown in changelog entry (#41)
- edit commit message after merging through PR (#43)
- **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)
- **cli**: add --version flag (#29)
### Bug Fixes
- **ci**: building release image fails (#21)
- **ci**: ko pipeline permissions (#23)
- **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)
@ -194,7 +11,6 @@ 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)
@ -204,14 +20,13 @@ 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

View file

@ -1,14 +1,6 @@
# releaser-pleaser
<p align="center">
<code>releaser-pleaser</code> is a tool designed to automate versioning and changelog management for your projects. Building on the concepts of <a href="https://github.com/googleapis/release-please"><code>release-please</code></a>, it streamlines the release process through GitHub Actions or GitLab CI.
</p>
<p align="center">
<a href="https://apricote.github.io/releaser-pleaser" target="_blank"><img src="https://img.shields.io/badge/Documentation-brightgreen?style=flat-square" alt="Badge: Documentation"/></a>
<a href="https://github.com/apricote/releaser-pleaser/releases" target="_blank"><img src="https://img.shields.io/github/v/release/apricote/releaser-pleaser?sort=semver&display_name=release&style=flat-square&color=green" alt="Badge: Stable Release"/></a>
<img src="https://img.shields.io/badge/License-GPL--3.0-green?style=flat-square" alt="Badge: License GPL-3.0"/>
</p>
`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.
## Features
@ -22,20 +14,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

View file

@ -5,33 +5,26 @@ branding:
icon: 'package'
color: 'red'
inputs:
# Remember to update docs/reference/github-action.md
branch:
default: main
description: "This branch is used as the target for releases."
token:
description: 'GitHub token for creating and updating release PRs, defaults to using secrets.GITHUB_TOKEN'
description: 'GitHub token for creating and grooming release PRs, defaults to using secrets.GITHUB_TOKEN'
required: false
default: ${{ github.token }}
extra-files:
description: 'List of files that are scanned for version references by the generic updater.'
description: 'List of files that are scanned for version references.'
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: docker://ghcr.io/apricote/releaser-pleaser:v0.7.1 # x-releaser-pleaser-version
image: ghcr.io/apricote/releaser-pleaser:v0.2.0-beta.2 # 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_TOKEN: ${{ inputs.token }}
GITHUB_USER: "oauth2"

58
changelog.go Normal file
View file

@ -0,0 +1,58 @@
package rp
import (
"bytes"
_ "embed"
"html/template"
"log"
)
const (
ChangelogFile = "CHANGELOG.md"
ChangelogHeader = "# Changelog"
)
var (
changelogTemplate *template.Template
)
//go:embed changelog.md.tpl
var rawChangelogTemplate string
func init() {
var err error
changelogTemplate, err = template.New("changelog").Parse(rawChangelogTemplate)
if err != nil {
log.Fatalf("failed to parse changelog template: %v", err)
}
}
func NewChangelogEntry(commits []AnalyzedCommit, version, link, prefix, suffix string) (string, error) {
features := make([]AnalyzedCommit, 0)
fixes := make([]AnalyzedCommit, 0)
for _, commit := range commits {
switch commit.Type {
case "feat":
features = append(features, commit)
case "fix":
fixes = append(fixes, commit)
}
}
var changelog bytes.Buffer
err := changelogTemplate.Execute(&changelog, map[string]any{
"Features": features,
"Fixes": fixes,
"Version": version,
"VersionLink": link,
"Prefix": prefix,
"Suffix": suffix,
})
if err != nil {
return "", err
}
return changelog.String(), nil
}

22
changelog.md.tpl Normal file
View file

@ -0,0 +1,22 @@
## [{{.Version}}]({{.VersionLink}})
{{- if .Prefix }}
{{ .Prefix }}
{{ end -}}
{{- if (gt (len .Features) 0) }}
### Features
{{ range .Features -}}
- {{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}}
{{ end -}}
{{- end -}}
{{- if (gt (len .Fixes) 0) }}
### Bug Fixes
{{ range .Fixes -}}
- {{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}}
{{ end -}}
{{- end -}}
{{- if .Suffix }}
{{ .Suffix }}
{{ end -}}

View file

@ -1,14 +1,9 @@
package changelog
package rp
import (
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
"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 {
@ -17,7 +12,7 @@ func ptr[T any](input T) *T {
func Test_NewChangelogEntry(t *testing.T) {
type args struct {
analyzedCommits []commitparser.AnalyzedCommit
analyzedCommits []AnalyzedCommit
version string
link string
prefix string
@ -32,19 +27,19 @@ func Test_NewChangelogEntry(t *testing.T) {
{
name: "empty",
args: args{
analyzedCommits: []commitparser.AnalyzedCommit{},
analyzedCommits: []AnalyzedCommit{},
version: "1.0.0",
link: "https://example.com/1.0.0",
},
want: "## [1.0.0](https://example.com/1.0.0)\n",
want: "## [1.0.0](https://example.com/1.0.0)",
wantErr: assert.NoError,
},
{
name: "single feature",
args: args{
analyzedCommits: []commitparser.AnalyzedCommit{
analyzedCommits: []AnalyzedCommit{
{
Commit: git.Commit{},
Commit: Commit{},
Type: "feat",
Description: "Foobar!",
},
@ -52,32 +47,15 @@ func Test_NewChangelogEntry(t *testing.T) {
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- 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",
want: "## [1.0.0](https://example.com/1.0.0)\n### Features\n\n- Foobar!\n",
wantErr: assert.NoError,
},
{
name: "single fix",
args: args{
analyzedCommits: []commitparser.AnalyzedCommit{
analyzedCommits: []AnalyzedCommit{
{
Commit: git.Commit{},
Commit: Commit{},
Type: "fix",
Description: "Foobar!",
},
@ -85,31 +63,31 @@ func Test_NewChangelogEntry(t *testing.T) {
version: "1.0.0",
link: "https://example.com/1.0.0",
},
want: "## [1.0.0](https://example.com/1.0.0)\n\n### Bug Fixes\n\n- Foobar!\n",
want: "## [1.0.0](https://example.com/1.0.0)\n### Bug Fixes\n\n- Foobar!\n",
wantErr: assert.NoError,
},
{
name: "multiple commits with scopes",
args: args{
analyzedCommits: []commitparser.AnalyzedCommit{
analyzedCommits: []AnalyzedCommit{
{
Commit: git.Commit{},
Commit: Commit{},
Type: "feat",
Description: "Blabla!",
},
{
Commit: git.Commit{},
Commit: Commit{},
Type: "feat",
Description: "So awesome!",
Scope: ptr("awesome"),
},
{
Commit: git.Commit{},
Commit: Commit{},
Type: "fix",
Description: "Foobar!",
},
{
Commit: git.Commit{},
Commit: Commit{},
Type: "fix",
Description: "So sad!",
Scope: ptr("sad"),
@ -119,7 +97,6 @@ func Test_NewChangelogEntry(t *testing.T) {
link: "https://example.com/1.0.0",
},
want: `## [1.0.0](https://example.com/1.0.0)
### Features
- Blabla!
@ -135,43 +112,56 @@ func Test_NewChangelogEntry(t *testing.T) {
{
name: "prefix",
args: args{
analyzedCommits: []commitparser.AnalyzedCommit{
analyzedCommits: []AnalyzedCommit{
{
Commit: git.Commit{},
Commit: Commit{},
Type: "fix",
Description: "Foobar!",
},
},
version: "1.0.0",
link: "https://example.com/1.0.0",
prefix: testdata.MustReadFileString(t, "prefix.txt"),
prefix: "### Breaking Changes",
},
want: testdata.MustReadFileString(t, "changelog-entry-prefix.txt"),
want: `## [1.0.0](https://example.com/1.0.0)
### Breaking Changes
### Bug Fixes
- Foobar!
`,
wantErr: assert.NoError,
},
{
name: "suffix",
args: args{
analyzedCommits: []commitparser.AnalyzedCommit{
analyzedCommits: []AnalyzedCommit{
{
Commit: git.Commit{},
Commit: Commit{},
Type: "fix",
Description: "Foobar!",
},
},
version: "1.0.0",
link: "https://example.com/1.0.0",
suffix: testdata.MustReadFileString(t, "suffix.txt"),
suffix: "### Compatibility\n\nThis version is compatible with flux-compensator v2.2 - v2.9.",
},
want: testdata.MustReadFileString(t, "changelog-entry-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.
`,
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
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{})
got, err := NewChangelogEntry(tt.args.analyzedCommits, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix)
if !tt.wantErr(t, err) {
return
}

View file

@ -1,80 +1,50 @@
package cmd
import (
"context"
"log/slog"
"os"
"os/signal"
"runtime/debug"
"syscall"
"github.com/spf13/cobra"
)
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 logger *slog.Logger
cmd.AddCommand(newRunCommand())
// rootCmd represents the base command when called without any subcommands
var rootCmd = &cobra.Command{
Use: "releaser-pleaser",
Short: "A brief description of your application",
Long: `A longer description that spans multiple lines and likely contains
examples and usage of using your application. For example:
return cmd
}
func version() string {
vcsrevision := "unknown"
vcsdirty := ""
buildInfo, ok := debug.ReadBuildInfo()
if ok {
for _, setting := range buildInfo.Settings {
switch setting.Key {
case "vcs.revision":
vcsrevision = setting.Value
case "vcs.modified":
if setting.Value == "true" {
vcsdirty = " (dirty)"
}
}
}
}
return vcsrevision + vcsdirty
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.`,
// Uncomment the following line if your bare application
// has an action associated with it:
// Run: func(cmd *cobra.Command, args []string) { },
}
// Execute adds all child commands to the root command and sets flags appropriately.
// This is called by main.main(). It only needs to happen once to the rootCmd.
func 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)
err := rootCmd.Execute()
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,
}))
// Here you will define your flags and configuration settings.
// Cobra supports persistent flags, which, if defined here,
// will be global for your application.
// rootCmd.PersistentFlags().StringVar(&cfgFile, "config", "", "config file (default is $HOME/.releaser-pleaser.yaml)")
// Cobra also supports local flags, which will only run
// when this action is called directly.
rootCmd.Flags().BoolP("toggle", "t", false, "Help message for toggle")
}

View file

@ -1,149 +1,84 @@
package cmd
import (
"fmt"
"log/slog"
"slices"
"strings"
"github.com/spf13/cobra"
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"
)
func newRunCommand() *cobra.Command {
var (
flagForge string
flagBranch string
flagOwner string
flagRepo string
flagExtraFiles string
flagUpdaters []string
// runCmd represents the run command
var runCmd = &cobra.Command{
Use: "run",
RunE: run,
}
flagAPIURL string
flagAPIToken string
flagUsername string
var (
flagForge string
flagBranch string
flagOwner string
flagRepo string
flagExtraFiles string
)
func init() {
rootCmd.AddCommand(runCmd)
// Here you will define your flags and configuration settings.
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()
logger.DebugContext(ctx, "run called",
"forge", flagForge,
"branch", flagBranch,
"owner", flagOwner,
"repo", flagRepo,
)
var cmd = &cobra.Command{
Use: "run",
RunE: func(cmd *cobra.Command, _ []string) error {
ctx := cmd.Context()
logger := log.GetLogger(cmd.ErrOrStderr())
var forge rp.Forge
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)
},
forgeOptions := rp.ForgeOptions{
Repository: flagRepo,
BaseBranch: flagBranch,
}
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{}, "")
switch flagForge { // nolint:gocritic // Will become a proper switch once gitlab is added
// case "gitlab":
// f = rp.NewGitLab(forgeOptions)
case "github":
logger.DebugContext(ctx, "using forge GitHub")
forge = rp.NewGitHub(logger, &rp.GitHubOptions{
ForgeOptions: forgeOptions,
Owner: flagOwner,
Repo: flagRepo,
})
}
cmd.PersistentFlags().StringVar(&flagAPIURL, "api-url", "", "")
cmd.PersistentFlags().StringVar(&flagAPIToken, "api-token", "", "")
cmd.PersistentFlags().StringVar(&flagUsername, "username", "", "")
extraFiles := parseExtraFiles(flagExtraFiles)
return cmd
releaserPleaser := rp.New(
forge,
logger,
flagBranch,
rp.NewConventionalCommitsParser(),
rp.SemVerNextVersion,
extraFiles,
[]rp.Updater{&rp.GenericUpdater{}},
)
return releaserPleaser.Run(ctx)
}
func parseExtraFiles(input string) []string {
// We quote the arg to avoid issues with the expected newlines in the value.
// Need to remove those quotes before parsing the data
input = strings.Trim(input, `"`)
// In some situations we get a "\n" sequence, where we actually expect new lines,
// replace the two characters with an actual new line
input = strings.ReplaceAll(input, `\n`, "\n")
lines := strings.Split(input, "\n")
extraFiles := make([]string, 0, len(lines))
@ -156,26 +91,3 @@ 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
}

View file

@ -1,104 +0,0 @@
package cmd
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_parseExtraFiles(t *testing.T) {
tests := []struct {
name string
input string
want []string
}{
{
name: "empty",
input: ``,
want: []string{},
},
{
name: "empty quoted",
input: `""`,
want: []string{},
},
{
name: "single",
input: `foo.txt`,
want: []string{"foo.txt"},
},
{
name: "single quoted",
input: `"foo.txt"`,
want: []string{"foo.txt"},
},
{
name: "multiple",
input: `foo.txt
dir/Chart.yaml`,
want: []string{"foo.txt", "dir/Chart.yaml"},
},
{
name: "multiple quoted",
input: `"foo.txt
dir/Chart.yaml"`,
want: []string{"foo.txt", "dir/Chart.yaml"},
},
{
name: "multiple with broken new lines",
input: `"action.yml\ntemplates/run.yml\n"`,
want: []string{"action.yml", "templates/run.yml"},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := parseExtraFiles(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
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)
})
}
}

View file

@ -2,7 +2,6 @@ package main
import (
"github.com/apricote/releaser-pleaser/cmd/rp/cmd"
_ "github.com/apricote/releaser-pleaser/internal/log"
)
func main() {

View file

@ -1,2 +0,0 @@
ignore:
- "test"

78
commits.go Normal file
View file

@ -0,0 +1,78 @@
package rp
import (
"fmt"
"github.com/leodido/go-conventionalcommits"
"github.com/leodido/go-conventionalcommits/parser"
)
type Commit struct {
Hash string
Message string
PullRequest *PullRequest
}
type PullRequest struct {
ID int
Title string
Description string
}
type AnalyzedCommit struct {
Commit
Type string
Description string
Scope *string
BreakingChange bool
}
type CommitParser interface {
Analyze(commits []Commit) ([]AnalyzedCommit, error)
}
type ConventionalCommitsParser struct {
machine conventionalcommits.Machine
}
func NewConventionalCommitsParser() *ConventionalCommitsParser {
parserMachine := parser.NewMachine(
parser.WithBestEffort(),
parser.WithTypes(conventionalcommits.TypesConventional),
)
return &ConventionalCommitsParser{
machine: parserMachine,
}
}
func (c *ConventionalCommitsParser) Analyze(commits []Commit) ([]AnalyzedCommit, error) {
analyzedCommits := make([]AnalyzedCommit, 0, len(commits))
for _, commit := range commits {
msg, err := c.machine.Parse([]byte(commit.Message))
if err != nil {
return nil, fmt.Errorf("failed to parse message of commit %q: %w", commit.Hash, err)
}
conventionalCommit, ok := msg.(*conventionalcommits.ConventionalCommit)
if !ok {
return nil, fmt.Errorf("unable to get ConventionalCommit from parser result: %T", msg)
}
commitVersionBump := conventionalCommit.VersionBump(conventionalcommits.DefaultStrategy)
if commitVersionBump > conventionalcommits.UnknownVersion {
// We only care about releasable commits
analyzedCommits = append(analyzedCommits, AnalyzedCommit{
Commit: commit,
Type: conventionalCommit.Type,
Description: conventionalCommit.Description,
Scope: conventionalCommit.Scope,
BreakingChange: conventionalCommit.IsBreakingChange(),
})
}
}
return analyzedCommits, nil
}

122
commits_test.go Normal file
View file

@ -0,0 +1,122 @@
package rp
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestAnalyzeCommits(t *testing.T) {
tests := []struct {
name string
commits []Commit
expectedCommits []AnalyzedCommit
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty commits",
commits: []Commit{},
expectedCommits: []AnalyzedCommit{},
wantErr: assert.NoError,
},
{
name: "malformed commit message",
commits: []Commit{
{
Message: "aksdjaklsdjka",
},
},
expectedCommits: nil,
wantErr: assert.Error,
},
{
name: "drops unreleasable",
commits: []Commit{
{
Message: "chore: foobar",
},
},
expectedCommits: []AnalyzedCommit{},
wantErr: assert.NoError,
},
{
name: "highest bump (patch)",
commits: []Commit{
{
Message: "chore: foobar",
},
{
Message: "fix: blabla",
},
},
expectedCommits: []AnalyzedCommit{
{
Commit: Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
},
wantErr: assert.NoError,
},
{
name: "highest bump (minor)",
commits: []Commit{
{
Message: "fix: blabla",
},
{
Message: "feat: foobar",
},
},
expectedCommits: []AnalyzedCommit{
{
Commit: Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
{
Commit: Commit{Message: "feat: foobar"},
Type: "feat",
Description: "foobar",
},
},
wantErr: assert.NoError,
},
{
name: "highest bump (major)",
commits: []Commit{
{
Message: "fix: blabla",
},
{
Message: "feat!: foobar",
},
},
expectedCommits: []AnalyzedCommit{
{
Commit: Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
{
Commit: Commit{Message: "feat!: foobar"},
Type: "feat",
Description: "foobar",
BreakingChange: true,
},
},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
analyzedCommits, err := NewConventionalCommitsParser().Analyze(tt.commits)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.expectedCommits, analyzedCommits)
})
}
}

1
docs/.gitignore vendored
View file

@ -1 +0,0 @@
book

View file

@ -1,32 +0,0 @@
# Summary
[Introduction](introduction.md)
# Tutorials
- [Getting started on GitHub](tutorials/github.md)
- [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/CD Component](reference/gitlab-cicd-component.md)
- [Updaters](reference/updaters.md)
---
[Changelog](changelog.md)

View file

@ -1,8 +0,0 @@
[book]
language = "en"
multilingual = false
src = "."
title = "releaser-pleaser"
[output.html]
git-repository-url = "https://github.com/apricote/releaser-pleaser"

View file

@ -1,3 +0,0 @@
# Changelog
{{#include ../CHANGELOG.md:2: }}

View file

@ -1,65 +0,0 @@
# 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)

View file

@ -1,21 +0,0 @@
# Release Pull Request
A _release pull request_ is opened by `releaser-pleaser` when it detects that there are _releasable changes_.
The pull request contains an _auto-generated Changelog_ and a _suggested next version_.
Once someone merges this pull request, `releaser-pleaser` will create a matching Git Tag and Release on GitHub/GitLab.
Maintainers can fill various fields in the pull request description and through labels to change the proposed release. Some examples of this are: _Changelog Prefix & Suffix text_ and _requesting a pre-release_ (`alpha`, `beta`, `rc`) version.
The pull request is automatically updated by `releaser-pleaser` every time it runs.
### Example Screenshot
![Screenshot of an example Release Pull Request on GitHub](./release-pr.png)
## Related Documentation
- **Guide**
- [Pre-releases](../guides/pre-releases.md)
- [Release Notes](../guides/release-notes.md)
- **Reference**
- [Pull Request Options](../reference/pr-options.md)

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:86cd4b5e6a24a1f77bfa882ea330c8af7f88967011a7adab7e24c236104cefe5
size 96399

View file

@ -1,83 +0,0 @@
# Workflow Permissions on GitHub
## Default GitHub token permissions
The [GitHub](../tutorials/github.md) tutorial uses the builtin `GITHUB_TOKEN` for the action to get access to the repository. It uses the following permissions on the token:
```yaml
jobs:
releaser-pleaser:
permissions:
# - list commits
# - push commits for the release pull request
# - push new releases & tags
contents: write
# - read pull requests for Changelog
# - read and write release pull request
# - create labels on the repository
pull-requests: write
```
These permissions are sufficient for simple operations. But fail if you want to run another workflow on `push: tag`.
## Workflows on Tag Push
When using the automatic `GITHUB_TOKEN` to create tags, GitHub does not create new workflow runs that are supposed to be created. This is done to prevent the user from "accidentally creating recursive workflow runs". You can read more about this behaviour in the [GitHub Actions docs](https://docs.github.com/en/actions/security-for-github-actions/security-guides/automatic-token-authentication#using-the-github_token-in-a-workflow).
Workflows that have a trigger on pushed tags are often used to build artifacts for the release, like binaries or container images.
```yaml
on:
push:
tags:
- "v*.*.*"
```
To circumvent this restriction, you can create a personal access token and instruct `releaser-pleaser` to use that instead to talk to GitHub.
### 1. Create Personal Access Token
On your account settings, navigate to the [Personal access tokens](https://github.com/settings/tokens?type=beta) section.
You can either use a "Fine-grained" or "Classic" token for this. Fine-grained tokens can be restricted to specific actions and repositories and are more secure because of this. On the other hand they have a mandatory expiration of 1 year maximum. Classic tokens have unrestricted access to your account, but do not expire.
Copy the token for the next step.
#### Fine-grained token
When you create a fine-grained token, restrict the access to the repository where you are using `releaser-pleaser`.
In the **repository permissions** you need to give **read and write** access for **Contents** and **Pull requests**. All other permissions can be set to **No access** (default).
No **account permissions** are required and you can set all to **No access** (default).
### 2. Repository Secret
Next you need to add the personal access token as a repository secret.
Open the repository settings to **Secrets and variables > Actions**:
> `https://github.com/YOUR-NAME/YOUR-REPO/settings/secrets/actions`
Click on **New repository secret** and add the personal access token to a secret named `RELEASER_PLEASER_TOKEN`.
### 3. Update Workflow
Update the workflow file (`.github/workflows/releaser-pleaser.yaml`) to pass the new secret to the `releaser-pleaser` action. You can also remove the permissions of the job, as they are now unused.
```diff
jobs:
releaser-pleaser:
runs-on: ubuntu-latest # The action uses docker containers
- permissions:
- contents: write
- pull-requests: write
steps:
- name: releaser-pleaser
uses: apricote/releaser-pleaser@v0.2.0
+ with:
+ token: ${{ secrets.RELEASER_PLEASER_TOKEN }}
```
The next release created by releaser-pleaser will now create the follow-up workflows as expected.

View file

@ -1,38 +0,0 @@
# Pre-releases
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.
## Creating a pre-release
If you want to create a pre-release, you can set **one** of the following labels on the release pull request:
- `rp-next-version::alpha`
- `rp-next-version::beta`
- `rp-next-version::rc`
This will cause `releaser-pleaser` to run, and it will change the release pull request to a matching version according to the type of pre-release.
## Versioning
For pre-releases, `releaser-pleaser` analyzes the commits made since the **last stable release**. The version bump from this is then applied to the last stable release and the pre-release info is added to the version number. If a previous pre-release of the matching type exists, the "pre-release counter" at the end of the version is increased by one.
An examples:
- The last stable version was `v1.0.0`
- Since then a `feat` commit was merged, this causes a bump of the minor version: `v1.1.0`
- The release pull request has the label `rp-next-version::beta`. This changes the suggested version to `v1.1.0-beta.0`
If there was already a `v1.1.0-beta.0`, then the suggested version would be `v1.1.0-beta.1`.
Changing the pre-release type (for example from `beta` to `rc`), resets the counter. `v1.1.0-beta.1` would be followed by `v1.1.0-rc.0`.
## Stable Release
`releaser-pleaser` ignores pre-releases when looking for releasable commits. This means that right after creating a new pre-release, `releaser-pleaser` again detects releasable commits and opens a new release pull request for the stable version.
## Related Documentation
- **Reference**
- [Pull Request Options](../reference/pr-options.md)

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:74f5f39210bdf9c55f7cec64d4b12465b569646efdfbb2e3eb08b32277cd5145
size 3357

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:9c28226eaa769033a45ca801f1e0655178faf86e7ddd764f470ae79d72c4b3c2
size 62031

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:04ca48b3250862d282dd54e14c08f9273ada0a49d2300364601799c56b1f6d11
size 72105

View file

@ -1,81 +0,0 @@
# Customizing Release Notes
You can customize the generated Release Notes in two ways:
## For a single commit / pull request
### Editing the Release Notes
After merging a non-release pull request, you can still modify how it appears in the Release Notes.
To do this, add a code block named `rp-commits` in the pull request description. When this block is present, `releaser-pleaser` will use its content for generating Release Notes instead of the commit message. If the code block contains multiple lines, each line will be treated as if it came from separate pull requests. This is useful for pull requests that introduce multiple features or fix several bugs.
You can update the description at any time after merging the pull request but before merging the release pull request. `releaser-pleaser` will then re-run and update the suggested Release Notes accordingly.
> ```rp-commits
> feat(api): add movie endpoints
> feat(api): add cinema endpoints
> fix(db): invalid schema for actor model
> ```
Using GitHub as an example, the pull request you are trying to change the Release Notes for should look like this:
![Screenshot of a pull request page on GitHub. Currently editing the description of the pull request and adding the rp-commits snippet from above.](release-notes-rp-commits.png)
In turn, `releaser-pleaser` updates the release pull request like this:
![Screenshot of a release pull request on GitHub. It shows the release notes with the three commits from the rp-commits example.](release-notes-rp-commits-release-pr.png)
### Removing the pull request from the Release Notes
If you add an empty code block, the pull request will be removed from the Release Notes.
> ```rp-commits
> ```
## For the release
It is possible to add custom **prefix** and **suffix** Markdown-formatted text to the Release Notes.
The release pull request description has text fields where maintainers can add the prefix and suffix. To see these fields, toggle the collapsible section in the description:
![Screenshot of the collapsed section](./release-notes-collapsible.png)
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
> ### Prefix
>
> This will be shown as the Prefix.
> ~~~~
>
> ~~~~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`).
Once the description was updated `releaser-pleaser` automatically runs again and adds the prefix and suffix to the Release Notes and to the committed Changelog:
```markdown
## v1.1.0
### Prefix
This will be shown as the Prefix.
### Features
- Added cool new thing (#1)
### Suffix
This will be shown as the Suffix.
```
## Related Documentation
- **Reference**
- [Pull Request Options](../reference/pr-options.md)

View file

@ -1,67 +0,0 @@
# 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)

View file

@ -1,3 +0,0 @@
# Introduction
{{#include ../README.md:2:}}

View file

@ -1,28 +0,0 @@
# GitHub Action
## Reference
The action is available as `apricote/releaser-pleaser` on GitHub.com.
## Versions
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`.
## 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 by the generic updater. | `""` | <pre><code>version/version.go<br>deploy/deployment.yaml</code></pre> |
| `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
The action does not define any outputs.

View file

@ -1,28 +0,0 @@
# 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. | `""` | <pre><code>version/version.go<br>deploy/deployment.yaml</code></pre> |
| `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. | `[]` | <pre><code>- validate-foo<br>- prepare-bar</code></pre> |

View file

@ -1,61 +0,0 @@
# Glossary
### 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.
### 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.
### 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.
[^wp-forge]: Quote from [Wikipedia "Forge (software)"](<https://en.wikipedia.org/wiki/Forge_(software)>)
### Markdown
[Markdown](https://en.wikipedia.org/wiki/Markdown) is a lightweight markup language used on many [forges](#forge) as the preferred way to format text.
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 not considered "stable" and are usually not recommended for most users.
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.
Learn more in the [Release Pull Request](../explanation/release-pr.md) explanation.
### Release Notes
Release Notes describe the changes made to the repository since the last release. They are made available in the [Changelog](#changelog), in Git Tags and through the [forge](#forge)-native Releases.
Learn more in the [Release Notes customization](../guides/release-notes.md) guide.
### 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.
### 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.

View file

@ -1,75 +0,0 @@
# Pull Request Options
The proposed releases can by influenced by changing the description and labels of either the release pull request or the normal pull requests created by other developers. This document lists the available options for both types of pull requests.
## Release Pull Request
Created by `releaser-pleaser`.
### Release Type
**Labels**:
- `rp-next-version::alpha`
- `rp-next-version::beta`
- `rp-next-version::rc`
- `rp-next-version::normal`
Adding one of these labels will change the type of the next release to the one indicated in the label. This is used to create [pre-releases](../guides/pre-releases.md).
Adding more than one of these labels is not allowed and the behaviour if multiple labels are added is undefined.
### Release Notes
**Code Blocks**:
- `rp-prefix`
- `rp-suffix`
Any text in code blocks with these languages is being added to the start or end of the Release Notes and Changelog. Learn more in the [Release Notes](../guides/release-notes.md) guide.
**Examples**:
~~~~rp-prefix
#### Awesome new feature!
This text is at the start of the release notes.
~~~~
~~~~rp-suffix
#### Version Compatibility
And this at the end.
~~~~
### Status
**Labels**:
- `rp-release::pending`
- `rp-release::tagged`
These labels are automatically added by `releaser-pleaser` to release pull requests. They are used to track if the corresponding release was already created.
Users should not set these labels themselves.
## Other Pull Requests
Not created by `releaser-pleaser`.
### Release Notes
**Code Blocks**:
- `rp-commits`
If specified, `releaser-pleaser` will consider each line in the code block as a commit message and add all of them to the Release Notes. Learn more in the [Release Notes](../guides/release-notes.md) guide.
The types of commits (`feat`, `fix`, ...) are also considered for the next version.
**Examples**:
```rp-commits
feat(api): add movie endpoints
fix(db): invalid schema for actor model
```

View file

@ -1,33 +0,0 @@
# 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.

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:3958761dd6d324040566d361b832ffaa3aff30edf6ad4a007a1a4e5bd47c1f79
size 55818

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:6591829476e418131fe6bbd52aa9886cb49ef6521da4dab5e9d02de6837742d1
size 16430

View file

@ -1,76 +0,0 @@
# Getting started on GitHub
In this tutorial you will learn how to set up `releaser-pleaser` in your GitHub project with GitHub Actions.
## 1. Repository Settings
### 1.1. Squash Merging
`releaser-pleaser` requires you to use `squash` merging. With other merge options it can not reliably find the right pull request for every commit on `main`.
Open your repository settings to page _General_:
> `https://github.com/YOUR-NAME/YOUR-PROJECT/settings`
In the "Pull Requests" section make sure that only "Allow squash merging" is enabled and "Allow merge commits" and "Allow rebase merging" is disabled.
![Screenshot of the required merge settings](./github-settings-pr.png)
### 1.2. Workflow Permissions
`releaser-pleaser` creates [release pull requests](../explanation/release-pr.md) for you. By default, Actions are not allowed to create pull requests, so we need to enable this.
Open your repository settings to page _Actions > General_:
> `https://github.com/YOUR-NAME/YOUR-PROJECT/settings/actions`
In the "Workflow permissions" section make sure that "Allow GitHub Actions to create and approve pull requests" is enabled.
![Screenshot of the required workflow settings](./github-settings-workflow.png)
## 2. GitHub Actions Workflow
Create a new file `.github/workflows/releaser-pleaser.yaml` with this content. Make sure that it is available on the `main` branch.
```yaml
name: releaser-pleaser
on:
push:
branches: [main]
pull_request_target:
types:
- edited
- labeled
- unlabeled
concurrency:
group: releaser-pleaser
cancel-in-progress: true
jobs:
releaser-pleaser:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
steps:
- name: releaser-pleaser
uses: apricote/releaser-pleaser@v0.4.0
```
## 3. Release Pull Request
Once this 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 pull request for the proposed release.
Once you merge this pull request, `releaser-pleaser` automatically creates a Git tag and GitHub Release with the proposed version and changelog.
## Related Documentation
- **Explanation**
- [Release Pull Request](../explanation/release-pr.md)
- **Guide**
- [GitHub Workflow Permissions](../guides/github-workflow-permissions.md)
- **Reference**
- [GitHub Action](../reference/github-action.md)

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:31b485bbe031443c4bfa0d39514dc7e5d524925aa877848def93ee40f69a1897
size 146496

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:b853625854582a66ab2438f11e6001a88bcb276225abed536ba68617bde324db
size 57583

View file

@ -1,3 +0,0 @@
version https://git-lfs.github.com/spec/v1
oid sha256:0ce9b9826229851e961ef55d91cb9ba91ca9ca4d955a932d9ff6b10d04788c29
size 41048

View file

@ -1,94 +0,0 @@
# 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.
<div class="warning">
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).
</div>
## 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)

659
forge.go Normal file
View file

@ -0,0 +1,659 @@
package rp
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"slices"
"strings"
"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"
)
const (
GitHubPerPageMax = 100
GitHubPRStateOpen = "open"
GitHubPRStateClosed = "closed"
GitHubEnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential
GitHubEnvUsername = "GITHUB_USER"
GitHubEnvRepository = "GITHUB_REPOSITORY"
GitHubLabelColor = "dedede"
)
type Forge interface {
RepoURL() string
CloneURL() string
ReleaseURL(version string) string
GitAuth() transport.AuthMethod
// 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) (Releases, error)
// CommitsSince returns all commits to main branch after the Tag. The tag can be `nil`, in which case this
// function should return all commits.
CommitsSince(context.Context, *Tag) ([]Commit, error)
// EnsureLabelsExist verifies that all desired labels are available on the repository. If labels are missing, they
// are created them.
EnsureLabelsExist(context.Context, []Label) error
// PullRequestForBranch returns the open pull request between the branch and ForgeOptions.BaseBranch. If no open PR
// exists, it returns nil.
PullRequestForBranch(context.Context, string) (*ReleasePullRequest, error)
// CreatePullRequest opens a new pull/merge request for the ReleasePullRequest.
CreatePullRequest(context.Context, *ReleasePullRequest) error
// UpdatePullRequest updates the pull/merge request identified through the ID of
// the ReleasePullRequest to the current description and title.
UpdatePullRequest(context.Context, *ReleasePullRequest) error
// SetPullRequestLabels updates the pull/merge request identified through the ID of
// the ReleasePullRequest to the current labels.
SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []Label) error
// ClosePullRequest closes the pull/merge request identified through the ID of
// the ReleasePullRequest, as it is no longer required.
ClosePullRequest(context.Context, *ReleasePullRequest) error
// PendingReleases returns a list of ReleasePullRequest. The list should contain all pull/merge requests that are
// merged and have the matching label.
PendingReleases(context.Context, Label) ([]*ReleasePullRequest, error)
// CreateRelease creates a release on the Forge, pointing at the commit with the passed in details.
CreateRelease(ctx context.Context, commit Commit, title, changelog string, prerelease, latest bool) error
}
type ForgeOptions struct {
Repository string
BaseBranch string
}
var _ Forge = &GitHub{}
// var _ Forge = &GitLab{}
type GitHub struct {
options *GitHubOptions
client *github.Client
log *slog.Logger
}
func (g *GitHub) RepoURL() string {
return fmt.Sprintf("https://github.com/%s/%s", g.options.Owner, g.options.Repo)
}
func (g *GitHub) CloneURL() string {
return fmt.Sprintf("https://github.com/%s/%s.git", g.options.Owner, g.options.Repo)
}
func (g *GitHub) ReleaseURL(version string) string {
return fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", g.options.Owner, g.options.Repo, version)
}
func (g *GitHub) GitAuth() transport.AuthMethod {
return &http.BasicAuth{
Username: g.options.Username,
Password: g.options.APIToken,
}
}
func (g *GitHub) LatestTags(ctx context.Context) (Releases, error) {
g.log.DebugContext(ctx, "listing all tags in github repository")
page := 1
var releases Releases
for {
tags, resp, err := g.client.Repositories.ListTags(
ctx, g.options.Owner, g.options.Repo,
&github.ListOptions{Page: page, PerPage: GitHubPerPageMax},
)
if err != nil {
return Releases{}, err
}
for _, ghTag := range tags {
tag := &Tag{
Hash: ghTag.GetCommit().GetSHA(),
Name: ghTag.GetName(),
}
version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v"))
if err != nil {
g.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
}
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return releases, nil
}
func (g *GitHub) CommitsSince(ctx context.Context, tag *Tag) ([]Commit, error) {
var repositoryCommits []*github.RepositoryCommit
var err error
if tag != nil {
repositoryCommits, err = g.commitsSinceTag(ctx, tag)
} else {
repositoryCommits, err = g.commitsSinceInit(ctx)
}
if err != nil {
return nil, err
}
var commits = make([]Commit, 0, len(repositoryCommits))
for _, ghCommit := range repositoryCommits {
commit := Commit{
Hash: ghCommit.GetSHA(),
Message: ghCommit.GetCommit().GetMessage(),
}
commit.PullRequest, err = g.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 (g *GitHub) commitsSinceTag(ctx context.Context, tag *Tag) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch
log := g.log.With("base", tag.Hash, "head", head)
log.Debug("comparing commits", "base", tag.Hash, "head", head)
page := 1
var repositoryCommits []*github.RepositoryCommit
for {
log.Debug("fetching page", "page", page)
comparison, resp, err := g.client.Repositories.CompareCommits(
ctx, g.options.Owner, g.options.Repo,
tag.Hash, head, &github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
})
if err != nil {
return nil, err
}
if repositoryCommits == nil {
// Pre-initialize slice on first request
log.Debug("found commits", "length", comparison.GetTotalCommits())
repositoryCommits = make([]*github.RepositoryCommit, 0, comparison.GetTotalCommits())
}
repositoryCommits = append(repositoryCommits, comparison.Commits...)
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return repositoryCommits, nil
}
func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch
log := g.log.With("head", head)
log.Debug("listing all commits")
page := 1
var repositoryCommits []*github.RepositoryCommit
for {
log.Debug("fetching page", "page", page)
commits, resp, err := g.client.Repositories.ListCommits(
ctx, g.options.Owner, g.options.Repo,
&github.CommitsListOptions{
SHA: head,
ListOptions: github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
},
})
if err != nil {
return nil, err
}
if repositoryCommits == nil && resp.LastPage > 0 {
// Pre-initialize slice on first request
log.Debug("found commits", "pages", resp.LastPage)
repositoryCommits = make([]*github.RepositoryCommit, 0, resp.LastPage*GitHubPerPageMax)
}
repositoryCommits = append(repositoryCommits, commits...)
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return repositoryCommits, nil
}
func (g *GitHub) prForCommit(ctx context.Context, commit Commit) (*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.
log := g.log.With("commit.hash", commit.Hash)
page := 1
var associatedPRs []*github.PullRequest
for {
log.Debug("fetching pull requests associated with commit", "page", page)
prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(
ctx, g.options.Owner, g.options.Repo,
commit.Hash, &github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
})
if err != nil {
return nil, err
}
associatedPRs = append(associatedPRs, prs...)
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
var pullrequest *github.PullRequest
for _, pr := range associatedPRs {
// We only look for the PR that has this commit set as the "merge commit" => The result of squashing this branch onto main
if pr.GetMergeCommitSHA() == commit.Hash {
pullrequest = pr
break
}
}
if pullrequest == nil {
return nil, nil
}
return gitHubPRToPullRequest(pullrequest), nil
}
func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []Label) error {
existingLabels := make([]string, 0, len(labels))
page := 1
for {
g.log.Debug("fetching labels on repo", "page", page)
ghLabels, resp, err := g.client.Issues.ListLabels(
ctx, g.options.Owner, g.options.Repo,
&github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
})
if err != nil {
return err
}
for _, label := range ghLabels {
existingLabels = append(existingLabels, label.GetName())
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
for _, label := range labels {
if !slices.Contains(existingLabels, string(label)) {
g.log.Info("creating label in repository", "label.name", label)
_, _, err := g.client.Issues.CreateLabel(
ctx, g.options.Owner, g.options.Repo,
&github.Label{
Name: Pointer(string(label)),
Color: Pointer(GitHubLabelColor),
},
)
if err != nil {
return err
}
}
}
return nil
}
func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*ReleasePullRequest, error) {
page := 1
for {
prs, resp, err := g.client.PullRequests.ListPullRequestsWithCommit(ctx, g.options.Owner, g.options.Repo, branch, &github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
})
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) {
if ghErr.Message == fmt.Sprintf("No commit found for SHA: %s", branch) {
return nil, nil
}
}
return nil, err
}
for _, pr := range prs {
if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == GitHubPRStateOpen {
return gitHubPRToReleasePullRequest(pr), nil
}
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return nil, nil
}
func (g *GitHub) CreatePullRequest(ctx context.Context, pr *ReleasePullRequest) error {
ghPR, _, err := g.client.PullRequests.Create(
ctx, g.options.Owner, g.options.Repo,
&github.NewPullRequest{
Title: &pr.Title,
Head: &pr.Head,
Base: &g.options.BaseBranch,
Body: &pr.Description,
},
)
if err != nil {
return err
}
// TODO: String ID?
pr.ID = ghPR.GetNumber()
err = g.SetPullRequestLabels(ctx, pr, []Label{}, pr.Labels)
if err != nil {
return err
}
return nil
}
func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{
Title: &pr.Title,
Body: &pr.Description,
},
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *ReleasePullRequest, remove, add []Label) error {
for _, label := range remove {
_, err := g.client.Issues.RemoveLabelForIssue(
ctx, g.options.Owner, g.options.Repo,
pr.ID, string(label),
)
if err != nil {
return err
}
}
addString := make([]string, 0, len(add))
for _, label := range add {
addString = append(addString, string(label))
}
_, _, err := g.client.Issues.AddLabelsToIssue(
ctx, g.options.Owner, g.options.Repo,
pr.ID, addString,
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) ClosePullRequest(ctx context.Context, pr *ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{
State: Pointer(GitHubPRStateClosed),
},
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel Label) ([]*ReleasePullRequest, error) {
page := 1
var prs []*ReleasePullRequest
for {
ghPRs, resp, err := g.client.PullRequests.List(
ctx, g.options.Owner, g.options.Repo,
&github.PullRequestListOptions{
State: GitHubPRStateClosed,
Base: g.options.BaseBranch,
ListOptions: github.ListOptions{
Page: page,
PerPage: GitHubPerPageMax,
},
})
if err != nil {
return nil, err
}
if prs == nil && resp.LastPage > 0 {
// Pre-initialize slice on first request
g.log.Debug("found pending releases", "pages", resp.LastPage)
prs = make([]*ReleasePullRequest, 0, (resp.LastPage-1)*GitHubPerPageMax)
}
for _, pr := range ghPRs {
pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool {
return l.GetName() == string(pendingLabel)
})
if !pending {
continue
}
// pr.Merged is always nil :(
if pr.MergedAt == nil {
// Closed and not merged
continue
}
prs = append(prs, gitHubPRToReleasePullRequest(pr))
}
if page == resp.LastPage || resp.LastPage == 0 {
break
}
page = resp.NextPage
}
return prs, nil
}
func (g *GitHub) CreateRelease(ctx context.Context, commit Commit, title, changelog string, preRelease, latest bool) error {
makeLatest := ""
if latest {
makeLatest = "true"
} else {
makeLatest = "false"
}
_, _, err := g.client.Repositories.CreateRelease(
ctx, g.options.Owner, g.options.Repo,
&github.RepositoryRelease{
TagName: &title,
TargetCommitish: &commit.Hash,
Name: &title,
Body: &changelog,
Prerelease: &preRelease,
MakeLatest: &makeLatest,
},
)
if err != nil {
return err
}
return nil
}
func gitHubPRToPullRequest(pr *github.PullRequest) *PullRequest {
return &PullRequest{
ID: pr.GetNumber(),
Title: pr.GetTitle(),
Description: pr.GetBody(),
}
}
func gitHubPRToReleasePullRequest(pr *github.PullRequest) *ReleasePullRequest {
labels := make([]Label, 0, len(pr.Labels))
for _, label := range pr.Labels {
labelName := Label(label.GetName())
if slices.Contains(KnownLabels, Label(label.GetName())) {
labels = append(labels, labelName)
}
}
var releaseCommit *Commit
if pr.MergeCommitSHA != nil {
releaseCommit = &Commit{Hash: pr.GetMergeCommitSHA()}
}
return &ReleasePullRequest{
ID: pr.GetNumber(),
Title: pr.GetTitle(),
Description: pr.GetBody(),
Labels: labels,
Head: pr.GetHead().GetRef(),
ReleaseCommit: releaseCommit,
}
}
func (g *GitHubOptions) autodiscover() {
if apiToken := os.Getenv(GitHubEnvAPIToken); apiToken != "" {
g.APIToken = apiToken
}
// TODO: Check if there is a better solution for cloning/pushing locally
if username := os.Getenv(GitHubEnvUsername); username != "" {
g.Username = username
}
if envRepository := os.Getenv(GitHubEnvRepository); envRepository != "" {
// GITHUB_REPOSITORY=apricote/releaser-pleaser
parts := strings.Split(envRepository, "/")
if len(parts) == 2 {
g.Owner = parts[0]
g.Repo = parts[1]
g.Repository = envRepository
}
}
}
type GitHubOptions struct {
ForgeOptions
Owner string
Repo string
APIToken string
Username string
}
func NewGitHub(log *slog.Logger, options *GitHubOptions) *GitHub {
options.autodiscover()
client := github.NewClient(nil)
if options.APIToken != "" {
client = client.WithAuthToken(options.APIToken)
}
gh := &GitHub{
options: options,
client: client,
log: log.With("forge", "github"),
}
return gh
}
type GitLab struct {
options ForgeOptions
}
func (g *GitLab) autodiscover() {
// Read settings from GitLab-CI env vars
}
func NewGitLab(options ForgeOptions) *GitLab {
gl := &GitLab{
options: options,
}
gl.autodiscover()
return gl
}
func (g *GitLab) RepoURL() string {
return fmt.Sprintf("https://gitlab.com/%s", g.options.Repository)
}
func Pointer[T any](value T) *T {
return &value
}

52
git.go Normal file
View file

@ -0,0 +1,52 @@
package rp
import (
"context"
"fmt"
"os"
"time"
"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/plumbing/transport"
)
const (
GitRemoteName = "origin"
)
type Tag struct {
Hash string
Name string
}
func CloneRepo(ctx context.Context, cloneURL, branch string, auth transport.AuthMethod) (*git.Repository, error) {
dir, err := os.MkdirTemp("", "releaser-pleaser.*")
if err != nil {
return nil, fmt.Errorf("failed to create temporary directory for repo clone: %w", err)
}
// TODO: Log tmpdir
fmt.Printf("Clone tmpdir: %s\n", dir)
repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
URL: cloneURL,
RemoteName: GitRemoteName,
ReferenceName: plumbing.NewBranchReferenceName(branch),
SingleBranch: false,
Auth: auth,
})
if err != nil {
return nil, fmt.Errorf("failed to clone repository: %w", err)
}
return repo, nil
}
func GitSignature() *object.Signature {
return &object.Signature{
Name: "releaser-pleaser",
Email: "",
When: time.Now(),
}
}

1
git_test.go Normal file
View file

@ -0,0 +1 @@
package rp

51
go.mod
View file

@ -1,59 +1,42 @@
module github.com/apricote/releaser-pleaser
go 1.24.0
toolchain go1.25.5
go 1.23.0
require (
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
github.com/blang/semver/v4 v4.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/go-git/go-billy/v5 v5.5.0
github.com/go-git/go-git/v5 v5.12.0
github.com/google/go-github/v63 v63.0.0
github.com/leodido/go-conventionalcommits v0.12.0
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
github.com/spf13/cobra v1.8.1
github.com/stretchr/testify v1.9.0
github.com/yuin/goldmark v1.7.4
)
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.1.6 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // 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/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/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // 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.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.2 // indirect
github.com/pjbgf/sha1cd v0.3.0 // 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.1 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/skeema/knownhosts v1.3.0 // indirect
github.com/spf13/pflag v1.0.5 // indirect
github.com/xanzy/ssh-agent v0.3.3 // 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
golang.org/x/crypto v0.26.0 // indirect
golang.org/x/net v0.28.0 // indirect
golang.org/x/sys v0.24.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

171
go.sum
View file

@ -1,65 +1,49 @@
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.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
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/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/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/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/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/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/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/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.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/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
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.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
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-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.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/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/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
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-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-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.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=
@ -75,89 +59,96 @@ 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.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/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/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.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
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/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.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/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/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.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/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/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.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=
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=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
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-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
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/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-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
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.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/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/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/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.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
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/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
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/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/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
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.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/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/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=
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=

View file

@ -1,72 +0,0 @@
package changelog
import (
"bytes"
_ "embed"
"log"
"log/slog"
"text/template"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/markdown"
)
var (
changelogTemplate *template.Template
)
//go:embed changelog.md.tpl
var rawChangelogTemplate string
func init() {
var err error
changelogTemplate, err = template.New("changelog").Parse(rawChangelogTemplate)
if err != nil {
log.Fatalf("failed to parse changelog template: %v", err)
}
}
func DefaultTemplate() *template.Template {
return changelogTemplate
}
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 := tpl.Execute(&changelog, map[string]any{
"Data": data,
"Formatting": formatting,
})
if err != nil {
return "", err
}
formatted, err := markdown.Format(changelog.String())
if err != nil {
logger.Warn("failed to format changelog entry, using unformatted", "error", err)
return changelog.String(), nil
}
return formatted, nil
}

View file

@ -1,24 +0,0 @@
{{define "entry" -}}
- {{ if .BreakingChange}}**BREAKING**: {{end}}{{ if .Scope }}**{{.Scope}}**: {{end}}{{.Description}}
{{ end }}
{{- if not .Formatting.HideVersionTitle }}
## [{{.Data.Version}}]({{.Data.VersionLink}})
{{ end -}}
{{- if .Data.Prefix }}
{{ .Data.Prefix }}
{{ end -}}
{{- with .Data.Commits.feat }}
### Features
{{ range . -}}{{template "entry" .}}{{end}}
{{- end -}}
{{- with .Data.Commits.fix }}
### Bug Fixes
{{ range . -}}{{template "entry" .}}{{end}}
{{- end -}}
{{- if .Data.Suffix }}
{{ .Data.Suffix }}
{{ end }}

View file

@ -1,32 +0,0 @@
package commitparser
import (
"github.com/apricote/releaser-pleaser/internal/git"
)
type CommitParser interface {
Analyze(commits []git.Commit) ([]AnalyzedCommit, error)
}
type AnalyzedCommit struct {
git.Commit
Type string
Description string
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
}

View file

@ -1,72 +0,0 @@
package conventionalcommits
import (
"fmt"
"log/slog"
"strings"
"github.com/leodido/go-conventionalcommits"
"github.com/leodido/go-conventionalcommits/parser"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/git"
)
type Parser struct {
machine conventionalcommits.Machine
logger *slog.Logger
}
func NewParser(logger *slog.Logger) *Parser {
parserMachine := parser.NewMachine(
parser.WithBestEffort(),
parser.WithTypes(conventionalcommits.TypesConventional),
)
return &Parser{
machine: parserMachine,
logger: logger,
}
}
func (c *Parser) Analyze(commits []git.Commit) ([]commitparser.AnalyzedCommit, error) {
analyzedCommits := make([]commitparser.AnalyzedCommit, 0, len(commits))
for _, commit := range commits {
msg, err := c.machine.Parse([]byte(strings.TrimSpace(commit.Message)))
if err != nil {
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)
if !ok {
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
analyzedCommits = append(analyzedCommits, commitparser.AnalyzedCommit{
Commit: commit,
Type: conventionalCommit.Type,
Description: conventionalCommit.Description,
Scope: conventionalCommit.Scope,
BreakingChange: conventionalCommit.IsBreakingChange(),
})
}
}
return analyzedCommits, nil
}

View file

@ -1,157 +0,0 @@
package conventionalcommits
import (
"log/slog"
"testing"
"github.com/stretchr/testify/assert"
"github.com/apricote/releaser-pleaser/internal/commitparser"
"github.com/apricote/releaser-pleaser/internal/git"
)
func TestAnalyzeCommits(t *testing.T) {
tests := []struct {
name string
commits []git.Commit
expectedCommits []commitparser.AnalyzedCommit
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty commits",
commits: []git.Commit{},
expectedCommits: []commitparser.AnalyzedCommit{},
wantErr: assert.NoError,
},
{
name: "skips malformed commit message",
commits: []git.Commit{
{
Message: "aksdjaklsdjka",
},
},
expectedCommits: []commitparser.AnalyzedCommit{},
wantErr: assert.NoError,
},
{
// GitLab seems to create commits with pattern "scope: message\n" if no body is added.
// This has previously caused a parser error "missing a blank line".
// We added a workaround with `strings.TrimSpace()` and this test make sure that it does not break again.
name: "handles title with new line",
commits: []git.Commit{
{
Message: "aksdjaklsdjka",
},
},
expectedCommits: []commitparser.AnalyzedCommit{},
wantErr: assert.NoError,
},
{
name: "drops unreleasable",
commits: []git.Commit{
{
Message: "chore: foobar",
},
},
expectedCommits: []commitparser.AnalyzedCommit{},
wantErr: assert.NoError,
},
{
name: "highest bump (patch)",
commits: []git.Commit{
{
Message: "chore: foobar",
},
{
Message: "fix: blabla",
},
},
expectedCommits: []commitparser.AnalyzedCommit{
{
Commit: git.Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
},
wantErr: assert.NoError,
},
{
name: "highest bump (minor)",
commits: []git.Commit{
{
Message: "fix: blabla",
},
{
Message: "feat: foobar",
},
},
expectedCommits: []commitparser.AnalyzedCommit{
{
Commit: git.Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
{
Commit: git.Commit{Message: "feat: foobar"},
Type: "feat",
Description: "foobar",
},
},
wantErr: assert.NoError,
},
{
name: "highest bump (major)",
commits: []git.Commit{
{
Message: "fix: blabla",
},
{
Message: "feat!: foobar",
},
},
expectedCommits: []commitparser.AnalyzedCommit{
{
Commit: git.Commit{Message: "fix: blabla"},
Type: "fix",
Description: "blabla",
},
{
Commit: git.Commit{Message: "feat!: foobar"},
Type: "feat",
Description: "foobar",
BreakingChange: true,
},
},
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) {
analyzedCommits, err := NewParser(slog.Default()).Analyze(tt.commits)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.expectedCommits, analyzedCommits)
})
}
}

View file

@ -1,65 +0,0 @@
package forge
import (
"context"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/apricote/releaser-pleaser/internal/git"
"github.com/apricote/releaser-pleaser/internal/releasepr"
)
type Forge interface {
RepoURL() string
CloneURL() string
ReleaseURL(version string) string
PullRequestURL(id int) string
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)
// CommitsSince returns all commits to main branch after the Tag. The tag can be `nil`, in which case this
// function should return all commits.
CommitsSince(context.Context, *git.Tag) ([]git.Commit, error)
// EnsureLabelsExist verifies that all desired labels are available on the repository. If labels are missing, they
// are created them.
EnsureLabelsExist(context.Context, []releasepr.Label) error
// PullRequestForBranch returns the open pull request between the branch and Options.BaseBranch. If no open PR
// exists, it returns nil.
PullRequestForBranch(context.Context, string) (*releasepr.ReleasePullRequest, error)
// CreatePullRequest opens a new pull/merge request for the ReleasePullRequest.
CreatePullRequest(context.Context, *releasepr.ReleasePullRequest) error
// UpdatePullRequest updates the pull/merge request identified through the ID of
// the ReleasePullRequest to the current description and title.
UpdatePullRequest(context.Context, *releasepr.ReleasePullRequest) error
// SetPullRequestLabels updates the pull/merge request identified through the ID of
// the ReleasePullRequest to the current labels.
SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error
// ClosePullRequest closes the pull/merge request identified through the ID of
// the ReleasePullRequest, as it is no longer required.
ClosePullRequest(context.Context, *releasepr.ReleasePullRequest) error
// PendingReleases returns a list of ReleasePullRequest. The list should contain all pull/merge requests that are
// merged and have the matching label.
PendingReleases(context.Context, releasepr.Label) ([]*releasepr.ReleasePullRequest, error)
// CreateRelease creates a release on the Forge, pointing at the commit with the passed in details.
CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, prerelease, latest bool) error
}
type Options struct {
Repository string
BaseBranch string
}

View file

@ -1,529 +0,0 @@
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
}

View file

@ -1,544 +0,0 @@
package github
import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"slices"
"strings"
"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/v74/github"
"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 (
PerPageMax = 100
PRStateOpen = "open"
PRStateClosed = "closed"
EnvAPIToken = "GITHUB_TOKEN" // nolint:gosec // Not actually a hardcoded credential
EnvUsername = "GITHUB_USER"
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 {
options *Options
client *github.Client
log *slog.Logger
}
func (g *GitHub) RepoURL() string {
return fmt.Sprintf("https://github.com/%s/%s", g.options.Owner, g.options.Repo)
}
func (g *GitHub) CloneURL() string {
return fmt.Sprintf("https://github.com/%s/%s.git", g.options.Owner, g.options.Repo)
}
func (g *GitHub) ReleaseURL(version string) string {
return fmt.Sprintf("https://github.com/%s/%s/releases/tag/%s", g.options.Owner, g.options.Repo, version)
}
func (g *GitHub) PullRequestURL(id int) string {
return fmt.Sprintf("https://github.com/%s/%s/pull/%d", g.options.Owner, g.options.Repo, id)
}
func (g *GitHub) GitAuth() transport.AuthMethod {
return &http.BasicAuth{
Username: g.options.Username,
Password: g.options.APIToken,
}
}
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")
tags, err := all(func(listOptions github.ListOptions) ([]*github.RepositoryTag, *github.Response, error) {
return g.client.Repositories.ListTags(
ctx, g.options.Owner, g.options.Repo,
&listOptions,
)
})
if err != nil {
return git.Releases{}, err
}
var releases git.Releases
for _, ghTag := range tags {
tag := &git.Tag{
Hash: ghTag.GetCommit().GetSHA(),
Name: ghTag.GetName(),
}
version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v"))
if err != nil {
g.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 (g *GitHub) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) {
var repositoryCommits []*github.RepositoryCommit
var err error
if tag != nil {
repositoryCommits, err = g.commitsSinceTag(ctx, tag)
} else {
repositoryCommits, err = g.commitsSinceInit(ctx)
}
if err != nil {
return nil, err
}
var commits = make([]git.Commit, 0, len(repositoryCommits))
for _, ghCommit := range repositoryCommits {
commit := git.Commit{
Hash: ghCommit.GetSHA(),
Message: ghCommit.GetCommit().GetMessage(),
}
commit.PullRequest, err = g.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 (g *GitHub) commitsSinceTag(ctx context.Context, tag *git.Tag) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch
log := g.log.With("base", tag.Hash, "head", head)
log.Debug("comparing commits")
repositoryCommits, err := all(
func(listOptions github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
comparison, resp, err := g.client.Repositories.CompareCommits(
ctx, g.options.Owner, g.options.Repo,
tag.Hash, head, &listOptions)
if err != nil {
return nil, nil, err
}
return comparison.Commits, resp, err
})
if err != nil {
return nil, err
}
return repositoryCommits, nil
}
func (g *GitHub) commitsSinceInit(ctx context.Context) ([]*github.RepositoryCommit, error) {
head := g.options.BaseBranch
log := g.log.With("head", head)
log.Debug("listing all commits")
repositoryCommits, err := all(
func(listOptions github.ListOptions) ([]*github.RepositoryCommit, *github.Response, error) {
return g.client.Repositories.ListCommits(
ctx, g.options.Owner, g.options.Repo,
&github.CommitsListOptions{
SHA: head,
ListOptions: listOptions,
})
})
if err != nil {
return nil, err
}
return repositoryCommits, nil
}
func (g *GitHub) prForCommit(ctx 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.
g.log.Debug("fetching pull requests associated with commit", "commit.hash", commit.Hash)
associatedPRs, err := all(
func(listOptions github.ListOptions) ([]*github.PullRequest, *github.Response, error) {
return g.client.PullRequests.ListPullRequestsWithCommit(
ctx, g.options.Owner, g.options.Repo,
commit.Hash, &listOptions)
})
if err != nil {
return nil, err
}
var pullRequest *github.PullRequest
for _, pr := range associatedPRs {
// We only look for the PR that has this commit set as the "merge commit" => The result of squashing this branch onto main
if pr.GetMergeCommitSHA() == commit.Hash {
pullRequest = pr
break
}
}
if pullRequest == nil {
return nil, nil
}
return gitHubPRToPullRequest(pullRequest), nil
}
func (g *GitHub) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label) error {
g.log.Debug("fetching labels on repo")
ghLabels, err := all(func(listOptions github.ListOptions) ([]*github.Label, *github.Response, error) {
return g.client.Issues.ListLabels(
ctx, g.options.Owner, g.options.Repo,
&listOptions)
})
if err != nil {
return err
}
for _, label := range labels {
if !slices.ContainsFunc(ghLabels, func(ghLabel *github.Label) bool {
return ghLabel.GetName() == label.Name
}) {
g.log.Info("creating label in repository", "label.name", label.Name)
_, _, err = g.client.Issues.CreateLabel(
ctx, g.options.Owner, g.options.Repo,
&github.Label{
Name: pointer.Pointer(label.Name),
Color: pointer.Pointer(label.Color),
Description: pointer.Pointer(label.Description),
},
)
if err != nil {
return err
}
}
}
return nil
}
func (g *GitHub) PullRequestForBranch(ctx context.Context, branch string) (*releasepr.ReleasePullRequest, error) {
prs, err := all(
func(listOptions github.ListOptions) ([]*github.PullRequest, *github.Response, error) {
return g.client.PullRequests.ListPullRequestsWithCommit(ctx, g.options.Owner, g.options.Repo, branch, &listOptions)
})
if err != nil {
var ghErr *github.ErrorResponse
if errors.As(err, &ghErr) {
if ghErr.Message == fmt.Sprintf("No commit found for SHA: %s", branch) {
return nil, nil
}
}
return nil, err
}
for _, pr := range prs {
if pr.GetBase().GetRef() == g.options.BaseBranch && pr.GetHead().GetRef() == branch && pr.GetState() == PRStateOpen {
return gitHubPRToReleasePullRequest(pr), nil
}
}
return nil, nil
}
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{
Title: &pr.Title,
Head: &pr.Head,
Base: &g.options.BaseBranch,
Body: &pr.Description,
},
)
if err != nil {
return err
}
pr.ID = ghPR.GetNumber()
err = g.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels)
if err != nil {
return err
}
return nil
}
func (g *GitHub) UpdatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{
Title: &pr.Title,
Body: &pr.Description,
},
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error {
for _, label := range remove {
_, err := g.client.Issues.RemoveLabelForIssue(
ctx, g.options.Owner, g.options.Repo,
pr.ID, label.Name,
)
if err != nil {
return err
}
}
addString := make([]string, 0, len(add))
for _, label := range add {
addString = append(addString, label.Name)
}
_, _, err := g.client.Issues.AddLabelsToIssue(
ctx, g.options.Owner, g.options.Repo,
pr.ID, addString,
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
_, _, err := g.client.PullRequests.Edit(
ctx, g.options.Owner, g.options.Repo,
pr.ID, &github.PullRequest{
State: pointer.Pointer(PRStateClosed),
},
)
if err != nil {
return err
}
return nil
}
func (g *GitHub) PendingReleases(ctx context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) {
ghPRs, err := all(func(listOptions github.ListOptions) ([]*github.PullRequest, *github.Response, error) {
return g.client.PullRequests.List(
ctx, g.options.Owner, g.options.Repo,
&github.PullRequestListOptions{
State: PRStateClosed,
Base: g.options.BaseBranch,
ListOptions: listOptions,
})
})
if err != nil {
return nil, err
}
prs := make([]*releasepr.ReleasePullRequest, 0, len(ghPRs))
for _, pr := range ghPRs {
pending := slices.ContainsFunc(pr.Labels, func(l *github.Label) bool {
return l.GetName() == pendingLabel.Name
})
if !pending {
continue
}
// pr.Merged is always nil :(
if pr.MergedAt == nil {
// Closed and not merged
continue
}
prs = append(prs, gitHubPRToReleasePullRequest(pr))
}
return prs, nil
}
func (g *GitHub) CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error {
makeLatest := ""
if latest {
makeLatest = "true"
} else {
makeLatest = "false"
}
_, _, err := g.client.Repositories.CreateRelease(
ctx, g.options.Owner, g.options.Repo,
&github.RepositoryRelease{
TagName: &title,
TargetCommitish: &commit.Hash,
Name: &title,
Body: &changelog,
Prerelease: &preRelease,
MakeLatest: &makeLatest,
},
)
if err != nil {
return err
}
return nil
}
func all[T any](f func(listOptions github.ListOptions) ([]T, *github.Response, error)) ([]T, error) {
results := make([]T, 0)
page := 1
for {
pageResults, resp, err := f(github.ListOptions{Page: page, PerPage: PerPageMax})
if err != nil {
return nil, err
}
results = append(results, pageResults...)
if page == resp.LastPage || resp.LastPage == 0 {
return results, nil
}
page = resp.NextPage
}
}
func gitHubPRToPullRequest(pr *github.PullRequest) *git.PullRequest {
return &git.PullRequest{
ID: pr.GetNumber(),
Title: pr.GetTitle(),
Description: pr.GetBody(),
}
}
func gitHubPRToReleasePullRequest(pr *github.PullRequest) *releasepr.ReleasePullRequest {
labels := make([]releasepr.Label, 0, len(pr.Labels))
for _, label := range pr.Labels {
labelName := label.GetName()
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.MergeCommitSHA != nil {
releaseCommit = &git.Commit{Hash: pr.GetMergeCommitSHA()}
}
return &releasepr.ReleasePullRequest{
PullRequest: *gitHubPRToPullRequest(pr),
Labels: labels,
Head: pr.GetHead().GetRef(),
ReleaseCommit: releaseCommit,
}
}
func (g *Options) autodiscover() {
if apiToken := os.Getenv(EnvAPIToken); apiToken != "" {
g.APIToken = apiToken
}
// TODO: Check if there is a better solution for cloning/pushing locally
if username := os.Getenv(EnvUsername); username != "" {
g.Username = username
}
if envRepository := os.Getenv(EnvRepository); envRepository != "" {
// GITHUB_REPOSITORY=apricote/releaser-pleaser
parts := strings.Split(envRepository, "/")
if len(parts) == 2 {
g.Owner = parts[0]
g.Repo = parts[1]
g.Repository = envRepository
}
}
}
type Options struct {
forge.Options
Owner string
Repo string
APIToken string
Username string
}
func New(log *slog.Logger, options *Options) *GitHub {
options.autodiscover()
client := github.NewClient(nil)
if options.APIToken != "" {
client = client.WithAuthToken(options.APIToken)
}
gh := &GitHub{
options: options,
client: client,
log: log.With("forge", "github"),
}
return gh
}

View file

@ -1,483 +0,0 @@
package gitlab
import (
"context"
"fmt"
"log/slog"
"os"
"slices"
"strings"
"github.com/blang/semver/v4"
"github.com/go-git/go-git/v5/plumbing/transport"
"github.com/go-git/go-git/v5/plumbing/transport/http"
gitlab "gitlab.com/gitlab-org/api/client-go"
"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 (
PerPageMax = 100
PRStateOpen = "opened"
PRStateMerged = "merged"
PRStateEventClose = "close"
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 {
options *Options
client *gitlab.Client
log *slog.Logger
}
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("%s.git", g.RepoURL())
}
func (g *GitLab) ReleaseURL(version string) string {
return fmt.Sprintf("%s/-/releases/%s", g.RepoURL(), version)
}
func (g *GitLab) PullRequestURL(id int) string {
return fmt.Sprintf("%s/-/merge_requests/%d", g.RepoURL(), id)
}
func (g *GitLab) GitAuth() transport.AuthMethod {
return &http.BasicAuth{
// Username just needs to be any non-blank value
Username: "api-token",
Password: g.options.APIToken,
}
}
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")
tags, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.Tag, *gitlab.Response, error) {
return g.client.Tags.ListTags(g.options.Path, &gitlab.ListTagsOptions{
OrderBy: pointer.Pointer("updated"),
ListOptions: listOptions,
}, gitlab.WithContext(ctx))
})
if err != nil {
return git.Releases{}, err
}
var releases git.Releases
for _, glTag := range tags {
tag := &git.Tag{
Hash: glTag.Commit.ID,
Name: glTag.Name,
}
version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v"))
if err != nil {
g.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 (g *GitLab) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) {
var err error
head := g.options.BaseBranch
log := g.log.With("head", head)
refName := ""
if tag != nil {
log = log.With("base", tag.Hash)
refName = fmt.Sprintf("%s..%s", tag.Hash, head)
} else {
refName = head
}
log.Debug("listing commits", "ref.name", refName)
gitLabCommits, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.Commit, *gitlab.Response, error) {
return g.client.Commits.ListCommits(g.options.Path, &gitlab.ListCommitsOptions{
RefName: &refName,
ListOptions: listOptions,
}, gitlab.WithContext(ctx))
})
if err != nil {
return nil, err
}
var commits = make([]git.Commit, 0, len(gitLabCommits))
for _, ghCommit := range gitLabCommits {
commit := git.Commit{
Hash: ghCommit.ID,
Message: ghCommit.Message,
}
commit.PullRequest, err = g.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 (g *GitLab) prForCommit(ctx context.Context, commit git.Commit) (*git.PullRequest, error) {
// We naively look up the associated MR for each commit through the "List merge requests associated with a commit"
// endpoint. This requires len(commits) requests.
// Using the "List merge requests" endpoint might be faster, as it allows us to fetch 100 arbitrary MRs per request,
// but worst case we need to look up all MRs made in the repository ever.
log := g.log.With("commit.hash", commit.Hash)
log.Debug("fetching pull requests associated with commit")
associatedMRs, _, err := g.client.Commits.ListMergeRequestsByCommit(
g.options.Path, commit.Hash,
gitlab.WithContext(ctx),
)
if err != nil {
return nil, err
}
var mergeRequest *gitlab.BasicMergeRequest
for _, mr := range associatedMRs {
// 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
}
}
if mergeRequest == nil {
return nil, nil
}
return gitlabMRToPullRequest(mergeRequest), nil
}
func (g *GitLab) EnsureLabelsExist(ctx context.Context, labels []releasepr.Label) error {
g.log.Debug("fetching labels on repo")
glLabels, err := all(func(listOptions gitlab.ListOptions) ([]*gitlab.Label, *gitlab.Response, error) {
return g.client.Labels.ListLabels(g.options.Path, &gitlab.ListLabelsOptions{
ListOptions: listOptions,
}, gitlab.WithContext(ctx))
})
if err != nil {
return err
}
for _, label := range labels {
if !slices.ContainsFunc(glLabels, func(glLabel *gitlab.Label) bool {
return glLabel.Name == label.Name
}) {
g.log.Info("creating label in repository", "label.name", label)
_, _, err := g.client.Labels.CreateLabel(g.options.Path, &gitlab.CreateLabelOptions{
Name: pointer.Pointer(label.Name),
Color: pointer.Pointer("#" + label.Color),
Description: pointer.Pointer(label.Description),
},
)
if err != nil {
return err
}
}
}
return nil
}
func (g *GitLab) PullRequestForBranch(ctx context.Context, branch string) (*releasepr.ReleasePullRequest, error) {
// There should only be a single open merge request from branch into g.options.BaseBranch at any given moment.
// We can skip pagination and just return the first result.
mrs, _, err := g.client.MergeRequests.ListProjectMergeRequests(g.options.Path, &gitlab.ListProjectMergeRequestsOptions{
State: pointer.Pointer(PRStateOpen),
SourceBranch: pointer.Pointer(branch),
TargetBranch: pointer.Pointer(g.options.BaseBranch),
ListOptions: gitlab.ListOptions{
Page: 1,
PerPage: PerPageMax,
},
}, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
if len(mrs) >= 1 {
return gitlabMRToReleasePullRequest(mrs[0]), nil
}
return nil, nil
}
func (g *GitLab) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
labels := make(gitlab.LabelOptions, 0, len(pr.Labels))
for _, label := range pr.Labels {
labels = append(labels, label.Name)
}
glMR, _, err := g.client.MergeRequests.CreateMergeRequest(g.options.Path, &gitlab.CreateMergeRequestOptions{
Title: &pr.Title,
Description: &pr.Description,
SourceBranch: &pr.Head,
TargetBranch: &g.options.BaseBranch,
Labels: &labels,
}, gitlab.WithContext(ctx))
if err != nil {
return err
}
pr.ID = glMR.IID
return nil
}
func (g *GitLab) UpdatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
_, _, err := g.client.MergeRequests.UpdateMergeRequest(g.options.Path, pr.ID, &gitlab.UpdateMergeRequestOptions{
Title: &pr.Title,
Description: &pr.Description,
}, gitlab.WithContext(ctx))
if err != nil {
return err
}
return nil
}
func (g *GitLab) SetPullRequestLabels(ctx context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error {
removeLabels := make(gitlab.LabelOptions, 0, len(remove))
for _, label := range remove {
removeLabels = append(removeLabels, label.Name)
}
addLabels := make(gitlab.LabelOptions, 0, len(add))
for _, label := range add {
addLabels = append(addLabels, label.Name)
}
_, _, err := g.client.MergeRequests.UpdateMergeRequest(g.options.Path, pr.ID, &gitlab.UpdateMergeRequestOptions{
RemoveLabels: &removeLabels,
AddLabels: &addLabels,
}, gitlab.WithContext(ctx))
if err != nil {
return err
}
return nil
}
func (g *GitLab) ClosePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
_, _, err := g.client.MergeRequests.UpdateMergeRequest(g.options.Path, pr.ID, &gitlab.UpdateMergeRequestOptions{
StateEvent: pointer.Pointer(PRStateEventClose),
}, gitlab.WithContext(ctx))
if err != nil {
return err
}
return nil
}
func (g *GitLab) PendingReleases(ctx context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, 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},
TargetBranch: pointer.Pointer(g.options.BaseBranch),
ListOptions: listOptions,
}, gitlab.WithContext(ctx))
})
if err != nil {
return nil, err
}
prs := make([]*releasepr.ReleasePullRequest, 0, len(glMRs))
for _, mr := range glMRs {
prs = append(prs, gitlabMRToReleasePullRequest(mr))
}
return prs, nil
}
func (g *GitLab) CreateRelease(ctx context.Context, commit git.Commit, title, changelog string, _, _ bool) error {
_, _, err := g.client.Releases.CreateRelease(g.options.Path, &gitlab.CreateReleaseOptions{
Name: &title,
TagName: &title,
Description: &changelog,
Ref: &commit.Hash,
}, gitlab.WithContext(ctx))
if err != nil {
return err
}
return nil
}
func all[T any](f func(listOptions gitlab.ListOptions) ([]T, *gitlab.Response, error)) ([]T, error) {
results := make([]T, 0)
page := 1
for {
pageResults, resp, err := f(gitlab.ListOptions{Page: page, PerPage: PerPageMax})
if err != nil {
return nil, err
}
results = append(results, pageResults...)
if page == resp.TotalPages || resp.TotalPages == 0 {
return results, nil
}
page = resp.NextPage
}
}
func gitlabMRToPullRequest(pr *gitlab.BasicMergeRequest) *git.PullRequest {
return &git.PullRequest{
ID: pr.IID,
Title: pr.Title,
Description: pr.Description,
}
}
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 {
return label.Name == labelName
}); i >= 0 {
labels = append(labels, releasepr.KnownLabels[i])
}
}
// Commit SHA is saved in either [MergeCommitSHA], [SquashCommitSHA] or [SHA] depending on which merge method was used.
var releaseCommit *git.Commit
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{
PullRequest: *gitlabMRToPullRequest(pr),
Labels: labels,
Head: pr.SHA,
ReleaseCommit: releaseCommit,
}
}
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
ProjectURL string
Path string
APIURL string
APIToken string
}
func New(log *slog.Logger, options *Options) (*GitLab, error) {
log = log.With("forge", "gitlab")
options.autodiscover()
client, err := gitlab.NewClient(options.APIToken, options.ClientOptions()...)
if err != nil {
return nil, err
}
gl := &GitLab{
options: options,
client: client,
log: log,
}
return gl, nil
}

View file

@ -1,292 +0,0 @@
package git
import (
"context"
"fmt"
"io"
"log/slog"
"os"
"time"
"github.com/go-git/go-git/v5"
"github.com/go-git/go-git/v5/config"
"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"
)
const (
remoteName = "origin"
newFilePermissions = 0o644
)
type Commit struct {
Hash string
Message string
PullRequest *PullRequest
}
type PullRequest struct {
ID int
Title string
Description string
}
type Tag struct {
Hash string
Name string
}
type Releases struct {
Latest *Tag
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 {
return nil, fmt.Errorf("failed to create temporary directory for repo clone: %w", err)
}
repo, err := git.PlainCloneContext(ctx, dir, false, &git.CloneOptions{
URL: cloneURL,
RemoteName: remoteName,
ReferenceName: plumbing.NewBranchReferenceName(branch),
SingleBranch: false,
Auth: auth,
})
if err != nil {
return nil, fmt.Errorf("failed to clone repository: %w", err)
}
return &Repository{r: repo, logger: logger, auth: auth}, nil
}
type Repository struct {
r *git.Repository
logger *slog.Logger
auth transport.AuthMethod
}
func (r *Repository) DeleteBranch(ctx context.Context, branch string) error {
if b, _ := r.r.Branch(branch); b != nil {
r.logger.DebugContext(ctx, "deleting local branch", "branch.name", branch)
if err := r.r.DeleteBranch(branch); err != nil {
return err
}
}
return nil
}
func (r *Repository) Checkout(_ context.Context, branch string) error {
worktree, err := r.r.Worktree()
if err != nil {
return err
}
if err = worktree.Checkout(&git.CheckoutOptions{
Branch: plumbing.NewBranchReferenceName(branch),
Create: true,
}); err != nil {
return fmt.Errorf("failed to check out branch: %w", err)
}
return nil
}
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
}
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() //nolint:errcheck
content, err := io.ReadAll(file)
if err != nil {
return err
}
updatedContent, err := updateHook(string(content))
if err != nil {
return fmt.Errorf("failed to run update hook on file %s", path)
}
err = file.Truncate(0)
if err != nil {
return fmt.Errorf("failed to replace file content: %w", err)
}
_, err = file.Seek(0, 0)
if err != nil {
return fmt.Errorf("failed to replace file content: %w", err)
}
_, err = file.Write([]byte(updatedContent))
if err != nil {
return fmt.Errorf("failed to replace file content: %w", err)
}
_, err = worktree.Add(path)
if err != nil {
return fmt.Errorf("failed to add updated file to git worktree: %w", err)
}
return nil
}
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: author.signature(now),
Committer: committer.signature(now),
})
if err != nil {
return Commit{}, fmt.Errorf("failed to commit changes: %w", err)
}
return Commit{
Hash: releaseCommitHash.String(),
Message: message,
}, nil
}
// 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
return true, nil
}
return false, err
}
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
}
commitOnLocalPRBranch, err := r.commitFromRef(localPRBranchRef)
if err != nil {
return false, err
}
localDiff, err := commitOnRemoteMain.PatchContext(ctx, commitOnLocalPRBranch)
if err != nil {
return false, err
}
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 nil, err
}
commit, err := r.r.CommitObject(ref.Hash())
if err != nil {
return nil, err
}
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 {
pushRefSpec := config.RefSpec(fmt.Sprintf(
"+%s:%s",
plumbing.NewBranchReferenceName(branch),
// This needs to be the local branch name, not the remotes/origin ref
// See https://stackoverflow.com/a/75727620
plumbing.NewBranchReferenceName(branch),
))
r.logger.DebugContext(ctx, "pushing branch", "branch.name", branch, "refspec", pushRefSpec.String())
return r.r.PushContext(ctx, &git.PushOptions{
RemoteName: remoteName,
RefSpecs: []config.RefSpec{pushRefSpec},
Force: true,
Auth: r.auth,
})
}

View file

@ -1,174 +0,0 @@
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 <bar@example.com>"},
{author: Author{Name: "bar", Email: "foo@example.com"}, want: "bar <foo@example.com>"},
}
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)
})
}
}

View file

@ -1,189 +0,0 @@
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
}
}

View file

@ -1,23 +0,0 @@
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))
}

View file

@ -7,8 +7,7 @@ import (
// A Section struct represents a section of elements.
type Section struct {
gast.BaseBlock
Name string
Hidden bool
Name string
}
// Dump implements Node.Dump.
@ -27,10 +26,6 @@ func (n *Section) Kind() gast.NodeKind {
return KindSection
}
func (n *Section) HideInOutput() {
n.Hidden = true
}
// NewSection returns a new Section node.
func NewSection(name string) *Section {
return &Section{Name: name}

View file

@ -1,13 +1,11 @@
package extensions
import (
"fmt"
"regexp"
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
@ -21,8 +19,8 @@ var (
const (
sectionTrigger = "<!--"
SectionStartFormat = "<!-- section-start %s -->\n"
SectionEndFormat = "\n<!-- section-end %s -->"
SectionStartFormat = "<!-- section-start %s -->"
SectionEndFormat = "<!-- section-end %s -->"
)
type sectionParser struct{}
@ -78,45 +76,6 @@ func (s *sectionParser) Trigger() []byte {
return []byte(sectionTrigger)
}
type SectionMarkdownRenderer struct{}
func NewSectionMarkdownRenderer() renderer.NodeRenderer {
return &SectionMarkdownRenderer{}
}
func (s SectionMarkdownRenderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
reg.Register(ast.KindSection, s.renderSection)
}
func (s SectionMarkdownRenderer) renderSection(w util.BufWriter, _ []byte, node gast.Node, enter bool) (gast.WalkStatus, error) {
n := node.(*ast.Section)
if n.Hidden {
return gast.WalkContinue, nil
}
if enter {
// Add blank previous line if applicable
if node.PreviousSibling() != nil && node.HasBlankPreviousLines() {
if _, err := w.WriteRune('\n'); err != nil {
return gast.WalkStop, err
}
}
if _, err := fmt.Fprintf(w, SectionStartFormat, n.Name); err != nil {
return gast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if _, err := fmt.Fprintf(w, SectionEndFormat, n.Name); err != nil {
return gast.WalkStop, fmt.Errorf(": %w", err)
}
}
// Somehow the goldmark-markdown renderer does not flush this properly on its own
return gast.WalkContinue, w.Flush()
}
type section struct{}
// Section is an extension that allow you to use group content under a shared parent ast node.
@ -126,7 +85,4 @@ func (e *section) Extend(m goldmark.Markdown) {
m.Parser().AddOptions(parser.WithBlockParsers(
util.Prioritized(NewSectionParser(), 0),
))
m.Renderer().AddOptions(renderer.WithNodeRenderers(
util.Prioritized(NewSectionMarkdownRenderer(), 500),
))
}

View file

@ -1,122 +1,17 @@
package markdown
import (
"bytes"
"strings"
markdown "github.com/teekennedy/goldmark-markdown"
"github.com/yuin/goldmark"
gast "github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/util"
"github.com/apricote/releaser-pleaser/internal/markdown/extensions"
"github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
"github.com/apricote/releaser-pleaser/internal/markdown/renderer/markdown"
)
func New() goldmark.Markdown {
return goldmark.New(
goldmark.WithExtensions(extensions.Section),
goldmark.WithParserOptions(parser.WithASTTransformers(
util.Prioritized(&newLineTransformer{}, 1),
)),
goldmark.WithRenderer(markdown.NewRenderer()),
goldmark.WithRenderer(renderer.NewRenderer(renderer.WithNodeRenderers(util.Prioritized(markdown.NewRenderer(), 1)))),
)
}
// Format the Markdown document in a style mimicking Prettier. This is done for compatibility with other tools
// users might have installed in their IDE. This does not guarantee that the output matches Prettier exactly.
func Format(input string) (string, error) {
var buf bytes.Buffer
buf.Grow(len(input))
err := New().Convert([]byte(input), &buf)
if err != nil {
return "", err
}
return buf.String(), nil
}
func GetCodeBlockText(source []byte, language string, output *string, found *bool) gast.Walker {
return func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
if n.Kind() != gast.KindFencedCodeBlock {
return gast.WalkContinue, nil
}
codeBlock := n.(*gast.FencedCodeBlock)
if string(codeBlock.Language(source)) != language {
return gast.WalkContinue, nil
}
*output = textFromLines(source, codeBlock)
if found != nil {
*found = true
}
// Stop looking after we find the first result
return gast.WalkStop, nil
}
}
func GetSectionText(source []byte, name string, output *string) gast.Walker {
return func(n gast.Node, entering bool) (gast.WalkStatus, error) {
if !entering {
return gast.WalkContinue, nil
}
if n.Kind() != ast.KindSection {
return gast.WalkContinue, nil
}
section := n.(*ast.Section)
if section.Name != name {
return gast.WalkContinue, nil
}
// Do not show section markings in output, we only care about the content
section.HideInOutput()
// Found the right section
outputBuffer := new(bytes.Buffer)
err := New().Renderer().Render(outputBuffer, source, section)
if err != nil {
return gast.WalkStop, err
}
*output = outputBuffer.String()
// Stop looking after we find the first result
return gast.WalkStop, nil
}
}
func textFromLines(source []byte, n gast.Node) string {
content := make([]byte, 0)
l := n.Lines().Len()
for i := 0; i < l; i++ {
line := n.Lines().At(i)
content = append(content, line.Value(source)...)
}
return strings.TrimSpace(string(content))
}
func WalkAST(source []byte, walkers ...gast.Walker) (err error) {
doc := New().Parser().Parse(text.NewReader(source))
for _, walker := range walkers {
err = gast.Walk(doc, walker)
if err != nil {
return err
}
}
return nil
}

View file

@ -1,260 +0,0 @@
package markdown
import (
"errors"
"testing"
"github.com/stretchr/testify/assert"
"github.com/yuin/goldmark/ast"
)
func TestFormat(t *testing.T) {
tests := []struct {
name string
input string
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "heading spacing",
input: "# Foo\n## Bar\n### Baz",
want: "# Foo\n\n## Bar\n\n### Baz\n",
wantErr: assert.NoError,
},
{
name: "no empty lines for list items",
input: "# Foo\n- 1\n- 2\n",
want: "# Foo\n\n- 1\n- 2\n",
wantErr: assert.NoError,
},
{
name: "sections",
input: "# Foo\n<!-- section-start foobar -->\n- 1\n- 2\n<!-- section-end foobar -->\n",
want: "# Foo\n\n<!-- section-start foobar -->\n- 1\n- 2\n\n<!-- section-end foobar -->\n",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := Format(tt.input)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}
func TestGetCodeBlockText(t *testing.T) {
type args struct {
source []byte
language string
}
tests := []struct {
name string
args args
wantText string
wantFound bool
wantErr assert.ErrorAssertionFunc
}{
{
name: "no code block",
args: args{
source: []byte("# Foo"),
language: "missing",
},
wantText: "",
wantFound: false,
wantErr: assert.NoError,
},
{
name: "code block",
args: args{
source: []byte("```test\nContent\n```"),
language: "test",
},
wantText: "Content",
wantFound: true,
wantErr: assert.NoError,
},
{
name: "code block with other language",
args: args{
source: []byte("```unknown\nContent\n```"),
language: "test",
},
wantText: "",
wantFound: false,
wantErr: assert.NoError,
},
{
name: "multiple code blocks with different languages",
args: args{
source: []byte("```unknown\nContent\n```\n\n```test\n1337\n```"),
language: "test",
},
wantText: "1337",
wantFound: true,
wantErr: assert.NoError,
},
{
name: "multiple code blocks with same language returns first one",
args: args{
source: []byte("```test\nContent\n```\n\n```test\n1337\n```"),
language: "test",
},
wantText: "Content",
wantFound: true,
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var gotText string
var gotFound bool
err := WalkAST(tt.args.source,
GetCodeBlockText(tt.args.source, tt.args.language, &gotText, &gotFound),
)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.wantText, gotText)
assert.Equal(t, tt.wantFound, gotFound)
})
}
}
func TestGetSectionText(t *testing.T) {
type args struct {
source []byte
name string
}
tests := []struct {
name string
args args
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no section",
args: args{
source: []byte("# Foo"),
name: "missing",
},
want: "",
wantErr: assert.NoError,
},
{
name: "section",
args: args{
source: []byte("<!-- section-start test -->\nContent\n<!-- section-end test -->"),
name: "test",
},
want: "Content\n",
wantErr: assert.NoError,
},
{
name: "section with other name",
args: args{
source: []byte("<!-- section-start unknown -->\nContent\n<!-- section-end unknown -->"),
name: "test",
},
want: "",
wantErr: assert.NoError,
},
{
name: "multiple sections with different names",
args: args{
source: []byte("<!-- section-start unknown -->\nContent\n<!-- section-end unknown -->\n\n<!-- section-start test -->\n1337\n<!-- section-end test -->"),
name: "test",
},
want: "1337\n",
wantErr: assert.NoError,
},
{
name: "multiple sections with same name returns first one",
args: args{
source: []byte("<!-- section-start test -->\nContent\n<!-- section-end test -->\n\n<!-- section-start test -->\n1337\n<!-- section-end test -->"),
name: "test",
},
want: "Content\n",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var got string
err := WalkAST(tt.args.source,
GetSectionText(tt.args.source, tt.args.name, &got),
)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, got)
})
}
}
func TestWalkAST(t *testing.T) {
type args struct {
source []byte
walkers []ast.Walker
}
tests := []struct {
name string
args args
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty walker",
args: args{
source: []byte("# Foo"),
walkers: []ast.Walker{
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, nil
},
},
},
wantErr: assert.NoError,
},
{
name: "returns walker error",
args: args{
source: []byte("# Foo"),
walkers: []ast.Walker{
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, errors.New("test")
},
},
},
wantErr: assert.Error,
},
{
name: "runs all walkers",
args: args{
source: []byte("# Foo"),
walkers: []ast.Walker{
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, nil
},
func(_ ast.Node, _ bool) (ast.WalkStatus, error) {
return ast.WalkStop, errors.New("test")
},
},
},
wantErr: assert.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := WalkAST(tt.args.source, tt.args.walkers...)
if !tt.wantErr(t, err) {
return
}
})
}
}

View file

@ -1,31 +0,0 @@
package markdown
import (
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/parser"
"github.com/yuin/goldmark/text"
)
type newLineTransformer struct{}
var _ parser.ASTTransformer = (*newLineTransformer)(nil) // interface compliance
func (t *newLineTransformer) Transform(doc *ast.Document, _ text.Reader, _ parser.Context) {
// No error can happen as they can only come from the walker function
_ = ast.Walk(doc, func(node ast.Node, entering bool) (ast.WalkStatus, error) {
if !entering || node.Type() != ast.TypeBlock {
return ast.WalkContinue, nil
}
switch node.Kind() {
case ast.KindListItem:
// Do not add empty lines between every list item
break
default:
// Add empty lines between every other block
node.SetBlankPreviousLines(true)
}
return ast.WalkContinue, nil
})
}

View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Rolf Lewis
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -0,0 +1,4 @@
This directory is a vendored copy of https://github.com/RolfLewis/goldmark-down/blob/main/markdown.go.
The original repository is set to a `main` package which can not be imported.
It is under the MIT license.

View file

@ -0,0 +1,836 @@
package markdown
import (
"bytes"
"fmt"
"io"
"strconv"
"strings"
"github.com/yuin/goldmark/ast"
exast "github.com/yuin/goldmark/extension/ast"
"github.com/yuin/goldmark/renderer"
"github.com/yuin/goldmark/text"
"github.com/yuin/goldmark/util"
rpexast "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
)
type blockState struct {
node ast.Node
fresh bool
}
type listState struct {
marker byte
ordered bool
index int
}
type Renderer struct {
listStack []listState
openBlocks []blockState
prefixStack []string
prefix []byte
atNewline bool
}
// NewRenderer returns a new Renderer with given options.
func NewRenderer() renderer.NodeRenderer {
r := &Renderer{}
return r
}
func (r *Renderer) RegisterFuncs(reg renderer.NodeRendererFuncRegisterer) {
// default registrations
// blocks
reg.Register(ast.KindDocument, r.renderDocument)
reg.Register(ast.KindHeading, r.renderHeading)
reg.Register(ast.KindBlockquote, r.renderBlockquote)
reg.Register(ast.KindCodeBlock, r.renderCodeBlock)
reg.Register(ast.KindFencedCodeBlock, r.renderFencedCodeBlock)
reg.Register(ast.KindHTMLBlock, r.renderHTMLBlock)
reg.Register(ast.KindList, r.renderList)
reg.Register(ast.KindListItem, r.renderListItem)
reg.Register(ast.KindParagraph, r.renderParagraph)
reg.Register(ast.KindTextBlock, r.renderTextBlock)
reg.Register(ast.KindThematicBreak, r.renderThematicBreak)
// inlines
reg.Register(ast.KindAutoLink, r.renderAutoLink)
reg.Register(ast.KindCodeSpan, r.renderCodeSpan)
reg.Register(ast.KindEmphasis, r.renderEmphasis)
reg.Register(ast.KindImage, r.renderImage)
reg.Register(ast.KindLink, r.renderLink)
reg.Register(ast.KindRawHTML, r.renderRawHTML)
reg.Register(ast.KindText, r.renderText)
reg.Register(ast.KindString, r.renderString)
// GFM Extensions
// Tables
reg.Register(exast.KindTable, r.renderTable)
reg.Register(exast.KindTableHeader, r.renderTableHeader)
reg.Register(exast.KindTableRow, r.renderTableRow)
reg.Register(exast.KindTableCell, r.renderTableCell)
// Strikethrough
reg.Register(exast.KindStrikethrough, r.renderStrikethrough)
// Checkbox
reg.Register(exast.KindTaskCheckBox, r.renderTaskCheckBox)
// releaser-pleaser Extensions
// Section
reg.Register(rpexast.KindSection, r.renderSection)
}
func (r *Renderer) write(w io.Writer, buf []byte) (int, error) {
written := 0
for len(buf) > 0 {
if r.atNewline {
if err := r.beginLine(w); err != nil {
return 0, fmt.Errorf(": %w", err)
}
}
atNewline := false
newline := bytes.IndexByte(buf, '\n')
if newline == -1 {
newline = len(buf) - 1
} else {
atNewline = true
}
n, err := w.Write(buf[:newline+1])
written += n
r.atNewline = n > 0 && atNewline && n == newline+1
if len(r.openBlocks) != 0 {
r.openBlocks[len(r.openBlocks)-1].fresh = false
}
if err != nil {
return written, fmt.Errorf(": %w", err)
}
buf = buf[n:]
}
return written, nil
}
func (r *Renderer) beginLine(w io.Writer) error {
if len(r.openBlocks) != 0 {
current := r.openBlocks[len(r.openBlocks)-1]
if current.node.Kind() == ast.KindParagraph && !current.fresh {
return nil
}
}
n, err := w.Write(r.prefix)
if n != 0 {
r.atNewline = r.prefix[len(r.prefix)-1] == '\n'
}
if err != nil {
return fmt.Errorf(": %w", err)
}
return nil
}
func (r *Renderer) writeLines(w util.BufWriter, source []byte, lines *text.Segments) error {
for i := 0; i < lines.Len(); i++ {
line := lines.At(i)
if _, err := r.write(w, line.Value(source)); err != nil {
return fmt.Errorf(": %w", err)
}
}
return nil
}
func (r *Renderer) writeByte(w io.Writer, c byte) error {
if _, err := r.write(w, []byte{c}); err != nil {
return fmt.Errorf(": %w", err)
}
return nil
}
// WriteString writes a string to an io.Writer, ensuring that appropriate indentation and prefixes are added at the
// beginning of each line.
func (r *Renderer) writeString(w io.Writer, s string) (int, error) {
n, err := r.write(w, []byte(s))
if err != nil {
return n, fmt.Errorf(": %w", err)
}
return n, nil
}
// PushIndent adds the specified amount of indentation to the current line prefix.
func (r *Renderer) pushIndent(amount int) {
r.pushPrefix(strings.Repeat(" ", amount))
}
// PushPrefix adds the specified string to the current line prefix.
func (r *Renderer) pushPrefix(prefix string) {
r.prefixStack = append(r.prefixStack, prefix)
r.prefix = append(r.prefix, []byte(prefix)...)
}
// PopPrefix removes the last piece added by a call to PushIndent or PushPrefix from the current line prefix.
func (r *Renderer) popPrefix() {
r.prefix = r.prefix[:len(r.prefix)-len(r.prefixStack[len(r.prefixStack)-1])]
r.prefixStack = r.prefixStack[:len(r.prefixStack)-1]
}
// OpenBlock ensures that each block begins on a new line, and that blank lines are inserted before blocks as
// indicated by node.HasPreviousBlankLines.
func (r *Renderer) openBlock(w util.BufWriter, _ []byte, node ast.Node) error {
r.openBlocks = append(r.openBlocks, blockState{
node: node,
fresh: true,
})
hasBlankPreviousLines := node.HasBlankPreviousLines()
// FIXME: standard goldmark table parser doesn't recognize Blank Previous Lines so we'll always add one
if node.Kind() == exast.KindTable {
hasBlankPreviousLines = true
}
// Work around the fact that the first child of a node notices the same set of preceding blank lines as its parent.
if p := node.Parent(); p != nil && p.FirstChild() == node {
if p.Kind() == ast.KindDocument || p.Kind() == ast.KindListItem || p.HasBlankPreviousLines() {
hasBlankPreviousLines = false
}
}
if hasBlankPreviousLines {
if err := r.writeByte(w, '\n'); err != nil {
return fmt.Errorf(": %w", err)
}
}
r.openBlocks[len(r.openBlocks)-1].fresh = true
return nil
}
// CloseBlock marks the current block as closed.
func (r *Renderer) closeBlock(w io.Writer) error {
if !r.atNewline {
if err := r.writeByte(w, '\n'); err != nil {
return fmt.Errorf(": %w", err)
}
}
r.openBlocks = r.openBlocks[:len(r.openBlocks)-1]
return nil
}
// RenderDocument renders an *ast.Document node to the given BufWriter.
func (r *Renderer) renderDocument(_ util.BufWriter, _ []byte, _ ast.Node, _ bool) (ast.WalkStatus, error) {
r.listStack, r.prefixStack, r.prefix, r.atNewline = nil, nil, nil, false
return ast.WalkContinue, nil
}
// RenderHeading renders an *ast.Heading node to the given BufWriter.
func (r *Renderer) renderHeading(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.writeString(w, strings.Repeat("#", node.(*ast.Heading).Level)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.writeByte(w, ' '); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderBlockquote renders an *ast.Blockquote node to the given BufWriter.
func (r *Renderer) renderBlockquote(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.writeString(w, "> "); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
r.pushPrefix("> ")
} else {
r.popPrefix()
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderCodeBlock renders an *ast.CodeBlock node to the given BufWriter.
func (r *Renderer) renderCodeBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
r.popPrefix()
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// // Each line of a code block needs to be aligned at the same offset, and a code block must start with at least four
// // spaces. To achieve this, we unconditionally add four spaces to the first line of the code block and indent the
// // rest as necessary.
// if _, err := r.writeString(w, " "); err != nil {
// return ast.WalkStop, fmt.Errorf(": %w", err)
// }
r.pushIndent(4)
if err := r.writeLines(w, source, node.Lines()); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
// RenderFencedCodeBlock renders an *ast.FencedCodeBlock node to the given BufWriter.
func (r *Renderer) renderFencedCodeBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
code := node.(*ast.FencedCodeBlock)
// Write the start of the fenced code block.
fence := []byte("```")
if _, err := r.write(w, fence); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
language := code.Language(source)
if _, err := r.write(w, language); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// Write the contents of the fenced code block.
if err := r.writeLines(w, source, node.Lines()); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// Write the end of the fenced code block.
if err := r.beginLine(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.write(w, fence); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
// RenderHTMLBlock renders an *ast.HTMLBlock node to the given BufWriter.
func (r *Renderer) renderHTMLBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// Write the contents of the HTML block.
if err := r.writeLines(w, source, node.Lines()); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// Write the closure line, if any.
html := node.(*ast.HTMLBlock)
if html.HasClosure() {
if _, err := r.write(w, html.ClosureLine.Value(source)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderList renders an *ast.List node to the given BufWriter.
func (r *Renderer) renderList(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
list := node.(*ast.List)
r.listStack = append(r.listStack, listState{
marker: list.Marker,
ordered: list.IsOrdered(),
index: list.Start,
})
} else {
r.listStack = r.listStack[:len(r.listStack)-1]
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderListItem renders an *ast.ListItem node to the given BufWriter.
func (r *Renderer) renderListItem(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
markerWidth := 2 // marker + space
state := &r.listStack[len(r.listStack)-1]
if state.ordered {
width, err := r.writeString(w, strconv.FormatInt(int64(state.index), 10))
if err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
state.index++
markerWidth += width // marker, space, and digits
}
if _, err := r.write(w, []byte{state.marker, ' '}); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
r.pushIndent(markerWidth)
} else {
r.popPrefix()
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderParagraph renders an *ast.Paragraph node to the given BufWriter.
func (r *Renderer) renderParagraph(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
// A paragraph that follows another paragraph or a blockquote must be preceded by a blank line.
if !node.HasBlankPreviousLines() {
if prev := node.PreviousSibling(); prev != nil && (prev.Kind() == ast.KindParagraph || prev.Kind() == ast.KindBlockquote) {
if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
}
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderTextBlock renders an *ast.TextBlock node to the given BufWriter.
func (r *Renderer) renderTextBlock(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderThematicBreak renders an *ast.ThematicBreak node to the given BufWriter.
func (r *Renderer) renderThematicBreak(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
// TODO: this prints an extra no line
if _, err := r.writeString(w, "--------"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
// RenderAutoLink renders an *ast.AutoLink node to the given BufWriter.
func (r *Renderer) renderAutoLink(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkContinue, nil
}
if err := r.writeByte(w, '<'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.write(w, node.(*ast.AutoLink).Label(source)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.writeByte(w, '>'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
func (r *Renderer) shouldPadCodeSpan(source []byte, node *ast.CodeSpan) bool {
c := node.FirstChild()
if c == nil {
return false
}
segment := c.(*ast.Text).Segment
text := segment.Value(source)
var firstChar byte
if len(text) > 0 {
firstChar = text[0]
}
allWhitespace := true
for {
if util.FirstNonSpacePosition(text) != -1 {
allWhitespace = false
break
}
c = c.NextSibling()
if c == nil {
break
}
segment = c.(*ast.Text).Segment
text = segment.Value(source)
}
if allWhitespace {
return false
}
var lastChar byte
if len(text) > 0 {
lastChar = text[len(text)-1]
}
return firstChar == '`' || firstChar == ' ' || lastChar == '`' || lastChar == ' '
}
// RenderCodeSpan renders an *ast.CodeSpan node to the given BufWriter.
func (r *Renderer) renderCodeSpan(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkContinue, nil
}
code := node.(*ast.CodeSpan)
delimiter := []byte{'`'}
pad := r.shouldPadCodeSpan(source, code)
if _, err := r.write(w, delimiter); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if pad {
if err := r.writeByte(w, ' '); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
for c := node.FirstChild(); c != nil; c = c.NextSibling() {
text := c.(*ast.Text).Segment
if _, err := r.write(w, text.Value(source)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
if pad {
if err := r.writeByte(w, ' '); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
if _, err := r.write(w, delimiter); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkSkipChildren, nil
}
// RenderEmphasis renders an *ast.Emphasis node to the given BufWriter.
func (r *Renderer) renderEmphasis(w util.BufWriter, _ []byte, node ast.Node, _ bool) (ast.WalkStatus, error) {
em := node.(*ast.Emphasis)
if _, err := r.writeString(w, strings.Repeat("*", em.Level)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
func (r *Renderer) escapeLinkDest(dest []byte) []byte {
requiresEscaping := false
for _, c := range dest {
if c <= 32 || c == '(' || c == ')' || c == 127 {
requiresEscaping = true
break
}
}
if !requiresEscaping {
return dest
}
escaped := make([]byte, 0, len(dest)+2)
escaped = append(escaped, '<')
for _, c := range dest {
if c == '<' || c == '>' {
escaped = append(escaped, '\\')
}
escaped = append(escaped, c)
}
escaped = append(escaped, '>')
return escaped
}
func (r *Renderer) linkTitleDelimiter(title []byte) byte {
for i, c := range title {
if c == '"' && (i == 0 || title[i-1] != '\\') {
return '\''
}
}
return '"'
}
func (r *Renderer) renderLinkOrImage(w util.BufWriter, open string, dest, title []byte, enter bool) error {
if enter {
if _, err := r.writeString(w, open); err != nil {
return fmt.Errorf(": %w", err)
}
} else {
if _, err := r.writeString(w, "]("); err != nil {
return fmt.Errorf(": %w", err)
}
if _, err := r.write(w, r.escapeLinkDest(dest)); err != nil {
return fmt.Errorf(": %w", err)
}
if len(title) != 0 {
delimiter := r.linkTitleDelimiter(title)
if _, err := fmt.Fprintf(w, ` %c%s%c`, delimiter, string(title), delimiter); err != nil {
return fmt.Errorf(": %w", err)
}
}
if err := r.writeByte(w, ')'); err != nil {
return fmt.Errorf(": %w", err)
}
}
return nil
}
// RenderImage renders an *ast.Image node to the given BufWriter.
func (r *Renderer) renderImage(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
img := node.(*ast.Image)
if err := r.renderLinkOrImage(w, "![", img.Destination, img.Title, enter); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
// RenderLink renders an *ast.Link node to the given BufWriter.
func (r *Renderer) renderLink(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
link := node.(*ast.Link)
if err := r.renderLinkOrImage(w, "[", link.Destination, link.Title, enter); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
// RenderRawHTML renders an *ast.RawHTML node to the given BufWriter.
func (r *Renderer) renderRawHTML(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkSkipChildren, nil
}
raw := node.(*ast.RawHTML)
for i := 0; i < raw.Segments.Len(); i++ {
segment := raw.Segments.At(i)
if _, err := r.write(w, segment.Value(source)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkSkipChildren, nil
}
// RenderText renders an *ast.Text node to the given BufWriter.
func (r *Renderer) renderText(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkContinue, nil
}
text := node.(*ast.Text)
value := text.Segment.Value(source)
if _, err := r.write(w, value); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
switch {
case text.HardLineBreak():
if _, err := r.writeString(w, "\\\n"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
case text.SoftLineBreak():
if err := r.writeByte(w, '\n'); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
// RenderString renders an *ast.String node to the given BufWriter.
func (r *Renderer) renderString(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
return ast.WalkContinue, nil
}
str := node.(*ast.String)
if _, err := r.write(w, str.Value); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTable(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTableHeader(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.writeString(w, "| "); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if _, err := r.writeString(w, " |\n|"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
for x := 0; x < node.ChildCount(); x++ { // use as column count
if _, err := r.writeString(w, " --- |"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTableRow(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.writeString(w, "| "); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if _, err := r.writeString(w, " |"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTableCell(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if !enter {
if node.NextSibling() != nil {
if _, err := r.writeString(w, " | "); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderStrikethrough(w util.BufWriter, _ []byte, _ ast.Node, _ bool) (ast.WalkStatus, error) {
if _, err := r.writeString(w, "~~"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
return ast.WalkContinue, nil
}
func (r *Renderer) renderTaskCheckBox(w util.BufWriter, _ []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
if enter {
var fill byte = ' '
if task := node.(*exast.TaskCheckBox); task.IsChecked {
fill = 'x'
}
if _, err := r.write(w, []byte{'[', fill, ']', ' '}); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}

View file

@ -0,0 +1,35 @@
package markdown
import (
"fmt"
"github.com/yuin/goldmark/ast"
"github.com/yuin/goldmark/util"
"github.com/apricote/releaser-pleaser/internal/markdown/extensions"
rpexast "github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
)
func (r *Renderer) renderSection(w util.BufWriter, source []byte, node ast.Node, enter bool) (ast.WalkStatus, error) {
n := node.(*rpexast.Section)
if enter {
if err := r.openBlock(w, source, node); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if _, err := r.writeString(w, fmt.Sprintf(extensions.SectionStartFormat, n.Name)+"\n"); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
} else {
if _, err := r.writeString(w, "\n"+fmt.Sprintf(extensions.SectionEndFormat, n.Name)); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
if err := r.closeBlock(w); err != nil {
return ast.WalkStop, fmt.Errorf(": %w", err)
}
}
return ast.WalkContinue, nil
}

View file

@ -1,5 +0,0 @@
package pointer
func Pointer[T any](value T) *T {
return &value
}

View file

@ -1,54 +0,0 @@
package releasepr
// Label is the string identifier of a pull/merge request label on the forge.
type Label struct {
Color string
Name string
Description string
}
var (
LabelNextVersionTypeNormal = Label{
Color: "EFC15B",
Name: "rp-next-version::normal",
Description: "Request a stable version",
}
LabelNextVersionTypeRC = Label{
Color: "EFC15B",
Name: "rp-next-version::rc",
Description: "Request a pre-release -rc version",
}
LabelNextVersionTypeBeta = Label{
Color: "EFC15B",
Name: "rp-next-version::beta",
Description: "Request a pre-release -beta version",
}
LabelNextVersionTypeAlpha = Label{
Color: "EFC15B",
Name: "rp-next-version::alpha",
Description: "Request a pre-release -alpha version",
}
)
var (
LabelReleasePending = Label{
Color: "DEDEDE",
Name: "rp-release::pending",
Description: "Release for this PR is pending",
}
LabelReleaseTagged = Label{
Color: "0E8A16",
Name: "rp-release::tagged",
Description: "Release for this PR is created",
}
)
var KnownLabels = []Label{
LabelNextVersionTypeNormal,
LabelNextVersionTypeRC,
LabelNextVersionTypeBeta,
LabelNextVersionTypeAlpha,
LabelReleasePending,
LabelReleaseTagged,
}

View file

@ -1,161 +0,0 @@
package releasepr
import (
"bytes"
_ "embed"
"fmt"
"log"
"regexp"
"text/template"
"github.com/apricote/releaser-pleaser/internal/git"
"github.com/apricote/releaser-pleaser/internal/markdown"
"github.com/apricote/releaser-pleaser/internal/versioning"
)
var (
releasePRTemplate *template.Template
)
//go:embed releasepr.md.tpl
var rawReleasePRTemplate string
func init() {
var err error
releasePRTemplate, err = template.New("releasepr").Parse(rawReleasePRTemplate)
if err != nil {
log.Fatalf("failed to parse release pr template: %v", err)
}
}
type ReleasePullRequest struct {
git.PullRequest
Labels []Label
Head string
ReleaseCommit *git.Commit
}
func NewReleasePullRequest(head, branch, version, changelogEntry string) (*ReleasePullRequest, error) {
rp := &ReleasePullRequest{
Head: head,
Labels: []Label{LabelReleasePending},
}
rp.SetTitle(branch, version)
if err := rp.SetDescription(changelogEntry, ReleaseOverrides{}); err != nil {
return nil, err
}
return rp, nil
}
type ReleaseOverrides struct {
Prefix string
Suffix string
NextVersionType versioning.NextVersionType
}
const (
DescriptionLanguagePrefix = "rp-prefix"
DescriptionLanguageSuffix = "rp-suffix"
)
const (
MarkdownSectionChangelog = "changelog"
)
const (
TitleFormat = "chore(%s): release %s"
)
var (
TitleRegex = regexp.MustCompile("chore(.*): release (.*)")
)
func (pr *ReleasePullRequest) GetOverrides() (ReleaseOverrides, error) {
overrides := ReleaseOverrides{}
overrides = pr.parseVersioningFlags(overrides)
overrides, err := pr.parseDescription(overrides)
if err != nil {
return ReleaseOverrides{}, err
}
return overrides, nil
}
func (pr *ReleasePullRequest) parseVersioningFlags(overrides ReleaseOverrides) ReleaseOverrides {
for _, label := range pr.Labels {
switch label {
// Versioning
case LabelNextVersionTypeNormal:
overrides.NextVersionType = versioning.NextVersionTypeNormal
case LabelNextVersionTypeRC:
overrides.NextVersionType = versioning.NextVersionTypeRC
case LabelNextVersionTypeBeta:
overrides.NextVersionType = versioning.NextVersionTypeBeta
case LabelNextVersionTypeAlpha:
overrides.NextVersionType = versioning.NextVersionTypeAlpha
case LabelReleasePending, LabelReleaseTagged:
// These labels have no effect on the versioning.
continue
}
}
return overrides
}
func (pr *ReleasePullRequest) parseDescription(overrides ReleaseOverrides) (ReleaseOverrides, error) {
source := []byte(pr.Description)
err := markdown.WalkAST(source,
markdown.GetCodeBlockText(source, DescriptionLanguagePrefix, &overrides.Prefix, nil),
markdown.GetCodeBlockText(source, DescriptionLanguageSuffix, &overrides.Suffix, nil),
)
if err != nil {
return ReleaseOverrides{}, err
}
return overrides, nil
}
func (pr *ReleasePullRequest) ChangelogText() (string, error) {
source := []byte(pr.Description)
var sectionText string
err := markdown.WalkAST(source, markdown.GetSectionText(source, MarkdownSectionChangelog, &sectionText))
if err != nil {
return "", err
}
return sectionText, nil
}
func (pr *ReleasePullRequest) SetTitle(branch, version string) {
pr.Title = fmt.Sprintf(TitleFormat, branch, version)
}
func (pr *ReleasePullRequest) Version() (string, error) {
matches := TitleRegex.FindStringSubmatch(pr.Title)
if len(matches) != 3 {
return "", fmt.Errorf("title has unexpected format")
}
return matches[2], nil
}
func (pr *ReleasePullRequest) SetDescription(changelogEntry string, overrides ReleaseOverrides) error {
var description bytes.Buffer
err := releasePRTemplate.Execute(&description, map[string]any{
"Changelog": changelogEntry,
"Overrides": overrides,
})
if err != nil {
return err
}
pr.Description = description.String()
return nil
}

View file

@ -1,191 +0,0 @@
package releasepr
import (
"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"
)
func TestReleasePullRequest_GetOverrides(t *testing.T) {
tests := []struct {
name string
pr ReleasePullRequest
want ReleaseOverrides
wantErr assert.ErrorAssertionFunc
}{
{
name: "empty",
pr: ReleasePullRequest{},
want: ReleaseOverrides{},
wantErr: assert.NoError,
},
{
// TODO: Test for multiple version flags
name: "single version flag",
pr: ReleasePullRequest{
Labels: []Label{LabelNextVersionTypeAlpha},
},
want: ReleaseOverrides{
NextVersionType: versioning.NextVersionTypeAlpha,
},
wantErr: assert.NoError,
},
{
name: "prefix in description",
pr: ReleasePullRequest{
PullRequest: git.PullRequest{
Description: testdata.MustReadFileString(t, "description-prefix.txt"),
},
},
want: ReleaseOverrides{
Prefix: testdata.MustReadFileString(t, "prefix.txt"),
},
wantErr: assert.NoError,
},
{
name: "suffix in description",
pr: ReleasePullRequest{
PullRequest: git.PullRequest{
Description: testdata.MustReadFileString(t, "description-suffix.txt"),
},
},
want: ReleaseOverrides{
Suffix: testdata.MustReadFileString(t, "suffix.txt"),
},
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := tt.pr.GetOverrides()
if !tt.wantErr(t, err, "GetOverrides()") {
return
}
assert.Equalf(t, tt.want, got, "GetOverrides()")
})
}
}
func TestReleasePullRequest_ChangelogText(t *testing.T) {
tests := []struct {
name string
description string
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no section",
description: "# Foo\n",
want: "",
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 {
t.Run(tt.name, func(t *testing.T) {
pr := &ReleasePullRequest{
PullRequest: git.PullRequest{
Description: tt.description,
},
}
got, err := pr.ChangelogText()
if !tt.wantErr(t, err, "ChangelogText()") {
return
}
assert.Equalf(t, tt.want, got, "ChangelogText()")
})
}
}
func TestReleasePullRequest_SetTitle(t *testing.T) {
type args struct {
branch string
version string
}
tests := []struct {
name string
pr *ReleasePullRequest
args args
want string
}{
{
name: "simple update",
pr: &ReleasePullRequest{
PullRequest: git.PullRequest{
Title: "foo: bar",
},
},
args: args{
branch: "main",
version: "v1.0.0",
},
want: "chore(main): release v1.0.0",
},
{
name: "no previous title",
pr: &ReleasePullRequest{},
args: args{
branch: "release-1.x",
version: "v1.1.1-rc.0",
},
want: "chore(release-1.x): release v1.1.1-rc.0",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tt.pr.SetTitle(tt.args.branch, tt.args.version)
assert.Equal(t, tt.want, tt.pr.Title)
})
}
}
func TestReleasePullRequest_SetDescription(t *testing.T) {
tests := []struct {
name string
changelogEntry string
overrides ReleaseOverrides
want string
wantErr assert.ErrorAssertionFunc
}{
{
name: "no overrides",
changelogEntry: `## v1.0.0`,
overrides: ReleaseOverrides{},
want: testdata.MustReadFileString(t, "description-no-overrides.txt"),
wantErr: assert.NoError,
},
{
name: "existing overrides",
changelogEntry: `## v1.0.0`,
overrides: ReleaseOverrides{
Prefix: testdata.MustReadFileString(t, "prefix.txt"),
Suffix: testdata.MustReadFileString(t, "suffix.txt"),
},
want: testdata.MustReadFileString(t, "description-overrides.txt"),
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
pr := &ReleasePullRequest{}
err := pr.SetDescription(tt.changelogEntry, tt.overrides)
if !tt.wantErr(t, err) {
return
}
assert.Equal(t, tt.want, pr.Description)
})
}
}

View file

@ -1,7 +0,0 @@
This is the changelog
## Awesome
### New
#### Changes

View file

@ -1,19 +0,0 @@
## [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!

View file

@ -1,9 +0,0 @@
## [1.0.0](https://example.com/1.0.0)
### Bug Fixes
- Foobar!
## Compatibility
This version is compatible with flux-compensator v2.2 - v2.9.

View file

@ -1,13 +0,0 @@
# Foobar
<!-- section-start changelog -->
This is the changelog
## Awesome
### New
#### Changes
<!-- section-end changelog -->
Suffix Things

View file

@ -1,28 +0,0 @@
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---
<details>
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
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
~~~~
</details>

View file

@ -1,44 +0,0 @@
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---
<details>
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
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.
~~~~
</details>

View file

@ -1,41 +0,0 @@
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---
<details>
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
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
~~~~
</details>

View file

@ -1,31 +0,0 @@
<!-- section-start changelog -->
## v1.0.0
<!-- section-end changelog -->
---
<details>
<summary><h4>PR by <a href="https://github.com/apricote/releaser-pleaser">releaser-pleaser</a> 🤖</h4></summary>
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.
~~~~
</details>

View file

@ -1,13 +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
}
```

View file

@ -1,3 +0,0 @@
## Compatibility
This version is compatible with flux-compensator v2.2 - v2.9.

View file

@ -1,19 +0,0 @@
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)
}

126
internal/testutils/git.go Normal file
View file

@ -0,0 +1,126 @@
package testutils
import (
"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/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
}
type commitFile struct {
path string
content string
}
type Commit func(*testing.T, *git.Repository) error
type Repo func(*testing.T) *git.Repository
func WithCommit(message string, options ...CommitOption) Commit {
return func(t *testing.T, repo *git.Repository) error {
t.Helper()
require.NotEmpty(t, message, "commit message is required")
opts := &commitOptions{}
for _, opt := range options {
opt(opts)
}
wt, err := repo.Worktree()
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)
}
// 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.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})
}
}
func WithCleanFiles() CommitOption {
return func(opts *commitOptions) {
opts.cleanFiles = true
}
}
func WithTag(name string) CommitOption {
return func(opts *commitOptions) {
opts.tags = append(opts.tags, name)
}
}
func WithTestRepo(commits ...Commit) Repo {
return func(t *testing.T) *git.Repository {
t.Helper()
repo, err := git.Init(memory.NewStorage(), memfs.New())
require.NoError(t, err, "failed to create in-memory repository")
// Make initial commit
err = WithCommit("chore: init")(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
}
}

View file

@ -1,47 +0,0 @@
package updater
import (
"fmt"
"regexp"
)
const (
ChangelogHeader = "# Changelog"
ChangelogFile = "CHANGELOG.md"
)
var (
ChangelogUpdaterHeaderRegex = regexp.MustCompile(`^# Changelog\n`)
)
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 {
return "", fmt.Errorf("unexpected format of CHANGELOG.md, header does not match")
}
if headerIndex != nil {
// Remove the header from the content
content = content[headerIndex[1]:]
}
content = ChangelogHeader + "\n\n" + info.ChangelogEntry + content
return content, nil
}
}

View file

@ -1,68 +0,0 @@
package updater
import (
"testing"
"github.com/stretchr/testify/assert"
)
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",
content: "",
info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n"},
want: "# Changelog\n\n## v1.0.0\n",
wantErr: assert.NoError,
},
{
name: "well-formatted changelog",
content: `# Changelog
## v0.0.1
- Bazzle
## v0.1.0
### Bazuuum
`,
info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"},
want: `# Changelog
## v1.0.0
- Version 1, juhu.
## v0.0.1
- Bazzle
## v0.1.0
### Bazuuum
`,
wantErr: assert.NoError,
},
{
name: "error on invalid header",
content: "What even is this file?",
info: ReleaseInfo{ChangelogEntry: "## v1.0.0\n\n- Version 1, juhu.\n"},
want: "",
wantErr: assert.Error,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runUpdaterTest(t, Changelog(), tt)
})
}
}

View file

@ -1,35 +0,0 @@
package updater
import (
"regexp"
"strings"
)
var GenericUpdaterSemVerRegex = regexp.MustCompile(`\d+\.\d+\.\d+(-[\w.]+)?(.*x-releaser-pleaser-version)`)
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")
return GenericUpdaterSemVerRegex.ReplaceAllString(content, version+"${2}"), nil
}
}

View file

@ -1,61 +0,0 @@
package updater
import (
"testing"
"github.com/stretchr/testify/assert"
)
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",
content: "v1.0.0 // x-releaser-pleaser-version",
info: ReleaseInfo{
Version: "v1.2.0",
},
want: "v1.2.0 // x-releaser-pleaser-version",
wantErr: assert.NoError,
},
{
name: "multiline line",
content: "Foooo\n\v1.2.0\nv1.0.0 // x-releaser-pleaser-version\n",
info: ReleaseInfo{
Version: "v1.2.0",
},
want: "Foooo\n\v1.2.0\nv1.2.0 // x-releaser-pleaser-version\n",
wantErr: assert.NoError,
},
{
name: "invalid existing version",
content: "1.0 // x-releaser-pleaser-version",
info: ReleaseInfo{
Version: "v1.2.0",
},
want: "1.0 // x-releaser-pleaser-version",
wantErr: assert.NoError,
},
{
name: "complicated line",
content: "version: v1.2.0-alpha.1 => Awesome, isnt it? x-releaser-pleaser-version foobar",
info: ReleaseInfo{
Version: "v1.2.0",
},
want: "version: v1.2.0 => Awesome, isnt it? x-releaser-pleaser-version foobar",
wantErr: assert.NoError,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
runUpdaterTest(t, Generic([]string{"version.txt"}), tt)
})
}
}

View file

@ -1,39 +0,0 @@
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
}
}

View file

@ -1,62 +0,0 @@
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)
})
}
}

View file

@ -1,23 +0,0 @@
package updater
type ReleaseInfo struct {
Version string
ChangelogEntry string
}
type Updater interface {
Files() []string
CreateNewFiles() bool
Update(info ReleaseInfo) func(content string) (string, error)
}
type NewUpdater func(ReleaseInfo) Updater
func WithInfo(info ReleaseInfo, constructors ...NewUpdater) []Updater {
updaters := make([]Updater, 0, len(constructors))
for _, constructor := range constructors {
updaters = append(updaters, constructor(info))
}
return updaters
}

Some files were not shown because too many files have changed in this diff Show more