From a196e5f4acf66b96ac6546803b5174f04d3d728b Mon Sep 17 00:00:00 2001 From: Robert Kaussow Date: Wed, 8 May 2024 12:54:51 +0200 Subject: [PATCH] refactor: rework gitea client and add tests (#44) --- .golangci.yml | 11 +- plugin/impl.go | 27 ++-- plugin/release.go | 160 +++++++++--------- plugin/release_test.go | 356 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 467 insertions(+), 87 deletions(-) create mode 100644 plugin/release_test.go diff --git a/.golangci.yml b/.golangci.yml index 5de88c8..9780cbf 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -23,7 +23,6 @@ linters: - errchkjson - errname - errorlint - - execinquery - exhaustive - exportloopref - forcetypeassert @@ -37,12 +36,12 @@ linters: - gocyclo - godot - godox - - goerr113 + - err113 - gofmt - gofumpt - goheader - goimports - - gomnd + - mnd - gomoddirectives - gomodguard - goprintffuncname @@ -95,3 +94,9 @@ run: linters-settings: gofumpt: extra-rules: true + +issues: + exclude-rules: + - path: "_test.go" + linters: + - err113 diff --git a/plugin/impl.go b/plugin/impl.go index 384f997..7cefe63 100644 --- a/plugin/impl.go +++ b/plugin/impl.go @@ -4,7 +4,6 @@ import ( "context" "errors" "fmt" - "net/http" "net/url" "path/filepath" "strings" @@ -70,19 +69,17 @@ func (p *Plugin) Validate() error { // Execute provides the implementation of the plugin. func (p *Plugin) Execute() error { - httpClient := &http.Client{} - - client, err := gitea.NewClient( + gitea, err := gitea.NewClient( p.Settings.baseURL.String(), gitea.SetToken(p.Settings.APIKey), - gitea.SetHTTPClient(httpClient), + gitea.SetHTTPClient(p.Network.Client), ) if err != nil { return err } - r := &Release{ - Client: client, + client := NewGiteaClient(gitea) + client.Release.Opt = GiteaReleaseOpt{ Owner: p.Metadata.Repository.Owner, Repo: p.Metadata.Repository.Name, Tag: strings.TrimPrefix(p.Settings.CommitRef, "refs/tags/"), @@ -93,12 +90,20 @@ func (p *Plugin) Execute() error { Note: p.Settings.Note, } - release, err := r.buildRelease() - if err != nil { - return fmt.Errorf("failed to create the release: %w", err) + release, err := client.Release.Find() + if err != nil && !errors.Is(err, ErrReleaseNotFound) { + return fmt.Errorf("failed to retrieve release: %w", err) + } + + // If no release was found by that tag, create a new one. + if release == nil { + release, err = client.Release.Create() + if err != nil { + return fmt.Errorf("failed to create release: %w", err) + } } - if err := r.uploadFiles(release.ID, p.Settings.files); err != nil { + if err := client.Release.AddAttachments(release.ID, p.Settings.files); err != nil { return fmt.Errorf("failed to upload the files: %w", err) } diff --git a/plugin/release.go b/plugin/release.go index 149c50e..2d0ca14 100644 --- a/plugin/release.go +++ b/plugin/release.go @@ -15,9 +15,17 @@ var ( ErrFileExists = errors.New("asset file already exist") ) -// Release represents a release for a Gitea repository. -type Release struct { - *gitea.Client +type GiteaClient struct { + client *gitea.Client + Release *GiteaRelease +} + +type GiteaRelease struct { + client *gitea.Client + Opt GiteaReleaseOpt +} + +type GiteaReleaseOpt struct { Owner string Repo string Tag string @@ -28,65 +36,66 @@ type Release struct { Note string } -// buildRelease attempts to retrieve an existing release by the specified tag name. -func (rc *Release) buildRelease() (*gitea.Release, error) { - // first attempt to get a release by that tag - release, err := rc.getRelease() +type FileExists string - if err != nil && release == nil { - fmt.Println(err) - } else if release != nil { - return release, nil - } +const ( + FileExistsOverwrite FileExists = "overwrite" + FileExistsFail FileExists = "fail" + FileExistsSkip FileExists = "skip" +) - // if no release was found by that tag, create a new one - release, err = rc.newRelease() - if err != nil { - return nil, fmt.Errorf("failed to retrieve or create a release: %w", err) +// NewGiteaClient creates a new GiteaClient instance with the provided Gitea client. +func NewGiteaClient(client *gitea.Client) *GiteaClient { + return &GiteaClient{ + client: client, + Release: &GiteaRelease{ + client: client, + Opt: GiteaReleaseOpt{}, + }, } - - return release, nil } -// getRelease retrieves the release with the specified tag name from the repository. -func (rc *Release) getRelease() (*gitea.Release, error) { - releases, _, err := rc.Client.ListReleases(rc.Owner, rc.Repo, gitea.ListReleasesOptions{}) +// Find retrieves the release with the specified tag name from the repository. +// If the release is not found, it returns an ErrReleaseNotFound error. +func (r *GiteaRelease) Find() (*gitea.Release, error) { + releases, _, err := r.client.ListReleases(r.Opt.Owner, r.Opt.Repo, gitea.ListReleasesOptions{}) if err != nil { return nil, err } for _, release := range releases { - if release.TagName == rc.Tag { - log.Info().Msgf("successfully retrieved %s release", rc.Tag) + if release.TagName == r.Opt.Tag { + log.Info().Msgf("found release: %s", r.Opt.Tag) return release, nil } } - return nil, fmt.Errorf("%w: %s", ErrReleaseNotFound, rc.Tag) + return nil, fmt.Errorf("%w: %s", ErrReleaseNotFound, r.Opt.Tag) } -// newRelease creates a new release on the repository with the specified options. -func (rc *Release) newRelease() (*gitea.Release, error) { - r := gitea.CreateReleaseOption{ - TagName: rc.Tag, - IsDraft: rc.Draft, - IsPrerelease: rc.Prerelease, - Title: rc.Title, - Note: rc.Note, +// Create creates a new release on the Gitea repository with the specified options. +// It returns the created release or an error if the creation failed. +func (r *GiteaRelease) Create() (*gitea.Release, error) { + opts := gitea.CreateReleaseOption{ + TagName: r.Opt.Tag, + IsDraft: r.Opt.Draft, + IsPrerelease: r.Opt.Prerelease, + Title: r.Opt.Title, + Note: r.Opt.Note, } - release, _, err := rc.Client.CreateRelease(rc.Owner, rc.Repo, r) + release, _, err := r.client.CreateRelease(r.Opt.Owner, r.Opt.Repo, opts) if err != nil { return nil, fmt.Errorf("failed to create release: %w", err) } - log.Info().Msgf("successfully created %s release", rc.Tag) + log.Info().Msgf("created release: %s", r.Opt.Tag) return release, nil } -// uploadFiles uploads the specified files as attachments to the release with the given ID. +// AddAttachments uploads the specified files as attachments to the release with the given ID. // It first checks for any existing attachments with the same names, // and handles them according to the FileExists option: // @@ -95,61 +104,66 @@ func (rc *Release) newRelease() (*gitea.Release, error) { // - "skip": skips uploading the file and logs a warning // // If there are no conflicts, it uploads the new files as attachments to the release. -func (rc *Release) uploadFiles(releaseID int64, files []string) error { - attachments, _, err := rc.Client.ListReleaseAttachments( - rc.Owner, - rc.Repo, +func (r *GiteaRelease) AddAttachments(releaseID int64, files []string) error { + attachments, _, err := r.client.ListReleaseAttachments( + r.Opt.Owner, + r.Opt.Repo, releaseID, gitea.ListReleaseAttachmentsOptions{}, ) if err != nil { - return fmt.Errorf("failed to fetch existing assets: %w", err) + return fmt.Errorf("failed to fetch attachments: %w", err) } - var uploadFiles []string - -files: - for _, file := range files { - for _, attachment := range attachments { - if attachment.Name == path.Base(file) { - switch rc.FileExists { - case "overwrite": - // do nothing - case "fail": - return fmt.Errorf("%w: %s", ErrFileExists, path.Base(file)) - case "skip": - log.Warn().Msgf("skipping pre-existing %s artifact", attachment.Name) - - continue files - } - } - } + existingAttachments := make(map[string]bool) + attachmentsMap := make(map[string]*gitea.Attachment) - uploadFiles = append(uploadFiles, file) + for _, attachment := range attachments { + attachmentsMap[attachment.Name] = attachment + existingAttachments[attachment.Name] = true } - for _, file := range uploadFiles { - handle, err := os.Open(file) - if err != nil { - return fmt.Errorf("failed to read %s artifact: %w", file, err) - } - - for _, attachment := range attachments { - if attachment.Name == path.Base(file) { - if _, err := rc.Client.DeleteReleaseAttachment(rc.Owner, rc.Repo, releaseID, attachment.ID); err != nil { - return fmt.Errorf("failed to delete %s artifact: %w", file, err) + for _, file := range files { + fileName := path.Base(file) + if existingAttachments[fileName] { + switch FileExists(r.Opt.FileExists) { + case FileExistsOverwrite: + _, err := r.client.DeleteReleaseAttachment(r.Opt.Owner, r.Opt.Repo, releaseID, attachmentsMap[fileName].ID) + if err != nil { + return fmt.Errorf("failed to delete artifact: %s: %w", fileName, err) } - log.Info().Msgf("successfully deleted old %s artifact", attachment.Name) + log.Info().Msgf("deleted artifact: %s", fileName) + case FileExistsFail: + return fmt.Errorf("%w: %s", ErrFileExists, fileName) + case FileExistsSkip: + log.Warn().Msgf("skip existing artifact: %s", fileName) + + continue } } - if _, _, err = rc.Client.CreateReleaseAttachment(rc.Owner, rc.Repo, releaseID, handle, path.Base(file)); err != nil { - return fmt.Errorf("failed to upload %s artifact: %w", file, err) + if err := r.uploadFile(releaseID, file); err != nil { + return err } + } - log.Info().Msgf("successfully uploaded %s artifact", file) + return nil +} + +func (r *GiteaRelease) uploadFile(releaseID int64, file string) error { + handle, err := os.Open(file) + if err != nil { + return fmt.Errorf("failed to read artifact: %s: %w", file, err) + } + defer handle.Close() + + _, _, err = r.client.CreateReleaseAttachment(r.Opt.Owner, r.Opt.Repo, releaseID, handle, path.Base(file)) + if err != nil { + return fmt.Errorf("failed to upload artifact: %s: %w", file, err) } + log.Info().Msgf("uploaded artifact: %s", path.Base(file)) + return nil } diff --git a/plugin/release_test.go b/plugin/release_test.go new file mode 100644 index 0000000..17e766d --- /dev/null +++ b/plugin/release_test.go @@ -0,0 +1,356 @@ +package plugin + +import ( + "bytes" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "github.com/rs/zerolog/log" + + "code.gitea.io/sdk/gitea" + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func giteaMockHandler(t *testing.T, opt GiteaReleaseOpt) func(http.ResponseWriter, *http.Request) { + t.Helper() + + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + fmt.Println(r.RequestURI) + + switch r.RequestURI { + case "/api/v1/version": + _, err := io.WriteString(w, `{"version":"1.21.0"}`) + if err != nil { + t.Fail() + } + case "/api/v1/repos/test-owner/test-repo/releases?limit=0&page=1": + _, err := io.WriteString(w, `[{ + "id": 1, + "tag_name": "v1.0.0", + "name": "Release v1.0.0", + "body": "This is the release notes for v1.0.0", + "draft": false, + "prerelease": false, + "created_at": "2023-05-01T12:00:00Z", + "published_at": "2023-05-01T12:30:00Z" + }]`) + if err != nil { + t.Fail() + } + case "/api/v1/repos/test-owner/test-repo/releases": + _, err := io.WriteString(w, fmt.Sprintf(`{ + "id": 1, + "tag_name": "%s", + "name": "Release %s", + "body": "This is the release notes for %s", + "draft": %t, + "prerelease": %t, + "created_at": "2023-05-01T12:00:00Z", + "published_at": "2023-05-01T12:30:00Z" + }`, opt.Tag, opt.Tag, opt.Tag, opt.Draft, opt.Prerelease)) + if err != nil { + t.Fail() + } + case "/api/v1/repos/test-owner/test-repo/releases/1/assets?limit=0&page=1": + _, err := io.WriteString(w, `[{ + "id": 1, + "name": "file1.txt", + "size": 1024, + "created_at": "2023-05-01T12:30:00Z" + }]`) + if err != nil { + t.Fail() + } + case "/api/v1/repos/test-owner/test-repo/releases/1/assets": + _, err := io.WriteString(w, `{ + "id": 1, + "name": "file1.txt", + "size": 1024, + "created_at": "2023-05-01T12:30:00Z" + }`) + if err != nil { + t.Fail() + } + } + } +} + +func TestGiteaReleaseFind(t *testing.T) { + tests := []struct { + name string + opt GiteaReleaseOpt + want *gitea.Release + wantErr error + }{ + { + name: "find release by tag", + opt: GiteaReleaseOpt{ + Owner: "test-owner", + Repo: "test-repo", + Tag: "v1.0.0", + }, + want: &gitea.Release{ + TagName: "v1.0.0", + }, + }, + { + name: "release not found", + opt: GiteaReleaseOpt{ + Owner: "test-owner", + Repo: "test-repo", + Tag: "v1.1.0", + }, + want: nil, + wantErr: ErrReleaseNotFound, + }, + } + + for _, tt := range tests { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + giteaMockHandler(t, tt.opt)(w, r) + })) + defer ts.Close() + + g, _ := gitea.NewClient(ts.URL) + client := NewGiteaClient(g) + + t.Run(tt.name, func(t *testing.T) { + client.Release.Opt = tt.opt + release, err := client.Release.Find() + + if tt.want == nil { + assert.Error(t, err) + assert.Nil(t, release) + + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want.TagName, release.TagName) + }) + } +} + +func TestGiteaReleaseCreate(t *testing.T) { + tests := []struct { + name string + opt GiteaReleaseOpt + want *gitea.Release + wantErr error + }{ + { + name: "create release", + opt: GiteaReleaseOpt{ + Owner: "test-owner", + Repo: "test-repo", + Tag: "v1.1.0", + Title: "Release v1.1.0", + Note: "This is the release notes for v1.1.0", + Draft: false, + Prerelease: false, + }, + want: &gitea.Release{ + TagName: "v1.1.0", + Title: "Release v1.1.0", + Note: "This is the release notes for v1.1.0", + IsDraft: false, + IsPrerelease: false, + }, + }, + { + name: "create draft release", + opt: GiteaReleaseOpt{ + Owner: "test-owner", + Repo: "test-repo", + Tag: "v1.2.0", + Title: "Release v1.2.0", + Note: "This is the release notes for v1.2.0", + Draft: true, + Prerelease: false, + }, + want: &gitea.Release{ + TagName: "v1.2.0", + Title: "Release v1.2.0", + Note: "This is the release notes for v1.2.0", + IsDraft: true, + IsPrerelease: false, + }, + }, + { + name: "create prerelease", + opt: GiteaReleaseOpt{ + Owner: "test-owner", + Repo: "test-repo", + Tag: "v1.3.0-rc1", + Title: "Release v1.3.0-rc1", + Note: "This is the release notes for v1.3.0-rc1", + Draft: false, + Prerelease: true, + }, + want: &gitea.Release{ + TagName: "v1.3.0-rc1", + Title: "Release v1.3.0-rc1", + Note: "This is the release notes for v1.3.0-rc1", + IsDraft: false, + IsPrerelease: true, + }, + }, + } + + for _, tt := range tests { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + giteaMockHandler(t, tt.opt)(w, r) + })) + defer ts.Close() + + g, _ := gitea.NewClient(ts.URL) + client := NewGiteaClient(g) + + t.Run(tt.name, func(t *testing.T) { + client.Release.Opt = tt.opt + release, err := client.Release.Create() + + if tt.wantErr != nil { + assert.Error(t, err) + assert.Nil(t, release) + + return + } + + assert.NoError(t, err) + assert.Equal(t, tt.want.TagName, release.TagName) + assert.Equal(t, tt.want.Title, release.Title) + assert.Equal(t, tt.want.Note, release.Note) + assert.Equal(t, tt.want.IsDraft, release.IsDraft) + assert.Equal(t, tt.want.IsPrerelease, release.IsPrerelease) + }) + } +} + +func TestGiteaReleaseAddAttachments(t *testing.T) { + logBuffer := &bytes.Buffer{} + logger := zerolog.New(logBuffer) + log.Logger = logger + + tests := []struct { + name string + opt GiteaReleaseOpt + files []string + fileExists string + wantErr error + wantLogs []string + }{ + { + name: "add new attachments", + opt: GiteaReleaseOpt{ + Owner: "test-owner", + Repo: "test-repo", + Tag: "v2.0.0", + Title: "Release v2.0.0", + FileExists: "overwrite", + }, + files: []string{createTempFile(t, "file1.txt"), createTempFile(t, "file2.txt")}, + wantLogs: []string{"uploaded artifact: file1.txt", "uploaded artifact: file2.txt"}, + }, + { + name: "fail on existing attachments", + opt: GiteaReleaseOpt{ + Owner: "test-owner", + Repo: "test-repo", + Tag: "v2.0.0", + Title: "Release v2.0.0", + FileExists: "fail", + }, + files: []string{createTempFile(t, "file1.txt"), createTempFile(t, "file2.txt")}, + wantErr: ErrFileExists, + }, + { + name: "overwrite on existing attachments", + opt: GiteaReleaseOpt{ + Owner: "test-owner", + Repo: "test-repo", + Tag: "v2.0.0", + Title: "Release v2.0.0", + FileExists: "overwrite", + }, + files: []string{createTempFile(t, "file1.txt"), createTempFile(t, "file2.txt")}, + wantErr: nil, + wantLogs: []string{"deleted artifact: file1.txt", "uploaded artifact: file1.txt"}, + }, + { + name: "skip on existing attachments", + opt: GiteaReleaseOpt{ + Owner: "test-owner", + Repo: "test-repo", + Tag: "v2.0.0", + Title: "Release v2.0.0", + FileExists: "skip", + }, + files: []string{createTempFile(t, "file1.txt"), createTempFile(t, "file2.txt")}, + wantErr: nil, + wantLogs: []string{"skip existing artifact: file1"}, + }, + { + name: "fail on invalid file", + opt: GiteaReleaseOpt{ + Owner: "test-owner", + Repo: "test-repo", + Tag: "v2.0.0", + Title: "Release v2.0.0", + FileExists: "overwrite", + }, + files: []string{"testdata/file1.txt", "testdata/invalid.txt"}, + wantErr: errors.New("no such file or directory"), + }, + } + + for _, tt := range tests { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + giteaMockHandler(t, tt.opt)(w, r) + })) + defer ts.Close() + + logBuffer.Reset() + + g, _ := gitea.NewClient(ts.URL) + client := NewGiteaClient(g) + + t.Run(tt.name, func(t *testing.T) { + client.Release.Opt = tt.opt + release, _ := client.Release.Create() + + err := client.Release.AddAttachments(release.ID, tt.files) + + // Assert log output. + for _, l := range tt.wantLogs { + assert.Contains(t, logBuffer.String(), l) + } + + if tt.wantErr != nil { + assert.Error(t, err) + assert.ErrorContains(t, err, tt.wantErr.Error()) + + return + } + + assert.NoError(t, err) + }) + } +} + +func createTempFile(t *testing.T, name string) string { + t.Helper() + + name = filepath.Join(t.TempDir(), name) + _ = os.WriteFile(name, []byte("hello"), 0o600) + + return name +}