mirror of
https://github.com/apricote/releaser-pleaser.git
synced 2026-01-13 21:21:03 +00:00
Compare commits
269 commits
v0.2.0-bet
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
944b70cee9 | ||
|
|
2a1a08057b | ||
|
|
60cbffba9d | ||
|
|
6ef1405140 | ||
|
|
d9c8d3e5af | ||
|
|
b48c9a654f | ||
|
|
163eaf31a6 | ||
|
|
804cf8040a | ||
|
|
60d9aa3982 | ||
|
|
8d6175c13b | ||
|
|
6dfa96a9ba | ||
|
|
fb2a0b8167 | ||
|
|
db611e5cc2 | ||
|
|
b451c08634 | ||
|
|
9592a6a975 | ||
|
|
e5bccc9fb9 | ||
|
|
995f4beb9a | ||
|
|
ec851d7511 | ||
|
|
ad845e61c7 | ||
|
|
0f040ff8e7 | ||
|
|
6dd0424029 | ||
|
|
291581ef6d | ||
|
|
29a033103d | ||
|
|
c1c2111e03 | ||
|
|
c4796a546e | ||
|
|
cb92e2b67f | ||
|
|
23e9d06c6e | ||
|
|
0129c78abc | ||
|
|
226548adfa | ||
|
|
0d2efe0a6d | ||
|
|
cead3fb5c1 | ||
|
|
918249a0d3 | ||
|
|
f24b69e8fa | ||
|
|
d95b779f83 | ||
|
|
e32838e3d0 | ||
|
|
8607cb6f71 | ||
|
|
ff899fe9e8 | ||
|
|
0d16c770d3 | ||
|
|
21e3fdbcad | ||
|
|
71364599d8 | ||
| fa27415be5 | |||
|
|
b0c50518b3 | ||
|
|
612928a382 | ||
|
|
93bb42e781 | ||
|
|
63a8b91b34 | ||
|
|
d0a44e3fb8 | ||
|
|
63acf9aa3a | ||
|
|
250cc6c2aa | ||
|
|
ef94c3c8f7 | ||
|
|
f300bbb6b0 | ||
|
|
cc627599db | ||
|
|
8c6b99560c | ||
|
|
2c1d29f639 | ||
| fcf7906149 | |||
|
|
afef176e37 | ||
|
|
e3d2cfa6b8 | ||
|
|
6b9738bcea | ||
|
|
e7950cfbc1 | ||
|
|
bccfa93a15 | ||
|
|
3f9108e702 | ||
|
|
5ea1798a68 | ||
|
|
de80649838 | ||
|
|
f765abbb92 | ||
|
|
132f62d82d | ||
|
|
1d2f74752b | ||
|
|
84b3acbe4d | ||
|
|
c4610b8e5e | ||
|
|
ce56ac0cd1 | ||
|
|
69be23dd6f | ||
| 852c08ed3d | |||
| e1afa22e0a | |||
| f077b647e7 | |||
| 44b76e55f8 | |||
| e83a7c9a23 | |||
|
|
563885899c | ||
|
|
16ba2c6b09 | ||
|
|
e6c8f3f93b | ||
| e6503da93a | |||
|
|
5b5b29c0b5 | ||
| c768260a2e | |||
| b3cb9e128c | |||
| d259921215 | |||
| 48b1894cac | |||
| 5306e2dd35 | |||
| f1aa1a2ef4 | |||
|
|
1e9e0aa5d9 | ||
|
|
6237c9b666 | ||
|
|
2f7e8b9afe | ||
|
|
73d9c877b0 | ||
|
|
014ec7b723 | ||
|
|
dbde726d15 | ||
|
|
6e2c754376 | ||
|
|
eb6c687737 | ||
|
|
2fe0f0e5b6 | ||
|
|
fd903e056c | ||
|
|
763a5defac | ||
|
|
e73bf82a92 | ||
|
|
1fad5e6264 | ||
|
|
785c29deb2 | ||
|
|
273107b9af | ||
|
|
64874f9089 | ||
|
|
942aa80aa9 | ||
| 8eb7eadc4e | |||
|
|
bcca36e856 | ||
|
|
75fe90ab6e | ||
|
|
3e77b7e0d9 | ||
|
|
a7347bc191 | ||
|
|
6e97e0d601 | ||
|
|
48b8696efc | ||
|
|
dfe39868ac | ||
|
|
5a273f9ab5 | ||
| fc1ee70c28 | |||
| 0de242a4e6 | |||
| d540e2221d | |||
| 2d3a960939 | |||
| d24ae7de98 | |||
| 08d35f2f57 | |||
| eae0045359 | |||
| 50b2762dca | |||
|
|
983162d26e | ||
|
|
e6e9779e87 | ||
| 5f1849106c | |||
| 81a855f5ab | |||
| 175d6d0633 | |||
|
|
f2786c8f39 | ||
| 1779356543 | |||
|
|
ad13dc24e0 | ||
|
|
d91d93fc8c | ||
|
|
e3ecd8993c | ||
|
|
49855aa700 | ||
|
|
f49481cd92 | ||
|
|
359912dcc0 | ||
|
|
1f882bf014 | ||
|
|
31b12b9c33 | ||
|
|
9c8b854de0 | ||
|
|
c1b0f15e07 | ||
|
|
58a9f1c9d5 | ||
| fe3c9488b3 | |||
| d9c081d280 | |||
|
|
9c95dd558b | ||
|
|
c31e40d04b | ||
|
|
d93378a72e | ||
|
|
86207b80f2 | ||
|
|
b658a3a531 | ||
|
|
85ff2126b1 | ||
|
|
377ec44cd3 | ||
|
|
7fe19174db | ||
|
|
3ed3a1856c | ||
|
|
9d0cfc7c83 | ||
|
|
1bb296a509 | ||
|
|
00986532b9 | ||
|
|
f9ba6daa42 | ||
|
|
1be7bf0f76 | ||
|
|
a594ac0373 | ||
|
|
98e60583a4 | ||
|
|
4dd80c9492 | ||
|
|
d6262d8ecb | ||
|
|
6c57ad0fbb | ||
|
|
d8841d3fab | ||
|
|
e795d16489 | ||
|
|
d22f87ecc2 | ||
|
|
761eede169 | ||
|
|
510f62f75d | ||
|
|
da66bd0cc4 | ||
|
|
11c61e9dbd | ||
|
|
a54d44673d | ||
|
|
b2a1754432 | ||
|
|
871f69acbe | ||
|
|
34ca528570 | ||
|
|
7a92e82d94 | ||
|
|
6802aad634 | ||
|
|
0774353639 | ||
|
|
2526149c16 | ||
|
|
28a71f54d4 | ||
|
|
42062bf401 | ||
|
|
23bf944b6d | ||
|
|
cc69c719cb | ||
|
|
da6fe2a380 | ||
|
|
d26d068626 | ||
|
|
16006339a1 | ||
|
|
a85e3a17e5 | ||
|
|
928472b7eb | ||
|
|
57d2ab8b26 | ||
|
|
4e784f2ccc | ||
|
|
093f0f97bd | ||
|
|
e7db5b2e66 | ||
|
|
4af9f88382 | ||
|
|
0ad9250a6a | ||
|
|
25c6cffa76 | ||
|
|
05b492fa0e | ||
|
|
b1180a17ba | ||
|
|
b5819a2c6a | ||
| b617232797 | |||
| dd166ec446 | |||
| ef1d92cff0 | |||
| 11f8403241 | |||
| e9b3ba8ea2 | |||
|
|
6c5bdfeee8 | ||
| 0ae2d909bc | |||
|
|
faf28fd314 | ||
|
|
147148c891 | ||
|
|
79c5b13e1f | ||
|
|
b6d6270d9e | ||
|
|
59aa7aae02 | ||
|
|
b55cba293f | ||
| 05be3684c6 | |||
| cbfacc894b | |||
|
|
71351140f6 | ||
|
|
8c7b9fcf93 | ||
|
|
db4aebcc73 | ||
|
|
8493c5a625 | ||
| 1883466c3e | |||
| 3caa7364ee | |||
|
|
763018ff9b | ||
|
|
acff7ea830 | ||
|
|
2a4f2b97d1 | ||
|
|
755d9b125b | ||
|
|
de4f26225a | ||
|
|
da6257e618 | ||
|
|
cd412ba59f | ||
|
|
40a15dfafa | ||
|
|
9bb117c7b9 | ||
| 4cc45ea244 | |||
| 89dc9e3fe8 | |||
| 55083f2a59 | |||
| 08505a55cd | |||
| 1a370c39dc | |||
| 2621c48d75 | |||
|
|
997b6492de | ||
|
|
937b885696 | ||
|
|
4402612538 | ||
|
|
6a2f536650 | ||
|
|
90685994d7 | ||
| 7bd752c2f5 | |||
| dc1903c4b4 | |||
| 2567293368 | |||
| 7a3d46eac7 | |||
| 8d7b1c9580 | |||
| 61cf12a052 | |||
| 7b49e8ea0c | |||
| da0c07497b | |||
| 84d4dd9d26 | |||
| ee83cec049 | |||
| 634eac3b76 | |||
| ee5c7aa142 | |||
| 2fba5414e5 | |||
| 48d9ede0a2 | |||
| 5ea41654a7 | |||
| 2010ac1143 | |||
| af505c94c6 | |||
| 0a199e693f | |||
| b9dd0f986c | |||
| 2effe5e72d | |||
| 36a0b90bcd | |||
| 0750bd6b46 | |||
| 4cb22eae10 | |||
| a0a064d387 | |||
| 44184a77f9 | |||
| 971b6e6ef7 | |||
| 693ca21e32 | |||
| 1f39df03c5 | |||
| 57a1d80600 | |||
| 499f127b9e | |||
| 2567f0ae8b | |||
| 2cd73a8679 | |||
| 1ede0bef10 | |||
| a67b510284 | |||
| 3e51dd8495 | |||
| a841447063 |
120 changed files with 7037 additions and 3221 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
*.png filter=lfs diff=lfs merge=lfs -text
|
||||
86
.github/renovate.json5
vendored
Normal file
86
.github/renovate.json5
vendored
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
{
|
||||
extends: [
|
||||
':semanticCommits',
|
||||
':semanticCommitTypeAll(deps)',
|
||||
':semanticCommitScopeDisabled',
|
||||
':dependencyDashboard',
|
||||
':approveMajorUpdates',
|
||||
':automergeMinor',
|
||||
':automergeLinters',
|
||||
':automergeTesters',
|
||||
':automergeTypes',
|
||||
':maintainLockFilesWeekly',
|
||||
':enableVulnerabilityAlerts',
|
||||
'helpers:pinGitHubActionDigests',
|
||||
],
|
||||
packageRules: [
|
||||
{
|
||||
groupName: 'linters',
|
||||
matchUpdateTypes: [
|
||||
'minor',
|
||||
'patch',
|
||||
],
|
||||
matchDepNames: [
|
||||
'golangci/golangci-lint',
|
||||
],
|
||||
automerge: true,
|
||||
},
|
||||
{
|
||||
groupName: 'testing',
|
||||
matchUpdateTypes: [
|
||||
'minor',
|
||||
'patch',
|
||||
],
|
||||
matchDepNames: [
|
||||
'github.com/stretchr/testify',
|
||||
],
|
||||
automerge: true,
|
||||
},
|
||||
{
|
||||
groupName: 'github-actions',
|
||||
matchUpdateTypes: [
|
||||
'minor',
|
||||
'patch',
|
||||
],
|
||||
matchDepTypes: [
|
||||
'action',
|
||||
],
|
||||
automerge: true,
|
||||
},
|
||||
{
|
||||
groupName: 'gitlab-ci',
|
||||
matchUpdateTypes: [
|
||||
'minor',
|
||||
'patch',
|
||||
],
|
||||
matchPackageNames: [
|
||||
'registry.gitlab.com/gitlab-org/release-cli',
|
||||
],
|
||||
automerge: true,
|
||||
},
|
||||
],
|
||||
customManagers: [
|
||||
{
|
||||
customType: 'regex',
|
||||
managerFilePatterns: [
|
||||
'/.+\\.ya?ml$/',
|
||||
],
|
||||
matchStrings: [
|
||||
': (?<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',
|
||||
],
|
||||
}
|
||||
60
.github/workflows/ci.yaml
vendored
60
.github/workflows/ci.yaml
vendored
|
|
@ -2,58 +2,76 @@ name: ci
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
branches: [ main ]
|
||||
pull_request:
|
||||
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v6
|
||||
uses: golangci/golangci-lint-action@4afd733a84b1f43292c63897423277bb7f4313a9 # v8
|
||||
with:
|
||||
version: v1.60.1 # renovate: datasource=github-releases depName=golangci/golangci-lint
|
||||
install-mode: none
|
||||
args: --timeout 5m
|
||||
|
||||
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
|
||||
|
||||
- name: Run tests
|
||||
run: go test -v -race -coverpkg=./... -coverprofile=coverage.txt ./...
|
||||
|
||||
- name: Upload results to Codecov
|
||||
uses: codecov/codecov-action@v4
|
||||
uses: codecov/codecov-action@5a1091511ad55cbe89839c7260b706298ca349f7 # v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: unit
|
||||
|
||||
test-e2e-forgejo:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@08eba0b27e820071cde6df949e0beb9ba4906955 # v4
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
|
||||
# We can not use "jobs.<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@v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
|
||||
|
||||
- name: Run go mod tidy
|
||||
run: go mod tidy
|
||||
|
|
|
|||
37
.github/workflows/docs.yaml
vendored
Normal file
37
.github/workflows/docs.yaml
vendored
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
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
|
||||
30
.github/workflows/mirror.yaml
vendored
Normal file
30
.github/workflows/mirror.yaml
vendored
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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
|
||||
16
.github/workflows/release.yaml
vendored
16
.github/workflows/release.yaml
vendored
|
|
@ -14,12 +14,16 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version-file: go.mod
|
||||
- uses: jdx/mise-action@5ac50f778e26fac95da98d50503682459e86d566 # v3
|
||||
|
||||
- name: Prepare ko
|
||||
run: |
|
||||
echo "${{ github.token }}" | ko login ghcr.io --username "dummy" --password-stdin
|
||||
|
||||
repo=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
echo "KO_DOCKER_REPO=ghcr.io/${repo}"
|
||||
echo "KO_DOCKER_REPO=ghcr.io/${repo}" >> $GITHUB_ENV
|
||||
|
||||
- uses: ko-build/setup-ko@v0.7
|
||||
- run: ko build --bare --tags ${{ github.ref_name }} github.com/apricote/releaser-pleaser/cmd/rp
|
||||
|
|
|
|||
32
.github/workflows/releaser-pleaser.yaml
vendored
32
.github/workflows/releaser-pleaser.yaml
vendored
|
|
@ -2,23 +2,47 @@ name: releaser-pleaser
|
|||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
# Using pull_request_target to avoid tainting the actual release PR with code from open feature pull requests
|
||||
pull_request_target:
|
||||
types:
|
||||
- edited
|
||||
- labeled
|
||||
- unlabeled
|
||||
|
||||
permissions: {}
|
||||
# Only one job needs to run at a time, if a new job is started there is probably new data to include in the response, so
|
||||
# it does not make sense to finish the previous job. This also helps with "data-race conflicts", where a human changes
|
||||
# the PR description but releaser-pleaser was already running and overwrites the humans changes.
|
||||
concurrency:
|
||||
group: releaser-pleaser
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions: { }
|
||||
|
||||
jobs:
|
||||
releaser-pleaser:
|
||||
# TODO: if: push or pull_request.closed
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5
|
||||
with:
|
||||
ref: main
|
||||
|
||||
- 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
|
||||
|
|
|
|||
15
.gitlab-ci.yml
Normal file
15
.gitlab-ci.yml
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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"
|
||||
|
|
@ -1,14 +1,38 @@
|
|||
version: "2"
|
||||
linters:
|
||||
presets:
|
||||
- bugs
|
||||
- error
|
||||
- import
|
||||
- metalinter
|
||||
- module
|
||||
- unused
|
||||
|
||||
enable:
|
||||
- asasalint
|
||||
- asciicheck
|
||||
- bidichk
|
||||
- bodyclose
|
||||
- contextcheck
|
||||
- durationcheck
|
||||
- errchkjson
|
||||
- errorlint
|
||||
- exhaustive
|
||||
- gocheckcompilerdirectives
|
||||
- gochecksumtype
|
||||
- gocritic
|
||||
- gomoddirectives
|
||||
- gomodguard
|
||||
- gosec
|
||||
- gosmopolitan
|
||||
- loggercheck
|
||||
- makezero
|
||||
- musttag
|
||||
- nilerr
|
||||
- nilnesserr
|
||||
- noctx
|
||||
- protogetter
|
||||
- reassign
|
||||
- recvcheck
|
||||
- rowserrcheck
|
||||
- spancheck
|
||||
- sqlclosecheck
|
||||
- testifylint
|
||||
- unparam
|
||||
- zerologlint
|
||||
- revive
|
||||
|
||||
disable:
|
||||
# preset error
|
||||
|
|
@ -18,10 +42,23 @@ linters:
|
|||
# preset import
|
||||
- depguard
|
||||
|
||||
linters-settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- localmodule
|
||||
settings:
|
||||
revive:
|
||||
rules:
|
||||
- name: exported
|
||||
disabled: true
|
||||
|
||||
gomoddirectives:
|
||||
replace-allow-list:
|
||||
- codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2
|
||||
|
||||
formatters:
|
||||
enable:
|
||||
- gci
|
||||
- goimports
|
||||
settings:
|
||||
gci:
|
||||
sections:
|
||||
- standard
|
||||
- default
|
||||
- localmodule
|
||||
|
|
|
|||
5
.ko.yaml
5
.ko.yaml
|
|
@ -1,3 +1,6 @@
|
|||
defaultPlatforms:
|
||||
- linux/arm64
|
||||
- linux/amd64
|
||||
- linux/amd64
|
||||
|
||||
# Need a shell for gitlab-ci
|
||||
defaultBaseImage: cgr.dev/chainguard/busybox
|
||||
|
|
|
|||
187
CHANGELOG.md
187
CHANGELOG.md
|
|
@ -1,6 +1,189 @@
|
|||
# Changelog
|
||||
|
||||
## [v0.7.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.7.1)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- using code blocks within release-notes (#275)
|
||||
- no html escaping for changelog template (#277)
|
||||
|
||||
## [v0.7.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.7.0)
|
||||
|
||||
### Highlights :sparkles:
|
||||
|
||||
#### Update version in `package.json`
|
||||
|
||||
Thanks to @Mattzi it is now possible to use `releaser-pleaser` in Javascript/Node.js projects with a `package.json` file.
|
||||
|
||||
You can enable this with the option `updaters: packagejson` in the GitHub Actions / GitLab CI/CD config.
|
||||
|
||||
All updaters, including the defaults `changelog` and `generic` can now be enabled and disabled through this field. You can find a full list in the [documentation](https://apricote.github.io/releaser-pleaser/reference/updaters.html).
|
||||
|
||||
### Features
|
||||
|
||||
- add updater for package.json (#213)
|
||||
- highlight breaking changes in release notes (#234)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- filter out empty updaters in input (#235)
|
||||
- **github**: duplicate release pr when process is stopped at wrong moment (#236)
|
||||
|
||||
## [v0.6.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.6.1)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **gitlab**: support fast-forward merges (#210)
|
||||
|
||||
## [v0.6.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.6.0)
|
||||
|
||||
### ✨ Highlights
|
||||
|
||||
#### Reduced resource usage
|
||||
|
||||
`releaser-pleaser` now uses less resources:
|
||||
|
||||
- It now skips pushing changes to the release pull request if they are only a rebase.
|
||||
- The configurations for GitHub Actions and GitLab CI/CD now makes sure that only a single job is running at the same time. On GitHub unnecessary/duplicate jobs are also automatically aborted.
|
||||
- It handles the stop signals from the CI environment and tries to exit quickly.
|
||||
|
||||
\```yaml
|
||||
concurrency:
|
||||
group: releaser-pleaser
|
||||
cancel-in-progress: true
|
||||
\```
|
||||
|
||||
#### Avoid losing manual edits to release pull request
|
||||
|
||||
Before, releaser-pleaser was prone to overwriting user changes to the release pull request if they were made after releaser-pleaser already started running. There is now an additional check right before submitting the changes to see if the description changed, and retry if it did.
|
||||
|
||||
#### Proper commit authorship
|
||||
|
||||
Before, the release commits were created by `releaser-pleaser <>`. This was ugly to look at. We now check for details on the API user used to talk to the forge, and use that users details instead as the commit author. The committer is still `releaser-pleaser`.
|
||||
|
||||
### Features
|
||||
|
||||
- real user as commit author (#187)
|
||||
- avoid pushing release branch only for rebasing (#114)
|
||||
- colorize log output (#195)
|
||||
- graceful shutdown when CI job is cancelled (#196)
|
||||
- detect changed pull request description and retry process (#197)
|
||||
- run one job concurrently to reduce chance of conflicts (#198)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- crash when running in repo without any tags (#190)
|
||||
|
||||
## [v0.5.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.5.1)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- invalid version for subsequent pre-releases (#174)
|
||||
|
||||
## [v0.5.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.5.0)
|
||||
|
||||
### Features
|
||||
|
||||
- **gitlab**: make job dependencies configurable and run immediately (#101)
|
||||
- **github**: mark pre-releases correctly (#110)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- use commits with slightly invalid messages in release notes (#105)
|
||||
- create CHANGELOG.md if it does not exist (#108)
|
||||
|
||||
## [v0.4.2](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.2)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **action**: container image reference used wrong syntax (#96)
|
||||
|
||||
## [v0.4.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.1)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **gitlab**: release not created when release pr was squashed (#86)
|
||||
|
||||
## [v0.4.0](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.0)
|
||||
|
||||
### ✨ Highlights
|
||||
|
||||
#### GitLab Support
|
||||
|
||||
You can now use `releaser-pleaser` with projects hosted on GitLab.com and self-managed GitLab installations. Check out the new [tutorial](https://apricote.github.io/releaser-pleaser/tutorials/gitlab.html) to get started.
|
||||
|
||||
### Features
|
||||
|
||||
- add support for GitLab repositories (#49)
|
||||
- add shell to container image (#59)
|
||||
- **gitlab**: add CI/CD component (#55)
|
||||
- **changelog**: omit version heading in forge release notes
|
||||
- **gitlab**: support self-managed instances (#75)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **parser**: continue on unparsable commit message (#48)
|
||||
- **cli**: command name in help output (#52)
|
||||
- **parser**: invalid handling of empty lines (#53)
|
||||
- multiple extra-files are not evaluated properly (#61)
|
||||
|
||||
## [v0.4.0-beta.1](https://github.com/apricote/releaser-pleaser/releases/tag/v0.4.0-beta.1)
|
||||
|
||||
### Features
|
||||
|
||||
- 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)
|
||||
|
|
@ -11,6 +194,7 @@
|
|||
- **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)
|
||||
|
|
@ -20,13 +204,14 @@
|
|||
- **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
|
||||
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -1,6 +1,14 @@
|
|||
# releaser-pleaser
|
||||
|
||||
`releaser-pleaser` is a tool designed to automate versioning and changelog management for your projects. Building on the concepts of [`release-please`](https://github.com/googleapis/release-please), it streamlines the release process through GitHub Actions or GitLab CI.
|
||||
<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>
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -14,20 +22,20 @@
|
|||
|
||||
`releaser-pleaser` simplifies release management, allowing maintainers to focus on development while ensuring consistent and well-documented releases.
|
||||
|
||||
## Status
|
||||
|
||||
This project is still under active development. You can not reasonably use it right now and not all features advertised above work. Keep your eyes open for any releases.
|
||||
|
||||
## Relation to `release-please`
|
||||
|
||||
After using `release-please` for 1.5 years, I've found it to be the best tool for low-effort releases currently available. While I appreciate many of its features, I identified several additional capabilities that would significantly enhance my workflow. Although it might be possible to incorporate these features into `release-please`, I decided to channel my efforts into creating a new tool that specifically addresses my needs.
|
||||
After using
|
||||
`release-please` for 1.5 years, I've found it to be the best tool for low-effort releases currently available. While I appreciate many of its features, I identified several additional capabilities that would significantly enhance my workflow. Although it might be possible to incorporate these features into
|
||||
`release-please`, I decided to channel my efforts into creating a new tool that specifically addresses my needs.
|
||||
|
||||
Key differences in `releaser-pleaser` include:
|
||||
|
||||
- Support for multiple forges (both GitHub and GitLab)
|
||||
- Better support for pre-releases
|
||||
|
||||
One notable limitation of `release-please` is its deep integration with the GitHub API, making the addition of support for other platforms (like GitLab) a substantial undertaking. `releaser-pleaser` aims to overcome this limitation by design, offering a more versatile solution for automated release management across different platforms and project requirements.
|
||||
One notable limitation of
|
||||
`release-please` is its deep integration with the GitHub API, making the addition of support for other platforms (like GitLab) a substantial undertaking.
|
||||
`releaser-pleaser` aims to overcome this limitation by design, offering a more versatile solution for automated release management across different platforms and project requirements.
|
||||
|
||||
## License
|
||||
|
||||
|
|
|
|||
17
action.yml
17
action.yml
|
|
@ -5,26 +5,33 @@ 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 grooming release PRs, defaults to using secrets.GITHUB_TOKEN'
|
||||
description: 'GitHub token for creating and updating 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.'
|
||||
description: 'List of files that are scanned for version references by the generic updater.'
|
||||
required: false
|
||||
default: ""
|
||||
outputs: {}
|
||||
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: { }
|
||||
runs:
|
||||
using: 'docker'
|
||||
image: ghcr.io/apricote/releaser-pleaser:v0.2.0-beta.2 # x-releaser-pleaser-version
|
||||
image: docker://ghcr.io/apricote/releaser-pleaser:v0.7.1 # x-releaser-pleaser-version
|
||||
args:
|
||||
- run
|
||||
- --forge=github
|
||||
- --branch=${{ inputs.branch }}
|
||||
- --extra-files="${{ inputs.extra-files }}"
|
||||
- --updaters="${{ inputs.updaters }}"
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ inputs.token }}
|
||||
GITHUB_TOKEN: "${{ inputs.token }}"
|
||||
GITHUB_USER: "oauth2"
|
||||
|
|
|
|||
58
changelog.go
58
changelog.go
|
|
@ -1,58 +0,0 @@
|
|||
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
|
||||
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
## [{{.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 -}}
|
||||
|
|
@ -1,50 +1,80 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/signal"
|
||||
"runtime/debug"
|
||||
"syscall"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
var logger *slog.Logger
|
||||
func NewRootCmd() *cobra.Command {
|
||||
var cmd = &cobra.Command{
|
||||
Use: "rp",
|
||||
Short: "",
|
||||
Long: ``,
|
||||
Version: version(),
|
||||
SilenceUsage: true, // Makes it harder to find the actual error
|
||||
SilenceErrors: true, // We log manually with slog
|
||||
}
|
||||
|
||||
// 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:
|
||||
cmd.AddCommand(newRunCommand())
|
||||
|
||||
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) { },
|
||||
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
|
||||
}
|
||||
|
||||
// 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() {
|
||||
err := rootCmd.Execute()
|
||||
// Behaviour when cancelling jobs:
|
||||
//
|
||||
// GitHub Actions: https://docs.github.com/en/actions/managing-workflow-runs-and-deployments/managing-workflow-runs/canceling-a-workflow#steps-github-takes-to-cancel-a-workflow-run
|
||||
// 1. SIGINT
|
||||
// 2. Wait 7500ms
|
||||
// 3. SIGTERM
|
||||
// 4. Wait 2500ms
|
||||
// 5. SIGKILL
|
||||
//
|
||||
// GitLab CI/CD: https://gitlab.com/gitlab-org/gitlab-runner/-/merge_requests/4446
|
||||
// 1. SIGTERM
|
||||
// 2. Wait ???
|
||||
// 3. SIGKILL
|
||||
//
|
||||
// We therefore need to listen on SIGINT and SIGTERM
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
go func() {
|
||||
// Make sure to stop listening on signals after receiving the first signal to hand control of the signal back
|
||||
// to the runtime. The Go runtime implements a "force shutdown" if the signal is received again.
|
||||
<-ctx.Done()
|
||||
slog.InfoContext(ctx, "Received shutdown signal, stopping...")
|
||||
stop()
|
||||
}()
|
||||
|
||||
err := NewRootCmd().ExecuteContext(ctx)
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, err.Error())
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func init() {
|
||||
logger = slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{
|
||||
Level: slog.LevelDebug,
|
||||
}))
|
||||
|
||||
// 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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,84 +1,149 @@
|
|||
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"
|
||||
)
|
||||
|
||||
// runCmd represents the run command
|
||||
var runCmd = &cobra.Command{
|
||||
Use: "run",
|
||||
RunE: run,
|
||||
}
|
||||
func newRunCommand() *cobra.Command {
|
||||
var (
|
||||
flagForge string
|
||||
flagBranch string
|
||||
flagOwner string
|
||||
flagRepo string
|
||||
flagExtraFiles string
|
||||
flagUpdaters []string
|
||||
|
||||
var (
|
||||
flagForge string
|
||||
flagBranch string
|
||||
flagOwner string
|
||||
flagRepo string
|
||||
flagExtraFiles string
|
||||
)
|
||||
|
||||
func init() {
|
||||
rootCmd.AddCommand(runCmd)
|
||||
|
||||
// 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,
|
||||
flagAPIURL string
|
||||
flagAPIToken string
|
||||
flagUsername string
|
||||
)
|
||||
|
||||
var forge rp.Forge
|
||||
var cmd = &cobra.Command{
|
||||
Use: "run",
|
||||
RunE: func(cmd *cobra.Command, _ []string) error {
|
||||
ctx := cmd.Context()
|
||||
logger := log.GetLogger(cmd.ErrOrStderr())
|
||||
|
||||
forgeOptions := rp.ForgeOptions{
|
||||
Repository: flagRepo,
|
||||
BaseBranch: flagBranch,
|
||||
var err error
|
||||
|
||||
logger.DebugContext(ctx, "run called",
|
||||
"forge", flagForge,
|
||||
"branch", flagBranch,
|
||||
"owner", flagOwner,
|
||||
"repo", flagRepo,
|
||||
)
|
||||
|
||||
var f forge.Forge
|
||||
|
||||
forgeOptions := forge.Options{
|
||||
Repository: flagRepo,
|
||||
BaseBranch: flagBranch,
|
||||
}
|
||||
|
||||
switch flagForge {
|
||||
case "gitlab":
|
||||
logger.DebugContext(ctx, "using forge GitLab")
|
||||
f, err = gitlab.New(logger, &gitlab.Options{
|
||||
Options: forgeOptions,
|
||||
Path: fmt.Sprintf("%s/%s", flagOwner, flagRepo),
|
||||
})
|
||||
if err != nil {
|
||||
slog.ErrorContext(ctx, "failed to create client", "err", err)
|
||||
return fmt.Errorf("failed to create gitlab client: %w", err)
|
||||
}
|
||||
case "github":
|
||||
logger.DebugContext(ctx, "using forge GitHub")
|
||||
f = github.New(logger, &github.Options{
|
||||
Options: forgeOptions,
|
||||
Owner: flagOwner,
|
||||
Repo: flagRepo,
|
||||
})
|
||||
case "forgejo":
|
||||
logger.DebugContext(ctx, "using forge Forgejo")
|
||||
f, err = forgejo.New(logger, &forgejo.Options{
|
||||
Options: forgeOptions,
|
||||
Owner: flagOwner,
|
||||
Repo: flagRepo,
|
||||
|
||||
APIURL: flagAPIURL,
|
||||
APIToken: flagAPIToken,
|
||||
Username: flagUsername,
|
||||
})
|
||||
if err != nil {
|
||||
logger.ErrorContext(ctx, "failed to create client", "err", err)
|
||||
return fmt.Errorf("failed to create forgejo client: %w", err)
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown --forge: %s", flagForge)
|
||||
}
|
||||
|
||||
extraFiles := parseExtraFiles(flagExtraFiles)
|
||||
|
||||
updaterNames := parseUpdaters(flagUpdaters)
|
||||
updaters := []updater.Updater{}
|
||||
for _, name := range updaterNames {
|
||||
switch name {
|
||||
case "generic":
|
||||
updaters = append(updaters, updater.Generic(extraFiles))
|
||||
case "changelog":
|
||||
updaters = append(updaters, updater.Changelog())
|
||||
case "packagejson":
|
||||
updaters = append(updaters, updater.PackageJson())
|
||||
default:
|
||||
return fmt.Errorf("unknown updater: %s", name)
|
||||
}
|
||||
}
|
||||
|
||||
releaserPleaser := rp.New(
|
||||
f,
|
||||
logger,
|
||||
flagBranch,
|
||||
conventionalcommits.NewParser(logger),
|
||||
versioning.SemVer,
|
||||
extraFiles,
|
||||
updaters,
|
||||
)
|
||||
|
||||
return releaserPleaser.Run(ctx)
|
||||
},
|
||||
}
|
||||
|
||||
switch flagForge { // 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(&flagForge, "forge", "", "")
|
||||
cmd.PersistentFlags().StringVar(&flagBranch, "branch", "main", "")
|
||||
cmd.PersistentFlags().StringVar(&flagOwner, "owner", "", "")
|
||||
cmd.PersistentFlags().StringVar(&flagRepo, "repo", "", "")
|
||||
cmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "")
|
||||
cmd.PersistentFlags().StringSliceVar(&flagUpdaters, "updaters", []string{}, "")
|
||||
|
||||
extraFiles := parseExtraFiles(flagExtraFiles)
|
||||
cmd.PersistentFlags().StringVar(&flagAPIURL, "api-url", "", "")
|
||||
cmd.PersistentFlags().StringVar(&flagAPIToken, "api-token", "", "")
|
||||
cmd.PersistentFlags().StringVar(&flagUsername, "username", "", "")
|
||||
|
||||
releaserPleaser := rp.New(
|
||||
forge,
|
||||
logger,
|
||||
flagBranch,
|
||||
rp.NewConventionalCommitsParser(),
|
||||
rp.SemVerNextVersion,
|
||||
extraFiles,
|
||||
[]rp.Updater{&rp.GenericUpdater{}},
|
||||
)
|
||||
|
||||
return releaserPleaser.Run(ctx)
|
||||
return cmd
|
||||
}
|
||||
|
||||
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))
|
||||
|
|
@ -91,3 +156,26 @@ func parseExtraFiles(input string) []string {
|
|||
|
||||
return extraFiles
|
||||
}
|
||||
|
||||
func parseUpdaters(input []string) []string {
|
||||
names := []string{"changelog", "generic"}
|
||||
|
||||
for _, u := range input {
|
||||
if u == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if strings.HasPrefix(u, "-") {
|
||||
name := u[1:]
|
||||
names = slices.DeleteFunc(names, func(existingName string) bool { return existingName == name })
|
||||
} else {
|
||||
names = append(names, u)
|
||||
}
|
||||
}
|
||||
|
||||
// Make sure we only have unique updaters
|
||||
slices.Sort(names)
|
||||
names = slices.Compact(names)
|
||||
|
||||
return names
|
||||
}
|
||||
|
|
|
|||
104
cmd/rp/cmd/run_test.go
Normal file
104
cmd/rp/cmd/run_test.go
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2,6 +2,7 @@ package main
|
|||
|
||||
import (
|
||||
"github.com/apricote/releaser-pleaser/cmd/rp/cmd"
|
||||
_ "github.com/apricote/releaser-pleaser/internal/log"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
|
|
|||
2
codecov.yaml
Normal file
2
codecov.yaml
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
ignore:
|
||||
- "test"
|
||||
78
commits.go
78
commits.go
|
|
@ -1,78 +0,0 @@
|
|||
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
122
commits_test.go
|
|
@ -1,122 +0,0 @@
|
|||
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
Normal file
1
docs/.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
book
|
||||
32
docs/SUMMARY.md
Normal file
32
docs/SUMMARY.md
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
# 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)
|
||||
8
docs/book.toml
Normal file
8
docs/book.toml
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
[book]
|
||||
language = "en"
|
||||
multilingual = false
|
||||
src = "."
|
||||
title = "releaser-pleaser"
|
||||
|
||||
[output.html]
|
||||
git-repository-url = "https://github.com/apricote/releaser-pleaser"
|
||||
3
docs/changelog.md
Normal file
3
docs/changelog.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Changelog
|
||||
|
||||
{{#include ../CHANGELOG.md:2: }}
|
||||
65
docs/explanation/concurrency-conflicts.md
Normal file
65
docs/explanation/concurrency-conflicts.md
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
# Concurrency and Conflicts
|
||||
|
||||
## Why
|
||||
|
||||
`releaser-pleaser` works on the "shared global state" that is your project on GitHub/GitLab. Each execution reads from that state and makes changes to it. While `releaser-pleaser` is generally [idempotent](https://en.wikipedia.org/wiki/Idempotence), we still need to consider concurrent executions for two reasons: avoiding conflicts and saving resources.
|
||||
|
||||
### Avoiding conflicts
|
||||
|
||||
The [Release Pull Request](release-pr.md) is used by `releaser-pleaser` to show the current release. Users may update the PR description to add additional notes into the Changelog.
|
||||
|
||||
When `releaser-pleaser` is running while the user modifies the Release Pull Request description, `releaser-pleaser` may overwrite the description afterward based on its outdated local copy of the pull request.
|
||||
|
||||
### Saving resources
|
||||
|
||||
While `releaser-pleaser` is idempotent, there is no benefit to running it multiple times in parallel. In the best case, `releaser-pleaser` could be stopped as soon as a new "change" that is relevant to it comes in and restarts based on that new state.
|
||||
|
||||
## Measures taken
|
||||
|
||||
### Concurrency limits in CI environments
|
||||
|
||||
Our default configurations for [GitHub Actions](../tutorials/github.md) and [GitLab CI/CD](../tutorials/gitlab.md) try to limit concurrent `releaser-pleaser` jobs to a single one.
|
||||
|
||||
#### GitHub Actions
|
||||
|
||||
On GitHub Actions, we use a `concurrency.group` to restrict it to a single running job per repository.
|
||||
|
||||
GitHub cancels the currently running job and any other pending ones when a new one is started. This makes sure that `releaser-pleaser` always works with the latest state.
|
||||
|
||||
Users need to enable this in their workflow (included in our GitHub tutorial):
|
||||
|
||||
```yaml
|
||||
concurrency:
|
||||
group: releaser-pleaser
|
||||
cancel-in-progress: true
|
||||
```
|
||||
|
||||
#### GitLab
|
||||
|
||||
On GitLab CI/CD, we use a `resource_group: releaser-pleaser` in our GitLab CI/CD component to restrict it to a single running job per repository. This is part of the component YAML, so users do not need to set this manually.
|
||||
|
||||
There is no easy way to cancel the running job, so we let it proceed and rely on the other measures to safely handle the data. Users can enable "auto-cancel redundant pipelines" if they want, but should consider the ramifications for the rest of their CI carefully before doing so.
|
||||
|
||||
### Graceful shutdown
|
||||
|
||||
When GitHub Actions and GitLab CI/CD cancel jobs, they first sent a signal to the running process (`SIGINT` on GitHub and `SIGTERM` on GitLab). We listen for these signals and initiate a shutdown of the process. This helps save resources by shutting down as fast as possible, but in a controlled manner.
|
||||
|
||||
### Re-checking PR description for conflict
|
||||
|
||||
When `releaser-pleaser` prepares the Release Pull Request, the first step is to check if there is an existing PR already opened. It then reads from this PR to learn if the user modified the release in some way ([Release Notes](../guides/release-notes.md#for-the-release), [Pre-releases](../guides/pre-releases.md)). Based on this, it prepares the commit and the next iteration of the Release Pull Request description. The last step is to update the Release Pull Request description.
|
||||
|
||||
Depending on the time since the last release, a lot of API calls are made to learn about these changes; this can take between a few seconds and a few minutes. If the user makes any changes to the Release Pull Request in this time frame, they are not considered for the next iteration of the description. To make sure that we do not lose these changes, `releaser-pleaser` fetches the Release Pull Request description again right before updating it. In case it changed from the start of the process, the attempt is aborted, and the whole process is retried two times.
|
||||
|
||||
This does not fully eliminate the potential for data loss, but reduces the time frame from multiple seconds (up to minutes) to a few hundred milliseconds.
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Explanation**
|
||||
- [Release Pull Request](release-pr.md)
|
||||
- **Guide**
|
||||
- [Pre-releases](../guides/pre-releases.md)
|
||||
- [Customizing Release Notes](../guides/release-notes.md)
|
||||
- **Tutorial**
|
||||
- [Getting started on GitHub](../tutorials/github.md)
|
||||
- [Getting started on GitLab](../tutorials/gitlab.md)
|
||||
|
||||
21
docs/explanation/release-pr.md
Normal file
21
docs/explanation/release-pr.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
# 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
|
||||
|
||||

|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Guide**
|
||||
- [Pre-releases](../guides/pre-releases.md)
|
||||
- [Release Notes](../guides/release-notes.md)
|
||||
- **Reference**
|
||||
- [Pull Request Options](../reference/pr-options.md)
|
||||
3
docs/explanation/release-pr.png
Normal file
3
docs/explanation/release-pr.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:86cd4b5e6a24a1f77bfa882ea330c8af7f88967011a7adab7e24c236104cefe5
|
||||
size 96399
|
||||
83
docs/guides/github-workflow-permissions.md
Normal file
83
docs/guides/github-workflow-permissions.md
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
# 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.
|
||||
38
docs/guides/pre-releases.md
Normal file
38
docs/guides/pre-releases.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# 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)
|
||||
3
docs/guides/release-notes-collapsible.png
Normal file
3
docs/guides/release-notes-collapsible.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:74f5f39210bdf9c55f7cec64d4b12465b569646efdfbb2e3eb08b32277cd5145
|
||||
size 3357
|
||||
3
docs/guides/release-notes-rp-commits-release-pr.png
Normal file
3
docs/guides/release-notes-rp-commits-release-pr.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9c28226eaa769033a45ca801f1e0655178faf86e7ddd764f470ae79d72c4b3c2
|
||||
size 62031
|
||||
3
docs/guides/release-notes-rp-commits.png
Normal file
3
docs/guides/release-notes-rp-commits.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:04ca48b3250862d282dd54e14c08f9273ada0a49d2300364601799c56b1f6d11
|
||||
size 72105
|
||||
81
docs/guides/release-notes.md
Normal file
81
docs/guides/release-notes.md
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
# 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:
|
||||
|
||||

|
||||
|
||||
In turn, `releaser-pleaser` updates the release pull request like this:
|
||||
|
||||

|
||||
|
||||
### 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:
|
||||
|
||||

|
||||
|
||||
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)
|
||||
67
docs/guides/updating-arbitrary-files.md
Normal file
67
docs/guides/updating-arbitrary-files.md
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
# Updating arbitrary files
|
||||
|
||||
In some situations it makes sense to have the current version committed in files in the repository:
|
||||
|
||||
- Documentation examples
|
||||
- A source-code file that has the version for user agents and introspection
|
||||
- Reference to a container image tag that is built from the repository
|
||||
|
||||
`releaser-pleaser` can automatically update these references in the [Release PR](../explanation/release-pr.md).
|
||||
|
||||
## Markers
|
||||
|
||||
The line that needs to be updated must have the marker
|
||||
`x-releaser-pleaser-version` somewhere after the version that should be updated.
|
||||
|
||||
For example:
|
||||
|
||||
```go
|
||||
// version/version.go
|
||||
|
||||
package version
|
||||
|
||||
const Version = "v1.0.0" // x-releaser-pleaser-version
|
||||
```
|
||||
|
||||
## Extra Files
|
||||
|
||||
You need to tell `releaser-pleaser` which files it should update. This happens through the CI-specific configuration.
|
||||
|
||||
### GitHub Action
|
||||
|
||||
In the GitHub Action you can set the
|
||||
`extra-files` input with a list of the files. They need to be formatted as a single multi-line string with one file path per line:
|
||||
|
||||
```yaml
|
||||
jobs:
|
||||
releaser-pleaser:
|
||||
steps:
|
||||
- uses: apricote/releaser-pleaser@v0.4.0
|
||||
with:
|
||||
extra-files: |
|
||||
version.txt
|
||||
version/version.go
|
||||
docker-compose.yml
|
||||
```
|
||||
|
||||
### GitLab CI/CD Component
|
||||
|
||||
In the GitLab CI/CD Component you can set the
|
||||
`extra-files` input with a list of files. They need to be formatted as a single multi-line string with one file path per line:
|
||||
|
||||
```yaml
|
||||
include:
|
||||
- component: $CI_SERVER_FQDN/apricote/releaser-pleaser/run@v0.4.0
|
||||
inputs:
|
||||
extra-files: |
|
||||
version.txt
|
||||
version/version.go
|
||||
docker-compose.yml
|
||||
```
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- **Reference**
|
||||
- [GitHub Action](../reference/github-action.md#inputs)
|
||||
- [GitLab CI/CD Component](../reference/gitlab-cicd-component.md#inputs)
|
||||
- [Updaters](../reference/updaters.md#generic-updater)
|
||||
3
docs/introduction.md
Normal file
3
docs/introduction.md
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
# Introduction
|
||||
|
||||
{{#include ../README.md:2:}}
|
||||
28
docs/reference/github-action.md
Normal file
28
docs/reference/github-action.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# 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.
|
||||
28
docs/reference/gitlab-cicd-component.md
Normal file
28
docs/reference/gitlab-cicd-component.md
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# GitLab CI/CD Component
|
||||
|
||||
## Reference
|
||||
|
||||
The CI/CD component is available as `$CI_SERVER_FQDN/apricote/releaser-pleaser/run` on gitlab.com.
|
||||
|
||||
It is being distributed through the CI/CD Catalog: [apricote/releaser-pleaser](https://gitlab.com/explore/catalog/apricote/releaser-pleaser).
|
||||
|
||||
## Versions
|
||||
|
||||
The `apricote/releaser-pleaser` action is released together with `releaser-pleaser` and they share the version number.
|
||||
|
||||
The component does not support floating tags (e.g.
|
||||
`v1`) right now ([#31](https://github.com/apricote/releaser-pleaser/issues/31)). You have to use the full version or commit SHA instead:
|
||||
`apricote/releaser-pleaser@v0.4.0`.
|
||||
|
||||
## Inputs
|
||||
|
||||
The following inputs are supported by the component.
|
||||
|
||||
| Input | Description | Default | Example |
|
||||
|------------------------|:---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------:|---------------------------------------------------------------------:|
|
||||
| `branch` | This branch is used as the target for releases. | `main` | `master` |
|
||||
| `token` (**required**) | GitLab access token for creating and updating release PRs | | `$RELEASER_PLEASER_TOKEN` |
|
||||
| `extra-files` | List of files that are scanned for version references by the generic updater. | `""` | <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> |
|
||||
61
docs/reference/glossary.md
Normal file
61
docs/reference/glossary.md
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
# 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.
|
||||
75
docs/reference/pr-options.md
Normal file
75
docs/reference/pr-options.md
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
# 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
|
||||
```
|
||||
33
docs/reference/updaters.md
Normal file
33
docs/reference/updaters.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# Updaters
|
||||
|
||||
There are different updater for different purposes available.
|
||||
|
||||
They each have a name and may be enabled by default. You can configure which updaters are used through the
|
||||
`updaters` input on GitHub Actions and GitLab CI/CD. This is a comma-delimited list of updaters that should be enabled, for updaters that are enabled by default you can remove them by adding a minus before its name:
|
||||
|
||||
```
|
||||
updaters: -generic,packagejson
|
||||
```
|
||||
|
||||
## Changelog
|
||||
|
||||
- **Name**: `changelog`
|
||||
- **Default**: enabled
|
||||
|
||||
This updater creates the `CHANGELOG.md` file and adds new release notes to it.
|
||||
|
||||
## Generic Updater
|
||||
|
||||
- **Name**: `generic`
|
||||
- **Default**: enabled
|
||||
|
||||
This updater can update any file and only needs a marker on the line. It is enabled by default.
|
||||
|
||||
Learn more about this updater in ["Updating arbitrary files"](../guides/updating-arbitrary-files.md).
|
||||
|
||||
## Node.js `package.json` Updater
|
||||
|
||||
- **Name**: `packagejson`
|
||||
- **Default**: disabled
|
||||
|
||||
This updater can update the `version` field in Node.js `package.json` files. The updater is disabled by default.
|
||||
3
docs/tutorials/github-settings-pr.png
Normal file
3
docs/tutorials/github-settings-pr.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3958761dd6d324040566d361b832ffaa3aff30edf6ad4a007a1a4e5bd47c1f79
|
||||
size 55818
|
||||
3
docs/tutorials/github-settings-workflow.png
Normal file
3
docs/tutorials/github-settings-workflow.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:6591829476e418131fe6bbd52aa9886cb49ef6521da4dab5e9d02de6837742d1
|
||||
size 16430
|
||||
76
docs/tutorials/github.md
Normal file
76
docs/tutorials/github.md
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
# 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.
|
||||
|
||||

|
||||
|
||||
### 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.
|
||||
|
||||

|
||||
|
||||
## 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)
|
||||
3
docs/tutorials/gitlab-access-token.png
Normal file
3
docs/tutorials/gitlab-access-token.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:31b485bbe031443c4bfa0d39514dc7e5d524925aa877848def93ee40f69a1897
|
||||
size 146496
|
||||
3
docs/tutorials/gitlab-settings-merge-method.png
Normal file
3
docs/tutorials/gitlab-settings-merge-method.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b853625854582a66ab2438f11e6001a88bcb276225abed536ba68617bde324db
|
||||
size 57583
|
||||
3
docs/tutorials/gitlab-settings-squash.png
Normal file
3
docs/tutorials/gitlab-settings-squash.png
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0ce9b9826229851e961ef55d91cb9ba91ca9ca4d955a932d9ff6b10d04788c29
|
||||
size 41048
|
||||
94
docs/tutorials/gitlab.md
Normal file
94
docs/tutorials/gitlab.md
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
# Getting started on GitLab
|
||||
|
||||
In this tutorial you will learn how to set up `releaser-pleaser` in your GitLab project with GitLab CI.
|
||||
|
||||
> In `releaser-pleaser` documentation we mostly use "Pull Request" (GitHub wording) instead of "Merge Request" (GitLab wording). The GitLab-specific pages are an exception and use "Merge Request".
|
||||
|
||||
## 1. Project Settings
|
||||
|
||||
### 1.1. Merge Requests
|
||||
|
||||
`releaser-pleaser` requires _Fast-forward merges_ and _squashing_. With other merge options it can not reliably find the right merge request for every commit on `main`.
|
||||
|
||||
Open your project settings to page _Merge Requests_:
|
||||
|
||||
> `https://gitlab.com/YOUR-PATH/YOUR-PROJECT/-/settings/merge_requests`
|
||||
|
||||
In the "Merge method" section select "Fast-forward merge":
|
||||
|
||||

|
||||
|
||||
In the "Squash commits when merging" section select "Require":
|
||||
|
||||

|
||||
|
||||
## 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.
|
||||
|
||||

|
||||
|
||||
## 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
659
forge.go
|
|
@ -1,659 +0,0 @@
|
|||
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
52
git.go
|
|
@ -1,52 +0,0 @@
|
|||
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 +0,0 @@
|
|||
package rp
|
||||
51
go.mod
51
go.mod
|
|
@ -1,42 +1,59 @@
|
|||
module github.com/apricote/releaser-pleaser
|
||||
|
||||
go 1.23.0
|
||||
go 1.24.0
|
||||
|
||||
toolchain go1.25.5
|
||||
|
||||
require (
|
||||
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.2.0
|
||||
github.com/blang/semver/v4 v4.0.0
|
||||
github.com/go-git/go-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/go-git/go-billy/v5 v5.7.0
|
||||
github.com/go-git/go-git/v5 v5.16.4
|
||||
github.com/google/go-github/v74 v74.0.0
|
||||
github.com/leodido/go-conventionalcommits v0.12.0
|
||||
github.com/spf13/cobra v1.8.1
|
||||
github.com/stretchr/testify v1.9.0
|
||||
github.com/yuin/goldmark v1.7.4
|
||||
github.com/lmittmann/tint v1.1.2
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/stretchr/testify v1.11.1
|
||||
github.com/teekennedy/goldmark-markdown v0.5.1
|
||||
github.com/yuin/goldmark v1.7.16
|
||||
gitlab.com/gitlab-org/api/client-go v0.161.1
|
||||
)
|
||||
|
||||
require (
|
||||
dario.cat/mergo v1.0.1 // indirect
|
||||
github.com/42wim/httpsig v1.2.3 // indirect
|
||||
github.com/Microsoft/go-winio v0.6.2 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.0.0 // indirect
|
||||
github.com/cloudflare/circl v1.4.0 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.3.1 // indirect
|
||||
github.com/ProtonMail/go-crypto v1.1.6 // indirect
|
||||
github.com/cloudflare/circl v1.6.1 // indirect
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/davidmz/go-pageant v1.0.2 // indirect
|
||||
github.com/emirpasic/gods v1.18.1 // indirect
|
||||
github.com/go-fed/httpsig v1.1.0 // indirect
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
|
||||
github.com/google/go-querystring v1.1.0 // indirect
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
|
||||
github.com/hashicorp/go-retryablehttp v0.7.8 // indirect
|
||||
github.com/hashicorp/go-version v1.7.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
|
||||
github.com/kevinburke/ssh_config v1.2.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.0 // indirect
|
||||
github.com/pjbgf/sha1cd v0.3.2 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 // indirect
|
||||
github.com/sirupsen/logrus v1.9.3 // indirect
|
||||
github.com/skeema/knownhosts v1.3.0 // indirect
|
||||
github.com/spf13/pflag v1.0.5 // indirect
|
||||
github.com/skeema/knownhosts v1.3.1 // indirect
|
||||
github.com/spf13/pflag v1.0.9 // indirect
|
||||
github.com/xanzy/ssh-agent v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.26.0 // indirect
|
||||
golang.org/x/net v0.28.0 // indirect
|
||||
golang.org/x/sys v0.24.0 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/oauth2 v0.30.0 // indirect
|
||||
golang.org/x/sys v0.38.0 // indirect
|
||||
golang.org/x/time v0.12.0 // indirect
|
||||
gopkg.in/warnings.v0 v0.1.2 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
||||
replace codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 => codeberg.org/apricote/forgejo-sdk/forgejo/v2 v2.1.2-0.20250615152743-47d3f0434561
|
||||
|
|
|
|||
171
go.sum
171
go.sum
|
|
@ -1,49 +1,65 @@
|
|||
codeberg.org/apricote/forgejo-sdk/forgejo/v2 v2.1.2-0.20250615152743-47d3f0434561 h1:ZFGmrGQ7cd2mbSLrfjrj3COwPKFfKM6sDO/IsrGDW7w=
|
||||
codeberg.org/apricote/forgejo-sdk/forgejo/v2 v2.1.2-0.20250615152743-47d3f0434561/go.mod h1:2i9GsyawlJtVMO5pTS/Om5uo2O3JN/eCjGWy5v15NGg=
|
||||
dario.cat/mergo v1.0.1 h1:Ra4+bf83h2ztPIQYNP99R6m+Y7KfnARDfID+a+vLl4s=
|
||||
dario.cat/mergo v1.0.1/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
|
||||
github.com/42wim/httpsig v1.2.3 h1:xb0YyWhkYj57SPtfSttIobJUPJZB9as1nsfo7KWVcEs=
|
||||
github.com/42wim/httpsig v1.2.3/go.mod h1:nZq9OlYKDrUBhptd77IHx4/sZZD+IxTBADvAPI9G/EM=
|
||||
github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY=
|
||||
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
|
||||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.0.0 h1:LRuvITjQWX+WIfr930YHG2HNfjR1uOfyf5vE0kC2U78=
|
||||
github.com/ProtonMail/go-crypto v1.0.0/go.mod h1:EjAoLdwvbIOoOQr3ihjnSoLZRtE8azugULFRteWMNc0=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be/go.mod h1:ySMOLuWl6zY27l47sB3qLNK6tF2fkHG55UZxx8oIVo4=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
|
||||
github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
|
||||
github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM=
|
||||
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||
github.com/bwesterb/go-ristretto v1.2.3/go.mod h1:fUIoIZaG73pV5biE2Blr2xEzDoMj7NFEuV9ekS419A0=
|
||||
github.com/cloudflare/circl v1.3.3/go.mod h1:5XYMA4rFBvNIrhs50XuiBJ15vF2pZn4nnUKZrLbUZFA=
|
||||
github.com/cloudflare/circl v1.4.0 h1:BV7h5MgrktNzytKmWjpOtdYrf0lkkbF8YMlBGPhJQrY=
|
||||
github.com/cloudflare/circl v1.4.0/go.mod h1:PDRU+oXvdD7KCtgKxW95M5Z8BpSCJXQORiZFnBQS5QU=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.4/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
|
||||
github.com/cyphar/filepath-securejoin v0.3.1 h1:1V7cHiaW+C+39wEfpH6XlLBQo3j/PciWFrgfCLS8XrE=
|
||||
github.com/cyphar/filepath-securejoin v0.3.1/go.mod h1:F7i41x/9cBF7lzCrVsYs9fuzwRZm4NQsGTBdpp6mETc=
|
||||
github.com/cloudflare/circl v1.6.1 h1:zqIqSPIndyBh1bjLVVDHMPpVKqp8Su/V+6MeDzzQBQ0=
|
||||
github.com/cloudflare/circl v1.6.1/go.mod h1:uddAzsPgqdMAYatqJ0lsjX1oECcQLIlRpzZh3pJrofs=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1 h1:JyxxyPEaktOD+GAnqIqTf9A8tHyAG22rowi7HkoSU1s=
|
||||
github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGLDGQL7h7bg04C/+u9jI=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a h1:mATvB/9r/3gvcejNsXKSkQ6lcIaNec2nyfOdlTBR2lU=
|
||||
github.com/elazarl/goproxy v0.0.0-20230808193330-2592e75ae04a/go.mod h1:Ro8st/ElPeALwNFlcTpWmkr6IoMFfkjXAvTHpevnDsM=
|
||||
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
|
||||
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
|
||||
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
|
||||
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
|
||||
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
|
||||
github.com/emirpasic/gods v1.18.1/go.mod h1:8tpGGwCnJ5H4r6BWwaV6OrWmMoPhUl5jm/FMNAnJvWQ=
|
||||
github.com/gliderlabs/ssh v0.3.7 h1:iV3Bqi942d9huXnzEF2Mt+CY9gLu8DNM4Obd+8bODRE=
|
||||
github.com/gliderlabs/ssh v0.3.7/go.mod h1:zpHEXBstFnQYtGnB8k8kQLol82umzn/2/snG7alWVD8=
|
||||
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/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 h1:+zs/tPmkDkHx3U66DAb0lQFJrpS6731Oaa12ikc+DiI=
|
||||
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376/go.mod h1:an3vInlBmSxCcxctByoQdvwPiA7DTK7jaaFDBTtu0ic=
|
||||
github.com/go-git/go-billy/v5 v5.5.0 h1:yEY4yhzCDuMGSv83oGxiBotRzhwhNr8VZyphhiu+mTU=
|
||||
github.com/go-git/go-billy/v5 v5.5.0/go.mod h1:hmexnoNsr2SJU1Ju67OaNz5ASJY3+sHgFRpCtpDCKow=
|
||||
github.com/go-git/go-billy/v5 v5.7.0 h1:83lBUJhGWhYp0ngzCMSgllhUSuoHP1iEWYjsPl9nwqM=
|
||||
github.com/go-git/go-billy/v5 v5.7.0/go.mod h1:/1IUejTKH8xipsAcdfcSAlUlo2J7lkYV8GTKxAT/L3E=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMje31YglSBqCdIqdhKBW8lokaMrL3uTkpGYlE2OOT4=
|
||||
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
|
||||
github.com/go-git/go-git/v5 v5.12.0 h1:7Md+ndsjrzZxbddRDZjF14qK+NN56sy6wkqaVrjZtys=
|
||||
github.com/go-git/go-git/v5 v5.12.0/go.mod h1:FTM9VKtnI2m65hNI/TenDDDnUf2Q9FHnXYjuz9i5OEY=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||
github.com/go-git/go-git/v5 v5.16.4 h1:7ajIEZHZJULcyJebDLo99bGgS0jRrOxzZG4uCk2Yb2Y=
|
||||
github.com/go-git/go-git/v5 v5.16.4/go.mod h1:4Ge4alE/5gPs30F2H1esi2gPd69R0C39lolkucHBOp8=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 h1:f+oWsMOmNPc8JmEHVZIycC7hBoQxHH9pNKQORJNozsQ=
|
||||
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8/go.mod h1:wcDNUvekVysuuOpQKo3191zZyTpiI6se1N1ULghS0sw=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||
github.com/google/go-github/v63 v63.0.0 h1:13xwK/wk9alSokujB9lJkuzdmQuVn2QCPeck76wR3nE=
|
||||
github.com/google/go-github/v63 v63.0.0/go.mod h1:IqbcrgUmIcEaioWrGYei/09o+ge5vhffGOcxrO0AfmA=
|
||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||
github.com/google/go-github/v74 v74.0.0 h1:yZcddTUn8DPbj11GxnMrNiAnXH14gNs559AsUpNpPgM=
|
||||
github.com/google/go-github/v74 v74.0.0/go.mod h1:ubn/YdyftV80VPSI26nSJvaEsTOnsjrxG3o9kJhcyak=
|
||||
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
|
||||
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
|
||||
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
|
||||
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
|
||||
github.com/hashicorp/go-hclog v1.6.3/go.mod h1:W4Qnvbt70Wk/zYJryRzDRU/4r0kIg0PVHBcfoyhpF5M=
|
||||
github.com/hashicorp/go-retryablehttp v0.7.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=
|
||||
|
|
@ -59,96 +75,89 @@ 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/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/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/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/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M=
|
||||
github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA=
|
||||
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/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN3Uc8sB6B/s6Z4t2xvBgU1htSHuq8=
|
||||
github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4=
|
||||
github.com/sirupsen/logrus v1.7.0/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0=
|
||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||
github.com/skeema/knownhosts v1.3.0 h1:AM+y0rI04VksttfwjkSTNQorvGqmwATnvnAHpSgc0LY=
|
||||
github.com/skeema/knownhosts v1.3.0/go.mod h1:sPINvnADmT/qYH1kfv+ePMmOBTH6Tbl7b5LvTDjFK7M=
|
||||
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
|
||||
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/skeema/knownhosts v1.3.1 h1:X2osQ+RAjK76shCbvhHHHVl3ZlgDm8apHEHFqRjnBY8=
|
||||
github.com/skeema/knownhosts v1.3.1/go.mod h1:r7KTdC8l4uxWRyK2TpQZ/1o5HaSzh06ePQNxPwTcfiY=
|
||||
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
|
||||
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
|
||||
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
|
||||
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
|
||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
|
||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/teekennedy/goldmark-markdown v0.5.1 h1:2lIlJ3AcIwaD1wFl4dflJSJFMhRTKEsEj+asVsu6M/0=
|
||||
github.com/teekennedy/goldmark-markdown v0.5.1/go.mod h1:so260mNSPELuRyynZY18719dRYlD+OSnAovqsyrOMOM=
|
||||
github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
|
||||
github.com/xanzy/ssh-agent v0.3.3/go.mod h1:6dzNDKs0J9rVPHPhaGCukekBHKqfl+L3KghI1Bc68Uw=
|
||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||
github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
|
||||
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
|
||||
github.com/yuin/goldmark v1.7.16 h1:n+CJdUxaFMiDUNnWC3dMWCIQJSkxH4uz3ZwQBkAlVNE=
|
||||
github.com/yuin/goldmark v1.7.16/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
|
||||
gitlab.com/gitlab-org/api/client-go v0.161.1 h1:XX0EtVGL6cGEdNy9xnJ96CSciIzjCwAVsayItHY1YyU=
|
||||
gitlab.com/gitlab-org/api/client-go v0.161.1/go.mod h1:YqKcnxyV9OPAL5U99mpwBVEgBPz1PK/3qwqq/3h6bao=
|
||||
go.abhg.dev/goldmark/toc v0.11.0 h1:IRixVy3/yVPKvFBc37EeBPi8XLTXrtH6BYaonSjkF8o=
|
||||
go.abhg.dev/goldmark/toc v0.11.0/go.mod h1:XMFIoI1Sm6dwF9vKzVDOYE/g1o5BmKXghLG8q/wJNww=
|
||||
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
|
||||
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
|
||||
golang.org/x/crypto v0.3.1-0.20221117191849-2c476679df9a/go.mod h1:hebNnKkNXi2UzZN1eVRvBB7co0a+JxK6XbPiWVs/3J4=
|
||||
golang.org/x/crypto v0.7.0/go.mod h1:pYwdfH91IfpZVANVyUOhSIPZaFoJGxTFbZhFTx+dXZU=
|
||||
golang.org/x/crypto v0.26.0 h1:RrRspgV4mU+YwB4FYnuBoKsUapNIL5cohGAmSH3azsw=
|
||||
golang.org/x/crypto v0.26.0/go.mod h1:GY7jblb9wI+FOo5y8/S2oY4zWP07AkOJ4+jxCqdqn54=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56 h1:2dVuKD2vS7b0QIHQbpyTISPd0LeHDbnYEryqj5Q1ug8=
|
||||
golang.org/x/exp v0.0.0-20240719175910-8a7402abbf56/go.mod h1:M4RDyNAINzryxdtnbRXRL/OHtkFuWGRjvuhBJpk2IlY=
|
||||
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.2.0/go.mod h1:KqCZLdyyvdV855qA2rE3GC2aiw5xGR5TEjj8smXukLY=
|
||||
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
|
||||
golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc=
|
||||
golang.org/x/net v0.28.0 h1:a9JDOJc5GMUJ0+UDqmLT86WiEy7iWyIhz8gz8E4e5hE=
|
||||
golang.org/x/net v0.28.0/go.mod h1:yqtgsTWOOnlGLG9GFRrK3++bGOUEkNBoHZc8MEDWPNg=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
|
||||
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
|
||||
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210124154548-22da62e12c0c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.3.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.24.0 h1:Twjiwq9dn6R1fQcyiK+wQyHWfaz/BJB+YIpzU/Cv3Xg=
|
||||
golang.org/x/sys v0.24.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
|
||||
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||
golang.org/x/term v0.2.0/go.mod h1:TVmDHMZPmdnySmBfhjOoOdhjzdE1h4u1VwSiw2l1Nuc=
|
||||
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
|
||||
golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U=
|
||||
golang.org/x/term v0.23.0 h1:F6D4vR+EHoL9/sWAWgAR1H2DcHr4PareCbAaCo1RpuU=
|
||||
golang.org/x/term v0.23.0/go.mod h1:DgV24QBUrK6jhZXl+20l6UWznPlwAHm1Q1mGHtydmSk=
|
||||
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
|
||||
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||
golang.org/x/text v0.4.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
|
||||
golang.org/x/text v0.8.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
|
||||
golang.org/x/text v0.17.0 h1:XtiM5bkSOt+ewxlOE/aE/AKEHibwj/6gvWMl9Rsh0Qc=
|
||||
golang.org/x/text v0.17.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
|
||||
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
|
||||
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
|
||||
golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
|
||||
golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||
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=
|
||||
|
|
|
|||
72
internal/changelog/changelog.go
Normal file
72
internal/changelog/changelog.go
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
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
|
||||
}
|
||||
24
internal/changelog/changelog.md.tpl
Normal file
24
internal/changelog/changelog.md.tpl
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
{{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 }}
|
||||
|
|
@ -1,9 +1,14 @@
|
|||
package rp
|
||||
package changelog
|
||||
|
||||
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 {
|
||||
|
|
@ -12,7 +17,7 @@ func ptr[T any](input T) *T {
|
|||
|
||||
func Test_NewChangelogEntry(t *testing.T) {
|
||||
type args struct {
|
||||
analyzedCommits []AnalyzedCommit
|
||||
analyzedCommits []commitparser.AnalyzedCommit
|
||||
version string
|
||||
link string
|
||||
prefix string
|
||||
|
|
@ -27,19 +32,19 @@ func Test_NewChangelogEntry(t *testing.T) {
|
|||
{
|
||||
name: "empty",
|
||||
args: args{
|
||||
analyzedCommits: []AnalyzedCommit{},
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{},
|
||||
version: "1.0.0",
|
||||
link: "https://example.com/1.0.0",
|
||||
},
|
||||
want: "## [1.0.0](https://example.com/1.0.0)",
|
||||
want: "## [1.0.0](https://example.com/1.0.0)\n",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "single feature",
|
||||
args: args{
|
||||
analyzedCommits: []AnalyzedCommit{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: Commit{},
|
||||
Commit: git.Commit{},
|
||||
Type: "feat",
|
||||
Description: "Foobar!",
|
||||
},
|
||||
|
|
@ -47,15 +52,32 @@ 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### Features\n\n- Foobar!\n",
|
||||
want: "## [1.0.0](https://example.com/1.0.0)\n\n### Features\n\n- Foobar!\n",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "single breaking change",
|
||||
args: args{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: git.Commit{},
|
||||
Type: "feat",
|
||||
Description: "Foobar!",
|
||||
BreakingChange: true,
|
||||
},
|
||||
},
|
||||
version: "1.0.0",
|
||||
link: "https://example.com/1.0.0",
|
||||
},
|
||||
want: "## [1.0.0](https://example.com/1.0.0)\n\n### Features\n\n- **BREAKING**: Foobar!\n",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "single fix",
|
||||
args: args{
|
||||
analyzedCommits: []AnalyzedCommit{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: Commit{},
|
||||
Commit: git.Commit{},
|
||||
Type: "fix",
|
||||
Description: "Foobar!",
|
||||
},
|
||||
|
|
@ -63,31 +85,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### Bug Fixes\n\n- Foobar!\n",
|
||||
want: "## [1.0.0](https://example.com/1.0.0)\n\n### Bug Fixes\n\n- Foobar!\n",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "multiple commits with scopes",
|
||||
args: args{
|
||||
analyzedCommits: []AnalyzedCommit{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: Commit{},
|
||||
Commit: git.Commit{},
|
||||
Type: "feat",
|
||||
Description: "Blabla!",
|
||||
},
|
||||
{
|
||||
Commit: Commit{},
|
||||
Commit: git.Commit{},
|
||||
Type: "feat",
|
||||
Description: "So awesome!",
|
||||
Scope: ptr("awesome"),
|
||||
},
|
||||
{
|
||||
Commit: Commit{},
|
||||
Commit: git.Commit{},
|
||||
Type: "fix",
|
||||
Description: "Foobar!",
|
||||
},
|
||||
{
|
||||
Commit: Commit{},
|
||||
Commit: git.Commit{},
|
||||
Type: "fix",
|
||||
Description: "So sad!",
|
||||
Scope: ptr("sad"),
|
||||
|
|
@ -97,6 +119,7 @@ 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!
|
||||
|
|
@ -112,56 +135,43 @@ func Test_NewChangelogEntry(t *testing.T) {
|
|||
{
|
||||
name: "prefix",
|
||||
args: args{
|
||||
analyzedCommits: []AnalyzedCommit{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: Commit{},
|
||||
Commit: git.Commit{},
|
||||
Type: "fix",
|
||||
Description: "Foobar!",
|
||||
},
|
||||
},
|
||||
version: "1.0.0",
|
||||
link: "https://example.com/1.0.0",
|
||||
prefix: "### Breaking Changes",
|
||||
prefix: testdata.MustReadFileString(t, "prefix.txt"),
|
||||
},
|
||||
want: `## [1.0.0](https://example.com/1.0.0)
|
||||
### Breaking Changes
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Foobar!
|
||||
`,
|
||||
want: testdata.MustReadFileString(t, "changelog-entry-prefix.txt"),
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "suffix",
|
||||
args: args{
|
||||
analyzedCommits: []AnalyzedCommit{
|
||||
analyzedCommits: []commitparser.AnalyzedCommit{
|
||||
{
|
||||
Commit: Commit{},
|
||||
Commit: git.Commit{},
|
||||
Type: "fix",
|
||||
Description: "Foobar!",
|
||||
},
|
||||
},
|
||||
version: "1.0.0",
|
||||
link: "https://example.com/1.0.0",
|
||||
suffix: "### Compatibility\n\nThis version is compatible with flux-compensator v2.2 - v2.9.",
|
||||
suffix: testdata.MustReadFileString(t, "suffix.txt"),
|
||||
},
|
||||
want: `## [1.0.0](https://example.com/1.0.0)
|
||||
### Bug Fixes
|
||||
|
||||
- Foobar!
|
||||
|
||||
### Compatibility
|
||||
|
||||
This version is compatible with flux-compensator v2.2 - v2.9.
|
||||
`,
|
||||
want: testdata.MustReadFileString(t, "changelog-entry-suffix.txt"),
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := NewChangelogEntry(tt.args.analyzedCommits, tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix)
|
||||
data := New(commitparser.ByType(tt.args.analyzedCommits), tt.args.version, tt.args.link, tt.args.prefix, tt.args.suffix)
|
||||
got, err := Entry(slog.Default(), DefaultTemplate(), data, Formatting{})
|
||||
if !tt.wantErr(t, err) {
|
||||
return
|
||||
}
|
||||
32
internal/commitparser/commitparser.go
Normal file
32
internal/commitparser/commitparser.go
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -0,0 +1,157 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
65
internal/forge/forge.go
Normal file
65
internal/forge/forge.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
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
|
||||
}
|
||||
529
internal/forge/forgejo/forgejo.go
Normal file
529
internal/forge/forgejo/forgejo.go
Normal file
|
|
@ -0,0 +1,529 @@
|
|||
package forgejo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2"
|
||||
"github.com/blang/semver/v4"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport"
|
||||
"github.com/go-git/go-git/v5/plumbing/transport/http"
|
||||
|
||||
"github.com/apricote/releaser-pleaser/internal/forge"
|
||||
"github.com/apricote/releaser-pleaser/internal/git"
|
||||
"github.com/apricote/releaser-pleaser/internal/pointer"
|
||||
"github.com/apricote/releaser-pleaser/internal/releasepr"
|
||||
)
|
||||
|
||||
const ()
|
||||
|
||||
var _ forge.Forge = &Forgejo{}
|
||||
|
||||
type Forgejo struct {
|
||||
options *Options
|
||||
|
||||
client *forgejo.Client
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
func (f *Forgejo) RepoURL() string {
|
||||
return fmt.Sprintf("%s/%s/%s", f.options.APIURL, f.options.Owner, f.options.Repo)
|
||||
}
|
||||
|
||||
func (f *Forgejo) CloneURL() string {
|
||||
return fmt.Sprintf("%s.git", f.RepoURL())
|
||||
}
|
||||
|
||||
func (f *Forgejo) ReleaseURL(version string) string {
|
||||
return fmt.Sprintf("%s/releases/tag/%s", f.RepoURL(), version)
|
||||
}
|
||||
|
||||
func (f *Forgejo) PullRequestURL(id int) string {
|
||||
return fmt.Sprintf("%s/pulls/%d", f.RepoURL(), id)
|
||||
}
|
||||
|
||||
func (f *Forgejo) GitAuth() transport.AuthMethod {
|
||||
return &http.BasicAuth{
|
||||
Username: f.options.Username,
|
||||
Password: f.options.APIToken,
|
||||
}
|
||||
}
|
||||
|
||||
func (f *Forgejo) CommitAuthor(ctx context.Context) (git.Author, error) {
|
||||
f.log.DebugContext(ctx, "getting commit author from current token user")
|
||||
|
||||
user, _, err := f.client.GetMyUserInfo()
|
||||
if err != nil {
|
||||
return git.Author{}, err
|
||||
}
|
||||
|
||||
// TODO: Same for other forges?
|
||||
name := user.FullName
|
||||
if name == "" {
|
||||
name = user.UserName
|
||||
}
|
||||
|
||||
return git.Author{
|
||||
Name: name,
|
||||
Email: user.Email,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) LatestTags(ctx context.Context) (git.Releases, error) {
|
||||
f.log.DebugContext(ctx, "listing all tags in forgejo repository")
|
||||
|
||||
tags, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Tag, *forgejo.Response, error) {
|
||||
return f.client.ListRepoTags(f.options.Owner, f.options.Repo,
|
||||
forgejo.ListRepoTagsOptions{ListOptions: listOptions},
|
||||
)
|
||||
})
|
||||
if err != nil {
|
||||
return git.Releases{}, err
|
||||
}
|
||||
|
||||
var releases git.Releases
|
||||
|
||||
for _, fTag := range tags {
|
||||
tag := &git.Tag{
|
||||
Hash: fTag.Commit.SHA,
|
||||
Name: fTag.Name,
|
||||
}
|
||||
|
||||
version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v"))
|
||||
if err != nil {
|
||||
f.log.WarnContext(
|
||||
ctx, "unable to parse tag as semver, skipping",
|
||||
"tag.name", tag.Name,
|
||||
"tag.hash", tag.Hash,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
|
||||
if releases.Latest == nil {
|
||||
releases.Latest = tag
|
||||
}
|
||||
if len(version.Pre) == 0 {
|
||||
// Stable version tag
|
||||
// We return once we have found the latest stable tag, not needed to look at every single tag.
|
||||
releases.Stable = tag
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return releases, nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) {
|
||||
var repositoryCommits []*forgejo.Commit
|
||||
var err error
|
||||
if tag != nil {
|
||||
repositoryCommits, err = f.commitsSinceTag(ctx, tag)
|
||||
} else {
|
||||
repositoryCommits, err = f.commitsSinceInit(ctx)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var commits = make([]git.Commit, 0, len(repositoryCommits))
|
||||
for _, fCommit := range repositoryCommits {
|
||||
commit := git.Commit{
|
||||
Hash: fCommit.SHA,
|
||||
Message: fCommit.RepoCommit.Message,
|
||||
}
|
||||
commit.PullRequest, err = f.prForCommit(ctx, commit)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check for commit pull request: %w", err)
|
||||
}
|
||||
|
||||
commits = append(commits, commit)
|
||||
}
|
||||
|
||||
return commits, nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) commitsSinceTag(_ context.Context, tag *git.Tag) ([]*forgejo.Commit, error) {
|
||||
head := f.options.BaseBranch
|
||||
log := f.log.With("base", tag.Hash, "head", head)
|
||||
log.Debug("comparing commits")
|
||||
|
||||
compare, _, err := f.client.CompareCommits(
|
||||
f.options.Owner, f.options.Repo,
|
||||
tag.Hash, head)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return compare.Commits, nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) commitsSinceInit(_ context.Context) ([]*forgejo.Commit, error) {
|
||||
head := f.options.BaseBranch
|
||||
log := f.log.With("head", head)
|
||||
log.Debug("listing all commits")
|
||||
|
||||
repositoryCommits, err := all(
|
||||
func(listOptions forgejo.ListOptions) ([]*forgejo.Commit, *forgejo.Response, error) {
|
||||
return f.client.ListRepoCommits(
|
||||
f.options.Owner, f.options.Repo,
|
||||
forgejo.ListCommitOptions{
|
||||
ListOptions: listOptions,
|
||||
SHA: f.options.BaseBranch,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return repositoryCommits, nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) prForCommit(_ context.Context, commit git.Commit) (*git.PullRequest, error) {
|
||||
// We naively look up the associated PR for each commit through the "List pull requests associated with a commit"
|
||||
// endpoint. This requires len(commits) requests.
|
||||
// Using the "List pull requests" endpoint might be faster, as it allows us to fetch 100 arbitrary PRs per request,
|
||||
// but worst case we need to look up all PRs made in the repository ever.
|
||||
|
||||
f.log.Debug("fetching pull requests associated with commit", "commit.hash", commit.Hash)
|
||||
|
||||
pullRequest, _, err := f.client.GetCommitPullRequest(
|
||||
f.options.Owner, f.options.Repo,
|
||||
commit.Hash,
|
||||
)
|
||||
if err != nil {
|
||||
if strings.HasPrefix(err.Error(), "pull request does not exist") {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return forgejoPRToPullRequest(pullRequest), nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) EnsureLabelsExist(_ context.Context, labels []releasepr.Label) error {
|
||||
f.log.Debug("fetching labels on repo")
|
||||
fLabels, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) {
|
||||
return f.client.ListRepoLabels(
|
||||
f.options.Owner, f.options.Repo,
|
||||
forgejo.ListLabelsOptions{ListOptions: listOptions})
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
for _, label := range labels {
|
||||
if !slices.ContainsFunc(fLabels, func(fLabel *forgejo.Label) bool {
|
||||
return fLabel.Name == label.Name
|
||||
}) {
|
||||
f.log.Info("creating label in repository", "label.name", label.Name)
|
||||
_, _, err = f.client.CreateLabel(
|
||||
f.options.Owner, f.options.Repo,
|
||||
forgejo.CreateLabelOption{
|
||||
Name: label.Name,
|
||||
Color: label.Color,
|
||||
Description: label.Description,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) PullRequestForBranch(_ context.Context, branch string) (*releasepr.ReleasePullRequest, error) {
|
||||
prs, err := all(
|
||||
func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) {
|
||||
return f.client.ListRepoPullRequests(
|
||||
f.options.Owner, f.options.Repo,
|
||||
forgejo.ListPullRequestsOptions{
|
||||
ListOptions: listOptions,
|
||||
State: forgejo.StateOpen,
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, pr := range prs {
|
||||
if pr.Base.Ref == f.options.BaseBranch && pr.Head.Ref == branch {
|
||||
return forgejoPRToReleasePullRequest(pr), nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error {
|
||||
fPR, _, err := f.client.CreatePullRequest(
|
||||
f.options.Owner, f.options.Repo,
|
||||
forgejo.CreatePullRequestOption{
|
||||
Title: pr.Title,
|
||||
Head: pr.Head,
|
||||
Base: f.options.BaseBranch,
|
||||
Body: pr.Description,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// TODO: String ID?
|
||||
pr.ID = int(fPR.ID)
|
||||
|
||||
err = f.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) UpdatePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error {
|
||||
_, _, err := f.client.EditPullRequest(
|
||||
f.options.Owner, f.options.Repo,
|
||||
int64(pr.ID), forgejo.EditPullRequestOption{
|
||||
Title: pr.Title,
|
||||
Body: pr.Description,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) SetPullRequestLabels(_ context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error {
|
||||
allLabels, err := all(
|
||||
func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) {
|
||||
return f.client.ListRepoLabels(f.options.Owner, f.options.Repo, forgejo.ListLabelsOptions{ListOptions: listOptions})
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
findLabel := func(labelName string) *forgejo.Label {
|
||||
for _, fLabel := range allLabels {
|
||||
if fLabel.Name == labelName {
|
||||
return fLabel
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, label := range remove {
|
||||
fLabel := findLabel(label.Name)
|
||||
if fLabel == nil {
|
||||
return fmt.Errorf("unable to remove label %q, not found in API", label.Name)
|
||||
}
|
||||
|
||||
_, err = f.client.DeleteIssueLabel(
|
||||
f.options.Owner, f.options.Repo,
|
||||
int64(pr.ID), fLabel.ID,
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
addIDs := make([]int64, 0, len(add))
|
||||
for _, label := range add {
|
||||
fLabel := findLabel(label.Name)
|
||||
if fLabel == nil {
|
||||
return fmt.Errorf("unable to add label %q, not found in API", label.Name)
|
||||
}
|
||||
|
||||
addIDs = append(addIDs, fLabel.ID)
|
||||
}
|
||||
|
||||
_, _, err = f.client.AddIssueLabels(
|
||||
f.options.Owner, f.options.Repo,
|
||||
int64(pr.ID), forgejo.IssueLabelsOption{Labels: addIDs},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) ClosePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error {
|
||||
_, _, err := f.client.EditPullRequest(
|
||||
f.options.Owner, f.options.Repo,
|
||||
int64(pr.ID), forgejo.EditPullRequestOption{
|
||||
State: pointer.Pointer(forgejo.StateClosed),
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) PendingReleases(_ context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) {
|
||||
fPRs, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) {
|
||||
return f.client.ListRepoPullRequests(
|
||||
f.options.Owner, f.options.Repo,
|
||||
forgejo.ListPullRequestsOptions{
|
||||
// Filtering by Label ID is possible in the API, but not implemented in the Go SDK.
|
||||
State: forgejo.StateClosed,
|
||||
ListOptions: listOptions,
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
// "The target couldn't be found." means that the repo does not have pull requests activated.
|
||||
return nil, err
|
||||
}
|
||||
|
||||
prs := make([]*releasepr.ReleasePullRequest, 0, len(fPRs))
|
||||
|
||||
for _, pr := range fPRs {
|
||||
pending := slices.ContainsFunc(pr.Labels, func(l *forgejo.Label) bool {
|
||||
return l.Name == pendingLabel.Name
|
||||
})
|
||||
if !pending {
|
||||
continue
|
||||
}
|
||||
|
||||
// pr.Merged is always nil :(
|
||||
if !pr.HasMerged {
|
||||
// Closed and not merged
|
||||
continue
|
||||
}
|
||||
|
||||
prs = append(prs, forgejoPRToReleasePullRequest(pr))
|
||||
}
|
||||
|
||||
return prs, nil
|
||||
}
|
||||
|
||||
func (f *Forgejo) CreateRelease(_ context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error {
|
||||
// latest can not be set through the API
|
||||
|
||||
_, _, err := f.client.CreateRelease(
|
||||
f.options.Owner, f.options.Repo,
|
||||
forgejo.CreateReleaseOption{
|
||||
TagName: title,
|
||||
Target: commit.Hash,
|
||||
Title: title,
|
||||
Note: changelog,
|
||||
IsPrerelease: preRelease,
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func all[T any](f func(listOptions forgejo.ListOptions) ([]T, *forgejo.Response, error)) ([]T, error) {
|
||||
results := make([]T, 0)
|
||||
page := 1
|
||||
|
||||
for {
|
||||
pageResults, resp, err := f(forgejo.ListOptions{Page: page})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results = append(results, pageResults...)
|
||||
|
||||
if page == resp.LastPage || resp.LastPage == 0 {
|
||||
return results, nil
|
||||
}
|
||||
page = resp.NextPage
|
||||
}
|
||||
}
|
||||
|
||||
func forgejoPRToPullRequest(pr *forgejo.PullRequest) *git.PullRequest {
|
||||
return &git.PullRequest{
|
||||
ID: int(pr.ID),
|
||||
Title: pr.Title,
|
||||
Description: pr.Body,
|
||||
}
|
||||
}
|
||||
|
||||
func forgejoPRToReleasePullRequest(pr *forgejo.PullRequest) *releasepr.ReleasePullRequest {
|
||||
labels := make([]releasepr.Label, 0, len(pr.Labels))
|
||||
for _, label := range pr.Labels {
|
||||
labelName := label.Name
|
||||
if i := slices.IndexFunc(releasepr.KnownLabels, func(label releasepr.Label) bool {
|
||||
return label.Name == labelName
|
||||
}); i >= 0 {
|
||||
labels = append(labels, releasepr.KnownLabels[i])
|
||||
}
|
||||
}
|
||||
|
||||
var releaseCommit *git.Commit
|
||||
if pr.MergedCommitID != nil {
|
||||
releaseCommit = &git.Commit{Hash: *pr.MergedCommitID}
|
||||
}
|
||||
|
||||
return &releasepr.ReleasePullRequest{
|
||||
PullRequest: *forgejoPRToPullRequest(pr),
|
||||
Labels: labels,
|
||||
|
||||
Head: pr.Head.Ref,
|
||||
ReleaseCommit: releaseCommit,
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Options) autodiscover() {
|
||||
// TODO
|
||||
}
|
||||
|
||||
func (g *Options) ClientOptions() []forgejo.ClientOption {
|
||||
options := []forgejo.ClientOption{}
|
||||
|
||||
if g.APIToken != "" {
|
||||
options = append(options, forgejo.SetToken(g.APIToken))
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
type Options struct {
|
||||
forge.Options
|
||||
|
||||
Owner string
|
||||
Repo string
|
||||
|
||||
APIURL string
|
||||
Username string
|
||||
APIToken string
|
||||
}
|
||||
|
||||
func New(log *slog.Logger, options *Options) (*Forgejo, error) {
|
||||
options.autodiscover()
|
||||
|
||||
client, err := forgejo.NewClient(options.APIURL, options.ClientOptions()...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
client.SetUserAgent("releaser-pleaser")
|
||||
|
||||
f := &Forgejo{
|
||||
options: options,
|
||||
|
||||
client: client,
|
||||
log: log.With("forge", "forgejo"),
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
544
internal/forge/github/github.go
Normal file
544
internal/forge/github/github.go
Normal file
|
|
@ -0,0 +1,544 @@
|
|||
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
|
||||
}
|
||||
483
internal/forge/gitlab/gitlab.go
Normal file
483
internal/forge/gitlab/gitlab.go
Normal file
|
|
@ -0,0 +1,483 @@
|
|||
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
|
||||
}
|
||||
292
internal/git/git.go
Normal file
292
internal/git/git.go
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
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,
|
||||
})
|
||||
}
|
||||
174
internal/git/git_test.go
Normal file
174
internal/git/git_test.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestAuthor_signature(t *testing.T) {
|
||||
now := time.Now()
|
||||
|
||||
tests := []struct {
|
||||
author Author
|
||||
want *object.Signature
|
||||
}{
|
||||
{author: Author{Name: "foo", Email: "bar@example.com"}, want: &object.Signature{Name: "foo", Email: "bar@example.com", When: now}},
|
||||
{author: Author{Name: "bar", Email: "foo@example.com"}, want: &object.Signature{Name: "bar", Email: "foo@example.com", When: now}},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(strconv.FormatInt(int64(i), 10), func(t *testing.T) {
|
||||
if got := tt.author.signature(now); !reflect.DeepEqual(got, tt.want) {
|
||||
t.Errorf("signature() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthor_String(t *testing.T) {
|
||||
tests := []struct {
|
||||
author Author
|
||||
want string
|
||||
}{
|
||||
{author: Author{Name: "foo", Email: "bar@example.com"}, want: "foo <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)
|
||||
})
|
||||
}
|
||||
}
|
||||
189
internal/git/util_test.go
Normal file
189
internal/git/util_test.go
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-git/go-billy/v5/memfs"
|
||||
"github.com/go-git/go-git/v5"
|
||||
"github.com/go-git/go-git/v5/plumbing"
|
||||
"github.com/go-git/go-git/v5/plumbing/object"
|
||||
"github.com/go-git/go-git/v5/storage/memory"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
var (
|
||||
author = &object.Signature{
|
||||
Name: "releaser-pleaser",
|
||||
When: time.Date(2020, 01, 01, 01, 01, 01, 01, time.UTC),
|
||||
}
|
||||
)
|
||||
|
||||
type CommitOption func(*commitOptions)
|
||||
type commitOptions struct {
|
||||
cleanFiles bool
|
||||
files []commitFile
|
||||
tags []string
|
||||
newRef plumbing.ReferenceName
|
||||
parentRef plumbing.ReferenceName
|
||||
}
|
||||
type commitFile struct {
|
||||
path string
|
||||
content string
|
||||
}
|
||||
|
||||
type TestCommit func(*testing.T, *Repository) error
|
||||
type TestRepo func(*testing.T) *Repository
|
||||
|
||||
func WithCommit(message string, options ...CommitOption) TestCommit {
|
||||
return func(t *testing.T, repo *Repository) error {
|
||||
t.Helper()
|
||||
|
||||
require.NotEmpty(t, message, "commit message is required")
|
||||
|
||||
opts := &commitOptions{}
|
||||
for _, opt := range options {
|
||||
opt(opts)
|
||||
}
|
||||
|
||||
wt, err := repo.r.Worktree()
|
||||
require.NoError(t, err)
|
||||
|
||||
if opts.parentRef != "" {
|
||||
checkoutOptions := &git.CheckoutOptions{}
|
||||
|
||||
if opts.newRef != "" {
|
||||
parentRef, err := repo.r.Reference(opts.parentRef, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
checkoutOptions.Create = true
|
||||
checkoutOptions.Hash = parentRef.Hash()
|
||||
checkoutOptions.Branch = opts.newRef
|
||||
} else {
|
||||
checkoutOptions.Branch = opts.parentRef
|
||||
}
|
||||
|
||||
err = wt.Checkout(checkoutOptions)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// Yeet all files
|
||||
if opts.cleanFiles {
|
||||
files, err := wt.Filesystem.ReadDir(".")
|
||||
require.NoError(t, err, "failed to get current files")
|
||||
|
||||
for _, fileInfo := range files {
|
||||
err = wt.Filesystem.Remove(fileInfo.Name())
|
||||
require.NoError(t, err, "failed to remove file %q", fileInfo.Name())
|
||||
}
|
||||
}
|
||||
|
||||
// Create new files
|
||||
for _, fileInfo := range opts.files {
|
||||
file, err := wt.Filesystem.Create(fileInfo.path)
|
||||
require.NoError(t, err, "failed to create file %q", fileInfo.path)
|
||||
|
||||
_, err = file.Write([]byte(fileInfo.content))
|
||||
_ = file.Close()
|
||||
require.NoError(t, err, "failed to write content to file %q", fileInfo.path)
|
||||
|
||||
_, err = wt.Add(fileInfo.path)
|
||||
require.NoError(t, err, "failed to stage changes to file %q", fileInfo.path)
|
||||
|
||||
}
|
||||
|
||||
// Commit
|
||||
commitHash, err := wt.Commit(message, &git.CommitOptions{
|
||||
All: true,
|
||||
AllowEmptyCommits: true,
|
||||
Author: author,
|
||||
Committer: author,
|
||||
})
|
||||
require.NoError(t, err, "failed to commit")
|
||||
|
||||
// Create tags
|
||||
for _, tagName := range opts.tags {
|
||||
_, err = repo.r.CreateTag(tagName, commitHash, nil)
|
||||
require.NoError(t, err, "failed to create tag %q", tagName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func WithFile(path, content string) CommitOption {
|
||||
return func(opts *commitOptions) {
|
||||
opts.files = append(opts.files, commitFile{path: path, content: content})
|
||||
}
|
||||
}
|
||||
|
||||
// WithCleanFiles removes all previous files from the repo. Make sure to leave at least one file in the root
|
||||
// directory when switching branches!
|
||||
func WithCleanFiles() CommitOption {
|
||||
return func(opts *commitOptions) {
|
||||
opts.cleanFiles = true
|
||||
}
|
||||
}
|
||||
|
||||
func AsNewBranch(ref plumbing.ReferenceName) CommitOption {
|
||||
return func(opts *commitOptions) {
|
||||
opts.newRef = ref
|
||||
}
|
||||
}
|
||||
|
||||
func OnBranch(ref plumbing.ReferenceName) CommitOption {
|
||||
return func(opts *commitOptions) {
|
||||
opts.parentRef = ref
|
||||
}
|
||||
}
|
||||
|
||||
func WithTag(name string) CommitOption {
|
||||
return func(opts *commitOptions) {
|
||||
opts.tags = append(opts.tags, name)
|
||||
}
|
||||
}
|
||||
|
||||
// Can be useful to debug git issues by using it in a terminal
|
||||
const useOnDiskTestRepository = false
|
||||
|
||||
func WithTestRepo(commits ...TestCommit) TestRepo {
|
||||
return func(t *testing.T) *Repository {
|
||||
t.Helper()
|
||||
|
||||
repo := &Repository{
|
||||
logger: slog.New(slog.NewTextHandler(io.Discard, nil)),
|
||||
}
|
||||
|
||||
var err error
|
||||
|
||||
initOptions := git.InitOptions{DefaultBranch: plumbing.Main}
|
||||
|
||||
if useOnDiskTestRepository {
|
||||
dir, err := os.MkdirTemp(os.TempDir(), "rp-test-repo-")
|
||||
require.NoError(t, err, "failed to create temp directory")
|
||||
|
||||
repo.r, err = git.PlainInitWithOptions(dir, &git.PlainInitOptions{InitOptions: initOptions})
|
||||
require.NoError(t, err, "failed to create fs repository")
|
||||
|
||||
fmt.Printf("using temp directory: %s", dir)
|
||||
} else {
|
||||
repo.r, err = git.InitWithOptions(memory.NewStorage(), memfs.New(), initOptions)
|
||||
require.NoError(t, err, "failed to create in-memory repository")
|
||||
}
|
||||
|
||||
// Make initial commit
|
||||
err = WithCommit("chore: init", WithFile("README.md", "# git test util"))(t, repo)
|
||||
require.NoError(t, err, "failed to create init commit")
|
||||
|
||||
for i, commit := range commits {
|
||||
err = commit(t, repo)
|
||||
require.NoError(t, err, "failed to create commit %d", i)
|
||||
}
|
||||
|
||||
return repo
|
||||
}
|
||||
}
|
||||
23
internal/log/log.go
Normal file
23
internal/log/log.go
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
package log
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/lmittmann/tint"
|
||||
)
|
||||
|
||||
func GetLogger(w io.Writer) *slog.Logger {
|
||||
return slog.New(
|
||||
tint.NewHandler(w, &tint.Options{
|
||||
Level: slog.LevelDebug,
|
||||
TimeFormat: time.RFC3339,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
func init() {
|
||||
slog.SetDefault(GetLogger(os.Stderr))
|
||||
}
|
||||
|
|
@ -7,7 +7,8 @@ import (
|
|||
// A Section struct represents a section of elements.
|
||||
type Section struct {
|
||||
gast.BaseBlock
|
||||
Name string
|
||||
Name string
|
||||
Hidden bool
|
||||
}
|
||||
|
||||
// Dump implements Node.Dump.
|
||||
|
|
@ -26,6 +27,10 @@ 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}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
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"
|
||||
|
||||
|
|
@ -19,8 +21,8 @@ var (
|
|||
|
||||
const (
|
||||
sectionTrigger = "<!--"
|
||||
SectionStartFormat = "<!-- section-start %s -->"
|
||||
SectionEndFormat = "<!-- section-end %s -->"
|
||||
SectionStartFormat = "<!-- section-start %s -->\n"
|
||||
SectionEndFormat = "\n<!-- section-end %s -->"
|
||||
)
|
||||
|
||||
type sectionParser struct{}
|
||||
|
|
@ -76,6 +78,45 @@ 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.
|
||||
|
|
@ -85,4 +126,7 @@ 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),
|
||||
))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +1,122 @@
|
|||
package markdown
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"strings"
|
||||
|
||||
markdown "github.com/teekennedy/goldmark-markdown"
|
||||
"github.com/yuin/goldmark"
|
||||
"github.com/yuin/goldmark/renderer"
|
||||
gast "github.com/yuin/goldmark/ast"
|
||||
"github.com/yuin/goldmark/parser"
|
||||
"github.com/yuin/goldmark/text"
|
||||
"github.com/yuin/goldmark/util"
|
||||
|
||||
"github.com/apricote/releaser-pleaser/internal/markdown/extensions"
|
||||
"github.com/apricote/releaser-pleaser/internal/markdown/renderer/markdown"
|
||||
"github.com/apricote/releaser-pleaser/internal/markdown/extensions/ast"
|
||||
)
|
||||
|
||||
func New() goldmark.Markdown {
|
||||
return goldmark.New(
|
||||
goldmark.WithExtensions(extensions.Section),
|
||||
goldmark.WithRenderer(renderer.NewRenderer(renderer.WithNodeRenderers(util.Prioritized(markdown.NewRenderer(), 1)))),
|
||||
goldmark.WithParserOptions(parser.WithASTTransformers(
|
||||
util.Prioritized(&newLineTransformer{}, 1),
|
||||
)),
|
||||
goldmark.WithRenderer(markdown.NewRenderer()),
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
260
internal/markdown/goldmark_test.go
Normal file
260
internal/markdown/goldmark_test.go
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
31
internal/markdown/prettier.go
Normal file
31
internal/markdown/prettier.go
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
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
|
||||
})
|
||||
}
|
||||
|
|
@ -1,21 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,4 +0,0 @@
|
|||
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.
|
||||
|
|
@ -1,836 +0,0 @@
|
|||
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
|
||||
}
|
||||
|
|
@ -1,35 +0,0 @@
|
|||
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
|
||||
}
|
||||
5
internal/pointer/pointer.go
Normal file
5
internal/pointer/pointer.go
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
package pointer
|
||||
|
||||
func Pointer[T any](value T) *T {
|
||||
return &value
|
||||
}
|
||||
54
internal/releasepr/label.go
Normal file
54
internal/releasepr/label.go
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
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,
|
||||
}
|
||||
161
internal/releasepr/releasepr.go
Normal file
161
internal/releasepr/releasepr.go
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
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, §ionText))
|
||||
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
|
||||
}
|
||||
|
|
@ -15,18 +15,18 @@ If you want to modify the proposed release, add you overrides here. You can lear
|
|||
|
||||
This will be added to the start of the release notes.
|
||||
|
||||
```rp-prefix
|
||||
~~~~rp-prefix
|
||||
{{- if .Overrides.Prefix }}
|
||||
{{ .Overrides.Prefix }}{{ end }}
|
||||
```
|
||||
~~~~
|
||||
|
||||
### Suffix / End
|
||||
|
||||
This will be added to the end of the release notes.
|
||||
|
||||
```rp-suffix
|
||||
~~~~rp-suffix
|
||||
{{- if .Overrides.Suffix }}
|
||||
{{ .Overrides.Suffix }}{{ end }}
|
||||
```
|
||||
~~~~
|
||||
|
||||
</details>
|
||||
191
internal/releasepr/releasepr_test.go
Normal file
191
internal/releasepr/releasepr_test.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
7
internal/testdata/changelog-content.txt
vendored
Normal file
7
internal/testdata/changelog-content.txt
vendored
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
This is the changelog
|
||||
|
||||
## Awesome
|
||||
|
||||
### New
|
||||
|
||||
#### Changes
|
||||
19
internal/testdata/changelog-entry-prefix.txt
vendored
Normal file
19
internal/testdata/changelog-entry-prefix.txt
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
## [1.0.0](https://example.com/1.0.0)
|
||||
|
||||
## Foo
|
||||
|
||||
- Cool thing
|
||||
|
||||
```go
|
||||
// Some code example
|
||||
func IsPositive(number int) error {
|
||||
if number < 0 {
|
||||
return fmt.Errorf("number %d is negative", number)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Foobar!
|
||||
9
internal/testdata/changelog-entry-suffix.txt
vendored
Normal file
9
internal/testdata/changelog-entry-suffix.txt
vendored
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
## [1.0.0](https://example.com/1.0.0)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Foobar!
|
||||
|
||||
## Compatibility
|
||||
|
||||
This version is compatible with flux-compensator v2.2 - v2.9.
|
||||
13
internal/testdata/changelog.txt
vendored
Normal file
13
internal/testdata/changelog.txt
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# Foobar
|
||||
|
||||
<!-- section-start changelog -->
|
||||
This is the changelog
|
||||
|
||||
## Awesome
|
||||
|
||||
### New
|
||||
|
||||
#### Changes
|
||||
<!-- section-end changelog -->
|
||||
|
||||
Suffix Things
|
||||
28
internal/testdata/description-no-overrides.txt
vendored
Normal file
28
internal/testdata/description-no-overrides.txt
vendored
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
<!-- 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>
|
||||
44
internal/testdata/description-overrides.txt
vendored
Normal file
44
internal/testdata/description-overrides.txt
vendored
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<!-- 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>
|
||||
41
internal/testdata/description-prefix.txt
vendored
Normal file
41
internal/testdata/description-prefix.txt
vendored
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
<!-- 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>
|
||||
31
internal/testdata/description-suffix.txt
vendored
Normal file
31
internal/testdata/description-suffix.txt
vendored
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
<!-- 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>
|
||||
13
internal/testdata/prefix.txt
vendored
Normal file
13
internal/testdata/prefix.txt
vendored
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
## Foo
|
||||
|
||||
- Cool thing
|
||||
|
||||
```go
|
||||
// Some code example
|
||||
func IsPositive(number int) error {
|
||||
if number < 0 {
|
||||
return fmt.Errorf("number %d is negative", number)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
3
internal/testdata/suffix.txt
vendored
Normal file
3
internal/testdata/suffix.txt
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
## Compatibility
|
||||
|
||||
This version is compatible with flux-compensator v2.2 - v2.9.
|
||||
19
internal/testdata/testdata.go
vendored
Normal file
19
internal/testdata/testdata.go
vendored
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
package testdata
|
||||
|
||||
import (
|
||||
"embed"
|
||||
"testing"
|
||||
)
|
||||
|
||||
//go:embed *.txt
|
||||
var testdata embed.FS
|
||||
|
||||
func MustReadFileString(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
|
||||
content, err := testdata.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(content)
|
||||
}
|
||||
|
|
@ -1,126 +0,0 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
47
internal/updater/changelog.go
Normal file
47
internal/updater/changelog.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
68
internal/updater/changelog_test.go
Normal file
68
internal/updater/changelog_test.go
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
35
internal/updater/generic.go
Normal file
35
internal/updater/generic.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
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
|
||||
}
|
||||
}
|
||||
61
internal/updater/generic_test.go
Normal file
61
internal/updater/generic_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
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)
|
||||
})
|
||||
}
|
||||
}
|
||||
39
internal/updater/packagejson.go
Normal file
39
internal/updater/packagejson.go
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// PackageJson creates an updater that modifies the version field in package.json files
|
||||
func PackageJson() Updater {
|
||||
return packagejson{}
|
||||
}
|
||||
|
||||
type packagejson struct{}
|
||||
|
||||
func (p packagejson) Files() []string {
|
||||
return []string{"package.json"}
|
||||
}
|
||||
|
||||
func (p packagejson) CreateNewFiles() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (p packagejson) Update(info ReleaseInfo) func(content string) (string, error) {
|
||||
return func(content string) (string, error) {
|
||||
// We strip the "v" prefix to match npm versioning convention
|
||||
version := strings.TrimPrefix(info.Version, "v")
|
||||
|
||||
// Regex to match "version": "..." with flexible whitespace and quote styles
|
||||
versionRegex := regexp.MustCompile(`("version"\s*:\s*)"[^"]*"`)
|
||||
|
||||
// Check if the file contains a version field
|
||||
if !versionRegex.MatchString(content) {
|
||||
return content, nil
|
||||
}
|
||||
|
||||
// Replace the version value while preserving the original formatting
|
||||
return versionRegex.ReplaceAllString(content, `${1}"`+version+`"`), nil
|
||||
}
|
||||
}
|
||||
62
internal/updater/packagejson_test.go
Normal file
62
internal/updater/packagejson_test.go
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
package updater
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPackageJsonUpdater_Files(t *testing.T) {
|
||||
assert.Equal(t, []string{"package.json"}, PackageJson().Files())
|
||||
}
|
||||
|
||||
func TestPackageJsonUpdater_CreateNewFiles(t *testing.T) {
|
||||
assert.False(t, PackageJson().CreateNewFiles())
|
||||
}
|
||||
|
||||
func TestPackageJsonUpdater_Update(t *testing.T) {
|
||||
tests := []updaterTestCase{
|
||||
{
|
||||
name: "simple package.json",
|
||||
content: `{"name":"test","version":"1.0.0"}`,
|
||||
info: ReleaseInfo{
|
||||
Version: "v2.0.5",
|
||||
},
|
||||
want: `{"name":"test","version":"2.0.5"}`,
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "complex package.json",
|
||||
content: "{\n \"name\": \"test\",\n \"version\": \"1.0.0\",\n \"dependencies\": {\n \"foo\": \"^1.0.0\"\n }\n}",
|
||||
info: ReleaseInfo{
|
||||
Version: "v2.0.0",
|
||||
},
|
||||
want: "{\n \"name\": \"test\",\n \"version\": \"2.0.0\",\n \"dependencies\": {\n \"foo\": \"^1.0.0\"\n }\n}",
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "invalid json",
|
||||
content: `not json`,
|
||||
info: ReleaseInfo{
|
||||
Version: "v2.0.0",
|
||||
},
|
||||
want: `not json`,
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
{
|
||||
name: "json without version",
|
||||
content: `{"name":"test"}`,
|
||||
info: ReleaseInfo{
|
||||
Version: "v2.0.0",
|
||||
},
|
||||
want: `{"name":"test"}`,
|
||||
wantErr: assert.NoError,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
runUpdaterTest(t, PackageJson(), tt)
|
||||
})
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue