diff --git a/.dockerignore b/.dockerignore index e03a455..5cca5ba 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,7 +1,11 @@ +.github/ .vscode/ .idea/ .gitignore .dockerignore README.md CHANGELOG.md -docs/ \ No newline at end of file +docs/ +coverage.txt +Dockerfile +Makefile \ No newline at end of file diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 0000000..eefad18 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,20 @@ +# Contributing Guidelines +:confetti_ball::medal_military: First of all, thank you for contributing! :medal_military::confetti_ball: + +## Issue: +- Search for already opened [issues](https://github.com/anton-yurchenko/git-release/issues) before submitting a [new one](https://github.com/anton-yurchenko/git-release/issues/new/choose). +- Provide as much information as you can, even if not requested by an Issue Template. + + +## Pull Request: +- Open [Pull Request](https://github.com/anton-yurchenko/git-release/pulls) towards **dev** branch. +- Ensure [Pull Request](https://github.com/anton-yurchenko/git-release/pulls) description clearly describes the problem and solution. +- Make sure all Tests are passed and there is no Code Coverage degradation. +- Please follow [AngularJS Commit Message Guidelines](https://github.com/angular/angular/blob/master/CONTRIBUTING.md#-commit-message-guidelines) + +## Other: +#### Require new feature or change of an existing one? +- Suggest your change via [email](email:anton.doar+git-release@gmail.com) or [Pull Request](https://github.com/anton-yurchenko/git-release/pulls), do not open an issue on GitHub. + +#### Have questions about the source code? +- Feel free to contact me via [email](email:anton.doar+git-release@gmail.com), I'll be glad to provide any information required. \ No newline at end of file diff --git a/.gitignore b/.gitignore index f9d0e8c..6336893 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,12 @@ +# OS +**/.DS_Store + +# IDE .vscode/ +**/__debug_bin .idea/ -**/.DS_Store -vendor/ \ No newline at end of file + +# project +coverage.txt +/vendor/ +/release/ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index e4ff9d2..6de344a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,9 +1,29 @@ +## [2.0.0] - 2019-12-28 +This is a major release as most of the code was refactored and some behavior was changed, for example "Tag version is set as a release title". + +### Fixed +- Artifact files not found caused panic - all files now being validated before release creation +- Custom changelog file now being validated before release creation +- Arguments parsing fixed + +### Added +- Unit testing +- Docker image now built from scratch, resulting in decreased size 139.73MB -> 2.43MB, improving action overall speed. +- **app** package +- `ALLOW_EMPTY_CHANGELOG` env.var to allow publishing a release without changelog (default **false**) +- Artifacts (provided as arguments) can now be separated by one of: `new line '\n', pipe '|', space ' ', comma ','` + +### Changed +- **local** package renamed to **repository** +- **remote** package splitted into 2 packages: **asset**, **release** +- Tag version is set as a release title + ## [1.1.0] - 2019-12-21 ### Added - [PR #3](https://github.com/anton-yurchenko/git-release/pull/3) Allow any prefix to semver tag. (*Thanks to [Taylor Becker](https://github.com/tajobe) for the PR*) ### Fixed -- PreRelease overwriting Draft configuration. (*Thanks to [Taylor Becker](https://github.com/tajobe) for the reporting an issue*) +- PreRelease overwriting Draft configuration. (*Thanks to [Taylor Becker](https://github.com/tajobe) for reporting an issue*) ## [1.0.0] - 2019-10-01 - First stable release. diff --git a/Dockerfile b/Dockerfile index 9e24971..d804bbc 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,13 +1,25 @@ -FROM golang:1.13.1-alpine +ARG BUILDER=golang:1.13.1-alpine +ARG TESTER=golangci/golangci-lint:latest -LABEL "repository"="https://github.com/anton-yurchenko/git-release" -LABEL "maintainer"="Anton Yurchenko " -LABEL "version"="1.1.0" +FROM ${TESTER} as test +WORKDIR /opt/src +COPY . . +RUN golangci-lint run ./... +RUN go test ./... -race -coverprofile=coverage.txt -covermode=atomic +FROM ${BUILDER} as build WORKDIR /opt/src COPY . . +RUN addgroup -g 1000 appuser &&\ + adduser -D -u 1000 -G appuser appuser +RUN apk add --update --no-cache alpine-sdk +RUN CGO_ENABLED=0 go build -ldflags="-w -s" -o /opt/app -RUN go install &&\ - go build -o /opt/release &&\ - rm -rf /opt/src -ENTRYPOINT [ "/opt/release" ] \ No newline at end of file +FROM scratch +LABEL "repository"="https://github.com/anton-yurchenko/git-release" +LABEL "maintainer"="Anton Yurchenko " +LABEL "version"="2.0.0" +COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/ +COPY --from=build /etc/passwd /etc/passwd +COPY --from=build --chown=1000:0 /opt/app /app +ENTRYPOINT [ "/app" ] \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..29a559c --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +# global +BINARY := $(notdir $(CURDIR)) +GO_BIN_DIR := $(GOPATH)/bin +PKGS := $(go list ./... | grep -v /vendor) + +# unit tests +test: lint + @echo "unit testing..." + @go test ./... -race -coverprofile=coverage.txt -covermode=atomic + +# lint +.PHONY: lint +lint: $(GO_LINTER) + @echo "vendoring..." + @go mod vendor + @go mod tidy + @echo "linting..." + @golangci-lint run ./... + +# initialize +.PHONY: init +init: + @rm -f go.mod + @rm -f go.sum + @rm -rf ./vendor + @go mod init + +# linter +GO_LINTER := $(GO_BIN_DIR)/golangci-lint +$(GO_LINTER): + @echo "installing linter..." + go get -u github.com/golangci/golangci-lint/cmd/golangci-lint + +.PHONY: release +release: test + @rm -rf ./release + @mkdir -p release + @GOOS=linux GOARCH=amd64 go build -o ./release/app + +.PHONY: codecov +codecov: test + @go tool cover -html=coverage.txt \ No newline at end of file diff --git a/README.md b/README.md index 1a6ca78..157e06e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@ # git-release -[![License](https://img.shields.io/github/license/anton-yurchenko/git-release?style=flat-square)](LICENSE.md) [![Release](https://img.shields.io/github/v/release/anton-yurchenko/git-release?style=flat-square)](https://github.com/anton-yurchenko/git-release/releases/latest) [![Docker Build](https://img.shields.io/docker/cloud/build/antonyurchenko/git-release?style=flat-square)](https://hub.docker.com/r/antonyurchenko/git-release) [![Docker Pulls](https://img.shields.io/docker/pulls/antonyurchenko/git-release?style=flat-square)](https://hub.docker.com/r/antonyurchenko/git-release) +[![Release](https://img.shields.io/github/v/release/anton-yurchenko/git-release)](https://github.com/anton-yurchenko/git-release/releases/latest) [![codecov](https://codecov.io/gh/anton-yurchenko/git-release/branch/master/graph/badge.svg)](https://codecov.io/gh/anton-yurchenko/git-release) [![Go Report Card](https://goreportcard.com/badge/github.com/anton-yurchenko/git-release)](https://goreportcard.com/report/github.com/anton-yurchenko/git-release) [![Docker Build](https://img.shields.io/docker/cloud/build/antonyurchenko/git-release)](https://hub.docker.com/r/antonyurchenko/git-release) [![Docker Pulls](https://img.shields.io/docker/pulls/antonyurchenko/git-release)](https://hub.docker.com/r/antonyurchenko/git-release) [![License](https://img.shields.io/github/license/anton-yurchenko/git-release)](LICENSE.md) A GitHub Action for creating a GitHub Release with Assets and Changelog whenever a Version Tag is pushed to the project. @@ -13,37 +13,42 @@ A GitHub Action for creating a GitHub Release with Assets and Changelog whenever ## Manual: 1. Add your changes to **CHANGELOG.md** in the following format (according to [keepachangelog.com](https://keepachangelog.com/en/1.0.0/ "Keep a ChangeLog")): ``` -## [2.1.5] - 2019-10-01 +## [2.0.0] - 2019-12-21 ### Added -- Feature 1. -- Feature 2. +- Feature A +- Feature B +- GitHub Actions as a CI system +- GitHub Release as an Artifactory system ### Changed -- Logger timestamp. +- User API ### Removed -- Old library. -- Configuration file. +- Previous CI build +- Previous Artifactory ``` 2. Tag a commit with Version (according to [semver.org](https://semver.org/ "Semantic Versioning")). - - Extensions like **alpha/beta/rc/...** are not supported. + - Prefixes like **release-/v/...** are supported. + - **Postfixes are not supported**. 3. Push and watch **Git-Release** publishing a Release on GitHub ;-) ![PIC](docs/images/log.png) ## Configuration: 1. Change the workflow to be triggered on Tag Push: - - Use either `'*'` or `'v*` + - Use either `'*'` or a more specific like `'v*'` or `'release-*'` ``` on: push: tags: - 'v*' ``` -2. Add Release stage to your workflow: - - **Optional**: Provide a list of assets as **args** - - **Optional**: `DRAFT_RELEASE: "true"/"false"` - Save release as draft instead of publishing it (default `false`). - - **Optional**: `PRE_RELEASE: "true"/"false"` - GitHub will point out that this release is identified as non-production ready (default `false`). - - **Optional**: `CHANGELOG_FILE: "changes.md"` - Changelog filename (default `CHANGELOG.md`). +2. Add Release stage to your workflow: + - Customize configuration with env.vars: + - Provide a list of assets as **args** + - `DRAFT_RELEASE: "true"` - Save release as draft instead of publishing it (default `false`). + - `PRE_RELEASE: "true"` - GitHub will point out that this release is identified as non-production ready (default `false`). + - `CHANGELOG_FILE: "changes.md"` - Changelog filename (default `CHANGELOG.md`). + - `ALLOW_EMPTY_CHANGELOG: "true"` - Allow publishing a release without changelog (default `false`). ``` - name: Release uses: docker://antonyurchenko/git-release:latest @@ -54,13 +59,13 @@ on: CHANGELOG_FILE: "CHANGELOG.md" with: args: | - build/release/artifact-darwin-amd64.zip - build/release/artifact-linux-amd64.zip - build/release/artifact-windows-amd64.zip + build/release/darwin-amd64.zip + build/release/linux-amd64.zip + build/release/windows-amd64.zip ``` ## Remarks: -- This action is automatically built at Docker Hub, and tagged with `latest / v1 / v1.0 / v1.0.0`. You may lock to a certain version instead of using **latest**. (*Recommended to lock against a major version, for example* `v1`). +- This action is automatically built at Docker Hub, and tagged with `latest / v2 / v2.0 / v2.0.0`. You may lock to a certain version instead of using **latest**. (*Recommended to lock against a major version, for example* `v2`). - Instead of using pre-built image, you may build it during the execution of your flow by changing `docker://antonyurchenko/git-release:latest` to `anton-yurchenko/git-release@master` ## License diff --git a/docs/images/log.png b/docs/images/log.png index eca5d4e..5f8cd76 100644 Binary files a/docs/images/log.png and b/docs/images/log.png differ diff --git a/docs/images/release.png b/docs/images/release.png index 487f68f..66f5648 100644 Binary files a/docs/images/release.png and b/docs/images/release.png differ diff --git a/go.mod b/go.mod index 9e59545..efea068 100644 --- a/go.mod +++ b/go.mod @@ -1,10 +1,13 @@ -module git-release +module github.com/anton-yurchenko/git-release -go 1.12 +go 1.13 require ( github.com/google/go-github v17.0.0+incompatible github.com/google/go-querystring v1.0.0 // indirect + github.com/pkg/errors v0.8.1 github.com/sirupsen/logrus v1.4.2 - golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 + github.com/spf13/afero v1.2.2 + github.com/stretchr/testify v1.4.0 + golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6 ) diff --git a/go.sum b/go.sum index 1fc226e..37a8bbd 100644 --- a/go.sum +++ b/go.sum @@ -1,4 +1,5 @@ cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= +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/golang/protobuf v1.2.0 h1:P3YflyNX/ehuJFLhxviNdFxQPkGK5cDcApsge1SqnvM= @@ -9,22 +10,34 @@ github.com/google/go-querystring v1.0.0 h1:Xkwi/a1rcvNg1PPYe5vI8GbeBY/jrVuDX5ASu github.com/google/go-querystring v1.0.0/go.mod h1:odCYkC5MyYFN7vkCjXpyrEuKhc/BUO6wN/zVPAxq5ck= github.com/konsorten/go-windows-terminal-sequences v1.0.1 h1:mweAR1A6xJ3oS2pRaGiHgQ4OO8tzTaLawm8vnODuwDk= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= +github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= +github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/sirupsen/logrus v1.4.2 h1:SPIRibHv4MatM3XXNO2BJeFLZwZ2LvZgfQ5+UNI2im4= github.com/sirupsen/logrus v1.4.2/go.mod h1:tLMulIdttU9McNUspp0xgXVQah82FyeX6MwdIuYE2rE= +github.com/spf13/afero v1.2.2 h1:5jhuqJyZCZf2JRofRvN/nIFgIWNzPa3/Vz8mYylgbWc= +github.com/spf13/afero v1.2.2/go.mod h1:9ZxEEn6pIJ8Rxe320qSDBk6AsU0r9pR7Q4OcevTdifk= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e h1:bRhVy7zSSasaqNksaRZiA5EEI+Ei4I1nO5Jh72wfHlg= golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45 h1:SVwTIAaPC2U/AvvLNZ2a7OVsmBpC8L5BlwK1whH3hm0= -golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6 h1:pE8b58s1HRDMi8RDc79m0HISf9D4TzseP40cEA6IGfs= +golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4 h1:YUO/7uOKsKeq9UokNS62b8FYywz3ker1l1vDZRCRefw= golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sys v0.0.0-20190422165155-953cdadca894 h1:Cz4ceDQGXuKRnVBDTS23GTn/pU5OE2C0WrNTOYK1Uuc= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= google.golang.org/appengine v1.4.0 h1:/wp5JvzpHIxhs/dumFmF7BXTf3Z+dd4uXta4kVyO508= google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= diff --git a/internal/app/exported.go b/internal/app/exported.go new file mode 100644 index 0000000..b3f1d4b --- /dev/null +++ b/internal/app/exported.go @@ -0,0 +1,160 @@ +package app + +import ( + "context" + "fmt" + "os" + "strings" + + "github.com/anton-yurchenko/git-release/internal/pkg/interfaces" + "github.com/anton-yurchenko/git-release/internal/pkg/release" + "github.com/anton-yurchenko/git-release/internal/pkg/repository" + "github.com/anton-yurchenko/git-release/pkg/changelog" + "github.com/google/go-github/github" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" + "golang.org/x/oauth2" +) + +// Configuration is a git-release settings struct +type Configuration struct { + AllowEmptyChangelog bool +} + +// GetConfig sets validated Release/Changelog configuration and returns github.com Token +func GetConfig(release release.Interface, changes changelog.Interface, fs afero.Fs, args []string) (*Configuration, string, error) { + conf := new(Configuration) + + token := os.Getenv("GITHUB_TOKEN") + if token == "" { + return conf, "", errors.New("env.var 'GITHUB_TOKEN' not defined") + } + + d := os.Getenv("DRAFT_RELEASE") + if strings.ToLower(d) == "true" { + release.EnableDraft() + } else if strings.ToLower(d) != "false" { + log.Warn("env.var 'DRAFT_RELEASE' is not equal to 'true', assuming 'false'") + } + + p := os.Getenv("PRE_RELEASE") + if strings.ToLower(p) == "true" { + release.EnablePreRelease() + } else if strings.ToLower(p) != "false" { + log.Warn("env.var 'PRE_RELEASE' is not equal to 'true', assuming 'false'") + } + + dir := os.Getenv("GITHUB_WORKSPACE") + if dir == "" { + log.Fatal("env.var 'GITHUB_WORKSPACE' not defined") + } + + temp := os.Getenv("ALLOW_EMPTY_CHANGELOG") + if strings.ToLower(temp) == "true" { + log.Warn("'ALLOW_EMPTY_CHANGELOG' enabled") + conf.AllowEmptyChangelog = true + } + + c := os.Getenv("CHANGELOG_FILE") + if c == "" { + log.Warn("env.var 'CHANGELOG_FILE' not defined, assuming 'CHANGELOG.md'") + c = "CHANGELOG.md" + } + + changes.SetFile(fmt.Sprintf("%v/%v", dir, c)) + b, err := IsExists(changes.GetFile(), fs) + if err != nil { + log.Fatal(err) + } + + if !b { + log.Fatalf("changelog '%v' not found!", changes.GetFile()) + } + + release.SetAssets(GetAssets(dir, fs, args)) + + return conf, token, nil +} + +// Login to github.com and return authenticated client +func Login(token string) *github.Client { + ts := oauth2.StaticTokenSource( + &oauth2.Token{AccessToken: token}, + ) + tc := oauth2.NewClient(context.Background(), ts) + + return github.NewClient(tc) +} + +// Hydrate fetches project's git repository information +func Hydrate(local repository.Interface, version *string) error { + if err := local.ReadProjectName(); err != nil { + return err + } + + if err := local.ReadCommitHash(); err != nil { + return err + } + + if err := local.ReadTag(version); err != nil { + return err + } + + return nil +} + +// GetReleaseBody populates 'changes.Body' property +// Body will be empty in case version did not match any of the changelog versions. +func (c *Configuration) GetReleaseBody(changes changelog.Interface, fs afero.Fs) error { + if err := changes.ReadChanges(fs); err != nil { + return err + } + + if changes.GetBody() == "" { + if c.AllowEmptyChangelog { + log.Warn("changelog does not contain changes for requested project version") + } else { + return errors.New("changelog does not contain changes for requested project version") + } + } + + return nil +} + +// Publish Release on github.com +func (c *Configuration) Publish(repo repository.Interface, release release.Interface, service interfaces.GitHub) error { + log.Infof("creating release: '%v'", *repo.GetTag()) + + errors := make(chan error, len(release.GetAssets())) + messages := make(chan string, len(release.GetAssets())) + + err := release.Publish(repo, service, messages, errors) + if err != nil { + log.Fatal(err) + } + + for i := 0; i <= (len(release.GetAssets()) - 1); i++ { + msg := <-messages + + if msg != "" { + log.Info(msg) + } + } + + var failure bool + for i := 0; i <= (len(release.GetAssets()) - 1); i++ { + err = <-errors + + if err != nil { + failure = true + log.Error(err) + } + } + + if failure { + log.Fatal("error uploading assets (release partly published)") + } + + return nil +} diff --git a/internal/app/exported_test.go b/internal/app/exported_test.go new file mode 100644 index 0000000..57a142c --- /dev/null +++ b/internal/app/exported_test.go @@ -0,0 +1,177 @@ +package app_test + +import ( + "os" + "testing" + + "github.com/anton-yurchenko/git-release/internal/app" + "github.com/anton-yurchenko/git-release/internal/pkg/asset" + "github.com/anton-yurchenko/git-release/internal/pkg/release" + "github.com/anton-yurchenko/git-release/internal/pkg/repository" + "github.com/anton-yurchenko/git-release/mocks" + "github.com/anton-yurchenko/git-release/pkg/changelog" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" +) + +func TestGetConfig(t *testing.T) { + assert := assert.New(t) + log.SetLevel(log.FatalLevel) + fs := afero.NewMemMapFs() + rel := new(release.Release) + rel.Changes = new(changelog.Changes) + + err := os.Setenv("GITHUB_WORKSPACE", ".") + assert.Equal(nil, err, "preparation: error setting env.var 'GITHUB_WORKSPACE'") + file, err := fs.Create("CHANGELOG.md") + file.Close() + assert.Equal(nil, err, "preparation: error creating test file 'CHANGELOG.md'") + + // TEST: missing GITHUB_TOKEN + _, _, err = app.GetConfig(rel, rel.Changes, fs, []string{}) + + assert.EqualError(err, "env.var 'GITHUB_TOKEN' not defined") + + // TEST: token + err = os.Setenv("GITHUB_TOKEN", "value") + assert.Equal(nil, err, "preparation: error setting env.var 'GITHUB_TOKEN'") + + expectedToken := "value" + + _, token, err := app.GetConfig(rel, rel.Changes, fs, []string{}) + + assert.Equal(expectedToken, token) + assert.Equal(nil, err) + + // TEST: Configuration: AllowEmptyChangelog + err = os.Setenv("ALLOW_EMPTY_CHANGELOG", "true") + assert.Equal(nil, err, "preparation: error setting env.var 'ALLOW_EMPTY_CHANGELOG'") + + rel = new(release.Release) + rel.Changes = new(changelog.Changes) + expectedConfig := &app.Configuration{ + AllowEmptyChangelog: true, + } + expectedToken = "value" + + config, token, err := app.GetConfig(rel, rel.Changes, fs, []string{}) + + assert.Equal(expectedConfig, config) + assert.Equal(expectedToken, token) + assert.Equal(nil, err) + + // TEST: Draft setting + err = os.Setenv("DRAFT_RELEASE", "true") + assert.Equal(nil, err, "preparation: error setting env.var 'DRAFT_RELEASE'") + + rel = new(release.Release) + rel.Changes = new(changelog.Changes) + expectedConfig = &app.Configuration{ + AllowEmptyChangelog: true, + } + expectedToken = "value" + expectedRelease := &release.Release{ + Draft: true, + Changes: &changelog.Changes{ + File: "./CHANGELOG.md", + }, + Assets: []asset.Asset{}, + } + + config, token, err = app.GetConfig(rel, rel.Changes, fs, []string{}) + + assert.Equal(expectedConfig, config) + assert.Equal(expectedToken, token) + assert.Equal(nil, err) + assert.Equal(expectedRelease, rel) + + // TEST: PreRelease setting + err = os.Setenv("PRE_RELEASE", "true") + assert.Equal(nil, err, "preparation: error setting env.var 'PRE_RELEASE'") + + rel = new(release.Release) + rel.Changes = new(changelog.Changes) + expectedConfig = &app.Configuration{ + AllowEmptyChangelog: true, + } + expectedToken = "value" + expectedRelease = &release.Release{ + Draft: true, + PreRelease: true, + Changes: &changelog.Changes{ + File: "./CHANGELOG.md", + }, + Assets: []asset.Asset{}, + } + + config, token, err = app.GetConfig(rel, rel.Changes, fs, []string{}) + + assert.Equal(expectedConfig, config) + assert.Equal(expectedToken, token) + assert.Equal(nil, err) + assert.Equal(expectedRelease, rel) +} + +func TestHydrate(t *testing.T) { + assert := assert.New(t) + + m := new(mocks.Repository) + v := "1.0.0" + + m.On("ReadProjectName").Return(nil).Once() + m.On("ReadCommitHash").Return(nil).Once() + m.On("ReadTag", &v).Return(nil).Once() + + err := app.Hydrate(m, &v) + + assert.Equal(nil, err) +} + +func TestGetReleaseBody(t *testing.T) { + assert := assert.New(t) + + // TEST: valid content + m := new(mocks.Changelog) + fs := afero.NewMemMapFs() + conf := new(app.Configuration) + + m.On("ReadChanges", fs).Return(nil).Once() + m.On("GetBody").Return("content").Once() + + err := conf.GetReleaseBody(m, fs) + + assert.Equal(nil, err) + + // TEST: empty content and AllowEmptyChangelog is enabled + m = new(mocks.Changelog) + fs = afero.NewMemMapFs() + conf = &app.Configuration{ + AllowEmptyChangelog: false, + } + + m.On("ReadChanges", fs).Return(nil).Once() + m.On("GetBody").Return("").Once() + + err = conf.GetReleaseBody(m, fs) + + assert.EqualError(err, "changelog does not contain changes for requested project version") +} + +func TestPublish(t *testing.T) { + assert := assert.New(t) + log.SetLevel(log.FatalLevel) + + // TEST: no exec errors + m := new(mocks.Release) + svc := new(mocks.GitHub) + repo := new(repository.Repository) + conf := app.Configuration{} + + m.On("Publish").Return(nil).Once() + m.On("GetAssets").Return(nil) + + err := conf.Publish(repo, m, svc) + + assert.Equal(nil, err) +} diff --git a/internal/app/private.go b/internal/app/private.go new file mode 100644 index 0000000..3646f4a --- /dev/null +++ b/internal/app/private.go @@ -0,0 +1,58 @@ +package app + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/anton-yurchenko/git-release/internal/pkg/asset" + log "github.com/sirupsen/logrus" + "github.com/spf13/afero" +) + +// GetAssets returns validated assets supplied via 'args' +func GetAssets(dir string, fs afero.Fs, args []string) []asset.Asset { + assets := make([]asset.Asset, 0) + arguments := make([]string, 0) + + for _, arg := range args { + // split arguments by space, new line, comma, pipe + if len(strings.Split(arg, " ")) > 1 { + arguments = append(arguments, strings.Split(arg, " ")...) + } else if len(strings.Split(arg, "\n")) > 1 { + arguments = append(arguments, strings.Split(arg, "\n")...) + } else if len(strings.Split(arg, ",")) > 1 { + arguments = append(arguments, strings.Split(arg, ",")...) + } else if len(strings.Split(arg, "|")) > 1 { + arguments = append(arguments, strings.Split(arg, "|")...) + } else { + arguments = append(arguments, arg) + } + } + + for _, argument := range arguments { + file := fmt.Sprintf("%v/%v", dir, filepath.Clean(argument)) + + b, err := IsExists(file, fs) + if err != nil { + log.Fatal(err) + } + + if b { + asset := asset.Asset{ + Name: filepath.Base(argument), + Path: file, + } + assets = append(assets, asset) + } else { + log.Fatalf("file '%v' not found!", file) + } + } + + return assets +} + +// IsExists validates whether a file exists and returns the result as a bool +func IsExists(file string, fs afero.Fs) (bool, error) { + return afero.Exists(fs, file) +} diff --git a/internal/app/private_test.go b/internal/app/private_test.go new file mode 100644 index 0000000..d049832 --- /dev/null +++ b/internal/app/private_test.go @@ -0,0 +1,225 @@ +package app_test + +import ( + "fmt" + "testing" + + "github.com/anton-yurchenko/git-release/internal/app" + "github.com/anton-yurchenko/git-release/internal/pkg/asset" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" +) + +func TestIsExists(t *testing.T) { + assert := assert.New(t) + fs := afero.NewMemMapFs() + + // TEST: file not exist + expected := false + result, err := app.IsExists("./not-exist.zip", fs) + + assert.Equal(nil, err) + assert.Equal(expected, result) + + // TEST: file exist + expected = true + + file, err := fs.Create("./file1") + file.Close() + assert.Equal(nil, err, "preparation: error creating test file 'file1'") + + result, err = app.IsExists("./file1", fs) + + assert.Equal(nil, err) + assert.Equal(expected, result) +} + +func TestGetAssets(t *testing.T) { + assert := assert.New(t) + fs := afero.NewMemMapFs() + + baseDirs := []string{ + ".", + "workspace", + } + + for _, baseDir := range baseDirs { + for i := 0; i <= 6; i++ { + _, err := fs.Create(fmt.Sprintf("%v/file%v", baseDir, i)) + assert.Equal(nil, err, fmt.Sprintf("preparation: error creating test file '%v/file%v'", baseDir, i)) + } + } + + // TEST: arguments separated by space + dir := "." + + args := []string{ + "file1", + "./file2 file3", + } + + expected := []asset.Asset{ + asset.Asset{ + Name: "file1", + Path: "./file1", + }, + asset.Asset{ + Name: "file2", + Path: "./file2", + }, + asset.Asset{ + Name: "file3", + Path: "./file3", + }, + } + + results := app.GetAssets(dir, fs, args) + + assert.Equal(expected, results) + + // TEST: arguments separated by new line + dir = "." + + args = []string{ + `file1 +file2 +file3 +./file4`, + `file5 +file6`, + } + + expected = []asset.Asset{ + asset.Asset{ + Name: "file1", + Path: "./file1", + }, + asset.Asset{ + Name: "file2", + Path: "./file2", + }, + asset.Asset{ + Name: "file3", + Path: "./file3", + }, + asset.Asset{ + Name: "file4", + Path: "./file4", + }, + asset.Asset{ + Name: "file5", + Path: "./file5", + }, + asset.Asset{ + Name: "file6", + Path: "./file6", + }, + } + + results = app.GetAssets(dir, fs, args) + + assert.Equal(expected, results) + + // TEST: arguments separated by comma + dir = "." + + args = []string{ + "file1,file2", + "file3,./file4,file5", + "./file6", + } + + expected = []asset.Asset{ + asset.Asset{ + Name: "file1", + Path: "./file1", + }, + asset.Asset{ + Name: "file2", + Path: "./file2", + }, + asset.Asset{ + Name: "file3", + Path: "./file3", + }, + asset.Asset{ + Name: "file4", + Path: "./file4", + }, + asset.Asset{ + Name: "file5", + Path: "./file5", + }, + asset.Asset{ + Name: "file6", + Path: "./file6", + }, + } + + results = app.GetAssets(dir, fs, args) + + assert.Equal(expected, results) + + // TEST: arguments separated by pipe + dir = "." + + args = []string{ + "file1|file2", + "file3|./file4|file5", + "./file6", + } + + expected = []asset.Asset{ + asset.Asset{ + Name: "file1", + Path: "./file1", + }, + asset.Asset{ + Name: "file2", + Path: "./file2", + }, + asset.Asset{ + Name: "file3", + Path: "./file3", + }, + asset.Asset{ + Name: "file4", + Path: "./file4", + }, + asset.Asset{ + Name: "file5", + Path: "./file5", + }, + asset.Asset{ + Name: "file6", + Path: "./file6", + }, + } + + results = app.GetAssets(dir, fs, args) + + assert.Equal(expected, results) + + // TEST: arguments separated by space not in current directory + dir = "workspace" + + args = []string{ + "file1", + "./file2", + } + + expected = []asset.Asset{ + asset.Asset{ + Name: "file1", + Path: "workspace/file1", + }, + asset.Asset{ + Name: "file2", + Path: "workspace/file2", + }, + } + + results = app.GetAssets(dir, fs, args) + + assert.Equal(expected, results) +} diff --git a/internal/pkg/asset/asset.go b/internal/pkg/asset/asset.go new file mode 100644 index 0000000..0a8ffc4 --- /dev/null +++ b/internal/pkg/asset/asset.go @@ -0,0 +1,65 @@ +package asset + +import ( + "context" + "fmt" + "os" + "strings" + "sync" + + "github.com/anton-yurchenko/git-release/internal/pkg/interfaces" + "github.com/anton-yurchenko/git-release/internal/pkg/repository" + "github.com/google/go-github/github" +) + +// Asset represents a single release artifact +type Asset struct { + Name string + Path string +} + +// Interface of an 'Asset' +type Interface interface { + Upload(int64, repository.Interface, interfaces.GitHub, *sync.WaitGroup, chan string, chan error) + SetName(string) + SetPath(string) +} + +// Upload an asset to pre-created github.com Release +func (a *Asset) Upload(id int64, repo repository.Interface, service interfaces.GitHub, wg *sync.WaitGroup, messages chan string, results chan error) { + defer wg.Done() + messages <- fmt.Sprintf("uploading asset: '%v'", a.Name) + + content, err := os.Open(a.Path) + if err != nil { + results <- err + return + } + + _, _, err = service.UploadReleaseAsset( + context.Background(), + repo.GetOwner(), + repo.GetProject(), + id, + &github.UploadOptions{ + Name: strings.ReplaceAll(a.Name, "/", "-"), + }, + content, + ) + if err != nil { + results <- err + return + } + + results <- nil +} + +// SetName sets provided string as an asset Name +func (a *Asset) SetName(name string) { + a.Name = name +} + +// SetPath sets provided string as an asset Path +func (a *Asset) SetPath(path string) { + a.Path = path +} diff --git a/internal/pkg/asset/asset_test.go b/internal/pkg/asset/asset_test.go new file mode 100644 index 0000000..fbb6cfb --- /dev/null +++ b/internal/pkg/asset/asset_test.go @@ -0,0 +1,89 @@ +package asset_test + +import ( + "fmt" + "testing" + + "sync" + + "github.com/anton-yurchenko/git-release/internal/pkg/asset" + "github.com/anton-yurchenko/git-release/internal/pkg/repository" + "github.com/anton-yurchenko/git-release/mocks" + "github.com/google/go-github/github" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestSetName(t *testing.T) { + assert := assert.New(t) + + m := new(asset.Asset) + expected := "value" + m.SetName(expected) + + assert.Equal(expected, m.Name) +} + +func TestSetPath(t *testing.T) { + assert := assert.New(t) + + m := new(asset.Asset) + expected := "value" + m.SetPath(expected) + + assert.Equal(expected, m.Path) +} + +func TestUpload(t *testing.T) { + assert := assert.New(t) + log.SetLevel(log.FatalLevel) + + var id int64 + + // TEST: successful upload + m := asset.Asset{ + Name: "file1", + Path: "asset_test.go", + } + id = 1 + repo := new(repository.Repository) + svc := new(mocks.GitHub) + wg := new(sync.WaitGroup) + wg.Add(1) + msgChan := make(chan string, 1) + errChan := make(chan error, 1) + + svc.On("UploadReleaseAsset").Return(new(github.ReleaseAsset), new(github.Response), nil).Once() + + m.Upload(id, repo, svc, wg, msgChan, errChan) + + msg := <-msgChan + err := <-errChan + + assert.Equal(fmt.Sprintf("uploading asset: '%v'", m.Name), msg) + assert.Equal(nil, err) + + // TEST: failed upload + m = asset.Asset{ + Name: "file1", + Path: "asset_test.go", + } + id = 2 + repo = new(repository.Repository) + svc = new(mocks.GitHub) + wg = new(sync.WaitGroup) + wg.Add(1) + msgChan = make(chan string, 1) + errChan = make(chan error, 1) + + svc.On("UploadReleaseAsset").Return(new(github.ReleaseAsset), new(github.Response), errors.New("value")).Once() + + m.Upload(id, repo, svc, wg, msgChan, errChan) + + msg = <-msgChan + err = <-errChan + + assert.Equal(fmt.Sprintf("uploading asset: '%v'", m.Name), msg) + assert.EqualError(err, "value") +} diff --git a/internal/pkg/interfaces/interfaces.go b/internal/pkg/interfaces/interfaces.go new file mode 100644 index 0000000..799b54c --- /dev/null +++ b/internal/pkg/interfaces/interfaces.go @@ -0,0 +1,14 @@ +package interfaces + +import ( + "context" + "os" + + "github.com/google/go-github/github" +) + +// GitHub is a 'github.client' interface +type GitHub interface { + UploadReleaseAsset(context.Context, string, string, int64, *github.UploadOptions, *os.File) (*github.ReleaseAsset, *github.Response, error) + CreateRelease(context.Context, string, string, *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) +} diff --git a/internal/pkg/local/local.go b/internal/pkg/local/local.go deleted file mode 100644 index 928b33c..0000000 --- a/internal/pkg/local/local.go +++ /dev/null @@ -1,76 +0,0 @@ -package local - -import ( - "errors" - "git-release/internal/pkg/remote" - "os" - "regexp" - "strings" -) - -// GetDetails returns local git information -func GetDetails(r *remote.Remote) error { - repoName, err := getRepoName() - if err != nil { - return err - } - - hash, err := getCommitHash() - if err != nil { - return err - } - - tag, err := getVersionTag() - if err != nil { - return err - } - - // remove v from version tag. - var re = regexp.MustCompile(".*([0-9]+.[0-9]+.[0-9]+)") - name := re.ReplaceAllString(tag, "$1") - - r.Owner = repoName["owner"] - r.Repository = repoName["name"] - r.Release.CommitHash = &hash - r.Release.Tag = &tag - r.Release.Name = &name - - return nil -} - -func getRepoName() (map[string]string, error) { - o := os.Getenv("GITHUB_REPOSITORY") - if o == "" { - return map[string]string{}, errors.New("environmental variable GITHUB_REPOSITORY not defined") - } - - repo := make(map[string]string) - - repo["owner"] = strings.Split(o, "/")[0] - repo["name"] = strings.Split(o, "/")[1] - - return repo, nil -} - -func getCommitHash() (string, error) { - o := os.Getenv("GITHUB_SHA") - if o == "" { - return "", errors.New("environmental variable GITHUB_SHA not defined") - } - - return o, nil -} - -func getVersionTag() (string, error) { - o := os.Getenv("GITHUB_REF") - if o == "" { - return "", errors.New("environmental variable GITHUB_REF not defined") - } - - regex := regexp.MustCompile("refs/tags/.*[0-9]+.[0-9]+.[0-9]+") - if regex.MatchString(o) { - return strings.Split(o, "/")[2], nil - } - - return "", errors.New("no matching tags found. expected to match regex '.*[0-9]+.[0-9]+.[0-9]+'") -} diff --git a/internal/pkg/release/release.go b/internal/pkg/release/release.go new file mode 100644 index 0000000..f923150 --- /dev/null +++ b/internal/pkg/release/release.go @@ -0,0 +1,85 @@ +package release + +import ( + "context" + "sync" + + "github.com/anton-yurchenko/git-release/internal/pkg/asset" + "github.com/anton-yurchenko/git-release/internal/pkg/interfaces" + "github.com/anton-yurchenko/git-release/internal/pkg/repository" + "github.com/anton-yurchenko/git-release/pkg/changelog" + "github.com/google/go-github/github" +) + +// Release represents a github.com release +type Release struct { + Name string + Draft bool + PreRelease bool + Assets []asset.Asset + Changes *changelog.Changes +} + +// Interface of 'Release' +type Interface interface { + Publish(repository.Interface, interfaces.GitHub, chan string, chan error) error + EnableDraft() + EnablePreRelease() + SetAssets([]asset.Asset) + GetAssets() []asset.Asset +} + +// Publish a new github.com release +func (r *Release) Publish(repo repository.Interface, service interfaces.GitHub, messages chan string, errors chan error) error { + // create release + release, _, err := service.CreateRelease( + context.Background(), + repo.GetOwner(), + repo.GetProject(), + &github.RepositoryRelease{ + Name: &r.Name, + TagName: repo.GetTag(), + TargetCommitish: repo.GetCommitHash(), + Body: &r.Changes.Body, + Draft: &r.Draft, + Prerelease: &r.PreRelease, + }, + ) + if err != nil { + return err + } + + // upload assets + if len(r.Assets) > 0 { + wg := new(sync.WaitGroup) + + wg.Add(len(r.Assets)) + + for _, asset := range r.Assets { + x := asset + go x.Upload(release.GetID(), repo, service, wg, messages, errors) + } + } + + return nil +} + +// EnableDraft sets release 'Draft' property to 'true' +func (r *Release) EnableDraft() { + r.Draft = true +} + +// EnablePreRelease sets release 'PreRelease' property to 'true' +func (r *Release) EnablePreRelease() { + r.PreRelease = true +} + +// SetAssets sets provided assets as a release assets +func (r *Release) SetAssets(a []asset.Asset) { + r.Assets = a +} + +// GetAssets returns release assets +func (r *Release) GetAssets() []asset.Asset { + return r.Assets +} diff --git a/internal/pkg/release/release_test.go b/internal/pkg/release/release_test.go new file mode 100644 index 0000000..51961a0 --- /dev/null +++ b/internal/pkg/release/release_test.go @@ -0,0 +1,198 @@ +package release_test + +import ( + "testing" + + "github.com/anton-yurchenko/git-release/internal/pkg/asset" + "github.com/anton-yurchenko/git-release/internal/pkg/release" + "github.com/anton-yurchenko/git-release/internal/pkg/repository" + "github.com/anton-yurchenko/git-release/mocks" + "github.com/anton-yurchenko/git-release/pkg/changelog" + "github.com/google/go-github/github" + "github.com/pkg/errors" + log "github.com/sirupsen/logrus" + "github.com/stretchr/testify/assert" +) + +func TestPublish(t *testing.T) { + assert := assert.New(t) + log.SetLevel(log.FatalLevel) + + // TEST: successfull release without assets + messages := make(chan string) + errs := make(chan error) + m := new(mocks.GitHub) + repo := new(repository.Repository) + rel := new(release.Release) + rel.Changes = new(changelog.Changes) + var id int64 = 12 + + m.On("CreateRelease").Return(&github.RepositoryRelease{ID: &id}, new(github.Response), nil).Once() + + err := rel.Publish(repo, m, messages, errs) + + assert.Equal(nil, err) + + // TEST: successfull release with single asset + m = new(mocks.GitHub) + repo = new(repository.Repository) + messages = make(chan string, 1) + errs = make(chan error, 1) + rel = &release.Release{ + Assets: []asset.Asset{ + asset.Asset{ + Name: "file1", + Path: "release_test.go", + }, + }, + } + rel.Changes = new(changelog.Changes) + id = 124 + + m.On("CreateRelease").Return(&github.RepositoryRelease{ID: &id}, new(github.Response), nil).Once() + m.On("UploadReleaseAsset").Return(new(github.ReleaseAsset), new(github.Response), nil).Once() + + err = rel.Publish(repo, m, messages, errs) + + assert.Equal(nil, err) + + // TEST: successfull release with multiple assets + m = new(mocks.GitHub) + repo = new(repository.Repository) + messages = make(chan string, 2) + errs = make(chan error, 2) + rel = &release.Release{ + Assets: []asset.Asset{ + asset.Asset{ + Name: "file1", + Path: "release_test.go", + }, + asset.Asset{ + Name: "file1", + Path: "asset_test.go", + }, + }, + } + rel.Changes = new(changelog.Changes) + id = 124 + + m.On("CreateRelease").Return(&github.RepositoryRelease{ID: &id}, new(github.Response), nil).Once() + m.On("UploadReleaseAsset").Return(new(github.ReleaseAsset), new(github.Response), nil).Twice() + + err = rel.Publish(repo, m, messages, errs) + + assert.Equal(nil, err) + + // TEST: failed release with single asset, test function abort mechanism + m = new(mocks.GitHub) + repo = new(repository.Repository) + messages = make(chan string, 1) + errs = make(chan error, 1) + rel = &release.Release{ + Assets: []asset.Asset{ + asset.Asset{ + Name: "file1", + Path: "file-not-found.zip", + }, + }, + } + rel.Changes = new(changelog.Changes) + id = 124 + + m.On("CreateRelease").Return(&github.RepositoryRelease{ID: &id}, new(github.Response), nil).Once() + m.On("UploadReleaseAsset").Return(new(github.ReleaseAsset), new(github.Response), nil).Once() + + err = rel.Publish(repo, m, messages, errs) + + assert.Equal(nil, err) + + err = <-errs + + assert.EqualError(err, "open file-not-found.zip: no such file or directory") + + // TEST: failed release creation + m = new(mocks.GitHub) + messages = make(chan string) + errs = make(chan error) + repo = new(repository.Repository) + + rel = new(release.Release) + rel.Changes = new(changelog.Changes) + id = 964 + + m.On("CreateRelease").Return(&github.RepositoryRelease{ID: &id}, new(github.Response), errors.New("release creation failed")).Once() + + err = rel.Publish(repo, m, messages, errs) + + assert.EqualError(err, "release creation failed") +} + +func TestEnableDraft(t *testing.T) { + assert := assert.New(t) + + expected := true + + m := release.Release{ + Draft: false, + } + + m.EnableDraft() + + assert.Equal(expected, m.Draft) +} + +func TestEnablePreRelease(t *testing.T) { + assert := assert.New(t) + + expected := true + + m := release.Release{ + PreRelease: false, + } + + m.EnablePreRelease() + + assert.Equal(expected, m.PreRelease) +} + +func TestSetAssets(t *testing.T) { + assert := assert.New(t) + + m := new(release.Release) + + expected := []asset.Asset{ + asset.Asset{ + Name: "file1", + Path: "release_test.go", + }, + asset.Asset{ + Name: "file1", + Path: "asset_test.go", + }, + } + + m.SetAssets(expected) + + assert.Equal(expected, m.Assets) +} + +func TestGetAssets(t *testing.T) { + assert := assert.New(t) + + expected := []asset.Asset{ + asset.Asset{ + Name: "file1", + Path: "release_test.go", + }, + asset.Asset{ + Name: "file1", + Path: "asset_test.go", + }, + } + + m := release.Release{ + Assets: expected, + } + + assert.Equal(expected, m.GetAssets()) +} diff --git a/internal/pkg/remote/remote.go b/internal/pkg/remote/remote.go deleted file mode 100644 index 4073c3b..0000000 --- a/internal/pkg/remote/remote.go +++ /dev/null @@ -1,120 +0,0 @@ -package remote - -import ( - "context" - - "os" - "sync" - - "errors" - - "github.com/google/go-github/github" - log "github.com/sirupsen/logrus" - "golang.org/x/oauth2" -) - -// Remote git repository and release configuration -type Remote struct { - Client *github.Client - Owner string - Repository string - Release Release - Assets []Asset -} - -// Release information -type Release struct { - ID int64 - Tag *string - CommitHash *string - Name *string - Body *string - Draft *bool - PreRelease *bool -} - -// Asset for an upload -type Asset struct { - Name string - Path string -} - -// Authenticate against github.com with access token -func Authenticate(token string) Remote { - ts := oauth2.StaticTokenSource( - &oauth2.Token{AccessToken: token}, - ) - tc := oauth2.NewClient(context.Background(), ts) - - return Remote{Client: github.NewClient(tc)} -} - -// Publish release and upload assets -func (r *Remote) Publish() error { - log.Info("creating release") - - // create release - rel, _, err := r.Client.Repositories.CreateRelease(context.Background(), r.Owner, r.Repository, &github.RepositoryRelease{ - TagName: r.Release.Tag, - TargetCommitish: r.Release.CommitHash, - Name: r.Release.Name, - Body: r.Release.Body, - Draft: r.Release.Draft, - Prerelease: r.Release.PreRelease, - }) - - if err != nil { - return err - } - - r.Release.ID = rel.GetID() - - // upload assets - if len(r.Assets) > 0 { - results := make(chan error, len(r.Assets)) - var wg sync.WaitGroup - wg.Add(len(r.Assets)) - for _, asset := range r.Assets { - go r.upload(asset, &wg, results) - } - wg.Wait() - - failed := false - for i := 0; i <= len(r.Assets)-1; i++ { - result := <-results - if result != nil { - log.Errorf("error uploading file %s: %s", r.Assets[i].Name, result) - failed = true - } - } - - if failed { - return errors.New("some assets were not uploaded, please validate github release manually") - } - } - - return nil -} - -func (r *Remote) upload(file Asset, wg *sync.WaitGroup, result chan error) { - defer wg.Done() - log.Info("uploading asset: ", file.Name) - - content, err := os.Open(file.Path) - - if err != nil { - result <- err - return - } - - _, _, err = r.Client.Repositories.UploadReleaseAsset(context.Background(), r.Owner, r.Repository, r.Release.ID, &github.UploadOptions{ - Name: file.Name, - }, content) - - if err != nil { - result <- err - return - } - - result <- nil -} diff --git a/internal/pkg/repository/repository.go b/internal/pkg/repository/repository.go new file mode 100644 index 0000000..3868145 --- /dev/null +++ b/internal/pkg/repository/repository.go @@ -0,0 +1,100 @@ +package repository + +import ( + "fmt" + "os" + "regexp" + "strings" + + "github.com/pkg/errors" +) + +// Repository represents a local git project repository +type Repository struct { + Owner string + Project string + CommitHash string + Tag string +} + +// Interface of 'Repository' +type Interface interface { + ReadTag(*string) error + ReadCommitHash() error + ReadProjectName() error + GetOwner() string + GetProject() string + GetTag() *string + GetCommitHash() *string +} + +// ReadTag sets tag to the receiver and sem.ver parsed version to provided parameter +func (r *Repository) ReadTag(version *string) error { + o := os.Getenv("GITHUB_REF") + if o == "" { + return errors.New("env.var 'GITHUB_REF' is empty or not defined") + } + + expression := "refs/tags/.*([0-9]+.[0-9]+.[0-9]+)" + regex := regexp.MustCompile(expression) + + if regex.MatchString(o) { + r.Tag = strings.Split(o, "/")[2] + *version = regex.ReplaceAllString(o, "$1") + return nil + } + + return errors.New(fmt.Sprintf("malformed env.var 'GITHUB_REF': expected to match regex '%v', got '%v'", expression, o)) +} + +// ReadCommitHash sets current commit hash +func (r *Repository) ReadCommitHash() error { + o := os.Getenv("GITHUB_SHA") + if o == "" { + return errors.New("env.var 'GITHUB_SHA' is empty or not defined") + } + + r.CommitHash = o + return nil +} + +// ReadProjectName sets parsed owner and project names +func (r *Repository) ReadProjectName() error { + o := os.Getenv("GITHUB_REPOSITORY") + if o == "" { + return errors.New("env.var 'GITHUB_REPOSITORY' is empty or not defined") + } + + // TODO: improve expression + expression := ".*/.*" + regex := regexp.MustCompile(expression) + + if regex.MatchString(o) { + r.Owner = strings.Split(o, "/")[0] + r.Project = strings.Split(o, "/")[1] + + return nil + } + + return errors.New(fmt.Sprintf("malformed env.var 'GITHUB_REPOSITORY': expected to match regex '%v', got '%v'", expression, o)) +} + +// GetOwner returns project owner +func (r *Repository) GetOwner() string { + return r.Owner +} + +// GetProject returns project name +func (r *Repository) GetProject() string { + return r.Project +} + +// GetTag returns current tag +func (r *Repository) GetTag() *string { + return &r.Tag +} + +// GetCommitHash returns current commit hash +func (r *Repository) GetCommitHash() *string { + return &r.CommitHash +} diff --git a/internal/pkg/repository/repository_test.go b/internal/pkg/repository/repository_test.go new file mode 100644 index 0000000..5779793 --- /dev/null +++ b/internal/pkg/repository/repository_test.go @@ -0,0 +1,170 @@ +package repository_test + +import ( + "fmt" + "github.com/anton-yurchenko/git-release/internal/pkg/repository" + "github.com/stretchr/testify/assert" + "os" + "testing" +) + +func TestReadTag(t *testing.T) { + assert := assert.New(t) + + // TEST: env.var correct + tag := "refs/tags/v1.0.0" + var version string + + err := os.Setenv("GITHUB_REF", tag) + assert.Equal(nil, err, "preparation: error setting env.var 'GITHUB_REF'") + + m := new(repository.Repository) + + err = m.ReadTag(&version) + + assert.Equal(nil, err) + assert.Equal("v1.0.0", m.Tag) + assert.Equal("1.0.0", version) + + // TEST: env.var incorrect + err = os.Setenv("GITHUB_REF", "malformed-var") + assert.Equal(nil, err, "preparation: error setting env.var 'GITHUB_REF'") + + m = new(repository.Repository) + + err = m.ReadTag(&version) + + assert.EqualError(err, "malformed env.var 'GITHUB_REF': expected to match regex 'refs/tags/.*([0-9]+.[0-9]+.[0-9]+)', got 'malformed-var'") + + // TEST: env.var not set + err = os.Setenv("GITHUB_REF", "") + assert.Equal(nil, err, "preparation: error setting env.var 'GITHUB_REF'") + + m = new(repository.Repository) + + err = m.ReadTag(&version) + + assert.EqualError(err, "env.var 'GITHUB_REF' is empty or not defined") +} + +func TestReadCommitHash(t *testing.T) { + assert := assert.New(t) + + // TEST: env.var set + expected := "123abc" + + err := os.Setenv("GITHUB_SHA", expected) + assert.Equal(nil, err, "preparation: error setting env.var 'GITHUB_SHA'") + + m := new(repository.Repository) + + err = m.ReadCommitHash() + + assert.Equal(nil, err) + assert.Equal(expected, m.CommitHash) + + // TEST: env.var not set + err = os.Setenv("GITHUB_SHA", "") + assert.Equal(nil, err, "preparation: error setting env.var 'GITHUB_SHA'") + + m = new(repository.Repository) + + err = m.ReadCommitHash() + + assert.EqualError(err, "env.var 'GITHUB_SHA' is empty or not defined") +} + +func TestReadProjectName(t *testing.T) { + assert := assert.New(t) + + // TEST: env.var correct + user := "user" + project := "project" + + err := os.Setenv("GITHUB_REPOSITORY", fmt.Sprintf("%v/%v", user, project)) + assert.Equal(nil, err, "preparation: error setting env.var 'GITHUB_REPOSITORY'") + + m := new(repository.Repository) + + err = m.ReadProjectName() + + assert.Equal(nil, err) + assert.Equal(user, m.Owner) + assert.Equal(project, m.Project) + + // TEST: env.var incorrect + err = os.Setenv("GITHUB_REPOSITORY", "value") + assert.Equal(nil, err, "preparation: error setting env.var 'GITHUB_REPOSITORY'") + + m = new(repository.Repository) + + err = m.ReadProjectName() + + assert.EqualError(err, "malformed env.var 'GITHUB_REPOSITORY': expected to match regex '.*/.*', got 'value'") + + // TEST: env.var not set + err = os.Setenv("GITHUB_REPOSITORY", "") + assert.Equal(nil, err, "preparation: error setting env.var 'GITHUB_REPOSITORY'") + + m = new(repository.Repository) + + err = m.ReadProjectName() + + assert.EqualError(err, "env.var 'GITHUB_REPOSITORY' is empty or not defined") +} + +func TestGetOwner(t *testing.T) { + assert := assert.New(t) + + expected := "user" + + m := repository.Repository{ + Owner: expected, + } + + result := m.GetOwner() + + assert.Equal(expected, result) +} + +func TestGetProject(t *testing.T) { + assert := assert.New(t) + + expected := "project" + + m := repository.Repository{ + Project: expected, + } + + result := m.GetProject() + + assert.Equal(expected, result) +} + +func TestGetTag(t *testing.T) { + assert := assert.New(t) + + expected := "1.0.0" + + m := repository.Repository{ + Tag: expected, + } + + result := m.GetTag() + + assert.Equal(expected, *result) +} + +func TestGetCommitHash(t *testing.T) { + assert := assert.New(t) + + expected := "123" + + m := repository.Repository{ + CommitHash: expected, + } + + result := m.GetCommitHash() + + assert.Equal(expected, *result) +} diff --git a/main.go b/main.go index 1b71f90..3a77186 100644 --- a/main.go +++ b/main.go @@ -1,27 +1,18 @@ package main import ( - "git-release/internal/pkg/local" - "git-release/internal/pkg/remote" - "git-release/pkg/changelog" - "strings" + "github.com/anton-yurchenko/git-release/internal/app" + "github.com/anton-yurchenko/git-release/internal/pkg/release" + "github.com/anton-yurchenko/git-release/internal/pkg/repository" + "github.com/anton-yurchenko/git-release/pkg/changelog" + "github.com/spf13/afero" "os" - "path/filepath" log "github.com/sirupsen/logrus" ) -type config struct { - Token string - Draft bool - PreRelease bool - Home string - Changelog string -} - func init() { - // set logger log.SetReportCaller(false) log.SetFormatter(&log.TextFormatter{ ForceColors: true, @@ -33,88 +24,28 @@ func init() { log.SetLevel(log.InfoLevel) } -func getConfig() config { - // get token - var c config - c.Token = os.Getenv("GITHUB_TOKEN") - if c.Token == "" { - log.Fatal("environmental variable GITHUB_TOKEN not defined") - } - - // get draft - d := os.Getenv("DRAFT_RELEASE") - c.Draft = false - if d == "true" { - c.Draft = true - } else if d != "false" { - log.Warn("environmental variable DRAFT_RELEASE not set, assuming FALSE") - } - - // get prerelease - p := os.Getenv("PRE_RELEASE") - c.PreRelease = false - if p == "true" { - c.PreRelease = true - } else if p != "false" { - log.Warn("environmental variable PRE_RELEASE not set, assuming FALSE") - } - - // get workspace - c.Home = os.Getenv("GITHUB_WORKSPACE") - if c.Home == "" { - log.Fatal("environmental variable GITHUB_WORKSPACE not defined") - } - - // get changelog - c.Changelog = os.Getenv("CHANGELOG_FILE") - if c.Changelog == "" { - log.Warn("environmental variable CHANGELOG_FILE not set, assuming 'CHANGELOG.md'") - c.Changelog = "CHANGELOG.md" - } - - return c -} - func main() { - conf := getConfig() - - // authenticate - r := remote.Authenticate(conf.Token) + fs := afero.NewOsFs() + repo := new(repository.Repository) + release := new(release.Release) + release.Changes = new(changelog.Changes) - // get details - r.Release.Draft = &conf.Draft - r.Release.PreRelease = &conf.PreRelease - - err := local.GetDetails(&r) + conf, token, err := app.GetConfig(release, release.Changes, fs, os.Args[1:]) if err != nil { log.Fatal(err) } - // get changelog - log.Infof("reading changelog: %+s", conf.Changelog) - r.Release.Body, err = changelog.GetBody(*r.Release.Name, conf.Changelog) - if *r.Release.Body == "" { - log.Warn("creating release with empty body") - } - if err != nil { - log.Warn(err) - } + cli := app.Login(token) - // prepare releast assets - // github 'jobs..steps.with.args' does not support arrays, so we need to parse it - arguments := strings.Split(os.Args[1], "\n") - for _, argument := range arguments { - r.Assets = append(r.Assets, remote.Asset{ - Name: filepath.Base(argument), - Path: conf.Home + "/" + argument, - }) + if err := app.Hydrate(repo, &release.Changes.Version); err != nil { + log.Fatal(err) } - err = r.Publish() - - if err != nil { + if err = conf.GetReleaseBody(release.Changes, fs); err != nil { log.Fatal(err) } - log.Infof("release '%+s' published", *r.Release.Name) + if err = conf.Publish(repo, release, cli.Repositories); err != nil { + log.Fatal(err) + } } diff --git a/mocks/GitHub.go b/mocks/GitHub.go new file mode 100644 index 0000000..ecf2cd2 --- /dev/null +++ b/mocks/GitHub.go @@ -0,0 +1,30 @@ +package mocks + +import ( + context "context" + + github "github.com/google/go-github/github" + + mock "github.com/stretchr/testify/mock" + + os "os" +) + +// GitHub is an mock type for the GitHub type +type GitHub struct { + mock.Mock +} + +// CreateRelease provides a stub function +func (_m *GitHub) CreateRelease(context.Context, string, string, *github.RepositoryRelease) (*github.RepositoryRelease, *github.Response, error) { + args := _m.Called() + + return args.Get(0).(*github.RepositoryRelease), args.Get(1).(*github.Response), args.Error(2) +} + +// UploadReleaseAsset provides a stub function +func (_m *GitHub) UploadReleaseAsset(context.Context, string, string, int64, *github.UploadOptions, *os.File) (*github.ReleaseAsset, *github.Response, error) { + args := _m.Called() + + return args.Get(0).(*github.ReleaseAsset), args.Get(1).(*github.Response), args.Error(2) +} diff --git a/mocks/asset.go b/mocks/asset.go new file mode 100644 index 0000000..4a81b7a --- /dev/null +++ b/mocks/asset.go @@ -0,0 +1,32 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package mocks + +import ( + interfaces "github.com/anton-yurchenko/git-release/internal/pkg/interfaces" + mock "github.com/stretchr/testify/mock" + + repository "github.com/anton-yurchenko/git-release/internal/pkg/repository" + + sync "sync" +) + +// Asset is an autogenerated mock type for the Asset type +type Asset struct { + mock.Mock +} + +// SetName provides a mock function with given fields: _a0 +func (_m *Asset) SetName(_a0 string) { + _m.Called(_a0) +} + +// SetPath provides a mock function with given fields: _a0 +func (_m *Asset) SetPath(_a0 string) { + _m.Called(_a0) +} + +// Upload provides a mock function with given fields: _a0, _a1, _a2, _a3, _a4, _a5 +func (_m *Asset) Upload(_a0 int64, _a1 repository.Interface, _a2 interfaces.GitHub, _a3 *sync.WaitGroup, _a4 chan string, _a5 chan error) { + _m.Called(_a0, _a1, _a2, _a3, _a4, _a5) +} diff --git a/mocks/changelog.go b/mocks/changelog.go new file mode 100644 index 0000000..f6f8807 --- /dev/null +++ b/mocks/changelog.go @@ -0,0 +1,61 @@ +// Code generated by mockery v1.0.0. DO NOT EDIT. + +package mocks + +import ( + afero "github.com/spf13/afero" + + mock "github.com/stretchr/testify/mock" +) + +// Changelog is an autogenerated mock type for the Changelog type +type Changelog struct { + mock.Mock +} + +// GetBody provides a mock function with given fields: +func (_m *Changelog) GetBody() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// GetFile provides a mock function with given fields: +func (_m *Changelog) GetFile() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// ReadChanges provides a mock function with given fields: _a0 +func (_m *Changelog) ReadChanges(_a0 afero.Fs) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(afero.Fs) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// SetFile provides a mock function with given fields: _a0 +func (_m *Changelog) SetFile(_a0 string) { + _m.Called(_a0) +} diff --git a/mocks/release.go b/mocks/release.go new file mode 100644 index 0000000..0b35732 --- /dev/null +++ b/mocks/release.go @@ -0,0 +1,53 @@ +package mocks + +import ( + asset "github.com/anton-yurchenko/git-release/internal/pkg/asset" + interfaces "github.com/anton-yurchenko/git-release/internal/pkg/interfaces" + + mock "github.com/stretchr/testify/mock" + + repository "github.com/anton-yurchenko/git-release/internal/pkg/repository" +) + +// Release is an autogenerated mock type for the Interface type +type Release struct { + mock.Mock +} + +// EnableDraft provides a mock function with given fields: +func (_m *Release) EnableDraft() { + _m.Called() +} + +// EnablePreRelease provides a mock function with given fields: +func (_m *Release) EnablePreRelease() { + _m.Called() +} + +// GetAssets provides a mock function with given fields: +func (_m *Release) GetAssets() []asset.Asset { + ret := _m.Called() + + var r0 []asset.Asset + if rf, ok := ret.Get(0).(func() []asset.Asset); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).([]asset.Asset) + } + } + + return r0 +} + +// Publish provides a stub function +func (_m *Release) Publish(repository.Interface, interfaces.GitHub, chan string, chan error) error { + args := _m.Called() + + return args.Error(0) +} + +// SetAssets provides a mock function with given fields: _a0 +func (_m *Release) SetAssets(_a0 []asset.Asset) { + _m.Called(_a0) +} diff --git a/mocks/repository.go b/mocks/repository.go new file mode 100644 index 0000000..423544d --- /dev/null +++ b/mocks/repository.go @@ -0,0 +1,110 @@ +package mocks + +import mock "github.com/stretchr/testify/mock" + +// Repository is an autogenerated mock type for the Repository type +type Repository struct { + mock.Mock +} + +// GetCommitHash provides a mock function with given fields: +func (_m *Repository) GetCommitHash() *string { + ret := _m.Called() + + var r0 *string + if rf, ok := ret.Get(0).(func() *string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*string) + } + } + + return r0 +} + +// GetOwner provides a mock function with given fields: +func (_m *Repository) GetOwner() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// GetProject provides a mock function with given fields: +func (_m *Repository) GetProject() string { + ret := _m.Called() + + var r0 string + if rf, ok := ret.Get(0).(func() string); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(string) + } + + return r0 +} + +// GetTag provides a mock function with given fields: +func (_m *Repository) GetTag() *string { + ret := _m.Called() + + var r0 *string + if rf, ok := ret.Get(0).(func() *string); ok { + r0 = rf() + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*string) + } + } + + return r0 +} + +// ReadCommitHash provides a mock function with given fields: +func (_m *Repository) ReadCommitHash() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ReadProjectName provides a mock function with given fields: +func (_m *Repository) ReadProjectName() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ReadTag provides a mock function with given fields: _a0 +func (_m *Repository) ReadTag(_a0 *string) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(*string) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} diff --git a/pkg/changelog/changelog.go b/pkg/changelog/changelog.go deleted file mode 100644 index ea03d18..0000000 --- a/pkg/changelog/changelog.go +++ /dev/null @@ -1,107 +0,0 @@ -package changelog - -import ( - "bufio" - "os" - "regexp" - "strings" -) - -// GetBody returns section from changelog for provided version -func GetBody(version string, filename string) (*string, error) { - var body string - - file, err := read(filename) - if err != nil { - return &body, err - } - - margins := getMargins(version, file) - - body = strings.Join(getContent(margins, file), "\n") - - return &body, nil -} - -// read file line by line to []string -func read(filename string) ([]string, error) { - file, err := os.Open(filename) - if err != nil { - return []string{}, err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - scanner.Split(bufio.ScanLines) - var lines []string - - for scanner.Scan() { - lines = append(lines, scanner.Text()) - } - - return lines, nil -} - -// getReleasesLines returns line numbers where every release start in changelog -func getReleasesLines(file []string) []int { - regex := regexp.MustCompile("## \\[[0-9]+.[0-9]+.[0-9]+\\].*") - var lines []int - - for i, line := range file { - if regex.MatchString(line) { - lines = append(lines, i) - } - } - - return lines -} - -// getEndOfFirstRelease returns line number when first releast ends. -// It may be an end of file or a start of an 'Unreleased'. -func getEndOfFirstRelease(start int, file []string) int { - regex := regexp.MustCompile("\\[.*\\]:.*") - - for i := start; i < len(file); i++ { - if regex.MatchString(file[i]) { - return i - 1 - } - } - - return len(file) -} - -// getMargins returns margins of requested version body -func getMargins(version string, file []string) map[string]int { - releaseLines := getReleasesLines(file) - - margins := make(map[string]int) - for i, line := range releaseLines { - v := strings.Split(strings.Trim(file[line], "## ["), "] ")[0] - - if v == version { - margins["start"] = line + 1 - - switch i < len(releaseLines)-1 { - // not first version - case true: - margins["end"] = releaseLines[i+1] - 1 - // first version - case false: - margins["end"] = getEndOfFirstRelease(margins["start"], file) - } - } - } - - return margins -} - -// getContent returns lines between margins -func getContent(margins map[string]int, file []string) []string { - var content []string - - for i := margins["start"]; i < margins["end"]; i++ { - content = append(content, file[i]) - } - - return content -} diff --git a/pkg/changelog/exported.go b/pkg/changelog/exported.go new file mode 100644 index 0000000..ab97fa5 --- /dev/null +++ b/pkg/changelog/exported.go @@ -0,0 +1,56 @@ +package changelog + +import ( + "strings" + + "github.com/pkg/errors" + "github.com/spf13/afero" +) + +// Changes represents changelog content for certain version +type Changes struct { + File string + Version string + Body string +} + +// Interface of 'Changes' +type Interface interface { + ReadChanges(afero.Fs) error + SetFile(string) + GetFile() string + GetBody() string +} + +// ReadChanges loads section from changelog for a requested version +func (c *Changes) ReadChanges(fs afero.Fs) error { + file, err := c.Read(fs) + if err != nil { + return err + } + + margins := c.GetMargins(file) + + c.Body = strings.Join(GetContent(margins, file), "\n") + + if c.Body == "" { + return errors.New("empty changelog for requested version") + } + + return nil +} + +// SetFile sets changelog filepath +func (c *Changes) SetFile(file string) { + c.File = file +} + +// GetFile returns changelog filepath +func (c *Changes) GetFile() string { + return c.File +} + +// GetBody returns changes body +func (c *Changes) GetBody() string { + return c.Body +} diff --git a/pkg/changelog/exported_test.go b/pkg/changelog/exported_test.go new file mode 100644 index 0000000..f871275 --- /dev/null +++ b/pkg/changelog/exported_test.go @@ -0,0 +1,110 @@ +package changelog_test + +import ( + "testing" + + "github.com/anton-yurchenko/git-release/pkg/changelog" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" +) + +func TestReadChanges(t *testing.T) { + fs := afero.NewMemMapFs() + file := createChangelog(fs, t) + + suite := map[string][]map[string]string{ + "pass": []map[string]string{ + map[string]string{ + "version": "1.0.0", + "expected": `- First stable release.`, + }, + map[string]string{ + "version": "1.0.1", + "expected": `### Added +- New feature. + +### Fixed +- Fixed env.var bug.`, + }, + }, + "fail": []map[string]string{ + map[string]string{ + "version": "99.0.0", + "expected": ``, + }, + }, + } + + for _, test := range suite["pass"] { + assert := assert.New(t) + + m := changelog.Changes{ + File: file, + Version: test["version"], + Body: "", + } + + err := m.ReadChanges(fs) + + assert.Equal(nil, err) + assert.Equal(test["expected"], m.Body) + } + + for _, test := range suite["fail"] { + assert := assert.New(t) + + m := changelog.Changes{ + File: file, + Version: test["version"], + Body: "", + } + + err := m.ReadChanges(fs) + + assert.EqualError(err, "empty changelog for requested version") + assert.Equal(test["expected"], m.Body) + } +} + +func TestSetFile(t *testing.T) { + assert := assert.New(t) + + m := new(changelog.Changes) + expected := "/home/user/file" + m.SetFile(expected) + + assert.Equal(expected, m.File) +} + +func TestGetFile(t *testing.T) { + assert := assert.New(t) + + m := new(changelog.Changes) + expected := "/home/user/file" + m.SetFile(expected) + + assert.Equal(expected, m.GetFile()) +} + +func TestGetBody(t *testing.T) { + assert := assert.New(t) + + expected := `### Added +- Feature A +- Feature B +- GitHub Actions as a CI system +- GitHub Release as an Artifactory system + +### Changed +- User API + +### Removed +- Previous CI build +- Previous Artifactory` + + m := changelog.Changes{ + Body: expected, + } + + assert.Equal(expected, m.GetBody()) +} diff --git a/pkg/changelog/private.go b/pkg/changelog/private.go new file mode 100644 index 0000000..2a6b063 --- /dev/null +++ b/pkg/changelog/private.go @@ -0,0 +1,96 @@ +package changelog + +import ( + "bufio" + "github.com/spf13/afero" + "regexp" + "strings" +) + +// Read changelog line by line and return content as []string +func (c *Changes) Read(fs afero.Fs) ([]string, error) { + lines := make([]string, 0) + + file, err := fs.Open(c.File) + if err != nil { + return lines, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + scanner.Split(bufio.ScanLines) + + for scanner.Scan() { + lines = append(lines, scanner.Text()) + } + + return lines, nil +} + +// GetEndOfFirstRelease returns line number on which the first release ends. +// It may be an end of file or a start of an 'Unreleased Versions'. +func GetEndOfFirstRelease(content []string) int { + expression := `\[.*\]:.*` + regex := regexp.MustCompile(expression) + + for i := 0; i < len(content); i++ { + if regex.MatchString(content[i]) { + return i - 1 + } + } + + return len(content) +} + +// GetReleasesLines returns line numbers where each release starts +func GetReleasesLines(content []string) []int { + lines := make([]int, 0) + + expression := `## \[[0-9]+.[0-9]+.[0-9]+\].*` + regex := regexp.MustCompile(expression) + + for i, line := range content { + if regex.MatchString(line) { + lines = append(lines, i) + } + } + + return lines +} + +// GetMargins returns margins of requested version body +func (c *Changes) GetMargins(content []string) map[string]int { + margins := make(map[string]int) + + releaseLines := GetReleasesLines(content) + + for i, line := range releaseLines { + v := strings.Split(strings.TrimPrefix(content[line], "## ["), "] ")[0] + + if v == c.Version { + margins["start"] = line + 1 + + switch i < len(releaseLines)-1 { + // not first version + case true: + margins["end"] = releaseLines[i+1] - 1 + // first version + case false: + margins["end"] = GetEndOfFirstRelease(content) + } + } + } + + return margins +} + +// GetContent returns lines between margins +func GetContent(margins map[string]int, content []string) []string { + releseContent := make([]string, 0) + + for i := margins["start"]; i < margins["end"]; i++ { + releseContent = append(releseContent, content[i]) + } + + return releseContent +} diff --git a/pkg/changelog/private_test.go b/pkg/changelog/private_test.go new file mode 100644 index 0000000..3e7b6cd --- /dev/null +++ b/pkg/changelog/private_test.go @@ -0,0 +1,93 @@ +package changelog_test + +import ( + "strings" + "testing" + + "github.com/anton-yurchenko/git-release/pkg/changelog" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" +) + +func TestRead(t *testing.T) { + assert := assert.New(t) + + // TEST: correct file + fs := afero.NewMemMapFs() + file := createChangelog(fs, t) + + m := new(changelog.Changes) + m.SetFile(file) + + result, err := m.Read(fs) + expected := strings.Split(content, "\n") + + assert.Equal(nil, err) + assert.Equal(expected, result) + + // TEST: read non existing file + fs = afero.NewMemMapFs() + + m = new(changelog.Changes) + m.SetFile("./non-existing-file") + + result, err = m.Read(fs) + + assert.EqualError(err, "open non-existing-file: file does not exist") + assert.Equal(make([]string, 0), result) +} + +func TestGetEndOfFirstRelease(t *testing.T) { + assert := assert.New(t) + + // TEST: expected content + expected := 40 + + result := changelog.GetEndOfFirstRelease(strings.Split(content, "\n")) + + assert.Equal(expected, result) + + // TEST: single release changelog + singleReleaseChangelog := `## [1.0.0] - 2018-01-01 +- First stable release.` + + expected = 2 + + result = changelog.GetEndOfFirstRelease(strings.Split(singleReleaseChangelog, "\n")) + + assert.Equal(expected, result) +} + +func TestGetReleasesLines(t *testing.T) { + assert := assert.New(t) + + expected := []int{4, 11, 14, 19, 34} + result := changelog.GetReleasesLines(strings.Split(content, "\n")) + + assert.Equal(expected, result) +} + +func TestGetMargins(t *testing.T) { + for version, expected := range releasesContentMargins { + assert := assert.New(t) + + m := changelog.Changes{ + Version: version, + } + + result := m.GetMargins(strings.Split(content, "\n")) + + assert.Equal(expected, result) + } +} + +func TestGetContent(t *testing.T) { + for version, margins := range releasesContentMargins { + assert := assert.New(t) + + expected := strings.Split(releasesContent[version], "\n") + result := changelog.GetContent(margins, strings.Split(content, "\n")) + + assert.Equal(expected, result) + } +} diff --git a/pkg/changelog/utils_test.go b/pkg/changelog/utils_test.go new file mode 100644 index 0000000..4ff5044 --- /dev/null +++ b/pkg/changelog/utils_test.go @@ -0,0 +1,120 @@ +package changelog_test + +import ( + "io" + "testing" + + "github.com/spf13/afero" +) + +const content string = `## [Unreleased] +- Unrelease feature. +- Parsing bug fixed. + +## [1.0.1] - 2018-01-28 +### Added +- New feature. + +### Fixed +- Fixed env.var bug. + +## [1.0.0] - 2018-01-01 +- First stable release. + +## [0.3.0] - 2017-12-31 +### Fixed +- Wrong message on success. +- Proper log message. + +## [0.2.0] - 2016-10-01 +### Added +- File reader. +- License. + +### Changed +- Remove 'v' from release name. + +### Fixed +- Create release without assets. + +### Removed +- 'DRAFT_RELEASE=false' warning logging. +- 'PRE_RELEASE=false' warning logging. + +## [0.1.0] - 2019-09-29 +### Added +- Create GitHub Release. +- Upload Assets. +- Control Release Draft through env.var 'DRAFT_RELEASE'. +- Control Release Pre Release through env.var 'PRE_RELEASE'. + +[Unreleased]: [0.3.0]: https://github.com/anton-yurchenko/git-release/compare/v0.2.0...v0.3.0 +[0.2.0]: https://github.com/anton-yurchenko/git-release/compare/v0.1.0...v0.2.0 +[0.1.0]: https://github.com/anton-yurchenko/git-release/releases/tag/v0.1.0` + +var releasesContentMargins map[string]map[string]int = map[string]map[string]int{ + "1.0.1": map[string]int{ + "start": 5, + "end": 10, + }, + "1.0.0": map[string]int{ + "start": 12, + "end": 13, + }, + "0.3.0": map[string]int{ + "start": 15, + "end": 18, + }, + "0.2.0": map[string]int{ + "start": 20, + "end": 33, + }, + "0.1.0": map[string]int{ + "start": 35, + "end": 40, + }, +} + +var releasesContent map[string]string = map[string]string{ + "1.0.1": `### Added +- New feature. + +### Fixed +- Fixed env.var bug.`, + "1.0.0": `- First stable release.`, + "0.3.0": `### Fixed +- Wrong message on success. +- Proper log message.`, + "0.2.0": `### Added +- File reader. +- License. + +### Changed +- Remove 'v' from release name. + +### Fixed +- Create release without assets. + +### Removed +- 'DRAFT_RELEASE=false' warning logging. +- 'PRE_RELEASE=false' warning logging.`, + "0.1.0": `### Added +- Create GitHub Release. +- Upload Assets. +- Control Release Draft through env.var 'DRAFT_RELEASE'. +- Control Release Pre Release through env.var 'PRE_RELEASE'.`, +} + +func createChangelog(fs afero.Fs, t *testing.T) string { + file, err := fs.Create("CHANGELOG.md") + if err != nil { + t.Fatal("error creating CHANGELOG.md", err) + } + + _, err = io.WriteString(file, content) + if err != nil { + t.Fatal("error writing to CHANGELOG.md", err) + } + + return file.Name() +}