diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..9226af6 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,22 @@ +on: [push, pull_request] +name: Test +jobs: + test: + strategy: + matrix: + go-version: [1.23.x, 1.24.x] + runs-on: ubuntu-latest + steps: + - name: Install Go + uses: actions/setup-go@master + with: + go-version: ${{ matrix.go-version }} + - uses: actions/checkout@master + with: + path: './src/github.com/github-release/github-release' + # staticcheck needs this for GOPATH + - run: echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV + - run: echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" >> $GITHUB_ENV + - name: Run tests + run: make lint test + working-directory: './src/github.com/github-release/github-release' diff --git a/.gitignore b/.gitignore index a266021..d0e4a2f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,26 +1,7 @@ -github-release -go-app -bin/ - -# Compiled Object files, Static and Dynamic libs (Shared Objects) -*.o -*.a -*.so - -# Folders -_obj -_test - -# Architecture specific extensions/prefixes -*.[568vq] -[568vq].out - -*.cgo1.go -*.cgo2.c -_cgo_defun.c -_cgo_gotypes.go -_cgo_export.* - -_testmain.go +/github-release +/go-app *.exe + +/bin +/var/cache diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..09a1317 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,10 @@ +## Releasing new versions + +1) Bump the version in github/version.go + +2) Add a commit with message "github-release v1.2.3" + +3) Run `git tag v1.2.3` where "1.2.3" stands in for the version you actually +want. + +4) Run `make release`. Be sure to have `GITHUB_TOKEN` set in your environment. diff --git a/LICENSE b/LICENSE index 12ed35a..08004f2 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,7 @@ The MIT License (MIT) -Copyright (c) 2014 Nicolas Hillegeer +Copyright (c) 2014-2017 Nicolas Hillegeer +Copyright (c) 2020 Meter, Inc. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/Makefile b/Makefile index 28dd9f2..87f6565 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,8 @@ -LAST_TAG := $(shell git describe --abbrev=0 --tags) +SHELL=/bin/bash -o pipefail -USER := aktau +LAST_TAG ?= $(shell git describe --abbrev=0 --tags) + +USER := github-release EXECUTABLE := github-release # only include the amd64 binaries, otherwise the github release will become @@ -12,7 +14,7 @@ UNIX_EXECUTABLES := \ WIN_EXECUTABLES := \ windows/amd64/$(EXECUTABLE).exe -COMPRESSED_EXECUTABLES=$(UNIX_EXECUTABLES:%=%.tar.bz2) $(WIN_EXECUTABLES:%.exe=%.zip) +COMPRESSED_EXECUTABLES=$(UNIX_EXECUTABLES:%=%.bz2) $(WIN_EXECUTABLES:%.exe=%.zip) COMPRESSED_EXECUTABLE_TARGETS=$(COMPRESSED_EXECUTABLES:%=bin/%) UPLOAD_CMD = bin/tmp/$(EXECUTABLE) upload -u $(USER) -r $(EXECUTABLE) -t $(LAST_TAG) -n $(subst /,-,$(FILE)) -f bin/$(FILE) @@ -21,7 +23,7 @@ all: $(EXECUTABLE) # the executable used to perform the upload, dogfooding and all... bin/tmp/$(EXECUTABLE): - go build -o "$@" + go build -v -o "$@" # arm bin/linux/arm/5/$(EXECUTABLE): @@ -49,25 +51,31 @@ bin/windows/amd64/$(EXECUTABLE).exe: # compressed artifacts, makes a huge difference (Go executable is ~9MB, # after compressing ~2MB) -%.tar.bz2: % - tar -jcvf "$<.tar.bz2" "$<" +%.bz2: % + bzip2 --keep "$<" %.zip: %.exe zip "$@" "$<" # git tag -a v$(RELEASE) -m 'release $(RELEASE)' -release: bin/tmp/$(EXECUTABLE) $(COMPRESSED_EXECUTABLE_TARGETS) - git push && git push --tags - bin/tmp/$(EXECUTABLE) release -u $(USER) -r $(EXECUTABLE) \ - -t $(LAST_TAG) -n $(LAST_TAG) || true - $(foreach FILE,$(COMPRESSED_EXECUTABLES),$(UPLOAD_CMD);) - -# install and/or update all dependencies, run this from the project directory -# go get -u ./... -# go test -i ./ -dep: - go list -f '{{join .Deps "\n"}}' | xargs go list -e -f '{{if not .Standard}}{{.ImportPath}}{{end}}' | xargs go get -u - -$(EXECUTABLE): dep +release: clean +ifndef GITHUB_TOKEN + @echo "Please set GITHUB_TOKEN in the environment to perform a release" + exit 1 +endif + docker run --rm --volume $(PWD)/var/cache:/root/.cache/go-build \ + --env GITHUB_TOKEN=$(GITHUB_TOKEN) \ + --env DEBUG_HTTP_TRAFFIC=true \ + --volume "$(PWD)":/app \ + --workdir /app \ + golang:latest \ + ./release \ + "$(MAKE) bin/tmp/$(EXECUTABLE) $(COMPRESSED_EXECUTABLE_TARGETS) && \ + git log --format=%B $(LAST_TAG) -1 | \ + bin/tmp/$(EXECUTABLE) release -u $(USER) -r $(EXECUTABLE) \ + -t $(LAST_TAG) -n $(LAST_TAG) -d - || true && \ + $(foreach FILE,$(COMPRESSED_EXECUTABLES),$(UPLOAD_CMD);)" + +$(EXECUTABLE): go build -o "$@" install: @@ -78,4 +86,10 @@ clean: rm $(EXECUTABLE) || true rm -rf bin/ -.PHONY: clean release dep install +lint: + go vet ./... + +test: + go test ./... + +.PHONY: clean release install diff --git a/README.md b/README.md index 048e66e..a9eba6a 100644 --- a/README.md +++ b/README.md @@ -5,12 +5,14 @@ A small commandline app written in Go that allows you to easily create and delete releases of your projects on Github. In addition it allows you to attach files to those releases. -It interacts with the [github releases API](http://developer.github.com/v3/repos/releases). -Though it's entirely possibly to [do all these things with -cURL](https://github.com/blog/1645-releases-api-preview), It's not -really that user-friendly. For example, you need to first query the API -to find the id of the release you want, before you can upload an -artifact. `github-release` takes care of those little details. +It interacts with the [github releases API][releases-api]. Though it's entirely +possible to [do all these things with cURL][curl], it's not really that +user-friendly. For example, you need to first query the API to find the id of +the release you want, before you can upload an artifact. `github-release` takes +care of those little details. + +[curl]: https://github.com/blog/1645-releases-api-preview +[releases-api]: https://developer.github.com/v3/repos/releases It might still be a bit rough around the edges, pull requests are welcome! @@ -21,13 +23,13 @@ How to install If you don't have the Go toolset installed, and you don't want to, but still want to use the app, you can download binaries for your platform on the [releases -page](https://github.com/aktau/github-release/releases/latest). Yes, that's +page](https://github.com/github-release/github-release/releases/latest). Yes, that's dogfooding, check the makefile! If you have Go installed, you can just do: ```sh -go get github.com/aktau/github-release +go install github.com/github-release/github-release ``` This will automatically download, compile and install the app. @@ -38,11 +40,11 @@ After that you should have a `github-release` executable in your How to use ========== -**NOTE**: for these examples I've [created a github -token](https://help.github.com/articles/creating-an-access-token-for-command-line-use) -and set it as the env variable `GITHUB_TOKEN`. `github-release` will -automatically pick it up from the environment so that you don't have to -pass it as an argument. +**NOTE**: for these examples I've [created a github token][token] and set it as +the env variable `GITHUB_TOKEN`. `github-release` will automatically pick it up +from the environment so that you don't have to pass it as an argument. + +[token]: https://help.github.com/articles/creating-an-access-token-for-command-line-use ```sh # set your token @@ -99,6 +101,13 @@ $ github-release delete \ --tag v0.1.0 ``` +Errata +====== + +The `release` command does not have an `--auth-user` flag because in practice, +Github ignores the `--auth-user` flag when validating releases. The only thing +that matters is passing a token that has permission to create the release. + GitHub Enterprise Support ========================= You can point to a different GitHub API endpoint via the environment variable ```GITHUB_API```: @@ -110,10 +119,12 @@ export GITHUB_API=http://github.company.com/api/v3 Used libraries ============== -| Package | Description | License | -| ------------------------------------------------------------------------ | ------------------- | ------- | -| [github.com/dustin/go-humanize](https://github.com/dustin/go-humanize) | humanize file sizes | MIT | -| [github.com/voxelbrain/goptions](https://github.com/voxelbrain/goptions) | option parsing | BSD | +| Package | Description | License | +| ------------------------------------------------------------------------ | ------------------- | ------- | +| [github.com/dustin/go-humanize](https://github.com/dustin/go-humanize) | humanize file sizes | MIT | +| [github.com/tomnomnom/linkheader](https://github.com/tomnomnom/linkheader) | GH API pagination | MIT | +| [github.com/voxelbrain/goptions](https://github.com/voxelbrain/goptions) | option parsing | BSD | +| [github.com/kevinburke/rest](https://github.com/kevinburke/rest) | HTTP client | MIT | Todo ==== @@ -123,4 +134,5 @@ Todo Copyright ========= -Copyright (c) 2014, Nicolas Hillegeer. All rights reserved. +Copyright (c) 2014-2017, Nicolas Hillegeer. All rights reserved. +Copyright (c) 2020, Meter, Inc. All rights reserved. diff --git a/api.go b/api.go deleted file mode 100644 index c91aee6..0000000 --- a/api.go +++ /dev/null @@ -1,135 +0,0 @@ -package main - -import ( - "bytes" - "encoding/json" - "errors" - "fmt" - "io" - "net/http" - "os" -) - -const ( - API_URL = "https://api.github.com" - GH_URL = "https://github.com" -) - -// materializeFile takes a physical file or stream (named pipe, user input, -// ...) and returns an io.Reader and the number of bytes that can be read -// from it. -func materializeFile(f *os.File) (io.Reader, int64, error) { - fi, err := f.Stat() - if err != nil { - return nil, 0, err - } - - // If the file is actually a char device (like user typed input) - // or a named pipe (like a streamed in file), buffer it up. - // - // When uploading a file, you need to either explicitly set the - // Content-Length header or send a chunked request. Since the - // github upload server doesn't accept chunked encoding, we have - // to set the size of the file manually. Since a stream doesn't have a - // predefined length, it's read entirely into a byte buffer. - if fi.Mode()&(os.ModeCharDevice|os.ModeNamedPipe) == 1 { - vprintln("input was a stream, buffering up") - - var buf bytes.Buffer - n, err := buf.ReadFrom(f) - if err != nil { - return nil, 0, errors.New("req: could not buffer up input stream: " + err.Error()) - } - return &buf, n, err - } - - // We know the os.File is most likely an actual file now. - n, err := GetFileSize(f) - return f, n, err -} - -/* create a new request that sends the auth token */ -func NewAuthRequest(method, url, bodyType, token string, headers map[string]string, body io.Reader) (*http.Request, error) { - vprintln("creating request:", method, url, bodyType, token) - - var n int64 // content length - var err error - if f, ok := body.(*os.File); ok { - // Retrieve the content-length and buffer up if necessary. - body, n, err = materializeFile(f) - if err != nil { - return nil, err - } - } - - req, err := http.NewRequest(method, url, body) - if err != nil { - return nil, err - } - - if n != 0 { - vprintln("setting content-length to", n) - req.ContentLength = n - } - - if bodyType != "" { - req.Header.Set("Content-Type", bodyType) - } - req.Header.Set("Authorization", fmt.Sprintf("token %s", token)) - - for k, v := range headers { - req.Header.Set(k, v) - } - - return req, nil -} - -func DoAuthRequest(method, url, bodyType, token string, headers map[string]string, body io.Reader) (*http.Response, error) { - req, err := NewAuthRequest(method, url, bodyType, token, headers, body) - if err != nil { - return nil, err - } - - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - - return resp, nil -} - -func GithubGet(uri string, v interface{}) error { - resp, err := http.Get(ApiURL() + uri) - if resp != nil { - defer resp.Body.Close() - } - if err != nil { - return fmt.Errorf("could not fetch releases, %v", err) - } - - vprintln("GET", ApiURL()+uri, "->", resp) - - if resp.StatusCode != http.StatusOK { - return fmt.Errorf("github did not response with 200 OK but with %v", resp.Status) - } - - var r io.Reader = resp.Body - if VERBOSITY > 0 { - vprintln("BODY:") - r = io.TeeReader(resp.Body, os.Stdout) - } - - if err = json.NewDecoder(r).Decode(v); err != nil { - return fmt.Errorf("could not unmarshall JSON into Release struct, %v", err) - } - - return nil -} - -func ApiURL() string { - if "" == EnvApiEndpoint { - return API_URL - } else { - return EnvApiEndpoint - } -} diff --git a/assets.go b/assets.go index 921bd36..f29c064 100644 --- a/assets.go +++ b/assets.go @@ -1,11 +1,21 @@ package main import ( + "fmt" + "net/http" "time" + + "github.com/github-release/github-release/github" ) const ( - ASSET_DOWNLOAD_URI = "/repos/%s/%s/releases/assets/%d" + // GET /repos/:owner/:repo/releases/assets/:id + // DELETE /repos/:owner/:repo/releases/assets/:id + ASSET_URI = "/repos/%s/%s/releases/assets/%d" + + // API: https://developer.github.com/v3/repos/releases/#list-assets-for-a-release + // GET /repos/:owner/:repo/releases/:id/assets + ASSET_RELEASE_LIST_URI = "/repos/%s/%s/releases/%d/assets" ) type Asset struct { @@ -19,3 +29,30 @@ type Asset struct { Created time.Time `json:"created_at"` Published time.Time `json:"published_at"` } + +// findAsset returns the asset if an asset with name can be found in assets, +// otherwise returns nil. +func findAsset(assets []Asset, name string) *Asset { + for _, asset := range assets { + if asset.Name == name { + return &asset + } + } + return nil +} + +// Delete sends a HTTP DELETE request for the given asset to Github. Returns +// nil if the asset was deleted OR there was nothing to delete. +func (a *Asset) Delete(user, repo, token string) error { + URL := nvls(EnvApiEndpoint, github.DefaultBaseURL) + + fmt.Sprintf(ASSET_URI, user, repo, a.Id) + resp, err := github.DoAuthRequest("DELETE", URL, "application/json", token, nil, nil) + if err != nil { + return fmt.Errorf("failed to delete asset %s (ID: %d), HTTP error: %b", a.Name, a.Id, err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusNoContent { + return fmt.Errorf("failed to delete asset %s (ID: %d), status: %s", a.Name, a.Id, resp.Status) + } + return nil +} diff --git a/cmd.go b/cmd.go index 7663b4d..506ab28 100644 --- a/cmd.go +++ b/cmd.go @@ -10,10 +10,13 @@ import ( "net/url" "os" "strconv" + + "github.com/github-release/github-release/github" ) func infocmd(opt Options) error { user := nvls(opt.Info.User, EnvUser) + authUser := nvls(opt.Info.AuthUser, EnvAuthUser) repo := nvls(opt.Info.Repo, EnvRepo) token := nvls(opt.Info.Token, EnvToken) tag := opt.Info.Tag @@ -22,70 +25,82 @@ func infocmd(opt Options) error { return fmt.Errorf("user and repo need to be passed as arguments") } - /* find regular git tags */ - allTags, err := Tags(user, repo, token) + // Find regular git tags. + foundTags, err := Tags(user, repo, authUser, token) if err != nil { return fmt.Errorf("could not fetch tags, %v", err) } - if len(allTags) == 0 { + if len(foundTags) == 0 { return fmt.Errorf("no tags available for %v/%v", user, repo) } - /* list all tags */ - tags := make([]Tag, 0, len(allTags)) - for _, t := range allTags { - /* if the user only requested see one tag, skip the ones that - * don't match */ - if tag != "" && t.Name != tag { - continue + tags := foundTags[:0] + for _, t := range foundTags { + // If the user only requested one tag, filter out the rest. + if tag == "" || t.Name == tag { + tags = append(tags, t) } - tags = append(tags, t) } - /* if no tags conformed to the users' request, exit */ - if len(tags) == 0 { - return fmt.Errorf("no tag '%v' was found for %v/%v", tag, user, repo) - } + renderer := renderInfoText - fmt.Println("git tags:") - for _, t := range tags { - fmt.Println("-", t.String()) + if opt.Info.JSON { + renderer = renderInfoJSON } - /* list releases + assets */ + // List releases + assets. var releases []Release if tag == "" { - /* get all releases */ + // Get all releases. vprintf("%v/%v: getting information for all releases\n", user, repo) - releases, err = Releases(user, repo, token) + releases, err = Releases(user, repo, authUser, token) if err != nil { return err } } else { - /* get only one release */ + // Get only one release. vprintf("%v/%v/%v: getting information for the release\n", user, repo, tag) - release, err := ReleaseOfTag(user, repo, tag, token) + release, err := ReleaseOfTag(user, repo, tag, authUser, token) if err != nil { return err } releases = []Release{*release} } - /* if no tags conformed to the users' request, exit */ - if len(releases) == 0 { - return fmt.Errorf("no release(s) were found for %v/%v (%v)", user, repo, tag) + return renderer(tags, releases) +} + +func renderInfoText(tags []Tag, releases []Release) error { + fmt.Println("tags:") + for _, tag := range tags { + fmt.Println("-", &tag) } fmt.Println("releases:") for _, release := range releases { - fmt.Println("-", release.String()) + fmt.Println("-", &release) } return nil } +func renderInfoJSON(tags []Tag, releases []Release) error { + out := struct { + Tags []Tag + Releases []Release + }{ + Tags: tags, + Releases: releases, + } + + enc := json.NewEncoder(os.Stdout) + enc.SetIndent("", " ") + return enc.Encode(&out) +} + func uploadcmd(opt Options) error { user := nvls(opt.Upload.User, EnvUser) + authUser := nvls(opt.Upload.AuthUser, EnvAuthUser) repo := nvls(opt.Upload.Repo, EnvRepo) token := nvls(opt.Upload.Token, EnvToken) tag := opt.Upload.Tag @@ -104,12 +119,41 @@ func uploadcmd(opt Options) error { return err } - /* find the release corresponding to the entered tag, if any */ - rel, err := ReleaseOfTag(user, repo, tag, token) + // Find the release corresponding to the entered tag, if any. + rel, err := ReleaseOfTag(user, repo, tag, authUser, token) if err != nil { return err } + // If the user has attempted to upload this asset before, someone could + // expect it to be present in the release struct (rel.Assets). However, + // we have to separately ask for the specific assets of this release. + // Reason: the assets in the Release struct do not contain incomplete + // uploads (which regrettably happen often using the Github API). See + // issue #26. + var assets []Asset + client := github.NewClient(authUser, token, nil) + client.SetBaseURL(EnvApiEndpoint) + err = client.Get(fmt.Sprintf(ASSET_RELEASE_LIST_URI, user, repo, rel.Id), &assets) + if err != nil { + return err + } + + // Incomplete (failed) uploads will have their state set to new. These + // assets are (AFAIK) useless in all cases. The only thing they will do + // is prevent the upload of another asset of the same name. To work + // around this GH API weirdness, let's just delete assets if: + // + // 1. Their state is new. + // 2. The user explicitly asked to delete/replace the asset with -R. + if asset := findAsset(assets, name); asset != nil && + (asset.State == "new" || opt.Upload.Replace) { + vprintf("asset (id: %d) already existed in state %s: removing...\n", asset.Id, asset.Name) + if err := asset.Delete(user, repo, token); err != nil { + return fmt.Errorf("could not replace asset: %v", err) + } + } + v := url.Values{} v.Set("name", name) if label != "" { @@ -118,31 +162,45 @@ func uploadcmd(opt Options) error { url := rel.CleanUploadUrl() + "?" + v.Encode() - resp, err := DoAuthRequest("POST", url, "application/octet-stream", + resp, err := github.DoAuthRequest("POST", url, "application/octet-stream", token, nil, file) - if resp != nil { - defer resp.Body.Close() - } if err != nil { return fmt.Errorf("can't create upload request to %v, %v", url, err) } - + defer resp.Body.Close() vprintln("RESPONSE:", resp) - if resp.StatusCode != http.StatusCreated { - if msg, err := ToMessage(resp.Body); err == nil { - return fmt.Errorf("could not upload, status code (%v), %v", + + var r io.Reader = resp.Body + if VERBOSITY != 0 { + r = io.TeeReader(r, os.Stderr) + } + var asset *Asset + // For HTTP status 201 and 502, Github will return a JSON encoding of + // the (partially) created asset. + if resp.StatusCode == http.StatusBadGateway || resp.StatusCode == http.StatusCreated { + vprintf("ASSET: ") + asset = new(Asset) + if err := json.NewDecoder(r).Decode(&asset); err != nil { + return fmt.Errorf("upload failed (%s), could not unmarshal asset (err: %v)", resp.Status, err) + } + } else { + vprintf("BODY: ") + if msg, err := ToMessage(r); err == nil { + return fmt.Errorf("could not upload, status code (%s), %v", resp.Status, msg) - } else { - return fmt.Errorf("could not upload, status code (%v)", resp.Status) } + return fmt.Errorf("could not upload, status code (%s)", resp.Status) } - if VERBOSITY != 0 { - body, err := ioutil.ReadAll(resp.Body) - if err != nil { - return fmt.Errorf("error while reading response, %v", err) + if resp.StatusCode == http.StatusBadGateway { + // 502 means the upload failed, but GitHub still retains metadata + // (an asset in state "new"). Attempt to delete that now since it + // would clutter the list of release assets. + vprintf("asset (id: %d) failed to upload, it's now in state %s: removing...\n", asset.Id, asset.Name) + if err := asset.Delete(user, repo, token); err != nil { + return fmt.Errorf("upload failed (%s), could not delete partially uploaded asset (ID: %d, err: %v) in order to cleanly reset GH API state, please try again", resp.Status, asset.Id, err) } - vprintln("BODY:", string(body)) + return fmt.Errorf("could not upload, status code (%s)", resp.Status) } return nil @@ -150,6 +208,7 @@ func uploadcmd(opt Options) error { func downloadcmd(opt Options) error { user := nvls(opt.Download.User, EnvUser) + authUser := nvls(opt.Download.AuthUser, EnvAuthUser) repo := nvls(opt.Download.Repo, EnvRepo) token := nvls(opt.Download.Token, EnvToken) tag := opt.Download.Tag @@ -166,44 +225,35 @@ func downloadcmd(opt Options) error { var rel *Release var err error if latest { - rel, err = LatestRelease(user, repo, token) + rel, err = LatestRelease(user, repo, authUser, token) } else { - rel, err = ReleaseOfTag(user, repo, tag, token) + rel, err = ReleaseOfTag(user, repo, tag, authUser, token) } if err != nil { return err } - assetId := 0 - for _, asset := range rel.Assets { - if asset.Name == name { - assetId = asset.Id - } - } - - if assetId == 0 { + asset := findAsset(rel.Assets, name) + if asset == nil { return fmt.Errorf("coud not find asset named %s", name) } var resp *http.Response - var url string if token == "" { - url = GH_URL + fmt.Sprintf("/%s/%s/releases/download/%s/%s", user, repo, tag, name) - resp, err = http.Get(url) + // Use the regular github.com site if we don't have a token. + resp, err = http.Get("https://github.com" + fmt.Sprintf("/%s/%s/releases/download/%s/%s", user, repo, tag, name)) } else { - url = ApiURL() + fmt.Sprintf(ASSET_DOWNLOAD_URI, user, repo, assetId) - resp, err = DoAuthRequest("GET", url, "", token, map[string]string{ + url := nvls(EnvApiEndpoint, github.DefaultBaseURL) + fmt.Sprintf(ASSET_URI, user, repo, asset.Id) + resp, err = github.DoAuthRequest("GET", url, "", token, map[string]string{ "Accept": "application/octet-stream", }, nil) } - if resp != nil { - defer resp.Body.Close() - } if err != nil { return fmt.Errorf("could not fetch releases, %v", err) } + defer resp.Body.Close() - vprintln("GET", url, "->", resp) + vprintln("GET", resp.Request.URL, "->", resp) contentLength, err := strconv.ParseInt(resp.Header.Get("Content-Length"), 10, 64) if err != nil { @@ -214,15 +264,26 @@ func downloadcmd(opt Options) error { return fmt.Errorf("github did not respond with 200 OK but with %v", resp.Status) } - out, err := os.Create(name) - if err != nil { - return fmt.Errorf("could not create file %s", name) + out := os.Stdout // Pipe the asset to stdout by default. + if isCharDevice(out) { + // If stdout is a char device, assume it's a TTY (terminal). In this + // case, don't pipe th easset to stdout, but create it as a file in + // the current working folder. + if out, err = os.Create(name); err != nil { + return fmt.Errorf("could not create file %s", name) + } + defer out.Close() } - defer out.Close() - n, err := io.Copy(out, resp.Body) - if n != contentLength { - return fmt.Errorf("downloaded data did not match content length %d != %d", contentLength, n) + return mustCopyN(out, resp.Body, contentLength) +} + +// mustCopyN attempts to copy exactly N bytes, if this fails, an error is +// returned. +func mustCopyN(w io.Writer, r io.Reader, n int64) error { + an, err := io.Copy(w, r) + if an != n { + return fmt.Errorf("data did not match content length %d != %d", an, n) } return err } @@ -256,11 +317,16 @@ func releasecmd(opt Options) error { repo := nvls(cmdopt.Repo, EnvRepo) token := nvls(cmdopt.Token, EnvToken) tag := cmdopt.Tag - name := nvls(cmdopt.Name, tag) - desc := nvls(cmdopt.Desc, tag) + name := cmdopt.Name + desc := cmdopt.Desc target := nvls(cmdopt.Target) draft := cmdopt.Draft prerelease := cmdopt.Prerelease + generateReleaseNotes := cmdopt.GenerateReleaseNotes + if !generateReleaseNotes { + name = nvls(name, tag) + desc = nvls(desc, tag) + } vprintln("releasing...") @@ -268,13 +334,23 @@ func releasecmd(opt Options) error { return err } + // Check if we need to read the description from stdin. + if desc == "-" { + b, err := ioutil.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("could not read description from stdin: %v", err) + } + desc = string(b) + } + params := ReleaseCreate{ - TagName: tag, - TargetCommitish: target, - Name: name, - Body: desc, - Draft: draft, - Prerelease: prerelease, + TagName: tag, + TargetCommitish: target, + Name: name, + Body: desc, + Draft: draft, + Prerelease: prerelease, + GenerateReleaseNotes: generateReleaseNotes, } /* encode params as json */ @@ -284,15 +360,19 @@ func releasecmd(opt Options) error { } reader := bytes.NewReader(payload) - uri := fmt.Sprintf("/repos/%s/%s/releases", user, repo) - resp, err := DoAuthRequest("POST", ApiURL()+uri, "application/json", - token, nil, reader) - if resp != nil { - defer resp.Body.Close() + // NB: Github appears to ignore the user here - the only thing that seems to + // matter is that the token is valid. + client := github.NewClient(user, token, nil) + client.SetBaseURL(EnvApiEndpoint) + req, err := client.NewRequest("POST", fmt.Sprintf("/repos/%s/%s/releases", user, repo), reader) + if err != nil { + return fmt.Errorf("while submitting %v: %w", string(payload), err) } + resp, err := client.Do(req) if err != nil { - return fmt.Errorf("while submitting %v, %v", string(payload), err) + return fmt.Errorf("while submitting %v: %w", string(payload), err) } + defer resp.Body.Close() vprintln("RESPONSE:", resp) if resp.StatusCode != http.StatusCreated { @@ -317,6 +397,7 @@ func releasecmd(opt Options) error { func editcmd(opt Options) error { cmdopt := opt.Edit user := nvls(cmdopt.User, EnvUser) + authUser := nvls(cmdopt.AuthUser, EnvAuthUser) repo := nvls(cmdopt.Repo, EnvRepo) token := nvls(cmdopt.Token, EnvToken) tag := cmdopt.Tag @@ -331,13 +412,22 @@ func editcmd(opt Options) error { return err } - id, err := IdOfTag(user, repo, tag, token) + id, err := IdOfTag(user, repo, tag, authUser, token) if err != nil { return err } vprintf("release %v has id %v\n", tag, id) + // Check if we need to read the description from stdin. + if desc == "-" { + b, err := ioutil.ReadAll(os.Stdin) + if err != nil { + return fmt.Errorf("could not read description from stdin: %v", err) + } + desc = string(b) + } + /* the release create struct works for editing releases as well */ params := ReleaseCreate{ TagName: tag, @@ -353,15 +443,12 @@ func editcmd(opt Options) error { return fmt.Errorf("can't encode release creation params, %v", err) } - uri := fmt.Sprintf("/repos/%s/%s/releases/%d", user, repo, id) - resp, err := DoAuthRequest("PATCH", ApiURL()+uri, "application/json", - token, nil, bytes.NewReader(payload)) - if resp != nil { - defer resp.Body.Close() - } + URL := nvls(EnvApiEndpoint, github.DefaultBaseURL) + fmt.Sprintf("/repos/%s/%s/releases/%d", user, repo, id) + resp, err := github.DoAuthRequest("PATCH", URL, "application/json", token, nil, bytes.NewReader(payload)) if err != nil { return fmt.Errorf("while submitting %v, %v", string(payload), err) } + defer resp.Body.Close() vprintln("RESPONSE:", resp) if resp.StatusCode != http.StatusOK { @@ -388,23 +475,23 @@ func deletecmd(opt Options) error { nvls(opt.Delete.Repo, EnvRepo), nvls(opt.Delete.Token, EnvToken), opt.Delete.Tag + authUser := nvls(opt.Delete.AuthUser, EnvAuthUser) vprintln("deleting...") - id, err := IdOfTag(user, repo, tag, token) + id, err := IdOfTag(user, repo, tag, authUser, token) if err != nil { return err } vprintf("release %v has id %v\n", tag, id) - resp, err := httpDelete(ApiURL()+fmt.Sprintf("/repos/%s/%s/releases/%d", - user, repo, id), token) - if resp != nil { - defer resp.Body.Close() - } + baseURL := nvls(EnvApiEndpoint, github.DefaultBaseURL) + resp, err := github.DoAuthRequest("DELETE", baseURL+fmt.Sprintf("/repos/%s/%s/releases/%d", + user, repo, id), "application/json", token, nil, nil) if err != nil { - return fmt.Errorf("release deletion unsuccesful, %v", err) + return fmt.Errorf("release deletion failed: %v", err) } + defer resp.Body.Close() if resp.StatusCode != http.StatusNoContent { return fmt.Errorf("could not delete the release corresponding to tag %s on repo %s/%s", @@ -413,12 +500,3 @@ func deletecmd(opt Options) error { return nil } - -func httpDelete(url, token string) (*http.Response, error) { - resp, err := DoAuthRequest("DELETE", url, "application/json", token, nil, nil) - if err != nil { - return nil, err - } - - return resp, nil -} diff --git a/file.go b/file.go deleted file mode 100644 index 7a2f7f5..0000000 --- a/file.go +++ /dev/null @@ -1,43 +0,0 @@ -package main - -import ( - "fmt" - "os" -) - -func GetFileSize(f *os.File) (int64, error) { - /* first try stat */ - off, err := fsizeStat(f) - if err != nil { - /* if that fails, try seek */ - return fsizeSeek(f) - } - - return off, nil -} - -func fsizeStat(f *os.File) (int64, error) { - fi, err := f.Stat() - - if err != nil { - return 0, err - } - - return fi.Size(), nil -} - -func fsizeSeek(f *os.File) (int64, error) { - off, err := f.Seek(0, 2) - if err != nil { - return 0, fmt.Errorf("seeking did not work, stdin is not" + - "supported yet because github doesn't support chunking" + - "requests (and I haven't implemented detecting stdin and" + - "buffering yet") - } - - _, err = f.Seek(0, 0) - if err != nil { - return 0, fmt.Errorf("could not seek back in the file") - } - return off, nil -} diff --git a/github-release.go b/github-release.go index 0e46bd1..5303cb8 100644 --- a/github-release.go +++ b/github-release.go @@ -4,9 +4,12 @@ import ( "fmt" "os" + "github.com/github-release/github-release/github" "github.com/voxelbrain/goptions" ) +const GH_URL = "https://github.com" + type Options struct { Help goptions.Help `goptions:"-h, --help, description='Show this help'"` Verbosity []bool `goptions:"-v, --verbose, description='Be verbose'"` @@ -15,54 +18,62 @@ type Options struct { goptions.Verbs Download struct { - Token string `goptions:"-s, --security-token, description='Github token ($GITHUB_TOKEN if set). required if repo is private.'"` - User string `goptions:"-u, --user, description='Github user (required if $GITHUB_USER not set)'"` - Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` - Latest bool `goptions:"-l, --latest, description='Download latest release (required if tag is not specified)',mutexgroup='input'"` - Tag string `goptions:"-t, --tag, description='Git tag to download from (required if latest is not specified)', mutexgroup='input',obligatory"` - Name string `goptions:"-n, --name, description='Name of the file', obligatory"` + Token string `goptions:"-s, --security-token, description='Github token ($GITHUB_TOKEN if set). required if repo is private.'"` + User string `goptions:"-u, --user, description='Github repo user or organisation (required if $GITHUB_USER not set)'"` + AuthUser string `goptions:"-a, --auth-user, description='Username for authenticating to the API (falls back to $GITHUB_AUTH_USER or $GITHUB_USER)'"` + Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` + Latest bool `goptions:"-l, --latest, description='Download latest release (required if tag is not specified)',mutexgroup='input'"` + Tag string `goptions:"-t, --tag, description='Git tag to download from (required if latest is not specified)', mutexgroup='input',obligatory"` + Name string `goptions:"-n, --name, description='Name of the file', obligatory"` } `goptions:"download"` Upload struct { - Token string `goptions:"-s, --security-token, description='Github token (required if $GITHUB_TOKEN not set)'"` - User string `goptions:"-u, --user, description='Github user (required if $GITHUB_USER not set)'"` - Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` - Tag string `goptions:"-t, --tag, description='Git tag to upload to', obligatory"` - Name string `goptions:"-n, --name, description='Name of the file', obligatory"` - Label string `goptions:"-l, --label, description='Label (description) of the file'"` - File *os.File `goptions:"-f, --file, description='File to upload (use - for stdin)', rdonly, obligatory"` + Token string `goptions:"-s, --security-token, description='Github token (required if $GITHUB_TOKEN not set)'"` + User string `goptions:"-u, --user, description='Github repo user or organisation (required if $GITHUB_USER not set)'"` + AuthUser string `goptions:"-a, --auth-user, description='Username for authenticating to the API (falls back to $GITHUB_AUTH_USER or $GITHUB_USER)'"` + Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` + Tag string `goptions:"-t, --tag, description='Git tag to upload to', obligatory"` + Name string `goptions:"-n, --name, description='Name of the file', obligatory"` + Label string `goptions:"-l, --label, description='Label (description) of the file'"` + File *os.File `goptions:"-f, --file, description='File to upload (use - for stdin)', rdonly, obligatory"` + Replace bool `goptions:"-R, --replace, description='Replace asset with same name if it already exists (WARNING: not atomic, failure to upload will remove the original asset too)'"` } `goptions:"upload"` Release struct { - Token string `goptions:"-s, --security-token, description='Github token (required if $GITHUB_TOKEN not set)'"` - User string `goptions:"-u, --user, description='Github user (required if $GITHUB_USER not set)'"` - Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` - Tag string `goptions:"-t, --tag, obligatory, description='Git tag to create a release from'"` - Name string `goptions:"-n, --name, description='Name of the release (defaults to tag)'"` - Desc string `goptions:"-d, --description, description='Description of the release (defaults to tag)'"` - Target string `goptions:"-c, --target, description='Commit SHA or branch to create release of (defaults to the repository default branch)'"` - Draft bool `goptions:"--draft, description='The release is a draft'"` - Prerelease bool `goptions:"-p, --pre-release, description='The release is a pre-release'"` + Token string `goptions:"-s, --security-token, description='Github token (required if $GITHUB_TOKEN not set)'"` + User string `goptions:"-u, --user, description='Github repo user or organisation (required if $GITHUB_USER not set)'"` + Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` + Tag string `goptions:"-t, --tag, obligatory, description='Git tag to create a release from'"` + Name string `goptions:"-n, --name, description='Name of the release (defaults to tag)'"` + Desc string `goptions:"-d, --description, description='Release description, use - for reading a description from stdin (defaults to tag)'"` + Target string `goptions:"-c, --target, description='Commit SHA or branch to create release of (defaults to the repository default branch)'"` + Draft bool `goptions:"--draft, description='The release is a draft'"` + Prerelease bool `goptions:"-p, --pre-release, description='The release is a pre-release'"` + GenerateReleaseNotes bool `goptions:"-g, --generate-release-notes, description='Generate name and description if not given'"` } `goptions:"release"` Edit struct { Token string `goptions:"-s, --security-token, description='Github token (required if $GITHUB_TOKEN not set)'"` - User string `goptions:"-u, --user, description='Github user (required if $GITHUB_USER not set)'"` + User string `goptions:"-u, --user, description='Github repo user or organisation (required if $GITHUB_USER not set)'"` + AuthUser string `goptions:"-a, --auth-user, description='Username for authenticating to the API (falls back to $GITHUB_AUTH_USER or $GITHUB_USER)'"` Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` Tag string `goptions:"-t, --tag, obligatory, description='Git tag to edit the release of'"` Name string `goptions:"-n, --name, description='New name of the release (defaults to tag)'"` - Desc string `goptions:"-d, --description, description='New description of the release (defaults to tag)'"` + Desc string `goptions:"-d, --description, description='New release description, use - for reading a description from stdin (defaults to tag)'"` Draft bool `goptions:"--draft, description='The release is a draft'"` Prerelease bool `goptions:"-p, --pre-release, description='The release is a pre-release'"` } `goptions:"edit"` Delete struct { - Token string `goptions:"-s, --security-token, description='Github token (required if $GITHUB_TOKEN not set)'"` - User string `goptions:"-u, --user, description='Github user (required if $GITHUB_USER not set)'"` - Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` - Tag string `goptions:"-t, --tag, obligatory, description='Git tag of release to delete'"` + Token string `goptions:"-s, --security-token, description='Github token (required if $GITHUB_TOKEN not set)'"` + User string `goptions:"-u, --user, description='Github repo user or organisation (required if $GITHUB_USER not set)'"` + AuthUser string `goptions:"-a, --auth-user, description='Username for authenticating to the API (falls back to $GITHUB_AUTH_USER or $GITHUB_USER)'"` + Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` + Tag string `goptions:"-t, --tag, obligatory, description='Git tag of release to delete'"` } `goptions:"delete"` Info struct { - Token string `goptions:"-s, --security-token, description='Github token ($GITHUB_TOKEN if set). required if repo is private.'"` - User string `goptions:"-u, --user, description='Github user (required if $GITHUB_USER not set)'"` - Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` - Tag string `goptions:"-t, --tag, description='Git tag to query (optional)'"` + Token string `goptions:"-s, --security-token, description='Github token ($GITHUB_TOKEN if set). required if repo is private.'"` + User string `goptions:"-u, --user, description='Github repo user or organisation (required if $GITHUB_USER not set)'"` + AuthUser string `goptions:"-a, --auth-user, description='Username for authenticating to the API (falls back to $GITHUB_AUTH_USER or $GITHUB_USER)'"` + Repo string `goptions:"-r, --repo, description='Github repo (required if $GITHUB_REPO not set)'"` + Tag string `goptions:"-t, --tag, description='Git tag to query (optional)'"` + JSON bool `goptions:"-j, --json, description='Emit info as JSON instead of text'"` } `goptions:"info"` } @@ -82,6 +93,9 @@ var ( ) var ( + // The user whose token is being used to authenticate to the API. If unset, + // EnvUser is used. + EnvAuthUser string EnvToken string EnvUser string EnvRepo string @@ -91,8 +105,13 @@ var ( func init() { EnvToken = os.Getenv("GITHUB_TOKEN") EnvUser = os.Getenv("GITHUB_USER") + EnvAuthUser = os.Getenv("GITHUB_AUTH_USER") EnvRepo = os.Getenv("GITHUB_REPO") EnvApiEndpoint = os.Getenv("GITHUB_API") + + if EnvAuthUser == "" { + EnvAuthUser = EnvUser + } } func main() { @@ -101,7 +120,7 @@ func main() { goptions.ParseAndFail(&options) if options.Version { - fmt.Printf("github-release v%s\n", VERSION) + fmt.Printf("github-release v%s\n", github.VERSION) return } @@ -111,6 +130,7 @@ func main() { } VERBOSITY = len(options.Verbosity) + github.VERBOSITY = VERBOSITY if cmd, found := commands[options.Verbs]; found { err := cmd(options) diff --git a/github/debug.go b/github/debug.go new file mode 100644 index 0000000..f139607 --- /dev/null +++ b/github/debug.go @@ -0,0 +1,22 @@ +package github + +import ( + "fmt" + "os" +) + +func vprintln(a ...interface{}) (int, error) { + if VERBOSITY > 0 { + return fmt.Fprintln(os.Stderr, a...) + } + + return 0, nil +} + +func vprintf(format string, a ...interface{}) (int, error) { + if VERBOSITY > 0 { + return fmt.Fprintf(os.Stderr, format, a...) + } + + return 0, nil +} diff --git a/github/file.go b/github/file.go new file mode 100644 index 0000000..8f503b6 --- /dev/null +++ b/github/file.go @@ -0,0 +1,79 @@ +package github + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" +) + +func GetFileSize(f *os.File) (int64, error) { + /* first try stat */ + off, err := fsizeStat(f) + if err != nil { + /* if that fails, try seek */ + return fsizeSeek(f) + } + + return off, nil +} + +func fsizeStat(f *os.File) (int64, error) { + fi, err := f.Stat() + + if err != nil { + return 0, err + } + + return fi.Size(), nil +} + +func fsizeSeek(f *os.File) (int64, error) { + off, err := f.Seek(0, 2) + if err != nil { + return 0, fmt.Errorf("seeking did not work, stdin is not" + + "supported yet because github doesn't support chunking" + + "requests (and I haven't implemented detecting stdin and" + + "buffering yet") + } + + _, err = f.Seek(0, 0) + if err != nil { + return 0, fmt.Errorf("could not seek back in the file") + } + return off, nil +} + +// materializeFile takes a physical file or stream (named pipe, user input, +// ...) and returns an io.Reader and the number of bytes that can be read +// from it. +func materializeFile(f *os.File) (io.Reader, int64, error) { + fi, err := f.Stat() + if err != nil { + return nil, 0, err + } + + // If the file is actually a char device (like user typed input) + // or a named pipe (like a streamed in file), buffer it up. + // + // When uploading a file, you need to either explicitly set the + // Content-Length header or send a chunked request. Since the + // github upload server doesn't accept chunked encoding, we have + // to set the size of the file manually. Since a stream doesn't have a + // predefined length, it's read entirely into a byte buffer. + if fi.Mode()&(os.ModeCharDevice|os.ModeNamedPipe) == 1 { + vprintln("input was a stream, buffering up") + + var buf bytes.Buffer + n, err := buf.ReadFrom(f) + if err != nil { + return nil, 0, errors.New("req: could not buffer up input stream: " + err.Error()) + } + return &buf, n, err + } + + // We know the os.File is most likely an actual file now. + n, err := GetFileSize(f) + return f, n, err +} diff --git a/file_test.go b/github/file_test.go similarity index 97% rename from file_test.go rename to github/file_test.go index 89086c4..59a5be6 100644 --- a/file_test.go +++ b/github/file_test.go @@ -1,4 +1,4 @@ -package main +package github import ( "os" diff --git a/github/github.go b/github/github.go new file mode 100644 index 0000000..0b2392a --- /dev/null +++ b/github/github.go @@ -0,0 +1,351 @@ +// Package github is a mini-library for querying the GitHub v3 API that +// takes care of authentication (with tokens only) and pagination. +package github + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "os" + "reflect" + + "github.com/kevinburke/rest/restclient" + "github.com/tomnomnom/linkheader" +) + +const DefaultBaseURL = "https://api.github.com" + +// Set to values > 0 to control verbosity, for debugging. +var VERBOSITY = 0 + +// DoAuthRequest ... +// +// TODO: This function is amazingly ugly (separate headers, token, no API +// URL constructions, et cetera). +func DoAuthRequest(method, url, mime, token string, headers map[string]string, body io.Reader) (*http.Response, error) { + req, err := newAuthRequest(method, url, mime, token, headers, body) + if err != nil { + return nil, err + } + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return nil, err + } + + return resp, nil +} + +// Client collects a few options that can be set when contacting the GitHub +// API, such as authorization tokens. Methods called on Client will supply +// these options when calling the API. +type Client struct { + client *restclient.Client +} + +// NewClient creates a new Client for use with the Github API. +func NewClient(username, token string, client *restclient.Client) Client { + c := Client{} + if client == nil { + c.client = restclient.New(username, token, DefaultBaseURL) + } else { + c.client = client + } + return c +} + +// SetBaseURL updates the client's base URL, if baseurl is a non-empty value. +func (c Client) SetBaseURL(baseurl string) { + // This is lazy, because the caller always tries to override the base URL + // with EnvApiEndpoint and we used to ignore that at the top of Get, but we + // don't do that now. So instead just filter out/ignore the empty string. + if baseurl == "" { + return + } + c.client.Base = baseurl +} + +// Get fetches uri (relative URL) from the GitHub API and unmarshals the +// response into v. It takes care of pagination transparantly. +func (c Client) Get(uri string, v interface{}) error { + rc, err := c.getPaginated(uri) + if err != nil { + return err + } + defer rc.Close() + var r io.Reader = rc + if VERBOSITY > 0 { + vprintln("BODY:") + r = io.TeeReader(rc, os.Stderr) + } + + // Github may return paginated responses. If so, githubGetPaginated will + // return a reader which yields the concatenation of all pages. These + // reponses are _separate_ JSON arrays. Standard json.Unmarshal() or + // json.Decoder.Decode() will not have the expected result when + // unmarshalling into v. For example, a 2-page response: + // + // 1. [{...}, {...}, {...}] + // 2. [{...}] + // + // If v is a slice type, we'd like to decode the four objects from the + // two pages into a single slice. However, if we just use + // json.Decoder.Decode(), that won't work. v will be overridden each + // time. + // + // For this reason, we use two very ugly things. + // + // 1. We analyze v with reflect to see if it's a slice. + // 2. If so, we use the json.Decoder token API and reflection to + // dynamically add new elements into the slice, ignoring the + // boundaries between JSON arrays. + // + // This is a lot of work, and feels very stupid. An alternative would be + // removing the outermost ][ in the intermediate responses, which would + // be even more finnicky. Another alternative would be to explicitly + // expose a pagination API, forcing clients of this code to deal with + // it. That's how the go-github library does it. But why solve a problem + // sensibly if one can power through it with reflection (half-joking)? + + sl := reflect.Indirect(reflect.ValueOf(v)) // Get the reflect.Value of the slice so we can append to it. + t := sl.Type() + if t.Kind() != reflect.Slice { + // Not a slice, not going to handle special pagination JSON stream + // semantics since it likely wouldn't work properly anyway. If this + // is a non-paginated stream, it should work. + return json.NewDecoder(r).Decode(v) + } + t = t.Elem() // Extract the type of the slice's elements. + + // Use streaming Token API to append all elements of the JSON stream + // arrays (pagination) to the slice. + for dec := json.NewDecoder(r); ; { + tok, err := dec.Token() + if err != nil { + if err == io.EOF { + return nil // Natural end of the JSON stream. + } + return err + } + vprintf("TOKEN %T: %v\n", tok, tok) + // Check for tokens until we get an opening array brace. If we're + // not in an array, we can't decode an array element later, which + // would result in an error. + if tok != json.Delim('[') { + continue + } + + // Read the array, appending all elements to the slice. + for dec.More() { + it := reflect.New(t) // Interface to a valid pointer to an object of the same type as the slice elements. + if err := dec.Decode(it.Interface()); err != nil { + return err + } + vprintf("OBJECT %T: %v\n", it.Interface(), it) + sl.Set(reflect.Append(sl, it.Elem())) + } + } +} + +var defaultHttpClient *http.Client + +func init() { + defaultHttpClient = &http.Client{ + Transport: restclient.DefaultTransport, + } +} + +// Caller is responsible for reading and closing the response body. +func (c Client) Do(r *http.Request) (*http.Response, error) { + // Pulled this out of client.go:Do because we need to read the response + // headers. + var res *http.Response + var err error + if c.client.Client == nil { + res, err = defaultHttpClient.Do(r) + } else { + res, err = c.client.Client.Do(r) + } + if err != nil { + return nil, err + } + if res.StatusCode >= 400 { + // both of these consume res.Body + if c.client.ErrorParser != nil { + return nil, c.client.ErrorParser(res) + } + return nil, restclient.DefaultErrorParser(res) + } + return res, nil +} + +const uaPart = "github-release/" + VERSION + +func (c Client) NewRequest(method, uri string, body io.Reader) (*http.Request, error) { + req, err := c.client.NewRequest(method, uri, body) + if err != nil { + return nil, err + } + ua := req.Header.Get("User-Agent") + if ua == "" { + req.Header.Set("User-Agent", uaPart) + } else { + req.Header.Set("User-Agent", uaPart+" "+ua) + } + return req, nil +} + +// getPaginated returns a reader that yields the concatenation of the +// paginated responses to a query (URI). +// +// TODO: Rework the API so we can cleanly append per_page=100 as a URL +// parameter. +func (c Client) getPaginated(uri string) (io.ReadCloser, error) { + // Parse the passed-in URI to make sure we don't lose any values when + // setting our own params. + u, err := url.Parse(uri) + if err != nil { + return nil, err + } + + v := u.Query() + v.Set("per_page", "100") // The default is 30, this makes it less likely for Github to rate-limit us. + u.RawQuery = v.Encode() + req, err := c.NewRequest("GET", u.String(), nil) + if err != nil { + return nil, err + } + resp, err := c.Do(req) + if err != nil { + return nil, err + } + vprintln("GET (top-level)", resp.Request.URL, "->", resp) + + // If the HTTP response is paginated, it will contain a Link header. + links := linkheader.Parse(resp.Header.Get("Link")) + if len(links) == 0 { + return resp.Body, nil // No pagination. + } + + // In this case, fetch all pages and concatenate them. + r, w := io.Pipe() + done := make(chan struct{}) // Backpressure from the pipe writer. + responses := make(chan *http.Response, 5) // Allow 5 concurrent HTTP requests. + responses <- resp + + // URL fetcher goroutine. Fetches paginated responses until no more + // pages can be found. Closes the write end of the pipe if fetching a + // page fails. + go func() { + defer close(responses) // Signal that no more requests are coming. + for len(links) > 0 { + nextLinkURL := nextLink(links) + if nextLinkURL == "" { + return // We're done. + } + + req, err := c.NewRequest("GET", nextLinkURL, nil) + if err != nil { + w.CloseWithError(err) + return + } + resp, err := c.Do(req) + if err != nil { + w.CloseWithError(err) + return + } + links = linkheader.Parse(resp.Header.Get("Link")) + if err != nil { + w.CloseWithError(err) + return + } + select { + case <-done: + return // The body concatenator goroutine signals it has stopped. + case responses <- resp: // Schedule the request body to be written to the pipe. + } + } + }() + + // Body concatenator goroutine. Writes each response into the pipe + // sequentially. Closes the write end of the pipe if the HTTP status is + // not 200 or the body can't be read. + go func() { + defer func() { + // Drain channel and close bodies, stop leaks. + for resp := range responses { + resp.Body.Close() + } + }() + defer close(done) // Signal that we're done writing all requests, or an error occurred. + for resp := range responses { + if resp.StatusCode != http.StatusOK { + resp.Body.Close() + w.CloseWithError(fmt.Errorf("expected '200 OK' but received '%v' (%s to %s)", resp.Status, resp.Request.Method, resp.Request.URL.Path)) + return + } + _, err := io.Copy(w, resp.Body) + resp.Body.Close() + if err != nil { + vprintln("error: io.Copy: ", err) + w.CloseWithError(err) + return + } + } + w.Close() + }() + + return r, nil +} + +// Create a new request that sends the auth token. +func newAuthRequest(method, url, mime, token string, headers map[string]string, body io.Reader) (*http.Request, error) { + vprintln("creating request:", method, url, mime, token) + + var n int64 // content length + var err error + if f, ok := body.(*os.File); ok { + // Retrieve the content-length and buffer up if necessary. + body, n, err = materializeFile(f) + if err != nil { + return nil, err + } + } + + // TODO find all of the usages and replace with the Client. + req, err := http.NewRequest(method, url, body) + if err != nil { + return nil, err + } + req.SetBasicAuth("", token) + req.Header.Set("User-Agent", uaPart) + + // net/http automatically does this if req.Body is of type + // (bytes.Reader|bytes.Buffer|strings.Reader). Sadly, we also need to + // handle *os.File. + if n != 0 { + vprintln("setting content-length to", n) + req.ContentLength = n + } + + if mime != "" { + req.Header.Set("Content-Type", mime) + } + + for k, v := range headers { + req.Header.Set(k, v) + } + return req, nil +} + +// nextLink returns the HTTP header Link annotated with 'next', "" otherwise. +func nextLink(links linkheader.Links) string { + for _, link := range links { + if link.Rel == "next" && link.URL != "" { + return link.URL + } + } + return "" +} diff --git a/github/version.go b/github/version.go new file mode 100644 index 0000000..7c86c5e --- /dev/null +++ b/github/version.go @@ -0,0 +1,3 @@ +package github + +const VERSION = "0.11.0" diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..27d2c9a --- /dev/null +++ b/go.mod @@ -0,0 +1,10 @@ +module github.com/github-release/github-release + +go 1.23.0 + +require ( + github.com/dustin/go-humanize v1.0.1 + github.com/kevinburke/rest v0.0.0-20250718180114-1a15e4f2364f + github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 + github.com/voxelbrain/goptions v0.0.0-20180630082107-58cddc247ea2 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..874fb47 --- /dev/null +++ b/go.sum @@ -0,0 +1,8 @@ +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/kevinburke/rest v0.0.0-20250718180114-1a15e4f2364f h1:y+inhBsY0ewgXFXCXlxodNxkXdYeU9YuneCYQEnRkmw= +github.com/kevinburke/rest v0.0.0-20250718180114-1a15e4f2364f/go.mod h1:3cBF15uOiTj025Ll5QHLw317EB+e06+AEwyt7oHUubI= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 h1:nrZ3ySNYwJbSpD6ce9duiP+QkD3JuLCcWkdaehUS/3Y= +github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80/go.mod h1:iFyPdL66DjUD96XmzVL3ZntbzcflLnznH0fr99w5VqE= +github.com/voxelbrain/goptions v0.0.0-20180630082107-58cddc247ea2 h1:txplJASvd6b/hrE0s/Ixfpp2cuwH9IO9oZBAN9iYa4A= +github.com/voxelbrain/goptions v0.0.0-20180630082107-58cddc247ea2/go.mod h1:DGCIhurYgnLz8J9ga1fMV/fbLDyUvTyrWXVWUIyJon4= diff --git a/release b/release new file mode 100755 index 0000000..4a1f30e --- /dev/null +++ b/release @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -euo pipefail + +main() { + apt-get update && apt-get install -y zip bzip2 + git branch --set-upstream-to=origin/master release + set -x + exec /bin/bash -c "$@" +} + +main "$@" diff --git a/releases.go b/releases.go index ed50ede..05177a3 100644 --- a/releases.go +++ b/releases.go @@ -5,12 +5,13 @@ import ( "strings" "time" + "github.com/github-release/github-release/github" "github.com/dustin/go-humanize" ) const ( - RELEASE_LIST_URI = "/repos/%s/%s/releases%s" - RELEASE_LATEST_URI = "/repos/%s/%s/releases/latest%s" + RELEASE_LIST_URI = "/repos/%s/%s/releases" + RELEASE_LATEST_URI = "/repos/%s/%s/releases/latest" RELEASE_DATE_FORMAT = "02/01/2006 at 15:04" ) @@ -58,20 +59,20 @@ func (r *Release) String() string { } type ReleaseCreate struct { - TagName string `json:"tag_name"` - TargetCommitish string `json:"target_commitish,omitempty"` - Name string `json:"name"` - Body string `json:"body"` - Draft bool `json:"draft"` - Prerelease bool `json:"prerelease"` + TagName string `json:"tag_name"` + TargetCommitish string `json:"target_commitish,omitempty"` + Name string `json:"name"` + Body string `json:"body"` + Draft bool `json:"draft"` + Prerelease bool `json:"prerelease"` + GenerateReleaseNotes bool `json:"generate_release_notes"` } -func Releases(user, repo, token string) ([]Release, error) { - if token != "" { - token = "?access_token=" + token - } +func Releases(user, repo, authUser, token string) ([]Release, error) { var releases []Release - err := GithubGet(fmt.Sprintf(RELEASE_LIST_URI, user, repo, token), &releases) + client := github.NewClient(authUser, token, nil) + client.SetBaseURL(EnvApiEndpoint) + err := client.Get(fmt.Sprintf(RELEASE_LIST_URI, user, repo), &releases) if err != nil { return nil, err } @@ -79,23 +80,22 @@ func Releases(user, repo, token string) ([]Release, error) { return releases, nil } -func latestReleaseApi(user, repo, token string) (*Release, error) { - if token != "" { - token = "?access_token=" + token - } +func latestReleaseApi(user, repo, authUser, token string) (*Release, error) { var release Release - return &release, GithubGet(fmt.Sprintf(RELEASE_LATEST_URI, user, repo, token), &release) + client := github.NewClient(authUser, token, nil) + client.SetBaseURL(EnvApiEndpoint) + return &release, client.Get(fmt.Sprintf(RELEASE_LATEST_URI, user, repo), &release) } -func LatestRelease(user, repo, token string) (*Release, error) { +func LatestRelease(user, repo, authUser, token string) (*Release, error) { // If latestReleaseApi DOESN'T give an error, return the release. - if latestRelease, err := latestReleaseApi(user, repo, token); err == nil { + if latestRelease, err := latestReleaseApi(user, repo, authUser, token); err == nil { return latestRelease, nil } // The enterprise api doesnt support the latest release endpoint. Get // all releases and compare the published date to get the latest. - releases, err := Releases(user, repo, token) + releases, err := Releases(user, repo, authUser, token) if err != nil { return nil, err } @@ -116,8 +116,8 @@ func LatestRelease(user, repo, token string) (*Release, error) { return &releases[latestRelIndex], nil } -func ReleaseOfTag(user, repo, tag, token string) (*Release, error) { - releases, err := Releases(user, repo, token) +func ReleaseOfTag(user, repo, tag, authUser, token string) (*Release, error) { + releases, err := Releases(user, repo, authUser, token) if err != nil { return nil, err } @@ -132,8 +132,8 @@ func ReleaseOfTag(user, repo, tag, token string) (*Release, error) { } /* find the release-id of the specified tag */ -func IdOfTag(user, repo, tag, token string) (int, error) { - release, err := ReleaseOfTag(user, repo, tag, token) +func IdOfTag(user, repo, tag, authUser, token string) (int, error) { + release, err := ReleaseOfTag(user, repo, tag, authUser, token) if err != nil { return 0, err } diff --git a/tags.go b/tags.go index c4c1a8b..3510704 100644 --- a/tags.go +++ b/tags.go @@ -2,10 +2,12 @@ package main import ( "fmt" + + "github.com/github-release/github-release/github" ) const ( - TAGS_URI = "/repos/%s/%s/tags%s" + TAGS_URI = "/repos/%s/%s/tags" ) type Tag struct { @@ -19,18 +21,10 @@ func (t *Tag) String() string { return t.Name + " (commit: " + t.Commit.Url + ")" } -/* get the tags associated with a repo */ -func Tags(user, repo, token string) ([]Tag, error) { +// Get the tags associated with a repo. +func Tags(user, repo, authUser, token string) ([]Tag, error) { var tags []Tag - - if token != "" { - token = "?access_token=" + token - } - - err := GithubGet(fmt.Sprintf(TAGS_URI, user, repo, token), &tags) - if err != nil { - return nil, err - } - - return tags, nil + client := github.NewClient(authUser, token, nil) + client.SetBaseURL(EnvApiEndpoint) + return tags, client.Get(fmt.Sprintf(TAGS_URI, user, repo), &tags) } diff --git a/util.go b/util.go index ea6d0a1..ac2a756 100644 --- a/util.go +++ b/util.go @@ -2,10 +2,11 @@ package main import ( "fmt" + "os" "time" ) -/* oracle nvl, return first non-empty string */ +/* nvls returns the first value in xs that is not empty. */ func nvls(xs ...string) string { for _, s := range xs { if s != "" { @@ -18,7 +19,7 @@ func nvls(xs ...string) string { func vprintln(a ...interface{}) (int, error) { if VERBOSITY > 0 { - return fmt.Println(a...) + return fmt.Fprintln(os.Stderr, a...) } return 0, nil @@ -26,7 +27,7 @@ func vprintln(a ...interface{}) (int, error) { func vprintf(format string, a ...interface{}) (int, error) { if VERBOSITY > 0 { - return fmt.Printf(format, a...) + return fmt.Fprintf(os.Stderr, format, a...) } return 0, nil @@ -39,3 +40,13 @@ func timeFmtOr(t *time.Time, fmt, def string) string { } return t.Format(fmt) } + +// isCharDevice returns true if f is a character device (panics if f can't +// be stat'ed). +func isCharDevice(f *os.File) bool { + stat, err := f.Stat() + if err != nil { + panic(err) + } + return (stat.Mode() & os.ModeCharDevice) != 0 +} diff --git a/vendor/github.com/dustin/go-humanize/.travis.yml b/vendor/github.com/dustin/go-humanize/.travis.yml new file mode 100644 index 0000000..ac12e48 --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/.travis.yml @@ -0,0 +1,21 @@ +sudo: false +language: go +go_import_path: github.com/dustin/go-humanize +go: + - 1.13.x + - 1.14.x + - 1.15.x + - 1.16.x + - stable + - master +matrix: + allow_failures: + - go: master + fast_finish: true +install: + - # Do nothing. This is needed to prevent default install action "go get -t -v ./..." from happening here (we want it to happen inside script step). +script: + - diff -u <(echo -n) <(gofmt -d -s .) + - go vet . + - go install -v -race ./... + - go test -v -race ./... diff --git a/vendor/github.com/dustin/go-humanize/LICENSE b/vendor/github.com/dustin/go-humanize/LICENSE new file mode 100644 index 0000000..8d9a94a --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/LICENSE @@ -0,0 +1,21 @@ +Copyright (c) 2005-2008 Dustin Sallings + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. + + diff --git a/vendor/github.com/dustin/go-humanize/README.markdown b/vendor/github.com/dustin/go-humanize/README.markdown new file mode 100644 index 0000000..7d0b16b --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/README.markdown @@ -0,0 +1,124 @@ +# Humane Units [![Build Status](https://travis-ci.org/dustin/go-humanize.svg?branch=master)](https://travis-ci.org/dustin/go-humanize) [![GoDoc](https://godoc.org/github.com/dustin/go-humanize?status.svg)](https://godoc.org/github.com/dustin/go-humanize) + +Just a few functions for helping humanize times and sizes. + +`go get` it as `github.com/dustin/go-humanize`, import it as +`"github.com/dustin/go-humanize"`, use it as `humanize`. + +See [godoc](https://pkg.go.dev/github.com/dustin/go-humanize) for +complete documentation. + +## Sizes + +This lets you take numbers like `82854982` and convert them to useful +strings like, `83 MB` or `79 MiB` (whichever you prefer). + +Example: + +```go +fmt.Printf("That file is %s.", humanize.Bytes(82854982)) // That file is 83 MB. +``` + +## Times + +This lets you take a `time.Time` and spit it out in relative terms. +For example, `12 seconds ago` or `3 days from now`. + +Example: + +```go +fmt.Printf("This was touched %s.", humanize.Time(someTimeInstance)) // This was touched 7 hours ago. +``` + +Thanks to Kyle Lemons for the time implementation from an IRC +conversation one day. It's pretty neat. + +## Ordinals + +From a [mailing list discussion][odisc] where a user wanted to be able +to label ordinals. + + 0 -> 0th + 1 -> 1st + 2 -> 2nd + 3 -> 3rd + 4 -> 4th + [...] + +Example: + +```go +fmt.Printf("You're my %s best friend.", humanize.Ordinal(193)) // You are my 193rd best friend. +``` + +## Commas + +Want to shove commas into numbers? Be my guest. + + 0 -> 0 + 100 -> 100 + 1000 -> 1,000 + 1000000000 -> 1,000,000,000 + -100000 -> -100,000 + +Example: + +```go +fmt.Printf("You owe $%s.\n", humanize.Comma(6582491)) // You owe $6,582,491. +``` + +## Ftoa + +Nicer float64 formatter that removes trailing zeros. + +```go +fmt.Printf("%f", 2.24) // 2.240000 +fmt.Printf("%s", humanize.Ftoa(2.24)) // 2.24 +fmt.Printf("%f", 2.0) // 2.000000 +fmt.Printf("%s", humanize.Ftoa(2.0)) // 2 +``` + +## SI notation + +Format numbers with [SI notation][sinotation]. + +Example: + +```go +humanize.SI(0.00000000223, "M") // 2.23 nM +``` + +## English-specific functions + +The following functions are in the `humanize/english` subpackage. + +### Plurals + +Simple English pluralization + +```go +english.PluralWord(1, "object", "") // object +english.PluralWord(42, "object", "") // objects +english.PluralWord(2, "bus", "") // buses +english.PluralWord(99, "locus", "loci") // loci + +english.Plural(1, "object", "") // 1 object +english.Plural(42, "object", "") // 42 objects +english.Plural(2, "bus", "") // 2 buses +english.Plural(99, "locus", "loci") // 99 loci +``` + +### Word series + +Format comma-separated words lists with conjuctions: + +```go +english.WordSeries([]string{"foo"}, "and") // foo +english.WordSeries([]string{"foo", "bar"}, "and") // foo and bar +english.WordSeries([]string{"foo", "bar", "baz"}, "and") // foo, bar and baz + +english.OxfordWordSeries([]string{"foo", "bar", "baz"}, "and") // foo, bar, and baz +``` + +[odisc]: https://groups.google.com/d/topic/golang-nuts/l8NhI74jl-4/discussion +[sinotation]: http://en.wikipedia.org/wiki/Metric_prefix diff --git a/vendor/github.com/dustin/go-humanize/big.go b/vendor/github.com/dustin/go-humanize/big.go new file mode 100644 index 0000000..f49dc33 --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/big.go @@ -0,0 +1,31 @@ +package humanize + +import ( + "math/big" +) + +// order of magnitude (to a max order) +func oomm(n, b *big.Int, maxmag int) (float64, int) { + mag := 0 + m := &big.Int{} + for n.Cmp(b) >= 0 { + n.DivMod(n, b, m) + mag++ + if mag == maxmag && maxmag >= 0 { + break + } + } + return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag +} + +// total order of magnitude +// (same as above, but with no upper limit) +func oom(n, b *big.Int) (float64, int) { + mag := 0 + m := &big.Int{} + for n.Cmp(b) >= 0 { + n.DivMod(n, b, m) + mag++ + } + return float64(n.Int64()) + (float64(m.Int64()) / float64(b.Int64())), mag +} diff --git a/vendor/github.com/dustin/go-humanize/bigbytes.go b/vendor/github.com/dustin/go-humanize/bigbytes.go new file mode 100644 index 0000000..3b015fd --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/bigbytes.go @@ -0,0 +1,189 @@ +package humanize + +import ( + "fmt" + "math/big" + "strings" + "unicode" +) + +var ( + bigIECExp = big.NewInt(1024) + + // BigByte is one byte in bit.Ints + BigByte = big.NewInt(1) + // BigKiByte is 1,024 bytes in bit.Ints + BigKiByte = (&big.Int{}).Mul(BigByte, bigIECExp) + // BigMiByte is 1,024 k bytes in bit.Ints + BigMiByte = (&big.Int{}).Mul(BigKiByte, bigIECExp) + // BigGiByte is 1,024 m bytes in bit.Ints + BigGiByte = (&big.Int{}).Mul(BigMiByte, bigIECExp) + // BigTiByte is 1,024 g bytes in bit.Ints + BigTiByte = (&big.Int{}).Mul(BigGiByte, bigIECExp) + // BigPiByte is 1,024 t bytes in bit.Ints + BigPiByte = (&big.Int{}).Mul(BigTiByte, bigIECExp) + // BigEiByte is 1,024 p bytes in bit.Ints + BigEiByte = (&big.Int{}).Mul(BigPiByte, bigIECExp) + // BigZiByte is 1,024 e bytes in bit.Ints + BigZiByte = (&big.Int{}).Mul(BigEiByte, bigIECExp) + // BigYiByte is 1,024 z bytes in bit.Ints + BigYiByte = (&big.Int{}).Mul(BigZiByte, bigIECExp) + // BigRiByte is 1,024 y bytes in bit.Ints + BigRiByte = (&big.Int{}).Mul(BigYiByte, bigIECExp) + // BigQiByte is 1,024 r bytes in bit.Ints + BigQiByte = (&big.Int{}).Mul(BigRiByte, bigIECExp) +) + +var ( + bigSIExp = big.NewInt(1000) + + // BigSIByte is one SI byte in big.Ints + BigSIByte = big.NewInt(1) + // BigKByte is 1,000 SI bytes in big.Ints + BigKByte = (&big.Int{}).Mul(BigSIByte, bigSIExp) + // BigMByte is 1,000 SI k bytes in big.Ints + BigMByte = (&big.Int{}).Mul(BigKByte, bigSIExp) + // BigGByte is 1,000 SI m bytes in big.Ints + BigGByte = (&big.Int{}).Mul(BigMByte, bigSIExp) + // BigTByte is 1,000 SI g bytes in big.Ints + BigTByte = (&big.Int{}).Mul(BigGByte, bigSIExp) + // BigPByte is 1,000 SI t bytes in big.Ints + BigPByte = (&big.Int{}).Mul(BigTByte, bigSIExp) + // BigEByte is 1,000 SI p bytes in big.Ints + BigEByte = (&big.Int{}).Mul(BigPByte, bigSIExp) + // BigZByte is 1,000 SI e bytes in big.Ints + BigZByte = (&big.Int{}).Mul(BigEByte, bigSIExp) + // BigYByte is 1,000 SI z bytes in big.Ints + BigYByte = (&big.Int{}).Mul(BigZByte, bigSIExp) + // BigRByte is 1,000 SI y bytes in big.Ints + BigRByte = (&big.Int{}).Mul(BigYByte, bigSIExp) + // BigQByte is 1,000 SI r bytes in big.Ints + BigQByte = (&big.Int{}).Mul(BigRByte, bigSIExp) +) + +var bigBytesSizeTable = map[string]*big.Int{ + "b": BigByte, + "kib": BigKiByte, + "kb": BigKByte, + "mib": BigMiByte, + "mb": BigMByte, + "gib": BigGiByte, + "gb": BigGByte, + "tib": BigTiByte, + "tb": BigTByte, + "pib": BigPiByte, + "pb": BigPByte, + "eib": BigEiByte, + "eb": BigEByte, + "zib": BigZiByte, + "zb": BigZByte, + "yib": BigYiByte, + "yb": BigYByte, + "rib": BigRiByte, + "rb": BigRByte, + "qib": BigQiByte, + "qb": BigQByte, + // Without suffix + "": BigByte, + "ki": BigKiByte, + "k": BigKByte, + "mi": BigMiByte, + "m": BigMByte, + "gi": BigGiByte, + "g": BigGByte, + "ti": BigTiByte, + "t": BigTByte, + "pi": BigPiByte, + "p": BigPByte, + "ei": BigEiByte, + "e": BigEByte, + "z": BigZByte, + "zi": BigZiByte, + "y": BigYByte, + "yi": BigYiByte, + "r": BigRByte, + "ri": BigRiByte, + "q": BigQByte, + "qi": BigQiByte, +} + +var ten = big.NewInt(10) + +func humanateBigBytes(s, base *big.Int, sizes []string) string { + if s.Cmp(ten) < 0 { + return fmt.Sprintf("%d B", s) + } + c := (&big.Int{}).Set(s) + val, mag := oomm(c, base, len(sizes)-1) + suffix := sizes[mag] + f := "%.0f %s" + if val < 10 { + f = "%.1f %s" + } + + return fmt.Sprintf(f, val, suffix) + +} + +// BigBytes produces a human readable representation of an SI size. +// +// See also: ParseBigBytes. +// +// BigBytes(82854982) -> 83 MB +func BigBytes(s *big.Int) string { + sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB", "RB", "QB"} + return humanateBigBytes(s, bigSIExp, sizes) +} + +// BigIBytes produces a human readable representation of an IEC size. +// +// See also: ParseBigBytes. +// +// BigIBytes(82854982) -> 79 MiB +func BigIBytes(s *big.Int) string { + sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB", "ZiB", "YiB", "RiB", "QiB"} + return humanateBigBytes(s, bigIECExp, sizes) +} + +// ParseBigBytes parses a string representation of bytes into the number +// of bytes it represents. +// +// See also: BigBytes, BigIBytes. +// +// ParseBigBytes("42 MB") -> 42000000, nil +// ParseBigBytes("42 mib") -> 44040192, nil +func ParseBigBytes(s string) (*big.Int, error) { + lastDigit := 0 + hasComma := false + for _, r := range s { + if !(unicode.IsDigit(r) || r == '.' || r == ',') { + break + } + if r == ',' { + hasComma = true + } + lastDigit++ + } + + num := s[:lastDigit] + if hasComma { + num = strings.Replace(num, ",", "", -1) + } + + val := &big.Rat{} + _, err := fmt.Sscanf(num, "%f", val) + if err != nil { + return nil, err + } + + extra := strings.ToLower(strings.TrimSpace(s[lastDigit:])) + if m, ok := bigBytesSizeTable[extra]; ok { + mv := (&big.Rat{}).SetInt(m) + val.Mul(val, mv) + rv := &big.Int{} + rv.Div(val.Num(), val.Denom()) + return rv, nil + } + + return nil, fmt.Errorf("unhandled size name: %v", extra) +} diff --git a/vendor/github.com/dustin/go-humanize/bytes.go b/vendor/github.com/dustin/go-humanize/bytes.go new file mode 100644 index 0000000..0b498f4 --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/bytes.go @@ -0,0 +1,143 @@ +package humanize + +import ( + "fmt" + "math" + "strconv" + "strings" + "unicode" +) + +// IEC Sizes. +// kibis of bits +const ( + Byte = 1 << (iota * 10) + KiByte + MiByte + GiByte + TiByte + PiByte + EiByte +) + +// SI Sizes. +const ( + IByte = 1 + KByte = IByte * 1000 + MByte = KByte * 1000 + GByte = MByte * 1000 + TByte = GByte * 1000 + PByte = TByte * 1000 + EByte = PByte * 1000 +) + +var bytesSizeTable = map[string]uint64{ + "b": Byte, + "kib": KiByte, + "kb": KByte, + "mib": MiByte, + "mb": MByte, + "gib": GiByte, + "gb": GByte, + "tib": TiByte, + "tb": TByte, + "pib": PiByte, + "pb": PByte, + "eib": EiByte, + "eb": EByte, + // Without suffix + "": Byte, + "ki": KiByte, + "k": KByte, + "mi": MiByte, + "m": MByte, + "gi": GiByte, + "g": GByte, + "ti": TiByte, + "t": TByte, + "pi": PiByte, + "p": PByte, + "ei": EiByte, + "e": EByte, +} + +func logn(n, b float64) float64 { + return math.Log(n) / math.Log(b) +} + +func humanateBytes(s uint64, base float64, sizes []string) string { + if s < 10 { + return fmt.Sprintf("%d B", s) + } + e := math.Floor(logn(float64(s), base)) + suffix := sizes[int(e)] + val := math.Floor(float64(s)/math.Pow(base, e)*10+0.5) / 10 + f := "%.0f %s" + if val < 10 { + f = "%.1f %s" + } + + return fmt.Sprintf(f, val, suffix) +} + +// Bytes produces a human readable representation of an SI size. +// +// See also: ParseBytes. +// +// Bytes(82854982) -> 83 MB +func Bytes(s uint64) string { + sizes := []string{"B", "kB", "MB", "GB", "TB", "PB", "EB"} + return humanateBytes(s, 1000, sizes) +} + +// IBytes produces a human readable representation of an IEC size. +// +// See also: ParseBytes. +// +// IBytes(82854982) -> 79 MiB +func IBytes(s uint64) string { + sizes := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB", "EiB"} + return humanateBytes(s, 1024, sizes) +} + +// ParseBytes parses a string representation of bytes into the number +// of bytes it represents. +// +// See Also: Bytes, IBytes. +// +// ParseBytes("42 MB") -> 42000000, nil +// ParseBytes("42 mib") -> 44040192, nil +func ParseBytes(s string) (uint64, error) { + lastDigit := 0 + hasComma := false + for _, r := range s { + if !(unicode.IsDigit(r) || r == '.' || r == ',') { + break + } + if r == ',' { + hasComma = true + } + lastDigit++ + } + + num := s[:lastDigit] + if hasComma { + num = strings.Replace(num, ",", "", -1) + } + + f, err := strconv.ParseFloat(num, 64) + if err != nil { + return 0, err + } + + extra := strings.ToLower(strings.TrimSpace(s[lastDigit:])) + if m, ok := bytesSizeTable[extra]; ok { + f *= float64(m) + if f >= math.MaxUint64 { + return 0, fmt.Errorf("too large: %v", s) + } + return uint64(f), nil + } + + return 0, fmt.Errorf("unhandled size name: %v", extra) +} diff --git a/vendor/github.com/dustin/go-humanize/comma.go b/vendor/github.com/dustin/go-humanize/comma.go new file mode 100644 index 0000000..520ae3e --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/comma.go @@ -0,0 +1,116 @@ +package humanize + +import ( + "bytes" + "math" + "math/big" + "strconv" + "strings" +) + +// Comma produces a string form of the given number in base 10 with +// commas after every three orders of magnitude. +// +// e.g. Comma(834142) -> 834,142 +func Comma(v int64) string { + sign := "" + + // Min int64 can't be negated to a usable value, so it has to be special cased. + if v == math.MinInt64 { + return "-9,223,372,036,854,775,808" + } + + if v < 0 { + sign = "-" + v = 0 - v + } + + parts := []string{"", "", "", "", "", "", ""} + j := len(parts) - 1 + + for v > 999 { + parts[j] = strconv.FormatInt(v%1000, 10) + switch len(parts[j]) { + case 2: + parts[j] = "0" + parts[j] + case 1: + parts[j] = "00" + parts[j] + } + v = v / 1000 + j-- + } + parts[j] = strconv.Itoa(int(v)) + return sign + strings.Join(parts[j:], ",") +} + +// Commaf produces a string form of the given number in base 10 with +// commas after every three orders of magnitude. +// +// e.g. Commaf(834142.32) -> 834,142.32 +func Commaf(v float64) string { + buf := &bytes.Buffer{} + if v < 0 { + buf.Write([]byte{'-'}) + v = 0 - v + } + + comma := []byte{','} + + parts := strings.Split(strconv.FormatFloat(v, 'f', -1, 64), ".") + pos := 0 + if len(parts[0])%3 != 0 { + pos += len(parts[0]) % 3 + buf.WriteString(parts[0][:pos]) + buf.Write(comma) + } + for ; pos < len(parts[0]); pos += 3 { + buf.WriteString(parts[0][pos : pos+3]) + buf.Write(comma) + } + buf.Truncate(buf.Len() - 1) + + if len(parts) > 1 { + buf.Write([]byte{'.'}) + buf.WriteString(parts[1]) + } + return buf.String() +} + +// CommafWithDigits works like the Commaf but limits the resulting +// string to the given number of decimal places. +// +// e.g. CommafWithDigits(834142.32, 1) -> 834,142.3 +func CommafWithDigits(f float64, decimals int) string { + return stripTrailingDigits(Commaf(f), decimals) +} + +// BigComma produces a string form of the given big.Int in base 10 +// with commas after every three orders of magnitude. +func BigComma(b *big.Int) string { + sign := "" + if b.Sign() < 0 { + sign = "-" + b.Abs(b) + } + + athousand := big.NewInt(1000) + c := (&big.Int{}).Set(b) + _, m := oom(c, athousand) + parts := make([]string, m+1) + j := len(parts) - 1 + + mod := &big.Int{} + for b.Cmp(athousand) >= 0 { + b.DivMod(b, athousand, mod) + parts[j] = strconv.FormatInt(mod.Int64(), 10) + switch len(parts[j]) { + case 2: + parts[j] = "0" + parts[j] + case 1: + parts[j] = "00" + parts[j] + } + j-- + } + parts[j] = strconv.Itoa(int(b.Int64())) + return sign + strings.Join(parts[j:], ",") +} diff --git a/vendor/github.com/dustin/go-humanize/commaf.go b/vendor/github.com/dustin/go-humanize/commaf.go new file mode 100644 index 0000000..2bc83a0 --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/commaf.go @@ -0,0 +1,41 @@ +//go:build go1.6 +// +build go1.6 + +package humanize + +import ( + "bytes" + "math/big" + "strings" +) + +// BigCommaf produces a string form of the given big.Float in base 10 +// with commas after every three orders of magnitude. +func BigCommaf(v *big.Float) string { + buf := &bytes.Buffer{} + if v.Sign() < 0 { + buf.Write([]byte{'-'}) + v.Abs(v) + } + + comma := []byte{','} + + parts := strings.Split(v.Text('f', -1), ".") + pos := 0 + if len(parts[0])%3 != 0 { + pos += len(parts[0]) % 3 + buf.WriteString(parts[0][:pos]) + buf.Write(comma) + } + for ; pos < len(parts[0]); pos += 3 { + buf.WriteString(parts[0][pos : pos+3]) + buf.Write(comma) + } + buf.Truncate(buf.Len() - 1) + + if len(parts) > 1 { + buf.Write([]byte{'.'}) + buf.WriteString(parts[1]) + } + return buf.String() +} diff --git a/vendor/github.com/dustin/go-humanize/ftoa.go b/vendor/github.com/dustin/go-humanize/ftoa.go new file mode 100644 index 0000000..bce923f --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/ftoa.go @@ -0,0 +1,49 @@ +package humanize + +import ( + "strconv" + "strings" +) + +func stripTrailingZeros(s string) string { + if !strings.ContainsRune(s, '.') { + return s + } + offset := len(s) - 1 + for offset > 0 { + if s[offset] == '.' { + offset-- + break + } + if s[offset] != '0' { + break + } + offset-- + } + return s[:offset+1] +} + +func stripTrailingDigits(s string, digits int) string { + if i := strings.Index(s, "."); i >= 0 { + if digits <= 0 { + return s[:i] + } + i++ + if i+digits >= len(s) { + return s + } + return s[:i+digits] + } + return s +} + +// Ftoa converts a float to a string with no trailing zeros. +func Ftoa(num float64) string { + return stripTrailingZeros(strconv.FormatFloat(num, 'f', 6, 64)) +} + +// FtoaWithDigits converts a float to a string but limits the resulting string +// to the given number of decimal places, and no trailing zeros. +func FtoaWithDigits(num float64, digits int) string { + return stripTrailingZeros(stripTrailingDigits(strconv.FormatFloat(num, 'f', 6, 64), digits)) +} diff --git a/vendor/github.com/dustin/go-humanize/humanize.go b/vendor/github.com/dustin/go-humanize/humanize.go new file mode 100644 index 0000000..a2c2da3 --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/humanize.go @@ -0,0 +1,8 @@ +/* +Package humanize converts boring ugly numbers to human-friendly strings and back. + +Durations can be turned into strings such as "3 days ago", numbers +representing sizes like 82854982 into useful strings like, "83 MB" or +"79 MiB" (whichever you prefer). +*/ +package humanize diff --git a/vendor/github.com/dustin/go-humanize/number.go b/vendor/github.com/dustin/go-humanize/number.go new file mode 100644 index 0000000..6470d0d --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/number.go @@ -0,0 +1,192 @@ +package humanize + +/* +Slightly adapted from the source to fit go-humanize. + +Author: https://github.com/gorhill +Source: https://gist.github.com/gorhill/5285193 + +*/ + +import ( + "math" + "strconv" +) + +var ( + renderFloatPrecisionMultipliers = [...]float64{ + 1, + 10, + 100, + 1000, + 10000, + 100000, + 1000000, + 10000000, + 100000000, + 1000000000, + } + + renderFloatPrecisionRounders = [...]float64{ + 0.5, + 0.05, + 0.005, + 0.0005, + 0.00005, + 0.000005, + 0.0000005, + 0.00000005, + 0.000000005, + 0.0000000005, + } +) + +// FormatFloat produces a formatted number as string based on the following user-specified criteria: +// * thousands separator +// * decimal separator +// * decimal precision +// +// Usage: s := RenderFloat(format, n) +// The format parameter tells how to render the number n. +// +// See examples: http://play.golang.org/p/LXc1Ddm1lJ +// +// Examples of format strings, given n = 12345.6789: +// "#,###.##" => "12,345.67" +// "#,###." => "12,345" +// "#,###" => "12345,678" +// "#\u202F###,##" => "12 345,68" +// "#.###,###### => 12.345,678900 +// "" (aka default format) => 12,345.67 +// +// The highest precision allowed is 9 digits after the decimal symbol. +// There is also a version for integer number, FormatInteger(), +// which is convenient for calls within template. +func FormatFloat(format string, n float64) string { + // Special cases: + // NaN = "NaN" + // +Inf = "+Infinity" + // -Inf = "-Infinity" + if math.IsNaN(n) { + return "NaN" + } + if n > math.MaxFloat64 { + return "Infinity" + } + if n < (0.0 - math.MaxFloat64) { + return "-Infinity" + } + + // default format + precision := 2 + decimalStr := "." + thousandStr := "," + positiveStr := "" + negativeStr := "-" + + if len(format) > 0 { + format := []rune(format) + + // If there is an explicit format directive, + // then default values are these: + precision = 9 + thousandStr = "" + + // collect indices of meaningful formatting directives + formatIndx := []int{} + for i, char := range format { + if char != '#' && char != '0' { + formatIndx = append(formatIndx, i) + } + } + + if len(formatIndx) > 0 { + // Directive at index 0: + // Must be a '+' + // Raise an error if not the case + // index: 0123456789 + // +0.000,000 + // +000,000.0 + // +0000.00 + // +0000 + if formatIndx[0] == 0 { + if format[formatIndx[0]] != '+' { + panic("RenderFloat(): invalid positive sign directive") + } + positiveStr = "+" + formatIndx = formatIndx[1:] + } + + // Two directives: + // First is thousands separator + // Raise an error if not followed by 3-digit + // 0123456789 + // 0.000,000 + // 000,000.00 + if len(formatIndx) == 2 { + if (formatIndx[1] - formatIndx[0]) != 4 { + panic("RenderFloat(): thousands separator directive must be followed by 3 digit-specifiers") + } + thousandStr = string(format[formatIndx[0]]) + formatIndx = formatIndx[1:] + } + + // One directive: + // Directive is decimal separator + // The number of digit-specifier following the separator indicates wanted precision + // 0123456789 + // 0.00 + // 000,0000 + if len(formatIndx) == 1 { + decimalStr = string(format[formatIndx[0]]) + precision = len(format) - formatIndx[0] - 1 + } + } + } + + // generate sign part + var signStr string + if n >= 0.000000001 { + signStr = positiveStr + } else if n <= -0.000000001 { + signStr = negativeStr + n = -n + } else { + signStr = "" + n = 0.0 + } + + // split number into integer and fractional parts + intf, fracf := math.Modf(n + renderFloatPrecisionRounders[precision]) + + // generate integer part string + intStr := strconv.FormatInt(int64(intf), 10) + + // add thousand separator if required + if len(thousandStr) > 0 { + for i := len(intStr); i > 3; { + i -= 3 + intStr = intStr[:i] + thousandStr + intStr[i:] + } + } + + // no fractional part, we can leave now + if precision == 0 { + return signStr + intStr + } + + // generate fractional part + fracStr := strconv.Itoa(int(fracf * renderFloatPrecisionMultipliers[precision])) + // may need padding + if len(fracStr) < precision { + fracStr = "000000000000000"[:precision-len(fracStr)] + fracStr + } + + return signStr + intStr + decimalStr + fracStr +} + +// FormatInteger produces a formatted number as string. +// See FormatFloat. +func FormatInteger(format string, n int) string { + return FormatFloat(format, float64(n)) +} diff --git a/vendor/github.com/dustin/go-humanize/ordinals.go b/vendor/github.com/dustin/go-humanize/ordinals.go new file mode 100644 index 0000000..43d88a8 --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/ordinals.go @@ -0,0 +1,25 @@ +package humanize + +import "strconv" + +// Ordinal gives you the input number in a rank/ordinal format. +// +// Ordinal(3) -> 3rd +func Ordinal(x int) string { + suffix := "th" + switch x % 10 { + case 1: + if x%100 != 11 { + suffix = "st" + } + case 2: + if x%100 != 12 { + suffix = "nd" + } + case 3: + if x%100 != 13 { + suffix = "rd" + } + } + return strconv.Itoa(x) + suffix +} diff --git a/vendor/github.com/dustin/go-humanize/si.go b/vendor/github.com/dustin/go-humanize/si.go new file mode 100644 index 0000000..8b85019 --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/si.go @@ -0,0 +1,127 @@ +package humanize + +import ( + "errors" + "math" + "regexp" + "strconv" +) + +var siPrefixTable = map[float64]string{ + -30: "q", // quecto + -27: "r", // ronto + -24: "y", // yocto + -21: "z", // zepto + -18: "a", // atto + -15: "f", // femto + -12: "p", // pico + -9: "n", // nano + -6: "µ", // micro + -3: "m", // milli + 0: "", + 3: "k", // kilo + 6: "M", // mega + 9: "G", // giga + 12: "T", // tera + 15: "P", // peta + 18: "E", // exa + 21: "Z", // zetta + 24: "Y", // yotta + 27: "R", // ronna + 30: "Q", // quetta +} + +var revSIPrefixTable = revfmap(siPrefixTable) + +// revfmap reverses the map and precomputes the power multiplier +func revfmap(in map[float64]string) map[string]float64 { + rv := map[string]float64{} + for k, v := range in { + rv[v] = math.Pow(10, k) + } + return rv +} + +var riParseRegex *regexp.Regexp + +func init() { + ri := `^([\-0-9.]+)\s?([` + for _, v := range siPrefixTable { + ri += v + } + ri += `]?)(.*)` + + riParseRegex = regexp.MustCompile(ri) +} + +// ComputeSI finds the most appropriate SI prefix for the given number +// and returns the prefix along with the value adjusted to be within +// that prefix. +// +// See also: SI, ParseSI. +// +// e.g. ComputeSI(2.2345e-12) -> (2.2345, "p") +func ComputeSI(input float64) (float64, string) { + if input == 0 { + return 0, "" + } + mag := math.Abs(input) + exponent := math.Floor(logn(mag, 10)) + exponent = math.Floor(exponent/3) * 3 + + value := mag / math.Pow(10, exponent) + + // Handle special case where value is exactly 1000.0 + // Should return 1 M instead of 1000 k + if value == 1000.0 { + exponent += 3 + value = mag / math.Pow(10, exponent) + } + + value = math.Copysign(value, input) + + prefix := siPrefixTable[exponent] + return value, prefix +} + +// SI returns a string with default formatting. +// +// SI uses Ftoa to format float value, removing trailing zeros. +// +// See also: ComputeSI, ParseSI. +// +// e.g. SI(1000000, "B") -> 1 MB +// e.g. SI(2.2345e-12, "F") -> 2.2345 pF +func SI(input float64, unit string) string { + value, prefix := ComputeSI(input) + return Ftoa(value) + " " + prefix + unit +} + +// SIWithDigits works like SI but limits the resulting string to the +// given number of decimal places. +// +// e.g. SIWithDigits(1000000, 0, "B") -> 1 MB +// e.g. SIWithDigits(2.2345e-12, 2, "F") -> 2.23 pF +func SIWithDigits(input float64, decimals int, unit string) string { + value, prefix := ComputeSI(input) + return FtoaWithDigits(value, decimals) + " " + prefix + unit +} + +var errInvalid = errors.New("invalid input") + +// ParseSI parses an SI string back into the number and unit. +// +// See also: SI, ComputeSI. +// +// e.g. ParseSI("2.2345 pF") -> (2.2345e-12, "F", nil) +func ParseSI(input string) (float64, string, error) { + found := riParseRegex.FindStringSubmatch(input) + if len(found) != 4 { + return 0, "", errInvalid + } + mag := revSIPrefixTable[found[2]] + unit := found[3] + + base, err := strconv.ParseFloat(found[1], 64) + return base * mag, unit, err +} diff --git a/vendor/github.com/dustin/go-humanize/times.go b/vendor/github.com/dustin/go-humanize/times.go new file mode 100644 index 0000000..dd3fbf5 --- /dev/null +++ b/vendor/github.com/dustin/go-humanize/times.go @@ -0,0 +1,117 @@ +package humanize + +import ( + "fmt" + "math" + "sort" + "time" +) + +// Seconds-based time units +const ( + Day = 24 * time.Hour + Week = 7 * Day + Month = 30 * Day + Year = 12 * Month + LongTime = 37 * Year +) + +// Time formats a time into a relative string. +// +// Time(someT) -> "3 weeks ago" +func Time(then time.Time) string { + return RelTime(then, time.Now(), "ago", "from now") +} + +// A RelTimeMagnitude struct contains a relative time point at which +// the relative format of time will switch to a new format string. A +// slice of these in ascending order by their "D" field is passed to +// CustomRelTime to format durations. +// +// The Format field is a string that may contain a "%s" which will be +// replaced with the appropriate signed label (e.g. "ago" or "from +// now") and a "%d" that will be replaced by the quantity. +// +// The DivBy field is the amount of time the time difference must be +// divided by in order to display correctly. +// +// e.g. if D is 2*time.Minute and you want to display "%d minutes %s" +// DivBy should be time.Minute so whatever the duration is will be +// expressed in minutes. +type RelTimeMagnitude struct { + D time.Duration + Format string + DivBy time.Duration +} + +var defaultMagnitudes = []RelTimeMagnitude{ + {time.Second, "now", time.Second}, + {2 * time.Second, "1 second %s", 1}, + {time.Minute, "%d seconds %s", time.Second}, + {2 * time.Minute, "1 minute %s", 1}, + {time.Hour, "%d minutes %s", time.Minute}, + {2 * time.Hour, "1 hour %s", 1}, + {Day, "%d hours %s", time.Hour}, + {2 * Day, "1 day %s", 1}, + {Week, "%d days %s", Day}, + {2 * Week, "1 week %s", 1}, + {Month, "%d weeks %s", Week}, + {2 * Month, "1 month %s", 1}, + {Year, "%d months %s", Month}, + {18 * Month, "1 year %s", 1}, + {2 * Year, "2 years %s", 1}, + {LongTime, "%d years %s", Year}, + {math.MaxInt64, "a long while %s", 1}, +} + +// RelTime formats a time into a relative string. +// +// It takes two times and two labels. In addition to the generic time +// delta string (e.g. 5 minutes), the labels are used applied so that +// the label corresponding to the smaller time is applied. +// +// RelTime(timeInPast, timeInFuture, "earlier", "later") -> "3 weeks earlier" +func RelTime(a, b time.Time, albl, blbl string) string { + return CustomRelTime(a, b, albl, blbl, defaultMagnitudes) +} + +// CustomRelTime formats a time into a relative string. +// +// It takes two times two labels and a table of relative time formats. +// In addition to the generic time delta string (e.g. 5 minutes), the +// labels are used applied so that the label corresponding to the +// smaller time is applied. +func CustomRelTime(a, b time.Time, albl, blbl string, magnitudes []RelTimeMagnitude) string { + lbl := albl + diff := b.Sub(a) + + if a.After(b) { + lbl = blbl + diff = a.Sub(b) + } + + n := sort.Search(len(magnitudes), func(i int) bool { + return magnitudes[i].D > diff + }) + + if n >= len(magnitudes) { + n = len(magnitudes) - 1 + } + mag := magnitudes[n] + args := []interface{}{} + escaped := false + for _, ch := range mag.Format { + if escaped { + switch ch { + case 's': + args = append(args, lbl) + case 'd': + args = append(args, diff/mag.DivBy) + } + escaped = false + } else { + escaped = ch == '%' + } + } + return fmt.Sprintf(mag.Format, args...) +} diff --git a/vendor/github.com/kevinburke/rest/LICENSE b/vendor/github.com/kevinburke/rest/LICENSE new file mode 100644 index 0000000..b1d4899 --- /dev/null +++ b/vendor/github.com/kevinburke/rest/LICENSE @@ -0,0 +1,22 @@ +Copyright (c) 2016 Shyp, Inc. + +Permission is hereby granted, free of charge, to any person +obtaining a copy of this software and associated documentation +files (the "Software"), to deal in the Software without +restriction, including without limitation the rights to use, +copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the +Software is furnished to do so, subject to the following +conditions: + +The above copyright notice and this permission notice shall be +included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES +OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND +NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. diff --git a/vendor/github.com/kevinburke/rest/restclient/client.go b/vendor/github.com/kevinburke/rest/restclient/client.go new file mode 100644 index 0000000..ca88014 --- /dev/null +++ b/vendor/github.com/kevinburke/rest/restclient/client.go @@ -0,0 +1,234 @@ +package restclient + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net" + "net/http" + "runtime" + "strings" + "sync/atomic" + "time" + + "github.com/kevinburke/rest/resterror" +) + +type UploadType string + +// JSON specifies you'd like to upload JSON data. +var JSON UploadType = "application/json" + +// FormURLEncoded specifies you'd like to upload form-urlencoded data. +var FormURLEncoded UploadType = "application/x-www-form-urlencoded" + +const Version = "2.12.0" + +var ua string + +func init() { + gv := strings.Replace(runtime.Version(), "go", "", 1) + ua = fmt.Sprintf("rest-client/%s (https://github.com/kevinburke/rest) go/%s (%s/%s)", + Version, gv, runtime.GOOS, runtime.GOARCH) +} + +// Client is a generic Rest client for making HTTP requests. +type Client struct { + // Username for use in HTTP Basic Auth + ID string + // HTTP Client to use for making requests + Client *http.Client + // The base URL for all requests to this API, for example, + // "https://fax.twilio.com/v1" + Base string + // Set UploadType to JSON or FormURLEncoded to control how data is sent to + // the server. Defaults to FormURLEncoded. + UploadType UploadType + // ErrorParser is invoked when the client gets a 400-or-higher status code + // from the server. Defaults to rest.DefaultErrorParser. + ErrorParser func(*http.Response) error + + useBearerAuth bool + + // Password for use in HTTP Basic Auth, or single token in Bearer auth + token atomic.Value +} + +func (c *Client) Token() string { + l := c.token.Load() + s, _ := l.(string) + return s +} + +// New returns a new Client with HTTP Basic Auth with the given user and +// password. Base is the scheme+domain to hit for all requests. +func New(user, pass, base string) *Client { + + c := &Client{ + ID: user, + Client: defaultHttpClient, + Base: base, + UploadType: JSON, + ErrorParser: DefaultErrorParser, + } + c.token.Store(pass) + return c +} + +// NewBearerClient returns a new Client configured to use Bearer authentication. +func NewBearerClient(token, base string) *Client { + c := &Client{ + ID: "", + Client: defaultHttpClient, + Base: base, + UploadType: JSON, + ErrorParser: DefaultErrorParser, + useBearerAuth: true, + } + c.token.Store(token) + return c +} + +func (c *Client) UpdateToken(newToken string) { + c.token.Store(newToken) +} + +var defaultDialer = &net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + DualStack: true, +} + +// DialSocket configures c to use the provided socket and http.Transport to +// dial a Unix socket instead of a TCP port. +// +// If transport is nil, the settings from DefaultTransport are used. +func (c *Client) DialSocket(socket string, transport *http.Transport) { + dialSock := func(ctx context.Context, proto, addr string) (conn net.Conn, err error) { + return defaultDialer.DialContext(ctx, "unix", socket) + } + if transport == nil { + ht := http.DefaultTransport.(*http.Transport) + transport = &http.Transport{ + Proxy: ht.Proxy, + MaxIdleConns: ht.MaxIdleConns, + IdleConnTimeout: ht.IdleConnTimeout, + TLSHandshakeTimeout: ht.TLSHandshakeTimeout, + ExpectContinueTimeout: ht.ExpectContinueTimeout, + DialContext: dialSock, + } + } + if c.Client == nil { + // need to copy this so we don't modify the default client + c.Client = &http.Client{ + Timeout: defaultHttpClient.Timeout, + } + } + switch tp := c.Client.Transport.(type) { + // TODO both of these cases clobbber the existing transport which isn't + // ideal. + case nil, *Transport: + c.Client.Transport = &Transport{ + RoundTripper: transport, + Debug: DefaultTransport.Debug, + Output: DefaultTransport.Output, + } + case *http.Transport: + c.Client.Transport = transport + default: + panic(fmt.Sprintf("could not set DialSocket on unknown transport: %#v", tp)) + } +} + +func (c *Client) NewRequestWithContext(ctx context.Context, method, path string, body io.Reader) (*http.Request, error) { + if c == nil { + panic("cannot call NewRequestWithContext on nil *Client") + } + // see for example https://github.com/meterup/github-release/issues/1 - if + // the path contains the full URL including the base, strip it out + path = strings.TrimPrefix(path, c.Base) + req, err := http.NewRequestWithContext(ctx, method, c.Base+path, body) + if err != nil { + return nil, err + } + token := c.Token() + switch { + case c.useBearerAuth && token != "": + req.Header.Add("Authorization", "Bearer "+token) + case !c.useBearerAuth && (c.ID != "" || token != ""): + req.SetBasicAuth(c.ID, token) + } + req.Header.Add("User-Agent", ua) + req.Header.Add("Accept", "application/json") + req.Header.Add("Accept-Charset", "utf-8") + if method == "POST" || method == "PUT" { + uploadType := c.UploadType + if uploadType == "" { + uploadType = JSON + } + req.Header.Add("Content-Type", string(uploadType)+"; charset=utf-8") + } + return req, nil +} + +// NewRequest creates a new Request and sets basic auth based on the client's +// authentication information. +func (c *Client) NewRequest(method, path string, body io.Reader) (*http.Request, error) { + return c.NewRequestWithContext(context.Background(), method, path, body) +} + +// Do performs the HTTP request. If the HTTP response is in the 2xx range, +// Unmarshal the response body into v. If the response status code is 400 or +// above, attempt to Unmarshal the response into an Error. Otherwise return +// a generic http error. +func (c *Client) Do(r *http.Request, v interface{}) error { + var res *http.Response + var err error + if c.Client == nil { + res, err = defaultHttpClient.Do(r) + } else { + res, err = c.Client.Do(r) + } + if err != nil { + return err + } + defer res.Body.Close() + if res.StatusCode >= 400 { + if c.ErrorParser != nil { + return c.ErrorParser(res) + } + return DefaultErrorParser(res) + } + + resBody, err := io.ReadAll(res.Body) + if err != nil { + return err + } + if v == nil || res.StatusCode == http.StatusNoContent { + return nil + } else { + return json.Unmarshal(resBody, v) + } +} + +// DefaultErrorParser attempts to parse the response body as a rest.Error. If +// it cannot do so, return an error containing the entire response body. +func DefaultErrorParser(resp *http.Response) error { + resBody, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + defer resp.Body.Close() + rerr := new(resterror.Error) + err = json.Unmarshal(resBody, rerr) + if err != nil { + return fmt.Errorf("invalid response body: %s", string(resBody)) + } + if rerr.Title == "" { + return fmt.Errorf("invalid response body: %s", string(resBody)) + } else { + rerr.Status = resp.StatusCode + return rerr + } +} diff --git a/vendor/github.com/kevinburke/rest/restclient/transport.go b/vendor/github.com/kevinburke/rest/restclient/transport.go new file mode 100644 index 0000000..dd7b894 --- /dev/null +++ b/vendor/github.com/kevinburke/rest/restclient/transport.go @@ -0,0 +1,84 @@ +package restclient + +import ( + "bytes" + "io" + "net/http" + "net/http/httputil" + "os" +) + +// DefaultTransport is like http.DefaultTransport, but prints the contents of +// HTTP requests to os.Stderr if the DEBUG_HTTP_TRAFFIC environment variable is +// set to true. +var DefaultTransport *Transport +var defaultHttpClient *http.Client + +func init() { + DefaultTransport = &Transport{ + RoundTripper: http.DefaultTransport, + Debug: os.Getenv("DEBUG_HTTP_TRAFFIC") == "true", + Output: os.Stderr, + } + defaultHttpClient = &http.Client{ + Transport: DefaultTransport, + } +} + +// Transport implements HTTP round trips, but adds hooks for debugging the HTTP +// request. +type Transport struct { + // The underlying RoundTripper. + RoundTripper http.RoundTripper + // Whether to write the HTTP request and response contents to Output. + Debug bool + // If Debug is true, write the HTTP request and response contents here. If + // Output is nil, os.Stderr will be used. + Output io.Writer +} + +func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { + if t == nil { + panic("nil Transport") + } + var w io.ReadWriter = nil + if t.Debug { + w = new(bytes.Buffer) + bits, err := httputil.DumpRequestOut(req, true) + if err != nil { + return nil, err + } + if len(bits) > 0 && bits[len(bits)-1] != '\n' { + bits = append(bits, '\n') + } + w.Write(bits) + } + var res *http.Response + var err error + if t.RoundTripper == nil { + res, err = http.DefaultTransport.RoundTrip(req) + } else { + res, err = t.RoundTripper.RoundTrip(req) + } + if err != nil { + return res, err + } + if t.Debug { + bits, err := httputil.DumpResponse(res, true) + if err != nil { + return res, err + } + if len(bits) > 0 && bits[len(bits)-1] != '\n' { + bits = append(bits, '\n') + } + w.Write(bits) + if t.Output == nil { + t.Output = os.Stderr + } + _, err = io.Copy(t.Output, w) + if err != nil { + return res, err + } + } + return res, err +} diff --git a/vendor/github.com/kevinburke/rest/resterror/resterror.go b/vendor/github.com/kevinburke/rest/resterror/resterror.go new file mode 100644 index 0000000..a41a778 --- /dev/null +++ b/vendor/github.com/kevinburke/rest/resterror/resterror.go @@ -0,0 +1,38 @@ +package resterror + +import "fmt" + +// Error implements the HTTP Problem spec laid out here: +// https://tools.ietf.org/html/rfc7807 +type Error struct { + // The main error message. Should be short enough to fit in a phone's + // alert box. Do not end this message with a period. + Title string `json:"title"` + + // Id of this error message ("forbidden", "invalid_parameter", etc) + ID string `json:"id"` + + // More information about what went wrong. + Detail string `json:"detail,omitempty"` + + // Path to the object that's in error. + Instance string `json:"instance,omitempty"` + + // Link to more information about the error (Zendesk, API docs, etc). + Type string `json:"type,omitempty"` + + // HTTP status code of the error. + Status int `json:"status,omitempty"` +} + +func (e *Error) Error() string { + return e.Title +} + +func (e *Error) String() string { + if e.Detail != "" { + return fmt.Sprintf("rest: %s. %s", e.Title, e.Detail) + } else { + return fmt.Sprintf("rest: %s", e.Title) + } +} diff --git a/vendor/github.com/tomnomnom/linkheader/.gitignore b/vendor/github.com/tomnomnom/linkheader/.gitignore new file mode 100644 index 0000000..0a00dde --- /dev/null +++ b/vendor/github.com/tomnomnom/linkheader/.gitignore @@ -0,0 +1,2 @@ +cpu.out +linkheader.test diff --git a/vendor/github.com/tomnomnom/linkheader/.travis.yml b/vendor/github.com/tomnomnom/linkheader/.travis.yml new file mode 100644 index 0000000..cfda086 --- /dev/null +++ b/vendor/github.com/tomnomnom/linkheader/.travis.yml @@ -0,0 +1,6 @@ +language: go + +go: + - 1.6 + - 1.7 + - tip diff --git a/vendor/github.com/tomnomnom/linkheader/CONTRIBUTING.mkd b/vendor/github.com/tomnomnom/linkheader/CONTRIBUTING.mkd new file mode 100644 index 0000000..0339bec --- /dev/null +++ b/vendor/github.com/tomnomnom/linkheader/CONTRIBUTING.mkd @@ -0,0 +1,10 @@ +# Contributing + +* Raise an issue if appropriate +* Fork the repo +* Bootstrap the dev dependencies (run `./script/bootstrap`) +* Make your changes +* Use [gofmt](https://golang.org/cmd/gofmt/) +* Make sure the tests pass (run `./script/test`) +* Make sure the linters pass (run `./script/lint`) +* Issue a pull request diff --git a/vendor/github.com/tomnomnom/linkheader/LICENSE b/vendor/github.com/tomnomnom/linkheader/LICENSE new file mode 100644 index 0000000..55192df --- /dev/null +++ b/vendor/github.com/tomnomnom/linkheader/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 Tom Hudson + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/vendor/github.com/tomnomnom/linkheader/README.mkd b/vendor/github.com/tomnomnom/linkheader/README.mkd new file mode 100644 index 0000000..2a949ca --- /dev/null +++ b/vendor/github.com/tomnomnom/linkheader/README.mkd @@ -0,0 +1,35 @@ +# Golang Link Header Parser + +Library for parsing HTTP Link headers. Requires Go 1.6 or higher. + +Docs can be found on [the GoDoc page](https://godoc.org/github.com/tomnomnom/linkheader). + +[![Build Status](https://travis-ci.org/tomnomnom/linkheader.svg)](https://travis-ci.org/tomnomnom/linkheader) + +## Basic Example + +```go +package main + +import ( + "fmt" + + "github.com/tomnomnom/linkheader" +) + +func main() { + header := "; rel=\"next\"," + + "; rel=\"last\"" + links := linkheader.Parse(header) + + for _, link := range links { + fmt.Printf("URL: %s; Rel: %s\n", link.URL, link.Rel) + } +} + +// Output: +// URL: https://api.github.com/user/58276/repos?page=2; Rel: next +// URL: https://api.github.com/user/58276/repos?page=2; Rel: last +``` + + diff --git a/vendor/github.com/tomnomnom/linkheader/main.go b/vendor/github.com/tomnomnom/linkheader/main.go new file mode 100644 index 0000000..6b81321 --- /dev/null +++ b/vendor/github.com/tomnomnom/linkheader/main.go @@ -0,0 +1,151 @@ +// Package linkheader provides functions for parsing HTTP Link headers +package linkheader + +import ( + "fmt" + "strings" +) + +// A Link is a single URL and related parameters +type Link struct { + URL string + Rel string + Params map[string]string +} + +// HasParam returns if a Link has a particular parameter or not +func (l Link) HasParam(key string) bool { + for p := range l.Params { + if p == key { + return true + } + } + return false +} + +// Param returns the value of a parameter if it exists +func (l Link) Param(key string) string { + for k, v := range l.Params { + if key == k { + return v + } + } + return "" +} + +// String returns the string representation of a link +func (l Link) String() string { + + p := make([]string, 0, len(l.Params)) + for k, v := range l.Params { + p = append(p, fmt.Sprintf("%s=\"%s\"", k, v)) + } + if l.Rel != "" { + p = append(p, fmt.Sprintf("%s=\"%s\"", "rel", l.Rel)) + } + return fmt.Sprintf("<%s>; %s", l.URL, strings.Join(p, "; ")) +} + +// Links is a slice of Link structs +type Links []Link + +// FilterByRel filters a group of Links by the provided Rel attribute +func (l Links) FilterByRel(r string) Links { + links := make(Links, 0) + for _, link := range l { + if link.Rel == r { + links = append(links, link) + } + } + return links +} + +// String returns the string representation of multiple Links +// for use in HTTP responses etc +func (l Links) String() string { + if l == nil { + return fmt.Sprint(nil) + } + + var strs []string + for _, link := range l { + strs = append(strs, link.String()) + } + return strings.Join(strs, ", ") +} + +// Parse parses a raw Link header in the form: +// ; rel="foo", ; rel="bar"; wat="dis" +// returning a slice of Link structs +func Parse(raw string) Links { + var links Links + + // One chunk: ; rel="foo" + for _, chunk := range strings.Split(raw, ",") { + + link := Link{URL: "", Rel: "", Params: make(map[string]string)} + + // Figure out what each piece of the chunk is + for _, piece := range strings.Split(chunk, ";") { + + piece = strings.Trim(piece, " ") + if piece == "" { + continue + } + + // URL + if piece[0] == '<' && piece[len(piece)-1] == '>' { + link.URL = strings.Trim(piece, "<>") + continue + } + + // Params + key, val := parseParam(piece) + if key == "" { + continue + } + + // Special case for rel + if strings.ToLower(key) == "rel" { + link.Rel = val + } else { + link.Params[key] = val + } + } + + if link.URL != "" { + links = append(links, link) + } + } + + return links +} + +// ParseMultiple is like Parse, but accepts a slice of headers +// rather than just one header string +func ParseMultiple(headers []string) Links { + links := make(Links, 0) + for _, header := range headers { + links = append(links, Parse(header)...) + } + return links +} + +// parseParam takes a raw param in the form key="val" and +// returns the key and value as seperate strings +func parseParam(raw string) (key, val string) { + + parts := strings.SplitN(raw, "=", 2) + if len(parts) == 1 { + return parts[0], "" + } + if len(parts) != 2 { + return "", "" + } + + key = parts[0] + val = strings.Trim(parts[1], "\"") + + return key, val + +} diff --git a/vendor/github.com/voxelbrain/goptions/.gitignore b/vendor/github.com/voxelbrain/goptions/.gitignore new file mode 100644 index 0000000..bf9dfdc --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/.gitignore @@ -0,0 +1,2 @@ +*.swp +.DS_Store diff --git a/vendor/github.com/voxelbrain/goptions/CHANGELOG.md b/vendor/github.com/voxelbrain/goptions/CHANGELOG.md new file mode 100644 index 0000000..32ec7ca --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/CHANGELOG.md @@ -0,0 +1,96 @@ +# Changelog +## 2.5.6 +### Bug fixes + +* Unexported fields are now ignored + +### Minor changes + +* Examples for Verbs and Remainder in documentation + +## 2.5.4 +### Bugfixes + +* Fix typo in documentation + +## 2.5.3 +### Bugfixes + +* Remove placeholders from LICENSE +* Add CONTROBUTORS + +## 2.5.2 +### Bugfixes + +* Bring `examples/readme_example.go` and `README.md` up to date +* Rewrite formatter + +## 2.5.1 +### Bugfixes + +* Make arrays of `goptions.Marshaler` work + +## 2.5.0 +### New features + +* Add support for `int32` and `int64` +* Add support for `float32` and `float64` + +### Bugfixes + +* Fix a bug where the name of a unknown type would not be properly + printed +* Fix checks whether to use `os.Stdin` or `os.Stdout` when "-" is given for a + `*os.File` +* Fix an test example where the output to `os.Stderr` is apparently + not evaluated anymore. + +## 2.4.1 +### Bugfixes + +* Code was not compilable due to temporary [maintainer](http://github.com/surma) idiocy + (Thanks [akrennmair](http://github.com/akrennmair)) + +## 2.4.0 +### New features + +* Gave `goptions.FlagSet` a `ParseAndFail()` method + +## 2.3.0 +### New features + +* Add support for `time.Duration` + +## 2.2.0 +### New features + +* Add support for `*net.TCPAddr` +* Add support for `*net/url.URL` + +### Bugfixes + +* Fix behaviour of `[]bool` fields + +## 2.1.0 +### New features + +* `goptions.Verbs` is of type `string` and will have selected verb name as value + after parsing. + +## 2.0.0 +### Breaking changes + +* Disallow multiple flag names for one member +* Remove `accumulate` option in favor of generic array support + +### New features + +* Add convenience function `ParseAndFail` to make common usage of the library + a one-liner (see `readme_example.go`) +* Add a `Marshaler` interface to enable thrid-party types +* Add support for slices (and thereby for mutiple flag definitions) + +### Minor changes + +* Refactoring to get more flexibility +* Make a flag's default value accessible in the template context diff --git a/vendor/github.com/voxelbrain/goptions/CONTRIBUTORS.md b/vendor/github.com/voxelbrain/goptions/CONTRIBUTORS.md new file mode 100644 index 0000000..65d757b --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/CONTRIBUTORS.md @@ -0,0 +1,7 @@ +Contributors +============ + +These people have contributed to goptions's design and implementation: + + * Andreas Krennmair + * GDG Berlin Golang diff --git a/vendor/github.com/voxelbrain/goptions/LICENSE.txt b/vendor/github.com/voxelbrain/goptions/LICENSE.txt new file mode 100644 index 0000000..44f1148 --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/LICENSE.txt @@ -0,0 +1,24 @@ +Copyright (c) 2012-2013, voxelbrain UG, Germany +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above copyright + notice, this list of conditions and the following disclaimer in the + documentation and/or other materials provided with the distribution. + * Neither the name of the voxelbrain UG nor the + names of its contributors may be used to endorse or promote products + derived from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL voxelbrain UG BE LIABLE FOR ANY +DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND +ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/vendor/github.com/voxelbrain/goptions/README.md b/vendor/github.com/voxelbrain/goptions/README.md new file mode 100644 index 0000000..0f31aef --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/README.md @@ -0,0 +1,114 @@ +`goptions` implements a flexible parser for command line options. + +Key targets were the support for both long and short flag versions, mutually +exclusive flags, and verbs. Flags and their corresponding variables are defined +by the tags in a (possibly anonymous) struct. + +![](https://circleci.com/gh/voxelbrain/goptions.png?circle-token=27cd98362d475cfa8c586565b659b2204733f25c) + +# Example + +```go +package main + +import ( + "github.com/voxelbrain/goptions" + "os" + "time" +) + +func main() { + options := struct { + Servers []string `goptions:"-s, --server, obligatory, description='Servers to connect to'"` + Password string `goptions:"-p, --password, description='Don\\'t prompt for password'"` + Timeout time.Duration `goptions:"-t, --timeout, description='Connection timeout in seconds'"` + Help goptions.Help `goptions:"-h, --help, description='Show this help'"` + + goptions.Verbs + Execute struct { + Command string `goptions:"--command, mutexgroup='input', description='Command to exectute', obligatory"` + Script *os.File `goptions:"--script, mutexgroup='input', description='Script to exectute', rdonly"` + } `goptions:"execute"` + Delete struct { + Path string `goptions:"-n, --name, obligatory, description='Name of the entity to be deleted'"` + Force bool `goptions:"-f, --force, description='Force removal'"` + } `goptions:"delete"` + }{ // Default values goes here + Timeout: 10 * time.Second, + } + goptions.ParseAndFail(&options) +} +``` + +``` +$ go run examples/readme_example.go --help +Usage: a.out [global options] [verb options] + +Global options: + -s, --server Servers to connect to (*) + -p, --password Don't prompt for password + -t, --timeout Connection timeout in seconds (default: 10s) + -h, --help Show this help + +Verbs: + delete: + -n, --name Name of the entity to be deleted (*) + -f, --force Force removal + execute: + --command Command to exectute (*) + --script Script to exectute +``` + +# Quick Reference + +## goptions + +Each field of your struct can be tagged with a `goptions` + +```go + FieldName type `goptions:"-S, --long, options..."` +``` + +Where the short options (`-S`) are declared with a single dash and +long options (`--long`) are declared with two dashes. Either or +both may be declared. + +After the short/long option names are one or more of the following: + +### Global Options + +* description='...' +* obligatory +* mutexgroup='GROUP_NAME' + +### os.File specific + +* create +* append +* rdonly +* wronly +* rdwr +* excl +* sync +* trunc +* perm=0777 + +## Supported Types + +* bool +* string +* float64 +* float32 +* int +* int64 +* int32 +* goptions.Help +* *os.File +* *net.TCPAddr +* *url.URL +* time.Duration + + + +--- +Version 2.5.11 diff --git a/vendor/github.com/voxelbrain/goptions/circle.yml b/vendor/github.com/voxelbrain/goptions/circle.yml new file mode 100644 index 0000000..b399d44 --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/circle.yml @@ -0,0 +1,9 @@ +version: 2 +jobs: + build: + docker: + - image: circleci/golang + steps: + - checkout + - run: go get -d . + - run: go test diff --git a/vendor/github.com/voxelbrain/goptions/flag.go b/vendor/github.com/voxelbrain/goptions/flag.go new file mode 100644 index 0000000..0af05b4 --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/flag.go @@ -0,0 +1,90 @@ +package goptions + +import ( + "fmt" + "reflect" + "strings" +) + +// Flag represents a single flag of a FlagSet. +type Flag struct { + Short string + Long string + MutexGroups []string + Description string + Obligatory bool + WasSpecified bool + value reflect.Value + optionMeta map[string]interface{} + DefaultValue interface{} +} + +// Return the name of the flag preceding the right amount of dashes. +// The long name is preferred. If no name has been specified, "" +// will be returned. +func (f *Flag) Name() string { + if len(f.Long) > 0 { + return "--" + f.Long + } + if len(f.Short) > 0 { + return "-" + f.Short + } + return "" +} + +// NeedsExtraValue returns true if the flag expects a separate value. +func (f *Flag) NeedsExtraValue() bool { + // Explicit over implicit + if f.value.Type() == reflect.TypeOf(new([]bool)).Elem() || + f.value.Type() == reflect.TypeOf(new(bool)).Elem() { + return false + } + if _, ok := f.value.Interface().(Help); ok { + return false + } + return true +} + +// IsMulti returns true if the flag can be specified multiple times. +func (f *Flag) IsMulti() bool { + if f.value.Kind() == reflect.Slice { + return true + } + return false +} + +func isShort(arg string) bool { + return strings.HasPrefix(arg, "-") && !strings.HasPrefix(arg, "--") && len(arg) >= 2 +} + +func isLong(arg string) bool { + return strings.HasPrefix(arg, "--") && len(arg) >= 3 +} + +func (f *Flag) Handles(arg string) bool { + return (isShort(arg) && arg[1:2] == f.Short) || + (isLong(arg) && arg[2:] == f.Long) + +} + +func (f *Flag) Parse(args []string) ([]string, error) { + param, value := args[0], "" + if f.NeedsExtraValue() && + (len(args) < 2 || (isShort(param) && len(param) > 2)) { + return args, fmt.Errorf("Flag %s needs an argument", f.Name()) + } + if f.WasSpecified && !f.IsMulti() { + return args, fmt.Errorf("Flag %s can only be specified once", f.Name()) + } + if isShort(param) && len(param) > 2 { + // Short flag cluster + args[0] = "-" + param[2:] + } else if f.NeedsExtraValue() { + value = args[1] + args = args[2:] + } else { + args = args[1:] + } + f.WasSpecified = true + return args, f.setValue(value) +} diff --git a/vendor/github.com/voxelbrain/goptions/flagset.go b/vendor/github.com/voxelbrain/goptions/flagset.go new file mode 100644 index 0000000..cf31038 --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/flagset.go @@ -0,0 +1,240 @@ +package goptions + +import ( + "errors" + "fmt" + "io" + "os" + "reflect" + "strings" + "sync" +) + +// A FlagSet represents one set of flags which belong to one particular program. +// A FlagSet is also used to represent a subset of flags belonging to one verb. +type FlagSet struct { + // This HelpFunc will be called when PrintHelp() is called. + HelpFunc + // Name of the program. Might be used by HelpFunc. + Name string + helpFlag *Flag + remainderFlag *Flag + shortMap map[string]*Flag + longMap map[string]*Flag + verbFlag *Flag + // Global option flags + Flags []*Flag + // Verbs and corresponding FlagSets + Verbs map[string]*FlagSet + parent *FlagSet +} + +// NewFlagSet returns a new FlagSet containing all the flags which result from +// parsing the tags of the struct. Said struct as to be passed to the function +// as a pointer. +// If a tag line is erroneous, NewFlagSet() panics as this is considered a +// compile time error rather than a runtme error. +func NewFlagSet(name string, v interface{}) *FlagSet { + structValue := reflect.ValueOf(v) + if structValue.Kind() != reflect.Ptr { + panic("Value type is not a pointer to a struct") + } + structValue = structValue.Elem() + if structValue.Kind() != reflect.Struct { + panic("Value type is not a pointer to a struct") + } + return newFlagset(name, structValue, nil) +} + +// Internal version which skips type checking and takes the "parent"'s +// remainder flag as a parameter. +func newFlagset(name string, structValue reflect.Value, parent *FlagSet) *FlagSet { + var once sync.Once + r := &FlagSet{ + Name: name, + Flags: make([]*Flag, 0), + HelpFunc: DefaultHelpFunc, + parent: parent, + } + + if parent != nil && parent.remainderFlag != nil { + r.remainderFlag = parent.remainderFlag + } + + var i int + // Parse Option fields + for i = 0; i < structValue.Type().NumField(); i++ { + // Skip unexported fields + if StartsWithLowercase(structValue.Type().Field(i).Name) { + continue + } + + fieldValue := structValue.Field(i) + tag := structValue.Type().Field(i).Tag.Get("goptions") + flag, err := parseStructField(fieldValue, tag) + + if err != nil { + panic(fmt.Sprintf("Invalid struct field: %s", err)) + } + if fieldValue.Type().Name() == "Verbs" { + r.verbFlag = flag + break + } + if fieldValue.Type().Name() == "Help" { + r.helpFlag = flag + } + if fieldValue.Type().Name() == "Remainder" && r.remainderFlag == nil { + r.remainderFlag = flag + } + + if len(tag) != 0 { + r.Flags = append(r.Flags, flag) + } + } + + // Parse verb fields + for i++; i < structValue.Type().NumField(); i++ { + once.Do(func() { + r.Verbs = make(map[string]*FlagSet) + }) + fieldValue := structValue.Field(i) + tag := structValue.Type().Field(i).Tag.Get("goptions") + r.Verbs[tag] = newFlagset(tag, fieldValue, r) + } + r.createMaps() + return r +} + +var ( + ErrHelpRequest = errors.New("Request for Help") +) + +// Parse takes the command line arguments and sets the corresponding values +// in the FlagSet's struct. +func (fs *FlagSet) Parse(args []string) (err error) { + // Parse global flags + for len(args) > 0 { + if !((isLong(args[0]) && fs.hasLongFlag(args[0][2:])) || + (isShort(args[0]) && fs.hasShortFlag(args[0][1:2]))) { + break + } + f := fs.FlagByName(args[0]) + args, err = f.Parse(args) + if err != nil { + return + } + if f == fs.helpFlag && f.WasSpecified { + return ErrHelpRequest + } + } + + // Process verb + if len(args) > 0 { + if verb, ok := fs.Verbs[args[0]]; ok { + fs.verbFlag.value.Set(reflect.ValueOf(Verbs(args[0]))) + err := verb.Parse(args[1:]) + if err != nil { + return err + } + args = args[0:0] + } + } + + // Process remainder + if len(args) > 0 { + if fs.remainderFlag == nil { + return fmt.Errorf("Invalid trailing arguments: %v", args) + } + remainder := reflect.MakeSlice(fs.remainderFlag.value.Type(), len(args), len(args)) + reflect.Copy(remainder, reflect.ValueOf(args)) + fs.remainderFlag.value.Set(remainder) + } + + // Check for unset, obligatory, single Flags + for _, f := range fs.Flags { + if f.Obligatory && !f.WasSpecified && len(f.MutexGroups) == 0 { + return fmt.Errorf("%s must be specified", f.Name()) + } + } + + // Check for multiple set Flags in one mutex group + // Check also for unset, obligatory mutex groups + mgs := fs.MutexGroups() + for _, mg := range mgs { + if !mg.IsValid() { + return fmt.Errorf("Exactly one of %s must be specified", strings.Join(mg.Names(), ", ")) + } + } + return nil +} + +func (fs *FlagSet) createMaps() { + fs.longMap = make(map[string]*Flag) + fs.shortMap = make(map[string]*Flag) + for _, flag := range fs.Flags { + fs.longMap[flag.Long] = flag + fs.shortMap[flag.Short] = flag + } +} + +func (fs *FlagSet) hasLongFlag(fname string) bool { + _, ok := fs.longMap[fname] + return ok +} + +func (fs *FlagSet) hasShortFlag(fname string) bool { + _, ok := fs.shortMap[fname] + return ok +} + +func (fs *FlagSet) FlagByName(fname string) *Flag { + if isShort(fname) && fs.hasShortFlag(fname[1:2]) { + return fs.shortMap[fname[1:2]] + } else if isLong(fname) && fs.hasLongFlag(fname[2:]) { + return fs.longMap[fname[2:]] + } + return nil +} + +// MutexGroups returns a map of Flag lists which contain mutually +// exclusive flags. +func (fs *FlagSet) MutexGroups() map[string]MutexGroup { + r := make(map[string]MutexGroup) + for _, f := range fs.Flags { + for _, mg := range f.MutexGroups { + if len(mg) == 0 { + continue + } + if _, ok := r[mg]; !ok { + r[mg] = make(MutexGroup, 0) + } + r[mg] = append(r[mg], f) + } + } + return r +} + +// Prints the FlagSet's help to the given writer. +func (fs *FlagSet) PrintHelp(w io.Writer) { + fs.HelpFunc(w, fs) +} + +func (fs *FlagSet) ParseAndFail(w io.Writer, args []string) { + err := fs.Parse(args) + if err != nil { + errCode := 0 + if err != ErrHelpRequest { + errCode = 1 + fmt.Fprintf(w, "Error: %s\n", err) + } + fs.PrintHelp(w) + os.Exit(errCode) + } +} + +func StartsWithLowercase(s string) bool { + if len(s) <= 0 { + return false + } + return strings.ToLower(s)[0] == s[0] +} diff --git a/vendor/github.com/voxelbrain/goptions/goptions.go b/vendor/github.com/voxelbrain/goptions/goptions.go new file mode 100644 index 0000000..992f7ee --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/goptions.go @@ -0,0 +1,99 @@ +/* +package goptions implements a flexible parser for command line options. + +Key targets were the support for both long and short flag versions, mutually +exclusive flags, and verbs. Flags and their corresponding variables are defined +by the tags in a (possibly anonymous) struct. + + var options struct { + Name string `goptions:"-n, --name"` + Force bool `goptions:"-f, --force"` + Verbosity int `goptions:"-v, --verbose"` + } + +Short flags can be combined (e.g. `-nfv`). Long flags take their value after a +separating space. The equals notation (`--long-flag=value`) is NOT supported +right now. + +Every member of the struct which is supposed to catch a command line value +has to have a "goptions" tag. The contains the short and long flag names for this +member but can additionally specify any of these options below. + + obligatory - Flag must be specified. Otherwise an error will be returned + when Parse() is called. + description='...' - Set the description for this particular flag. Will be + used by the HelpFunc. + mutexgroup='...' - Add this flag to a MutexGroup. Only one flag of the + ones sharing a MutexGroup can be set. Otherwise an error + will be returned when Parse() is called. If one flag in a + MutexGroup is `obligatory` one flag of the group must be + specified. A flag can be in multiple MutexGroups at once. + +Depending on the type of the struct member, additional options might become available: + + Type: *os.File + The given string is interpreted as a path to a file. If the string is "-" + os.Stdin or os.Stdout will be used. os.Stdin will be returned, if the + `rdonly` flag was set. os.Stdout will be returned, if `wronly` was set. + Available options: + Any combination of create, append, rdonly, wronly, rdwr, + excl, sync, trunc and perm can be specified and correspond directly with + the combination of the homonymous flags in the os package. + + Type: *net.TCPAddr + The given string is interpreted as a tcp address. It is passed to + net.ResolvTCPAddr() with "tcp" as the network type identifier. + + Type: *net/url.URL + The given string is parsed by net/url.Parse() + + Type: time.Duration + The given string is parsed by time.ParseDuration() + +If a member is a slice type, multiple definitions of the flags are possible. For each +specification the underlying type will be used. + + var options struct { + Servers []string `goptions:"-s, --server, description='Servers to connect to'"` + }{} + +goptions also has support for verbs. Each verb accepts its own set of flags which +take exactly the same tag format as global options. For an usage example of verbs +see the PrintHelp() example. +*/ +package goptions + +import ( + "os" + "path/filepath" +) + +const ( + VERSION = "2.5.11" +) + +var ( + globalFlagSet *FlagSet +) + +// ParseAndFail is a convenience function to parse os.Args[1:] and print +// the help if an error occurs. This should cover 90% of this library's +// applications. +func ParseAndFail(v interface{}) { + globalFlagSet = NewFlagSet(filepath.Base(os.Args[0]), v) + globalFlagSet.ParseAndFail(os.Stderr, os.Args[1:]) +} + +// Parse parses the command-line flags from os.Args[1:]. +func Parse(v interface{}) error { + globalFlagSet = NewFlagSet(filepath.Base(os.Args[0]), v) + return globalFlagSet.Parse(os.Args[1:]) +} + +// PrintHelp renders the default help to os.Stderr. +func PrintHelp() { + if globalFlagSet == nil { + panic("Must call Parse() before PrintHelp()") + } + globalFlagSet.PrintHelp(os.Stderr) +} diff --git a/vendor/github.com/voxelbrain/goptions/helpfunc.go b/vendor/github.com/voxelbrain/goptions/helpfunc.go new file mode 100644 index 0000000..3b0165e --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/helpfunc.go @@ -0,0 +1,72 @@ +package goptions + +import ( + "io" + "sync" + "text/tabwriter" + "text/template" +) + +// HelpFunc is the signature of a function responsible for printing the help. +type HelpFunc func(w io.Writer, fs *FlagSet) + +// Generates a new HelpFunc taking a `text/template.Template`-formatted +// string as an argument. The resulting template will be executed with the FlagSet +// as its data. +func NewTemplatedHelpFunc(tpl string) HelpFunc { + var once sync.Once + var t *template.Template + return func(w io.Writer, fs *FlagSet) { + once.Do(func() { + t = template.Must(template.New("helpTemplate").Parse(tpl)) + }) + err := t.Execute(w, fs) + if err != nil { + panic(err) + } + } +} + +const ( + _DEFAULT_HELP = "\xffUsage: {{.Name}} [global options] {{with .Verbs}} [verb options]{{end}}\n" + + "\n" + + "Global options:\xff" + + "{{range .Flags}}" + + "\n\t" + + "\t{{with .Short}}" + "-{{.}}," + "{{end}}" + + "\t{{with .Long}}" + "--{{.}}" + "{{end}}" + + "\t{{.Description}}" + + "{{with .DefaultValue}}" + + " (default: {{.}})" + + "{{end}}" + + "{{if .Obligatory}}" + + " (*)" + + "{{end}}" + + "{{end}}" + + "\xff\n\n{{with .Verbs}}Verbs:\xff" + + "{{range .}}" + + "\xff\n {{.Name}}:\xff" + + "{{range .Flags}}" + + "\n\t" + + "\t{{with .Short}}" + "-{{.}}," + "{{end}}" + + "\t{{with .Long}}" + "--{{.}}" + "{{end}}" + + "\t{{.Description}}" + + "{{with .DefaultValue}}" + + " (default: {{.}})" + + "{{end}}" + + "{{if .Obligatory}}" + + " (*)" + + "{{end}}" + + "{{end}}" + + "{{end}}" + + "{{end}}" + + "\n" +) + +// DefaultHelpFunc is a HelpFunc which renders the default help template and pipes +// the output through a text/tabwriter.Writer before flushing it to the output. +func DefaultHelpFunc(w io.Writer, fs *FlagSet) { + tw := tabwriter.NewWriter(w, 4, 4, 1, ' ', tabwriter.StripEscape|tabwriter.DiscardEmptyColumns) + NewTemplatedHelpFunc(_DEFAULT_HELP)(tw, fs) + tw.Flush() +} diff --git a/vendor/github.com/voxelbrain/goptions/marshaler.go b/vendor/github.com/voxelbrain/goptions/marshaler.go new file mode 100644 index 0000000..b79cc60 --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/marshaler.go @@ -0,0 +1,5 @@ +package goptions + +type Marshaler interface { + MarshalGoption(s string) error +} diff --git a/vendor/github.com/voxelbrain/goptions/mutexgroup.go b/vendor/github.com/voxelbrain/goptions/mutexgroup.go new file mode 100644 index 0000000..14c2053 --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/mutexgroup.go @@ -0,0 +1,48 @@ +package goptions + +// A MutexGroup holds a set of flags which are mutually exclusive and cannot +// be specified at the same time. +type MutexGroup []*Flag + +// IsObligatory returns true if exactly one of the flags in the MutexGroup has +// to be specified +func (mg MutexGroup) IsObligatory() bool { + for _, flag := range mg { + if flag.Obligatory { + return true + } + } + return false +} + +func (mg MutexGroup) WasSpecified() bool { + for _, flag := range mg { + if flag.WasSpecified { + return true + } + } + return false +} + +// IsValid checks if the flags in the MutexGroup describe a valid state. +// I.e. At most one has been specified or – if it is an obligatory MutexGroup – +// exactly one has been specified. +func (mg MutexGroup) IsValid() bool { + c := 0 + for _, flag := range mg { + if flag.WasSpecified { + c++ + } + } + return c <= 1 && (!mg.IsObligatory() || c == 1) +} + +// Names is a convenience function to return the array of names of the flags +// in the MutexGroup. +func (mg MutexGroup) Names() []string { + r := make([]string, len(mg)) + for i, flag := range mg { + r[i] = flag.Name() + } + return r +} diff --git a/vendor/github.com/voxelbrain/goptions/options.go b/vendor/github.com/voxelbrain/goptions/options.go new file mode 100644 index 0000000..86ed6bc --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/options.go @@ -0,0 +1,136 @@ +package goptions + +import ( + "fmt" + "os" + "reflect" + "strconv" + "strings" + "time" +) + +type optionFunc func(f *Flag, option, value string) error +type optionMap map[string]optionFunc + +var ( + typeOptionMap = map[reflect.Type]optionMap{ + // Global options + nil: optionMap{ + "description": description, + "obligatory": obligatory, + "mutexgroup": mutexgroup, + }, + reflect.TypeOf(new(time.Time)).Elem(): optionMap{ + "format": time_format, + }, + reflect.TypeOf(new(*os.File)).Elem(): optionMap{ + "create": initOptionMeta(file_create, "file_mode", 0), + "append": initOptionMeta(file_append, "file_mode", 0), + "rdonly": initOptionMeta(file_rdonly, "file_mode", 0), + "wronly": initOptionMeta(file_wronly, "file_mode", 0), + "rdwr": initOptionMeta(file_rdwr, "file_mode", 0), + "excl": initOptionMeta(file_excl, "file_mode", 0), + "sync": initOptionMeta(file_sync, "file_mode", 0), + "trunc": initOptionMeta(file_trunc, "file_mode", 0), + "perm": file_perm, + }, + } +) + +// Wraps another optionFunc and inits optionMeta[field] with value if it does +// not have one already. +func initOptionMeta(fn optionFunc, field string, init_value interface{}) optionFunc { + return func(f *Flag, option, value string) error { + if _, ok := f.optionMeta[field]; !ok { + f.optionMeta[field] = init_value + } + return fn(f, option, value) + } +} + +func description(f *Flag, option, value string) error { + f.Description = strings.Replace(value, `\`, ``, -1) + return nil +} + +func obligatory(f *Flag, option, value string) error { + f.Obligatory = true + return nil +} + +func mutexgroup(f *Flag, option, value string) error { + if len(value) <= 0 { + return fmt.Errorf("Mutexgroup option needs a value") + } + for _, group := range strings.Split(value, ",") { + f.MutexGroups = append(f.MutexGroups, group) + } + return nil +} + +func file_create(f *Flag, option, value string) error { + f.optionMeta["file_mode"] = f.optionMeta["file_mode"].(int) | os.O_CREATE + return nil +} + +func file_append(f *Flag, option, value string) error { + f.optionMeta["file_mode"] = f.optionMeta["file_mode"].(int) | os.O_APPEND + return nil +} + +func file_rdonly(f *Flag, option, value string) error { + f.optionMeta["file_mode"] = f.optionMeta["file_mode"].(int) | os.O_RDONLY + return nil +} + +func file_wronly(f *Flag, option, value string) error { + f.optionMeta["file_mode"] = f.optionMeta["file_mode"].(int) | os.O_WRONLY + return nil +} + +func file_rdwr(f *Flag, option, value string) error { + f.optionMeta["file_mode"] = f.optionMeta["file_mode"].(int) | os.O_RDWR + return nil +} + +func file_excl(f *Flag, option, value string) error { + f.optionMeta["file_mode"] = f.optionMeta["file_mode"].(int) | os.O_EXCL + return nil +} + +func file_sync(f *Flag, option, value string) error { + f.optionMeta["file_mode"] = f.optionMeta["file_mode"].(int) | os.O_SYNC + return nil +} + +func file_trunc(f *Flag, option, value string) error { + f.optionMeta["file_mode"] = f.optionMeta["file_mode"].(int) | os.O_TRUNC + return nil +} + +func file_perm(f *Flag, option, value string) error { + perm, err := strconv.ParseInt(value, 8, 32) + if err != nil { + return err + } + f.optionMeta["file_perm"] = uint32(perm) + return nil +} + +func time_format(f *Flag, option, value string) error { + f.optionMeta["format"] = value + return nil +} + +func optionMapForType(t reflect.Type) optionMap { + g := typeOptionMap[nil] + m, _ := typeOptionMap[t] + r := make(optionMap) + for k, v := range g { + r[k] = v + } + for k, v := range m { + r[k] = v + } + return r +} diff --git a/vendor/github.com/voxelbrain/goptions/special_types.go b/vendor/github.com/voxelbrain/goptions/special_types.go new file mode 100644 index 0000000..dc0078f --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/special_types.go @@ -0,0 +1,14 @@ +package goptions + +// Help Defines the common help flag. It is handled separately as it will cause +// Parse() to return ErrHelpRequest. +type Help bool + +// Verbs marks the point in the struct where the verbs start. Its value will be +// the name of the selected verb. +type Verbs string + +// A remainder catches all excessive arguments. If both a verb and +// the containing options struct have a remainder field, only the latter one +// will be used. +type Remainder []string diff --git a/vendor/github.com/voxelbrain/goptions/tagparser.go b/vendor/github.com/voxelbrain/goptions/tagparser.go new file mode 100644 index 0000000..7a507e9 --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/tagparser.go @@ -0,0 +1,68 @@ +package goptions + +import ( + "fmt" + "reflect" + "regexp" + "strings" +) + +const ( + _LONG_FLAG_REGEXP = `--[[:word:]-]+` + _SHORT_FLAG_REGEXP = `-[[:alnum:]]` + _QUOTED_STRING_REGEXP = `'((?:\\'|[^\\'])+)'` + _OPTION_REGEXP = `([[:word:]-]+)(?:=` + _QUOTED_STRING_REGEXP + `)?` +) + +var ( + optionRegexp = regexp.MustCompile(`^(` + strings.Join([]string{_SHORT_FLAG_REGEXP, _LONG_FLAG_REGEXP, _OPTION_REGEXP}, "|") + `)(?:,|$)`) +) + +func parseStructField(fieldValue reflect.Value, tag string) (*Flag, error) { + f := &Flag{ + value: fieldValue, + DefaultValue: fieldValue.Interface(), + optionMeta: make(map[string]interface{}), + } + for { + tag = strings.TrimSpace(tag) + if len(tag) == 0 { + break + } + idx := optionRegexp.FindStringSubmatchIndex(tag) + if idx == nil { + return nil, fmt.Errorf("Could not find a valid flag definition at the beginning of \"%s\"", tag) + } + option := tag[idx[2]:idx[3]] + + if strings.HasPrefix(option, "--") { + if f.Long != "" { + return nil, fmt.Errorf("Multiple flags assigned to a member: %s", strings.Join([]string{"--" + f.Long, option}, ", ")) + } + f.Long = option[2:] + } else if strings.HasPrefix(option, "-") { + if f.Short != "" { + return nil, fmt.Errorf("Multiple flags assigned to a member: %s", strings.Join([]string{"-" + f.Short, option}, ", ")) + } + f.Short = option[1:] + } else { + option := tag[idx[4]:idx[5]] + value := "" + if idx[6] != -1 { + value = tag[idx[6]:idx[7]] + } + optionmap := optionMapForType(fieldValue.Type()) + opf, ok := optionmap[option] + if !ok { + return nil, fmt.Errorf("Unknown option %s", option) + } + err := opf(f, option, value) + if err != nil { + return nil, fmt.Errorf("Option %s invalid: %s", option, err) + } + } + // Keep remainder + tag = tag[idx[1]:] + } + return f, nil +} diff --git a/vendor/github.com/voxelbrain/goptions/valueparser.go b/vendor/github.com/voxelbrain/goptions/valueparser.go new file mode 100644 index 0000000..e8176fa --- /dev/null +++ b/vendor/github.com/voxelbrain/goptions/valueparser.go @@ -0,0 +1,163 @@ +package goptions + +import ( + "fmt" + "net" + "net/url" + "os" + "reflect" + "strconv" + "time" +) + +type valueParser func(f *Flag, val string) (reflect.Value, error) + +var ( + parserMap = map[reflect.Type]valueParser{ + reflect.TypeOf(new(bool)).Elem(): boolValueParser, + reflect.TypeOf(new(string)).Elem(): stringValueParser, + reflect.TypeOf(new(float64)).Elem(): float64ValueParser, + reflect.TypeOf(new(float32)).Elem(): float32ValueParser, + reflect.TypeOf(new(int)).Elem(): intValueParser, + reflect.TypeOf(new(int64)).Elem(): int64ValueParser, + reflect.TypeOf(new(int32)).Elem(): int32ValueParser, + reflect.TypeOf(new(Help)).Elem(): helpValueParser, + reflect.TypeOf(new(*os.File)).Elem(): fileValueParser, + reflect.TypeOf(new(*net.TCPAddr)).Elem(): tcpAddrValueParser, + reflect.TypeOf(new(*url.URL)).Elem(): urlValueParser, + reflect.TypeOf(new(time.Duration)).Elem(): durationValueParser, + reflect.TypeOf(new(time.Time)).Elem(): timeValueParser, + } +) + +func parseMarshalValue(value reflect.Value, s string) error { + newval := reflect.New(value.Type()).Elem() + if newval.Kind() == reflect.Ptr { + newptrval := reflect.New(value.Type().Elem()) + newval.Set(newptrval) + } + err := newval.Interface().(Marshaler).MarshalGoption(s) + value.Set(newval) + return err +} + +func (f *Flag) setValue(s string) (err error) { + defer func() { + if x := recover(); x != nil { + err = x.(error) + return + } + }() + if f.value.Type().Implements(reflect.TypeOf(new(Marshaler)).Elem()) { + return parseMarshalValue(f.value, s) + } + vtype := f.value.Type() + newval := reflect.New(vtype).Elem() + if f.value.Kind() == reflect.Slice { + vtype = f.value.Type().Elem() + if vtype.Implements(reflect.TypeOf(new(Marshaler)).Elem()) { + newval = reflect.New(vtype).Elem() + err := parseMarshalValue(newval, s) + f.value.Set(reflect.Append(f.value, newval)) + return err + } + } + if parser, ok := parserMap[vtype]; ok { + val, err := parser(f, s) + if err != nil { + return err + } + if f.value.Kind() == reflect.Slice { + f.value.Set(reflect.Append(f.value, val)) + } else { + f.value.Set(val) + } + return nil + } else { + return fmt.Errorf("Unsupported flag type: %s", f.value.Type()) + } + panic("Invalid execution path") +} + +func boolValueParser(f *Flag, val string) (reflect.Value, error) { + return reflect.ValueOf(true), nil +} + +func stringValueParser(f *Flag, val string) (reflect.Value, error) { + return reflect.ValueOf(val), nil +} + +func float64ValueParser(f *Flag, val string) (reflect.Value, error) { + floatval, err := strconv.ParseFloat(val, 64) + return reflect.ValueOf(float64(floatval)), err +} + +func float32ValueParser(f *Flag, val string) (reflect.Value, error) { + floatval, err := strconv.ParseFloat(val, 32) + return reflect.ValueOf(float32(floatval)), err +} + +func int64ValueParser(f *Flag, val string) (reflect.Value, error) { + intval, err := strconv.ParseInt(val, 10, 64) + return reflect.ValueOf(int64(intval)), err +} + +func int32ValueParser(f *Flag, val string) (reflect.Value, error) { + intval, err := strconv.ParseInt(val, 10, 32) + return reflect.ValueOf(int32(intval)), err +} + +func intValueParser(f *Flag, val string) (reflect.Value, error) { + intval, err := strconv.ParseInt(val, 10, 64) + return reflect.ValueOf(int(intval)), err +} + +func fileValueParser(f *Flag, val string) (reflect.Value, error) { + mode := 0 + if v, ok := f.optionMeta["file_mode"]; ok { + mode = v.(int) + } + if val == "-" { + if mode&1 == os.O_RDONLY { + return reflect.ValueOf(os.Stdin), nil + } else if mode&1 == os.O_WRONLY { + return reflect.ValueOf(os.Stdout), nil + } + } else { + perm := uint32(0644) + if v, ok := f.optionMeta["file_perm"].(uint32); ok { + perm = v + } + f, e := os.OpenFile(val, mode, os.FileMode(perm)) + return reflect.ValueOf(f), e + } + panic("Invalid execution path") +} + +func tcpAddrValueParser(f *Flag, val string) (reflect.Value, error) { + addr, err := net.ResolveTCPAddr("tcp", val) + return reflect.ValueOf(addr), err +} + +func urlValueParser(f *Flag, val string) (reflect.Value, error) { + url, err := url.Parse(val) + return reflect.ValueOf(url), err +} + +func durationValueParser(f *Flag, val string) (reflect.Value, error) { + d, err := time.ParseDuration(val) + return reflect.ValueOf(d), err +} + +func timeValueParser(f *Flag, val string) (reflect.Value, error) { + format := time.RFC3339 + if altFormat, ok := f.optionMeta["format"]; ok { + format = altFormat.(string) + } + d, err := time.Parse(format, val) + return reflect.ValueOf(d), err +} + +func helpValueParser(f *Flag, val string) (reflect.Value, error) { + return reflect.Value{}, ErrHelpRequest +} diff --git a/vendor/modules.txt b/vendor/modules.txt new file mode 100644 index 0000000..2ecbcc9 --- /dev/null +++ b/vendor/modules.txt @@ -0,0 +1,13 @@ +# github.com/dustin/go-humanize v1.0.1 +## explicit; go 1.16 +github.com/dustin/go-humanize +# github.com/kevinburke/rest v0.0.0-20250718180114-1a15e4f2364f +## explicit; go 1.23.0 +github.com/kevinburke/rest/restclient +github.com/kevinburke/rest/resterror +# github.com/tomnomnom/linkheader v0.0.0-20180905144013-02ca5825eb80 +## explicit +github.com/tomnomnom/linkheader +# github.com/voxelbrain/goptions v0.0.0-20180630082107-58cddc247ea2 +## explicit +github.com/voxelbrain/goptions diff --git a/version.go b/version.go index 6f55e51..06ab7d0 100644 --- a/version.go +++ b/version.go @@ -1,3 +1 @@ package main - -const VERSION = "0.6.2"