feat: initial version
This commit is contained in:
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.idea
|
||||||
|
/release
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
include:
|
||||||
|
- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml'
|
||||||
|
- project: unboundsoftware/ci-templates
|
||||||
|
file: Defaults.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.4@sha256:40891f7b63de861049787c5262bff91906d30cbe221753840e276b3e785a66ab
|
||||||
|
|
||||||
|
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.4@sha256:40891f7b63de861049787c5262bff91906d30cbe221753840e276b3e785a66ab
|
||||||
|
script:
|
||||||
|
- go install golang.org/x/vuln/cmd/govulncheck@latest
|
||||||
|
- govulncheck ./...
|
||||||
@@ -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$
|
||||||
@@ -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/pagination
|
||||||
|
- 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.1.6
|
||||||
|
hooks:
|
||||||
|
- id: golangci-lint-full
|
||||||
|
- repo: https://github.com/gitleaks/gitleaks
|
||||||
|
rev: v8.27.0
|
||||||
|
hooks:
|
||||||
|
- id: gitleaks
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
# Pagination helper
|
||||||
|
|
||||||
|
Pagination helper
|
||||||
|
|
||||||
|
[](https://gitlab.com/unboundsoftware/pagination/commits/main)
|
||||||
|
[](https://codecov.io/gl/unboundsoftware:pagination)
|
||||||
+87
@@ -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 = """
|
||||||
|
<!-- generated by git-cliff -->
|
||||||
|
"""
|
||||||
|
# remove the leading and trailing s
|
||||||
|
trim = true
|
||||||
|
# postprocessors
|
||||||
|
postprocessors = [
|
||||||
|
# { pattern = '<REPO>', 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}](<REPO>/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 = "<!-- 0 -->🚀 Features" },
|
||||||
|
{ message = "^fix", group = "<!-- 1 -->🐛 Bug Fixes" },
|
||||||
|
{ message = "^doc", group = "<!-- 3 -->📚 Documentation" },
|
||||||
|
{ message = "^perf", group = "<!-- 4 -->⚡ Performance" },
|
||||||
|
{ message = "^refactor", group = "<!-- 2 -->🚜 Refactor" },
|
||||||
|
{ message = "^style", group = "<!-- 5 -->🎨 Styling" },
|
||||||
|
{ message = "^test", group = "<!-- 6 -->🧪 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 = "<!-- 7 -->⚙️ Miscellaneous Tasks" },
|
||||||
|
{ body = ".*security", group = "<!-- 8 -->🛡️ Security" },
|
||||||
|
{ message = "^revert", group = "<!-- 9 -->◀️ 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]+"
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
module gitlab.com/unboundsoftware/pagination
|
||||||
|
|
||||||
|
go 1.24.4
|
||||||
|
|
||||||
|
require github.com/stretchr/testify v1.10.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.1 // indirect
|
||||||
|
)
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
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/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
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=
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
package pagination
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/base64"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Validate(first *int, after *string, last *int, before *string) error {
|
||||||
|
if first != nil && last != nil {
|
||||||
|
return fmt.Errorf("only one of first and last can be provided")
|
||||||
|
}
|
||||||
|
if first != nil && *first < 0 {
|
||||||
|
return fmt.Errorf("first must be greater than 0")
|
||||||
|
}
|
||||||
|
if last != nil && *last < 0 {
|
||||||
|
return fmt.Errorf("last must be greater than 0")
|
||||||
|
}
|
||||||
|
if after != nil && len(*after) > 0 && before != nil && len(*before) > 0 {
|
||||||
|
return fmt.Errorf("only one of after and before can be provided")
|
||||||
|
}
|
||||||
|
if ValidateCursor(after) != nil {
|
||||||
|
return fmt.Errorf("after is not a valid cursor")
|
||||||
|
}
|
||||||
|
if ValidateCursor(before) != nil {
|
||||||
|
return fmt.Errorf("before is not a valid cursor")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateCursor(cursor *string) error {
|
||||||
|
_, err := DecodeCursor(cursor)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeCursor(cursor *string) (string, error) {
|
||||||
|
if cursor == nil {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
b64, err := base64.StdEncoding.DecodeString(*cursor)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return string(b64), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func EncodeCursor(cursor string) string {
|
||||||
|
return base64.StdEncoding.EncodeToString([]byte(cursor))
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetPage[T any](items []T, first *int, after *string, last *int, before *string, max int, fn func(T) string) ([]T, PageInfo) {
|
||||||
|
if items == nil {
|
||||||
|
return nil, PageInfo{}
|
||||||
|
}
|
||||||
|
tmp := min(max, len(items))
|
||||||
|
sIx := 0
|
||||||
|
eIx := sIx + tmp
|
||||||
|
if first != nil {
|
||||||
|
tmp = *first
|
||||||
|
eIx = sIx + tmp
|
||||||
|
} else if last != nil {
|
||||||
|
tmp = *last
|
||||||
|
sIx = len(items) - tmp
|
||||||
|
eIx = len(items)
|
||||||
|
}
|
||||||
|
if cursor, err := DecodeCursor(after); err == nil && cursor != "" {
|
||||||
|
idx := slices.IndexFunc(items, func(item T) bool {
|
||||||
|
return fn(item) == cursor
|
||||||
|
})
|
||||||
|
idx = idx + 1
|
||||||
|
if idx+tmp >= len(items) {
|
||||||
|
tmp = len(items) - idx
|
||||||
|
}
|
||||||
|
sIx = idx
|
||||||
|
eIx = idx + tmp
|
||||||
|
} else if cursor, err := DecodeCursor(before); err == nil && cursor != "" {
|
||||||
|
idx := slices.IndexFunc(items, func(item T) bool {
|
||||||
|
return fn(item) == cursor
|
||||||
|
})
|
||||||
|
f := idx - tmp
|
||||||
|
if f < 0 {
|
||||||
|
f = 0
|
||||||
|
}
|
||||||
|
sIx = f
|
||||||
|
eIx = idx
|
||||||
|
}
|
||||||
|
page := items[sIx:eIx]
|
||||||
|
return page, PageInfo{
|
||||||
|
StartCursor: ptr(EncodeCursor(fn(page[0]))),
|
||||||
|
HasNextPage: eIx < len(items),
|
||||||
|
HasPreviousPage: sIx > 0,
|
||||||
|
EndCursor: ptr(EncodeCursor(fn(page[len(page)-1]))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ptr[T any](v T) *T {
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
type PageInfo struct {
|
||||||
|
StartCursor *string
|
||||||
|
HasNextPage bool
|
||||||
|
HasPreviousPage bool
|
||||||
|
EndCursor *string
|
||||||
|
}
|
||||||
@@ -0,0 +1,105 @@
|
|||||||
|
package pagination
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestGetPage(t *testing.T) {
|
||||||
|
type args[T any] struct {
|
||||||
|
items []T
|
||||||
|
first *int
|
||||||
|
after *string
|
||||||
|
last *int
|
||||||
|
before *string
|
||||||
|
max int
|
||||||
|
fn func(T) string
|
||||||
|
}
|
||||||
|
type testCase[T any] struct {
|
||||||
|
name string
|
||||||
|
args args[T]
|
||||||
|
wantItems []T
|
||||||
|
wantPageInfo PageInfo
|
||||||
|
}
|
||||||
|
tests := []testCase[string]{
|
||||||
|
{
|
||||||
|
name: "empty",
|
||||||
|
args: args[string]{max: 10},
|
||||||
|
wantItems: nil,
|
||||||
|
wantPageInfo: PageInfo{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "first 2",
|
||||||
|
args: args[string]{items: []string{"1", "2", "3"}, first: ptr(2), max: 10, fn: func(s string) string { return s }},
|
||||||
|
wantItems: []string{"1", "2"},
|
||||||
|
wantPageInfo: PageInfo{
|
||||||
|
StartCursor: ptr("MQ=="),
|
||||||
|
HasNextPage: true,
|
||||||
|
EndCursor: ptr("Mg=="),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "after 2",
|
||||||
|
args: args[string]{items: []string{"1", "2", "3", "4"}, first: ptr(2), after: ptr("Mg=="), max: 10, fn: func(s string) string { return s }},
|
||||||
|
wantItems: []string{"3", "4"},
|
||||||
|
wantPageInfo: PageInfo{
|
||||||
|
StartCursor: ptr("Mw=="),
|
||||||
|
HasNextPage: false,
|
||||||
|
HasPreviousPage: true,
|
||||||
|
EndCursor: ptr("NA=="),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "end of items",
|
||||||
|
args: args[string]{items: []string{"1", "2", "3"}, first: ptr(2), after: ptr("Mg=="), max: 10, fn: func(s string) string { return s }},
|
||||||
|
wantItems: []string{"3"},
|
||||||
|
wantPageInfo: PageInfo{
|
||||||
|
StartCursor: ptr("Mw=="),
|
||||||
|
HasNextPage: false,
|
||||||
|
HasPreviousPage: true,
|
||||||
|
EndCursor: ptr("Mw=="),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "last 2",
|
||||||
|
args: args[string]{items: []string{"1", "2", "3"}, last: ptr(2), max: 10, fn: func(s string) string { return s }},
|
||||||
|
wantItems: []string{"2", "3"},
|
||||||
|
wantPageInfo: PageInfo{
|
||||||
|
StartCursor: ptr("Mg=="),
|
||||||
|
HasNextPage: false,
|
||||||
|
HasPreviousPage: true,
|
||||||
|
EndCursor: ptr("Mw=="),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "before 3",
|
||||||
|
args: args[string]{items: []string{"1", "2", "3"}, last: ptr(2), before: ptr("Mw=="), max: 10, fn: func(s string) string { return s }},
|
||||||
|
wantItems: []string{"1", "2"},
|
||||||
|
wantPageInfo: PageInfo{
|
||||||
|
StartCursor: ptr("MQ=="),
|
||||||
|
HasNextPage: true,
|
||||||
|
HasPreviousPage: false,
|
||||||
|
EndCursor: ptr("Mg=="),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "before 2",
|
||||||
|
args: args[string]{items: []string{"1", "2", "3"}, last: ptr(2), before: ptr("Mg=="), max: 10, fn: func(s string) string { return s }},
|
||||||
|
wantItems: []string{"1"},
|
||||||
|
wantPageInfo: PageInfo{
|
||||||
|
StartCursor: ptr("MQ=="),
|
||||||
|
HasNextPage: true,
|
||||||
|
HasPreviousPage: false,
|
||||||
|
EndCursor: ptr("MQ=="),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, got1 := GetPage(tt.args.items, tt.args.first, tt.args.after, tt.args.last, tt.args.before, tt.args.max, tt.args.fn)
|
||||||
|
assert.Equalf(t, tt.wantItems, got, "GetPage(%v, %v, %v, %v, %v, %v)", tt.args.items, tt.args.first, tt.args.after, tt.args.last, tt.args.before, tt.args.max)
|
||||||
|
assert.Equalf(t, tt.wantPageInfo, got1, "GetPage(%v, %v, %v, %v, %v, %v)", tt.args.items, tt.args.first, tt.args.after, tt.args.last, tt.args.before, tt.args.max)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||||
|
"extends": [
|
||||||
|
"config:recommended"
|
||||||
|
]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user