diff --git a/internal/releaser/github/github.go b/internal/releaser/github/github.go index 3f73b95..0eb9b4d 100644 --- a/internal/releaser/github/github.go +++ b/internal/releaser/github/github.go @@ -89,7 +89,7 @@ func (g *Client) CreateRelease(releaseVersion *shared.ReleaseVersion, generatedC prerelease := releaseVersion.Next.Version.Prerelease() != "" - release, resp, err := g.client.Repositories.CreateRelease(g.context, g.config.User, g.config.Repo, &github.RepositoryRelease{ + release, _, err := g.client.Repositories.CreateRelease(g.context, g.config.User, g.config.Repo, &github.RepositoryRelease{ TagName: &tag, TargetCommitish: &releaseVersion.Branch, Name: &generatedChangelog.Title, @@ -97,19 +97,18 @@ func (g *Client) CreateRelease(releaseVersion *shared.ReleaseVersion, generatedC Draft: &releaseVersion.Draft, Prerelease: &prerelease, }) - if err != nil { - if !strings.Contains(err.Error(), "already_exists") && resp.StatusCode >= http.StatusUnprocessableEntity { - return fmt.Errorf("could not create release: %v", err) + if strings.Contains(err.Error(), "already_exists") { + log.Infof("A release with tag %s already exits, will not perform a release or update", tag) + return nil } - log.Infof("A release with tag %s already exits, will not perform a release or update", tag) - } else { - g.release = release - log.Debugf("Release repsone: %+v", *release) - log.Infof("Crated release") + return fmt.Errorf("could not create release: %v", err) } - + g.release = release + log.Debugf("Release repsone: %+v", *release) + log.Infof("Crated release") return nil + } // UploadAssets uploads specified assets diff --git a/internal/releaser/github/github_test.go b/internal/releaser/github/github_test.go new file mode 100644 index 0000000..d9ab789 --- /dev/null +++ b/internal/releaser/github/github_test.go @@ -0,0 +1,237 @@ +package github_test + +import ( + "fmt" + "net/http" + "net/http/httptest" + "os" + "testing" + + log "github.com/sirupsen/logrus" + + "github.com/Masterminds/semver" + + "github.com/Nightapes/go-semantic-release/internal/releaser/github" + "github.com/Nightapes/go-semantic-release/internal/shared" + "github.com/Nightapes/go-semantic-release/pkg/config" + "github.com/stretchr/testify/assert" +) + +type testHelperMethodStruct struct { + config config.GitHubProvider + valid bool +} + +type testReleaseStruct struct { + config config.GitHubProvider + releaseVersion *shared.ReleaseVersion + generatedChangelog *shared.GeneratedChangelog + requestResponseBody string + requestResponseCode int + valid bool +} + +var testNewClient = []testHelperMethodStruct{ + testHelperMethodStruct{config: config.GitHubProvider{ + Repo: "foo", + User: "bar", + }, + valid: true, + }, + + testHelperMethodStruct{config: config.GitHubProvider{ + Repo: "foo", + User: "bar", + CustomURL: "https://test.com", + }, + valid: false, + }, +} + +var testHelperMethod = []testHelperMethodStruct{ + testHelperMethodStruct{config: config.GitHubProvider{ + Repo: "foo", + User: "bar", + }, + valid: true, + }, + + testHelperMethodStruct{config: config.GitHubProvider{ + Repo: "", + User: "bar", + }, + valid: false, + }, + + testHelperMethodStruct{config: config.GitHubProvider{ + Repo: "foo", + User: "", + }, + valid: false, + }, +} + +var lastVersion, _ = semver.NewVersion("1.0.0") +var newVersion, _ = semver.NewVersion("2.0.0") + +var testReleases = []testReleaseStruct{ + testReleaseStruct{ + config: config.GitHubProvider{ + Repo: "foo", + User: "bar", + }, + releaseVersion: &shared.ReleaseVersion{ + Last: shared.ReleaseVersionEntry{ + Version: lastVersion, + Commit: "foo", + }, + Next: shared.ReleaseVersionEntry{ + Version: newVersion, + Commit: "bar", + }, + Branch: "master", + Draft: false, + }, + generatedChangelog: &shared.GeneratedChangelog{ + Title: "title", + Content: "content", + }, + requestResponseBody: "{ \"url\": \"https://api.github.com/repos/octocat/Hello-World/releases/1\", \"html_url\": \"https://github.com/octocat/Hello-World/releases/v1.0.0\", \"assets_url\": \"https://api.github.com/repos/octocat/Hello-World/releases/1/assets\", \"upload_url\": \"https://uploads.github.com/repos/octocat/Hello-World/releases/1/assets{?name,label}\", \"tarball_url\": \"https://api.github.com/repos/octocat/Hello-World/tarball/v1.0.0\", \"zipball_url\": \"https://api.github.com/repos/octocat/Hello-World/zipball/v1.0.0\", \"id\": 1, \"node_id\": \"MDc6UmVsZWFzZTE=\", \"tag_name\": \"v1.0.0\", \"target_commitish\": \"master\", \"name\": \"v1.0.0\", \"body\": \"Description of the release\", \"draft\": false, \"prerelease\": false, \"created_at\": \"2013-02-27T19:35:32Z\", \"published_at\": \"2013-02-27T19:35:32Z\", \"author\": { \"login\": \"octocat\", \"id\": 1, \"node_id\": \"MDQ6VXNlcjE=\", \"avatar_url\": \"https://github.com/images/error/octocat_happy.gif\", \"gravatar_id\": \"\", \"url\": \"https://api.github.com/users/octocat\", \"html_url\": \"https://github.com/octocat\", \"followers_url\": \"https://api.github.com/users/octocat/followers\", \"following_url\": \"https://api.github.com/users/octocat/following{/other_user}\", \"gists_url\": \"https://api.github.com/users/octocat/gists{/gist_id}\", \"starred_url\": \"https://api.github.com/users/octocat/starred{/owner}{/repo}\", \"subscriptions_url\": \"https://api.github.com/users/octocat/subscriptions\", \"organizations_url\": \"https://api.github.com/users/octocat/orgs\", \"repos_url\": \"https://api.github.com/users/octocat/repos\", \"events_url\": \"https://api.github.com/users/octocat/events{/privacy}\", \"received_events_url\": \"https://api.github.com/users/octocat/received_events\", \"type\": \"User\", \"site_admin\": false }, \"assets\": [ ]}", + requestResponseCode: 200, + valid: true, + }, + testReleaseStruct{ + config: config.GitHubProvider{ + Repo: "foo", + User: "bar", + }, + releaseVersion: &shared.ReleaseVersion{ + Last: shared.ReleaseVersionEntry{ + Version: lastVersion, + Commit: "foo", + }, + Next: shared.ReleaseVersionEntry{ + Version: newVersion, + Commit: "bar", + }, + Branch: "master", + Draft: false, + }, + generatedChangelog: &shared.GeneratedChangelog{ + Title: "title", + Content: "content", + }, + requestResponseCode: 400, + valid: false, + }, +} + +func initHTTPServer(respCode int, body string) *httptest.Server { + + return httptest.NewServer(http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) { + + log.Infof("Got call from %s %s", req.Method, req.URL.String()) + + rw.WriteHeader(respCode) + rw.Header().Set("Content-Type", "application/json") + + if _, err := rw.Write([]byte(body)); err != nil { + log.Info(err) + } + + })) +} + +func TestNew(t *testing.T) { + for _, testOject := range testNewClient { + if testOject.valid { + os.Setenv("GITHUB_ACCESS_TOKEN", "XXX") + } + + _, err := github.New(&testOject.config) + assert.Equal(t, testOject.valid, err == nil) + + os.Unsetenv("GITHUB_ACCESS_TOKEN") + + } +} + +func TestGetCommitURL(t *testing.T) { + os.Setenv("GITHUB_ACCESS_TOKEN", "XX") + for _, testOject := range testNewClient { + client, _ := github.New(&testOject.config) + actualURL := client.GetCommitURL() + if testOject.config.CustomURL != "" { + expectedURL := fmt.Sprintf("%s/%s/%s/commit/{{hash}}", testOject.config.CustomURL, testOject.config.User, testOject.config.Repo) + assert.EqualValues(t, expectedURL, actualURL) + + } else { + expectedURL := fmt.Sprintf("%s/%s/%s/commit/{{hash}}", "https://github.com", testOject.config.User, testOject.config.Repo) + assert.EqualValues(t, expectedURL, actualURL) + } + } + os.Unsetenv("GITHUB_ACCESS_TOKEN") + +} + +func TestGetCompareURL(t *testing.T) { + os.Setenv("GITHUB_ACCESS_TOKEN", "XX") + for _, testOject := range testNewClient { + client, _ := github.New(&testOject.config) + actualURL := client.GetCompareURL("1", "2") + if testOject.config.CustomURL != "" { + expectedURL := fmt.Sprintf("%s/%s/%s/compare/%s...%s", testOject.config.CustomURL, testOject.config.User, testOject.config.Repo, "1", "2") + assert.EqualValues(t, expectedURL, actualURL) + + } else { + expectedURL := fmt.Sprintf("%s/%s/%s/compare/%s...%s", "https://github.com", testOject.config.User, testOject.config.Repo, "1", "2") + assert.EqualValues(t, expectedURL, actualURL) + } + } + os.Unsetenv("GITHUB_ACCESS_TOKEN") + +} + +func TestValidateConfig(t *testing.T) { + os.Setenv("GITHUB_ACCESS_TOKEN", "XX") + for _, testOject := range testHelperMethod { + client, _ := github.New(&testOject.config) + err := client.ValidateConfig() + + assert.Equal(t, testOject.valid, err == nil) + + } + os.Unsetenv("GITHUB_ACCESS_TOKEN") +} + +func TestCreateRelease(t *testing.T) { + os.Setenv("GITHUB_ACCESS_TOKEN", "XX") + + for _, testObejct := range testReleases { + if testObejct.valid { + server := initHTTPServer(testObejct.requestResponseCode, testObejct.requestResponseBody) + testObejct.config.CustomURL = server.URL + client, _ := github.New(&testObejct.config) + + err := client.CreateRelease(testObejct.releaseVersion, testObejct.generatedChangelog) + if err != nil { + t.Log(err) + } + assert.Equal(t, testObejct.valid, err == nil) + + server.Close() + + } else { + testObejct.config.CustomURL = "http://foo" + client, _ := github.New(&testObejct.config) + + err := client.CreateRelease(testObejct.releaseVersion, testObejct.generatedChangelog) + if err != nil { + t.Log(err) + } + assert.Error(t, err) + } + } + os.Unsetenv("GITHUB_ACCESS_TOKEN") + +} diff --git a/internal/releaser/gitlab/gitlab.go b/internal/releaser/gitlab/gitlab.go new file mode 100644 index 0000000..d966c11 --- /dev/null +++ b/internal/releaser/gitlab/gitlab.go @@ -0,0 +1,229 @@ +package gitlab + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "mime/multipart" + "net/http" + "os" + "strings" + "time" + + "github.com/Nightapes/go-semantic-release/internal/releaser/util" + "github.com/Nightapes/go-semantic-release/internal/shared" + "github.com/Nightapes/go-semantic-release/pkg/config" + + log "github.com/sirupsen/logrus" +) + +// GITLAB identifer for gitlab interface +const GITLAB = "gitlab" + +// Client type struct +type Client struct { + config *config.GitLabProvider + context context.Context + client *http.Client + baseURL string + apiURL string + token string + release string +} + +// New initialize a new gitlabRelease +func New(config *config.GitLabProvider) (*Client, error) { + accessToken, err := util.GetAccessToken(GITLAB) + if err != nil { + return nil, err + } + + if config.CustomURL == "" { + config.CustomURL = "https://gitlab.com" + } + + baseURL, err := util.CheckURL(config.CustomURL) + log.Debugf("Use gitlab url %s", baseURL) + if err != nil { + return nil, err + } + + ctx := context.Background() + tokenHeader := util.NewAddHeaderTransport(nil, "PRIVATE-TOKEN", accessToken) + acceptHeader := util.NewAddHeaderTransport(tokenHeader, "Accept", "application/json") + httpClient := &http.Client{ + Transport: acceptHeader, + Timeout: time.Second * 60, + } + + return &Client{ + token: accessToken, + config: config, + context: ctx, + baseURL: baseURL, + apiURL: baseURL + "api/v4", + client: httpClient, + }, nil +} + +//GetCommitURL for gitlab +func (g *Client) GetCommitURL() string { + return fmt.Sprintf("%s%s/commit/{{hash}}", g.baseURL, g.config.Repo) +} + +//GetCompareURL for gitlab +func (g *Client) GetCompareURL(oldVersion, newVersion string) string { + return fmt.Sprintf("%s%s/compare/%s...%s", g.baseURL, g.config.Repo, oldVersion, newVersion) +} + +//ValidateConfig for gitlab +func (g *Client) ValidateConfig() error { + log.Debugf("validate gitlab provider config") + + if g.config.Repo == "" { + return fmt.Errorf("gitlab Repro is not set") + } + + return nil + +} + +// CreateRelease creates release on remote +func (g *Client) CreateRelease(releaseVersion *shared.ReleaseVersion, generatedChangelog *shared.GeneratedChangelog) error { + + tag := releaseVersion.Next.Version.String() + g.release = tag + log.Debugf("create release with version %s", tag) + + bodyBytes, err := json.Marshal(Release{ + TagName: tag, + Name: generatedChangelog.Title, + Description: generatedChangelog.Content, + Ref: releaseVersion.Branch, + }) + if err != nil { + return err + } + bodyReader := bytes.NewReader(bodyBytes) + + url := fmt.Sprintf("%s/projects/%s/releases", g.apiURL, util.PathEscape(g.config.Repo)) + log.Debugf("Send release to %s", url) + + resp, err := g.client.Post(url, "application/json", bodyReader) + + if err != nil { + + if !strings.Contains(err.Error(), "already_exists") && resp.StatusCode >= http.StatusUnprocessableEntity { + return fmt.Errorf("could not create release: %v", err) + } + log.Infof("A release with tag %s already exits, will not perform a release or update", tag) + } else { + + defer resp.Body.Close() + + bodyBytes, err := ioutil.ReadAll(resp.Body) + if err != nil { + return err + } + log.Debugf("Release repsone: %+v", string(bodyBytes)) + + if err := util.IsValidResult(resp); err != nil { + return err + } + + log.Infof("Crated release") + } + + return nil +} + +// UploadAssets uploads specified assets +func (g *Client) UploadAssets(repoDir string, assets []config.Asset) error { + filesToUpload, err := util.PrepareAssets(repoDir, assets) + if err != nil { + return err + } + for _, f := range filesToUpload { + + file, err := os.Open(*f) + if err != nil { + return err + } + defer file.Close() + + fileInfo, _ := file.Stat() + + result, err := g.uploadFile(fileInfo.Name(), file) + if err != nil { + return fmt.Errorf("releaser: gitlab: Could not upload asset %s: %s", file.Name(), err.Error()) + } + + downloadURL := fmt.Sprintf("%s%s%s", g.baseURL, g.config.Repo, result.URL) + + log.Infof("Uploaded file %s to gitlab can be downloaded under %s", file.Name(), downloadURL) + + path := fmt.Sprintf("%s/projects/%s/releases/%s/assets/links?name=%s&url=%s", g.apiURL, util.PathEscape(g.config.Repo), g.release, util.PathEscape(fileInfo.Name()), downloadURL) + + req, err := http.NewRequest("POST", path, nil) + if err != nil { + return err + } + + log.Infof("Link file %s with release %s", file.Name(), g.release) + + resp, err := util.Do(g.client, req, nil) + if err != nil { + return err + } + + if err = util.IsValidResult(resp); err != nil { + return err + } + + log.Infof("Link file with release %s done", g.release) + } + return nil +} + +func (g *Client) uploadFile(fileName string, file *os.File) (*ProjectFile, error) { + + b := &bytes.Buffer{} + w := multipart.NewWriter(b) + + fw, err := w.CreateFormFile("file", fileName) + if err != nil { + return nil, err + } + + _, err = io.Copy(fw, file) + if err != nil { + return nil, err + } + w.Close() + + url := fmt.Sprintf("%s/projects/%s/uploads", g.apiURL, util.PathEscape(g.config.Repo)) + + req, err := http.NewRequest("POST", url, nil) + if err != nil { + return nil, err + } + + req.Body = ioutil.NopCloser(b) + req.ContentLength = int64(b.Len()) + req.Header.Set("Content-Type", w.FormDataContentType()) + + uf := &ProjectFile{} + resp, err := util.Do(g.client, req, uf) + if err != nil { + return nil, err + } + + if err = util.IsValidResult(resp); err != nil { + return nil, err + } + + return uf, nil +} diff --git a/internal/releaser/gitlab/types.go b/internal/releaser/gitlab/types.go new file mode 100644 index 0000000..c5c092b --- /dev/null +++ b/internal/releaser/gitlab/types.go @@ -0,0 +1,25 @@ +package gitlab + +// Release struct +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Ref string `json:"ref"` + Description string `json:"description,omitempty"` + Assets struct { + Links []*ReleaseLink `json:"links"` + } `json:"assets"` +} + +// ReleaseLink struct +type ReleaseLink struct { + Name string `json:"name"` + URL string `json:"url"` +} + +// ProjectFile struct +type ProjectFile struct { + Alt string `json:"alt"` + URL string `json:"url"` + Markdown string `json:"markdown"` +} diff --git a/internal/releaser/releaser.go b/internal/releaser/releaser.go index 5d82da3..b2a9863 100644 --- a/internal/releaser/releaser.go +++ b/internal/releaser/releaser.go @@ -4,6 +4,7 @@ import ( "fmt" "github.com/Nightapes/go-semantic-release/internal/releaser/github" + "github.com/Nightapes/go-semantic-release/internal/releaser/gitlab" "github.com/Nightapes/go-semantic-release/internal/shared" "github.com/Nightapes/go-semantic-release/pkg/config" @@ -37,6 +38,9 @@ func (r *Releasers) GetReleaser() (Releaser, error) { case github.GITHUB: log.Debugf("initialize new %s-provider", github.GITHUB) return github.New(&r.config.GitHubProvider) + case gitlab.GITLAB: + log.Debugf("initialize new %s-provider", gitlab.GITLAB) + return gitlab.New(&r.config.GitLabProvider) } return nil, fmt.Errorf("could not initialize a releaser from this type: %s", r.config.Release) } diff --git a/internal/releaser/util/util.go b/internal/releaser/util/util.go index 86745e1..61523fc 100644 --- a/internal/releaser/util/util.go +++ b/internal/releaser/util/util.go @@ -3,9 +3,11 @@ package util import ( "archive/zip" "context" + "encoding/json" "fmt" "io" "net/http" + "net/url" "os" "strings" @@ -25,6 +27,27 @@ func CreateBearerHTTPClient(ctx context.Context, token string) *http.Client { return client } +// AddHeaderTransport struct +type AddHeaderTransport struct { + T http.RoundTripper + key string + value string +} + +// RoundTrip add header +func (adt *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { + req.Header.Add(adt.key, adt.value) + return adt.T.RoundTrip(req) +} + +//NewAddHeaderTransport to add default header +func NewAddHeaderTransport(T http.RoundTripper, key, value string) *AddHeaderTransport { + if T == nil { + T = http.DefaultTransport + } + return &AddHeaderTransport{T, key, value} +} + // GetAccessToken lookup for the providers accesstoken func GetAccessToken(providerName string) (string, error) { var token string @@ -109,3 +132,60 @@ func zipFile(repository string, file string) (string, error) { return zipFileName, nil } + +// CheckURL if is valid +func CheckURL(urlStr string) (string, error) { + + if !strings.HasSuffix(urlStr, "/") { + urlStr += "/" + } + + _, err := url.Parse(urlStr) + if err != nil { + return "", err + } + + return urlStr, nil +} + +//PathEscape to be url save +func PathEscape(s string) string { + return strings.Replace(url.PathEscape(s), ".", "%2E", -1) +} + +// Do request for client +func Do(client *http.Client, req *http.Request, v interface{}) (*http.Response, error) { + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + switch resp.StatusCode { + case 200, 201, 202, 204, 304: + if v != nil { + if w, ok := v.(io.Writer); ok { + _, err = io.Copy(w, resp.Body) + } else { + err = json.NewDecoder(resp.Body).Decode(v) + } + } + } + + return resp, err +} + +// IsValidResult validates response code +func IsValidResult(resp *http.Response) error { + switch resp.StatusCode { + case 200, 201, 202, 204, 304: + return nil + default: + return fmt.Errorf("%s %s: %d", resp.Request.Method, resp.Request.URL, resp.StatusCode) + } +} + +// ShouldRetry request +func ShouldRetry(resp *http.Response) bool { + return resp.StatusCode == http.StatusTooManyRequests +} diff --git a/pkg/config/config.go b/pkg/config/config.go index 7c08d77..31936d0 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -29,6 +29,13 @@ type GitHubProvider struct { AccessToken string } +// GitLabProvider struct +type GitLabProvider struct { + Repo string `yaml:"repo"` + CustomURL string `yaml:"customUrl,omitempty"` + AccessToken string +} + // ReleaseConfig struct type ReleaseConfig struct { CommitFormat string `yaml:"commitFormat"` @@ -36,6 +43,7 @@ type ReleaseConfig struct { Changelog ChangelogConfig `yaml:"changelog,omitempty"` Release string `yaml:"release,omitempty"` GitHubProvider GitHubProvider `yaml:"github,omitempty"` + GitLabProvider GitLabProvider `yaml:"gitlab,omitempty"` Assets []Asset `yaml:"assets"` ReleaseTitle string `yaml:"title"` IsPreRelease, IsDraft bool diff --git a/pkg/semanticrelease/semantic-release.go b/pkg/semanticrelease/semantic-release.go index 4d90658..cd3e2b0 100644 --- a/pkg/semanticrelease/semantic-release.go +++ b/pkg/semanticrelease/semantic-release.go @@ -193,8 +193,8 @@ func (s *SemanticRelease) Release(provider *ci.ProviderConfig, force bool) error return err } - if releaseVersion.Next.Version.Equal(releaseVersion.Next.Version) { - log.Infof("No new version, no release needed") + if releaseVersion.Next.Version.Equal(releaseVersion.Last.Version) { + log.Infof("No new version, no release needed %s <> %s", releaseVersion.Next.Version.String(), releaseVersion.Last.Version.String()) return nil }