From fcf79061498d94592b10c8dd0c04c3f809d64395 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Julian=20T=C3=B6lle?= Date: Sat, 13 Sep 2025 12:00:54 +0200 Subject: [PATCH] test: add e2e tests with local Forgejo instance (#201) * 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. * test(e2e): introduce e2e test framework with local forgejo --- .github/workflows/ci.yaml | 29 ++ .golangci.yaml | 4 + cmd/rp/cmd/run.go | 24 ++ codecov.yaml | 2 + go.mod | 9 +- go.sum | 19 ++ internal/forge/forgejo/forgejo.go | 529 ++++++++++++++++++++++++++++++ mise.toml | 15 + test/e2e/forge.go | 19 ++ test/e2e/forgejo/app.ini | 23 ++ test/e2e/forgejo/compose.yaml | 16 + test/e2e/forgejo/forge.go | 113 +++++++ test/e2e/forgejo/forgejo_test.go | 39 +++ test/e2e/framework.go | 96 ++++++ 14 files changed, 936 insertions(+), 1 deletion(-) create mode 100644 codecov.yaml create mode 100644 internal/forge/forgejo/forgejo.go create mode 100644 test/e2e/forge.go create mode 100644 test/e2e/forgejo/app.ini create mode 100644 test/e2e/forgejo/compose.yaml create mode 100644 test/e2e/forgejo/forge.go create mode 100644 test/e2e/forgejo/forgejo_test.go create mode 100644 test/e2e/framework.go diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index ddc3e66..6483868 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -35,6 +35,35 @@ jobs: 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@11bd71901bbe5b1630ceea73d27597364c9af683 # v4 + + - name: Set up Go + uses: actions/setup-go@d35c59abb061a4a6fb18e82ac0862c26744d6ab5 # v5 + with: + go-version-file: go.mod + + # We can not use "jobs..services". + # We want to mount the config file, which is only available after "Checkout". + - name: Start Forgejo + working-directory: test/e2e/forgejo + run: docker compose up --wait + + - name: Run tests + run: go test -tags e2e_forgejo -v -race -coverpkg=./... -coverprofile=coverage.txt ./test/e2e/forgejo + + + - name: Upload results to Codecov + uses: codecov/codecov-action@18283e04ce6e62d37312384ff67231eb8fd56d24 # v5 + with: + token: ${{ secrets.CODECOV_TOKEN }} + flags: e2e go-mod-tidy: runs-on: ubuntu-latest diff --git a/.golangci.yaml b/.golangci.yaml index 5af5a17..b66f0a5 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -48,6 +48,10 @@ linters: - name: exported disabled: true + gomoddirectives: + replace-allow-list: + - codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2 + formatters: enable: - gci 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/codecov.yaml b/codecov.yaml new file mode 100644 index 0000000..b52d0c4 --- /dev/null +++ b/codecov.yaml @@ -0,0 +1,2 @@ +ignore: + - "test" diff --git a/go.mod b/go.mod index 22ee46f..a9edd3d 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,11 @@ module github.com/apricote/releaser-pleaser -go 1.23.2 +go 1.24 toolchain go1.25.1 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 fed9f72..fee79a7 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.144.0 h1:np2G+h2vanpxQrqGIM9LGmoKQecyUanj gitlab.com/gitlab-org/api/client-go v0.144.0/go.mod h1:rw89Kl9AsKmxRhzkfUSfZ+1jpTewwueKvAYwoYmUoQ8= 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.28.0 h1:rhazDwis8INMIwQ4tpjLDzUhx6RlXqZNPEM0huQojng= golang.org/x/text v0.28.0/go.mod h1:U8nCwOR8jO/marOQ0QbDiOngZVEBB7MAiitBuMjXiNU= diff --git a/internal/forge/forgejo/forgejo.go b/internal/forge/forgejo/forgejo.go new file mode 100644 index 0000000..9b0c548 --- /dev/null +++ b/internal/forge/forgejo/forgejo.go @@ -0,0 +1,529 @@ +package forgejo + +import ( + "context" + "fmt" + "log/slog" + "slices" + "strings" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + "github.com/blang/semver/v4" + "github.com/go-git/go-git/v5/plumbing/transport" + "github.com/go-git/go-git/v5/plumbing/transport/http" + + "github.com/apricote/releaser-pleaser/internal/forge" + "github.com/apricote/releaser-pleaser/internal/git" + "github.com/apricote/releaser-pleaser/internal/pointer" + "github.com/apricote/releaser-pleaser/internal/releasepr" +) + +const () + +var _ forge.Forge = &Forgejo{} + +type Forgejo struct { + options *Options + + client *forgejo.Client + log *slog.Logger +} + +func (f *Forgejo) RepoURL() string { + return fmt.Sprintf("%s/%s/%s", f.options.APIURL, f.options.Owner, f.options.Repo) +} + +func (f *Forgejo) CloneURL() string { + return fmt.Sprintf("%s.git", f.RepoURL()) +} + +func (f *Forgejo) ReleaseURL(version string) string { + return fmt.Sprintf("%s/releases/tag/%s", f.RepoURL(), version) +} + +func (f *Forgejo) PullRequestURL(id int) string { + return fmt.Sprintf("%s/pulls/%d", f.RepoURL(), id) +} + +func (f *Forgejo) GitAuth() transport.AuthMethod { + return &http.BasicAuth{ + Username: f.options.Username, + Password: f.options.APIToken, + } +} + +func (f *Forgejo) CommitAuthor(ctx context.Context) (git.Author, error) { + f.log.DebugContext(ctx, "getting commit author from current token user") + + user, _, err := f.client.GetMyUserInfo() + if err != nil { + return git.Author{}, err + } + + // TODO: Same for other forges? + name := user.FullName + if name == "" { + name = user.UserName + } + + return git.Author{ + Name: name, + Email: user.Email, + }, nil +} + +func (f *Forgejo) LatestTags(ctx context.Context) (git.Releases, error) { + f.log.DebugContext(ctx, "listing all tags in forgejo repository") + + tags, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Tag, *forgejo.Response, error) { + return f.client.ListRepoTags(f.options.Owner, f.options.Repo, + forgejo.ListRepoTagsOptions{ListOptions: listOptions}, + ) + }) + if err != nil { + return git.Releases{}, err + } + + var releases git.Releases + + for _, fTag := range tags { + tag := &git.Tag{ + Hash: fTag.Commit.SHA, + Name: fTag.Name, + } + + version, err := semver.Parse(strings.TrimPrefix(tag.Name, "v")) + if err != nil { + f.log.WarnContext( + ctx, "unable to parse tag as semver, skipping", + "tag.name", tag.Name, + "tag.hash", tag.Hash, + "error", err, + ) + continue + } + + if releases.Latest == nil { + releases.Latest = tag + } + if len(version.Pre) == 0 { + // Stable version tag + // We return once we have found the latest stable tag, not needed to look at every single tag. + releases.Stable = tag + break + } + } + + return releases, nil +} + +func (f *Forgejo) CommitsSince(ctx context.Context, tag *git.Tag) ([]git.Commit, error) { + var repositoryCommits []*forgejo.Commit + var err error + if tag != nil { + repositoryCommits, err = f.commitsSinceTag(ctx, tag) + } else { + repositoryCommits, err = f.commitsSinceInit(ctx) + } + + if err != nil { + return nil, err + } + + var commits = make([]git.Commit, 0, len(repositoryCommits)) + for _, fCommit := range repositoryCommits { + commit := git.Commit{ + Hash: fCommit.SHA, + Message: fCommit.RepoCommit.Message, + } + commit.PullRequest, err = f.prForCommit(ctx, commit) + if err != nil { + return nil, fmt.Errorf("failed to check for commit pull request: %w", err) + } + + commits = append(commits, commit) + } + + return commits, nil +} + +func (f *Forgejo) commitsSinceTag(_ context.Context, tag *git.Tag) ([]*forgejo.Commit, error) { + head := f.options.BaseBranch + log := f.log.With("base", tag.Hash, "head", head) + log.Debug("comparing commits") + + compare, _, err := f.client.CompareCommits( + f.options.Owner, f.options.Repo, + tag.Hash, head) + if err != nil { + return nil, err + } + + return compare.Commits, nil +} + +func (f *Forgejo) commitsSinceInit(_ context.Context) ([]*forgejo.Commit, error) { + head := f.options.BaseBranch + log := f.log.With("head", head) + log.Debug("listing all commits") + + repositoryCommits, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.Commit, *forgejo.Response, error) { + return f.client.ListRepoCommits( + f.options.Owner, f.options.Repo, + forgejo.ListCommitOptions{ + ListOptions: listOptions, + SHA: f.options.BaseBranch, + }) + }) + if err != nil { + return nil, err + } + + return repositoryCommits, nil +} + +func (f *Forgejo) prForCommit(_ context.Context, commit git.Commit) (*git.PullRequest, error) { + // We naively look up the associated PR for each commit through the "List pull requests associated with a commit" + // endpoint. This requires len(commits) requests. + // Using the "List pull requests" endpoint might be faster, as it allows us to fetch 100 arbitrary PRs per request, + // but worst case we need to look up all PRs made in the repository ever. + + f.log.Debug("fetching pull requests associated with commit", "commit.hash", commit.Hash) + + pullRequest, _, err := f.client.GetCommitPullRequest( + f.options.Owner, f.options.Repo, + commit.Hash, + ) + if err != nil { + if strings.HasPrefix(err.Error(), "pull request does not exist") { + return nil, nil + } + + return nil, err + } + + return forgejoPRToPullRequest(pullRequest), nil +} + +func (f *Forgejo) EnsureLabelsExist(_ context.Context, labels []releasepr.Label) error { + f.log.Debug("fetching labels on repo") + fLabels, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) { + return f.client.ListRepoLabels( + f.options.Owner, f.options.Repo, + forgejo.ListLabelsOptions{ListOptions: listOptions}) + }) + if err != nil { + return err + } + + for _, label := range labels { + if !slices.ContainsFunc(fLabels, func(fLabel *forgejo.Label) bool { + return fLabel.Name == label.Name + }) { + f.log.Info("creating label in repository", "label.name", label.Name) + _, _, err = f.client.CreateLabel( + f.options.Owner, f.options.Repo, + forgejo.CreateLabelOption{ + Name: label.Name, + Color: label.Color, + Description: label.Description, + }, + ) + if err != nil { + return err + } + } + } + + return nil +} + +func (f *Forgejo) PullRequestForBranch(_ context.Context, branch string) (*releasepr.ReleasePullRequest, error) { + prs, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) { + return f.client.ListRepoPullRequests( + f.options.Owner, f.options.Repo, + forgejo.ListPullRequestsOptions{ + ListOptions: listOptions, + State: forgejo.StateOpen, + }, + ) + }, + ) + if err != nil { + return nil, err + } + + for _, pr := range prs { + if pr.Base.Ref == f.options.BaseBranch && pr.Head.Ref == branch { + return forgejoPRToReleasePullRequest(pr), nil + } + } + + return nil, nil +} + +func (f *Forgejo) CreatePullRequest(ctx context.Context, pr *releasepr.ReleasePullRequest) error { + fPR, _, err := f.client.CreatePullRequest( + f.options.Owner, f.options.Repo, + forgejo.CreatePullRequestOption{ + Title: pr.Title, + Head: pr.Head, + Base: f.options.BaseBranch, + Body: pr.Description, + }, + ) + if err != nil { + return err + } + + // TODO: String ID? + pr.ID = int(fPR.ID) + + err = f.SetPullRequestLabels(ctx, pr, []releasepr.Label{}, pr.Labels) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) UpdatePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error { + _, _, err := f.client.EditPullRequest( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.EditPullRequestOption{ + Title: pr.Title, + Body: pr.Description, + }, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) SetPullRequestLabels(_ context.Context, pr *releasepr.ReleasePullRequest, remove, add []releasepr.Label) error { + allLabels, err := all( + func(listOptions forgejo.ListOptions) ([]*forgejo.Label, *forgejo.Response, error) { + return f.client.ListRepoLabels(f.options.Owner, f.options.Repo, forgejo.ListLabelsOptions{ListOptions: listOptions}) + }, + ) + if err != nil { + return err + } + + findLabel := func(labelName string) *forgejo.Label { + for _, fLabel := range allLabels { + if fLabel.Name == labelName { + return fLabel + } + } + + return nil + } + + for _, label := range remove { + fLabel := findLabel(label.Name) + if fLabel == nil { + return fmt.Errorf("unable to remove label %q, not found in API", label.Name) + } + + _, err = f.client.DeleteIssueLabel( + f.options.Owner, f.options.Repo, + int64(pr.ID), fLabel.ID, + ) + if err != nil { + return err + } + } + + addIDs := make([]int64, 0, len(add)) + for _, label := range add { + fLabel := findLabel(label.Name) + if fLabel == nil { + return fmt.Errorf("unable to add label %q, not found in API", label.Name) + } + + addIDs = append(addIDs, fLabel.ID) + } + + _, _, err = f.client.AddIssueLabels( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.IssueLabelsOption{Labels: addIDs}, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) ClosePullRequest(_ context.Context, pr *releasepr.ReleasePullRequest) error { + _, _, err := f.client.EditPullRequest( + f.options.Owner, f.options.Repo, + int64(pr.ID), forgejo.EditPullRequestOption{ + State: pointer.Pointer(forgejo.StateClosed), + }, + ) + if err != nil { + return err + } + + return nil +} + +func (f *Forgejo) PendingReleases(_ context.Context, pendingLabel releasepr.Label) ([]*releasepr.ReleasePullRequest, error) { + fPRs, err := all(func(listOptions forgejo.ListOptions) ([]*forgejo.PullRequest, *forgejo.Response, error) { + return f.client.ListRepoPullRequests( + f.options.Owner, f.options.Repo, + forgejo.ListPullRequestsOptions{ + // Filtering by Label ID is possible in the API, but not implemented in the Go SDK. + State: forgejo.StateClosed, + ListOptions: listOptions, + }) + }) + if err != nil { + // "The target couldn't be found." means that the repo does not have pull requests activated. + return nil, err + } + + prs := make([]*releasepr.ReleasePullRequest, 0, len(fPRs)) + + for _, pr := range fPRs { + pending := slices.ContainsFunc(pr.Labels, func(l *forgejo.Label) bool { + return l.Name == pendingLabel.Name + }) + if !pending { + continue + } + + // pr.Merged is always nil :( + if !pr.HasMerged { + // Closed and not merged + continue + } + + prs = append(prs, forgejoPRToReleasePullRequest(pr)) + } + + return prs, nil +} + +func (f *Forgejo) CreateRelease(_ context.Context, commit git.Commit, title, changelog string, preRelease, latest bool) error { + // latest can not be set through the API + + _, _, err := f.client.CreateRelease( + f.options.Owner, f.options.Repo, + forgejo.CreateReleaseOption{ + TagName: title, + Target: commit.Hash, + Title: title, + Note: changelog, + IsPrerelease: preRelease, + }, + ) + if err != nil { + return err + } + + return nil +} + +func all[T any](f func(listOptions forgejo.ListOptions) ([]T, *forgejo.Response, error)) ([]T, error) { + results := make([]T, 0) + page := 1 + + for { + pageResults, resp, err := f(forgejo.ListOptions{Page: page}) + if err != nil { + return nil, err + } + + results = append(results, pageResults...) + + if page == resp.LastPage || resp.LastPage == 0 { + return results, nil + } + page = resp.NextPage + } +} + +func forgejoPRToPullRequest(pr *forgejo.PullRequest) *git.PullRequest { + return &git.PullRequest{ + ID: int(pr.ID), + Title: pr.Title, + Description: pr.Body, + } +} + +func forgejoPRToReleasePullRequest(pr *forgejo.PullRequest) *releasepr.ReleasePullRequest { + labels := make([]releasepr.Label, 0, len(pr.Labels)) + for _, label := range pr.Labels { + labelName := label.Name + if i := slices.IndexFunc(releasepr.KnownLabels, func(label releasepr.Label) bool { + return label.Name == labelName + }); i >= 0 { + labels = append(labels, releasepr.KnownLabels[i]) + } + } + + var releaseCommit *git.Commit + if pr.MergedCommitID != nil { + releaseCommit = &git.Commit{Hash: *pr.MergedCommitID} + } + + return &releasepr.ReleasePullRequest{ + PullRequest: *forgejoPRToPullRequest(pr), + Labels: labels, + + Head: pr.Head.Ref, + ReleaseCommit: releaseCommit, + } +} + +func (g *Options) autodiscover() { + // TODO +} + +func (g *Options) ClientOptions() []forgejo.ClientOption { + options := []forgejo.ClientOption{} + + if g.APIToken != "" { + options = append(options, forgejo.SetToken(g.APIToken)) + } + + return options +} + +type Options struct { + forge.Options + + Owner string + Repo string + + APIURL string + Username string + APIToken string +} + +func New(log *slog.Logger, options *Options) (*Forgejo, error) { + options.autodiscover() + + client, err := forgejo.NewClient(options.APIURL, options.ClientOptions()...) + if err != nil { + return nil, err + } + + client.SetUserAgent("releaser-pleaser") + + f := &Forgejo{ + options: options, + + client: client, + log: log.With("forge", "forgejo"), + } + + return f, nil +} diff --git a/mise.toml b/mise.toml index 46db89d..9503842 100644 --- a/mise.toml +++ b/mise.toml @@ -8,3 +8,18 @@ ko = "v0.18.0" # renovate: datasource=github-releases depName=ko-build/ko [settings] # Experimental features are needed for the Go backend experimental = true + +[tasks.lint] +run = "golangci-lint run" + +[tasks.test] +run = "go test -v -race ./..." + +[tasks.test-e2e] +run = "go test -tags e2e_forgejo -v -race ./test/e2e/forgejo" + +[tasks.e2e-forgejo-start] +run = "docker compose --project-directory ./test/e2e/forgejo up -d --wait" + +[tasks.e2e-forgejo-stop] +run = "docker compose --project-directory ./test/e2e/forgejo down" diff --git a/test/e2e/forge.go b/test/e2e/forge.go new file mode 100644 index 0000000..ea55545 --- /dev/null +++ b/test/e2e/forge.go @@ -0,0 +1,19 @@ +package e2e + +import ( + "context" + "testing" +) + +type TestForge interface { + Init(ctx context.Context, runID string) error + CreateRepo(t *testing.T, opts CreateRepoOpts) (*Repository, error) + + RunArguments() []string +} + +type CreateRepoOpts struct { + Name string + Description string + DefaultBranch string +} diff --git a/test/e2e/forgejo/app.ini b/test/e2e/forgejo/app.ini new file mode 100644 index 0000000..4a22d2e --- /dev/null +++ b/test/e2e/forgejo/app.ini @@ -0,0 +1,23 @@ +WORK_PATH = /data/gitea + +[database] +DB_TYPE = sqlite3 +PATH = /data/gitea/forgejo.db + +[security] +INSTALL_LOCK = true +SECRET_KEY = releaser-pleaser +INTERNAL_TOKEN = releaser-pleaser + +[service] +REGISTER_EMAIL_CONFIRM = false +ENABLE_NOTIFY_MAIL = false +DISABLE_REGISTRATION = true + +[server] +DOMAIN = localhost +HTTP_PORT = 3000 +ROOT_URL = http://localhost:3000/ + +[oauth2] +JWT_SECRET = rTD-FL2n_aBB6v4AOcr5lBvwgZ6PSr3HGZAuNH6nMu8 diff --git a/test/e2e/forgejo/compose.yaml b/test/e2e/forgejo/compose.yaml new file mode 100644 index 0000000..3f20c3a --- /dev/null +++ b/test/e2e/forgejo/compose.yaml @@ -0,0 +1,16 @@ +services: + forgejo: + image: codeberg.org/forgejo/forgejo:11 + ports: + - '3000:3000' + - '222:22' + volumes: + - data:/data/gitea + - ./app.ini:/data/gitea/conf/app.ini:ro + + healthcheck: + test: ["CMD", "curl", "localhost:3000/api/healthz"] + + +volumes: + data: diff --git a/test/e2e/forgejo/forge.go b/test/e2e/forgejo/forge.go new file mode 100644 index 0000000..c631cb8 --- /dev/null +++ b/test/e2e/forgejo/forge.go @@ -0,0 +1,113 @@ +package forgejo + +import ( + "context" + "fmt" + "log/slog" + "os/exec" + "strings" + "testing" + + "codeberg.org/mvdkleijn/forgejo-sdk/forgejo/v2" + + "github.com/apricote/releaser-pleaser/test/e2e" +) + +const ( + TestAPIURL = "http://localhost:3000" + + TestUserNameTemplate = "rp-%s" + TestUserPassword = "releaser-pleaser" + TestUserEmailTemplate = "releaser-pleaser-%s@example.com" + TestTokenName = "rp" + TestTokenScopes = "write:user,write:issue,write:repository" +) + +type TestForge struct { + username string + token string + client *forgejo.Client +} + +func (f *TestForge) Init(ctx context.Context, runID string) error { + if err := f.initUser(ctx, runID); err != nil { + return err + } + if err := f.initClient(ctx); err != nil { + return err + } + + return nil +} + +func (f *TestForge) initUser(ctx context.Context, runID string) error { + f.username = fmt.Sprintf(TestUserNameTemplate, runID) + + //gosec:disable G204 + if output, err := exec.CommandContext(ctx, + "docker", "compose", "exec", "--user=1000", "forgejo", + "forgejo", "admin", "user", "create", + "--username", f.username, + "--password", TestUserPassword, + "--email", fmt.Sprintf(TestUserEmailTemplate, runID), + "--must-change-password=false", + ).CombinedOutput(); err != nil { + slog.Debug("create forgejo user output", "output", output) + return fmt.Errorf("failed to create forgejo user: %w", err) + } + + //gosec:disable G204 + token, err := exec.CommandContext(ctx, + "docker", "compose", "exec", "--user=1000", "forgejo", + "forgejo", "admin", "user", "generate-access-token", + "--username", f.username, + "--token-name", TestTokenName, + "--scopes", TestTokenScopes, + "--raw", + ).Output() + if err != nil { + return fmt.Errorf("failed to create forgejo token: %w", err) + } + + f.token = strings.TrimSpace(string(token)) + + return nil +} + +func (f *TestForge) initClient(ctx context.Context) (err error) { + f.client, err = forgejo.NewClient(TestAPIURL, + forgejo.SetToken(f.token), + forgejo.SetUserAgent("releaser-pleaser-e2e-tests"), + forgejo.SetContext(ctx), + // forgejo.SetDebugMode(), + ) + return err +} + +func (f *TestForge) CreateRepo(t *testing.T, opts e2e.CreateRepoOpts) (*e2e.Repository, error) { + t.Helper() + + repo, _, err := f.client.CreateRepo(forgejo.CreateRepoOption{ + Name: opts.Name, + Description: opts.Description, + DefaultBranch: opts.DefaultBranch, + Readme: "Default", + AutoInit: true, + }) + if err != nil { + return nil, err + } + + return &e2e.Repository{ + Name: repo.Name, + }, nil +} + +func (f *TestForge) RunArguments() []string { + return []string{"--forge=forgejo", + fmt.Sprintf("--owner=%s", f.username), + fmt.Sprintf("--api-url=%s", TestAPIURL), + fmt.Sprintf("--api-token=%s", f.token), + fmt.Sprintf("--username=%s", f.username), + } +} diff --git a/test/e2e/forgejo/forgejo_test.go b/test/e2e/forgejo/forgejo_test.go new file mode 100644 index 0000000..b52504f --- /dev/null +++ b/test/e2e/forgejo/forgejo_test.go @@ -0,0 +1,39 @@ +//go:build e2e_forgejo + +package forgejo + +import ( + "context" + "log/slog" + "os" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/apricote/releaser-pleaser/test/e2e" +) + +var ( + f *e2e.Framework +) + +func TestMain(m *testing.M) { + ctx := context.Background() + + var err error + f, err = e2e.NewFramework(ctx, &TestForge{}) + if err != nil { + slog.Error("failed to set up test framework", "err", err) + } + + os.Exit(m.Run()) +} + +func TestCreateRepository(t *testing.T) { + _ = f.NewRepository(t, t.Name()) +} + +func TestRun(t *testing.T) { + repo := f.NewRepository(t, t.Name()) + require.NoError(t, f.Run(t, repo, []string{})) +} diff --git a/test/e2e/framework.go b/test/e2e/framework.go new file mode 100644 index 0000000..7352f14 --- /dev/null +++ b/test/e2e/framework.go @@ -0,0 +1,96 @@ +package e2e + +import ( + "bytes" + "context" + "crypto/rand" + "encoding/hex" + "fmt" + "strings" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/apricote/releaser-pleaser/cmd/rp/cmd" +) + +const ( + TestDefaultBranch = "main" +) + +func randomString() string { + randomBytes := make([]byte, 4) + if _, err := rand.Read(randomBytes); err != nil { + panic(err) + } + return hex.EncodeToString(randomBytes) +} + +type Framework struct { + runID string + forge TestForge +} + +func NewFramework(ctx context.Context, forge TestForge) (*Framework, error) { + f := &Framework{ + runID: randomString(), + forge: forge, + } + + err := forge.Init(ctx, f.runID) + if err != nil { + return nil, err + } + + return f, nil +} + +type Repository struct { + Name string +} + +func (f *Framework) NewRepository(t *testing.T, name string) *Repository { + t.Helper() + + r := &Repository{ + Name: fmt.Sprintf("%s-%s-%s", name, f.runID, randomString()), + } + + repo, err := f.forge.CreateRepo(t, CreateRepoOpts{ + Name: r.Name, + Description: name, + DefaultBranch: TestDefaultBranch, + }) + require.NoError(t, err) + require.NotNil(t, repo) + + return r +} + +func (f *Framework) Run(t *testing.T, r *Repository, extraFiles []string) error { + t.Helper() + + ctx := t.Context() + + rootCmd := cmd.NewRootCmd() + rootCmd.SetArgs(append([]string{ + "run", + fmt.Sprintf("--repo=%s", r.Name), + fmt.Sprintf("--extra-files=%q", strings.Join(extraFiles, "\n")), + }, f.forge.RunArguments()...)) + + var stdout, stderr bytes.Buffer + + rootCmd.SetOut(&stdout) + rootCmd.SetErr(&stderr) + + err := rootCmd.ExecuteContext(ctx) + + stdoutString := stdout.String() + stderrString := stderr.String() + + t.Log(stdoutString) + t.Log(stderrString) + + return err +}