commit d354d7b8313079f9a9564108c4335ccea23b3f32 Author: Joakim Olsson Date: Sun Mar 30 13:27:34 2025 +0200 feat: initial version diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..04cd3ad --- /dev/null +++ b/.editorconfig @@ -0,0 +1,11 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +trim_trailing_whitespace = true + +[*.go] +indent_style = tab +indent_size = 2 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9ada4d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.idea +/release diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml new file mode 100644 index 0000000..5c7ec3a --- /dev/null +++ b/.gitlab-ci.yml @@ -0,0 +1,36 @@ +include: +- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml' +- project: unboundsoftware/ci-templates + file: Release.gitlab-ci.yml +- project: unboundsoftware/ci-templates + file: Pre-Commit-Go.gitlab-ci.yml + +image: amd64/golang:1.24.1@sha256:5ecf3334c3970cf435bf821e0b7c48276ca8f00459fbfa107f4702e59e011d97 + +stages: +- deps +- test + +deps: + stage: deps + script: + - go mod download + +test: + stage: test + dependencies: + - deps + script: + - CGO_ENABLED=1 go test -mod=readonly -race -coverprofile=coverage.txt -covermode=atomic -coverpkg=$(go list ./... | tr '\n' , | sed 's/,$//') ./... + - go tool cover -html=coverage.txt -o coverage.html + - go tool cover -func=coverage.txt + - curl -Os https://uploader.codecov.io/latest/linux/codecov + - chmod +x codecov + - ./codecov -t ${CODECOV_TOKEN} -R $CI_PROJECT_DIR -C $CI_COMMIT_SHA -r $CI_PROJECT_PATH + +vulnerabilities: + stage: test + image: amd64/golang:1.24.1@sha256:5ecf3334c3970cf435bf821e0b7c48276ca8f00459fbfa107f4702e59e011d97 + script: + - go install golang.org/x/vuln/cmd/govulncheck@latest + - govulncheck ./... diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..5381cc5 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,22 @@ +version: "2" +run: + allow-parallel-runners: true +linters: + exclusions: + generated: lax + presets: + - comments + - common-false-positives + - legacy + - std-error-handling + paths: + - third_party$ + - builtin$ + - examples$ +formatters: + exclusions: + generated: lax + paths: + - third_party$ + - builtin$ + - examples$ diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..c32c4b2 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,46 @@ +# See https://pre-commit.com for more information +# See https://pre-commit.com/hooks.html for more hooks +repos: +- repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + args: + - --allow-multiple-documents + - id: check-added-large-files +- repo: https://gitlab.com/devopshq/gitlab-ci-linter + rev: v1.0.6 + hooks: + - id: gitlab-ci-linter + args: + - --project + - unboundsoftware/shiny/sentrysetup +- repo: https://github.com/alessandrojcm/commitlint-pre-commit-hook + rev: v9.22.0 + hooks: + - id: commitlint + stages: [ commit-msg ] + additional_dependencies: [ '@commitlint/config-conventional' ] +- repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-mod-tidy + - id: go-imports + args: + - -local + - gitlab.com/unboundsoftware/shiny/presenter +- repo: https://github.com/lietu/go-pre-commit + rev: v0.1.0 + hooks: + - id: go-test + - id: gofumpt +- repo: https://github.com/golangci/golangci-lint + rev: v2.0.2 + hooks: + - id: golangci-lint-full +- repo: https://github.com/gitleaks/gitleaks + rev: v8.24.2 + hooks: + - id: gitleaks diff --git a/README.md b/README.md new file mode 100644 index 0000000..2981f30 --- /dev/null +++ b/README.md @@ -0,0 +1,6 @@ +# Shiny global error presenter + +GraphQL global error presenter handling coded errors. + +[![Build Status](https://gitlab.com/unboundsoftware/shiny/presenter/badges/main/pipeline.svg)](https://gitlab.com/unboundsoftware/shiny/presenter/commits/main) +[![codecov](https://codecov.io/gl/unboundsoftware:shiny/presenter/branch/main/graph/badge.svg?token=8F3HKACF7A)](https://codecov.io/gl/unboundsoftware:shiny/presenter) diff --git a/cliff.toml b/cliff.toml new file mode 100644 index 0000000..d6ed913 --- /dev/null +++ b/cliff.toml @@ -0,0 +1,87 @@ +# git-cliff ~ default configuration file +# https://git-cliff.org/docs/configuration +# +# Lines starting with "#" are comments. +# Configuration options are organized into tables and keys. +# See documentation for more information on available options. + +[bump] +initial_tag = "v0.0.1" + +[changelog] +# template for the changelog header +header = """ +# Changelog\n +All notable changes to this project will be documented in this file.\n +""" +# template for the changelog body +# https://keats.github.io/tera/docs/#introduction +body = """ +{% if version %}\ + ## [{{ version | trim_start_matches(pat="v") }}] - {{ timestamp | date(format="%Y-%m-%d") }} +{% else %}\ + ## [unreleased] +{% endif %}\ +{% for group, commits in commits | group_by(attribute="group") %} + ### {{ group | striptags | trim | upper_first }} + {% for commit in commits %} + - {% if commit.scope %}*({{ commit.scope }})* {% endif %}\ + {% if commit.breaking %}[**breaking**] {% endif %}\ + {{ commit.message | upper_first }}\ + {% endfor %} +{% endfor %}\n +""" +# template for the changelog footer +footer = """ + +""" +# remove the leading and trailing s +trim = true +# postprocessors +postprocessors = [ + # { pattern = '', replace = "https://github.com/orhun/git-cliff" }, # replace repository URL +] +# render body even when there are no releases to process +# render_always = true +# output file path +# output = "test.md" + +[git] +# parse the commits based on https://www.conventionalcommits.org +conventional_commits = true +# filter out the commits that are not conventional +filter_unconventional = true +# process each line of a commit as an individual commit +split_commits = false +# regex for preprocessing the commit messages +commit_preprocessors = [ + # Replace issue numbers + #{ pattern = '\((\w+\s)?#([0-9]+)\)', replace = "([#${2}](/issues/${2}))"}, + # Check spelling of the commit with https://github.com/crate-ci/typos + # If the spelling is incorrect, it will be automatically fixed. + #{ pattern = '.*', replace_command = 'typos --write-changes -' }, +] +# regex for parsing and grouping commits +commit_parsers = [ + { message = "^feat", group = "๐Ÿš€ Features" }, + { message = "^fix", group = "๐Ÿ› Bug Fixes" }, + { message = "^doc", group = "๐Ÿ“š Documentation" }, + { message = "^perf", group = "โšก Performance" }, + { message = "^refactor", group = "๐Ÿšœ Refactor" }, + { message = "^style", group = "๐ŸŽจ Styling" }, + { message = "^test", group = "๐Ÿงช Testing" }, + { message = "^chore\\(release\\): prepare for", skip = true }, + { message = "^chore\\(deps.*\\)", skip = true }, + { message = "^chore\\(pr\\)", skip = true }, + { message = "^chore\\(pull\\)", skip = true }, + { message = "^chore|^ci", group = "โš™๏ธ Miscellaneous Tasks" }, + { body = ".*security", group = "๐Ÿ›ก๏ธ Security" }, + { message = "^revert", group = "โ—€๏ธ Revert" }, +] +# filter out the commits that are not matched by commit parsers +filter_commits = false +# sort the tags topologically +topo_order = false +# sort the commits inside sections by oldest/newest order +sort_commits = "oldest" +tag_pattern = "v[0-9]+\\.[0-9]+\\.[0-9]+" diff --git a/error.go b/error.go new file mode 100644 index 0000000..9c2a142 --- /dev/null +++ b/error.go @@ -0,0 +1,96 @@ +package presenter + +import "errors" + +type ( + Code string + Entity string +) + +const ( + CodeConflict = Code("CONFLICT") + CodeNotFound = Code("NOT_FOUND") + CodePreconditionFailed = Code("PRECONDITION_FAILED") +) + +const ( + EntityCompany = Entity("COMPANY") + EntityConsumer = Entity("CONSUMER") + EntityAddress = Entity("ADDRESS") + EntityContactPerson = Entity("CONTACT_PERSON") + EntityActivity = Entity("ACTIVITY") + EntityPrice = Entity("PRICE") + EntityEmployee = Entity("EMPLOYEE") + EntityContract = Entity("CONTRACT") + EntityEmploymentAgreement = Entity("EMPLOYMENT_AGREEMENT") + EntityInvoiceBasis = Entity("INVOICE_BASIS") + EntityInvoice = Entity("INVOICE") + EntitySupplier = Entity("SUPPLIER") + EntitySupplierInvoice = Entity("SUPPLIER_INVOICE") + EntityTimeEntry = Entity("TIME_ENTRY") + EntityEntrySeries = Entity("ENTRY_SERIES") + EntityFiscalYear = Entity("FISCAL_YEAR") + EntityAccountClass = Entity("ACCOUNT_CLASS") + EntityAccountGroup = Entity("ACCOUNT_GROUP") + EntityAccount = Entity("ACCOUNT") + EntityTag = Entity("TAG") + EntityEntry = Entity("ENTRY") + EntityEntryBasis = Entity("ENTRY_BASIS") +) + +type CodedError struct { + Message string + Code Code + Entity Entity + Params map[string]string +} + +func NewCodedError(message string, code Code, entity Entity) CodedError { + return CodedError{ + Message: message, + Code: code, + Entity: entity, + Params: make(map[string]string), + } +} + +func (c CodedError) Error() string { + return c.Message +} + +func (c CodedError) Is(err error) bool { + var e CodedError + if errors.As(err, &e) { + return c.Code == e.Code && c.Entity == e.Entity && c.Message == e.Message && mapEqual(c.Params, e.Params) + } + return false +} + +func mapEqual(m1, m2 map[string]string) bool { + if len(m1) != len(m2) { + return false + } + for k1, v1 := range m1 { + v2, exists := m2[k1] + if !exists || v1 != v2 { + return false + } + } + return true +} + +func (c CodedError) WithParam(key, value string) CodedError { + params := make(map[string]string) + for k, v := range c.Params { + params[k] = v + } + params[key] = value + return CodedError{ + Message: c.Message, + Code: c.Code, + Entity: c.Entity, + Params: params, + } +} + +var _ error = CodedError{} diff --git a/error_test.go b/error_test.go new file mode 100644 index 0000000..cb8d991 --- /dev/null +++ b/error_test.go @@ -0,0 +1,24 @@ +package presenter_test + +import ( + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/require" + + "gitlab.com/unboundsoftware/shiny/presenter" +) + +func TestCodedError_ErrorIs(t *testing.T) { + require.True(t, errors.Is(ErrActivityNotFound, ErrActivityNotFound)) + require.False(t, errors.Is(ErrActivityNotFound, fmt.Errorf("other"))) + require.False(t, errors.Is(ErrActivityNotFound, ErrEntryNotFound)) + require.False(t, errors.Is(ErrActivityNotFound, ErrActivityNotFound.WithParam("some", "value"))) + require.False(t, errors.Is(ErrActivityNotFound.WithParam("some", "other"), ErrActivityNotFound.WithParam("some", "value"))) +} + +var ( + ErrActivityNotFound = presenter.NewCodedError("activity not found", presenter.CodeNotFound, presenter.EntityActivity) + ErrEntryNotFound = presenter.NewCodedError("entry not found", presenter.CodeNotFound, presenter.EntityEntry) +) diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..8da6b6e --- /dev/null +++ b/go.mod @@ -0,0 +1,17 @@ +module gitlab.com/unboundsoftware/shiny/presenter + +go 1.24.1 + +require ( + github.com/99designs/gqlgen v0.17.70 + github.com/stretchr/testify v1.10.0 + github.com/vektah/gqlparser/v2 v2.5.23 +) + +require ( + github.com/davecgh/go-spew v1.1.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/sosodev/duration v1.3.1 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5ed0467 --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/99designs/gqlgen v0.17.70 h1:xgLIgQuG+Q2L/AE9cW595CT7xCWCe/bpPIFGSfsGSGs= +github.com/99designs/gqlgen v0.17.70/go.mod h1:fvCiqQAu2VLhKXez2xFvLmE47QgAPf/KTPN5XQ4rsHQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883 h1:bvNMNQO63//z+xNgfBlViaCIJKLlCJ6/fmUseuG0wVQ= +github.com/andreyvit/diff v0.0.0-20170406064948-c7f18ee00883/go.mod h1:rCTlJbsFo29Kk6CurOXKm700vrz8f0KW0JNfpkRJY/8= +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/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +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/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= +github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= +github.com/sosodev/duration v1.3.1 h1:qtHBDMQ6lvMQsL15g4aopM4HEfOaYuhWBw3NPTtlqq4= +github.com/sosodev/duration v1.3.1/go.mod h1:RQIBBX0+fMLc/D9+Jb/fwvVmo0eZvDDEERAikUR6SDg= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= +github.com/vektah/gqlparser/v2 v2.5.23 h1:PurJ9wpgEVB7tty1seRUwkIDa/QH5RzkzraiKIjKLfA= +github.com/vektah/gqlparser/v2 v2.5.23/go.mod h1:D1/VCZtV3LPnQrcPBeR/q5jkSQIPti0uYCP/RI0gIeo= +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.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/presenter.go b/presenter.go new file mode 100644 index 0000000..d992dc0 --- /dev/null +++ b/presenter.go @@ -0,0 +1,58 @@ +package presenter + +import ( + "context" + "errors" + "fmt" + "log/slog" + + "github.com/99designs/gqlgen/graphql" + "github.com/vektah/gqlparser/v2/gqlerror" +) + +func New[C ~string, E ~string](logger *slog.Logger, codes []C, entities []E, internalErrorCode C) func(ctx context.Context, e error) *gqlerror.Error { + return func(ctx context.Context, e error) *gqlerror.Error { + err := graphql.DefaultErrorPresenter(ctx, e) + var codedError CodedError + if errors.As(e, &codedError) { + code := toModelErrorCode(codedError.Code, codes, internalErrorCode) + errorEntity := toModelErrorEntity(codedError.Entity, entities) + extensions := map[string]interface{}{"code": code} + if len(errorEntity) > 0 { + extensions["errorEntity"] = errorEntity + } + if len(codedError.Params) > 0 { + extensions["params"] = codedError.Params + } + err.Extensions = extensions + } else { + err.Extensions = map[string]interface{}{"code": internalErrorCode} + } + if logError(e) { + logger.ErrorContext(ctx, fmt.Sprintf("%v", e)) + } + return err + } +} + +func logError(err error) bool { + return !errors.Is(err, context.Canceled) +} + +func toModelErrorCode[C ~string](code Code, codes []C, internalErrorCode C) C { + for _, c := range codes { + if string(c) == string(code) { + return c + } + } + return internalErrorCode +} + +func toModelErrorEntity[E ~string](entity Entity, entities []E) E { + for _, e := range entities { + if string(e) == string(entity) { + return e + } + } + return "" +} diff --git a/presenter_test.go b/presenter_test.go new file mode 100644 index 0000000..036e4fa --- /dev/null +++ b/presenter_test.go @@ -0,0 +1,195 @@ +package presenter_test + +import ( + "bytes" + "context" + "fmt" + "log/slog" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/vektah/gqlparser/v2/gqlerror" + + "gitlab.com/unboundsoftware/shiny/presenter" +) + +func Test_globalErrorPresenter(t *testing.T) { + type args struct { + ctx context.Context + err error + } + tests := []struct { + name string + args args + want *gqlerror.Error + logged []string + }{ + { + name: "coded error", + args: args{ + ctx: context.Background(), + err: ErrEntryNotFound, + }, + want: wrap(ErrEntryNotFound, Code("NOT_FOUND"), ErrorEntity("ENTRY")), + logged: []string{"level=ERROR msg=\"entry not found\""}, + }, + { + name: "coded error with param", + args: args{ + ctx: context.Background(), + err: ErrEntryNotFound.WithParam("email", "jim@example.org").WithParam("other", "stuff"), + }, + want: wrap(ErrEntryNotFound.WithParam("email", "jim@example.org").WithParam("other", "stuff"), Code("NOT_FOUND"), ErrorEntity("ENTRY"), Param("email", "jim@example.org"), Param("other", "stuff")), + logged: []string{"level=ERROR msg=\"entry not found\""}, + }, + { + name: "unknown code", + args: args{ + ctx: context.Background(), + err: presenter.NewCodedError("unknown code", "UNKNOWN", presenter.EntityEntry), + }, + want: wrap(presenter.NewCodedError("unknown code", "UNKNOWN", presenter.EntityEntry), Code("INTERNAL"), ErrorEntity("ENTRY")), + logged: []string{"level=ERROR msg=\"unknown code\""}, + }, + { + name: "unknown entity", + args: args{ + ctx: context.Background(), + err: presenter.NewCodedError("unknown entity", presenter.CodeNotFound, "UNKNOWN"), + }, + want: wrap(presenter.NewCodedError("unknown entity", presenter.CodeNotFound, "UNKNOWN"), Code("NOT_FOUND")), + logged: []string{"level=ERROR msg=\"unknown entity\""}, + }, + { + name: "unhandled error", + args: args{ + ctx: context.Background(), + err: fmt.Errorf("unhandled"), + }, + want: wrap(fmt.Errorf("unhandled"), Code("INTERNAL")), + logged: []string{"level=ERROR msg=unhandled"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + logger := newMockLogger() + got := presenter.New(logger.Logger(), AllErrorCode, AllTestErrorEntity, ErrorCodeInternal)(tt.args.ctx, tt.args.err) + assert.Equal(t, tt.want, got) + logger.Check(t, tt.logged) + }) + } +} + +type ErrorOpt = func(err *gqlerror.Error) + +func Code(code ErrorCode) ErrorOpt { + return func(err *gqlerror.Error) { + err.Extensions["code"] = code + } +} + +func ErrorEntity(entity TestErrorEntity) ErrorOpt { + return func(err *gqlerror.Error) { + err.Extensions["errorEntity"] = entity + } +} + +func Param(key, value string) ErrorOpt { + return func(err *gqlerror.Error) { + if e, exists := err.Extensions["params"]; !exists { + params := make(map[string]string) + params[key] = value + err.Extensions["params"] = params + } else { + e.(map[string]string)[key] = value + } + } +} + +func wrap(err error, opts ...ErrorOpt) *gqlerror.Error { + e := gqlerror.WrapPath(nil, err) + e.Extensions = map[string]interface{}{} + for _, o := range opts { + o(e) + } + return e +} + +func newMockLogger() *mockLogger { + logged := &bytes.Buffer{} + + return &mockLogger{ + logged: logged, + logger: slog.New(slog.NewTextHandler(logged, &slog.HandlerOptions{ + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if a.Key == "time" { + return slog.Attr{} + } + return a + }, + })), + } +} + +type mockLogger struct { + logger *slog.Logger + logged *bytes.Buffer +} + +func (m *mockLogger) Logger() *slog.Logger { + return m.logger +} + +func (m *mockLogger) Check(t testing.TB, wantLogged []string) { + var gotLogged []string + if m.logged.String() != "" { + gotLogged = strings.Split(m.logged.String(), "\n") + gotLogged = gotLogged[:len(gotLogged)-1] + } + if len(wantLogged) == 0 { + assert.Empty(t, gotLogged) + return + } + assert.Equal(t, wantLogged, gotLogged) +} + +type ErrorCode string + +const ( + ErrorCodeNotFound ErrorCode = "NOT_FOUND" + ErrorCodeConflict ErrorCode = "CONFLICT" + ErrorCodePreconditionFailed ErrorCode = "PRECONDITION_FAILED" + ErrorCodeInternal ErrorCode = "INTERNAL" +) + +var AllErrorCode = []ErrorCode{ + ErrorCodeNotFound, + ErrorCodeConflict, + ErrorCodePreconditionFailed, + ErrorCodeInternal, +} + +type TestErrorEntity string + +const ( + TestErrorEntityEntrySeries TestErrorEntity = "ENTRY_SERIES" + TestErrorEntityFiscalYear TestErrorEntity = "FISCAL_YEAR" + TestErrorEntityAccountClass TestErrorEntity = "ACCOUNT_CLASS" + TestErrorEntityAccountGroup TestErrorEntity = "ACCOUNT_GROUP" + TestErrorEntityAccount TestErrorEntity = "ACCOUNT" + TestErrorEntityTag TestErrorEntity = "TAG" + TestErrorEntityEntry TestErrorEntity = "ENTRY" + TestErrorEntityEntryBasis TestErrorEntity = "ENTRY_BASIS" +) + +var AllTestErrorEntity = []TestErrorEntity{ + TestErrorEntityEntrySeries, + TestErrorEntityFiscalYear, + TestErrorEntityAccountClass, + TestErrorEntityAccountGroup, + TestErrorEntityAccount, + TestErrorEntityTag, + TestErrorEntityEntry, + TestErrorEntityEntryBasis, +} diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..5db72dd --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "config:recommended" + ] +}