feat(forge): add new forge for forgejo

We only support repositories hosted on Forgejo instances, but not
Forgejo Actions or Woodpecker as CI solutions for now.
This commit is contained in:
Julian Tölle 2025-06-15 16:25:34 +02:00
parent f077b647e7
commit 7f69971116
4 changed files with 579 additions and 0 deletions

View file

@ -11,6 +11,7 @@ import (
rp "github.com/apricote/releaser-pleaser"
"github.com/apricote/releaser-pleaser/internal/commitparser/conventionalcommits"
"github.com/apricote/releaser-pleaser/internal/forge"
"github.com/apricote/releaser-pleaser/internal/forge/forgejo"
"github.com/apricote/releaser-pleaser/internal/forge/github"
"github.com/apricote/releaser-pleaser/internal/forge/gitlab"
"github.com/apricote/releaser-pleaser/internal/log"
@ -26,6 +27,10 @@ func newRunCommand() *cobra.Command {
flagRepo string
flagExtraFiles string
flagUpdaters []string
flagAPIURL string
flagAPIToken string
flagUsername string
)
var cmd = &cobra.Command{
@ -68,6 +73,21 @@ func newRunCommand() *cobra.Command {
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)
}
@ -110,6 +130,10 @@ func newRunCommand() *cobra.Command {
cmd.PersistentFlags().StringVar(&flagExtraFiles, "extra-files", "", "")
cmd.PersistentFlags().StringSliceVar(&flagUpdaters, "updaters", []string{}, "")
cmd.PersistentFlags().StringVar(&flagAPIURL, "api-url", "", "")
cmd.PersistentFlags().StringVar(&flagAPIToken, "api-token", "", "")
cmd.PersistentFlags().StringVar(&flagUsername, "username", "", "")
return cmd
}

7
go.mod
View file

@ -5,6 +5,7 @@ go 1.23.2
toolchain go1.25.0
require (
codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 v2.0.0-00010101000000-000000000000
github.com/blang/semver/v4 v4.0.0
github.com/go-git/go-billy/v5 v5.6.2
github.com/go-git/go-git/v5 v5.16.2
@ -20,17 +21,21 @@ require (
require (
dario.cat/mergo v1.0.1 // indirect
github.com/42wim/httpsig v1.2.3 // indirect
github.com/Microsoft/go-winio v0.6.2 // indirect
github.com/ProtonMail/go-crypto v1.1.6 // indirect
github.com/cloudflare/circl v1.6.1 // indirect
github.com/cyphar/filepath-securejoin v0.4.1 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/davidmz/go-pageant v1.0.2 // indirect
github.com/emirpasic/gods v1.18.1 // indirect
github.com/go-fed/httpsig v1.1.0 // indirect
github.com/go-git/gcfg v1.5.1-0.20230307220236-3a3c6141e376 // indirect
github.com/golang/groupcache v0.0.0-20241129210726-2c02b8208cf8 // indirect
github.com/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
@ -49,3 +54,5 @@ require (
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

19
go.sum
View file

@ -1,5 +1,9 @@
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=
@ -19,6 +23,8 @@ github.com/cyphar/filepath-securejoin v0.4.1/go.mod h1:Sdj7gXlvMcPZsbhwhQ33GguGL
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davidmz/go-pageant v1.0.2 h1:bPblRCh5jGU+Uptpz6LgMZGD5hJoOt7otgT454WvHn0=
github.com/davidmz/go-pageant v1.0.2/go.mod h1:P2EDDnMqIwG5Rrp05dTRITj9z2zpGcD9efWSkTNKLIE=
github.com/elazarl/goproxy v1.7.2 h1:Y2o6urb7Eule09PjlhQRGNsqRfPmYI3KKQLFpCAV3+o=
github.com/elazarl/goproxy v1.7.2/go.mod h1:82vkLNir0ALaW14Rc399OTTjyNREgmdL2cVoIbS6XaE=
github.com/emirpasic/gods v1.18.1 h1:FXtiHYKDGKCW2KzwZKx0iC0PQmdlorYgdFG9jPXJ1Bc=
@ -27,6 +33,8 @@ 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.6.2 h1:6Q86EsPXMa7c3YZ3aLAQsMA0VlWmy43r6FHqa/UNbRM=
@ -50,6 +58,8 @@ github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB1
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=
@ -111,16 +121,23 @@ gitlab.com/gitlab-org/api/client-go v0.142.0 h1:cR8+RhDc7ooH0SiGNhgm3Nf5ZpW5D1R3
gitlab.com/gitlab-org/api/client-go v0.142.0/go.mod h1:3YuWlZCirs2TTcaAzM6qNwVHB7WvV67ATb0GGpBCdlQ=
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=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.0.0-20210513164829-c07d793c2f9a/go.mod h1:P+XmwS30IXTQdn5tA2iutPOUgjI07+tq3H3K9MVA1s8=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
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.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
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=
@ -132,6 +149,8 @@ golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
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.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=

View 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
}