├── .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
57 | Messages []CheckMessage
58 | // bump: link
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: //
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: command ...
495 | check.CommandShells = append(check.CommandShells, CheckShell{
496 | Cmd: rest,
497 | File: file,
498 | LineNr: lineNr,
499 | })
500 | case "after":
501 | // bump: after ...
502 | check.AfterShells = append(check.AfterShells, CheckShell{
503 | Cmd: rest,
504 | File: file,
505 | LineNr: lineNr,
506 | })
507 | case "message":
508 | // bump: message ...
509 | check.Messages = append(check.Messages, CheckMessage{
510 | Message: rest,
511 | File: file,
512 | LineNr: lineNr,
513 | })
514 | case "link":
515 | // bump: link
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: |
35 | gitrefs:
36 | depsdev::
37 | docker:
38 | svn:
39 | fetch: | |
40 | semver: | semver: | |
41 | re:// | re:/// | // | ///
42 | sort
43 | key: | @
44 | static:,...
45 | err:
46 |
--------------------------------------------------------------------------------
/internal/cli/testdata/help_filter:
--------------------------------------------------------------------------------
1 | $ bump help static
2 | >stdout:
3 | Syntax:
4 | static:,...
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::
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::")
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:
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:
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:, or
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: or
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/ -> 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:
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: or @
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: or @")
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://, re:///, // or ///
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.)(?P.)/
49 | static:ab|re:/(?P.)(?P.)/|@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:, semver:, or
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:,...
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 |
17 |
18 | /p/lame/svn/tags/
19 | ...
20 |
21 |
22 | /p/lame/svn/tags/RELEASE__3_100/
23 | ...
24 |
25 |
26 | 6403
27 | ...
28 |
29 | HTTP/1.1 200 OK
30 |
31 |
32 |
33 | */
34 |
35 | // Name of filter
36 | const Name = "svn"
37 |
38 | // Help text
39 | var Help = `
40 | svn:
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 ", "--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|/(GitHub)/ -> fetch:https://www.github.com|re:/(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: or @
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.)(?P.)/ -> re:/(?P.)(?P.)/
29 | ab,cd -> a:name=a:value=b,c:name=c:value=d a
30 |
31 | # non-matching submatch
32 | /(?Pb)(?Pa)?./ -> re:/(?Pb)(?Pa)?./
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..).*/ -> key:commit|re:/^(?P..).*/
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 |
--------------------------------------------------------------------------------