From 79ab0c34e76f86bf84af29e04e393f67e4c0364f Mon Sep 17 00:00:00 2001 From: Viacheslav Poturaev Date: Fri, 21 Oct 2022 07:17:04 +0200 Subject: [PATCH] Add error implementation with multiple unwrappables (#16) --- .github/workflows/bench.yml | 2 +- .github/workflows/cloc.yml | 4 +- .github/workflows/golangci-lint.yml | 6 +-- .github/workflows/gorelease.yml | 6 +-- .github/workflows/test-unit.yml | 71 ++++++++++++++++++++--------- .golangci.yml | 16 +++++++ Makefile | 2 +- error.go | 65 +++++++++++++++++++++++++- error_test.go | 23 ++++++++++ go.mod | 8 ++-- go.sum | 20 ++++---- 11 files changed, 177 insertions(+), 46 deletions(-) diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 0e6bddc..d750b9d 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -21,7 +21,7 @@ env: GO111MODULE: "on" CACHE_BENCHMARK: "off" # Enables benchmark result reuse between runs, may skew latency results. RUN_BASE_BENCHMARK: "on" # Runs benchmark for PR base in case benchmark result is missing. - GO_VERSION: 1.18.x + GO_VERSION: 1.19.x jobs: bench: runs-on: ubuntu-latest diff --git a/.github/workflows/cloc.yml b/.github/workflows/cloc.yml index d6ff20d..619ca74 100644 --- a/.github/workflows/cloc.yml +++ b/.github/workflows/cloc.yml @@ -24,7 +24,9 @@ jobs: - name: Count Lines Of Code id: loc run: | - curl -sLO https://github.com/vearutop/sccdiff/releases/download/v1.0.1/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz + curl -sLO https://github.com/vearutop/sccdiff/releases/download/v1.0.3/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz + sccdiff_hash=$(git hash-object ./sccdiff) + [ "$sccdiff_hash" == "ae8a07b687bd3dba60861584efe724351aa7ff63" ] || (echo "::error::unexpected hash for sccdiff, possible tampering: $sccdiff_hash" && exit 1) OUTPUT=$(cd pr && ../sccdiff -basedir ../base) echo "${OUTPUT}" OUTPUT="${OUTPUT//$'\n'/%0A}" diff --git a/.github/workflows/golangci-lint.yml b/.github/workflows/golangci-lint.yml index b4f7600..bf0bcdb 100644 --- a/.github/workflows/golangci-lint.yml +++ b/.github/workflows/golangci-lint.yml @@ -21,13 +21,13 @@ jobs: steps: - uses: actions/setup-go@v3 with: - go-version: 1.18.x + go-version: 1.19.x - uses: actions/checkout@v2 - name: golangci-lint - uses: golangci/golangci-lint-action@v3.1.0 + uses: golangci/golangci-lint-action@v3.2.0 with: # Required: the version of golangci-lint is required and must be specified without patch version: we always use the latest patch version. - version: v1.45.2 + version: v1.50.0 # Optional: working directory, useful for monorepos # working-directory: somedir diff --git a/.github/workflows/gorelease.yml b/.github/workflows/gorelease.yml index 429d5ed..6267500 100644 --- a/.github/workflows/gorelease.yml +++ b/.github/workflows/gorelease.yml @@ -9,7 +9,7 @@ concurrency: cancel-in-progress: true env: - GO_VERSION: 1.18.x + GO_VERSION: 1.19.x jobs: gorelease: runs-on: ubuntu-latest @@ -35,12 +35,12 @@ jobs: with: path: | ~/go/bin/gorelease - key: ${{ runner.os }}-gorelease + key: ${{ runner.os }}-gorelease-generic - name: Gorelease id: gorelease run: | test -e ~/go/bin/gorelease || go install golang.org/x/exp/cmd/gorelease@latest - OUTPUT=$(gorelease || exit 0) + OUTPUT=$(gorelease 2>&1 || exit 0) echo "${OUTPUT}" OUTPUT="${OUTPUT//$'\n'/%0A}" echo "::set-output name=report::$OUTPUT" diff --git a/.github/workflows/test-unit.yml b/.github/workflows/test-unit.yml index a1d30c9..94441bd 100644 --- a/.github/workflows/test-unit.yml +++ b/.github/workflows/test-unit.yml @@ -16,11 +16,12 @@ env: GO111MODULE: "on" RUN_BASE_COVERAGE: "on" # Runs test for PR base in case base test coverage is missing. COV_GO_VERSION: 1.18.x # Version of Go to collect coverage + TARGET_DELTA_COV: 90 # Target coverage of changed lines, in percents jobs: test: strategy: matrix: - go-version: [ 1.16.x, 1.17.x, 1.18.x, tip ] + go-version: [ 1.16.x, 1.17.x, 1.18.x, 1.19.x ] runs-on: ubuntu-latest steps: - name: Install Go stable @@ -28,6 +29,7 @@ jobs: uses: actions/setup-go@v3 with: go-version: ${{ matrix.go-version }} + - name: Install Go tip if: matrix.go-version == 'tip' run: | @@ -37,8 +39,10 @@ jobs: tar -C ~/sdk/gotip -xzf gotip.tar.gz ~/sdk/gotip/bin/go version echo "PATH=$HOME/go/bin:$HOME/sdk/gotip/bin/:$PATH" >> $GITHUB_ENV + - name: Checkout code uses: actions/checkout@v2 + - name: Go cache uses: actions/cache@v2 with: @@ -51,44 +55,57 @@ jobs: key: ${{ runner.os }}-go-cache-${{ hashFiles('**/go.sum') }} restore-keys: | ${{ runner.os }}-go-cache + - name: Restore base test coverage id: base-coverage - if: matrix.go-version == env.COV_GO_VERSION + if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' uses: actions/cache@v2 with: path: | unit-base.txt # Use base sha for PR or new commit hash for master/main push in test result key. key: ${{ runner.os }}-unit-test-coverage-${{ (github.event.pull_request.base.sha != github.event.after) && github.event.pull_request.base.sha || github.event.after }} - - name: Checkout base code - if: matrix.go-version == env.COV_GO_VERSION && env.RUN_BASE_COVERAGE == 'on' && steps.base-coverage.outputs.cache-hit != 'true' && github.event.pull_request.base.sha != '' - uses: actions/checkout@v2 - with: - ref: ${{ github.event.pull_request.base.sha }} - path: __base + - name: Run test for base code if: matrix.go-version == env.COV_GO_VERSION && env.RUN_BASE_COVERAGE == 'on' && steps.base-coverage.outputs.cache-hit != 'true' && github.event.pull_request.base.sha != '' run: | - cd __base - make | grep test-unit && (make test-unit && go tool cover -func=./unit.coverprofile | sed -e 's/.go:[0-9]*:\t/.go\t/g' | sed -e 's/\t\t*/\t/g' > ../unit-base.txt) || echo "No test-unit in base" + git fetch origin master ${{ github.event.pull_request.base.sha }} + HEAD=$(git rev-parse HEAD) + git reset --hard ${{ github.event.pull_request.base.sha }} + (make test-unit && go tool cover -func=./unit.coverprofile > unit-base.txt) || echo "No test-unit in base" + git reset --hard $HEAD + - name: Test id: test run: | make test-unit - go tool cover -func=./unit.coverprofile | sed -e 's/.go:[0-9]*:\t/.go\t/g' | sed -e 's/\t\t*/\t/g' > unit.txt - OUTPUT=$(test -e unit-base.txt && (diff unit-base.txt unit.txt || exit 0) || cat unit.txt) - echo "${OUTPUT}" - OUTPUT="${OUTPUT//$'\n'/%0A}" + go tool cover -func=./unit.coverprofile > unit.txt TOTAL=$(grep 'total:' unit.txt) echo "${TOTAL}" - echo "::set-output name=diff::$OUTPUT" echo "::set-output name=total::$TOTAL" - - name: Store base coverage - if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} - run: cp unit.txt unit-base.txt + + - name: Annotate missing test coverage + id: annotate + if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' + run: | + curl -sLO https://github.com/vearutop/gocovdiff/releases/download/v1.3.6/linux_amd64.tar.gz && tar xf linux_amd64.tar.gz + gocovdiff_hash=$(git hash-object ./gocovdiff) + [ "$gocovdiff_hash" == "8e507e0d671d4d6dfb3612309b72b163492f28eb" ] || (echo "::error::unexpected hash for gocovdiff, possible tampering: $gocovdiff_hash" && exit 1) + git fetch origin master ${{ github.event.pull_request.base.sha }} + REP=$(./gocovdiff -cov unit.coverprofile -gha-annotations gha-unit.txt -delta-cov-file delta-cov-unit.txt -target-delta-cov ${TARGET_DELTA_COV}) + echo "${REP}" + REP="${REP//$'\n'/%0A}" + cat gha-unit.txt + DIFF=$(test -e unit-base.txt && ./gocovdiff -func-cov unit.txt -func-base-cov unit-base.txt || echo "Missing base coverage file") + DIFF="${DIFF//$'\n'/%0A}" + TOTAL=$(cat delta-cov-unit.txt) + echo "::set-output name=rep::$REP" + echo "::set-output name=diff::$DIFF" + echo "::set-output name=total::$TOTAL" + - name: Comment Test Coverage continue-on-error: true - if: matrix.go-version == env.COV_GO_VERSION + if: matrix.go-version == env.COV_GO_VERSION && github.event.pull_request.base.sha != '' uses: marocchino/sticky-pull-request-comment@v2 with: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -96,13 +113,23 @@ jobs: message: | ### Unit Test Coverage ${{ steps.test.outputs.total }} + ${{ steps.annotate.outputs.total }} +
Coverage of changed lines + + ${{ steps.annotate.outputs.rep }} + +
+
Coverage diff with base branch - ```diff - ${{ steps.test.outputs.diff }} - ``` + ${{ steps.annotate.outputs.diff }} +
+ - name: Store base coverage + if: ${{ github.ref == 'refs/heads/master' || github.ref == 'refs/heads/main' }} + run: cp unit.txt unit-base.txt + - name: Upload code coverage if: matrix.go-version == env.COV_GO_VERSION uses: codecov/codecov-action@v1 diff --git a/.golangci.yml b/.golangci.yml index 97710e1..219b387 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -37,6 +37,14 @@ linters: - tagliatelle - errname - ireturn + - exhaustruct + - nonamedreturns + - nosnakecase + - structcheck + - varcheck + - deadcode + - testableexamples + - dupword issues: exclude-use-default: false @@ -48,5 +56,13 @@ issues: - noctx - funlen - dupl + - structcheck + - unused + - unparam + - nosnakecase path: "_test.go" + - linters: + - errcheck # Error checking omitted for brevity. + - gosec + path: "example_" diff --git a/Makefile b/Makefile index 77a5ec3..80dc683 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -#GOLANGCI_LINT_VERSION := "v1.45.2" # Optional configuration to pinpoint golangci-lint version. +#GOLANGCI_LINT_VERSION := "v1.50.0" # Optional configuration to pinpoint golangci-lint version. # The head of Makefile determines location of dev-go to include standard targets. GO ?= go diff --git a/error.go b/error.go index 86111fe..5a9b3e3 100644 --- a/error.go +++ b/error.go @@ -87,7 +87,7 @@ func WrapError(ctx context.Context, err error, message string, keysAndValues ... // // LogError fields from context are also added to error structured data. func NewError(ctx context.Context, message string, keysAndValues ...interface{}) error { - // nolint:goerr113 // Static errors can be used with WrapError. + //nolint:goerr113 // Static errors can be used with WrapError. err := errors.New(message) se, ok := newError(ctx, err, keysAndValues...) @@ -236,6 +236,8 @@ func (se structuredError) MarshalJSON() ([]byte, error) { } // SentinelError is a constant error. +// +// See https://dave.cheney.net/2016/04/07/constant-errors for more details. type SentinelError string // Error returns error message. @@ -300,3 +302,64 @@ func (le labeledError) As(v interface{}) bool { func (le labeledError) Unwrap() error { return le.err } + +// MultiError creates an error with multiple unwrappables. +// +// Secondary errors could be checked with errors.Is, errors.As. +// Error message remains the same with primary error. +// +// Multi errors can be used to augment error with multiple +// checkable perks, without a limitation of single wrapping inheritance. +func MultiError(primary error, secondary ...error) error { + return multi{ + primary: primary, + secondary: secondary, + } +} + +type multi struct { + primary error + secondary []error +} + +// Error returns message. +func (le multi) Error() string { + return le.primary.Error() +} + +// Is returns true if err matches primary error or any of secondary. +func (le multi) Is(err error) bool { + if errors.Is(le.primary, err) { + return true + } + + for _, l := range le.secondary { + if errors.Is(err, l) { + return true + } + } + + return false +} + +// As returns true if primary error or any of secondary can be assigned to v. +// +// If multiple assignations are possible, only first one is performed. +func (le multi) As(v interface{}) bool { + if errors.As(le.primary, v) { + return true + } + + for _, l := range le.secondary { + if errors.As(l, v) { + return true + } + } + + return false +} + +// Unwrap returns primary error. +func (le multi) Unwrap() error { + return le.primary +} diff --git a/error_test.go b/error_test.go index 5c7bb11..86eed42 100644 --- a/error_test.go +++ b/error_test.go @@ -259,3 +259,26 @@ func TestTuples_Fields(t *testing.T) { assert.Equal(t, map[string]interface{}{"a": 123, "b": 456}, ctxd.Tuples{"a", 123, "b", 456}.Fields()) // All good. } + +func TestNewMulti(t *testing.T) { + errPrimary := errors.New("failed") + errSecondary1 := ctxd.SentinelError("miserably") + errSecondary2 := ctxd.SentinelError("hopelessly") + errSecondary3 := ctxd.SentinelError("deadly") + + err := ctxd.MultiError(fmt.Errorf("oops: %w", errPrimary), errSecondary1, errSecondary2) + + assert.True(t, errors.Is(err, errPrimary)) + assert.True(t, errors.Is(err, errSecondary1)) + assert.True(t, errors.Is(err, errSecondary2)) + assert.False(t, errors.Is(err, errSecondary3)) + + // Labels do not implicitly contribute to error message. + assert.Equal(t, "oops: failed", err.Error()) + + // If there are two matches, only first is returned. + var errSentinel ctxd.SentinelError + + assert.True(t, errors.As(err, &errSentinel)) + assert.Equal(t, "miserably", string(errSentinel)) +} diff --git a/go.mod b/go.mod index 1d8e1a7..85c4f21 100644 --- a/go.mod +++ b/go.mod @@ -3,13 +3,13 @@ module github.com/bool64/ctxd go 1.17 require ( - github.com/bool64/dev v0.2.5 - github.com/stretchr/testify v1.7.1 - github.com/swaggest/usecase v1.1.2 + github.com/bool64/dev v0.2.22 + github.com/stretchr/testify v1.8.0 + github.com/swaggest/usecase v1.2.0 ) require ( github.com/davecgh/go-spew v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect - gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 7a4a08c..f391c0d 100644 --- a/go.sum +++ b/go.sum @@ -1,20 +1,20 @@ -github.com/bool64/dev v0.2.5 h1:H0bylghwcjDBBhEwSFTjArEO9Dr8cCaB54QSOF7esOA= -github.com/bool64/dev v0.2.5/go.mod h1:cTHiTDNc8EewrQPy3p1obNilpMpdmlUesDkFTF2zRWU= +github.com/bool64/dev v0.2.20/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= +github.com/bool64/dev v0.2.22 h1:YJFKBRKplkt+0Emq/5Xk1Z5QRmMNzc1UOJkR3rxJksA= +github.com/bool64/dev v0.2.22/go.mod h1:iJbh1y/HkunEPhgebWRNcs8wfGq7sjvJ6W5iabL8ACg= 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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= -github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= -github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -github.com/swaggest/usecase v1.1.2 h1:2LfuSyjYtPtpHnxqPwV87/eunbhGBC5HKdRp8/fINBk= -github.com/swaggest/usecase v1.1.2/go.mod h1:abZWuMFYujaeLDODqRySJZpWD/ugsnE3Wj9K6jUeCjo= +github.com/stretchr/testify v1.8.0 h1:pSgiaMZlXftHpm5L7V1+rVB+AZJydKsMxsQBIJw4PKk= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/swaggest/usecase v1.2.0 h1:cHVFqxIbHfyTXp02JmWXk+ZADaSa87UZP+b3qL5Nz90= +github.com/swaggest/usecase v1.2.0/go.mod h1:oc5+QoAxG3Et5Gl9lRXgEOm00l4VN9gdVQSMIa5EeLY= 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/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= -gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=