├── .github └── workflows │ ├── bump.yml │ └── ci.yml ├── Bumpfile ├── Dockerfile ├── LICENSE ├── Makefile ├── README.md ├── _dev ├── filtersmarkdown.go └── mdsh.go ├── action ├── action.yml └── go │ └── action.yml ├── cmd └── bump │ ├── main.go │ └── main_test.sh ├── examples ├── Bumpfile └── Dockerfile ├── go.mod ├── go.sum └── internal ├── bump ├── fileset.go ├── os.go └── update.go ├── cli ├── cli.go ├── cli_test.go └── testdata │ ├── bumpfile_and_files │ ├── bumpfile_different_name │ ├── bumpfile_err_in_included │ ├── bumpfile_glob │ ├── bumpfile_match_line_end │ ├── bumpfile_no_update │ ├── bumpfile_only_glob │ ├── dotstar_match │ ├── err_has_no_config_or_matches │ ├── err_has_no_current_version_matches │ ├── err_invalid_current_version_regexp │ ├── err_invalid_pipeline │ ├── err_name_already_used_at │ ├── err_no_version_found │ ├── err_overlapping_matches │ ├── err_regexp_must_have_one_submatch1 │ ├── err_regexp_must_have_one_submatch2 │ ├── err_run_not_defined │ ├── err_run_unknown_config │ ├── files_ignore_bumpfile │ ├── help │ ├── help_filter │ ├── multi_currents │ ├── multi_versions │ ├── option_after_command │ ├── parsing │ ├── pipeline │ ├── select_excluded │ ├── select_included │ ├── simple_after │ ├── simple_after_skip │ ├── simple_check │ ├── simple_command │ ├── simple_current │ ├── simple_diff │ ├── simple_list │ ├── simple_skip_command │ ├── simple_update │ └── version ├── deepequal ├── deepequal.go └── deepequal_test.go ├── dockerv2 ├── dockerv2.go └── dockerv2_test.go ├── filter ├── all │ └── all.go ├── depsdev │ └── depsdev.go ├── docker │ └── docker.go ├── err │ └── err.go ├── fetch │ └── fetch.go ├── filter.go ├── git │ └── git.go ├── gitrefs │ └── gitrefs.go ├── key │ └── key.go ├── re │ └── re.go ├── semver │ └── semver.go ├── sort │ └── sort.go ├── static │ └── static.go ├── svn │ └── svn.go ├── version.go └── version_test.go ├── github ├── action.go ├── action_test.go ├── api.go └── api_test.go ├── githubaction ├── githubaction.go └── githubaction_test.go ├── gitrefs ├── pktline │ ├── pktline.go │ └── pktline_test.go ├── refs.go └── refs_test.go ├── lexer ├── lexer.go └── lexer_test.go ├── locline ├── locline.go └── locline_test.go ├── pipeline ├── pipeline.go ├── pipeline_test.go └── testdata │ ├── depsdev │ ├── docker │ ├── err │ ├── fetch │ ├── git │ ├── gitrefs │ ├── key │ ├── re │ ├── semver │ ├── sort │ ├── static │ └── svn ├── rereplacer ├── rereplacer.go └── rereplacer_test.go └── slicex └── slicex.go /.github/workflows/bump.yml: -------------------------------------------------------------------------------- 1 | name: 'Automatic version updates' 2 | 3 | on: 4 | schedule: 5 | # minute hour dom month dow (UTC) 6 | - cron: '0 16 * * *' 7 | # enable manual trigger of version updates 8 | workflow_dispatch: 9 | jobs: 10 | bump: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@master 14 | - uses: wader/bump/action/go@master 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.BUMP_TOKEN }} 17 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: ci 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | # enable manual trigger 9 | workflow_dispatch: 10 | 11 | jobs: 12 | docker: 13 | runs-on: ubuntu-latest 14 | steps: 15 | - 16 | name: Checkout 17 | uses: actions/checkout@v2 18 | - 19 | name: Docker meta 20 | id: docker_meta 21 | uses: crazy-max/ghaction-docker-meta@v1 22 | with: 23 | images: mwader/bump # list of Docker images to use as base name for tags 24 | tag-sha: true # add git short SHA as Docker tag 25 | - 26 | name: Set up QEMU 27 | uses: docker/setup-qemu-action@v1 28 | - 29 | name: Set up Docker Buildx 30 | uses: docker/setup-buildx-action@v1 31 | - 32 | name: Login to DockerHub 33 | if: github.event_name != 'pull_request' 34 | uses: docker/login-action@v1 35 | with: 36 | username: ${{ secrets.DOCKERHUB_USERNAME }} 37 | password: ${{ secrets.DOCKERHUB_TOKEN }} 38 | - 39 | name: Build and push bump:latest 40 | uses: docker/build-push-action@v2 41 | with: 42 | context: . 43 | file: ./Dockerfile 44 | platforms: linux/amd64 45 | push: ${{ github.event_name != 'pull_request' }} 46 | tags: mwader/bump:latest 47 | labels: ${{ steps.docker_meta.outputs.labels }} 48 | target: bump-base 49 | 50 | - 51 | name: Build and push bump:go 52 | uses: docker/build-push-action@v2 53 | with: 54 | context: . 55 | file: ./Dockerfile 56 | platforms: linux/amd64 57 | push: ${{ github.event_name != 'pull_request' }} 58 | tags: mwader/bump:go 59 | labels: ${{ steps.docker_meta.outputs.labels }} 60 | target: bump-go 61 | -------------------------------------------------------------------------------- /Bumpfile: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | go.mod 3 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | # bump: golang /FROM golang:([\d.]+)/ docker:golang|^1 2 | # bump: golang link "Release notes" https://golang.org/doc/devel/release.html 3 | FROM golang:1.24.3-bookworm AS builder 4 | 5 | # patch is used by cmd/bump/main_test.sh to test diff 6 | RUN apt update && apt install -y patch 7 | 8 | ARG GO111MODULE=on 9 | WORKDIR $GOPATH/src/bump 10 | COPY go.mod go.sum ./ 11 | RUN go mod download 12 | COPY internal internal 13 | COPY cmd cmd 14 | RUN go test -v -cover -race ./... 15 | RUN CGO_ENABLED=0 go build -o /bump -tags netgo -ldflags '-extldflags "-static"' ./cmd/bump 16 | RUN cmd/bump/main_test.sh /bump 17 | 18 | # bump: alpine /FROM alpine:([\d.]+)/ docker:alpine|^3 19 | # bump: alpine link "Release notes" https://alpinelinux.org/posts/Alpine-$LATEST-released.html 20 | FROM alpine:3.22.0 AS bump-base 21 | # git is used by github action code 22 | # curl for convenience 23 | RUN apk add --no-cache \ 24 | git \ 25 | curl 26 | COPY --from=builder /bump /usr/local/bin 27 | RUN ["/usr/local/bin/bump", "version"] 28 | RUN ["/usr/local/bin/bump", "pipeline", "git:https://github.com/torvalds/linux.git|*"] 29 | ENTRYPOINT ["/usr/local/bin/bump"] 30 | 31 | FROM bump-base AS bump-go 32 | RUN apk add --no-cache go 33 | 34 | FROM bump-base 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2021 Mattias Wadman 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of 4 | this software and associated documentation files (the "Software"), to deal in 5 | the Software without restriction, including without limitation the rights to 6 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies 7 | of the Software, and to permit persons to whom the Software is furnished to do 8 | so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | all: README.md test 2 | 3 | test: 4 | go test -v -cover -race ./... 5 | 6 | cover: 7 | go test -cover -race -coverpkg=./... -coverprofile=cover.out ./... 8 | go tool cover -func=cover.out 9 | 10 | lint: 11 | golangci-lint run 12 | 13 | README.md: 14 | $(eval REPODIR=$(shell pwd)) 15 | $(eval TEMPDIR=$(shell mktemp -d)) 16 | cp -a examples/* "${TEMPDIR}" 17 | go build -o "${TEMPDIR}/bump" cmd/bump/main.go 18 | go build -o "${TEMPDIR}/filtersmarkdown" _dev/filtersmarkdown.go 19 | cd "${TEMPDIR}" ; \ 20 | cat "${REPODIR}/README.md" | PATH="${TEMPDIR}:${PATH}" go run "${REPODIR}/_dev/mdsh.go" > "${TEMPDIR}/README.md" 21 | mv "${TEMPDIR}/README.md" "${REPODIR}/README.md" 22 | rm -rf "${TEMPDIR}" 23 | 24 | actions: 25 | for i in $(shell cd action && ls -1 | grep -v .yml) ; do \ 26 | cat action/action.yml | sed -E "s/image: .*/image: 'docker:\/\/mwader\/bump:$$i'/" > action/$$i/action.yml ; \ 27 | done 28 | 29 | .PHONY: README.md test cover lint 30 | -------------------------------------------------------------------------------- /_dev/filtersmarkdown.go: -------------------------------------------------------------------------------- 1 | // Convert filter help texts into markdown 2 | package main 3 | 4 | import ( 5 | "bytes" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strings" 10 | 11 | "github.com/wader/bump/internal/filter" 12 | "github.com/wader/bump/internal/filter/all" 13 | "github.com/wader/bump/internal/pipeline" 14 | ) 15 | 16 | func main() { 17 | listBuf := &bytes.Buffer{} 18 | filtersBuf := &bytes.Buffer{} 19 | 20 | for _, nf := range all.Filters() { 21 | syntax, description, examples := filter.ParseHelp(nf.Help) 22 | 23 | var syntaxMDParts []string 24 | for i, s := range syntax { 25 | delim := "" 26 | if i < len(syntax)-2 { 27 | delim = ", " 28 | } else if i < len(syntax)-1 { 29 | delim = " or " 30 | } 31 | syntaxMDParts = append(syntaxMDParts, fmt.Sprintf("`%s`%s", s, delim)) 32 | } 33 | var examplesMDParts []string 34 | for _, e := range examples { 35 | if strings.HasPrefix(e, "#") { 36 | examplesMDParts = append(examplesMDParts, e) 37 | continue 38 | } 39 | 40 | examplesMDParts = append(examplesMDParts, fmt.Sprintf("$ bump pipeline '%s'", e)) 41 | 42 | p, err := pipeline.New(all.Filters(), e) 43 | if err != nil { 44 | panic(err.Error() + ":" + e) 45 | } 46 | 47 | v, err := p.Value(nil) 48 | if err != nil { 49 | examplesMDParts = append(examplesMDParts, err.Error()) 50 | } else { 51 | examplesMDParts = append(examplesMDParts, v) 52 | } 53 | } 54 | 55 | replacer := strings.NewReplacer( 56 | "{{name}}", nf.Name, 57 | "{{syntax}}", strings.Join(syntaxMDParts, ""), 58 | "{{desc}}", description, 59 | "{{examples}}", strings.Join(examplesMDParts, "\n"), 60 | "{{block}}", "```", 61 | ) 62 | 63 | fmt.Fprintf(listBuf, replacer.Replace(` 64 | [{{name}}](#filter-{{name}}) {{syntax}}
65 | `[1:])) 66 | 67 | fmt.Fprintf(filtersBuf, replacer.Replace(` 68 | ### {{name}} 69 | 70 | {{syntax}} 71 | 72 | {{desc}} 73 | 74 | {{block}}sh 75 | {{examples}} 76 | {{block}} 77 | 78 | `[1:])) 79 | } 80 | 81 | io.Copy(os.Stdout, listBuf) 82 | io.Copy(os.Stdout, filtersBuf) 83 | } 84 | -------------------------------------------------------------------------------- /_dev/mdsh.go: -------------------------------------------------------------------------------- 1 | // Takes markdown on stdin and outputs same markdown with shell commands expanded 2 | // 3 | // ```sh (exec) 4 | // # comment 5 | // $ echo test 6 | // ``` 7 | // Becomes: 8 | // ```sh (exec) 9 | // # comment 10 | // $ echo test 11 | // test 12 | // ``` 13 | // 14 | // [echo test]: sh-start 15 | // 16 | // anything here 17 | // 18 | // [#]: sh-end 19 | // Becomes: 20 | // [echo test]: sh-start 21 | // 22 | // test 23 | // 24 | // [#]: sh-end 25 | package main 26 | 27 | import ( 28 | "bufio" 29 | "fmt" 30 | "os" 31 | "os/exec" 32 | "regexp" 33 | "strings" 34 | ) 35 | 36 | func main() { 37 | scanner := bufio.NewScanner(os.Stdin) 38 | nextLine := func() (string, bool) { 39 | ok := scanner.Scan() 40 | return scanner.Text(), ok 41 | } 42 | 43 | shStartRe := regexp.MustCompile(`\[(.*)\]: sh-start`) 44 | shEnd := "[#]: sh-end" 45 | 46 | for { 47 | l, ok := nextLine() 48 | if !ok { 49 | break 50 | } 51 | 52 | if l == "```sh (exec)" { 53 | fmt.Println(l) 54 | for { 55 | l, ok := nextLine() 56 | if !ok || l == "```" { 57 | fmt.Println(l) 58 | break 59 | } 60 | if strings.HasPrefix(l, "$") { 61 | fmt.Println(l) 62 | cmd := exec.Command("sh", "-c", l[1:]) 63 | o, _ := cmd.CombinedOutput() 64 | fmt.Print(string(o)) 65 | } else if strings.HasPrefix(l, "#") { 66 | fmt.Println(l) 67 | } 68 | } 69 | } else if sm := shStartRe.FindStringSubmatch(l); sm != nil { 70 | fmt.Println(l) 71 | fmt.Println() 72 | for { 73 | l, ok := nextLine() 74 | if !ok || l == shEnd { 75 | break 76 | } 77 | } 78 | cmd := exec.Command("sh", "-c", sm[1]) 79 | o, _ := cmd.CombinedOutput() 80 | fmt.Print(string(o)) 81 | fmt.Println() 82 | fmt.Println(shEnd) 83 | } else { 84 | fmt.Println(l) 85 | } 86 | } 87 | } 88 | -------------------------------------------------------------------------------- /action/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Bump versions' 2 | description: 'Use bump to create version update pull requests' 3 | author: 'Mattias Wadman' 4 | branding: 5 | icon: 'chevrons-up' 6 | color: 'green' 7 | runs: 8 | using: 'docker' 9 | image: 'docker://mwader/bump' 10 | inputs: 11 | bumpfile: 12 | description: 'Bumpfile to read' 13 | required: false 14 | default: 'Bumpfile' 15 | files: 16 | description: 'Files with embedded configuration or versions to update' 17 | required: false 18 | default: '' 19 | title_template: 20 | description: 'Commit and pull request title template' 21 | required: false 22 | default: 'Update {{.Name}} to {{.Latest}} from {{join .Current ", "}}' 23 | commit_body_template: 24 | description: 'Commit body template' 25 | required: false 26 | default: '{{range .Messages}}{{.}}{{"\n\n"}}{{end}}{{range .Links}}{{.Title}} {{.URL}}{{"\n"}}{{end}}' 27 | pr_body_template: 28 | description: 'Pull request body template' 29 | required: false 30 | default: '{{range .Messages}}{{.}}{{"\n\n"}}{{end}}{{range .Links}}[{{.Title}}]({{.URL}}) {{"\n"}}{{end}}' 31 | branch_template: 32 | description: 'Pull requests branch name template' 33 | required: false 34 | default: 'bump-{{.Name}}-{{.Latest}}' 35 | user_name: 36 | description: 'Commit user name' 37 | required: false 38 | default: 'bump' 39 | user_email: 40 | description: 'Commit user email' 41 | required: false 42 | default: 'bump-action@github' 43 | -------------------------------------------------------------------------------- /action/go/action.yml: -------------------------------------------------------------------------------- 1 | name: 'Bump versions' 2 | description: 'Use bump to create version update pull requests' 3 | author: 'Mattias Wadman' 4 | branding: 5 | icon: 'chevrons-up' 6 | color: 'green' 7 | runs: 8 | using: 'docker' 9 | image: 'docker://mwader/bump:go' 10 | inputs: 11 | bumpfile: 12 | description: 'Bumpfile to read' 13 | required: false 14 | default: 'Bumpfile' 15 | files: 16 | description: 'Files with embedded configuration or versions to update' 17 | required: false 18 | default: '' 19 | title_template: 20 | description: 'Commit and pull request title template' 21 | required: false 22 | default: 'Update {{.Name}} to {{.Latest}} from {{join .Current ", "}}' 23 | commit_body_template: 24 | description: 'Commit body template' 25 | required: false 26 | default: '{{range .Messages}}{{.}}{{"\n\n"}}{{end}}{{range .Links}}{{.Title}} {{.URL}}{{"\n"}}{{end}}' 27 | pr_body_template: 28 | description: 'Pull request body template' 29 | required: false 30 | default: '{{range .Messages}}{{.}}{{"\n\n"}}{{end}}{{range .Links}}[{{.Title}}]({{.URL}}) {{"\n"}}{{end}}' 31 | branch_template: 32 | description: 'Pull requests branch name template' 33 | required: false 34 | default: 'bump-{{.Name}}-{{.Latest}}' 35 | user_name: 36 | description: 'Commit user name' 37 | required: false 38 | default: 'bump' 39 | user_email: 40 | description: 'Commit user email' 41 | required: false 42 | default: 'bump-action@github' 43 | -------------------------------------------------------------------------------- /cmd/bump/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "os" 6 | "os/exec" 7 | "path/filepath" 8 | 9 | "github.com/wader/bump/internal/cli" 10 | "github.com/wader/bump/internal/github" 11 | "github.com/wader/bump/internal/githubaction" 12 | ) 13 | 14 | var version = "dev" 15 | 16 | // OS implements bump.OS using os 17 | type OS struct{} 18 | 19 | // Args returns os args 20 | func (OS) Args() []string { 21 | return os.Args 22 | } 23 | 24 | // Getenv return env using os env 25 | func (OS) Getenv(name string) string { 26 | return os.Getenv(name) 27 | } 28 | 29 | // Stdout returns os stdout 30 | func (OS) Stdout() io.Writer { 31 | return os.Stdout 32 | } 33 | 34 | // Stderr returns os stderr 35 | func (OS) Stderr() io.Writer { 36 | return os.Stderr 37 | } 38 | 39 | // WriteFile writes os file 40 | func (OS) WriteFile(filename string, data []byte) error { 41 | return os.WriteFile(filename, data, 0644) 42 | } 43 | 44 | // ReadFile read os file 45 | func (OS) ReadFile(filename string) ([]byte, error) { 46 | return os.ReadFile(filename) 47 | } 48 | 49 | // Glob returns list of matched os files 50 | func (OS) Glob(pattern string) ([]string, error) { 51 | return filepath.Glob(pattern) 52 | } 53 | 54 | // Shell runs a sh command 55 | func (o OS) Shell(cmd string, env []string) error { 56 | // TODO: non-sh OS:s? 57 | return o.Exec([]string{"sh", "-c", cmd}, env) 58 | } 59 | 60 | // Exec a command (not thru shell) 61 | func (OS) Exec(args []string, env []string) error { 62 | // TODO: non-sh OS:s? 63 | c := exec.Command(args[0], args[1:]...) 64 | c.Stdout = os.Stdout 65 | c.Stderr = os.Stderr 66 | c.Env = append(os.Environ(), env...) 67 | return c.Run() 68 | } 69 | 70 | func main() { 71 | o := OS{} 72 | var r interface{ Run() []error } 73 | 74 | if github.IsActionEnv(o.Getenv) { 75 | r = githubaction.Command{ 76 | Version: version, 77 | OS: o, 78 | } 79 | } else { 80 | r = cli.Command{ 81 | Version: version, 82 | OS: o, 83 | } 84 | } 85 | 86 | if errs := r.Run(); errs != nil { 87 | os.Exit(1) 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /cmd/bump/main_test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # path to bump binary to test 4 | BUMP="$1" 5 | 6 | function test_update() { 7 | local r=0 8 | local TESTDIR=$(mktemp -d) 9 | cd "$TESTDIR" 10 | 11 | cat <a 12 | bump: name /name: (\d+)/ static:456 13 | EOF 14 | 15 | cat <b 16 | name: 123 17 | EOF 18 | 19 | "$BUMP" update a b >stdout 2>stderr 20 | local actual_exit_code=$? 21 | local actual_b=$(cat b) 22 | local actual_stdout=$(cat stdout) 23 | local actual_stderr=$(cat stderr) 24 | local expected_exit_code=0 25 | local expected_b="name: 456" 26 | local expected_stdout="" 27 | local expected_stderr="" 28 | 29 | if [ "$expected_exit_code" != "$actual_exit_code" ]; then 30 | echo "exit_code $expected_exit_code got $actual_exit_code" 31 | r=1 32 | fi 33 | if [ "$expected_b" != "$actual_b" ]; then 34 | echo "expected_b $expected_b got $actual_b" 35 | r=1 36 | fi 37 | if [ "$expected_stdout" != "$actual_stdout" ]; then 38 | echo "stdout $expected_stdout got $actual_stdout" 39 | r=1 40 | fi 41 | if [ "$expected_stderr" != "$actual_stderr" ]; then 42 | echo "stderr $expected_stderr got $actual_stderr" 43 | r=1 44 | fi 45 | 46 | rm -rf "$TESTDIR" 47 | 48 | return $r 49 | } 50 | 51 | function test_diff() { 52 | local r=0 53 | local TESTDIR=$(mktemp -d) 54 | cd "$TESTDIR" 55 | 56 | cat <a 57 | bump: name /name: (\d+)/ static:456 58 | EOF 59 | 60 | cat <b 61 | name: 123 62 | EOF 63 | 64 | "$BUMP" diff a b >stdout 2>stderr 65 | local actual_exit_code=$? 66 | 67 | cat stdout | patch -p0 68 | 69 | local actual_b=$(cat b) 70 | local actual_stdout=$(cat stdout) 71 | local actual_stderr=$(cat stderr) 72 | local expected_exit_code=0 73 | local expected_b="name: 456" 74 | local expected_stderr="" 75 | 76 | if [ "$expected_exit_code" != "$actual_exit_code" ]; then 77 | echo "exit_code $expected_exit_code got $actual_exit_code" 78 | r=1 79 | fi 80 | if [ "$expected_b" != "$actual_b" ]; then 81 | echo "expected_b $expected_b got $actual_b" 82 | r=1 83 | fi 84 | if [ "$expected_stderr" != "$actual_stderr" ]; then 85 | echo "stderr $expected_stderr got $actual_stderr" 86 | r=1 87 | fi 88 | 89 | rm -rf "$TESTDIR" 90 | 91 | return $r 92 | } 93 | 94 | function test_error() { 95 | local r=0 96 | local TESTDIR=$(mktemp -d) 97 | cd "$TESTDIR" 98 | 99 | cat <a 100 | bump: name /name: (\d+)/ static:456 101 | EOF 102 | 103 | "$BUMP" update a >stdout 2>stderr 104 | local actual_exit_code=$? 105 | local actual_stdout=$(cat stdout) 106 | local actual_stderr=$(cat stderr) 107 | local expected_exit_code=1 108 | local expected_stdout="" 109 | local expected_stderr="a:1: name has no current version matches" 110 | 111 | if [ "$expected_exit_code" != "$actual_exit_code" ]; then 112 | echo "exit_code $expected_exit_code got $actual_exit_code" 113 | r=1 114 | fi 115 | if [ "$expected_stdout" != "$actual_stdout" ]; then 116 | echo "stdout $expected_stdout got $actual_stdout" 117 | r=1 118 | fi 119 | if [ "$expected_stderr" != "$actual_stderr" ]; then 120 | echo "stderr $expected_stderr got $actual_stderr" 121 | r=1 122 | fi 123 | 124 | rm -rf "$TESTDIR" 125 | 126 | return $r 127 | } 128 | 129 | r=0 130 | echo "test_update" 131 | test_update || r=1 132 | echo "test_error" 133 | test_error || r=1 134 | echo "test_diff" 135 | test_diff || r=1 136 | 137 | exit $r 138 | -------------------------------------------------------------------------------- /examples/Bumpfile: -------------------------------------------------------------------------------- 1 | alpine /FROM alpine:([\d.]+)/ docker:alpine|^3 2 | alpine link "Release notes" https://alpinelinux.org/posts/Alpine-$LATEST-released.html 3 | Dockerfile 4 | -------------------------------------------------------------------------------- /examples/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM alpine:3.9.2 AS builder 2 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/wader/bump 2 | 3 | go 1.21 4 | 5 | toolchain go1.22.5 6 | 7 | require ( 8 | // bump: semver /github.com\/Masterminds\/semver\/v3 v(.*)/ git:https://github.com/Masterminds/semver|^3 9 | // bump: semver command go get -d github.com/Masterminds/semver/v3@v$LATEST && go mod tidy 10 | github.com/Masterminds/semver/v3 v3.3.1 11 | // bump: go-difflib /github.com\/pmezard\/go-difflib v(.*)/ git:https://github.com/pmezard/go-difflib|^1 12 | // bump: go-difflib command go get -d github.com/pmezard/go-difflib@v$LATEST && go mod tidy 13 | github.com/pmezard/go-difflib v1.0.0 14 | ) 15 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Masterminds/semver/v3 v3.3.1 h1:QtNSWtVZ3nBfk8mAOu/B6v7FMJ+NHTIgUPi7rj+4nv4= 2 | github.com/Masterminds/semver/v3 v3.3.1/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= 3 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 4 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 5 | -------------------------------------------------------------------------------- /internal/bump/fileset.go: -------------------------------------------------------------------------------- 1 | // testing is done thru cli tests 2 | 3 | package bump 4 | 5 | import ( 6 | "fmt" 7 | "regexp" 8 | "strings" 9 | "sync" 10 | "time" 11 | 12 | "github.com/wader/bump/internal/filter" 13 | "github.com/wader/bump/internal/lexer" 14 | "github.com/wader/bump/internal/locline" 15 | "github.com/wader/bump/internal/pipeline" 16 | "github.com/wader/bump/internal/rereplacer" 17 | ) 18 | 19 | var bumpRe = regexp.MustCompile(`bump:\s*(\w.*)`) 20 | 21 | type CheckLink struct { 22 | Title string 23 | URL string 24 | File *File 25 | LineNr int 26 | } 27 | 28 | type CheckShell struct { 29 | Cmd string 30 | File *File 31 | LineNr int 32 | } 33 | 34 | type CheckMessage struct { 35 | Message string 36 | File *File 37 | LineNr int 38 | } 39 | 40 | // Check is a bump config line 41 | type Check struct { 42 | File *File 43 | Name string 44 | 45 | // bump: // 46 | PipelineLineNr int 47 | CurrentREStr string 48 | CurrentRE *regexp.Regexp 49 | Pipeline pipeline.Pipeline 50 | PipelineDuration time.Duration 51 | 52 | // bump: command ... 53 | CommandShells []CheckShell 54 | // bump: after ... 55 | AfterShells []CheckShell 56 | // bump: message <url> 57 | Messages []CheckMessage 58 | // bump: <name> link <title> <url> 59 | Links []CheckLink 60 | 61 | Latest string 62 | Currents []Current 63 | } 64 | 65 | // HasUpdate returns true if any current version does not match Latest 66 | func (c *Check) HasUpdate() bool { 67 | for _, cur := range c.Currents { 68 | if cur.Version != c.Latest { 69 | return true 70 | } 71 | } 72 | return false 73 | } 74 | 75 | // Current version found in a file 76 | type Current struct { 77 | File *File 78 | LineNr int 79 | Range [2]int 80 | Version string 81 | } 82 | 83 | func (c *Check) String() string { 84 | return fmt.Sprintf("%s /%s/ %s", c.Name, c.CurrentREStr, c.Pipeline) 85 | } 86 | 87 | // FileSet is a set of File:s, filters and checks found in files 88 | type FileSet struct { 89 | Files []*File 90 | Filters []filter.NamedFilter 91 | Checks []*Check 92 | SkipCheckFn func(c *Check) bool 93 | } 94 | 95 | // File is file with config or versions 96 | type File struct { 97 | Name string 98 | Text []byte 99 | HasConfig bool 100 | HasCurrents bool 101 | HasNoVersions bool // for Bumpfile 102 | } 103 | 104 | func rangeOverlap(x1, x2, y1, y2 int) bool { 105 | return x1 < y2 && y1 < x2 106 | } 107 | 108 | // scan name-with-no-space-or-quote-characters 109 | func makeNameScanFn() lexer.ScanFn { 110 | return lexer.Re(regexp.MustCompile(`[^"\s]`)) 111 | } 112 | 113 | // NewBumpFileSet creates a new BumpFileSet 114 | func NewBumpFileSet( 115 | os OS, 116 | filters []filter.NamedFilter, 117 | bumpfile string, 118 | filenames []string) (*FileSet, []error) { 119 | 120 | b := &FileSet{ 121 | Filters: filters, 122 | } 123 | 124 | if len(filenames) > 0 { 125 | for _, f := range filenames { 126 | if err := b.addFile(os, f); err != nil { 127 | return nil, []error{err} 128 | } 129 | } 130 | } else { 131 | if err := b.addBumpfile(os, bumpfile); err != nil { 132 | return nil, []error{err} 133 | } 134 | } 135 | 136 | b.findCurrent() 137 | 138 | if errs := b.Lint(); errs != nil { 139 | return nil, errs 140 | } 141 | 142 | return b, nil 143 | } 144 | 145 | func (fs *FileSet) addBumpfile(os OS, name string) error { 146 | text, err := os.ReadFile(name) 147 | if err != nil { 148 | return err 149 | } 150 | file := &File{Name: name, Text: text, HasNoVersions: true} 151 | fs.Files = append(fs.Files, file) 152 | 153 | lineNr := 0 154 | for _, l := range strings.Split(string(text), "\n") { 155 | lineNr++ 156 | if strings.HasPrefix(l, "#") || strings.TrimSpace(l) == "" { 157 | continue 158 | } 159 | 160 | file.HasConfig = true 161 | 162 | matches, _ := os.Glob(l) 163 | if len(matches) > 0 { 164 | for _, m := range matches { 165 | if err := fs.addFile(os, m); err != nil { 166 | return err 167 | } 168 | } 169 | continue 170 | } 171 | 172 | err := fs.parseCheckLine(file, lineNr, l, fs.Filters) 173 | if err != nil { 174 | return fmt.Errorf("%s:%d: %w", file.Name, lineNr, err) 175 | } 176 | } 177 | 178 | return nil 179 | } 180 | 181 | func (fs *FileSet) addFile(os OS, name string) error { 182 | text, err := os.ReadFile(name) 183 | if err != nil { 184 | return err 185 | } 186 | file := &File{Name: name, Text: text} 187 | fs.Files = append(fs.Files, file) 188 | 189 | err = fs.parseFile(file, fs.Filters) 190 | if err != nil { 191 | return err 192 | } 193 | 194 | return nil 195 | } 196 | 197 | // SelectedChecks returns selected checks based on SkipCheckFn 198 | func (fs *FileSet) SelectedChecks() []*Check { 199 | if fs.SkipCheckFn == nil { 200 | return fs.Checks 201 | } 202 | 203 | var filteredChecks []*Check 204 | for _, c := range fs.Checks { 205 | if fs.SkipCheckFn(c) { 206 | continue 207 | } 208 | filteredChecks = append(filteredChecks, c) 209 | } 210 | 211 | return filteredChecks 212 | } 213 | 214 | // Latest run all pipelines to get latest version 215 | func (fs *FileSet) Latest() []error { 216 | type result struct { 217 | i int 218 | latest string 219 | err error 220 | duration time.Duration 221 | } 222 | 223 | selectedChecks := fs.SelectedChecks() 224 | resultCh := make(chan result, len(selectedChecks)) 225 | 226 | wg := sync.WaitGroup{} 227 | wg.Add(len(selectedChecks)) 228 | for i, c := range selectedChecks { 229 | go func(i int, c *Check) { 230 | defer wg.Done() 231 | start := time.Now() 232 | v, err := c.Pipeline.Value(nil) 233 | resultCh <- result{i: i, latest: v, err: err, duration: time.Since(start)} 234 | }(i, c) 235 | } 236 | 237 | go func() { 238 | wg.Wait() 239 | close(resultCh) 240 | }() 241 | 242 | var errs []error 243 | for r := range resultCh { 244 | c := selectedChecks[r.i] 245 | c.PipelineDuration = r.duration 246 | c.Latest = r.latest 247 | if r.err != nil { 248 | errs = append(errs, fmt.Errorf("%s:%d: %s: %w", c.File.Name, c.PipelineLineNr, c.Name, r.err)) 249 | } 250 | } 251 | 252 | return errs 253 | } 254 | 255 | func (fs *FileSet) findCurrent() { 256 | for _, c := range fs.SelectedChecks() { 257 | for _, f := range fs.Files { 258 | if f.HasNoVersions { 259 | continue 260 | } 261 | 262 | locLine := locline.New(f.Text) 263 | checkLineSet := map[int]bool{} 264 | for _, sm := range bumpRe.FindAllSubmatchIndex(f.Text, -1) { 265 | lineNr := locLine.Line(sm[0]) 266 | checkLineSet[lineNr] = true 267 | } 268 | 269 | for _, sm := range c.CurrentRE.FindAllSubmatchIndex(f.Text, -1) { 270 | lineNr := locLine.Line(sm[0]) 271 | if _, ok := checkLineSet[lineNr]; ok { 272 | continue 273 | } 274 | 275 | f.HasCurrents = true 276 | 277 | version := string(f.Text[sm[2]:sm[3]]) 278 | c.Currents = append(c.Currents, Current{ 279 | File: f, 280 | LineNr: lineNr, 281 | Range: [2]int{sm[2], sm[3]}, 282 | Version: version, 283 | }) 284 | } 285 | } 286 | } 287 | } 288 | 289 | // Lint configuration 290 | func (fs *FileSet) Lint() []error { 291 | var errs []error 292 | 293 | for _, c := range fs.Checks { 294 | if len(c.Currents) != 0 { 295 | continue 296 | } 297 | errs = append(errs, fmt.Errorf("%s:%d: %s has no current version matches", c.File.Name, c.PipelineLineNr, c.Name)) 298 | } 299 | 300 | for _, f := range fs.Files { 301 | if f.HasNoVersions { 302 | if f.HasConfig { 303 | continue 304 | } 305 | errs = append(errs, fmt.Errorf("%s: has no configuration", f.Name)) 306 | } else { 307 | if f.HasConfig || f.HasCurrents { 308 | continue 309 | } 310 | errs = append(errs, fmt.Errorf("%s: has no configuration or current version matches", f.Name)) 311 | } 312 | } 313 | 314 | for _, ca := range fs.Checks { 315 | for _, cca := range ca.Currents { 316 | for _, cb := range fs.Checks { 317 | if ca == cb { 318 | continue 319 | } 320 | 321 | for _, ccb := range cb.Currents { 322 | if cca.File.Name != ccb.File.Name || 323 | !rangeOverlap(cca.Range[0], cca.Range[1], ccb.Range[0], ccb.Range[1]) { 324 | continue 325 | } 326 | 327 | errs = append(errs, fmt.Errorf("%s:%d:%s has overlapping matches with %s:%d:%s at %s:%d", 328 | ca.File.Name, ca.PipelineLineNr, ca.Name, 329 | cb.File.Name, cb.PipelineLineNr, cb.Name, 330 | cca.File.Name, cca.LineNr)) 331 | } 332 | } 333 | } 334 | } 335 | 336 | return errs 337 | } 338 | 339 | // Replace current with latest versions in text 340 | func (fs *FileSet) Replace(file *File) []byte { 341 | if file.HasNoVersions { 342 | return file.Text 343 | } 344 | 345 | locLine := locline.New(file.Text) 346 | checkLineSet := map[int]bool{} 347 | for _, sm := range bumpRe.FindAllSubmatchIndex(file.Text, -1) { 348 | lineNr := locLine.Line(sm[0]) 349 | checkLineSet[lineNr] = true 350 | } 351 | 352 | selectedChecks := fs.SelectedChecks() 353 | var replacers []rereplacer.Replace 354 | for _, c := range selectedChecks { 355 | // skip if check has run commands 356 | if len(c.CommandShells) > 0 { 357 | continue 358 | } 359 | 360 | // new variable for the replacer fn closure 361 | c := c 362 | replacers = append(replacers, rereplacer.Replace{ 363 | Re: c.CurrentRE, 364 | Fn: func(b []byte, sm []int) []byte { 365 | matchLine := locLine.Line(sm[0]) 366 | if _, ok := checkLineSet[matchLine]; ok { 367 | return b[sm[0]:sm[1]] 368 | } 369 | 370 | l := []byte{} 371 | l = append(l, b[sm[0]:sm[2]]...) 372 | l = append(l, []byte(c.Latest)...) 373 | l = append(l, b[sm[3]:sm[1]]...) 374 | 375 | return l 376 | }, 377 | }) 378 | } 379 | 380 | return rereplacer.Replacer(replacers).Replace(file.Text) 381 | } 382 | 383 | func (fs *FileSet) CommandEnv(check *Check) []string { 384 | return []string{ 385 | fmt.Sprintf("NAME=%s", check.Name), 386 | fmt.Sprintf("LATEST=%s", check.Latest), 387 | } 388 | } 389 | 390 | func (fs *FileSet) parseFile(file *File, filters []filter.NamedFilter) error { 391 | locLine := locline.New(file.Text) 392 | 393 | for _, sm := range bumpRe.FindAllSubmatchIndex(file.Text, -1) { 394 | lineNr := locLine.Line(sm[0]) 395 | checkLine := strings.TrimSpace(string(file.Text[sm[2]:sm[3]])) 396 | err := fs.parseCheckLine(file, lineNr, checkLine, filters) 397 | if err != nil { 398 | return fmt.Errorf("%s:%d: %w", file.Name, lineNr, err) 399 | } 400 | } 401 | 402 | return nil 403 | } 404 | 405 | func (fs *FileSet) findCheckByName(name string) *Check { 406 | for _, c := range fs.Checks { 407 | if c.Name == name { 408 | return c 409 | } 410 | } 411 | return nil 412 | } 413 | 414 | func (fs *FileSet) parseCheckLine(file *File, lineNr int, line string, filters []filter.NamedFilter) error { 415 | file.HasConfig = true 416 | 417 | var name string 418 | var rest string 419 | if _, err := lexer.Scan(line, 420 | lexer.Concat( 421 | lexer.Var("name", &name, makeNameScanFn()), 422 | lexer.Re(regexp.MustCompile(`\s`)), 423 | lexer.Var("rest", &rest, lexer.Rest(1)), 424 | ), 425 | ); err != nil { 426 | return fmt.Errorf("invalid name and arguments: %w", err) 427 | } 428 | 429 | switch { 430 | case strings.HasPrefix(rest, "/"): 431 | // bump: <name> /<re>/ <pipeline> 432 | var currentReStr string 433 | var pipelineStr string 434 | if _, err := lexer.Scan(rest, 435 | lexer.Concat( 436 | lexer.Var("re", ¤tReStr, lexer.Quoted(`/`)), 437 | lexer.Re(regexp.MustCompile(`\s`)), 438 | lexer.Var("pipeline", &pipelineStr, lexer.Rest(1)), 439 | ), 440 | ); err != nil { 441 | return err 442 | } 443 | pl, err := pipeline.New(filters, pipelineStr) 444 | if err != nil { 445 | return fmt.Errorf("%s: %w", pipelineStr, err) 446 | } 447 | // compile in multi-line mode: ^$ matches end/start of line 448 | currentRe, err := regexp.Compile("(?m)" + currentReStr) 449 | if err != nil { 450 | return fmt.Errorf("invalid current version regexp: %q", currentReStr) 451 | } 452 | if currentRe.NumSubexp() != 1 { 453 | return fmt.Errorf("regexp must have one submatch: %q", currentReStr) 454 | } 455 | 456 | check := &Check{ 457 | File: file, 458 | Name: name, 459 | CurrentREStr: currentReStr, 460 | CurrentRE: currentRe, 461 | PipelineLineNr: lineNr, 462 | Pipeline: pl, 463 | } 464 | 465 | for _, bc := range fs.Checks { 466 | if check.Name == bc.Name { 467 | return fmt.Errorf("%s already used at %s:%d", 468 | check.Name, bc.File.Name, bc.PipelineLineNr) 469 | } 470 | } 471 | 472 | fs.Checks = append(fs.Checks, check) 473 | 474 | return nil 475 | default: 476 | check := fs.findCheckByName(name) 477 | if check == nil { 478 | return fmt.Errorf("%s has not been defined yet", name) 479 | } 480 | 481 | var kind string 482 | if _, err := lexer.Scan(rest, 483 | lexer.Concat( 484 | lexer.Var("kind", &kind, lexer.Re(regexp.MustCompile(`\w`))), 485 | lexer.Re(regexp.MustCompile(`\s`)), 486 | lexer.Var("rest", &rest, lexer.Rest(1)), 487 | ), 488 | ); err != nil { 489 | return fmt.Errorf("invalid name and arguments: %w", err) 490 | } 491 | 492 | switch kind { 493 | case "command": 494 | // bump: <name> command ... 495 | check.CommandShells = append(check.CommandShells, CheckShell{ 496 | Cmd: rest, 497 | File: file, 498 | LineNr: lineNr, 499 | }) 500 | case "after": 501 | // bump: <name> after ... 502 | check.AfterShells = append(check.AfterShells, CheckShell{ 503 | Cmd: rest, 504 | File: file, 505 | LineNr: lineNr, 506 | }) 507 | case "message": 508 | // bump: <name> message ... 509 | check.Messages = append(check.Messages, CheckMessage{ 510 | Message: rest, 511 | File: file, 512 | LineNr: lineNr, 513 | }) 514 | case "link": 515 | // bump: <name> link <title> <url> 516 | var linkTitle string 517 | var linkURL string 518 | if _, err := lexer.Scan(rest, 519 | lexer.Concat( 520 | lexer.Var("title", &linkTitle, lexer.Or( 521 | lexer.Quoted(`"`), 522 | makeNameScanFn(), 523 | )), 524 | lexer.Re(regexp.MustCompile(`\s`)), 525 | lexer.Var("URL", &linkURL, lexer.Rest(1)), 526 | ), 527 | ); err != nil { 528 | return err 529 | } 530 | 531 | check.Links = append(check.Links, CheckLink{ 532 | Title: linkTitle, 533 | URL: linkURL, 534 | File: file, 535 | LineNr: lineNr, 536 | }) 537 | default: 538 | return fmt.Errorf("expected command, after or link: %q", line) 539 | } 540 | } 541 | 542 | return nil 543 | } 544 | -------------------------------------------------------------------------------- /internal/bump/os.go: -------------------------------------------------------------------------------- 1 | package bump 2 | 3 | import ( 4 | "io" 5 | ) 6 | 7 | type OS interface { 8 | Args() []string 9 | Getenv(name string) string 10 | Stdout() io.Writer 11 | Stderr() io.Writer 12 | WriteFile(filename string, data []byte) error 13 | ReadFile(filename string) ([]byte, error) 14 | Glob(pattern string) ([]string, error) 15 | Shell(cmd string, env []string) error 16 | Exec(args []string, env []string) error 17 | } 18 | -------------------------------------------------------------------------------- /internal/bump/update.go: -------------------------------------------------------------------------------- 1 | package bump 2 | 3 | import ( 4 | "bytes" 5 | 6 | "github.com/pmezard/go-difflib/difflib" 7 | ) 8 | 9 | type VersionChange struct { 10 | Check *Check 11 | Currents []Current 12 | } 13 | 14 | type FileChange struct { 15 | File *File 16 | NewText string 17 | Diff string 18 | } 19 | 20 | type RunShell struct { 21 | Check *Check 22 | Cmd string 23 | Env []string 24 | } 25 | 26 | type Actions struct { 27 | VersionChanges []VersionChange 28 | FileChanges []FileChange 29 | RunShells []RunShell 30 | } 31 | 32 | func (fs *FileSet) UpdateActions() (Actions, []error) { 33 | if errs := fs.Latest(); errs != nil { 34 | return Actions{}, errs 35 | } 36 | 37 | a := Actions{} 38 | 39 | for _, check := range fs.SelectedChecks() { 40 | var currentChanges []Current 41 | for _, c := range check.Currents { 42 | if c.Version == check.Latest { 43 | continue 44 | } 45 | currentChanges = append(currentChanges, c) 46 | } 47 | 48 | if len(currentChanges) == 0 { 49 | continue 50 | } 51 | 52 | a.VersionChanges = append(a.VersionChanges, VersionChange{ 53 | Check: check, 54 | Currents: currentChanges, 55 | }) 56 | 57 | env := fs.CommandEnv(check) 58 | // TODO: refactor, currently Replace skips if there are CommandRuns 59 | for _, cr := range check.CommandShells { 60 | a.RunShells = append(a.RunShells, RunShell{Check: check, Cmd: cr.Cmd, Env: env}) 61 | } 62 | for _, cr := range check.AfterShells { 63 | a.RunShells = append(a.RunShells, RunShell{Check: check, Cmd: cr.Cmd, Env: env}) 64 | } 65 | } 66 | 67 | for _, f := range fs.Files { 68 | newTextBuf := fs.Replace(f) 69 | // might return equal even if version has changed if checks has CommandRuns 70 | if bytes.Equal(f.Text, newTextBuf) { 71 | continue 72 | } 73 | newText := string(newTextBuf) 74 | 75 | udiff, err := difflib.GetUnifiedDiffString(difflib.UnifiedDiff{ 76 | A: difflib.SplitLines(string(f.Text)), 77 | B: difflib.SplitLines(newText), 78 | FromFile: f.Name, 79 | ToFile: f.Name, 80 | Context: 3, 81 | }) 82 | if err != nil { 83 | return Actions{}, []error{err} 84 | } 85 | 86 | a.FileChanges = append(a.FileChanges, FileChange{ 87 | File: f, 88 | NewText: newText, 89 | Diff: udiff, 90 | }) 91 | } 92 | 93 | return a, nil 94 | } 95 | -------------------------------------------------------------------------------- /internal/cli/cli.go: -------------------------------------------------------------------------------- 1 | package cli 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/wader/bump/internal/bump" 10 | "github.com/wader/bump/internal/filter" 11 | "github.com/wader/bump/internal/filter/all" 12 | "github.com/wader/bump/internal/pipeline" 13 | ) 14 | 15 | // BumpfileName default Bumpfile name 16 | const BumpfileName = "Bumpfile" 17 | 18 | func flagWasPassed(flags *flag.FlagSet, name string) bool { 19 | passed := false 20 | flags.Visit(func(f *flag.Flag) { 21 | if f.Name == name { 22 | passed = true 23 | } 24 | }) 25 | return passed 26 | } 27 | 28 | func csvToSlice(s string) []string { 29 | return strings.FieldsFunc(s, func(r rune) bool { return r == ',' }) 30 | } 31 | 32 | // Command is a command based interface to bump packages 33 | type Command struct { 34 | Version string 35 | OS bump.OS 36 | } 37 | 38 | func (cmd Command) filters() []filter.NamedFilter { 39 | return all.Filters() 40 | } 41 | 42 | func (c Command) help(flags *flag.FlagSet) string { 43 | text := ` 44 | Usage: {{ARGV0}} [OPTIONS] COMMAND 45 | OPTIONS: 46 | {{OPTIONS_HELP}} 47 | 48 | COMMANDS: 49 | version Show version of bump itself ({{VERSION}}) 50 | help [FILTER] Show help or help for a filter 51 | list [FILE...] Show bump configurations 52 | current [FILE...] Show current versions 53 | check [FILE...] Check for possible version updates 54 | update [FILE...] Update versions 55 | diff [FILE...] Show diff of what an update would change 56 | pipeline PIPELINE Run a filter pipeline 57 | 58 | BUMPFILE is a file with CONFIG:s or glob patterns of FILE:s 59 | FILE is a file with EMBEDCONFIG:s or versions to be checked and updated 60 | EMBEDCONFIG is "bump: CONFIG" 61 | CONFIG is 62 | NAME /REGEXP/ PIPELINE | 63 | NAME command COMMAND | 64 | NAME after COMMAND | 65 | NAME message MESSAGE | 66 | NAME link TITLE URL 67 | NAME is a configuration name 68 | REGEXP is a regexp with one submatch to find current version 69 | PIPELINE is a filter pipeline: FILTER|FILTER|... 70 | FILTER 71 | {{FILTER_HELP}} 72 | `[1:] 73 | 74 | var optionsHelps []string 75 | flags.VisitAll(func(f *flag.Flag) { 76 | var ss []string 77 | ss = append(ss, fmt.Sprintf(" -%-20s", f.Name)) 78 | ss = append(ss, f.Usage) 79 | if f.DefValue != "" { 80 | ss = append(ss, fmt.Sprintf("(%s)", f.DefValue)) 81 | } 82 | optionsHelps = append(optionsHelps, strings.Join(ss, " ")) 83 | }) 84 | optionHelp := strings.Join(optionsHelps, "\n") 85 | 86 | var filterHelps []string 87 | for _, nf := range c.filters() { 88 | syntax, _, _ := filter.ParseHelp(nf.Help) 89 | filterHelps = append(filterHelps, " "+strings.Join(syntax, " | ")) 90 | } 91 | filterHelp := strings.Join(filterHelps, "\n") 92 | 93 | return strings.NewReplacer( 94 | "{{ARGV0}}", c.OS.Args()[0], 95 | "{{VERSION}}", c.Version, 96 | "{{OPTIONS_HELP}}", optionHelp, 97 | "{{FILTER_HELP}}", filterHelp, 98 | ).Replace(text) 99 | } 100 | 101 | func (cmd Command) helpFilter(nf filter.NamedFilter) string { 102 | syntax, description, examples := filter.ParseHelp(nf.Help) 103 | 104 | var examplesRuns []string 105 | for _, e := range examples { 106 | if strings.HasPrefix(e, "#") { 107 | examplesRuns = append(examplesRuns, e) 108 | continue 109 | } 110 | examplesRuns = append(examplesRuns, fmt.Sprintf("bump pipeline '%s'", e)) 111 | } 112 | 113 | return fmt.Sprintf(` 114 | Syntax: 115 | %s 116 | 117 | %s 118 | 119 | Examples: 120 | %s 121 | `[1:], 122 | strings.Join(syntax, ", "), 123 | description, 124 | strings.Join(examplesRuns, "\n"), 125 | ) 126 | } 127 | 128 | // Run bump command 129 | func (c Command) Run() []error { 130 | errs := c.run() 131 | for _, err := range errs { 132 | fmt.Fprintln(c.OS.Stderr(), err) 133 | } 134 | return errs 135 | } 136 | 137 | func (c Command) run() []error { 138 | var bumpfile string 139 | var include string 140 | var exclude string 141 | var verbose bool 142 | var runCommands bool 143 | 144 | flags := flag.NewFlagSet(c.OS.Args()[0], flag.ContinueOnError) 145 | flags.StringVar(&bumpfile, "f", BumpfileName, "Bumpfile to read") 146 | flags.StringVar(&include, "i", "", "Comma separated names to include") 147 | flags.StringVar(&exclude, "e", "", "Comma separated names to exclude") 148 | flags.BoolVar(&verbose, "v", false, "Verbose") 149 | flags.BoolVar(&runCommands, "r", false, "Run update commands") 150 | flags.SetOutput(c.OS.Stderr()) 151 | flags.Usage = func() { 152 | fmt.Fprint(flags.Output(), c.help(flags)) 153 | } 154 | parseFlags := func(args []string) ([]error, bool) { 155 | err := flags.Parse(args) 156 | if errors.Is(err, flag.ErrHelp) { 157 | flags.Usage() 158 | return nil, false 159 | } else if err != nil { 160 | return []error{err}, false 161 | } 162 | return nil, true 163 | } 164 | 165 | if err, ok := parseFlags(c.OS.Args()[1:]); err != nil || !ok { 166 | return err 167 | } 168 | if len(flags.Args()) == 0 { 169 | flags.Usage() 170 | return nil 171 | } 172 | command := flags.Arg(0) 173 | if err, ok := parseFlags(flags.Args()[1:]); err != nil || !ok { 174 | return err 175 | } 176 | 177 | if command == "version" { 178 | fmt.Fprintf(c.OS.Stdout(), "%s\n", c.Version) 179 | return nil 180 | } else if command == "help" { 181 | filterName := flags.Arg(0) 182 | if filterName == "" { 183 | flags.Usage() 184 | return nil 185 | } 186 | for _, nf := range c.filters() { 187 | if filterName == nf.Name { 188 | fmt.Fprint(c.OS.Stdout(), c.helpFilter(nf)) 189 | return nil 190 | } 191 | } 192 | fmt.Fprintf(c.OS.Stdout(), "Filter not found\n") 193 | return nil 194 | } 195 | 196 | files := flags.Args() 197 | includes := map[string]bool{} 198 | excludes := map[string]bool{} 199 | var bfs *bump.FileSet 200 | var errs []error 201 | 202 | for _, n := range csvToSlice(include) { 203 | includes[n] = true 204 | } 205 | for _, n := range csvToSlice(exclude) { 206 | excludes[n] = true 207 | } 208 | 209 | switch command { 210 | case "list", "current", "check", "diff", "update": 211 | bumpfilePassed := flagWasPassed(flags, "f") 212 | if bumpfilePassed && len(files) > 0 { 213 | return []error{errors.New("both bumpfile and file arguments can't be specified")} 214 | } 215 | 216 | bfs, errs = bump.NewBumpFileSet(c.OS, c.filters(), bumpfile, files) 217 | if errs != nil { 218 | return errs 219 | } 220 | } 221 | 222 | if bfs != nil && (len(includes) > 0 || len(excludes) > 0) { 223 | names := map[string]bool{} 224 | for _, c := range bfs.Checks { 225 | names[c.Name] = true 226 | } 227 | for n := range includes { 228 | if _, found := names[n]; !found { 229 | return []error{fmt.Errorf("include name %q not found", n)} 230 | } 231 | } 232 | for n := range excludes { 233 | if _, found := names[n]; !found { 234 | return []error{fmt.Errorf("exclude name %q not found", n)} 235 | } 236 | } 237 | bfs.SkipCheckFn = func(c *bump.Check) bool { 238 | includeFound := true 239 | if len(include) > 0 { 240 | _, includeFound = includes[c.Name] 241 | } 242 | _, excludeFound := excludes[c.Name] 243 | return excludeFound || !includeFound 244 | } 245 | } 246 | 247 | switch command { 248 | case "list": 249 | for _, check := range bfs.SelectedChecks() { 250 | if verbose { 251 | fmt.Fprintf(c.OS.Stdout(), "%s:%d: %s\n", check.File.Name, check.PipelineLineNr, check) 252 | for _, cs := range check.CommandShells { 253 | fmt.Fprintf(c.OS.Stdout(), "%s:%d: %s command %s\n", cs.File.Name, cs.LineNr, check.Name, cs.Cmd) 254 | } 255 | for _, ca := range check.AfterShells { 256 | fmt.Fprintf(c.OS.Stdout(), "%s:%d: %s after %s\n", ca.File.Name, ca.LineNr, check.Name, ca.Cmd) 257 | } 258 | for _, m := range check.Messages { 259 | fmt.Fprintf(c.OS.Stdout(), "%s:%d: %s message %s\n", m.File.Name, m.LineNr, check.Name, m.Message) 260 | } 261 | for _, l := range check.Links { 262 | fmt.Fprintf(c.OS.Stdout(), "%s:%d: %s link %q %s\n", l.File.Name, l.LineNr, check.Name, l.Title, l.URL) 263 | } 264 | } else { 265 | fmt.Fprintf(c.OS.Stdout(), "%s\n", check.Name) 266 | } 267 | } 268 | case "current": 269 | for _, check := range bfs.SelectedChecks() { 270 | for _, current := range check.Currents { 271 | fmt.Fprintf(c.OS.Stdout(), "%s:%d: %s %s\n", current.File.Name, current.LineNr, check.Name, current.Version) 272 | } 273 | } 274 | case "check", "diff", "update": 275 | ua, errs := bfs.UpdateActions() 276 | if errs != nil { 277 | return errs 278 | } 279 | 280 | switch command { 281 | case "check": 282 | if verbose { 283 | for _, check := range bfs.Checks { 284 | for _, current := range check.Currents { 285 | fmt.Fprintf(c.OS.Stdout(), "%s:%d: %s %s -> %s %.3fs\n", 286 | current.File.Name, current.LineNr, check.Name, current.Version, check.Latest, 287 | float32(check.PipelineDuration.Milliseconds())/1000.0) 288 | } 289 | } 290 | } else { 291 | for _, vs := range ua.VersionChanges { 292 | fmt.Fprintf(c.OS.Stdout(), "%s %s\n", vs.Check.Name, vs.Check.Latest) 293 | } 294 | } 295 | case "diff": 296 | for _, fc := range ua.FileChanges { 297 | fmt.Fprint(c.OS.Stdout(), fc.Diff) 298 | } 299 | case "update": 300 | for _, fc := range ua.FileChanges { 301 | if err := c.OS.WriteFile(fc.File.Name, []byte(fc.NewText)); err != nil { 302 | return []error{err} 303 | } 304 | } 305 | if runCommands { 306 | for _, rs := range ua.RunShells { 307 | if verbose { 308 | fmt.Fprintf(c.OS.Stdout(), "%s: shell: %s %s\n", rs.Check.Name, strings.Join(rs.Env, " "), rs.Cmd) 309 | } 310 | if err := c.OS.Shell(rs.Cmd, rs.Env); err != nil { 311 | return []error{fmt.Errorf("%s: shell: %s: %w", rs.Check.Name, rs.Cmd, err)} 312 | } 313 | } 314 | } else if len(ua.RunShells) > 0 { 315 | for _, rs := range ua.RunShells { 316 | fmt.Fprintf(c.OS.Stdout(), "skipping %s: shell: %s %s\n", rs.Check.Name, strings.Join(rs.Env, " "), rs.Cmd) 317 | } 318 | } 319 | } 320 | case "pipeline": 321 | plStr := flags.Arg(0) 322 | pl, err := pipeline.New(c.filters(), plStr) 323 | if err != nil { 324 | return []error{err} 325 | } 326 | logFn := func(format string, v ...interface{}) {} 327 | if verbose { 328 | logFn = func(format string, v ...interface{}) { 329 | fmt.Fprintf(c.OS.Stderr(), format+"\n", v...) 330 | } 331 | } 332 | logFn("Parsed pipeline: %s", pl) 333 | v, err := pl.Value(logFn) 334 | if err != nil { 335 | return []error{err} 336 | } 337 | fmt.Fprintf(c.OS.Stdout(), "%s\n", v) 338 | default: 339 | flags.Usage() 340 | return []error{fmt.Errorf("unknown command: %s", command)} 341 | } 342 | 343 | return nil 344 | } 345 | -------------------------------------------------------------------------------- /internal/cli/cli_test.go: -------------------------------------------------------------------------------- 1 | package cli_test 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "regexp" 10 | "strconv" 11 | "strings" 12 | "testing" 13 | 14 | "github.com/wader/bump/internal/cli" 15 | "github.com/wader/bump/internal/deepequal" 16 | ) 17 | 18 | const testCaseDelim = "---\n" 19 | 20 | type testShell struct { 21 | cmd string 22 | env []string 23 | } 24 | 25 | type testCaseFile struct { 26 | name string 27 | data string 28 | } 29 | 30 | type testCase struct { 31 | parts []interface{} 32 | } 33 | 34 | type testCaseComment string 35 | type testCaseExistingFile testCaseFile 36 | type testCaseExpectedWriteFile testCaseFile 37 | type testCaseArgs string 38 | type testCaseExpectedStdout string 39 | type testCaseExpectedStderr string 40 | type testCaseExpectedShell testShell 41 | 42 | func (tc testCase) String() string { 43 | sb := &strings.Builder{} 44 | for _, p := range tc.parts { 45 | switch p := p.(type) { 46 | case testCaseComment: 47 | fmt.Fprintf(sb, "#%s\n", p) 48 | case testCaseExistingFile: 49 | fmt.Fprintf(sb, "/%s:\n", p.name) 50 | fmt.Fprint(sb, p.data) 51 | case testCaseArgs: 52 | fmt.Fprintf(sb, "$%s\n", p) 53 | case testCaseExpectedWriteFile: 54 | fmt.Fprintf(sb, "/%s:\n", p.name) 55 | fmt.Fprint(sb, p.data) 56 | case testCaseExpectedStdout: 57 | fmt.Fprintf(sb, ">stdout:\n") 58 | fmt.Fprint(sb, p) 59 | case testCaseExpectedStderr: 60 | fmt.Fprintf(sb, ">stderr:\n") 61 | fmt.Fprint(sb, p) 62 | case testCaseExpectedShell: 63 | fmt.Fprintf(sb, "!%s\n", p.cmd) 64 | for _, e := range p.env { 65 | fmt.Fprintln(sb, e) 66 | } 67 | default: 68 | panic("unreachable") 69 | } 70 | } 71 | return sb.String() 72 | } 73 | 74 | type testCaseOS struct { 75 | tc testCase 76 | actualWrittenFiles []testCaseFile 77 | actualStdoutBuf *bytes.Buffer 78 | actualStderrBuf *bytes.Buffer 79 | actualShells []testShell 80 | } 81 | 82 | func (t *testCaseOS) Args() []string { 83 | for _, p := range t.tc.parts { 84 | if a, ok := p.(testCaseArgs); ok { 85 | return strings.Fields(string(a)) 86 | } 87 | } 88 | return nil 89 | } 90 | func (t *testCaseOS) Getenv(name string) string { panic("not implemented") } 91 | func (t *testCaseOS) Stdout() io.Writer { return t.actualStdoutBuf } 92 | func (t *testCaseOS) Stderr() io.Writer { return t.actualStderrBuf } 93 | func (t *testCaseOS) WriteFile(name string, data []byte) error { 94 | t.actualWrittenFiles = append(t.actualWrittenFiles, testCaseFile{name: name, data: string(data)}) 95 | return nil 96 | } 97 | 98 | func (t *testCaseOS) ReadFile(name string) ([]byte, error) { 99 | for _, p := range t.tc.parts { 100 | if f, ok := p.(testCaseExistingFile); ok && f.name == name { 101 | return []byte(f.data), nil 102 | } 103 | } 104 | return nil, &os.PathError{Op: "open", Path: name, Err: os.ErrNotExist} 105 | } 106 | 107 | func (t *testCaseOS) Glob(pattern string) ([]string, error) { 108 | var matches []string 109 | for _, p := range t.tc.parts { 110 | if f, ok := p.(testCaseExistingFile); ok { 111 | ok, err := filepath.Match(pattern, f.name) 112 | if err != nil { 113 | return nil, err 114 | } 115 | if !ok { 116 | continue 117 | } 118 | matches = append(matches, f.name) 119 | } 120 | } 121 | return matches, nil 122 | } 123 | 124 | func (t *testCaseOS) Shell(cmd string, env []string) error { 125 | t.actualShells = append(t.actualShells, testShell{ 126 | cmd: cmd, 127 | env: env, 128 | }) 129 | return nil 130 | } 131 | 132 | func (t *testCaseOS) Exec(args []string, env []string) error { panic("not implemented") } 133 | 134 | func (t *testCaseOS) String() string { 135 | sb := &strings.Builder{} 136 | for _, p := range t.tc.parts { 137 | switch p := p.(type) { 138 | case testCaseComment: 139 | fmt.Fprintf(sb, "#%s\n", p) 140 | case testCaseExistingFile: 141 | fmt.Fprintf(sb, "/%s:\n", p.name) 142 | fmt.Fprint(sb, p.data) 143 | case testCaseArgs: 144 | fmt.Fprintf(sb, "$%s\n", string(p)) 145 | for _, awf := range t.actualWrittenFiles { 146 | fmt.Fprintf(sb, "/%s:\n", awf.name) 147 | fmt.Fprint(sb, awf.data) 148 | } 149 | if t.actualStdoutBuf.Len() > 0 { 150 | fmt.Fprintf(sb, ">stdout:\n") 151 | fmt.Fprint(sb, t.actualStdoutBuf.String()) 152 | } 153 | if t.actualStderrBuf.Len() > 0 { 154 | fmt.Fprintf(sb, ">stderr:\n") 155 | fmt.Fprint(sb, t.actualStderrBuf.String()) 156 | } 157 | for _, s := range t.actualShells { 158 | fmt.Fprintf(sb, "!%s\n", s.cmd) 159 | for _, e := range s.env { 160 | fmt.Fprintln(sb, e) 161 | } 162 | } 163 | case testCaseExpectedWriteFile, 164 | testCaseExpectedStdout, 165 | testCaseExpectedStderr, 166 | testCaseExpectedShell: 167 | // nop 168 | default: 169 | panic(fmt.Sprintf("unreachable %#+v", p)) 170 | } 171 | } 172 | return sb.String() 173 | } 174 | 175 | type section struct { 176 | LineNr int 177 | Name string 178 | Value string 179 | } 180 | 181 | func sectionParser(re *regexp.Regexp, s string) []section { 182 | var sections []section 183 | 184 | firstMatch := func(ss []string, fn func(s string) bool) string { 185 | for _, s := range ss { 186 | if fn(s) { 187 | return s 188 | } 189 | } 190 | return "" 191 | } 192 | 193 | const lineDelim = "\n" 194 | var cs *section 195 | lineNr := 0 196 | lines := strings.Split(s, lineDelim) 197 | // skip last if empty because of how split works "a\n" -> ["a", ""] 198 | if lines[len(lines)-1] == "" { 199 | lines = lines[:len(lines)-1] 200 | } 201 | for _, l := range lines { 202 | lineNr++ 203 | 204 | sm := re.FindStringSubmatch(l) 205 | if cs == nil || len(sm) > 0 { 206 | sections = append(sections, section{}) 207 | cs = §ions[len(sections)-1] 208 | 209 | cs.LineNr = lineNr 210 | cs.Name = firstMatch(sm, func(s string) bool { return len(s) != 0 }) 211 | } else { 212 | // TODO: use builder somehow if performance is needed 213 | cs.Value += l + lineDelim 214 | } 215 | 216 | } 217 | 218 | return sections 219 | } 220 | 221 | func TestSectionParser(t *testing.T) { 222 | actualSections := sectionParser( 223 | regexp.MustCompile(`^(?:(a:)|(b:))$`), 224 | ` 225 | a: 226 | c 227 | c 228 | b: 229 | a: 230 | c 231 | a: 232 | `[1:]) 233 | 234 | expectedSections := []section{ 235 | {LineNr: 1, Name: "a:", Value: "c\nc\n"}, 236 | {LineNr: 4, Name: "b:", Value: ""}, 237 | {LineNr: 5, Name: "a:", Value: "c\n"}, 238 | {LineNr: 7, Name: "a:", Value: ""}, 239 | } 240 | 241 | deepequal.Error(t, "sections", expectedSections, actualSections) 242 | } 243 | 244 | func parseTestCases(s string) []testCase { 245 | var tcs []testCase 246 | 247 | for _, c := range strings.Split(s, testCaseDelim) { 248 | tc := testCase{} 249 | seenRun := false 250 | 251 | for _, section := range sectionParser(regexp.MustCompile(`^([/>].*:)|^[#\$!].*$`), c) { 252 | n, v := section.Name, section.Value 253 | 254 | switch { 255 | case strings.HasPrefix(n, "#"): 256 | tc.parts = append(tc.parts, testCaseComment(strings.TrimPrefix(n, "#"))) 257 | case !seenRun && strings.HasPrefix(n, "/"): 258 | name := n[1 : len(n)-1] 259 | tc.parts = append(tc.parts, testCaseExistingFile{name: name, data: v}) 260 | case !seenRun && strings.HasPrefix(n, "$"): 261 | seenRun = true 262 | tc.parts = append(tc.parts, testCaseArgs(strings.TrimPrefix(n, "$"))) 263 | case seenRun && n == ">stdout:": 264 | tc.parts = append(tc.parts, testCaseExpectedStdout(v)) 265 | case seenRun && n == ">stderr:": 266 | tc.parts = append(tc.parts, testCaseExpectedStderr(v)) 267 | case seenRun && strings.HasPrefix(n, "/"): 268 | name := n[1 : len(n)-1] 269 | tc.parts = append(tc.parts, testCaseExpectedWriteFile{name: name, data: v}) 270 | case seenRun && strings.HasPrefix(n, "!"): 271 | env := strings.Split(v, "\n") 272 | env = env[0 : len(env)-1] 273 | tc.parts = append(tc.parts, testCaseExpectedShell{cmd: strings.TrimPrefix(n[1:], "!"), env: env}) 274 | default: 275 | panic(fmt.Sprintf("%d: unexpected section %q %q", section.LineNr, n, v)) 276 | } 277 | } 278 | 279 | tcs = append(tcs, tc) 280 | } 281 | 282 | return tcs 283 | } 284 | 285 | func TestParseTestCase(t *testing.T) { 286 | testCaseText := ` 287 | /a: 288 | input content a 289 | $ a b 290 | /a: 291 | expected content a 292 | >stdout: 293 | expected stdout 294 | >stderr: 295 | expected stderr 296 | !command a b 297 | enva=valuea 298 | envb=valueb 299 | --- 300 | /a2: 301 | input content a2 302 | $ a2 b2 303 | /a2: 304 | expected content a2 305 | >stdout: 306 | expected stdout2 307 | >stderr: 308 | expected stderr2 309 | !command2 a2 b2 310 | enva2=valuea2 311 | envb2=valueb2 312 | !command22 a22 b22 313 | enva22=valuea22 314 | envb22=valueb22 315 | `[1:] 316 | 317 | actualTestCases := parseTestCases(testCaseText) 318 | var actualTestCasesTexts []string 319 | for _, tc := range actualTestCases { 320 | actualTestCasesTexts = append(actualTestCasesTexts, tc.String()) 321 | } 322 | 323 | deepequal.Error(t, "test case", testCaseText, strings.Join(actualTestCasesTexts, testCaseDelim)) 324 | } 325 | 326 | func TestCommand(t *testing.T) { 327 | const testDataDir = "testdata" 328 | testDataFiles, err := os.ReadDir(testDataDir) 329 | if err != nil { 330 | t.Fatal(err) 331 | } 332 | 333 | for _, fi := range testDataFiles { 334 | fi := fi 335 | t.Run(fi.Name(), func(t *testing.T) { 336 | t.Parallel() 337 | 338 | testFilePath := filepath.Join(testDataDir, fi.Name()) 339 | b, err := os.ReadFile(testFilePath) 340 | if err != nil { 341 | t.Fatal(err) 342 | } 343 | tcs := parseTestCases(string(b)) 344 | var actualTexts []string 345 | 346 | for i, tc := range tcs { 347 | t.Run(strconv.Itoa(i), func(t *testing.T) { 348 | to := &testCaseOS{ 349 | tc: tc, 350 | actualWrittenFiles: []testCaseFile{}, 351 | actualStdoutBuf: &bytes.Buffer{}, 352 | actualStderrBuf: &bytes.Buffer{}, 353 | actualShells: []testShell{}, 354 | } 355 | 356 | cli.Command{Version: "test", OS: to}.Run() 357 | deepequal.Error(t, "testcase", tc.String(), to.String()) 358 | 359 | actualTexts = append(actualTexts, to.String()) 360 | }) 361 | } 362 | 363 | actualText := strings.Join(actualTexts, testCaseDelim) 364 | _ = actualText 365 | 366 | if v := os.Getenv("WRITE_ACTUAL"); v != "" { 367 | if err := os.WriteFile(testFilePath, []byte(actualText), 0644); err != nil { 368 | t.Error(err) 369 | } 370 | } 371 | }) 372 | } 373 | } 374 | -------------------------------------------------------------------------------- /internal/cli/testdata/bumpfile_and_files: -------------------------------------------------------------------------------- 1 | /a: 2 | /b: 3 | $ bump -f b list a 4 | >stderr: 5 | both bumpfile and file arguments can't be specified 6 | -------------------------------------------------------------------------------- /internal/cli/testdata/bumpfile_different_name: -------------------------------------------------------------------------------- 1 | /a: 2 | name: 1 3 | /b: 4 | a /name: (1)/ static:1 5 | a 6 | $ bump -f b list 7 | >stdout: 8 | a 9 | -------------------------------------------------------------------------------- /internal/cli/testdata/bumpfile_err_in_included: -------------------------------------------------------------------------------- 1 | /Bumpfile: 2 | a 3 | /a: 4 | bump: err 5 | $ bump list 6 | >stderr: 7 | a:1: invalid name and arguments: unexpected end, expected \s 8 | -------------------------------------------------------------------------------- /internal/cli/testdata/bumpfile_glob: -------------------------------------------------------------------------------- 1 | /Bumpfile: 2 | name /name: (1)/ static:2 3 | a* 4 | a*/b* 5 | # name: 1 6 | /aaa: 7 | name: 1 8 | /aaa/bbb: 9 | name: 1 10 | $ bump update 11 | /aaa: 12 | name: 2 13 | /aaa/bbb: 14 | name: 2 15 | -------------------------------------------------------------------------------- /internal/cli/testdata/bumpfile_match_line_end: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /^name: (1)$/ static:2 3 | name: 1 4 | $ bump current a 5 | >stdout: 6 | a:2: name 1 -------------------------------------------------------------------------------- /internal/cli/testdata/bumpfile_no_update: -------------------------------------------------------------------------------- 1 | /Bumpfile: 2 | name /name: (1)/ static:2 3 | a 4 | # name: 1 5 | /a: 6 | name: 1 7 | $ bump update 8 | /a: 9 | name: 2 10 | -------------------------------------------------------------------------------- /internal/cli/testdata/bumpfile_only_glob: -------------------------------------------------------------------------------- 1 | /Bumpfile: 2 | a 3 | /a: 4 | bump: name /name: (1)/ static:2 5 | name: 1 6 | $ bump list 7 | >stdout: 8 | name 9 | -------------------------------------------------------------------------------- /internal/cli/testdata/dotstar_match: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: aaa /AAA_VERSION=(.*)/ static:222 3 | AAA_VERSION=111 4 | $ bump update a 5 | /a: 6 | bump: aaa /AAA_VERSION=(.*)/ static:222 7 | AAA_VERSION=222 8 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_has_no_config_or_matches: -------------------------------------------------------------------------------- 1 | /a: 2 | $ bump update a 3 | >stderr: 4 | a: has no configuration or current version matches 5 | --- 6 | /Bumpfile: 7 | $ bump update 8 | >stderr: 9 | Bumpfile: has no configuration 10 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_has_no_current_version_matches: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /name: (1)/ static:1 3 | $ bump list a 4 | >stderr: 5 | a:1: name has no current version matches 6 | --- 7 | /Bumpfile: 8 | name /name: (1)/ static:1 9 | $ bump list 10 | >stderr: 11 | Bumpfile:1: name has no current version matches 12 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_invalid_current_version_regexp: -------------------------------------------------------------------------------- 1 | /Bumpfile: 2 | name /(/ static:1 3 | $ bump update 4 | >stderr: 5 | Bumpfile:1: invalid current version regexp: "(" 6 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_invalid_pipeline: -------------------------------------------------------------------------------- 1 | /Bumpfile: 2 | a /(r))/ invalid 3 | $ bump update 4 | >stderr: 5 | Bumpfile:1: invalid: no filter matches 6 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_name_already_used_at: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /(re)/ static:1 3 | bump: name /(re)/ static:1 4 | $ bump list a 5 | >stderr: 6 | a:2: name already used at a:1 7 | --- 8 | /Bumpfile: 9 | name /(re)/ static:1 10 | name /(re)/ static:1 11 | $ bump list 12 | >stderr: 13 | Bumpfile:2: name already used at Bumpfile:1 14 | --- 15 | /a: 16 | bump: name /(re)/ static:1 17 | /Bumpfile: 18 | a 19 | name /(re)/ static:1 20 | $ bump list 21 | >stderr: 22 | Bumpfile:2: name already used at a:1 23 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_no_version_found: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /name: (1)/ static: 3 | name: 1 4 | $ bump update a 5 | >stderr: 6 | a:1: name: no version found 7 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_overlapping_matches: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name1 /name: (1)/ static:1 3 | bump: name2 /name: (1)/ static:1 4 | name: 1 5 | $ bump update a 6 | >stderr: 7 | a:1:name1 has overlapping matches with a:2:name2 at a:3 8 | a:2:name2 has overlapping matches with a:1:name1 at a:3 9 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_regexp_must_have_one_submatch1: -------------------------------------------------------------------------------- 1 | /Bumpfile: 2 | a /r/ static:2 3 | $ bump update 4 | >stderr: 5 | Bumpfile:1: regexp must have one submatch: "r" 6 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_regexp_must_have_one_submatch2: -------------------------------------------------------------------------------- 1 | /Bumpfile: 2 | a /(r)(r)/ static:2 3 | $ bump update 4 | >stderr: 5 | Bumpfile:1: regexp must have one submatch: "(r)(r)" 6 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_run_not_defined: -------------------------------------------------------------------------------- 1 | /Bumpfile: 2 | name command cmd arg1 arg2 3 | $ bump list 4 | >stderr: 5 | Bumpfile:1: name has not been defined yet 6 | -------------------------------------------------------------------------------- /internal/cli/testdata/err_run_unknown_config: -------------------------------------------------------------------------------- 1 | /Bumpfile: 2 | name /name: (1)/ static:1 3 | name abc cmd arg1 arg2 4 | $ bump list 5 | >stderr: 6 | Bumpfile:2: expected command, after or link: "name abc cmd arg1 arg2" 7 | -------------------------------------------------------------------------------- /internal/cli/testdata/files_ignore_bumpfile: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: a /name: (1)/ static:1 3 | name: 1 4 | /Bumpfile: 5 | b /(re)/ static:1 6 | $ bump list a 7 | >stdout: 8 | a 9 | -------------------------------------------------------------------------------- /internal/cli/testdata/help: -------------------------------------------------------------------------------- 1 | $ bump help 2 | >stderr: 3 | Usage: bump [OPTIONS] COMMAND 4 | OPTIONS: 5 | -e Comma separated names to exclude 6 | -f Bumpfile to read (Bumpfile) 7 | -i Comma separated names to include 8 | -r Run update commands (false) 9 | -v Verbose (false) 10 | 11 | COMMANDS: 12 | version Show version of bump itself (test) 13 | help [FILTER] Show help or help for a filter 14 | list [FILE...] Show bump configurations 15 | current [FILE...] Show current versions 16 | check [FILE...] Check for possible version updates 17 | update [FILE...] Update versions 18 | diff [FILE...] Show diff of what an update would change 19 | pipeline PIPELINE Run a filter pipeline 20 | 21 | BUMPFILE is a file with CONFIG:s or glob patterns of FILE:s 22 | FILE is a file with EMBEDCONFIG:s or versions to be checked and updated 23 | EMBEDCONFIG is "bump: CONFIG" 24 | CONFIG is 25 | NAME /REGEXP/ PIPELINE | 26 | NAME command COMMAND | 27 | NAME after COMMAND | 28 | NAME message MESSAGE | 29 | NAME link TITLE URL 30 | NAME is a configuration name 31 | REGEXP is a regexp with one submatch to find current version 32 | PIPELINE is a filter pipeline: FILTER|FILTER|... 33 | FILTER 34 | git:<repo> | <repo.git> 35 | gitrefs:<repo> 36 | depsdev:<system>:<package> 37 | docker:<image> 38 | svn:<repo> 39 | fetch:<url> | <http://> | <https://> 40 | semver:<constraint> | semver:<n.n.n-pre+build> | <constraint> | <n.n.n-pre+build> 41 | re:/<regexp>/ | re:/<regexp>/<template>/ | /<regexp>/ | /<regexp>/<template>/ 42 | sort 43 | key:<name> | @<name> 44 | static:<name[:key=value:...]>,... 45 | err:<error> 46 | -------------------------------------------------------------------------------- /internal/cli/testdata/help_filter: -------------------------------------------------------------------------------- 1 | $ bump help static 2 | >stdout: 3 | Syntax: 4 | static:<name[:key=value:...]>,... 5 | 6 | Produce versions from filter argument. 7 | 8 | Examples: 9 | bump pipeline 'static:1,2,3,4:key=value:a=b|sort' 10 | -------------------------------------------------------------------------------- /internal/cli/testdata/multi_currents: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /name: (1)/ static:2 3 | /b: 4 | name: 1 5 | $ bump update a b 6 | /b: 7 | name: 2 8 | -------------------------------------------------------------------------------- /internal/cli/testdata/multi_versions: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: namea /namea: (1)/ static:2 3 | bump: nameb /nameb: (1)/ static:3 4 | /b: 5 | namea: 1 6 | nameb: 1 7 | $ bump update a b 8 | /b: 9 | namea: 2 10 | nameb: 3 11 | -------------------------------------------------------------------------------- /internal/cli/testdata/option_after_command: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name1 /name1: (1)/ static:2 3 | bump: name2 /name2: (1)/ static:2 4 | name1: 1 5 | name2: 1 6 | $ bump check -i name1 a 7 | >stdout: 8 | name1 2 9 | -------------------------------------------------------------------------------- /internal/cli/testdata/parsing: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: a-b_c-123 /name: (1)/ static:2 3 | bump: a-b_c-123 link "title 1" url1 4 | bump: a-b_c-123 link title2 url2 5 | name: 1 6 | $ bump -v list a 7 | >stdout: 8 | a:1: a-b_c-123 /name: (1)/ static:2 9 | a:2: a-b_c-123 link "title 1" url1 10 | a:3: a-b_c-123 link "title2" url2 11 | -------------------------------------------------------------------------------- /internal/cli/testdata/pipeline: -------------------------------------------------------------------------------- 1 | $ bump pipeline static:1 2 | >stdout: 3 | 1 4 | -------------------------------------------------------------------------------- /internal/cli/testdata/select_excluded: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name1 /name1: (1)/ static:2 3 | bump: name2 /name2: (1)/ static:2 4 | name1: 1 5 | name2: 1 6 | $ bump -e name1 check a 7 | >stdout: 8 | name2 2 9 | -------------------------------------------------------------------------------- /internal/cli/testdata/select_included: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name1 /name1: (1)/ static:2 3 | bump: name2 /name2: (1)/ static:2 4 | name1: 1 5 | name2: 1 6 | $ bump -i name1 check a 7 | >stdout: 8 | name1 2 9 | -------------------------------------------------------------------------------- /internal/cli/testdata/simple_after: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /name: (1)/ static:2 3 | bump: name after cmd arg1 arg2 4 | name: 1 5 | $ bump -r update a 6 | /a: 7 | bump: name /name: (1)/ static:2 8 | bump: name after cmd arg1 arg2 9 | name: 2 10 | !cmd arg1 arg2 11 | NAME=name 12 | LATEST=2 13 | --- 14 | /Bumpfile: 15 | name /name: (1)/ static:2 16 | name after cmd arg1 arg2 17 | a 18 | /a: 19 | name: 1 20 | $ bump -r update 21 | /a: 22 | name: 2 23 | !cmd arg1 arg2 24 | NAME=name 25 | LATEST=2 26 | --- 27 | /Bumpfile: 28 | name /name: (1)/ static:1 29 | name after cmd arg1 arg2 30 | a 31 | /a: 32 | name: 1 33 | $ bump -r update 34 | -------------------------------------------------------------------------------- /internal/cli/testdata/simple_after_skip: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /name: (1)/ static:2 3 | bump: name after cmd arg1 arg2 4 | name: 1 5 | $ bump update a 6 | /a: 7 | bump: name /name: (1)/ static:2 8 | bump: name after cmd arg1 arg2 9 | name: 2 10 | >stdout: 11 | skipping name: shell: NAME=name LATEST=2 cmd arg1 arg2 12 | --- 13 | /Bumpfile: 14 | name /name: (1)/ static:2 15 | name after cmd arg1 arg2 16 | a 17 | /a: 18 | name: 1 19 | $ bump update 20 | /a: 21 | name: 2 22 | >stdout: 23 | skipping name: shell: NAME=name LATEST=2 cmd arg1 arg2 24 | --- 25 | /Bumpfile: 26 | name /name: (1)/ static:1 27 | name after cmd arg1 arg2 28 | a 29 | /a: 30 | name: 1 31 | $ bump update 32 | -------------------------------------------------------------------------------- /internal/cli/testdata/simple_check: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /name: (1)/ static:2 3 | name: 1 4 | $ bump check a 5 | >stdout: 6 | name 2 7 | --- 8 | /Bumpfile: 9 | name /name: (1)/ static:2 10 | a 11 | /a: 12 | name: 1 13 | $ bump check 14 | >stdout: 15 | name 2 16 | --- 17 | /Bumpfile: 18 | name /name: (1)/ static:2 19 | a 20 | /a: 21 | name: 1 22 | $ bump -v check 23 | >stdout: 24 | a:1: name 1 -> 2 0.000s 25 | -------------------------------------------------------------------------------- /internal/cli/testdata/simple_command: -------------------------------------------------------------------------------- 1 | # command assumes the external command will do the replacement 2 | /a: 3 | bump: name /name: (1)/ static:2 4 | bump: name command cmd arg1 arg2 5 | name: 1 6 | $ bump -r update a 7 | !cmd arg1 arg2 8 | NAME=name 9 | LATEST=2 10 | --- 11 | /Bumpfile: 12 | name /name: (1)/ static:2 13 | name command cmd arg1 arg2 14 | a 15 | /a: 16 | name: 1 17 | $ bump -r update 18 | !cmd arg1 arg2 19 | NAME=name 20 | LATEST=2 21 | --- 22 | /Bumpfile: 23 | name /name: (1)/ static:1 24 | name command cmd arg1 arg2 25 | a 26 | /a: 27 | name: 1 28 | $ bump -r update 29 | -------------------------------------------------------------------------------- /internal/cli/testdata/simple_current: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /name: (1)/ static:2 3 | name: 1 4 | $ bump current a 5 | >stdout: 6 | a:2: name 1 7 | --- 8 | /Bumpfile: 9 | name /name: (1)/ static:2 10 | a 11 | /a: 12 | name: 1 13 | $ bump current 14 | >stdout: 15 | a:1: name 1 16 | -------------------------------------------------------------------------------- /internal/cli/testdata/simple_diff: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /name: (1)/ static:2 3 | name: 1 4 | $ bump diff a 5 | >stdout: 6 | --- a 7 | +++ a 8 | @@ -1,3 +1,3 @@ 9 | bump: name /name: (1)/ static:2 10 | -name: 1 11 | +name: 2 12 | 13 | --- 14 | /Bumpfile: 15 | name /name: (1)/ static:2 16 | a 17 | /a: 18 | name: 1 19 | $ bump diff 20 | >stdout: 21 | --- a 22 | +++ a 23 | @@ -1,2 +1,2 @@ 24 | -name: 1 25 | +name: 2 26 | 27 | -------------------------------------------------------------------------------- /internal/cli/testdata/simple_list: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /name: (1)/ static:2 3 | bump: name command cmd1 4 | bump: name command cmd2 5 | bump: name after after1 6 | bump: name after after2 7 | bump: name message msg1 8 | bump: name message msg1 9 | bump: name link "title 1" url1 10 | bump: name link title2 url2 11 | name: 1 12 | $ bump list a 13 | >stdout: 14 | name 15 | --- 16 | /Bumpfile: 17 | name /name: (1)/ static:2 18 | name command cmd1 19 | name command cmd2 20 | name after after1 21 | name after after2 22 | name message msg1 23 | name message msg1 24 | name link "title 1" url1 25 | name link "title2" url2 26 | a 27 | /a: 28 | name: 1 29 | $ bump list 30 | >stdout: 31 | name 32 | --- 33 | /a: 34 | bump: name /name: (1)/ static:2 35 | bump: name command cmd1 36 | bump: name command cmd2 37 | bump: name after after1 38 | bump: name after after2 39 | bump: name message msg1 40 | bump: name message msg1 41 | bump: name link "title 1" url1 42 | bump: name link title2 url2 43 | name: 1 44 | $ bump -v list a 45 | >stdout: 46 | a:1: name /name: (1)/ static:2 47 | a:2: name command cmd1 48 | a:3: name command cmd2 49 | a:4: name after after1 50 | a:5: name after after2 51 | a:6: name message msg1 52 | a:7: name message msg1 53 | a:8: name link "title 1" url1 54 | a:9: name link "title2" url2 55 | --- 56 | /Bumpfile: 57 | name /name: (1)/ static:2 58 | name command cmd1 59 | name command cmd2 60 | name after after1 61 | name after after2 62 | name message msg1 63 | name message msg1 64 | name link "title 1" url1 65 | name link title2 url2 66 | a 67 | /a: 68 | name: 1 69 | $ bump -v list 70 | >stdout: 71 | Bumpfile:1: name /name: (1)/ static:2 72 | Bumpfile:2: name command cmd1 73 | Bumpfile:3: name command cmd2 74 | Bumpfile:4: name after after1 75 | Bumpfile:5: name after after2 76 | Bumpfile:6: name message msg1 77 | Bumpfile:7: name message msg1 78 | Bumpfile:8: name link "title 1" url1 79 | Bumpfile:9: name link "title2" url2 80 | -------------------------------------------------------------------------------- /internal/cli/testdata/simple_skip_command: -------------------------------------------------------------------------------- 1 | # command assumes the external command will do the replacement 2 | /a: 3 | bump: name /name: (1)/ static:2 4 | bump: name command cmd arg1 arg2 5 | name: 1 6 | $ bump update a 7 | >stdout: 8 | skipping name: shell: NAME=name LATEST=2 cmd arg1 arg2 9 | --- 10 | /Bumpfile: 11 | name /name: (1)/ static:2 12 | name command cmd arg1 arg2 13 | a 14 | /a: 15 | name: 1 16 | $ bump update 17 | >stdout: 18 | skipping name: shell: NAME=name LATEST=2 cmd arg1 arg2 19 | --- 20 | /Bumpfile: 21 | name /name: (1)/ static:1 22 | name command cmd arg1 arg2 23 | a 24 | /a: 25 | name: 1 26 | $ bump update 27 | -------------------------------------------------------------------------------- /internal/cli/testdata/simple_update: -------------------------------------------------------------------------------- 1 | /a: 2 | bump: name /name: (1)/ static:2 3 | name: 1 4 | $ bump update a 5 | /a: 6 | bump: name /name: (1)/ static:2 7 | name: 2 8 | --- 9 | /Bumpfile: 10 | name /name: (1)/ static:2 11 | a 12 | /a: 13 | name: 1 14 | $ bump update 15 | /a: 16 | name: 2 17 | -------------------------------------------------------------------------------- /internal/cli/testdata/version: -------------------------------------------------------------------------------- 1 | $ bump version 2 | >stdout: 3 | test 4 | -------------------------------------------------------------------------------- /internal/deepequal/deepequal.go: -------------------------------------------------------------------------------- 1 | package deepequal 2 | 3 | import ( 4 | "fmt" 5 | "reflect" 6 | 7 | "github.com/pmezard/go-difflib/difflib" 8 | ) 9 | 10 | type tf interface { 11 | Errorf(format string, args ...interface{}) 12 | Fatalf(format string, args ...interface{}) 13 | } 14 | 15 | func testDeepEqual(fn func(format string, args ...interface{}), name string, expected interface{}, actual interface{}) { 16 | expectedStr := fmt.Sprintf("%s", expected) 17 | actualStr := fmt.Sprintf("%s", actual) 18 | 19 | if !reflect.DeepEqual(expected, actual) { 20 | diff := difflib.UnifiedDiff{ 21 | A: difflib.SplitLines(expectedStr), 22 | B: difflib.SplitLines(actualStr), 23 | FromFile: fmt.Sprintf("%s expected", name), 24 | ToFile: fmt.Sprintf("%s actual", name), 25 | Context: 3, 26 | } 27 | udiff, err := difflib.GetUnifiedDiffString(diff) 28 | if err != nil { 29 | panic(err) 30 | } 31 | fn("\n%s", udiff) 32 | } 33 | } 34 | 35 | func Error(t tf, name string, expected interface{}, actual interface{}) { 36 | testDeepEqual(t.Errorf, name, expected, actual) 37 | } 38 | 39 | func Fatal(t tf, name string, expected interface{}, actual interface{}) { 40 | testDeepEqual(t.Fatalf, name, expected, actual) 41 | } 42 | -------------------------------------------------------------------------------- /internal/deepequal/deepequal_test.go: -------------------------------------------------------------------------------- 1 | package deepequal_test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/wader/bump/internal/deepequal" 8 | ) 9 | 10 | type tfFn func(format string, args ...interface{}) 11 | 12 | func (fn tfFn) Errorf(format string, args ...interface{}) { 13 | fn(format, args...) 14 | } 15 | 16 | func (fn tfFn) Fatalf(format string, args ...interface{}) { 17 | fn(format, args...) 18 | } 19 | 20 | func TestError(t *testing.T) { 21 | deepequal.Error( 22 | tfFn(func(format string, args ...interface{}) { 23 | expected := ` 24 | --- name expected 25 | +++ name actual 26 | @@ -1 +1 @@ 27 | -aaaaaaaaa 28 | +aaaaaabba 29 | ` 30 | actual := fmt.Sprintf(format, args...) 31 | if expected != actual { 32 | t.Errorf("expected %s, got %s", expected, actual) 33 | } 34 | }), 35 | "name", 36 | "aaaaaaaaa", "aaaaaabba", 37 | ) 38 | } 39 | -------------------------------------------------------------------------------- /internal/dockerv2/dockerv2.go: -------------------------------------------------------------------------------- 1 | package dockerv2 2 | 3 | import ( 4 | "encoding/csv" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "net/url" 9 | "strings" 10 | ) 11 | 12 | type Registry struct { 13 | Host string 14 | Image string 15 | Token string 16 | } 17 | 18 | var defaultRegistry = Registry{ 19 | Host: "index.docker.io", 20 | } 21 | 22 | const listTagsURLTemplate = `https://%s/v2/%s/tags/list` 23 | 24 | func NewFromImage(image string) (*Registry, error) { 25 | parts := strings.Split(image, "/") 26 | r := defaultRegistry 27 | switch { 28 | case len(parts) == 0: 29 | return &r, fmt.Errorf("invalid image") 30 | case len(parts) == 1: 31 | // image 32 | r.Image = "library/" + image 33 | return &r, nil 34 | case strings.Contains(parts[0], "."): 35 | // host.tldr/image 36 | r.Host = parts[0] 37 | r.Image = strings.Join(parts[1:], "/") 38 | return &r, nil 39 | default: 40 | // repo/image 41 | r.Image = image 42 | return &r, nil 43 | } 44 | } 45 | 46 | // The WWW-Authenticate Response Header Field 47 | // https://www.rfc-editor.org/rfc/rfc6750#section-3 48 | type WWWAuth struct { 49 | Scheme string 50 | Params map[string]string 51 | } 52 | 53 | // quoteSplit splits but respects quotes and escapes, and can mix quotes 54 | func quoteSplit(s string, sep rune) ([]string, error) { 55 | r := csv.NewReader(strings.NewReader(s)) 56 | // allows mix quotes and explicit "," 57 | r.LazyQuotes = true 58 | r.Comma = rune(sep) 59 | return r.Read() 60 | } 61 | 62 | // WWW-Authenticate: Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:org/image:pull" 63 | func ParseWWWAuth(s string) (WWWAuth, error) { 64 | var w WWWAuth 65 | parts := strings.SplitN(s, " ", 2) 66 | if len(parts) != 2 { 67 | return WWWAuth{}, fmt.Errorf("invalid params") 68 | } 69 | w.Scheme = parts[0] 70 | 71 | pairs, pairsErr := quoteSplit(strings.TrimSpace(parts[1]), ',') 72 | if pairsErr != nil { 73 | return WWWAuth{}, pairsErr 74 | } 75 | 76 | w.Params = map[string]string{} 77 | for _, p := range pairs { 78 | kv, kvErr := quoteSplit(p, '=') 79 | if kvErr != nil { 80 | return WWWAuth{}, kvErr 81 | } 82 | if len(kv) != 2 { 83 | return WWWAuth{}, fmt.Errorf("invalid pair") 84 | } 85 | w.Params[kv[0]] = kv[1] 86 | } 87 | 88 | return w, nil 89 | } 90 | 91 | func get(rawURL string, doAuth bool, token string, out interface{}) error { 92 | req, err := http.NewRequest(http.MethodGet, rawURL, nil) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | if token != "" { 98 | req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", token)) 99 | } 100 | 101 | r, err := http.DefaultClient.Do(req) 102 | if err != nil { 103 | return fmt.Errorf("request failed: %w", err) 104 | } 105 | defer r.Body.Close() 106 | 107 | // 4xx some client error 108 | if r.StatusCode/100 == 4 { 109 | if doAuth && r.StatusCode == http.StatusUnauthorized { 110 | wwwAuth := r.Header.Get("WWW-Authenticate") 111 | if wwwAuth == "" { 112 | return fmt.Errorf("no WWW-Authenticate found") 113 | } 114 | 115 | w, wwwAuthErr := ParseWWWAuth(wwwAuth) 116 | if wwwAuthErr != nil { 117 | return wwwAuthErr 118 | } 119 | 120 | authURLValues := url.Values{} 121 | authURLValues.Set("service", w.Params["service"]) 122 | authURLValues.Set("scope", w.Params["scope"]) 123 | authURL, authURLErr := url.Parse(w.Params["realm"]) 124 | if authURLErr != nil { 125 | return authURLErr 126 | } 127 | authURL.RawQuery = authURLValues.Encode() 128 | 129 | var authResp struct { 130 | Token string `json:"token"` 131 | } 132 | authTokenErr := get(authURL.String(), false, "", &authResp) 133 | if authTokenErr != nil { 134 | return authTokenErr 135 | } 136 | 137 | return get(rawURL, false, authResp.Token, out) 138 | } 139 | return fmt.Errorf(r.Status) 140 | } 141 | 142 | // not 2xx success 143 | if r.StatusCode/100 != 2 { 144 | return fmt.Errorf("error response: %s", r.Status) 145 | } 146 | 147 | if err := json.NewDecoder(r.Body).Decode(&out); err != nil { 148 | return fmt.Errorf("failed parse response: %w", err) 149 | } 150 | 151 | return nil 152 | } 153 | 154 | func (r *Registry) Tags() ([]string, error) { 155 | var resp struct { 156 | Tags []string `json:"tags"` 157 | } 158 | if err := get(fmt.Sprintf(listTagsURLTemplate, r.Host, r.Image), true, "", &resp); err != nil { 159 | return nil, err 160 | } 161 | return resp.Tags, nil 162 | } 163 | -------------------------------------------------------------------------------- /internal/dockerv2/dockerv2_test.go: -------------------------------------------------------------------------------- 1 | package dockerv2_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/wader/bump/internal/dockerv2" 7 | ) 8 | 9 | func TestParseWWWAuth(t *testing.T) { 10 | w, err := dockerv2.ParseWWWAuth(`Bearer realm="https://ghcr.io/token",service="ghcr.io",scope="repository:org/image:pull"`) 11 | if err != nil { 12 | t.Fatal(err) 13 | } 14 | if w.Scheme != "Bearer" { 15 | t.Fatalf("schema %s", w.Scheme) 16 | } 17 | if v := w.Params["service"]; v != "ghcr.io" { 18 | t.Fatalf("service %s", v) 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /internal/filter/all/all.go: -------------------------------------------------------------------------------- 1 | package all 2 | 3 | import ( 4 | "github.com/wader/bump/internal/filter" 5 | "github.com/wader/bump/internal/filter/depsdev" 6 | "github.com/wader/bump/internal/filter/docker" 7 | "github.com/wader/bump/internal/filter/err" 8 | "github.com/wader/bump/internal/filter/fetch" 9 | "github.com/wader/bump/internal/filter/git" 10 | "github.com/wader/bump/internal/filter/gitrefs" 11 | "github.com/wader/bump/internal/filter/key" 12 | "github.com/wader/bump/internal/filter/re" 13 | "github.com/wader/bump/internal/filter/semver" 14 | "github.com/wader/bump/internal/filter/sort" 15 | "github.com/wader/bump/internal/filter/static" 16 | "github.com/wader/bump/internal/filter/svn" 17 | ) 18 | 19 | // Filters return all filters 20 | func Filters() []filter.NamedFilter { 21 | return []filter.NamedFilter{ 22 | {Name: git.Name, Help: git.Help, NewFn: git.New}, // before fetch to let it get URLs ending with .git 23 | {Name: gitrefs.Name, Help: gitrefs.Help, NewFn: gitrefs.New}, 24 | {Name: depsdev.Name, Help: depsdev.Help, NewFn: depsdev.New}, 25 | {Name: docker.Name, Help: docker.Help, NewFn: docker.New}, 26 | {Name: svn.Name, Help: svn.Help, NewFn: svn.New}, 27 | {Name: fetch.Name, Help: fetch.Help, NewFn: fetch.New}, 28 | {Name: semver.Name, Help: semver.Help, NewFn: semver.New}, 29 | {Name: re.Name, Help: re.Help, NewFn: re.New}, 30 | {Name: sort.Name, Help: sort.Help, NewFn: sort.New}, 31 | {Name: key.Name, Help: key.Help, NewFn: key.New}, 32 | {Name: static.Name, Help: static.Help, NewFn: static.New}, 33 | {Name: err.Name, Help: err.Help, NewFn: err.New}, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/filter/depsdev/depsdev.go: -------------------------------------------------------------------------------- 1 | package depsdev 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/wader/bump/internal/filter" 11 | ) 12 | 13 | const depsDevURLTemplate = `https://api.deps.dev/v3alpha/systems/%s/packages/%s` 14 | 15 | // Name of filter 16 | const Name = "depsdev" 17 | 18 | // Help text 19 | var Help = ` 20 | depsdev:<system>:<package> 21 | 22 | Produce versions from https://deps.dev. 23 | 24 | Supported package systems npm, go, maven, pypi and cargo. 25 | 26 | depsdev:npm:react|* 27 | depsdev:go:golang.org/x/net 28 | depsdev:maven:log4j:log4j|^1 29 | depsdev:pypi:av|* 30 | depsdev:cargo:serde|* 31 | `[1:] 32 | 33 | // New depsdev filter 34 | func New(prefix string, arg string) (filter filter.Filter, err error) { 35 | if prefix != Name { 36 | return nil, nil 37 | } 38 | if arg == "" { 39 | return nil, fmt.Errorf("needs a image name") 40 | } 41 | 42 | parts := strings.SplitN(arg, ":", 2) 43 | if len(parts) != 2 { 44 | return nil, fmt.Errorf("requires depsdev:<system>:<package>") 45 | } 46 | 47 | return depsDevFilter{ 48 | system: parts[0], 49 | package_: parts[1], 50 | }, nil 51 | } 52 | 53 | type depsDevFilter struct { 54 | system string 55 | package_ string 56 | } 57 | 58 | func (f depsDevFilter) String() string { 59 | return Name + ":" + f.system + ":" + f.package_ 60 | } 61 | 62 | func (f depsDevFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 63 | var response struct { 64 | Versions []struct { 65 | VersionKey struct { 66 | Version string `json:"version"` 67 | } `json:"versionKey"` 68 | } `json:"versions"` 69 | } 70 | 71 | r, err := http.Get( 72 | fmt.Sprintf( 73 | depsDevURLTemplate, 74 | url.PathEscape(f.system), 75 | url.PathEscape(f.package_)), 76 | ) 77 | if err != nil { 78 | return nil, "", err 79 | } 80 | defer r.Body.Close() 81 | 82 | if r.StatusCode/100 != 2 { 83 | return nil, "", fmt.Errorf("error response: %s", r.Status) 84 | } 85 | 86 | jd := json.NewDecoder(r.Body) 87 | if err := jd.Decode(&response); err != nil { 88 | return nil, "", err 89 | } 90 | 91 | var vs filter.Versions 92 | for _, v := range response.Versions { 93 | vs = append(vs, filter.NewVersionWithName( 94 | // TODO: better way, go versions start with "v" 95 | strings.TrimLeft(v.VersionKey.Version, "v"), 96 | nil, 97 | )) 98 | } 99 | 100 | return vs, versionKey, nil 101 | } 102 | -------------------------------------------------------------------------------- /internal/filter/docker/docker.go: -------------------------------------------------------------------------------- 1 | package docker 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/wader/bump/internal/dockerv2" 7 | "github.com/wader/bump/internal/filter" 8 | ) 9 | 10 | // Name of filter 11 | const Name = "docker" 12 | 13 | // Help text 14 | var Help = ` 15 | docker:<image> 16 | 17 | Produce versions from a image on docker hub or other registry. 18 | Currently only supports anonymous access. 19 | 20 | docker:alpine|^3 21 | docker:mwader/static-ffmpeg|^4 22 | docker:ghcr.io/nginx-proxy/nginx-proxy|^0.9 23 | `[1:] 24 | 25 | // New docker filter 26 | func New(prefix string, arg string) (filter filter.Filter, err error) { 27 | if prefix != Name { 28 | return nil, nil 29 | } 30 | if arg == "" { 31 | return nil, fmt.Errorf("needs a image name") 32 | } 33 | 34 | registry, err := dockerv2.NewFromImage(arg) 35 | if err != nil { 36 | return nil, fmt.Errorf("%w: %s", err, arg) 37 | } 38 | 39 | return dockerFilter{ 40 | image: arg, 41 | registry: registry, 42 | }, nil 43 | } 44 | 45 | type dockerFilter struct { 46 | image string 47 | registry *dockerv2.Registry 48 | } 49 | 50 | func (f dockerFilter) String() string { 51 | return Name + ":" + f.image 52 | } 53 | 54 | func (f dockerFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 55 | tags, tagsErr := f.registry.Tags() 56 | if tagsErr != nil { 57 | return filter.Versions{}, "", tagsErr 58 | } 59 | 60 | tagNames := append(filter.Versions{}, versions...) 61 | for _, t := range tags { 62 | tagNames = append(tagNames, filter.NewVersionWithName(t, nil)) 63 | } 64 | 65 | return tagNames, versionKey, nil 66 | } 67 | -------------------------------------------------------------------------------- /internal/filter/err/err.go: -------------------------------------------------------------------------------- 1 | package err 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/wader/bump/internal/filter" 7 | ) 8 | 9 | // Name of filter 10 | const Name = "err" 11 | 12 | // Help text 13 | var Help = ` 14 | err:<error> 15 | 16 | Fail with error message. Used for testing. 17 | 18 | err:test 19 | `[1:] 20 | 21 | // New err filter 22 | func New(prefix string, arg string) (filter filter.Filter, err error) { 23 | if prefix != Name { 24 | return nil, nil 25 | } 26 | return errFilter{err: errors.New(arg)}, nil 27 | } 28 | 29 | type errFilter struct { 30 | err error 31 | } 32 | 33 | func (f errFilter) String() string { 34 | return Name + ":" + f.err.Error() 35 | } 36 | 37 | func (f errFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 38 | return nil, versionKey, f.err 39 | } 40 | -------------------------------------------------------------------------------- /internal/filter/fetch/fetch.go: -------------------------------------------------------------------------------- 1 | package fetch 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "strings" 8 | 9 | "github.com/wader/bump/internal/filter" 10 | ) 11 | 12 | // Name of filter 13 | const Name = "fetch" 14 | 15 | // Help text 16 | var Help = ` 17 | fetch:<url>, <http://> or <https://> 18 | 19 | Fetch a URL and produce one version with the content as the key "name". 20 | 21 | fetch:http://libjpeg.sourceforge.net|/latest release is version (\w+)/ 22 | `[1:] 23 | 24 | // New http fetch filter 25 | func New(prefix string, arg string) (filter filter.Filter, err error) { 26 | var urlStr string 27 | 28 | if prefix == Name { 29 | urlStr = arg 30 | } else if strings.HasPrefix(arg, "//") { 31 | for _, p := range []string{"http", "https"} { 32 | if prefix != p { 33 | continue 34 | } 35 | 36 | urlStr = prefix + ":" + arg 37 | break 38 | } 39 | } else { 40 | return nil, nil 41 | } 42 | 43 | if urlStr == "" { 44 | if prefix != Name { 45 | return nil, nil 46 | } 47 | return nil, fmt.Errorf("needs a url") 48 | } 49 | 50 | return fetchFilter{urlStr: urlStr}, nil 51 | } 52 | 53 | type fetchFilter struct { 54 | urlStr string 55 | } 56 | 57 | func (f fetchFilter) String() string { 58 | return Name + ":" + f.urlStr 59 | } 60 | 61 | func (f fetchFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 62 | req, err := http.NewRequest("GET", f.urlStr, nil) 63 | req.Header.Add("User-Agent", "bump (https://github.com/wader/bump)") 64 | if err != nil { 65 | return nil, "", err 66 | } 67 | r, err := http.DefaultClient.Do(req) 68 | if err != nil { 69 | return nil, "", err 70 | } 71 | defer r.Body.Close() 72 | 73 | if r.StatusCode/100 != 2 { 74 | return nil, "", fmt.Errorf("error response: %s", r.Status) 75 | } 76 | 77 | b, err := io.ReadAll(r.Body) 78 | if err != nil { 79 | return nil, "", err 80 | } 81 | 82 | vs := append(filter.Versions{}, versions...) 83 | vs = append(vs, filter.NewVersionWithName(string(b), nil)) 84 | 85 | return vs, versionKey, nil 86 | } 87 | -------------------------------------------------------------------------------- /internal/filter/filter.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "strings" 7 | ) 8 | 9 | // ErrNoFilterMatching no filter matches filter expression 10 | var ErrNoFilterMatching = errors.New("no filter matches") 11 | 12 | // Filter filters, translate or produces versions 13 | type Filter interface { 14 | String() string 15 | Filter(versions Versions, versionKey string) (newVersions Versions, newVersionKey string, err error) 16 | } 17 | 18 | // NewFilterFn function used to create a new filter 19 | type NewFilterFn func(prefix string, arg string) (Filter, error) 20 | 21 | // NamedFilter is a struct with filter name, help and new function 22 | type NamedFilter struct { 23 | Name string 24 | Help string 25 | NewFn NewFilterFn 26 | } 27 | 28 | var filterNameArgRe = regexp.MustCompile(`^(\w+):(.*)$`) 29 | 30 | // NewFilter creates a new filter from expression based on list of filter create functions 31 | func NewFilter(filters []NamedFilter, filterExp string) (Filter, error) { 32 | nameArgSM := filterNameArgRe.FindStringSubmatch(filterExp) 33 | var name, arg string 34 | if len(nameArgSM) == 3 { 35 | name = nameArgSM[1] 36 | arg = nameArgSM[2] 37 | } else { 38 | arg = filterExp 39 | } 40 | 41 | // match name, "name:..." or "name" without args 42 | for _, nf := range filters { 43 | if f, err := nf.NewFn(name, arg); err != nil { 44 | return nil, err 45 | } else if f != nil { 46 | return f, nil 47 | } 48 | } 49 | 50 | // fuzzy arg as prefix, "sort" etc 51 | for _, nf := range filters { 52 | if f, err := nf.NewFn(arg, ""); f != nil { 53 | return f, err 54 | } 55 | } 56 | 57 | // fuzzy "^4", "/re/" etc 58 | if name == "" { 59 | for _, nf := range filters { 60 | if f, err := nf.NewFn("", arg); f != nil { 61 | return f, err 62 | } 63 | } 64 | } 65 | 66 | return nil, ErrNoFilterMatching 67 | } 68 | 69 | // ParseHelp text 70 | func ParseHelp(help string) (syntax []string, description string, examples []string) { 71 | syntaxSplitRe := regexp.MustCompile(`(, | or )`) 72 | parts := strings.Split(help, "\n\n") 73 | syntax = syntaxSplitRe.Split(parts[0], -1) 74 | description = strings.Join(parts[1:len(parts)-1], "\n\n") 75 | examples = strings.Split(strings.TrimSpace(parts[len(parts)-1]), "\n") 76 | 77 | return syntax, description, examples 78 | } 79 | -------------------------------------------------------------------------------- /internal/filter/git/git.go: -------------------------------------------------------------------------------- 1 | package git 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/wader/bump/internal/filter" 9 | "github.com/wader/bump/internal/gitrefs" 10 | ) 11 | 12 | // Name of filter 13 | const Name = "git" 14 | 15 | // Help text 16 | var Help = ` 17 | git:<repo> or <repo.git> 18 | 19 | Produce versions from tags for a git repository. Name will be 20 | the version found in the tag, commit the commit hash or tag object. 21 | 22 | Use gitrefs filter to get all refs unfiltered. 23 | 24 | https://github.com/git/git.git|* 25 | `[1:] 26 | 27 | // default ref filter 28 | // refs/tags/<non-digits><version-number> -> version-number 29 | var refFilterRe = regexp.MustCompile(`^refs/tags/[^\d]*([\d\.\-]+)$`) 30 | 31 | // New git filter 32 | func New(prefix string, arg string) (filter filter.Filter, err error) { 33 | // TODO hmm 34 | if prefix == Name || 35 | (strings.HasSuffix(arg, ".git") && 36 | (prefix == "git" || prefix == "http" || prefix == "https")) { 37 | if strings.HasPrefix(arg, "//") { 38 | arg = prefix + ":" + arg 39 | } 40 | } else { 41 | return nil, nil 42 | } 43 | 44 | if arg == "" { 45 | return nil, fmt.Errorf("needs a repo") 46 | } 47 | 48 | return gitFilter{repo: arg}, nil 49 | } 50 | 51 | type gitFilter struct { 52 | repo string 53 | } 54 | 55 | func (f gitFilter) String() string { 56 | return Name + ":" + f.repo 57 | } 58 | 59 | func (f gitFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 60 | refPairs, err := gitrefs.Refs(f.repo, gitrefs.AllProtos) 61 | if err != nil { 62 | return nil, "", err 63 | } 64 | 65 | vs := append(filter.Versions{}, versions...) 66 | for _, p := range refPairs { 67 | sm := refFilterRe.FindStringSubmatch(p.Name) 68 | // find first non-empty submatch 69 | if sm == nil { 70 | continue 71 | } 72 | var name string 73 | for _, m := range sm[1:] { 74 | if m != "" { 75 | name = m 76 | break 77 | } 78 | } 79 | vs = append(vs, filter.NewVersionWithName(name, map[string]string{"commit": p.ObjID})) 80 | } 81 | 82 | return vs, versionKey, nil 83 | } 84 | -------------------------------------------------------------------------------- /internal/filter/gitrefs/gitrefs.go: -------------------------------------------------------------------------------- 1 | package gitrefs 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/wader/bump/internal/filter" 7 | "github.com/wader/bump/internal/gitrefs" 8 | ) 9 | 10 | // Name of filter 11 | const Name = "gitrefs" 12 | 13 | // Help text 14 | var Help = ` 15 | gitrefs:<repo> 16 | 17 | Produce versions from all refs for a git repository. Name will be the whole ref 18 | like "refs/tags/v2.7.3" and commit will be the commit hash. 19 | 20 | Use git filter to get versions from only tags. 21 | 22 | gitrefs:https://github.com/git/git.git 23 | `[1:] 24 | 25 | // New gitrefs filter 26 | func New(prefix string, arg string) (filter filter.Filter, err error) { 27 | if prefix != Name { 28 | return nil, nil 29 | } 30 | 31 | if arg == "" { 32 | return nil, fmt.Errorf("needs a repo") 33 | } 34 | 35 | return gitRefsFilter{repo: arg}, nil 36 | } 37 | 38 | type gitRefsFilter struct { 39 | repo string 40 | } 41 | 42 | func (f gitRefsFilter) String() string { 43 | return Name + ":" + f.repo 44 | } 45 | 46 | func (f gitRefsFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 47 | refPairs, err := gitrefs.Refs(f.repo, gitrefs.AllProtos) 48 | if err != nil { 49 | return nil, "", err 50 | } 51 | 52 | vs := append(filter.Versions{}, versions...) 53 | for _, p := range refPairs { 54 | vs = append(vs, filter.NewVersionWithName(p.Name, map[string]string{"commit": p.ObjID})) 55 | } 56 | 57 | return vs, versionKey, nil 58 | } 59 | -------------------------------------------------------------------------------- /internal/filter/key/key.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/wader/bump/internal/filter" 8 | ) 9 | 10 | // Name of filter 11 | const Name = "key" 12 | 13 | // Help text 14 | var Help = ` 15 | key:<name> or @<name> 16 | 17 | Change default key for a pipeline. Useful to have last in a pipeline 18 | to use git commit hash instead of tag name etc or in the middle of 19 | a pipeline if you want to regexp filter on something else than name. 20 | 21 | static:1.0:hello=world|@hello 22 | static:1.0:hello=world|@name 23 | static:1.0:hello=world|key:hello 24 | `[1:] 25 | 26 | // New key filter 27 | // Used to change default key in a pipeline. 28 | func New(prefix string, arg string) (filter filter.Filter, err error) { 29 | if prefix != Name && prefix != "" { 30 | return nil, nil 31 | } 32 | 33 | if prefix == Name { 34 | if arg == "" { 35 | return nil, fmt.Errorf("should be key:<name> or @<name>") 36 | } 37 | return valueFilter{key: arg}, nil 38 | } else if strings.HasPrefix(arg, "@") { 39 | return valueFilter{key: arg[1:]}, nil 40 | } 41 | 42 | return nil, nil 43 | } 44 | 45 | type valueFilter struct { 46 | key string 47 | } 48 | 49 | func (f valueFilter) String() string { 50 | return Name + ":" + f.key 51 | } 52 | 53 | func (f valueFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 54 | return versions, f.key, nil 55 | } 56 | -------------------------------------------------------------------------------- /internal/filter/re/re.go: -------------------------------------------------------------------------------- 1 | package re 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/wader/bump/internal/filter" 9 | ) 10 | 11 | // Name of filter 12 | const Name = "re" 13 | 14 | // Help text 15 | var Help = ` 16 | re:/<regexp>/, re:/<regexp>/<template>/, /<regexp>/ or /<regexp>/<template>/ 17 | 18 | An alternative regex/template delimited can specified by changing the first 19 | / into some other character, for example: re:#regexp#template#. 20 | 21 | Filter name using a [golang regexp](https://golang.org/pkg/regexp/syntax/). 22 | If name does not match regexp the version will be skipped. 23 | 24 | If only a regexp and no template is provided and no submatches are defined the 25 | name will not be changed. 26 | 27 | If submatches are defined a submatch named "name" or "value" will be used as 28 | name and value otherwise first submatch will be used as name. 29 | 30 | If a template is defined and no submatches was defined it will be used as a 31 | replacement string. If submatches are defined it will be used as a template 32 | to expand $0, ${1}, $name etc. 33 | 34 | A regexp can match many times. Use ^$ anchors or (?m:) to match just one time 35 | or per line. 36 | 37 | # just filter 38 | static:a,b|/b/ 39 | # simple replace 40 | static:aaa|re:/a/b/ 41 | # simple replace with # as delimiter 42 | static:aaa|re:#a#b# 43 | # name as first submatch 44 | static:ab|re:/a(.)/ 45 | # multiple submatch replace 46 | static:ab:1|/(.)(.)/${0}$2$1/ 47 | # named submatch as name and value 48 | static:ab|re:/(?P<name>.)(?P<value>.)/ 49 | static:ab|re:/(?P<name>.)(?P<value>.)/|@value 50 | `[1:] 51 | 52 | func parse(delim string, s string) (re *regexp.Regexp, hasExpand bool, expand string, err error) { 53 | p := strings.Split(s, delim) 54 | if len(p) == 3 && p[0] == "" && p[2] == "" { 55 | // /re/ -> ["", "re", ""] 56 | re, err := regexp.Compile(p[1]) 57 | if err != nil { 58 | return nil, false, "", err 59 | } 60 | return re, false, "", nil 61 | } else if len(p) == 4 && p[0] == "" && p[3] == "" { 62 | // /re/expand/ -> ["", "re", "expand", ""] 63 | re, err := regexp.Compile(p[1]) 64 | if err != nil { 65 | return nil, false, "", err 66 | } 67 | return re, true, p[2], nil 68 | } else { 69 | return nil, false, "", fmt.Errorf("should be /re/ or /re/template/") 70 | } 71 | } 72 | 73 | // New re regular expression match/replace filter 74 | func New(prefix string, arg string) (filter filter.Filter, err error) { 75 | if prefix != Name && prefix != "" { 76 | return nil, nil 77 | } 78 | 79 | delim := "/" 80 | if prefix == Name && len(arg) > 0 { 81 | delim = arg[0:1] 82 | } 83 | 84 | re, hasExpand, expand, err := parse(delim, arg) 85 | if err != nil { 86 | if prefix == "" { 87 | return nil, nil 88 | } 89 | return nil, err 90 | } 91 | 92 | return reFilter{re: re, delim: delim, hasExpand: hasExpand, expand: expand}, nil 93 | } 94 | 95 | type reFilter struct { 96 | re *regexp.Regexp 97 | delim string 98 | hasExpand bool 99 | expand string 100 | } 101 | 102 | func (f reFilter) String() string { 103 | ss := []string{f.re.String()} 104 | if f.hasExpand { 105 | ss = append(ss, f.expand) 106 | } 107 | 108 | return Name + ":" + f.delim + strings.Join(ss, f.delim) + f.delim 109 | } 110 | 111 | func (f reFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 112 | subexpNames := f.re.SubexpNames() 113 | 114 | var filtered filter.Versions 115 | if f.re.NumSubexp() == 0 { 116 | for _, v := range versions { 117 | value, ok := v[versionKey] 118 | if !ok { 119 | return nil, "", fmt.Errorf("key %q is missing for %s", versionKey, v) 120 | } 121 | if !f.re.MatchString(value) { 122 | continue 123 | } 124 | 125 | if f.hasExpand { 126 | values := map[string]string{} 127 | for k, v := range v { 128 | values[k] = v 129 | } 130 | values[versionKey] = f.re.ReplaceAllLiteralString(value, f.expand) 131 | 132 | filtered = append(filtered, filter.NewVersionWithName(values["name"], values)) 133 | } else { 134 | filtered = append(filtered, v) 135 | } 136 | } 137 | } else { 138 | for _, v := range versions { 139 | value, ok := v[versionKey] 140 | if !ok { 141 | return nil, "", fmt.Errorf("key %q is missing for %s", versionKey, v) 142 | } 143 | 144 | for _, sm := range f.re.FindAllStringSubmatchIndex(value, -1) { 145 | values := map[string]string{} 146 | for k, v := range v { 147 | values[k] = v 148 | } 149 | 150 | foundNamedSubexp := false 151 | for smi := 0; smi < f.re.NumSubexp()+1; smi++ { 152 | subexpName := subexpNames[smi] 153 | if subexpName == "" || sm[smi*2] == -1 { 154 | continue 155 | } 156 | 157 | foundNamedSubexp = true 158 | values[subexpNames[smi]] = value[sm[smi*2]:sm[smi*2+1]] 159 | } 160 | 161 | if f.hasExpand { 162 | values[versionKey] = string(f.re.ExpandString(nil, f.expand, value, sm)) 163 | } else if !foundNamedSubexp && sm[2] != -1 { 164 | // TODO: no name subexp, use first? 165 | values[versionKey] = value[sm[2]:sm[3]] 166 | } 167 | 168 | filtered = append(filtered, filter.NewVersionWithName(values["name"], values)) 169 | } 170 | } 171 | } 172 | 173 | return filtered, versionKey, nil 174 | } 175 | -------------------------------------------------------------------------------- /internal/filter/semver/semver.go: -------------------------------------------------------------------------------- 1 | package semver 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "sort" 7 | "strconv" 8 | "strings" 9 | 10 | mmsemver "github.com/Masterminds/semver/v3" 11 | "github.com/wader/bump/internal/filter" 12 | ) 13 | 14 | // Name of filter 15 | const Name = "semver" 16 | 17 | // Help text 18 | var Help = ` 19 | semver:<constraint>, semver:<n.n.n-pre+build>, <constraint> or <n.n.n-pre+build> 20 | 21 | Use [semver](https://semver.org/) to filter or transform versions. 22 | 23 | When a constraint is provided it will be used to find the latest version fulfilling 24 | the constraint. 25 | 26 | When a version pattern is provided it will be used to transform a version. 27 | 28 | # find latest major 1 version 29 | static:1.1.2,1.1.3,1.2.0|semver:^1 30 | # find latest minor 1.1 version 31 | static:1.1.2,1.1.3,1.2.0|~1.1 32 | # transform into just major.minor 33 | static:1.2.3|n.n 34 | `[1:] 35 | 36 | var nRe = regexp.MustCompile("n") 37 | 38 | // semver package used to allow leading zeroes but got more strict 39 | // so let's regexp to strip them out for now, maybe in the future use 40 | // own or fork of a semver version and constraint package 41 | var findLeadingZeroes = regexp.MustCompile(`(?:^|\.)0+[1-9]`) 42 | 43 | func expandTemplate(v *mmsemver.Version, t string) string { 44 | prerelease := "" 45 | if v.Prerelease() != "" { 46 | prerelease = "-" + v.Prerelease() 47 | } 48 | build := "" 49 | if v.Metadata() != "" { 50 | build = "+" + v.Metadata() 51 | } 52 | 53 | s := strings.NewReplacer( 54 | "-pre", prerelease, 55 | "+build", build, 56 | ).Replace(t) 57 | 58 | i := 0 59 | m := map[int]uint64{ 60 | 0: v.Major(), 61 | 1: v.Minor(), 62 | 2: v.Patch(), 63 | } 64 | return nRe.ReplaceAllStringFunc(s, func(s string) string { 65 | if n, ok := m[i]; ok { 66 | i++ 67 | return strconv.FormatUint(n, 10) 68 | } 69 | return s 70 | }) 71 | } 72 | 73 | // New semver filter 74 | func New(prefix string, arg string) (filter filter.Filter, err error) { 75 | var constraint *mmsemver.Constraints 76 | 77 | if prefix != Name && prefix != "" { 78 | return nil, nil 79 | } 80 | if arg == "" { 81 | return nil, fmt.Errorf("needs a constraint or version pattern argument") 82 | } 83 | 84 | constraint, err = mmsemver.NewConstraint(arg) 85 | if prefix == Name { 86 | if err != nil { 87 | return semverFilter{template: arg}, nil 88 | } 89 | return semverFilter{constraint: constraint, constraintStr: arg}, nil 90 | } 91 | 92 | if err == nil { 93 | return semverFilter{constraint: constraint, constraintStr: arg}, nil 94 | } 95 | 96 | if strings.HasPrefix(arg, "n.n") { 97 | return semverFilter{template: arg}, nil 98 | } 99 | 100 | return nil, nil 101 | } 102 | 103 | type semverFilter struct { 104 | constraintStr string 105 | template string 106 | constraint *mmsemver.Constraints 107 | } 108 | 109 | func (f semverFilter) String() string { 110 | if f.template != "" { 111 | return Name + ":" + f.template 112 | } 113 | return Name + ":" + f.constraintStr 114 | } 115 | 116 | func (f semverFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 117 | type semverVersion struct { 118 | ver *mmsemver.Version 119 | v filter.Version 120 | } 121 | 122 | var svs []semverVersion 123 | for _, v := range versions { 124 | verStr := v[versionKey] 125 | filteredVerStr := findLeadingZeroes.ReplaceAllStringFunc(verStr, func(s string) string { 126 | s, hasDot := strings.CutPrefix(s, ".") 127 | s = strings.TrimLeft(s, "0") 128 | if hasDot { 129 | return "." + s 130 | } 131 | return s 132 | }) 133 | ver, err := mmsemver.NewVersion(filteredVerStr) 134 | // ignore everything that is not valid semver 135 | if err != nil { 136 | continue 137 | } 138 | 139 | if f.template != "" { 140 | svs = append(svs, semverVersion{ver: ver, v: filter.NewVersionWithName( 141 | expandTemplate(ver, f.template), 142 | v, 143 | )}) 144 | } else { 145 | svs = append(svs, semverVersion{ver: ver, v: v}) 146 | } 147 | } 148 | 149 | // if template assume input is already sorted etc 150 | if f.template != "" { 151 | var vs filter.Versions 152 | for _, v := range svs { 153 | vs = append(vs, v.v) 154 | } 155 | return vs, versionKey, nil 156 | } 157 | 158 | sort.Slice(svs, func(i int, j int) bool { 159 | return svs[i].ver.LessThan(svs[j].ver) 160 | }) 161 | 162 | var latest *semverVersion 163 | var latestIndex int 164 | for i, v := range svs { 165 | if f.constraint.Check(v.ver) { 166 | if latest == nil || latest.ver.Compare(v.ver) == -1 { 167 | latest = &svs[i] 168 | latestIndex = i 169 | continue 170 | } 171 | 172 | if len((*latest).v[versionKey]) <= len(v.v[versionKey]) { 173 | latest = &svs[i] 174 | latestIndex = i 175 | continue 176 | } 177 | } 178 | } 179 | if latest == nil { 180 | return nil, "", nil 181 | } 182 | 183 | var latestAndLower filter.Versions 184 | for i := latestIndex; i >= 0; i-- { 185 | latestAndLower = append(latestAndLower, svs[i].v) 186 | } 187 | 188 | return latestAndLower, versionKey, nil 189 | } 190 | -------------------------------------------------------------------------------- /internal/filter/sort/sort.go: -------------------------------------------------------------------------------- 1 | package sort 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/wader/bump/internal/filter" 8 | ) 9 | 10 | // Name of filter 11 | const Name = "sort" 12 | 13 | // Help text 14 | var Help = ` 15 | sort 16 | 17 | Sort versions reverse alphabetically. 18 | 19 | static:a,b,c|sort 20 | `[1:] 21 | 22 | // New sort filter 23 | func New(prefix string, arg string) (filter filter.Filter, err error) { 24 | if prefix != Name { 25 | return nil, nil 26 | } 27 | if arg != "" { 28 | return nil, fmt.Errorf("arg should be empty") 29 | } 30 | return sortFilter{}, nil 31 | } 32 | 33 | type sortFilter struct{} 34 | 35 | func (f sortFilter) String() string { 36 | return Name 37 | } 38 | 39 | func (f sortFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 40 | svs := append(filter.Versions{}, versions...) 41 | sort.Slice(svs, func(i int, j int) bool { 42 | return svs[i][versionKey] > svs[j][versionKey] 43 | }) 44 | return svs, versionKey, nil 45 | } 46 | -------------------------------------------------------------------------------- /internal/filter/static/static.go: -------------------------------------------------------------------------------- 1 | package static 2 | 3 | import ( 4 | "github.com/wader/bump/internal/filter" 5 | ) 6 | 7 | // Name of filter 8 | const Name = "static" 9 | 10 | // Help text 11 | var Help = ` 12 | static:<name[:key=value:...]>,... 13 | 14 | Produce versions from filter argument. 15 | 16 | static:1,2,3,4:key=value:a=b|sort 17 | `[1:] 18 | 19 | // New static filter 20 | func New(prefix string, arg string) (_ filter.Filter, err error) { 21 | if prefix != Name { 22 | return nil, nil 23 | } 24 | 25 | return staticFilter(filter.NewVersionsFromString(arg)), nil 26 | } 27 | 28 | type staticFilter filter.Versions 29 | 30 | func (f staticFilter) String() string { 31 | return Name + ":" + filter.Versions(f).String() 32 | } 33 | 34 | func (f staticFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 35 | vs := append(filter.Versions{}, versions...) 36 | vs = append(vs, f...) 37 | return vs, versionKey, nil 38 | } 39 | -------------------------------------------------------------------------------- /internal/filter/svn/svn.go: -------------------------------------------------------------------------------- 1 | package svn 2 | 3 | import ( 4 | "encoding/xml" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "regexp" 9 | "strings" 10 | 11 | "github.com/wader/bump/internal/filter" 12 | ) 13 | 14 | // curl -H "Depth: 1" -X PROPFIND http://svn.code.sf.net/p/lame/svn/tags 15 | /* 16 | <D:multistatus xmlns:D="DAV:"> 17 | <D:response xmlns:S="http://subversion.tigris.org/xmlns/svn/" xmlns:C="http://subversion.tigris.org/xmlns/custom/" xmlns:V="http://subversion.tigris.org/xmlns/dav/" xmlns:lp1="DAV:" xmlns:lp3="http://subversion.tigris.org/xmlns/dav/" xmlns:lp2="http://apache.org/dav/props/"> 18 | <D:href>/p/lame/svn/tags/</D:href> 19 | ... 20 | </D:response> 21 | <D:response xmlns:S="http://subversion.tigris.org/xmlns/svn/" xmlns:C="http://subversion.tigris.org/xmlns/custom/" xmlns:V="http://subversion.tigris.org/xmlns/dav/" xmlns:lp1="DAV:" xmlns:lp3="http://subversion.tigris.org/xmlns/dav/" xmlns:lp2="http://apache.org/dav/props/"> 22 | <D:href>/p/lame/svn/tags/RELEASE__3_100/</D:href> 23 | ... 24 | <D:propstat> 25 | <lp1:prop> 26 | <lp1:version-name>6403</lp1:version-name> 27 | ... 28 | </lp1:prop> 29 | <D:status>HTTP/1.1 200 OK</D:status> 30 | </D:propstat> 31 | </D:response> 32 | </D:multistatus> 33 | */ 34 | 35 | // Name of filter 36 | const Name = "svn" 37 | 38 | // Help text 39 | var Help = ` 40 | svn:<repo> 41 | 42 | Produce versions from tags and branches from a subversion repository. Name will 43 | be the tag or branch name, version the revision. 44 | 45 | svn:https://svn.apache.org/repos/asf/subversion|* 46 | `[1:] 47 | 48 | type multistatus struct { 49 | Response []struct { 50 | Href string `xml:"DAV: href"` 51 | VersionName string `xml:"DAV: propstat>prop>version-name"` 52 | } `xml:"DAV: response"` 53 | } 54 | 55 | // New svn filter 56 | func New(prefix string, arg string) (filter filter.Filter, err error) { 57 | if prefix != Name { 58 | return nil, nil 59 | } 60 | 61 | if arg == "" { 62 | return nil, fmt.Errorf("needs a repo url") 63 | } 64 | 65 | return svnFilter{repo: arg}, nil 66 | } 67 | 68 | type svnFilter struct { 69 | repo string 70 | } 71 | 72 | func (f svnFilter) String() string { 73 | return Name + ":" + f.repo 74 | } 75 | 76 | var elmRE = regexp.MustCompile(`</?[^ >]*?>`) 77 | 78 | func (f svnFilter) Filter(versions filter.Versions, versionKey string) (filter.Versions, string, error) { 79 | req, err := http.NewRequest("PROPFIND", f.repo+"/tags/", nil) 80 | if err != nil { 81 | return nil, "", err 82 | } 83 | req.Header.Set("Depth", "1") 84 | 85 | r, err := http.DefaultClient.Do(req) 86 | if err != nil { 87 | return nil, "", err 88 | } 89 | defer r.Body.Close() 90 | 91 | if r.StatusCode/100 != 2 { 92 | return nil, "", fmt.Errorf("error response: %s", r.Status) 93 | } 94 | 95 | bodyBytes, err := io.ReadAll(r.Body) 96 | if err != nil { 97 | return nil, "", err 98 | } 99 | // HACK: 100 | // go 1.20+ encoding/xml don't allow invalid xml with with colon in namespace 101 | // as we only care about propstat > prop > version-name let's just mangle the exceeding colons for now 102 | // https://issues.apache.org/jira/browse/SVN-1971 103 | bodyBytes = elmRE.ReplaceAllFunc(bodyBytes, func(b []byte) []byte { 104 | colons := 0 105 | for i, c := range b { 106 | if c != ':' { 107 | continue 108 | } 109 | colons++ 110 | if colons >= 2 { 111 | b[i] = '_' 112 | } 113 | } 114 | return b 115 | }) 116 | 117 | var m multistatus 118 | if err := xml.Unmarshal(bodyBytes, &m); err != nil { 119 | return nil, "", err 120 | } 121 | 122 | vs := append(filter.Versions{}, versions...) 123 | for _, r := range m.Response { 124 | // ".../svn/tags/a/" -> {..., "svn", "tags", "a", ""} 125 | parts := strings.Split(r.Href, "/") 126 | if len(parts) < 3 { 127 | continue 128 | } 129 | 130 | parent := parts[len(parts)-3] 131 | v := parts[len(parts)-2] 132 | if parent != "tags" { 133 | continue 134 | } 135 | 136 | vs = append(vs, filter.NewVersionWithName(v, map[string]string{"version": r.VersionName})) 137 | } 138 | 139 | return vs, versionKey, nil 140 | } 141 | -------------------------------------------------------------------------------- /internal/filter/version.go: -------------------------------------------------------------------------------- 1 | package filter 2 | 3 | import "strings" 4 | 5 | var newlineUnescape = strings.NewReplacer(`\n`, "\n") 6 | var newlineEscape = strings.NewReplacer("\n", `\n`) 7 | 8 | // Version is a version with associated values 9 | // Key "name" is the version number "1.2.3" or some something symbolic like "master". 10 | // Other keys can be "commit" etc. 11 | type Version map[string]string 12 | 13 | // NewVersionWithName build a new version with name and values 14 | func NewVersionWithName(name string, values map[string]string) Version { 15 | newValues := map[string]string{} 16 | for k, v := range values { 17 | newValues[k] = v 18 | } 19 | newValues["name"] = name 20 | 21 | return Version(newValues) 22 | } 23 | 24 | // NewVersionFromString build a version from a string 25 | func NewVersionFromString(s string) Version { 26 | nameValues := strings.SplitN(s, ":", 2) 27 | name := newlineUnescape.Replace(nameValues[0]) 28 | values := map[string]string{} 29 | if len(nameValues) > 1 { 30 | keyValues := strings.Split(nameValues[1], ":") 31 | for _, keyValues := range keyValues { 32 | keyValueParts := strings.SplitN(keyValues, "=", 2) 33 | key := keyValueParts[0] 34 | value := "" 35 | if len(keyValueParts) == 2 { 36 | value = keyValueParts[1] 37 | } 38 | values[newlineUnescape.Replace(key)] = newlineUnescape.Replace(value) 39 | } 40 | } 41 | return NewVersionWithName(name, values) 42 | } 43 | 44 | func (p Version) String() string { 45 | var ss = []string{} 46 | if s, ok := p["name"]; ok { 47 | ss = append(ss, newlineEscape.Replace(s)) 48 | } 49 | for k, v := range p { 50 | if k == "name" { 51 | continue 52 | } 53 | ss = append(ss, newlineEscape.Replace(k)+"="+newlineEscape.Replace(v)) 54 | } 55 | return strings.Join(ss, ":") 56 | } 57 | 58 | // Versions is a slice of versions 59 | type Versions []Version 60 | 61 | // NewVersionsFromString build a slice of versions from string 62 | func NewVersionsFromString(s string) Versions { 63 | if s == "" { 64 | return nil 65 | } 66 | var vs Versions 67 | for _, sp := range strings.Split(s, ",") { 68 | vs = append(vs, NewVersionFromString(sp)) 69 | } 70 | return vs 71 | } 72 | 73 | func (vs Versions) String() string { 74 | var ss []string 75 | for _, v := range vs { 76 | ss = append(ss, v.String()) 77 | } 78 | return strings.Join(ss, ",") 79 | } 80 | 81 | // Minus treat versions as set keyed on name and build new set with minus m names 82 | func (vs Versions) Minus(m Versions) Versions { 83 | ns := map[string]Version{} 84 | for _, p := range vs { 85 | ns[p["name"]] = p 86 | } 87 | for _, p := range m { 88 | delete(ns, p["name"]) 89 | } 90 | var nvs Versions 91 | for _, v := range ns { 92 | nvs = append(nvs, v) 93 | } 94 | return nvs 95 | } 96 | -------------------------------------------------------------------------------- /internal/filter/version_test.go: -------------------------------------------------------------------------------- 1 | package filter_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/wader/bump/internal/filter" 8 | ) 9 | 10 | func TestTestFromString(t *testing.T) { 11 | testCases := []struct { 12 | s string 13 | expected filter.Version 14 | }{ 15 | {s: "a", expected: map[string]string{"name": "a"}}, 16 | {s: "a:b=", expected: map[string]string{"name": "a", "b": ""}}, 17 | {s: "a:b=1", expected: map[string]string{"name": "a", "b": "1"}}, 18 | } 19 | for _, tC := range testCases { 20 | t.Run(tC.s, func(t *testing.T) { 21 | actual := filter.NewVersionFromString(tC.s) 22 | if !reflect.DeepEqual(tC.expected, actual) { 23 | t.Errorf("expected %v, got %v", tC.expected, actual) 24 | } 25 | actualString := actual.String() 26 | if tC.s != actualString { 27 | t.Errorf("expected %v, got %v", tC.s, actualString) 28 | } 29 | }) 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /internal/github/action.go: -------------------------------------------------------------------------------- 1 | package github 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | // GetenvFn function to return environment values 9 | type GetenvFn func(name string) string 10 | 11 | // ActionEnv is a GitHub action environment 12 | // https://help.github.com/en/articles/virtual-environments-for-github-actions#default-environment-variables 13 | type ActionEnv struct { 14 | getenv GetenvFn 15 | Client *Client 16 | Workflow string // GITHUB_WORKFLOW The name of the workflow. 17 | Action string // GITHUB_ACTION The name of the action. 18 | Actor string // GITHUB_ACTOR The name of the person or app that initiated the workflow. For example, octocat. 19 | EventName string // GITHUB_EVENT_NAME The name of the webhook event that triggered the workflow. 20 | EventPath string // GITHUB_EVENT_PATH The path of the file with the complete webhook event payload. For example, /github/workflow/event.json. 21 | Workspace string // GITHUB_WORKSPACE The GitHub workspace directory path. The workspace directory contains a subdirectory with a copy of your repository if your workflow uses the actions/checkout action. If you don't use the actions/checkout action, the directory will be empty. For example, /home/runner/work/my-repo-name/my-repo-name. 22 | SHA string // GITHUB_SHA The commit SHA that triggered the workflow. For example, ffac537e6cbbf934b08745a378932722df287a53. 23 | Ref string // GITHUB_REF The branch or tag ref that triggered the workflow. For example, refs/heads/feature-branch-1. If neither a branch or tag is available for the event type, the variable will not exist. 24 | HeadRef string // GITHUB_HEAD_REF Only set for forked repositories. The branch of the head repository. 25 | BaseRef string // GITHUB_BASE_REF Only set for forked repositories. The branch of the base repository. 26 | Repository string // GITHUB_REPOSITORY user/repo 27 | Owner string // user (extracted from Repository) 28 | RepoName string // repo (extracted from Repository) 29 | RepoRef *RepoRef // *RepoRef variant of Repository 30 | } 31 | 32 | // IsActionEnv return true if running in action environment 33 | func IsActionEnv(getenv GetenvFn) bool { 34 | return getenv("GITHUB_ACTION") != "" 35 | } 36 | 37 | // NewActionEnv creates a new ActionEnv 38 | func NewActionEnv(getenv GetenvFn, version string) (*ActionEnv, error) { 39 | getenvOrErr := func(name string) (string, error) { 40 | v := getenv(name) 41 | if v == "" { 42 | return "", fmt.Errorf("%s not set", name) 43 | } 44 | return v, nil 45 | } 46 | 47 | token, err := getenvOrErr("GITHUB_TOKEN") 48 | if err != nil { 49 | return nil, err 50 | } 51 | workflow, err := getenvOrErr("GITHUB_WORKFLOW") 52 | if err != nil { 53 | return nil, err 54 | } 55 | action, err := getenvOrErr("GITHUB_ACTION") 56 | if err != nil { 57 | return nil, err 58 | } 59 | actor, err := getenvOrErr("GITHUB_ACTOR") 60 | if err != nil { 61 | return nil, err 62 | } 63 | repository, err := getenvOrErr("GITHUB_REPOSITORY") 64 | if err != nil { 65 | return nil, err 66 | } 67 | repositoryParts := strings.SplitN(repository, "/", 2) 68 | if len(repositoryParts) < 2 { 69 | return nil, fmt.Errorf("GITHUB_REPOSITORY has invalid value %q", repository) 70 | } 71 | 72 | client := &Client{ 73 | Token: token, 74 | Version: version, 75 | } 76 | 77 | return &ActionEnv{ 78 | getenv: getenv, 79 | Client: client, 80 | Workflow: workflow, 81 | Action: action, 82 | Actor: actor, 83 | EventName: getenv("GITHUB_EVENT_NAME"), 84 | EventPath: getenv("GITHUB_EVENT_PATH"), 85 | Workspace: getenv("GITHUB_WORKSPACE"), 86 | SHA: getenv("GITHUB_SHA"), 87 | Ref: getenv("GITHUB_REF"), 88 | HeadRef: getenv("GITHUB_HEAD_REF"), 89 | BaseRef: getenv("GITHUB_BASE_REF"), 90 | Repository: repository, 91 | Owner: repositoryParts[0], 92 | RepoName: repositoryParts[1], 93 | RepoRef: client.NewRepoRef(repository), 94 | }, nil 95 | } 96 | 97 | // Input returns value of input variable as defined in action.yml 98 | func (a *ActionEnv) Input(name string) (string, error) { 99 | envName := "INPUT_" + strings.ToUpper(name) 100 | v := a.getenv(envName) 101 | if v == "" { 102 | return "", fmt.Errorf("%s (%s) is empty", name, envName) 103 | } 104 | return v, nil 105 | } 106 | -------------------------------------------------------------------------------- /internal/github/action_test.go: -------------------------------------------------------------------------------- 1 | package github_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/wader/bump/internal/github" 7 | ) 8 | 9 | func createGetEnvFn(env map[string]string) github.GetenvFn { 10 | return func(name string) string { 11 | return env[name] 12 | } 13 | } 14 | 15 | func expect(t *testing.T, actual, expected string) { 16 | if actual != expected { 17 | t.Errorf("expected %q, got %q", expected, actual) 18 | } 19 | } 20 | 21 | func TestIsActionEnv(t *testing.T) { 22 | if !github.IsActionEnv(createGetEnvFn(map[string]string{"GITHUB_ACTION": "action"})) { 23 | t.Fatal("should be action env") 24 | } 25 | if github.IsActionEnv(createGetEnvFn(map[string]string{})) { 26 | t.Fatal("should not be action env") 27 | } 28 | } 29 | 30 | func TestNewActionEnv(t *testing.T) { 31 | ae, err := github.NewActionEnv(createGetEnvFn(map[string]string{ 32 | "GITHUB_TOKEN": "token", 33 | "GITHUB_WORKFLOW": "workflow", 34 | "GITHUB_ACTION": "action", 35 | "GITHUB_ACTOR": "actor", 36 | "GITHUB_EVENT_NAME": "event name", 37 | "GITHUB_EVENT_PATH": "event path", 38 | "GITHUB_WORKSPACE": "workspace", 39 | "GITHUB_SHA": "sha", 40 | "GITHUB_REF": "refs/heads/master", 41 | "GITHUB_HEAD_REF": "head ref", 42 | "GITHUB_BASE_REF": "base ref", 43 | "GITHUB_REPOSITORY": "user/repo", 44 | "INPUT_A": "a", 45 | }), "test") 46 | 47 | if err != nil { 48 | t.Fatal(err) 49 | } 50 | 51 | t.Run("Workflow", func(t *testing.T) { expect(t, ae.Workflow, "workflow") }) 52 | t.Run("Action", func(t *testing.T) { expect(t, ae.Action, "action") }) 53 | t.Run("Actor", func(t *testing.T) { expect(t, ae.Actor, "actor") }) 54 | t.Run("EventName", func(t *testing.T) { expect(t, ae.EventName, "event name") }) 55 | t.Run("EventPath", func(t *testing.T) { expect(t, ae.EventPath, "event path") }) 56 | t.Run("Workspace", func(t *testing.T) { expect(t, ae.Workspace, "workspace") }) 57 | t.Run("SHA", func(t *testing.T) { expect(t, ae.SHA, "sha") }) 58 | t.Run("Ref", func(t *testing.T) { expect(t, ae.Ref, "refs/heads/master") }) 59 | t.Run("HeadRef", func(t *testing.T) { expect(t, ae.HeadRef, "head ref") }) 60 | t.Run("BaseRef", func(t *testing.T) { expect(t, ae.BaseRef, "base ref") }) 61 | t.Run("Repository", func(t *testing.T) { expect(t, ae.Repository, "user/repo") }) 62 | t.Run("Owner", func(t *testing.T) { expect(t, ae.Owner, "user") }) 63 | t.Run("RepoName", func(t *testing.T) { expect(t, ae.RepoName, "repo") }) 64 | t.Run("Input lowercase", func(t *testing.T) { 65 | actual, err := ae.Input("a") 66 | if err != nil { 67 | t.Error(err) 68 | } 69 | expect(t, actual, "a") 70 | }) 71 | t.Run("Input uppercase", func(t *testing.T) { 72 | actual, err := ae.Input("A") 73 | if err != nil { 74 | t.Error(err) 75 | } 76 | expect(t, actual, "a") 77 | }) 78 | } 79 | -------------------------------------------------------------------------------- /internal/github/api.go: -------------------------------------------------------------------------------- 1 | // Package github implements part of the GitHub REST API and Action functionality 2 | package github 3 | 4 | import ( 5 | "bytes" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/url" 11 | "regexp" 12 | "strings" 13 | "time" 14 | ) 15 | 16 | // DEFAULT_BASE_URL to GitHub REST API 17 | const DEFAULT_BASE_URL = "https://api.github.com" 18 | 19 | // StrR creates a string ref 20 | func StrR(s string) *string { 21 | return &s 22 | } 23 | 24 | // BoolR creates a bool ref 25 | func BoolR(b bool) *bool { 26 | return &b 27 | } 28 | 29 | var cntrlRe = regexp.MustCompile(`[[:cntrl:]]`) 30 | 31 | // IsValidBranchName checks if a string is a valid git branch name 32 | // Might be a bit more strict than git itself 33 | // https://wincent.com/wiki/Legal_Git_branch_names 34 | // Cant: 35 | // Have a path component that begins with "." 36 | // Have a double dot "…" 37 | // Have an ASCII control character, "~", "^", ":" or SP, anywhere 38 | // End with a "/" 39 | // End with ".lock" 40 | // Contain a "\" (backslash) 41 | // the sequence @{ is not allowed 42 | // ? and [ are not allowed 43 | // * is allowed only if it constitutes an entire path component (eg. foo/* or bar/*/baz), in which case it is interpreted as a wildcard and not as part of the actual ref name 44 | func IsValidBranchName(b string) error { 45 | if len(b) == 0 { 46 | return fmt.Errorf("can't be empty") 47 | } 48 | if strings.HasPrefix(b, ".") { 49 | return fmt.Errorf("can't start with '.'") 50 | } 51 | if strings.HasSuffix(b, "/") { 52 | return fmt.Errorf("can't end with '/'") 53 | } 54 | if strings.HasSuffix(b, ".lock") { 55 | return fmt.Errorf("can't end with '.lock'") 56 | } 57 | if strings.Contains(b, "..") { 58 | return fmt.Errorf("can't include '..'") 59 | } 60 | if cntrlRe.MatchString(b) { 61 | return fmt.Errorf("can't include control characters") 62 | } 63 | invalidChars := `~^: \@?*{}[]` 64 | if strings.ContainsAny(b, invalidChars) { 65 | return fmt.Errorf("can't include any of '%s'", invalidChars) 66 | } 67 | 68 | return nil 69 | } 70 | 71 | // PullRequest is a GitHub Pull request fetched from the API 72 | // from api docs converted using https://mholt.github.io/json-to-go/ 73 | type PullRequest struct { 74 | URL string `json:"url"` 75 | ID int `json:"id"` 76 | NodeID string `json:"node_id"` 77 | HTMLURL string `json:"html_url"` 78 | DiffURL string `json:"diff_url"` 79 | PatchURL string `json:"patch_url"` 80 | IssueURL string `json:"issue_url"` 81 | CommitsURL string `json:"commits_url"` 82 | ReviewCommentsURL string `json:"review_comments_url"` 83 | ReviewCommentURL string `json:"review_comment_url"` 84 | CommentsURL string `json:"comments_url"` 85 | StatusesURL string `json:"statuses_url"` 86 | Number int `json:"number"` 87 | State string `json:"state"` 88 | Locked bool `json:"locked"` 89 | Title string `json:"title"` 90 | User User `json:"user"` 91 | Body string `json:"body"` 92 | Labels []Label `json:"labels"` 93 | Milestone Milestone `json:"milestone"` 94 | ActiveLockReason string `json:"active_lock_reason"` 95 | CreatedAt time.Time `json:"created_at"` 96 | UpdatedAt time.Time `json:"updated_at"` 97 | ClosedAt time.Time `json:"closed_at"` 98 | MergedAt time.Time `json:"merged_at"` 99 | MergeCommitSha string `json:"merge_commit_sha"` 100 | Assignee User `json:"assignee"` 101 | Assignees []User `json:"assignees"` 102 | RequestedReviewers []User `json:"requested_reviewers"` 103 | RequestedTeams []Team `json:"requested_teams"` 104 | Head Ref `json:"head"` 105 | Base Ref `json:"base"` 106 | Links Links `json:"_links"` 107 | AuthorAssociation string `json:"author_association"` 108 | Draft bool `json:"draft"` 109 | Merged bool `json:"merged"` 110 | Mergeable bool `json:"mergeable"` 111 | Rebaseable bool `json:"rebaseable"` 112 | MergeableState string `json:"mergeable_state"` 113 | MergedBy User `json:"merged_by"` 114 | Comments int `json:"comments"` 115 | ReviewComments int `json:"review_comments"` 116 | MaintainerCanModify bool `json:"maintainer_can_modify"` 117 | Commits int `json:"commits"` 118 | Additions int `json:"additions"` 119 | Deletions int `json:"deletions"` 120 | ChangedFiles int `json:"changed_files"` 121 | } 122 | 123 | type User struct { 124 | Login string `json:"login"` 125 | ID int `json:"id"` 126 | NodeID string `json:"node_id"` 127 | AvatarURL string `json:"avatar_url"` 128 | GravatarID string `json:"gravatar_id"` 129 | URL string `json:"url"` 130 | HTMLURL string `json:"html_url"` 131 | FollowersURL string `json:"followers_url"` 132 | FollowingURL string `json:"following_url"` 133 | GistsURL string `json:"gists_url"` 134 | StarredURL string `json:"starred_url"` 135 | SubscriptionsURL string `json:"subscriptions_url"` 136 | OrganizationsURL string `json:"organizations_url"` 137 | ReposURL string `json:"repos_url"` 138 | EventsURL string `json:"events_url"` 139 | ReceivedEventsURL string `json:"received_events_url"` 140 | Type string `json:"type"` 141 | SiteAdmin bool `json:"site_admin"` 142 | } 143 | 144 | type Repo struct { 145 | ID int `json:"id"` 146 | NodeID string `json:"node_id"` 147 | Name string `json:"name"` 148 | FullName string `json:"full_name"` 149 | Owner User `json:"owner"` 150 | Private bool `json:"private"` 151 | HTMLURL string `json:"html_url"` 152 | Description string `json:"description"` 153 | Fork bool `json:"fork"` 154 | URL string `json:"url"` 155 | ArchiveURL string `json:"archive_url"` 156 | AssigneesURL string `json:"assignees_url"` 157 | BlobsURL string `json:"blobs_url"` 158 | BranchesURL string `json:"branches_url"` 159 | CollaboratorsURL string `json:"collaborators_url"` 160 | CommentsURL string `json:"comments_url"` 161 | CommitsURL string `json:"commits_url"` 162 | CompareURL string `json:"compare_url"` 163 | ContentsURL string `json:"contents_url"` 164 | ContributorsURL string `json:"contributors_url"` 165 | DeploymentsURL string `json:"deployments_url"` 166 | DownloadsURL string `json:"downloads_url"` 167 | EventsURL string `json:"events_url"` 168 | ForksURL string `json:"forks_url"` 169 | GitCommitsURL string `json:"git_commits_url"` 170 | GitRefsURL string `json:"git_refs_url"` 171 | GitTagsURL string `json:"git_tags_url"` 172 | GitURL string `json:"git_url"` 173 | IssueCommentURL string `json:"issue_comment_url"` 174 | IssueEventsURL string `json:"issue_events_url"` 175 | IssuesURL string `json:"issues_url"` 176 | KeysURL string `json:"keys_url"` 177 | LabelsURL string `json:"labels_url"` 178 | LanguagesURL string `json:"languages_url"` 179 | MergesURL string `json:"merges_url"` 180 | MilestonesURL string `json:"milestones_url"` 181 | NotificationsURL string `json:"notifications_url"` 182 | PullsURL string `json:"pulls_url"` 183 | ReleasesURL string `json:"releases_url"` 184 | SSHURL string `json:"ssh_url"` 185 | StargazersURL string `json:"stargazers_url"` 186 | StatusesURL string `json:"statuses_url"` 187 | SubscribersURL string `json:"subscribers_url"` 188 | SubscriptionURL string `json:"subscription_url"` 189 | TagsURL string `json:"tags_url"` 190 | TeamsURL string `json:"teams_url"` 191 | TreesURL string `json:"trees_url"` 192 | CloneURL string `json:"clone_url"` 193 | MirrorURL string `json:"mirror_url"` 194 | HooksURL string `json:"hooks_url"` 195 | SvnURL string `json:"svn_url"` 196 | Homepage string `json:"homepage"` 197 | Language interface{} `json:"language"` 198 | ForksCount int `json:"forks_count"` 199 | StargazersCount int `json:"stargazers_count"` 200 | WatchersCount int `json:"watchers_count"` 201 | Size int `json:"size"` 202 | DefaultBranch string `json:"default_branch"` 203 | OpenIssuesCount int `json:"open_issues_count"` 204 | IsTemplate bool `json:"is_template"` 205 | Topics []string `json:"topics"` 206 | HasIssues bool `json:"has_issues"` 207 | HasProjects bool `json:"has_projects"` 208 | HasWiki bool `json:"has_wiki"` 209 | HasPages bool `json:"has_pages"` 210 | HasDownloads bool `json:"has_downloads"` 211 | Archived bool `json:"archived"` 212 | Disabled bool `json:"disabled"` 213 | PushedAt time.Time `json:"pushed_at"` 214 | CreatedAt time.Time `json:"created_at"` 215 | UpdatedAt time.Time `json:"updated_at"` 216 | Permissions struct { 217 | Admin bool `json:"admin"` 218 | Push bool `json:"push"` 219 | Pull bool `json:"pull"` 220 | } `json:"permissions"` 221 | AllowRebaseMerge bool `json:"allow_rebase_merge"` 222 | TemplateRepository interface{} `json:"template_repository"` 223 | AllowSquashMerge bool `json:"allow_squash_merge"` 224 | AllowMergeCommit bool `json:"allow_merge_commit"` 225 | SubscribersCount int `json:"subscribers_count"` 226 | NetworkCount int `json:"network_count"` 227 | } 228 | 229 | type Ref struct { 230 | Label string `json:"label"` 231 | Ref string `json:"ref"` 232 | Sha string `json:"sha"` 233 | User User `json:"user"` 234 | Repo Repo `json:"repo"` 235 | } 236 | 237 | type Links struct { 238 | Self struct { 239 | Href string `json:"href"` 240 | } `json:"self"` 241 | HTML struct { 242 | Href string `json:"href"` 243 | } `json:"html"` 244 | Issue struct { 245 | Href string `json:"href"` 246 | } `json:"issue"` 247 | Comments struct { 248 | Href string `json:"href"` 249 | } `json:"comments"` 250 | ReviewComments struct { 251 | Href string `json:"href"` 252 | } `json:"review_comments"` 253 | ReviewComment struct { 254 | Href string `json:"href"` 255 | } `json:"review_comment"` 256 | Commits struct { 257 | Href string `json:"href"` 258 | } `json:"commits"` 259 | Statuses struct { 260 | Href string `json:"href"` 261 | } `json:"statuses"` 262 | } 263 | 264 | type Team struct { 265 | ID int `json:"id"` 266 | NodeID string `json:"node_id"` 267 | URL string `json:"url"` 268 | HTMLURL string `json:"html_url"` 269 | Name string `json:"name"` 270 | Slug string `json:"slug"` 271 | Description string `json:"description"` 272 | Privacy string `json:"privacy"` 273 | Permission string `json:"permission"` 274 | MembersURL string `json:"members_url"` 275 | RepositoriesURL string `json:"repositories_url"` 276 | Parent interface{} `json:"parent"` 277 | } 278 | 279 | type Label struct { 280 | ID int `json:"id"` 281 | NodeID string `json:"node_id"` 282 | URL string `json:"url"` 283 | Name string `json:"name"` 284 | Description string `json:"description"` 285 | Color string `json:"color"` 286 | Default bool `json:"default"` 287 | } 288 | 289 | type Milestone struct { 290 | URL string `json:"url"` 291 | HTMLURL string `json:"html_url"` 292 | LabelsURL string `json:"labels_url"` 293 | ID int `json:"id"` 294 | NodeID string `json:"node_id"` 295 | Number int `json:"number"` 296 | State string `json:"state"` 297 | Title string `json:"title"` 298 | Description string `json:"description"` 299 | Creator User `json:"creator"` 300 | OpenIssues int `json:"open_issues"` 301 | ClosedIssues int `json:"closed_issues"` 302 | CreatedAt time.Time `json:"created_at"` 303 | UpdatedAt time.Time `json:"updated_at"` 304 | ClosedAt time.Time `json:"closed_at"` 305 | DueOn time.Time `json:"due_on"` 306 | } 307 | 308 | type Comment struct { 309 | ID int `json:"id"` 310 | NodeID string `json:"node_id"` 311 | URL string `json:"url"` 312 | HTMLURL string `json:"html_url"` 313 | Body string `json:"body"` 314 | User User `json:"user"` 315 | CreatedAt time.Time `json:"created_at"` 316 | UpdatedAt time.Time `json:"updated_at"` 317 | } 318 | 319 | type Client struct { 320 | BaseURL string 321 | Token string 322 | Version string // used in user-agent 323 | HTTPClient *http.Client 324 | } 325 | 326 | func (c *Client) URL(path string, params []string) (*url.URL, error) { 327 | rawBaseURL := c.BaseURL 328 | if rawBaseURL == "" { 329 | rawBaseURL = DEFAULT_BASE_URL 330 | } 331 | baseURL, err := url.Parse(rawBaseURL) 332 | if err != nil { 333 | return nil, err 334 | } 335 | if len(params)%2 != 0 { 336 | return nil, fmt.Errorf("params should be pairs") 337 | } 338 | 339 | v := url.Values{} 340 | for i := 0; i < len(params)/2; i++ { 341 | v[params[i*2]] = []string{params[i*2+1]} 342 | } 343 | 344 | return baseURL.ResolveReference(&url.URL{ 345 | Path: path, 346 | RawQuery: v.Encode(), 347 | }), nil 348 | } 349 | 350 | func (c *Client) Do(method, path string, params []string, body interface{}, out interface{}) error { 351 | if c.Token == "" { 352 | return fmt.Errorf("token not set") 353 | } 354 | 355 | u, err := c.URL(path, params) 356 | if err != nil { 357 | return err 358 | } 359 | 360 | var bodyR io.Reader 361 | if body != nil { 362 | bodyBuf, err := json.Marshal(body) 363 | if err != nil { 364 | return err 365 | } 366 | bodyR = bytes.NewReader(bodyBuf) 367 | } 368 | 369 | req, err := http.NewRequest(method, u.String(), bodyR) 370 | if err != nil { 371 | return err 372 | } 373 | 374 | // https://developer.github.com/v3/#user-agent-required 375 | req.Header.Set("User-Agent", "https://github.com/wader/bump "+c.Version) 376 | req.Header.Add("Accept", "application/vnd.github.v3+json") 377 | req.Header.Add("Authorization", "token "+c.Token) 378 | 379 | if body != nil { 380 | req.Header.Add("Content-Type", "application/json") 381 | } 382 | 383 | hc := http.DefaultClient 384 | if c.HTTPClient != nil { 385 | hc = c.HTTPClient 386 | } 387 | 388 | resp, err := hc.Do(req) 389 | if err != nil { 390 | return err 391 | } 392 | defer resp.Body.Close() 393 | if resp.StatusCode/100 != 2 { 394 | return fmt.Errorf("%s", resp.Status) 395 | } 396 | 397 | if out != nil { 398 | err = json.NewDecoder(resp.Body).Decode(out) 399 | if err != nil { 400 | return err 401 | } 402 | } 403 | 404 | return nil 405 | } 406 | 407 | func (c *Client) NewRepoRef(name string) *RepoRef { 408 | return &RepoRef{c: c, Name: name} 409 | } 410 | 411 | type RepoRef struct { 412 | c *Client 413 | Name string 414 | } 415 | 416 | type NewPullRequest struct { 417 | Title string `json:"title"` 418 | Head string `json:"head"` 419 | Base string `json:"base"` 420 | Body *string `json:"body,omitempty"` 421 | MaintainerCanModify *bool `json:"maintainer_can_modify,omitempty"` 422 | Draft *bool `json:"draft,omitempty"` 423 | } 424 | 425 | func (repo *RepoRef) CreatePullRequest(pr NewPullRequest) (PullRequest, error) { 426 | var newPr PullRequest 427 | err := repo.c.Do("POST", fmt.Sprintf("repos/%s/pulls", repo.Name), nil, pr, &newPr) 428 | return newPr, err 429 | } 430 | 431 | type UpdatePullRequest struct { 432 | Title *string `json:"title,omitempty"` 433 | Base *string `json:"base,omitempty"` 434 | Body *string `json:"body,omitempty"` 435 | State *string `json:"state,omitempty"` 436 | MaintainerCanModify *bool `json:"maintainer_can_modify,omitempty"` 437 | } 438 | 439 | func (repo *RepoRef) UpdatePullRequest(prNumber int, pr UpdatePullRequest) (PullRequest, error) { 440 | var outPr PullRequest 441 | err := repo.c.Do("PATCH", fmt.Sprintf("repos/%s/pulls/%d", repo.Name, prNumber), nil, pr, &outPr) 442 | return outPr, err 443 | } 444 | 445 | func (repo *RepoRef) ListPullRequest(params ...string) ([]PullRequest, error) { 446 | var outPrs []PullRequest 447 | err := repo.c.Do("GET", fmt.Sprintf("repos/%s/pulls", repo.Name), params, nil, &outPrs) 448 | return outPrs, err 449 | } 450 | 451 | type NewComment struct { 452 | Body string `json:"body"` 453 | } 454 | 455 | func (repo *RepoRef) CreateComment(prNumber int, com NewComment) (Comment, error) { 456 | var outCom Comment 457 | err := repo.c.Do("POST", fmt.Sprintf("repos/%s/issues/%d/comments", repo.Name, prNumber), nil, com, &outCom) 458 | return outCom, err 459 | } 460 | -------------------------------------------------------------------------------- /internal/github/api_test.go: -------------------------------------------------------------------------------- 1 | package github_test 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "reflect" 9 | "testing" 10 | 11 | "github.com/wader/bump/internal/github" 12 | ) 13 | 14 | // TODO: better tests? 15 | 16 | type RoundTripFunc func(*http.Request) (*http.Response, error) 17 | 18 | func (r RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) { 19 | return r(req) 20 | } 21 | 22 | func responseClient(fn func(*http.Request) (interface{}, int)) *http.Client { 23 | return &http.Client{ 24 | Transport: RoundTripFunc(func(req *http.Request) (*http.Response, error) { 25 | v, code := fn(req) 26 | var b []byte 27 | if v != nil { 28 | var err error 29 | b, err = json.Marshal(v) 30 | if err != nil { 31 | panic(err) 32 | } 33 | } 34 | return &http.Response{ 35 | StatusCode: code, 36 | Body: io.NopCloser(bytes.NewReader(b)), 37 | }, nil 38 | }), 39 | } 40 | } 41 | 42 | func TestHeaders(t *testing.T) { 43 | gotCalled := false 44 | c := &github.Client{ 45 | Token: "abc", 46 | Version: "test", 47 | HTTPClient: responseClient(func(req *http.Request) (interface{}, int) { 48 | gotCalled = true 49 | type c struct { 50 | UserAgent string 51 | Accept string 52 | Authorization string 53 | ContentType string 54 | } 55 | 56 | expectedC := c{ 57 | UserAgent: "https://github.com/wader/bump test", 58 | Accept: "application/vnd.github.v3+json", 59 | Authorization: "token abc", 60 | ContentType: "application/json", 61 | } 62 | actualC := c{ 63 | UserAgent: req.UserAgent(), 64 | Accept: req.Header.Get("Accept"), 65 | Authorization: req.Header.Get("Authorization"), 66 | ContentType: req.Header.Get("Content-Type"), 67 | } 68 | 69 | if expectedC != actualC { 70 | t.Errorf("expected %#v, got %#v", expectedC, actualC) 71 | } 72 | 73 | return nil, 200 74 | }), 75 | } 76 | _, _ = c.NewRepoRef("user/repo").CreatePullRequest(github.NewPullRequest{}) 77 | if !gotCalled { 78 | t.Error("did not get called") 79 | } 80 | } 81 | 82 | func TestListPullRequest(t *testing.T) { 83 | expectedPRs := []github.PullRequest{ 84 | {ID: 123, Number: 1, Title: "PR title 1"}, 85 | {ID: 456, Number: 2, Title: "PR title 2"}, 86 | } 87 | 88 | c := &github.Client{ 89 | Token: "abc", 90 | HTTPClient: responseClient(func(req *http.Request) (interface{}, int) { 91 | type c struct { 92 | Method string 93 | ParamState string 94 | Path string 95 | } 96 | 97 | expectedC := c{ 98 | Method: "GET", 99 | ParamState: "closed", 100 | Path: "/repos/user/repo/pulls", 101 | } 102 | actualC := c{ 103 | Method: req.Method, 104 | ParamState: req.URL.Query().Get("state"), 105 | Path: req.URL.Path, 106 | } 107 | 108 | if expectedC != actualC { 109 | t.Errorf("expected %#v, got %#v", expectedC, actualC) 110 | } 111 | 112 | return expectedPRs, 200 113 | }), 114 | } 115 | 116 | actualPRs, err := c.NewRepoRef("user/repo").ListPullRequest("state", "closed") 117 | if err != nil { 118 | t.Error(err) 119 | } 120 | if !reflect.DeepEqual(expectedPRs, actualPRs) { 121 | t.Errorf("expected PRs %#v, got %#v", expectedPRs, actualPRs) 122 | } 123 | } 124 | 125 | func TestCreatePullRequest(t *testing.T) { 126 | expectedNewPR := github.NewPullRequest{ 127 | Title: "a", 128 | Head: "b", 129 | Base: "c", 130 | Body: github.StrR("d"), 131 | MaintainerCanModify: github.BoolR(true), 132 | Draft: github.BoolR(true), 133 | } 134 | expectedPR := github.PullRequest{ 135 | Title: "a", 136 | Head: github.Ref{Ref: "b"}, 137 | Base: github.Ref{Ref: "c"}, 138 | Body: "d", 139 | MaintainerCanModify: true, 140 | Draft: true, 141 | } 142 | 143 | c := &github.Client{ 144 | Token: "abc", 145 | HTTPClient: responseClient(func(req *http.Request) (interface{}, int) { 146 | type r struct { 147 | Method string 148 | Path string 149 | NewPR github.NewPullRequest 150 | } 151 | expectedR := r{ 152 | Method: "POST", 153 | Path: "/repos/user/repo/pulls", 154 | NewPR: expectedNewPR, 155 | } 156 | actualR := r{ 157 | Method: req.Method, 158 | Path: req.URL.Path, 159 | } 160 | _ = json.NewDecoder(req.Body).Decode(&actualR.NewPR) 161 | 162 | if !reflect.DeepEqual(expectedR, actualR) { 163 | t.Errorf("expected:\n%#v\ngot:\n%#v\n", expectedR, actualR) 164 | } 165 | 166 | return expectedPR, 200 167 | }), 168 | } 169 | 170 | actualPR, err := c.NewRepoRef("user/repo").CreatePullRequest(expectedNewPR) 171 | if err != nil { 172 | t.Error(err) 173 | } 174 | 175 | if !reflect.DeepEqual(expectedPR, actualPR) { 176 | t.Errorf("expected PR %#v, got %#v", expectedPR, actualPR) 177 | } 178 | } 179 | 180 | func TestUpdatePullRequest(t *testing.T) { 181 | expectedUpdatePR := github.UpdatePullRequest{ 182 | Title: github.StrR("a"), 183 | Base: github.StrR("c"), 184 | Body: github.StrR("d"), 185 | State: github.StrR("closed"), 186 | MaintainerCanModify: github.BoolR(true), 187 | } 188 | expectedPR := github.PullRequest{ 189 | Title: "a", 190 | Head: github.Ref{Ref: "b"}, 191 | Base: github.Ref{Ref: "c"}, 192 | Body: "d", 193 | MaintainerCanModify: true, 194 | Draft: true, 195 | } 196 | 197 | c := &github.Client{ 198 | Token: "abc", 199 | HTTPClient: responseClient(func(req *http.Request) (interface{}, int) { 200 | type r struct { 201 | Method string 202 | Path string 203 | UpdatePR github.UpdatePullRequest 204 | } 205 | expectedR := r{ 206 | Method: "PATCH", 207 | Path: "/repos/user/repo/pulls/123", 208 | UpdatePR: expectedUpdatePR, 209 | } 210 | actualR := r{ 211 | Method: req.Method, 212 | Path: req.URL.Path, 213 | } 214 | _ = json.NewDecoder(req.Body).Decode(&actualR.UpdatePR) 215 | 216 | if !reflect.DeepEqual(expectedR, actualR) { 217 | t.Errorf("expected:\n%#v\ngot:\n%#v\n", expectedR, actualR) 218 | } 219 | 220 | return expectedPR, 200 221 | }), 222 | } 223 | 224 | actualPR, err := c.NewRepoRef("user/repo").UpdatePullRequest(123, expectedUpdatePR) 225 | if err != nil { 226 | t.Error(err) 227 | } 228 | 229 | if !reflect.DeepEqual(expectedPR, actualPR) { 230 | t.Errorf("expected PR %#v, got %#v", expectedPR, actualPR) 231 | } 232 | } 233 | 234 | func TestCreateComment(t *testing.T) { 235 | expectedNewComment := github.NewComment{ 236 | Body: "a", 237 | } 238 | expectedComment := github.Comment{ 239 | Body: "a", 240 | } 241 | 242 | c := &github.Client{ 243 | Token: "abc", 244 | HTTPClient: responseClient(func(req *http.Request) (interface{}, int) { 245 | type r struct { 246 | Method string 247 | Path string 248 | NewComment github.NewComment 249 | } 250 | expectedR := r{ 251 | Method: "POST", 252 | Path: "/repos/user/repo/issues/123/comments", 253 | NewComment: expectedNewComment, 254 | } 255 | actualR := r{ 256 | Method: req.Method, 257 | Path: req.URL.Path, 258 | } 259 | _ = json.NewDecoder(req.Body).Decode(&actualR.NewComment) 260 | 261 | if !reflect.DeepEqual(expectedR, actualR) { 262 | t.Errorf("expected:\n%#v\ngot:\n%#v\n", expectedR, actualR) 263 | } 264 | 265 | return expectedComment, 200 266 | }), 267 | } 268 | 269 | actualComment, err := c.NewRepoRef("user/repo").CreateComment(123, expectedNewComment) 270 | if err != nil { 271 | t.Error(err) 272 | } 273 | 274 | if !reflect.DeepEqual(expectedComment, actualComment) { 275 | t.Errorf("expected PR %#v, got %#v", expectedComment, actualComment) 276 | } 277 | } 278 | 279 | func TestIsValidBranchName(t *testing.T) { 280 | testCases := []struct { 281 | s string 282 | e string 283 | }{ 284 | {``, "can't be empty"}, 285 | {`.a`, "can't start with '.'"}, 286 | {`a/`, "can't end with '/'"}, 287 | {`a.lock`, "can't end with '.lock'"}, 288 | {`/a/`, "can't end with '/'"}, 289 | {`~`, `can't include any of '~^: \@?*{}[]'`}, 290 | {`^`, `can't include any of '~^: \@?*{}[]'`}, 291 | {`:`, `can't include any of '~^: \@?*{}[]'`}, 292 | {` `, `can't include any of '~^: \@?*{}[]'`}, 293 | {`\`, `can't include any of '~^: \@?*{}[]'`}, 294 | {`@`, `can't include any of '~^: \@?*{}[]'`}, 295 | {`?`, `can't include any of '~^: \@?*{}[]'`}, 296 | {`*`, `can't include any of '~^: \@?*{}[]'`}, 297 | {`{`, `can't include any of '~^: \@?*{}[]'`}, 298 | {`}`, `can't include any of '~^: \@?*{}[]'`}, 299 | {`[`, `can't include any of '~^: \@?*{}[]'`}, 300 | {`]`, `can't include any of '~^: \@?*{}[]'`}, 301 | {"\x00", "can't include control characters"}, 302 | {"\x01", "can't include control characters"}, 303 | {"\x02", "can't include control characters"}, 304 | {"\x03", "can't include control characters"}, 305 | {"\x04", "can't include control characters"}, 306 | {"\x05", "can't include control characters"}, 307 | {"\x06", "can't include control characters"}, 308 | {"\x07", "can't include control characters"}, 309 | {"\x08", "can't include control characters"}, 310 | {"\x09", "can't include control characters"}, 311 | {"\x0a", "can't include control characters"}, 312 | {"\x0b", "can't include control characters"}, 313 | {"\x0c", "can't include control characters"}, 314 | {"\x0d", "can't include control characters"}, 315 | {"\x0e", "can't include control characters"}, 316 | {"\x0f", "can't include control characters"}, 317 | {"\x10", "can't include control characters"}, 318 | {"\x11", "can't include control characters"}, 319 | {"\x12", "can't include control characters"}, 320 | {"\x13", "can't include control characters"}, 321 | {"\x14", "can't include control characters"}, 322 | {"\x15", "can't include control characters"}, 323 | {"\x16", "can't include control characters"}, 324 | {"\x17", "can't include control characters"}, 325 | {"\x18", "can't include control characters"}, 326 | {"\x19", "can't include control characters"}, 327 | {"\x1a", "can't include control characters"}, 328 | {"\x1b", "can't include control characters"}, 329 | {"\x1c", "can't include control characters"}, 330 | {"\x1d", "can't include control characters"}, 331 | {"\x1e", "can't include control characters"}, 332 | {"\x1f", "can't include control characters"}, 333 | {"\x7f", "can't include control characters"}, 334 | {`a`, ""}, 335 | {`ab/cd/ab-cd-ab.cd_ab_cd`, ""}, 336 | } 337 | for _, tC := range testCases { 338 | t.Run(tC.s, func(t *testing.T) { 339 | actual := github.IsValidBranchName(tC.s) 340 | expected := tC.e 341 | 342 | if expected == "" { 343 | if actual != nil { 344 | t.Errorf("expected nil got %s", actual.Error()) 345 | 346 | } 347 | } else { 348 | if actual == nil { 349 | t.Errorf("expected %s got nil", expected) 350 | } else if expected != actual.Error() { 351 | t.Errorf("expected %s got %s", expected, actual.Error()) 352 | } 353 | } 354 | }) 355 | } 356 | } 357 | 358 | // // user to do test requests durinv dev 359 | // func TestRealAPI(t *testing.T) { 360 | // c := &Client{ 361 | // Token: "", 362 | // Version: "test", 363 | // } 364 | // prs, err := c.NewRepoRef("user/repo").ListPullRequest() 365 | // log.Printf("err: %#+v\n", err) 366 | // log.Printf("prs: %#+v\n", prs) 367 | // } 368 | -------------------------------------------------------------------------------- /internal/githubaction/githubaction.go: -------------------------------------------------------------------------------- 1 | package githubaction 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "html/template" 7 | "strings" 8 | 9 | "github.com/wader/bump/internal/bump" 10 | "github.com/wader/bump/internal/filter/all" 11 | "github.com/wader/bump/internal/github" 12 | sx "github.com/wader/bump/internal/slicex" 13 | ) 14 | 15 | // CheckTemplateReplaceFn builds a function for doing template replacing for check 16 | func CheckTemplateReplaceFn(c *bump.Check) func(s string) (string, error) { 17 | varReplacer := strings.NewReplacer( 18 | "$NAME", c.Name, 19 | "$LATEST", c.Latest, 20 | // TODO: this might be wrong if there are multiple current versions 21 | "$CURRENT", c.Currents[0].Version, 22 | ) 23 | 24 | currentVersions := sx.Unique(sx.Map(c.Currents, func(c bump.Current) string { 25 | return c.Version 26 | })) 27 | messages := sx.Map(c.Messages, func(m bump.CheckMessage) string { 28 | return varReplacer.Replace(m.Message) 29 | }) 30 | type link struct { 31 | Title string 32 | URL string 33 | } 34 | links := sx.Map(c.Links, func(l bump.CheckLink) link { 35 | return link{ 36 | Title: varReplacer.Replace(l.Title), 37 | URL: varReplacer.Replace(l.URL), 38 | } 39 | }) 40 | 41 | tmplData := struct { 42 | Name string 43 | Current []string 44 | Messages []string 45 | Latest string 46 | Links []link 47 | }{ 48 | Name: c.Name, 49 | Current: currentVersions, 50 | Messages: messages, 51 | Latest: c.Latest, 52 | Links: links, 53 | } 54 | 55 | return func(s string) (string, error) { 56 | tmpl := template.New("") 57 | tmpl = tmpl.Funcs(template.FuncMap{ 58 | "join": strings.Join, 59 | }) 60 | tmpl, err := tmpl.Parse(s) 61 | if err != nil { 62 | return "", err 63 | } 64 | 65 | execBuf := &bytes.Buffer{} 66 | err = tmpl.Execute(execBuf, tmplData) 67 | if err != nil { 68 | return "", err 69 | } 70 | 71 | return execBuf.String(), nil 72 | } 73 | } 74 | 75 | // Command is a github action interface to bump packages 76 | type Command struct { 77 | Version string 78 | OS bump.OS 79 | } 80 | 81 | // Run bump in a github action environment 82 | func (c Command) Run() []error { 83 | errs := c.run() 84 | for _, err := range errs { 85 | fmt.Fprintln(c.OS.Stderr(), err) 86 | } 87 | 88 | return errs 89 | } 90 | 91 | func (c Command) execs(argss [][]string) error { 92 | for _, args := range argss { 93 | fmt.Printf("exec> %s\n", strings.Join(args, " ")) 94 | if err := c.OS.Exec(args, nil); err != nil { 95 | return err 96 | } 97 | } 98 | return nil 99 | } 100 | 101 | func (c Command) shell(cmd string, env []string) error { 102 | fmt.Printf("shell> %s %s\n", strings.Join(env, " "), cmd) 103 | if err := c.OS.Shell(cmd, env); err != nil { 104 | return err 105 | } 106 | return nil 107 | } 108 | 109 | func (c Command) run() []error { 110 | ae, err := github.NewActionEnv(c.OS.Getenv, c.Version) 111 | if err != nil { 112 | return []error{err} 113 | } 114 | // TODO: used in tests 115 | ae.Client.BaseURL = c.OS.Getenv("GITHUB_API_URL") 116 | 117 | if ae.SHA == "" { 118 | return []error{fmt.Errorf("GITHUB_SHA not set")} 119 | } 120 | 121 | // support "bump_files" for backward compatibility 122 | bumpFiles, _ := ae.Input("bump_files") 123 | files, _ := ae.Input("files") 124 | var bumpfile, 125 | titleTemplate, 126 | commitBodyTemplate, 127 | prBodyTemplate, 128 | branchTemplate, 129 | userName, 130 | userEmail string 131 | for _, v := range []struct { 132 | s *string 133 | n string 134 | }{ 135 | {&bumpfile, "bumpfile"}, 136 | {&titleTemplate, "title_template"}, 137 | {&commitBodyTemplate, "commit_body_template"}, 138 | {&prBodyTemplate, "pr_body_template"}, 139 | {&branchTemplate, "branch_template"}, 140 | {&userName, "user_name"}, 141 | {&userEmail, "user_email"}, 142 | } { 143 | s, err := ae.Input(v.n) 144 | if err != nil { 145 | return []error{err} 146 | } 147 | *v.s = s 148 | } 149 | 150 | pushURL := fmt.Sprintf("https://%s:%s@github.com/%s.git", ae.Actor, ae.Client.Token, ae.Repository) 151 | err = c.execs([][]string{ 152 | // safe.directory workaround for CVE-2022-24765 153 | // https://github.blog/2022-04-12-git-security-vulnerability-announced/ 154 | {"git", "config", "--global", "--add", "safe.directory", ae.Workspace}, 155 | {"git", "config", "--global", "user.name", userName}, 156 | {"git", "config", "--global", "user.email", userEmail}, 157 | {"git", "remote", "set-url", "--push", "origin", pushURL}, 158 | }) 159 | if err != nil { 160 | return []error{err} 161 | } 162 | 163 | // TODO: whitespace in filenames 164 | var filenames []string 165 | filenames = append(filenames, strings.Fields(bumpFiles)...) 166 | filenames = append(filenames, strings.Fields(files)...) 167 | bfs, errs := bump.NewBumpFileSet(c.OS, all.Filters(), bumpfile, filenames) 168 | if errs != nil { 169 | return errs 170 | } 171 | 172 | for _, check := range bfs.Checks { 173 | // only consider this check for update actions 174 | bfs.SkipCheckFn = func(skipC *bump.Check) bool { 175 | return skipC.Name != check.Name 176 | } 177 | 178 | ua, errs := bfs.UpdateActions() 179 | if errs != nil { 180 | return errs 181 | } 182 | 183 | fmt.Printf("Checking %s\n", check.Name) 184 | 185 | if !check.HasUpdate() { 186 | fmt.Printf(" No updates\n") 187 | 188 | // TODO: close if PR is open? 189 | continue 190 | } 191 | 192 | fmt.Printf(" Updatable to %s\n", check.Latest) 193 | 194 | templateReplacerFn := CheckTemplateReplaceFn(check) 195 | 196 | branchName, err := templateReplacerFn(branchTemplate) 197 | if err != nil { 198 | return []error{fmt.Errorf("branch template error: %w", err)} 199 | } 200 | if err := github.IsValidBranchName(branchName); err != nil { 201 | return []error{fmt.Errorf("branch name %q is invalid: %w", branchName, err)} 202 | } 203 | 204 | prs, err := ae.RepoRef.ListPullRequest("state", "all", "head", ae.Owner+":"+branchName) 205 | if err != nil { 206 | return []error{err} 207 | } 208 | 209 | // there is already an open or closed PR for this update 210 | if len(prs) > 0 { 211 | fmt.Printf(" Open or closed PR %d %s already exists\n", 212 | prs[0].Number, ae.Owner+":"+branchName) 213 | 214 | // TODO: do get pull request and check for mergable and rerun/close if needed? 215 | continue 216 | } 217 | 218 | // reset HEAD back to triggering commit before each PR 219 | err = c.execs([][]string{{"git", "reset", "--hard", ae.SHA}}) 220 | if err != nil { 221 | return []error{err} 222 | } 223 | 224 | for _, fc := range ua.FileChanges { 225 | if err := c.OS.WriteFile(fc.File.Name, []byte(fc.NewText)); err != nil { 226 | return []error{err} 227 | } 228 | 229 | fmt.Printf(" Wrote change to %s\n", fc.File.Name) 230 | } 231 | 232 | for _, rs := range ua.RunShells { 233 | if err := c.shell(rs.Cmd, rs.Env); err != nil { 234 | return []error{fmt.Errorf("%s: shell: %s: %w", rs.Check.Name, rs.Cmd, err)} 235 | } 236 | } 237 | 238 | title, err := templateReplacerFn(titleTemplate) 239 | if err != nil { 240 | return []error{fmt.Errorf("title template error: %w", err)} 241 | } 242 | commitBody, err := templateReplacerFn(commitBodyTemplate) 243 | if err != nil { 244 | return []error{fmt.Errorf("title template error: %w", err)} 245 | } 246 | prBody, err := templateReplacerFn(prBodyTemplate) 247 | if err != nil { 248 | return []error{fmt.Errorf("title template error: %w", err)} 249 | } 250 | 251 | err = c.execs([][]string{ 252 | {"git", "diff"}, 253 | {"git", "add", "--all"}, 254 | {"git", "commit", "--message", title, "--message", commitBody}, 255 | // force so if for some reason there was an existing closed update PR with the same name 256 | {"git", "push", "--force", "origin", "HEAD:refs/heads/" + branchName}, 257 | }) 258 | if err != nil { 259 | return []error{err} 260 | } 261 | 262 | fmt.Printf(" Committed and pushed\n") 263 | 264 | newPr, err := ae.RepoRef.CreatePullRequest(github.NewPullRequest{ 265 | Base: ae.Ref, 266 | Head: ae.Owner + ":" + branchName, 267 | Title: title, 268 | Body: &prBody, 269 | }) 270 | if err != nil { 271 | return []error{err} 272 | } 273 | 274 | fmt.Printf(" Created PR %s\n", newPr.URL) 275 | } 276 | 277 | return nil 278 | } 279 | -------------------------------------------------------------------------------- /internal/githubaction/githubaction_test.go: -------------------------------------------------------------------------------- 1 | package githubaction_test 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/wader/bump/internal/bump" 7 | "github.com/wader/bump/internal/githubaction" 8 | ) 9 | 10 | func Test(t *testing.T) { 11 | c := &bump.Check{ 12 | Name: "aaa", 13 | Latest: "3", 14 | Currents: []bump.Current{ 15 | {Version: "1"}, 16 | {Version: "2"}, 17 | {Version: "2"}, 18 | }, 19 | Messages: []bump.CheckMessage{ 20 | {Message: "msg1 $NAME/$CURRENT/$LATEST"}, 21 | {Message: "msg2 $NAME/$CURRENT/$LATEST"}, 22 | }, 23 | Links: []bump.CheckLink{ 24 | {Title: "title 1 $NAME/$CURRENT/$LATEST", URL: "https://1/$NAME/$CURRENT/$LATEST"}, 25 | {Title: "title 2 $NAME/$CURRENT/$LATEST", URL: "https://2/$NAME/$CURRENT/$LATEST"}, 26 | }, 27 | } 28 | 29 | tf := githubaction.CheckTemplateReplaceFn(c) 30 | 31 | testCases := []struct { 32 | template string 33 | expected string 34 | }{ 35 | {`Update {{.Name}} to {{.Latest}} from {{join .Current ", "}}`, `Update aaa to 3 from 1, 2`}, 36 | { 37 | `` + 38 | `{{range .Messages}}{{.}}{{"\n\n"}}{{end}}` + 39 | `{{range .Links}}{{.Title}} {{.URL}}{{"\n"}}{{end}}`, 40 | "" + 41 | "msg1 aaa/1/3\n\n" + 42 | "msg2 aaa/1/3\n\n" + 43 | "title 1 aaa/1/3 https://1/aaa/1/3\n" + 44 | "title 2 aaa/1/3 https://2/aaa/1/3\n", 45 | }, 46 | { 47 | `` + 48 | `{{range .Messages}}{{.}}{{"\n\n"}}{{end}}` + 49 | `{{range .Links}}[{{.Title}}]({{.URL}}) {{"\n"}}{{end}}`, 50 | "" + 51 | "msg1 aaa/1/3\n\n" + 52 | "msg2 aaa/1/3\n\n" + 53 | "[title 1 aaa/1/3](https://1/aaa/1/3) \n" + 54 | "[title 2 aaa/1/3](https://2/aaa/1/3) \n", 55 | }, 56 | {`bump-{{.Name}}-{{.Latest}}`, `bump-aaa-3`}, 57 | } 58 | for _, tC := range testCases { 59 | t.Run(tC.template, func(t *testing.T) { 60 | actual, err := tf(tC.template) 61 | if err != nil { 62 | t.Error(err) 63 | } 64 | if tC.expected != actual { 65 | t.Errorf("expected %q, got %q", tC.expected, actual) 66 | } 67 | }) 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /internal/gitrefs/pktline/pktline.go: -------------------------------------------------------------------------------- 1 | // Package pktline implements git pktline format 2 | // https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt 3 | // Encoded as hexlen + string where len is 16 bit hex encoded len(string) + len(hexlen) 4 | // Ex: "a" is "0005a" 5 | // Ex: "" is "0000" (special case) 6 | package pktline 7 | 8 | import ( 9 | "encoding/binary" 10 | "encoding/hex" 11 | "fmt" 12 | "io" 13 | ) 14 | 15 | // Read a pktline 16 | func Read(r io.Reader) (string, error) { 17 | var err error 18 | 19 | var lenHexBuf [4]byte 20 | _, err = io.ReadFull(r, lenHexBuf[:]) 21 | if err != nil { 22 | return "", err 23 | } 24 | var lenBuf [2]byte 25 | _, err = hex.Decode(lenBuf[:], lenHexBuf[:]) 26 | if err != nil { 27 | return "", err 28 | } 29 | l := binary.BigEndian.Uint16(lenBuf[:]) 30 | if l == 0 { 31 | return "", nil 32 | } 33 | if l < 4 { 34 | return "", fmt.Errorf("short len %d", l) 35 | } 36 | lineBuf := make([]byte, l-4) 37 | _, err = io.ReadFull(r, lineBuf[:]) 38 | if err != nil { 39 | return "", err 40 | } 41 | 42 | return string(lineBuf), nil 43 | } 44 | 45 | // Write a pktline 46 | func Write(w io.Writer, s string) (int, error) { 47 | return w.Write(Encode(s)) 48 | } 49 | 50 | // Encode a pktline 51 | func Encode(s string) []byte { 52 | if len(s) == 0 { 53 | return []byte("0000") 54 | } 55 | 56 | return []byte(fmt.Sprintf("%04x%s", uint16(len(s)+4), s)) 57 | } 58 | -------------------------------------------------------------------------------- /internal/gitrefs/pktline/pktline_test.go: -------------------------------------------------------------------------------- 1 | package pktline_test 2 | 3 | import ( 4 | "bytes" 5 | "testing" 6 | 7 | "github.com/wader/bump/internal/gitrefs/pktline" 8 | ) 9 | 10 | func Test(t *testing.T) { 11 | testCases := []struct { 12 | pktLine []byte 13 | line string 14 | }{ 15 | { 16 | []byte("001e# service=git-upload-pack\n"), 17 | "# service=git-upload-pack\n", 18 | }, 19 | { 20 | []byte("004895dcfa3633004da0049d3d0fa03f80589cbcaf31 refs/heads/maint\x00multi_ack\n"), 21 | "95dcfa3633004da0049d3d0fa03f80589cbcaf31 refs/heads/maint\x00multi_ack\n", 22 | }, 23 | { 24 | []byte("003fd049f6c27a2244e12041955e262a404c7faba355 refs/heads/master\n"), 25 | "d049f6c27a2244e12041955e262a404c7faba355 refs/heads/master\n", 26 | }, 27 | { 28 | []byte("003c2cb58b79488a98d2721cea644875a8dd0026b115 refs/tags/v1.0\n"), 29 | "2cb58b79488a98d2721cea644875a8dd0026b115 refs/tags/v1.0\n", 30 | }, 31 | { 32 | []byte("003fa3c2e2402b99163d1d59756e5f207ae21cccba4c refs/tags/v1.0^{}\n"), 33 | "a3c2e2402b99163d1d59756e5f207ae21cccba4c refs/tags/v1.0^{}\n", 34 | }, 35 | { 36 | []byte("0000"), 37 | "", 38 | }, 39 | } 40 | for _, tC := range testCases { 41 | tC := tC 42 | t.Run(tC.line, func(t *testing.T) { 43 | actualLine, err := pktline.Read(bytes.NewReader(tC.pktLine)) 44 | if err != nil { 45 | t.Error(err) 46 | } else if tC.line != actualLine { 47 | t.Errorf("expected %q got %q", tC.line, actualLine) 48 | } 49 | 50 | b := &bytes.Buffer{} 51 | _, err = pktline.Write(b, tC.line) 52 | if err != nil { 53 | t.Error(err) 54 | } else if !bytes.Equal(tC.pktLine, b.Bytes()) { 55 | t.Errorf("expected %q got %q", tC.pktLine, b.Bytes()) 56 | } 57 | }) 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /internal/gitrefs/refs.go: -------------------------------------------------------------------------------- 1 | // Package gitrefs gets refs from a git repo (like git ls-remote) 2 | // https://github.com/git/git/blob/master/Documentation/technical/http-protocol.txt 3 | // https://github.com/git/git/blob/master/Documentation/technical/pack-protocol.txt 4 | package gitrefs 5 | 6 | import ( 7 | "bufio" 8 | "errors" 9 | "fmt" 10 | "io" 11 | "net" 12 | "net/http" 13 | "net/url" 14 | "os" 15 | "path/filepath" 16 | "strconv" 17 | "strings" 18 | 19 | "github.com/wader/bump/internal/gitrefs/pktline" 20 | ) 21 | 22 | const gitPort = 9418 23 | 24 | // AllProtos all protocols 25 | // FileProto might be dangerous if you don't control the url 26 | var AllProtos = []Proto{HTTPProto{}, GitProto{}, FileProto{}} 27 | 28 | // Ref is name/object id pair 29 | type Ref struct { 30 | Name string 31 | ObjID string 32 | } 33 | 34 | // Refs fetches refs for a remote repo (like git ls-remote) 35 | func Refs(rawurl string, protos []Proto) ([]Ref, error) { 36 | u, err := url.Parse(rawurl) 37 | if err != nil { 38 | return nil, err 39 | } 40 | 41 | for _, p := range protos { 42 | refs, err := p.Refs(u) 43 | if err == nil && refs == nil { 44 | continue 45 | } 46 | 47 | return refs, err 48 | } 49 | 50 | return nil, fmt.Errorf("unknown url: %s", rawurl) 51 | } 52 | 53 | // HEAD\0multi_ack thin-pack -> HEAD 54 | // HEAD -> HEAD 55 | func refName(s string) string { 56 | n := strings.Index(s, "\x00") 57 | if n == -1 { 58 | return s 59 | } 60 | return s[0:n] 61 | } 62 | 63 | // GITProtocol talk native git protocol 64 | // 000eversion 1 65 | // 00887217a7c7e582c46cec22a130adf4b9d7d950fba0 HEAD\0multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag 66 | // 00441d3fcd5ced445d1abc402225c0b8a1299641f497 refs/heads/integration 67 | // 003f7217a7c7e582c46cec22a130adf4b9d7d950fba0 refs/heads/master 68 | // 003cb88d2441cac0977faf98efc80305012112238d9d refs/tags/v0.9 69 | // 003c525128480b96c89e6418b1e40909bf6c5b2d580f refs/tags/v1.0 70 | // 003fe92df48743b7bc7d26bcaabfddde0a1e20cae47c refs/tags/v1.0^{} 71 | // 0000 72 | func GITProtocol(u *url.URL, rw io.ReadWriter) ([]Ref, error) { 73 | _, err := pktline.Write(rw, fmt.Sprintf("git-upload-pack %s\x00host=%s\x00\x00version=1\x00", u.Path, u.Host)) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | var refs []Ref 79 | for { 80 | line, err := pktline.Read(rw) 81 | if err != nil { 82 | return nil, err 83 | } 84 | if line == "" { 85 | break 86 | } 87 | line = strings.TrimSpace(line) 88 | 89 | objIDName := strings.SplitN(line, ` `, 2) 90 | if len(objIDName) != 2 { 91 | return nil, fmt.Errorf("unexpected refs line: %s", line) 92 | } 93 | objID := objIDName[0] 94 | name := refName(objIDName[1]) 95 | 96 | if objID == "version" { 97 | continue 98 | } 99 | 100 | refs = append(refs, Ref{Name: name, ObjID: objID}) 101 | } 102 | 103 | return refs, nil 104 | } 105 | 106 | // HTTPSmartProtocol talk git HTTP protocol 107 | // 001e# service=git-upload-pack\n 108 | // 0000 109 | // 004895dcfa3633004da0049d3d0fa03f80589cbcaf31 refs/heads/maint\0multi_ack\n 110 | // 003fd049f6c27a2244e12041955e262a404c7faba355 refs/heads/master\n 111 | // 003c2cb58b79488a98d2721cea644875a8dd0026b115 refs/tags/v1.0\n 112 | // 003fa3c2e2402b99163d1d59756e5f207ae21cccba4c refs/tags/v1.0^{}\n 113 | // 0000 114 | func HTTPSmartProtocol(r io.Reader) ([]Ref, error) { 115 | // read "# service=git-upload-pack" line 116 | _, err := pktline.Read(r) 117 | if err != nil { 118 | return nil, err 119 | } 120 | 121 | // read section start 122 | line, err := pktline.Read(r) 123 | if err != nil { 124 | return nil, err 125 | } 126 | if line != "" { 127 | return nil, fmt.Errorf("unexpected section start line: %s", line) 128 | } 129 | 130 | var refs []Ref 131 | for { 132 | line, err := pktline.Read(r) 133 | if err != nil { 134 | return nil, err 135 | } 136 | if line == "" { 137 | break 138 | } 139 | line = strings.TrimSpace(line) 140 | 141 | objIDName := strings.SplitN(line, " ", 2) 142 | if len(objIDName) != 2 { 143 | return nil, fmt.Errorf("unexpected refs line: %s", line) 144 | } 145 | objID := objIDName[0] 146 | name := refName(objIDName[1]) 147 | 148 | refs = append(refs, Ref{Name: name, ObjID: objID}) 149 | } 150 | 151 | return refs, nil 152 | } 153 | 154 | // HTTPDumbProtocol talk git dump HTTP protocol 155 | // 95dcfa3633004da0049d3d0fa03f80589cbcaf31\trefs/heads/maint\n 156 | // d049f6c27a2244e12041955e262a404c7faba355\trefs/heads/master\n 157 | // 2cb58b79488a98d2721cea644875a8dd0026b115\trefs/tags/v1.0\n 158 | // a3c2e2402b99163d1d59756e5f207ae21cccba4c\trefs/tags/v1.0^{}\n 159 | func HTTPDumbProtocol(r io.Reader) ([]Ref, error) { 160 | scanner := bufio.NewScanner(r) 161 | 162 | var refs []Ref 163 | for scanner.Scan() { 164 | line := scanner.Text() 165 | parts := strings.SplitN(line, "\t", 2) 166 | if len(parts) != 2 { 167 | return nil, fmt.Errorf("unexpected refs line: %s", line) 168 | } 169 | refs = append(refs, Ref{Name: parts[1], ObjID: parts[0]}) 170 | } 171 | if scanner.Err() != nil { 172 | return nil, scanner.Err() 173 | } 174 | 175 | return refs, nil 176 | } 177 | 178 | // Proto is a git protocol 179 | type Proto interface { 180 | Refs(u *url.URL) ([]Ref, error) 181 | } 182 | 183 | // HTTPProto implements git http protocol 184 | type HTTPProto struct { 185 | Client *http.Client // http.DefaultClient if nil 186 | } 187 | 188 | // Refs from http repo 189 | func (h HTTPProto) Refs(u *url.URL) ([]Ref, error) { 190 | if u.Scheme != "http" && u.Scheme != "https" { 191 | return nil, nil 192 | } 193 | 194 | client := h.Client 195 | if client == nil { 196 | client = http.DefaultClient 197 | } 198 | 199 | req, err := http.NewRequest(http.MethodGet, u.String()+"/info/refs?service=git-upload-pack", nil) 200 | if err != nil { 201 | return nil, err 202 | } 203 | // some git hosts behave differently based on this, github allows 204 | // to skip .git if set for example 205 | req.Header.Set("User-Agent", "git/1.0") 206 | 207 | resp, err := client.Do(req) 208 | if err != nil { 209 | return nil, err 210 | } 211 | defer resp.Body.Close() 212 | if resp.Header.Get("Content-Type") == "application/x-git-upload-pack-advertisement" { 213 | return HTTPSmartProtocol(resp.Body) 214 | } 215 | return HTTPDumbProtocol(resp.Body) 216 | } 217 | 218 | // GitProto implements gits own protocol 219 | type GitProto struct{} 220 | 221 | // Refs from git repo 222 | func (GitProto) Refs(u *url.URL) ([]Ref, error) { 223 | if u.Scheme != "git" { 224 | return nil, nil 225 | } 226 | 227 | address := u.Host 228 | if u.Port() == "" { 229 | address = address + ":" + strconv.Itoa(gitPort) 230 | } 231 | n, err := net.Dial("tcp", address) 232 | if err != nil { 233 | return nil, err 234 | } 235 | defer n.Close() 236 | return GITProtocol(u, n) 237 | } 238 | 239 | func readSymref(gitPath string, p string) (string, error) { 240 | fp := filepath.Join(gitPath, p) 241 | fi, err := os.Stat(fp) 242 | if err != nil { 243 | return "", err 244 | } 245 | 246 | // if symlink try read content of dest file otherwise return just name of dest 247 | if fi.Mode()&os.ModeSymlink != 0 { 248 | dst, err := os.Readlink(fp) 249 | if err != nil { 250 | return "", err 251 | } 252 | 253 | b, err := os.ReadFile(filepath.Join(gitPath, dst)) 254 | if err != nil { 255 | return dst, nil 256 | } 257 | return strings.TrimSpace(string(b)), nil 258 | } 259 | 260 | // if "ref: path" try read content of file otherwise return just name of file 261 | b, err := os.ReadFile(fp) 262 | if err != nil { 263 | return "", nil 264 | } 265 | 266 | // "ref: path" 267 | parts := strings.SplitN(strings.TrimSpace(string(b)), ": ", 2) 268 | if len(parts) != 2 { 269 | return "", errors.New("unknown ref format") 270 | } 271 | 272 | dst := parts[1] 273 | b, err = os.ReadFile(filepath.Join(gitPath, dst)) 274 | if err != nil { 275 | return dst, nil 276 | } 277 | return strings.TrimSpace(string(b)), nil 278 | } 279 | 280 | // FileProto implements reading local git repo 281 | type FileProto struct{} 282 | 283 | // Refs from file repo 284 | func (f FileProto) Refs(u *url.URL) ([]Ref, error) { 285 | if u.Scheme != "file" { 286 | return nil, nil 287 | } 288 | 289 | // bare or normal repo? 290 | gitPath := u.Path 291 | testPath := filepath.Join(u.Path, ".git") 292 | if fi, err := os.Stat(testPath); err == nil && fi.IsDir() { 293 | gitPath = testPath 294 | } 295 | 296 | var refs []Ref 297 | 298 | ref, err := readSymref(gitPath, "HEAD") 299 | if err != nil { 300 | return nil, err 301 | } 302 | refs = append(refs, Ref{Name: "HEAD", ObjID: ref}) 303 | 304 | err = filepath.Walk( 305 | filepath.Join(gitPath, "refs"), 306 | func(path string, info os.FileInfo, err error) error { 307 | if err != nil { 308 | return err 309 | } 310 | if !info.Mode().IsRegular() { 311 | return nil 312 | } 313 | 314 | b, err := os.ReadFile(path) 315 | if err != nil { 316 | return err 317 | } 318 | 319 | refs = append(refs, Ref{ 320 | Name: strings.TrimPrefix(path, gitPath+string(os.PathSeparator)), 321 | ObjID: strings.TrimSpace(string(b)), 322 | }) 323 | 324 | return nil 325 | }) 326 | if err != nil { 327 | return nil, err 328 | } 329 | 330 | return refs, nil 331 | } 332 | -------------------------------------------------------------------------------- /internal/gitrefs/refs_test.go: -------------------------------------------------------------------------------- 1 | package gitrefs_test 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | "os" 10 | "os/exec" 11 | "reflect" 12 | "strings" 13 | "testing" 14 | 15 | "github.com/wader/bump/internal/gitrefs" 16 | ) 17 | 18 | func TestLocalRepo(t *testing.T) { 19 | tempDir, err := os.MkdirTemp("", "refs-test") 20 | if err != nil { 21 | t.Fatal(err) 22 | } 23 | defer os.RemoveAll(tempDir) 24 | 25 | runOrFatal := func(arg ...string) string { 26 | c := exec.Command(arg[0], arg[1:]...) 27 | c.Dir = tempDir 28 | b, err := c.Output() 29 | if err != nil { 30 | t.Fatal(err) 31 | } 32 | return string(b) 33 | } 34 | 35 | runOrFatal("git", "init", "-b", "main", ".") 36 | 37 | actualRefs, err := gitrefs.Refs("file://"+tempDir, gitrefs.AllProtos) 38 | if err != nil { 39 | t.Fatal(err) 40 | } 41 | expectedRefs := []gitrefs.Ref{{Name: "HEAD", ObjID: "refs/heads/main"}} 42 | if !reflect.DeepEqual(expectedRefs, actualRefs) { 43 | t.Errorf("expected %v got %v", expectedRefs, actualRefs) 44 | } 45 | 46 | runOrFatal("git", "config", "user.email", "test") 47 | runOrFatal("git", "config", "user.name", "test") 48 | runOrFatal("git", "commit", "--allow-empty", "--author", "test <test@test>", "--message", "test") 49 | sha := strings.TrimSpace(runOrFatal("git", "rev-parse", "HEAD")) 50 | 51 | actualRefs, err = gitrefs.Refs("file://"+tempDir, gitrefs.AllProtos) 52 | if err != nil { 53 | t.Fatal(err) 54 | } 55 | expectedRefs = []gitrefs.Ref{ 56 | {Name: "HEAD", ObjID: sha}, 57 | {Name: "refs/heads/main", ObjID: sha}, 58 | } 59 | if !reflect.DeepEqual(expectedRefs, actualRefs) { 60 | t.Errorf("expected %v got %v", expectedRefs, actualRefs) 61 | } 62 | } 63 | 64 | func TestRemoteRepos(t *testing.T) { 65 | for _, rawurl := range []string{ 66 | "https://code.videolan.org/videolan/x264.git", 67 | "https://github.com/FFmpeg/FFmpeg", 68 | "https://github.com/FFmpeg/FFmpeg.git", 69 | "https://aomedia.googlesource.com/aom", 70 | } { 71 | t.Run(rawurl, func(t *testing.T) { 72 | rawurl := rawurl 73 | t.Parallel() 74 | refs, err := gitrefs.Refs(rawurl, gitrefs.AllProtos) 75 | if err != nil { 76 | t.Fatal(err) 77 | } 78 | if len(refs) == 0 { 79 | t.Error("expected repo to have refs") 80 | } 81 | }) 82 | } 83 | } 84 | 85 | func hereBytes(s string) []byte { 86 | return []byte(strings.NewReplacer(`\0`, "\x00").Replace(s[1 : len(s)-1])) 87 | } 88 | 89 | func TestGitProtocol(t *testing.T) { 90 | r := bufio.NewReader(bytes.NewBuffer(hereBytes(` 91 | 000eversion 1 92 | 00887217a7c7e582c46cec22a130adf4b9d7d950fba0 HEAD\0multi_ack thin-pack side-band side-band-64k ofs-delta shallow no-progress include-tag 93 | 00441d3fcd5ced445d1abc402225c0b8a1299641f497 refs/heads/integration 94 | 003f7217a7c7e582c46cec22a130adf4b9d7d950fba0 refs/heads/master 95 | 003cb88d2441cac0977faf98efc80305012112238d9d refs/tags/v0.9 96 | 003c525128480b96c89e6418b1e40909bf6c5b2d580f refs/tags/v1.0 97 | 003fe92df48743b7bc7d26bcaabfddde0a1e20cae47c refs/tags/v1.0^{} 98 | 0000 99 | `))) 100 | wBuf := &bytes.Buffer{} 101 | w := bufio.NewWriter(wBuf) 102 | rw := bufio.NewReadWriter(r, w) 103 | 104 | u, err := url.Parse("git://host/repo.git") 105 | if err != nil { 106 | t.Fatal(err) 107 | } 108 | actualRefs, err := gitrefs.GITProtocol(u, rw) 109 | if err != nil { 110 | t.Fatal(err) 111 | } 112 | w.Flush() 113 | 114 | expectedRefs := []gitrefs.Ref{ 115 | {Name: "HEAD", ObjID: "7217a7c7e582c46cec22a130adf4b9d7d950fba0"}, 116 | {Name: "refs/heads/integration", ObjID: "1d3fcd5ced445d1abc402225c0b8a1299641f497"}, 117 | {Name: "refs/heads/master", ObjID: "7217a7c7e582c46cec22a130adf4b9d7d950fba0"}, 118 | {Name: "refs/tags/v0.9", ObjID: "b88d2441cac0977faf98efc80305012112238d9d"}, 119 | {Name: "refs/tags/v1.0", ObjID: "525128480b96c89e6418b1e40909bf6c5b2d580f"}, 120 | {Name: "refs/tags/v1.0^{}", ObjID: "e92df48743b7bc7d26bcaabfddde0a1e20cae47c"}, 121 | } 122 | 123 | if !reflect.DeepEqual(expectedRefs, actualRefs) { 124 | t.Errorf("expected %v got %v", expectedRefs, actualRefs) 125 | } 126 | 127 | actualCommand := wBuf.String() 128 | expectedCommand := "0033git-upload-pack /repo.git\x00host=host\x00\x00version=1\x00" 129 | if expectedCommand != actualCommand { 130 | t.Errorf("expected %v got %v", expectedCommand, actualCommand) 131 | } 132 | } 133 | 134 | func TestHTTPSmartProtocol(t *testing.T) { 135 | r := bytes.NewBuffer(hereBytes(` 136 | 001e# service=git-upload-pack 137 | 0000004895dcfa3633004da0049d3d0fa03f80589cbcaf31 refs/heads/maint\0multi_ack 138 | 003fd049f6c27a2244e12041955e262a404c7faba355 refs/heads/master 139 | 003c2cb58b79488a98d2721cea644875a8dd0026b115 refs/tags/v1.0 140 | 003fa3c2e2402b99163d1d59756e5f207ae21cccba4c refs/tags/v1.0^{} 141 | 0000 142 | `)) 143 | 144 | actualRefs, err := gitrefs.HTTPSmartProtocol(r) 145 | if err != nil { 146 | t.Fatal(err) 147 | } 148 | 149 | expectedRefs := []gitrefs.Ref{ 150 | {Name: "refs/heads/maint", ObjID: "95dcfa3633004da0049d3d0fa03f80589cbcaf31"}, 151 | {Name: "refs/heads/master", ObjID: "d049f6c27a2244e12041955e262a404c7faba355"}, 152 | {Name: "refs/tags/v1.0", ObjID: "2cb58b79488a98d2721cea644875a8dd0026b115"}, 153 | {Name: "refs/tags/v1.0^{}", ObjID: "a3c2e2402b99163d1d59756e5f207ae21cccba4c"}, 154 | } 155 | 156 | if !reflect.DeepEqual(expectedRefs, actualRefs) { 157 | t.Errorf("expected %v got %v", expectedRefs, actualRefs) 158 | } 159 | } 160 | 161 | func TestHTTPDumbProtocol(t *testing.T) { 162 | r := bytes.NewBuffer(hereBytes(` 163 | 95dcfa3633004da0049d3d0fa03f80589cbcaf31 refs/heads/maint 164 | d049f6c27a2244e12041955e262a404c7faba355 refs/heads/master 165 | 2cb58b79488a98d2721cea644875a8dd0026b115 refs/tags/v1.0 166 | a3c2e2402b99163d1d59756e5f207ae21cccba4c refs/tags/v1.0^{} 167 | `)) 168 | 169 | actualRefs, err := gitrefs.HTTPDumbProtocol(r) 170 | if err != nil { 171 | t.Fatal(err) 172 | } 173 | 174 | expectedRefs := []gitrefs.Ref{ 175 | {Name: "refs/heads/maint", ObjID: "95dcfa3633004da0049d3d0fa03f80589cbcaf31"}, 176 | {Name: "refs/heads/master", ObjID: "d049f6c27a2244e12041955e262a404c7faba355"}, 177 | {Name: "refs/tags/v1.0", ObjID: "2cb58b79488a98d2721cea644875a8dd0026b115"}, 178 | {Name: "refs/tags/v1.0^{}", ObjID: "a3c2e2402b99163d1d59756e5f207ae21cccba4c"}, 179 | } 180 | 181 | if !reflect.DeepEqual(expectedRefs, actualRefs) { 182 | t.Errorf("expected %v got %v", expectedRefs, actualRefs) 183 | } 184 | } 185 | 186 | type roundTripFunc func(*http.Request) (*http.Response, error) 187 | 188 | func (r roundTripFunc) RoundTrip(req *http.Request) (resp *http.Response, err error) { 189 | return r(req) 190 | } 191 | 192 | func TestHTTPClient(t *testing.T) { 193 | roundTripCalled := false 194 | hp := &gitrefs.HTTPProto{Client: &http.Client{ 195 | Transport: roundTripFunc(func(req *http.Request) (resp *http.Response, err error) { 196 | roundTripCalled = true 197 | return &http.Response{StatusCode: 200, Body: io.NopCloser(&bytes.Buffer{})}, nil 198 | }), 199 | }} 200 | u, _ := url.Parse("http://test/repo.git") 201 | _, _ = hp.Refs(u) 202 | if !roundTripCalled { 203 | t.Error("expected custom client RoundTrip to be called") 204 | } 205 | } 206 | -------------------------------------------------------------------------------- /internal/lexer/lexer.go: -------------------------------------------------------------------------------- 1 | package lexer 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "regexp" 7 | "strings" 8 | ) 9 | 10 | type lexer struct { 11 | s string 12 | p int 13 | } 14 | 15 | // ScanFn scans one character 16 | // s is one char or empty if at end 17 | // returns a string on success, empty if needs more or error on error 18 | type ScanFn func(s string) (string, error) 19 | 20 | func charToNice(s string) string { 21 | switch s { 22 | case "": 23 | return "end" 24 | default: 25 | return fmt.Sprintf("%q", s) 26 | } 27 | } 28 | 29 | func (l *lexer) scan(fn ScanFn) (string, int, error) { 30 | slen := len(l.s) 31 | 32 | for ; l.p < slen; l.p++ { 33 | if t, err := fn(l.s[l.p : l.p+1]); err != nil { 34 | return "", l.p, err 35 | } else if t != "" { 36 | return t, l.p - len(t), nil 37 | } 38 | } 39 | 40 | t, err := fn("") 41 | return t, slen, err 42 | } 43 | 44 | // Scan a string 45 | func Scan(s string, fn ScanFn) (int, error) { 46 | l := &lexer{s: s} 47 | for { 48 | t, p, err := l.scan(fn) 49 | if err != nil || t == "" { 50 | return p, err 51 | } 52 | } 53 | } 54 | 55 | // Var names and assigns string on success 56 | func Var(name string, dest *string, fn ScanFn) func(s string) (string, error) { 57 | return func(c string) (string, error) { 58 | t, err := fn(c) 59 | if err != nil { 60 | return t, fmt.Errorf("%s: %w", name, err) 61 | } 62 | if t != "" { 63 | *dest = t 64 | } 65 | return t, err 66 | } 67 | } 68 | 69 | // Re scans using a regexp 70 | func Re(re *regexp.Regexp) func(s string) (string, error) { 71 | start := true 72 | sb := strings.Builder{} 73 | return func(c string) (string, error) { 74 | if start && !re.MatchString(c) { 75 | return "", fmt.Errorf("unexpected %s, expected %s", charToNice(c), re) 76 | } 77 | start = false 78 | if re.MatchString(c) { 79 | sb.WriteString(c) 80 | return "", nil 81 | } 82 | return sb.String(), nil 83 | } 84 | } 85 | 86 | // Rest consumes the rest of the string assert it is at least min length 87 | func Rest(min int) func(s string) (string, error) { 88 | sb := strings.Builder{} 89 | return func(c string) (string, error) { 90 | if c == "" { 91 | if sb.Len() < min { 92 | return "", fmt.Errorf("unexpected end") 93 | } 94 | return sb.String(), nil 95 | } 96 | sb.WriteString(c) 97 | return "", nil 98 | } 99 | } 100 | 101 | // Quoted scans a quoted string using q as quote character 102 | func Quoted(q string) func(s string) (string, error) { 103 | const ( 104 | Start = iota 105 | InRe 106 | Escape 107 | End 108 | ) 109 | state := Start 110 | sb := strings.Builder{} 111 | 112 | return func(c string) (string, error) { 113 | if c == "" && state != End { 114 | return "", fmt.Errorf("found no quote ending") 115 | } 116 | 117 | switch state { 118 | case Start: 119 | if c != q { 120 | return "", fmt.Errorf("unexpected %s, expected quote start", charToNice(c)) 121 | } 122 | state = InRe 123 | return "", nil 124 | case InRe: 125 | if c == `\` { 126 | state = Escape 127 | } else if c == q { 128 | state = End 129 | } else { 130 | sb.WriteString(c) 131 | } 132 | return "", nil 133 | case Escape: 134 | if c != q { 135 | sb.WriteString(`\`) 136 | } 137 | sb.WriteString(c) 138 | state = InRe 139 | return "", nil 140 | case End: 141 | return sb.String(), nil 142 | } 143 | 144 | return "", errors.New("should not be reached") 145 | } 146 | } 147 | 148 | // Or scans until one succeeds 149 | func Or(fns ...ScanFn) func(s string) (string, error) { 150 | return func(c string) (string, error) { 151 | if len(fns) == 0 && c != "" { 152 | return "", errors.New("found no match") 153 | } 154 | 155 | var newFns []ScanFn 156 | for _, fn := range fns { 157 | if s, err := fn(c); err != nil { 158 | continue 159 | } else if s != "" { 160 | return s, nil 161 | } 162 | newFns = append(newFns, fn) 163 | } 164 | fns = newFns 165 | 166 | return "", nil 167 | } 168 | } 169 | 170 | // Concat scans all in order 171 | func Concat(fns ...ScanFn) func(s string) (string, error) { 172 | i := 0 173 | 174 | return func(c string) (string, error) { 175 | if i == len(fns) { 176 | if c == "" { 177 | return "", nil 178 | } 179 | return "", fmt.Errorf("unexpected %s", charToNice(c)) 180 | } 181 | fn := fns[i] 182 | 183 | s, err := fn(c) 184 | if err != nil { 185 | return s, err 186 | } else if s != "" { 187 | i++ 188 | return s, nil 189 | } 190 | return "", nil 191 | } 192 | } 193 | -------------------------------------------------------------------------------- /internal/lexer/lexer_test.go: -------------------------------------------------------------------------------- 1 | package lexer_test 2 | 3 | import ( 4 | "reflect" 5 | "regexp" 6 | "testing" 7 | 8 | "github.com/wader/bump/internal/lexer" 9 | ) 10 | 11 | func TestScan(t *testing.T) { 12 | makeStr := func() *string { 13 | var s string 14 | return &s 15 | } 16 | type input struct { 17 | s string 18 | expected map[string]string 19 | } 20 | testCases := []struct { 21 | makeScanFn func(vars map[string]*string) lexer.ScanFn 22 | vars map[string]*string 23 | inputs []input 24 | }{ 25 | { 26 | makeScanFn: func(vars map[string]*string) lexer.ScanFn { 27 | return lexer.Concat( 28 | lexer.Var("title", vars["title"], lexer.Or( 29 | lexer.Quoted(`"`), 30 | lexer.Re(regexp.MustCompile(`\w`)), 31 | )), 32 | lexer.Re(regexp.MustCompile(`\s`)), 33 | lexer.Var("URL", vars["URL"], lexer.Rest(1)), 34 | ) 35 | }, 36 | vars: map[string]*string{ 37 | "title": makeStr(), 38 | "URL": makeStr(), 39 | }, 40 | inputs: []input{ 41 | { 42 | s: `aaa bbb`, 43 | expected: map[string]string{ 44 | "title": "aaa", 45 | "URL": "bbb", 46 | }, 47 | }, 48 | { 49 | s: `"aaa aaa" bbb`, 50 | expected: map[string]string{ 51 | "title": "aaa aaa", 52 | "URL": "bbb", 53 | }, 54 | }, 55 | }, 56 | }, 57 | { 58 | makeScanFn: func(vars map[string]*string) lexer.ScanFn { 59 | return lexer.Concat( 60 | lexer.Var("name", vars["name"], lexer.Or( 61 | lexer.Quoted(`"`), 62 | lexer.Re(regexp.MustCompile(`\w`)), 63 | )), 64 | lexer.Re(regexp.MustCompile(`\s`)), 65 | lexer.Var("title", vars["title"], lexer.Or( 66 | lexer.Quoted(`"`), 67 | lexer.Re(regexp.MustCompile(`\w`)), 68 | )), 69 | lexer.Re(regexp.MustCompile(`\s`)), 70 | lexer.Var("rest", vars["rest"], lexer.Or( 71 | lexer.Quoted(`"`), 72 | lexer.Rest(1), 73 | )), 74 | ) 75 | }, 76 | vars: map[string]*string{ 77 | "name": makeStr(), 78 | "title": makeStr(), 79 | "rest": makeStr(), 80 | }, 81 | inputs: []input{ 82 | { 83 | s: `aaa bbb ccc ccc`, 84 | expected: map[string]string{ 85 | "name": "aaa", 86 | "title": "bbb", 87 | "rest": "ccc ccc", 88 | }, 89 | }, 90 | { 91 | s: `"aaa aaa" bbb ccc ccc`, 92 | expected: map[string]string{ 93 | "name": "aaa aaa", 94 | "title": "bbb", 95 | "rest": "ccc ccc", 96 | }, 97 | }, 98 | { 99 | s: `aaa "bbb bbb" ccc ccc`, 100 | expected: map[string]string{ 101 | "name": "aaa", 102 | "title": "bbb bbb", 103 | "rest": "ccc ccc", 104 | }, 105 | }, 106 | { 107 | s: `"aaa aaa" "bbb bbb" ccc ccc`, 108 | expected: map[string]string{ 109 | "name": "aaa aaa", 110 | "title": "bbb bbb", 111 | "rest": "ccc ccc", 112 | }, 113 | }, 114 | { 115 | s: `"aaa aaa" "bbb bbb" "ccc ccc"`, 116 | expected: map[string]string{ 117 | "name": "aaa aaa", 118 | "title": "bbb bbb", 119 | "rest": "ccc ccc", 120 | }, 121 | }, 122 | }, 123 | }, 124 | } 125 | for _, tC := range testCases { 126 | for _, i := range tC.inputs { 127 | t.Run(i.s, func(t *testing.T) { 128 | _, err := lexer.Scan(i.s, tC.makeScanFn(tC.vars)) 129 | if err != nil { 130 | t.Fatal(err) 131 | } 132 | 133 | actual := map[string]string{} 134 | for k, v := range tC.vars { 135 | actual[k] = *v 136 | } 137 | if !reflect.DeepEqual(actual, i.expected) { 138 | t.Errorf("expected %v, actual %v", i.expected, actual) 139 | } 140 | }) 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /internal/locline/locline.go: -------------------------------------------------------------------------------- 1 | // Package locline is used to translate from location to line number in a text 2 | package locline 3 | 4 | import ( 5 | "bytes" 6 | ) 7 | 8 | // LocLine is type for holding data to translate from location to line 9 | type LocLine [][2]int 10 | 11 | // New create a new LocLone for text 12 | func New(text []byte) LocLine { 13 | endIndex := len(text) 14 | index := 0 15 | lastIndex := 0 16 | var ranges [][2]int 17 | 18 | for { 19 | l := bytes.IndexByte(text[lastIndex:], "\n"[0]) 20 | if l == -1 { 21 | break 22 | } 23 | index = lastIndex + l + 1 24 | 25 | ranges = append(ranges, [2]int{lastIndex, index}) 26 | lastIndex = index 27 | } 28 | 29 | if index != endIndex { 30 | ranges = append(ranges, [2]int{lastIndex, endIndex}) 31 | } 32 | 33 | return LocLine(ranges) 34 | } 35 | 36 | // Line for location 37 | func (ll LocLine) Line(loc int) int { 38 | line := 1 39 | for _, l := range ll { 40 | if loc >= l[0] && loc < l[1] { 41 | return line 42 | } 43 | line++ 44 | } 45 | 46 | return -1 47 | } 48 | -------------------------------------------------------------------------------- /internal/locline/locline_test.go: -------------------------------------------------------------------------------- 1 | package locline_test 2 | 3 | import ( 4 | "reflect" 5 | "testing" 6 | 7 | "github.com/wader/bump/internal/locline" 8 | ) 9 | 10 | func TestLine(t *testing.T) { 11 | testCases := []struct { 12 | desc string 13 | text string 14 | expected []int 15 | }{ 16 | { 17 | desc: "empty", 18 | text: "", 19 | expected: nil, 20 | }, 21 | { 22 | desc: "no new line", 23 | text: "a", 24 | expected: []int{1}, 25 | }, 26 | { 27 | desc: "empty line", 28 | text: "\n", 29 | expected: []int{1}, 30 | }, 31 | { 32 | desc: "no newline", 33 | text: "aaa", 34 | expected: []int{1, 1, 1}, 35 | }, 36 | { 37 | desc: "one line", 38 | text: "aaa\n", 39 | expected: []int{1, 1, 1, 1}, 40 | }, 41 | { 42 | desc: "two lines, no ending newline", 43 | text: "aaa\nbbb", 44 | expected: []int{1, 1, 1, 1, 2, 2, 2}, 45 | }, 46 | { 47 | desc: "two lines", 48 | text: "aaa\nbbb\n", 49 | expected: []int{1, 1, 1, 1, 2, 2, 2, 2}, 50 | }, 51 | { 52 | desc: "one char lines", 53 | text: "a\nb\nc\n", 54 | expected: []int{1, 1, 2, 2, 3, 3}, 55 | }, 56 | { 57 | desc: "empty lines", 58 | text: "\n\n\n", 59 | expected: []int{1, 2, 3}, 60 | }, 61 | } 62 | for _, tC := range testCases { 63 | t.Run(tC.desc, func(t *testing.T) { 64 | ll := locline.New([]byte(tC.text)) 65 | 66 | var actual []int 67 | for i := 0; i < len(tC.text); i++ { 68 | actual = append(actual, ll.Line(i)) 69 | } 70 | 71 | if !reflect.DeepEqual(tC.expected, actual) { 72 | t.Errorf("expected %v, got %v", tC.expected, actual) 73 | } 74 | }) 75 | } 76 | } 77 | 78 | func TestOutOfBounds(t *testing.T) { 79 | ll := locline.New([]byte("a")) 80 | if ll.Line(1) != -1 { 81 | t.Error("expected 1 to be outside lines") 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /internal/pipeline/pipeline.go: -------------------------------------------------------------------------------- 1 | package pipeline 2 | 3 | import ( 4 | "fmt" 5 | "regexp" 6 | "strings" 7 | 8 | "github.com/wader/bump/internal/filter" 9 | ) 10 | 11 | // DefaultVersionKey is the default start key for pipelines 12 | const DefaultVersionKey = "name" 13 | 14 | // Pipeline is a slice of filters 15 | type Pipeline []filter.Filter 16 | 17 | var cntrlRe = regexp.MustCompile(`[[:cntrl:]]`) 18 | 19 | func hasControlCharacters(s string) bool { 20 | return cntrlRe.MatchString(s) 21 | } 22 | 23 | // New pipeline 24 | func New(filters []filter.NamedFilter, pipelineStr string) (pipeline Pipeline, err error) { 25 | var ppl []filter.Filter 26 | 27 | parts := strings.Split(pipelineStr, `|`) 28 | 29 | for _, filterExp := range parts { 30 | filterExp = strings.TrimSpace(filterExp) 31 | f, err := filter.NewFilter(filters, filterExp) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | ppl = append(ppl, f) 37 | } 38 | 39 | return Pipeline(ppl), nil 40 | } 41 | 42 | func (pl Pipeline) String() string { 43 | var ss []string 44 | for _, p := range pl { 45 | ss = append(ss, p.String()) 46 | 47 | } 48 | 49 | return strings.Join(ss, "|") 50 | } 51 | 52 | // Run pipeline 53 | func (pl Pipeline) Run(inVersionKey string, inVersions filter.Versions, logFn func(format string, v ...interface{})) (outValue string, outVersions filter.Versions, err error) { 54 | vs := inVersions 55 | versionKey := inVersionKey 56 | 57 | for _, f := range pl { 58 | beforeVersionKey := versionKey 59 | vs, versionKey, err = f.Filter(vs, versionKey) 60 | if err != nil { 61 | return "", nil, err 62 | } 63 | 64 | if logFn != nil { 65 | if logFn != nil { 66 | logFn("%s:", f) 67 | for _, v := range vs { 68 | logFn(" %v", v) 69 | } 70 | if len(vs) == 0 { 71 | logFn(" (none)") 72 | } 73 | logFn(" @ %s -> %s", beforeVersionKey, versionKey) 74 | } 75 | } 76 | } 77 | 78 | if len(vs) == 0 { 79 | return "", vs, nil 80 | } 81 | 82 | value := vs[0][versionKey] 83 | if hasControlCharacters(value) { 84 | return "", nil, fmt.Errorf("value %q for key %q version %s contains control characters", value, versionKey, vs[0]) 85 | } 86 | 87 | if logFn != nil { 88 | logFn(" value %s", value) 89 | } 90 | 91 | return value, vs, nil 92 | } 93 | 94 | // Value run the pipeline and return one value or error 95 | func (pl Pipeline) Value(logFn func(format string, v ...interface{})) (value string, err error) { 96 | v, pp, err := pl.Run(DefaultVersionKey, nil, logFn) 97 | if err != nil { 98 | return "", err 99 | } 100 | 101 | if len(pp) == 0 { 102 | return "", fmt.Errorf("no version found") 103 | } 104 | 105 | return v, err 106 | } 107 | -------------------------------------------------------------------------------- /internal/pipeline/pipeline_test.go: -------------------------------------------------------------------------------- 1 | package pipeline_test 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | "testing" 9 | 10 | "github.com/wader/bump/internal/deepequal" 11 | "github.com/wader/bump/internal/filter" 12 | "github.com/wader/bump/internal/filter/all" 13 | "github.com/wader/bump/internal/pipeline" 14 | ) 15 | 16 | type testCase struct { 17 | lineNr int 18 | pipelineStr string 19 | expectedPipelineStr string 20 | expectedErr string 21 | testFilterCases []testFilterCase 22 | } 23 | 24 | type testFilterCase struct { 25 | lineNr int 26 | versions filter.Versions 27 | expectedVersions filter.Versions 28 | expectedValue string 29 | expectedErr string 30 | } 31 | 32 | func parseTestCase(s string) []testCase { 33 | const errPrefix = "error:" 34 | var cases []testCase 35 | var tc testCase 36 | lineNr := 0 37 | 38 | for _, l := range strings.Split(s, "\n") { 39 | lineNr++ 40 | 41 | if strings.TrimSpace(l) == "" || strings.HasPrefix(l, "#") { 42 | continue 43 | } 44 | 45 | if strings.HasPrefix(l, " ") { 46 | parts := strings.Split(l, "->") 47 | 48 | versions := strings.TrimSpace(parts[0]) 49 | result := strings.TrimSpace(parts[1]) 50 | 51 | if strings.HasPrefix(result, errPrefix) { 52 | tc.testFilterCases = append(tc.testFilterCases, testFilterCase{ 53 | lineNr: lineNr, 54 | versions: filter.NewVersionsFromString(versions), 55 | expectedErr: strings.TrimPrefix(result, errPrefix), 56 | }) 57 | } else { 58 | resultParts := strings.SplitN(result, " ", 2) 59 | value := "" 60 | if len(resultParts) == 2 { 61 | value = resultParts[1] 62 | } 63 | 64 | tc.testFilterCases = append(tc.testFilterCases, testFilterCase{ 65 | lineNr: lineNr, 66 | versions: filter.NewVersionsFromString(versions), 67 | expectedVersions: filter.NewVersionsFromString(resultParts[0]), 68 | expectedValue: value, 69 | }) 70 | } 71 | } else { 72 | if tc.pipelineStr != "" { 73 | cases = append(cases, tc) 74 | } 75 | 76 | parts := strings.Split(l, "->") 77 | pipelineStr := strings.TrimSpace(parts[0]) 78 | expectedPipelineStr := strings.TrimSpace(parts[1]) 79 | 80 | if strings.HasPrefix(expectedPipelineStr, errPrefix) { 81 | tc = testCase{ 82 | lineNr: lineNr, 83 | pipelineStr: pipelineStr, 84 | expectedErr: strings.TrimPrefix(expectedPipelineStr, errPrefix), 85 | } 86 | } else { 87 | tc = testCase{ 88 | lineNr: lineNr, 89 | pipelineStr: pipelineStr, 90 | expectedPipelineStr: expectedPipelineStr, 91 | } 92 | } 93 | } 94 | } 95 | 96 | if tc.pipelineStr != "" { 97 | cases = append(cases, tc) 98 | } 99 | 100 | return cases 101 | } 102 | 103 | func TestParseTestCase(t *testing.T) { 104 | actual := parseTestCase(` 105 | # test 106 | expr -> expected 107 | -> 108 | a:key=1 -> a:key=1 value 109 | a,b:key=2 -> a,b:key=2 value 110 | test -> error:test 111 | 112 | /re/template/ -> re:/re/template/ 113 | re:/re/ -> re:/re/ 114 | -> error:test 115 | `[1:]) 116 | 117 | expected := []testCase{ 118 | { 119 | lineNr: 2, 120 | pipelineStr: "expr", 121 | expectedPipelineStr: "expected", 122 | testFilterCases: []testFilterCase{ 123 | { 124 | lineNr: 3, 125 | }, 126 | { 127 | lineNr: 4, 128 | versions: filter.Versions{map[string]string{"name": "a", "key": "1"}}, 129 | expectedVersions: filter.Versions{map[string]string{"name": "a", "key": "1"}}, 130 | expectedValue: "value", 131 | }, 132 | { 133 | lineNr: 5, 134 | versions: filter.Versions{ 135 | map[string]string{"name": "a"}, 136 | map[string]string{"name": "b", "key": "2"}, 137 | }, 138 | expectedVersions: filter.Versions{ 139 | map[string]string{"name": "a"}, 140 | map[string]string{"name": "b", "key": "2"}, 141 | }, 142 | expectedValue: "value", 143 | }, 144 | }, 145 | }, 146 | {lineNr: 6, pipelineStr: "test", expectedErr: "test"}, 147 | {lineNr: 8, pipelineStr: "/re/template/", expectedPipelineStr: "re:/re/template/"}, 148 | { 149 | lineNr: 9, 150 | pipelineStr: "re:/re/", 151 | expectedPipelineStr: "re:/re/", 152 | testFilterCases: []testFilterCase{ 153 | { 154 | lineNr: 10, 155 | expectedErr: "test", 156 | }, 157 | }, 158 | }, 159 | } 160 | 161 | deepequal.Error(t, "parse", expected, actual) 162 | } 163 | 164 | func testPipelineTestCase(t *testing.T, tcs []testCase) { 165 | for _, tc := range tcs { 166 | t.Run(fmt.Sprintf("%d", tc.lineNr), func(t *testing.T) { 167 | tc := tc 168 | p, err := pipeline.New(all.Filters(), tc.pipelineStr) 169 | if tc.expectedErr != "" { 170 | if err == nil { 171 | t.Fatalf("expected error %q got success", tc.expectedErr) 172 | } else if tc.expectedErr != err.Error() { 173 | t.Fatalf("expected error %q got %q", tc.expectedErr, err.Error()) 174 | } 175 | } else { 176 | if err != nil { 177 | t.Fatalf("expected %v got error %q", tc.expectedPipelineStr, err) 178 | } else { 179 | deepequal.Error(t, "pipeline string", tc.expectedPipelineStr, p.String()) 180 | } 181 | } 182 | 183 | for _, ft := range tc.testFilterCases { 184 | t.Run(fmt.Sprintf("%d", ft.lineNr), func(t *testing.T) { 185 | actualValue, actualVersions, err := p.Run(pipeline.DefaultVersionKey, ft.versions, nil) 186 | 187 | if ft.expectedErr != "" { 188 | if err == nil { 189 | t.Fatalf("expected error %q got success", ft.expectedErr) 190 | } else if err.Error() != ft.expectedErr { 191 | t.Fatalf("expected error %q got %q", ft.expectedErr, err) 192 | } 193 | } else { 194 | if err != nil { 195 | t.Fatalf("expected %v got error %q", ft.expectedVersions, err) 196 | } else { 197 | deepequal.Error(t, "versions", ft.expectedVersions, actualVersions) 198 | if ft.expectedValue != actualValue { 199 | t.Errorf("expected %q, got %q", ft.expectedValue, actualValue) 200 | } 201 | } 202 | } 203 | }) 204 | } 205 | }) 206 | } 207 | } 208 | 209 | func TestPipeline(t *testing.T) { 210 | const testDataDir = "testdata" 211 | testDataFiles, err := os.ReadDir(testDataDir) 212 | if err != nil { 213 | t.Fatal(err) 214 | } 215 | 216 | for _, fi := range testDataFiles { 217 | fi := fi 218 | t.Run(fi.Name(), func(t *testing.T) { 219 | t.Parallel() 220 | b, err := os.ReadFile(filepath.Join(testDataDir, fi.Name())) 221 | if err != nil { 222 | t.Fatal(err) 223 | } 224 | tcs := parseTestCase(string(b)) 225 | testPipelineTestCase(t, tcs) 226 | }) 227 | } 228 | } 229 | 230 | type testFilter struct { 231 | name string 232 | vs filter.Versions 233 | } 234 | 235 | func (t testFilter) String() string { 236 | return t.name 237 | } 238 | 239 | func (t testFilter) Filter(versions filter.Versions, versionKey string) (newVersions filter.Versions, newVersionKey string, err error) { 240 | return t.vs, versionKey, nil 241 | } 242 | 243 | func testPipeline(t *testing.T, pipelineStr string) pipeline.Pipeline { 244 | p, err := pipeline.New( 245 | []filter.NamedFilter{ 246 | { 247 | Name: "a", 248 | NewFn: func(prefix string, arg string) (filter.Filter, error) { 249 | if arg == "a" { 250 | return testFilter{name: "a", vs: filter.Versions{filter.NewVersionWithName("a", nil)}}, nil 251 | } 252 | return nil, nil 253 | }, 254 | }, 255 | }, 256 | pipelineStr, 257 | ) 258 | 259 | if err != nil { 260 | t.Fatal(err) 261 | } 262 | 263 | return p 264 | } 265 | 266 | func TestString(t *testing.T) { 267 | p := testPipeline(t, "a|a") 268 | expectedString := "a|a" 269 | actualString := p.String() 270 | if expectedString != actualString { 271 | t.Errorf("expected %q got %q", expectedString, actualString) 272 | } 273 | } 274 | 275 | func TestRun(t *testing.T) { 276 | p := testPipeline(t, "a|a") 277 | expectedRun := filter.Versions{map[string]string{"name": "a"}} 278 | expectedValue := "a" 279 | actualValue, actualRun, runErr := p.Run(pipeline.DefaultVersionKey, nil, nil) 280 | 281 | if runErr != nil { 282 | t.Fatal(runErr) 283 | } 284 | if expectedValue != actualValue { 285 | t.Errorf("expected value %q got %q", expectedValue, actualValue) 286 | } 287 | deepequal.Error(t, "run", expectedRun, actualRun) 288 | } 289 | 290 | func TestValue(t *testing.T) { 291 | p := testPipeline(t, "a|a") 292 | expectedValue := "a" 293 | actualValue, errValue := p.Value(nil) 294 | 295 | if errValue != nil { 296 | t.Fatal(errValue) 297 | } 298 | if expectedValue != actualValue { 299 | t.Errorf("expected value %q got %q", expectedValue, actualValue) 300 | } 301 | } 302 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/depsdev: -------------------------------------------------------------------------------- 1 | depsdev:npm:react|/^[\d.]+$/|=0.0.2 -> depsdev:npm:react|re:/^[\d.]+$/|semver:=0.0.2 2 | -> 0.0.2,0.0.1 0.0.2 3 | depsdev:go:golang.org/x/net|/^[\d.]+$/|=0.2.0 -> depsdev:go:golang.org/x/net|re:/^[\d.]+$/|semver:=0.2.0 4 | -> 0.2.0,0.1.0 0.2.0 5 | depsdev:maven:log4j:log4j|=1.2.4 -> depsdev:maven:log4j:log4j|semver:=1.2.4 6 | -> 1.2.4,1.1.3 1.2.4 7 | depsdev:pypi:av|=0.2.0 -> depsdev:pypi:av|semver:=0.2.0 8 | -> 0.2.0,0.1.0 0.2.0 9 | depsdev:cargo:serde|=0.2.0 -> depsdev:cargo:serde|semver:=0.2.0 10 | -> 0.2.0,0.0.0 0.2.0 -------------------------------------------------------------------------------- /internal/pipeline/testdata/docker: -------------------------------------------------------------------------------- 1 | docker:alpine|^2.7 -> docker:alpine|semver:^2.7 2 | -> 2.7,2.6 2.7 3 | docker:mwader/static-ffmpeg|^3.4 -> docker:mwader/static-ffmpeg|semver:^3.4 4 | -> 3.4.2,3.4 3.4.2 5 | docker:gcr.io/google.com/cloudsdktool/google-cloud-cli|=365.0.1-alpine -> docker:gcr.io/google.com/cloudsdktool/google-cloud-cli|semver:=365.0.1-alpine 6 | -> 365.0.1-alpine 365.0.1-alpine 7 | docker:ghcr.io/nginx-proxy/nginx-proxy|0.7.0 -> docker:ghcr.io/nginx-proxy/nginx-proxy|semver:0.7.0 8 | -> 0.7.0,0.7.0-alpine,0.7-alpine 0.7.0 9 | docker:non/existing -> docker:non/existing 10 | -> error:401 Unauthorized 11 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/err: -------------------------------------------------------------------------------- 1 | err:test -> err:test 2 | -> error:test 3 | static:a\n:b=c\n -> static:a\n:b=c\n 4 | -> error:value "a\n" for key "name" version a\n:b=c\n contains control characters 5 | static:a\n:b=c\n|key:b -> static:a\n:b=c\n|key:b 6 | -> error:value "c\n" for key "b" version a\n:b=c\n contains control characters 7 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/fetch: -------------------------------------------------------------------------------- 1 | http://a -> fetch:http://a 2 | https://a -> fetch:https://a 3 | fetch:http://a -> fetch:http://a 4 | fetch:https://a -> fetch:https://a 5 | fetch:https://www.github.com|/<title>(GitHub)/ -> fetch:https://www.github.com|re:/<title>(GitHub)/ 6 | -> GitHub GitHub 7 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/git: -------------------------------------------------------------------------------- 1 | git://a -> git:git://a 2 | git:git://a -> git:git://a 3 | git://a.git -> git:git://a.git 4 | https://a.git -> git:https://a.git 5 | 6 | git:https://github.com/torvalds/linux.git|=2.6.12 -> git:https://github.com/torvalds/linux.git|semver:=2.6.12 7 | -> 2.6.12:commit=26791a8bcf0e6d33f43aef7682bdb555236d56de,2.6.11:commit=5dc01c595e6c6ec9ccda4f6f69c131c0dd945f8c 2.6.12 8 | 9 | # test dash in git tags 10 | git:https://github.com/actions/go-versions.git|/(.*)-.*/$1/|=1.10.8 -> git:https://github.com/actions/go-versions.git|re:/(.*)-.*/$1/|semver:=1.10.8 11 | -> 1.10.8:commit=6cf25b0561303d5d83e3141c038d03ecab681b7b,1.9.7:commit=6cf25b0561303d5d83e3141c038d03ecab681b7b 1.10.8 12 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/gitrefs: -------------------------------------------------------------------------------- 1 | gitrefs:https://github.com/torvalds/linux.git|re:#^refs/tags/v3\.19$# -> gitrefs:https://github.com/torvalds/linux.git|re:#^refs/tags/v3\.19$# 2 | -> refs/tags/v3.19:name=refs/tags/v3.19:commit=e24f071559c7a928a3033e9fe9f68e52f4f6ec01 refs/tags/v3.19 3 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/key: -------------------------------------------------------------------------------- 1 | key:a -> key:a 2 | @abc -> key:abc 3 | 1:abc=a,2:abc=b -> 1:abc=a,2:abc=b a 4 | @a|@b -> key:a|key:b 5 | 1:a=a:b=b -> 1:a=a:b=b b 6 | key: -> error:should be key:<name> or @<name> 7 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/re: -------------------------------------------------------------------------------- 1 | /^a.*$/ -> re:/^a.*$/ 2 | -> 3 | a:1,b:2,c:3 -> a:1 a 4 | aa:1,b:2,ab:3 -> aa:1,ab:3 aa 5 | 6 | # just filter 7 | re:/a/ -> re:/a/ 8 | a:1,b:2,c:3 -> a:1 a 9 | 10 | # simple replace 11 | re:/a// -> re:/a// 12 | bab:1 -> bb:1 bb 13 | 14 | re:/a/b/ -> re:/a/b/ 15 | a:1,b:2,c:3 -> b:1 b 16 | re:/a/b/ -> re:/a/b/ 17 | bab:1 -> bbb:1 bbb 18 | 19 | # submatch replace 20 | /a(.)/ -> re:/a(.)/ 21 | ab:1 -> b:1 b 22 | 23 | # multiple submatch replace 24 | /(.)(.)/${0}$2$1/ -> re:/(.)(.)/${0}$2$1/ 25 | ab:1 -> abba:1 abba 26 | 27 | # named submatch name/value 28 | /(?P<name>.)(?P<value>.)/ -> re:/(?P<name>.)(?P<value>.)/ 29 | ab,cd -> a:name=a:value=b,c:name=c:value=d a 30 | 31 | # non-matching submatch 32 | /(?P<name>b)(?P<a>a)?./ -> re:/(?P<name>b)(?P<a>a)?./ 33 | bb -> b:name=b b 34 | 35 | re:/(/ -> error:error parsing regexp: missing closing ): `(` 36 | re:/(// -> error:error parsing regexp: missing closing ): `(` 37 | 38 | static:aaa 1.1.1\naaa 3.1.2\naaa 1.1.1\n|re:/(?m:^.* (.*)$)/|semver:3.1.2 -> static:aaa 1.1.1\naaa 3.1.2\naaa 1.1.1\n|re:/(?m:^.* (.*)$)/|semver:3.1.2 39 | -> 3.1.2,1.1.1,1.1.1 3.1.2 40 | 41 | # alternative delimiter 42 | re:#a# -> re:#a# 43 | a:1,b:2,c:3 -> a:1 a 44 | 45 | re:#a#b# -> re:#a#b# 46 | bab:1 -> bbb:1 bbb 47 | 48 | # does not support short syntax 49 | %a% -> error:no filter matches 50 | 51 | # transform with named submatch should change current key 52 | @commit|/^(.{12})/ -> key:commit|re:/^(.{12})/ 53 | a:commit=6e8e738ad208923de99951fe0b48239bfd864f28 -> a:commit=6e8e738ad208 6e8e738ad208 54 | 55 | 56 | @commit|/1/a/|@name -> key:commit|re:/1/a/|key:name 57 | abc:commit=1234 -> abc:commit=a234 abc 58 | 59 | @commit|/^(..).*/ -> key:commit|re:/^(..).*/ 60 | abc:commit=1234 -> abc:commit=12 12 61 | 62 | @commit|/^(?P<abc>..).*/ -> key:commit|re:/^(?P<abc>..).*/ 63 | abc:commit=1234 -> abc:abc=12:commit=1234 1234 64 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/semver: -------------------------------------------------------------------------------- 1 | ^2 -> semver:^2 2 | -> 3 | 1,2 -> 2,1 2 4 | semver:n -> semver:n 5 | 1.2.3 -> 1 1 6 | n.n -> semver:n.n 7 | 1.2.3 -> 1.2 1.2 8 | n.n.n -> semver:n.n.n 9 | 1.2.3 -> 1.2.3 1.2.3 10 | n.n.n-pre+build -> semver:n.n.n-pre+build 11 | 1.2.3-aaa+bbb:v -> 1.2.3-aaa+bbb:v 1.2.3-aaa+bbb 12 | semver:vn.n.n.n -> semver:vn.n.n.n 13 | 1.2.3:v -> v1.2.3.n:v v1.2.3.n 14 | 15 | ^3 -> semver:^3 16 | 3.12,3.9.1 -> 3.12,3.9.1 3.12 17 | 3.9.1,3.12 -> 3.12,3.9.1 3.12 18 | 3.12,3.12.0 -> 3.12.0,3.12 3.12.0 19 | 3.12.0,3.12 -> 3.12.0 3.12.0 20 | 21 | # TODO: constraintStr = "*" ? hmm 22 | 23 | n -> error:no filter matches 24 | n. -> error:no filter matches 25 | 26 | semver -> error:no filter matches 27 | semver: -> error:needs a constraint or version pattern argument 28 | 29 | n.n.n -> semver:n.n.n 30 | 4.5.6,1.2.3 -> 4.5.6,1.2.3 4.5.6 31 | 1.2.3,4.5.6 -> 1.2.3,4.5.6 1.2.3 32 | semver:n -> semver:n 33 | 4.5.6,1.2.3 -> 4,1 4 34 | 1.2.3,4.5.6 -> 1,4 1 35 | 36 | # ignore leading zeros 37 | * -> semver:* 38 | 1.2.3,03.04.05 -> 03.04.05,1.2.3 03.04.05 39 | 22,1001 -> 1001,22 1001 40 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/sort: -------------------------------------------------------------------------------- 1 | sort -> sort 2 | 1,2,3 -> 3,2,1 3 3 | sort: -> sort 4 | sort:a -> error:arg should be empty 5 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/static: -------------------------------------------------------------------------------- 1 | static:1,2,3 -> static:1,2,3 2 | -> 1:name=1,2:name=2,3:name=3 1 3 | static:1:value=a,2:value=b,3:value=c -> static:1:value=a,2:value=b,3:value=c 4 | -> 1:name=1:value=a,2:name=2:value=b,3:name=3:value=c 1 5 | static:1,2:value=b -> static:1,2:value=b 6 | -> 1,2:name=2:value=b 1 7 | -------------------------------------------------------------------------------- /internal/pipeline/testdata/svn: -------------------------------------------------------------------------------- 1 | svn:https://a -> svn:https://a 2 | svn:https://svn.apache.org/repos/asf/subversion|semver:0.8.0 -> svn:https://svn.apache.org/repos/asf/subversion|semver:0.8.0 3 | -> 0.8.0:version=849186,0.7.0:version=849186,0.6.0:version=849186 0.8.0 4 | 5 | # svn with login 6 | svn:https://anonymous:@svn.xvid.org|re:/^release-(.*)$/|re:/_/./|semver:0.9.2 -> svn:https://anonymous:@svn.xvid.org|re:/^release-(.*)$/|re:/_/./|semver:0.9.2 7 | -> 0.9.2:version=1104,0.9.1:version=846,0.9.0:version=844 0.9.2 8 | -------------------------------------------------------------------------------- /internal/rereplacer/rereplacer.go: -------------------------------------------------------------------------------- 1 | // Package rereplacer is similar to strings.Replacer but with regexp 2 | package rereplacer 3 | 4 | import ( 5 | "bytes" 6 | "regexp" 7 | "sort" 8 | ) 9 | 10 | // ReplaceFn is a function returning how to replace a match 11 | type ReplaceFn func(b []byte, sm []int) []byte 12 | 13 | // Replace is a one replacement 14 | type Replace struct { 15 | Re *regexp.Regexp 16 | Fn ReplaceFn 17 | } 18 | 19 | // Replacer is multi regex replacer 20 | type Replacer []Replace 21 | 22 | func min(a, b int) int { 23 | if a < b { 24 | return a 25 | } 26 | return b 27 | } 28 | 29 | func rangeOverlap(amin, amax, bmin, bmax int) bool { 30 | return amin < bmax && bmin < amax 31 | } 32 | 33 | func commonEnds(a, b []byte) (int, int) { 34 | minl := min(len(a), len(b)) 35 | var l, r int 36 | for l = 0; l < minl && a[l] == b[l]; l++ { 37 | } 38 | for r = 0; r < minl-l && r < minl-1 && a[len(a)-r-1] == b[len(b)-r-1]; r++ { 39 | } 40 | return l, r 41 | } 42 | 43 | // Replace return a new copy with replacements performed 44 | func (r Replacer) Replace(s []byte) []byte { 45 | type edit struct { 46 | prio int 47 | re *regexp.Regexp 48 | loc [2]int 49 | s []byte 50 | } 51 | var edits []edit 52 | 53 | // collect edits to be made 54 | for replaceI, replace := range r { 55 | for _, submatchIndexes := range replace.Re.FindAllSubmatchIndex(s, -1) { 56 | sm := s[submatchIndexes[0]:submatchIndexes[1]] 57 | r := replace.Fn(s, submatchIndexes) 58 | if bytes.Equal(sm, r) { 59 | // same, skip 60 | continue 61 | } 62 | 63 | leftCommon, rightCommon := commonEnds(sm, r) 64 | edits = append(edits, edit{ 65 | prio: replaceI, 66 | re: replace.Re, 67 | loc: [2]int{submatchIndexes[0] + leftCommon, submatchIndexes[1] - rightCommon}, 68 | s: r[leftCommon : len(r)-rightCommon], 69 | }) 70 | } 71 | } 72 | 73 | // sort by start edit index and prioritized by replacer index on overlap 74 | sort.Slice(edits, func(i, j int) bool { 75 | li := edits[i].loc 76 | lj := edits[j].loc 77 | if rangeOverlap(li[0], li[1], lj[0], lj[1]) { 78 | return edits[i].prio < edits[j].prio 79 | } 80 | return li[0] < lj[0] 81 | }) 82 | 83 | // build new using edits 84 | n := &bytes.Buffer{} 85 | lastIndex := 0 86 | for _, e := range edits { 87 | if e.loc[0] < lastIndex { 88 | // skip one that were not prioritized 89 | continue 90 | } 91 | n.Write(s[lastIndex:e.loc[0]]) 92 | n.Write(e.s) 93 | lastIndex = e.loc[1] 94 | } 95 | 96 | endIndex := len(s) 97 | if lastIndex != endIndex { 98 | n.Write(s[lastIndex:endIndex]) 99 | } 100 | 101 | return n.Bytes() 102 | } 103 | -------------------------------------------------------------------------------- /internal/rereplacer/rereplacer_test.go: -------------------------------------------------------------------------------- 1 | package rereplacer_test 2 | 3 | import ( 4 | "bytes" 5 | "regexp" 6 | "testing" 7 | 8 | "github.com/wader/bump/internal/rereplacer" 9 | ) 10 | 11 | func TestReplace(t *testing.T) { 12 | testCases := []struct { 13 | s []byte 14 | replacer []rereplacer.Replace 15 | expected []byte 16 | }{ 17 | { 18 | s: []byte(`abc`), 19 | replacer: []rereplacer.Replace{ 20 | {Fn: func(b []byte, sm []int) []byte { return b[sm[0]:sm[1]] }, Re: regexp.MustCompile(`.*`)}, 21 | }, 22 | expected: []byte(`abc`), 23 | }, 24 | { 25 | s: []byte(`abc`), 26 | replacer: []rereplacer.Replace{ 27 | {Fn: func(b []byte, sm []int) []byte { return b[sm[2]:sm[3]] }, Re: regexp.MustCompile(`.*(b).*`)}, 28 | }, 29 | expected: []byte(`b`), 30 | }, 31 | { 32 | s: []byte(`abcde`), 33 | replacer: []rereplacer.Replace{ 34 | {Fn: func(b []byte, sm []int) []byte { return b[sm[2]:sm[5]] }, Re: regexp.MustCompile(`a(b)c(d)e`)}, 35 | }, 36 | expected: []byte(`bcd`), 37 | }, 38 | { 39 | s: []byte(`abc`), 40 | replacer: []rereplacer.Replace{ 41 | {Fn: func(b []byte, sm []int) []byte { return []byte("1a1") }, Re: regexp.MustCompile(`a`)}, 42 | {Fn: func(b []byte, sm []int) []byte { return []byte("2b2") }, Re: regexp.MustCompile(`b`)}, 43 | {Fn: func(b []byte, sm []int) []byte { return []byte("3c3") }, Re: regexp.MustCompile(`c`)}, 44 | }, 45 | expected: []byte(`1a12b23c3`), 46 | }, 47 | { 48 | s: []byte(`abc`), 49 | replacer: []rereplacer.Replace{ 50 | {Fn: func(b []byte, sm []int) []byte { return []byte("1bc") }, Re: regexp.MustCompile(`abc`)}, 51 | {Fn: func(b []byte, sm []int) []byte { return []byte("a2c") }, Re: regexp.MustCompile(`abc`)}, 52 | {Fn: func(b []byte, sm []int) []byte { return []byte("ab3") }, Re: regexp.MustCompile(`abc`)}, 53 | }, 54 | expected: []byte(`123`), 55 | }, 56 | { 57 | s: []byte(`aabbcc`), 58 | replacer: []rereplacer.Replace{ 59 | {Fn: func(b []byte, sm []int) []byte { return []byte("aabb33") }, Re: regexp.MustCompile(`aabbcc`)}, 60 | {Fn: func(b []byte, sm []int) []byte { return []byte("aa22cc") }, Re: regexp.MustCompile(`aabbcc`)}, 61 | {Fn: func(b []byte, sm []int) []byte { return []byte("11bbcc") }, Re: regexp.MustCompile(`aabbcc`)}, 62 | }, 63 | expected: []byte(`112233`), 64 | }, 65 | { 66 | s: []byte(`aabaa`), 67 | replacer: []rereplacer.Replace{ 68 | {Fn: func(b []byte, sm []int) []byte { return []byte("aba") }, Re: regexp.MustCompile(`aabaa`)}, 69 | }, 70 | expected: []byte(`aba`), 71 | }, 72 | { 73 | s: []byte(`abc`), 74 | replacer: []rereplacer.Replace{ 75 | {Fn: func(b []byte, sm []int) []byte { return []byte("1") }, Re: regexp.MustCompile(`ab`)}, 76 | {Fn: func(b []byte, sm []int) []byte { return []byte("2") }, Re: regexp.MustCompile(`bc`)}, 77 | }, 78 | expected: []byte(`1c`), 79 | }, 80 | { 81 | s: []byte(`abc`), 82 | replacer: []rereplacer.Replace{ 83 | {Fn: func(b []byte, sm []int) []byte { return []byte("2") }, Re: regexp.MustCompile(`bc`)}, 84 | {Fn: func(b []byte, sm []int) []byte { return []byte("1") }, Re: regexp.MustCompile(`ab`)}, 85 | }, 86 | expected: []byte(`a2`), 87 | }, 88 | { 89 | s: []byte(`aabbcc`), 90 | replacer: []rereplacer.Replace{ 91 | {Fn: func(b []byte, sm []int) []byte { return []byte("aab333") }, Re: regexp.MustCompile(`aabbcc`)}, 92 | {Fn: func(b []byte, sm []int) []byte { return []byte("aa22cc") }, Re: regexp.MustCompile(`aabbcc`)}, 93 | {Fn: func(b []byte, sm []int) []byte { return []byte("111bcc") }, Re: regexp.MustCompile(`aabbcc`)}, 94 | }, 95 | expected: []byte(`aab333`), 96 | }, 97 | } 98 | for _, tC := range testCases { 99 | t.Run(string(tC.s)+" -> "+string(tC.expected), func(t *testing.T) { 100 | r := rereplacer.Replacer(tC.replacer) 101 | actual := r.Replace(tC.s) 102 | if !bytes.Equal(tC.expected, actual) { 103 | t.Errorf("expected %q got %q", string(tC.expected), string(actual)) 104 | } 105 | }) 106 | } 107 | } 108 | -------------------------------------------------------------------------------- /internal/slicex/slicex.go: -------------------------------------------------------------------------------- 1 | // slicex is package with generic slice functions 2 | package slicex 3 | 4 | func Map[F, T any](s []F, fn func(F) T) []T { 5 | ts := make([]T, len(s)) 6 | for i, e := range s { 7 | ts[i] = fn(e) 8 | } 9 | return ts 10 | } 11 | 12 | func Unique[T comparable](s []T) []T { 13 | seen := map[T]struct{}{} 14 | var us []T 15 | for _, e := range s { 16 | if _, ok := seen[e]; ok { 17 | continue 18 | } 19 | seen[e] = struct{}{} 20 | us = append(us, e) 21 | } 22 | return us 23 | } 24 | --------------------------------------------------------------------------------