diff --git a/cmd/rp/cmd/run.go b/cmd/rp/cmd/run.go index d6bbe4b..3d575b0 100644 --- a/cmd/rp/cmd/run.go +++ b/cmd/rp/cmd/run.go @@ -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 } diff --git a/go.mod b/go.mod index 84390a0..d589450 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/go.sum b/go.sum index 283db4c..f0a4935 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/forge/forgejo/forgejo.go b/internal/forge/forgejo/forgejo.go new file mode 100644 index 0000000..9b0c548 --- /dev/null +++ b/internal/forge/forgejo/forgejo.go @@ -0,0 +1,529 @@ +package forgejo + +import ( + "context" + "fmt" + "log/slog" + "slices" + "strings" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + "github.com/blang/semver/v4" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" + + "github.com/apricote/releaser-pleaser/internal/forge" + "github.com/apricote/releaser-pleaser/internal/git" + "github.com/apricote/releaser-pleaser/internal/pointer" + "github.com/apricote/releaser-pleaser/internal/releasepr" +) + +const () + +var _ forge.Forge = &Forgejo{} + +type Forgejo struct { + options *Options + + client *forgejo.Client + log *slog.Logger +} + +func (f *Forgejo) RepoURL() string { + return fmt.Sprintf("%s/%s/%s", f.options.APIURL, f.options.Owner, f.options.Repo) +} + +func (f *Forgejo) CloneURL() string { + return fmt.Sprintf("%s.git", f.RepoURL()) +} + +func (f *Forgejo) ReleaseURL(version string) string { + return fmt.Sprintf("%s/releases/tag/%s", f.RepoURL(), version) +} + +func (f *Forgejo) PullRequestURL(id int) string { + return fmt.Sprintf("%s/pulls/%d", f.RepoURL(), id) +} + +func (f *Forgejo) GitAuth() transport.AuthMethod { + return &http.BasicAuth{ + Username: f.options.Username, + Password: f.options.APIToken, + } +} + +func (f *Forgejo) CommitAuthor(ctx context.Context) (git.Author, error) { + f.log.DebugContext(ctx, "getting commit author from current token user") + + user, _, err := f.client.GetMyUserInfo() + if err != nil { + return git.Author{}, err + } + + // TODO: Same for other forges? + name := user.FullName + if name == "" { + name = user.UserName + } + + return git.Author{ + Name: name, + Email: user.Email, + }, nil +} + +func (f *Forgejo) LatestTags(ctx context.Context) (git.Releases, error) { + f.log.DebugContext(ctx, "listing all tags in forgejo repository") + + tags, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Tag, *forgejo.Response, error) { + return f.client.ListRepoTags(f.options.Owner, f.options.Repo, + forgejo.ListRepoTagsOptions{ListOptions: listOptions}, + ) + }) + if err != nil { + return git.Releases{}, err + } + + var releases git.Releases + + for _, fTag := range tags { + tag := &git.Tag{ + Hash: fTag.Commit.SHA, + Name: fTag.Name, + } + + version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v")) + if err != nil { + f.log.WarnContext( + ctx, "unable to parse tag as semver, skipping", + "tag.name", tag.Name, + "tag.hash", tag.Hash, + "error", err, + ) + continue + } + + if releases.Latest == nil { + releases.Latest = tag + } + if len(version.Pre) == 0 { + // Stable version tag + // We return once we have found the latest stable tag, not needed to look at every single tag. + releases.Stable = tag + break + } + } + + return releases, nil +} + +func (f *Forgejo) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) { + var repositoryCommits []*forgejo.Commit + var err error + if tag != nil { + repositoryCommits, err = f.commitsSinceTag(ctx, tag) + } else { + repositoryCommits, err = f.commitsSinceInit(ctx) + } + + if err != nil { + return nil, err + } + + var commits = make([]git.Commit, 0, len(repositoryCommits)) + for _, fCommit := range repositoryCommits { + commit := git.Commit{ + Hash: fCommit.SHA, + Message: fCommit.RepoCommit.Message, + } + commit.PullRequest, err = f.prForCommit(ctx, commit) + if err != nil { + return nil, fmt.Errorf("failed to check for commit pull request: %w", err) + } + + commits = append(commits, commit) + } + + return commits, nil +} + +func (f *Forgejo) commitsSinceTag(_ context.Context, tag *git.Tag) ([]*forgejo.Commit, error) { + head := f.options.BaseBranch + log := f.log.With("base", tag.Hash, "head", head) + log.Debug("comparing commits") + + compare, _, err := f.client.CompareCommits( + f.options.Owner, f.options.Repo, + tag.Hash, head) + if err != nil { + return nil, err + } + + return compare.Commits, nil +} + +func (f *Forgejo) commitsSinceInit(_ context.Context) ([]*forgejo.Commit, error) { + head := f.options.BaseBranch + log := f.log.With("head", head) + log.Debug("listing all commits") + + repositoryCommits, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.Commit, *forgejo.Response, error) { + return f.client.ListRepoCommits( + f.options.Owner, f.options.Repo, + forgejo.ListCommitOptions{ + ListOptions: listOptions, + SHA: f.options.BaseBranch, + }) + }) + if err != nil { + return nil, err + } + + return repositoryCommits, nil +} + +func (f *Forgejo) prForCommit(_ context.Context, commit git.Commit) (*git.PullRequest, error) { + // We naively look up the associated PR for each commit through the "List pull requests associated with a commit" + // endpoint. This requires len(commits) requests. + // Using the "List pull requests" endpoint might be faster, as it allows us to fetch 100 arbitrary PRs per request, + // but worst case we need to look up all PRs made in the repository ever. + + f.log.Debug("fetching pull requests associated with commit", "commit.hash", commit.Hash) + + pullRequest, _, err := f.client.GetCommitPullRequest( + f.options.Owner, f.options.Repo, + commit.Hash, + ) + if err != nil { + if strings.HasPrefix(err.Error(), "pull request does not exist") { + return nil, nil + } + + return nil, err + } + + return forgejoPRToPullRequest(pullRequest), nil +} + +func (f *Forgejo) EnsureLabelsExist(_ context.Context, labels []releasepr.Label) error { + f.log.Debug("fetching labels on repo") + fLabels, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) { + return f.client.ListRepoLabels( + f.options.Owner, f.options.Repo, + forgejo.ListLabelsOptions{ListOptions: listOptions}) + }) + if err != nil { + return err + } + + for _, label := range labels { + if !slices.ContainsFunc(fLabels, func(fLabel *forgejo.Label) bool { + return fLabel.Name == label.Name + }) { + f.log.Info("creating label in repository", "label.name", label.Name) + _, _, err = f.client.CreateLabel( + f.options.Owner, f.options.Repo, + forgejo.CreateLabelOption{ + Name: label.Name, + Color: label.Color, + Description: label.Description, + }, + ) + if err != nil { + return err + } + } + } + + return nil +} + +func (f *Forgejo) PullRequestForBranch(_ context.Context, branch string) (*releasepr.ReleasePullRequest, error) { + prs, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) { + return f.client.ListRepoPullRequests( + f.options.Owner, f.options.Repo, + forgejo.ListPullRequestsOptions{ + ListOptions: listOptions, + State: forgejo.StateOpen, + }, + ) + }, + ) + if err != nil { + return nil, err + } + + for _, pr := range prs { + if pr.Base.Ref == f.options.BaseBranch && pr.Head.Ref == branch { + return forgejoPRToReleasePullRequest(pr), nil + } + } + + return nil, nil +} + +func (f *Forgejo) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error { + fPR, _, err := f.client.CreatePullRequest( + f.options.Owner, f.options.Repo, + forgejo.CreatePullRequestOption{ + Title: pr.Title, + Head: pr.Head, + Base: f.options.BaseBranch, + Body: pr.Description, + }, + ) + if err != nil { + return err + } + + // TODO: String ID? + pr.ID = int(fPR.ID) + + err = f.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) UpdatePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error { + _, _, err := f.client.EditPullRequest( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.EditPullRequestOption{ + Title: pr.Title, + Body: pr.Description, + }, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) SetPullRequestLabels(_ context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error { + allLabels, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) { + return f.client.ListRepoLabels(f.options.Owner, f.options.Repo, forgejo.ListLabelsOptions{ListOptions: listOptions}) + }, + ) + if err != nil { + return err + } + + findLabel := func(labelName string) *forgejo.Label { + for _, fLabel := range allLabels { + if fLabel.Name == labelName { + return fLabel + } + } + + return nil + } + + for _, label := range remove { + fLabel := findLabel(label.Name) + if fLabel == nil { + return fmt.Errorf("unable to remove label %q, not found in API", label.Name) + } + + _, err = f.client.DeleteIssueLabel( + f.options.Owner, f.options.Repo, + int64(pr.ID), fLabel.ID, + ) + if err != nil { + return err + } + } + + addIDs := make([]int64, 0, len(add)) + for _, label := range add { + fLabel := findLabel(label.Name) + if fLabel == nil { + return fmt.Errorf("unable to add label %q, not found in API", label.Name) + } + + addIDs = append(addIDs, fLabel.ID) + } + + _, _, err = f.client.AddIssueLabels( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.IssueLabelsOption{Labels: addIDs}, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) ClosePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error { + _, _, err := f.client.EditPullRequest( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.EditPullRequestOption{ + State: pointer.Pointer(forgejo.StateClosed), + }, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) PendingReleases(_ context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) { + fPRs, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) { + return f.client.ListRepoPullRequests( + f.options.Owner, f.options.Repo, + forgejo.ListPullRequestsOptions{ + // Filtering by Label ID is possible in the API, but not implemented in the Go SDK. + State: forgejo.StateClosed, + ListOptions: listOptions, + }) + }) + if err != nil { + // "The target couldn't be found." means that the repo does not have pull requests activated. + return nil, err + } + + prs := make([]*releasepr.ReleasePullRequest, 0, len(fPRs)) + + for _, pr := range fPRs { + pending := slices.ContainsFunc(pr.Labels, func(l *forgejo.Label) bool { + return l.Name == pendingLabel.Name + }) + if !pending { + continue + } + + // pr.Merged is always nil :( + if !pr.HasMerged { + // Closed and not merged + continue + } + + prs = append(prs, forgejoPRToReleasePullRequest(pr)) + } + + return prs, nil +} + +func (f *Forgejo) CreateRelease(_ context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error { + // latest can not be set through the API + + _, _, err := f.client.CreateRelease( + f.options.Owner, f.options.Repo, + forgejo.CreateReleaseOption{ + TagName: title, + Target: commit.Hash, + Title: title, + Note: changelog, + IsPrerelease: preRelease, + }, + ) + if err != nil { + return err + } + + return nil +} + +func all[T any](f func(listOptions forgejo.ListOptions) ([]T, *forgejo.Response, error)) ([]T, error) { + results := make([]T, 0) + page := 1 + + for { + pageResults, resp, err := f(forgejo.ListOptions{Page: page}) + if err != nil { + return nil, err + } + + results = append(results, pageResults...) + + if page == resp.LastPage || resp.LastPage == 0 { + return results, nil + } + page = resp.NextPage + } +} + +func forgejoPRToPullRequest(pr *forgejo.PullRequest) *git.PullRequest { + return &git.PullRequest{ + ID: int(pr.ID), + Title: pr.Title, + Description: pr.Body, + } +} + +func forgejoPRToReleasePullRequest(pr *forgejo.PullRequest) *releasepr.ReleasePullRequest { + labels := make([]releasepr.Label, 0, len(pr.Labels)) + for _, label := range pr.Labels { + labelName := label.Name + if i := slices.IndexFunc(releasepr.KnownLabels, func(label releasepr.Label) bool { + return label.Name == labelName + }); i >= 0 { + labels = append(labels, releasepr.KnownLabels[i]) + } + } + + var releaseCommit *git.Commit + if pr.MergedCommitID != nil { + releaseCommit = &git.Commit{Hash: *pr.MergedCommitID} + } + + return &releasepr.ReleasePullRequest{ + PullRequest: *forgejoPRToPullRequest(pr), + Labels: labels, + + Head: pr.Head.Ref, + ReleaseCommit: releaseCommit, + } +} + +func (g *Options) autodiscover() { + // TODO +} + +func (g *Options) ClientOptions() []forgejo.ClientOption { + options := []forgejo.ClientOption{} + + if g.APIToken != "" { + options = append(options, forgejo.SetToken(g.APIToken)) + } + + return options +} + +type Options struct { + forge.Options + + Owner string + Repo string + + APIURL string + Username string + APIToken string +} + +func New(log *slog.Logger, options *Options) (*Forgejo, error) { + options.autodiscover() + + client, err := forgejo.NewClient(options.APIURL, options.ClientOptions()...) + if err != nil { + return nil, err + } + + client.SetUserAgent("releaser-pleaser") + + f := &Forgejo{ + options: options, + + client: client, + log: log.With("forge", "forgejo"), + } + + return f, nil +}