diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e610380e9..09f60f318 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -3,6 +3,8 @@ [legal]: https://help.github.com/articles/github-terms-of-service/#6-contributions-under-repository-license [license]: ../LICENSE [code-of-conduct]: CODE-OF-CONDUCT.md +[bug issues]: https://github.com/cli/cli/issues?q=is%3Aopen+is%3Aissue+label%3Abug +[feature request issues]: https://github.com/cli/cli/issues?q=is%3Aopen+is%3Aissue+label%3Aenhancement Hi! Thanks for your interest in contributing to the GitHub CLI! @@ -10,6 +12,7 @@ We accept pull requests for bug fixes and features where we've discussed the app Please do: +* check existing issues to verify that the [bug][bug issues] or [feature request][feature request issues] has not already been submitted * open an issue if things aren't working as expected * open an issue to propose a significant change * open a pull request to fix a bug @@ -24,7 +27,7 @@ Please avoid: Prerequisites: - Go 1.13+ for building the binary -- Go 1.14+ for running the test suite +- Go 1.15+ for running the test suite Build with: `make` or `go build -o bin/gh ./cmd/gh` diff --git a/.github/ISSUE_TEMPLATE/feedback.md b/.github/ISSUE_TEMPLATE/feedback.md new file mode 100644 index 000000000..837c36632 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feedback.md @@ -0,0 +1,28 @@ +--- +name: "\U0001F4E3 Feedback" +about: Give us general feedback about the GitHub CLI +title: '' +labels: feedback +assignees: '' + +--- + +# CLI Feedback + +You can use this template to give us structured feedback or just wipe it and leave us a note. Thank you! + +## What have you loved? + +_eg "the nice colors"_ + +## What was confusing or gave you pause? + +_eg "it did something unexpected"_ + +## Are there features you'd like to see added? + +_eg "gh cli needs mini-games"_ + +## Anything else? + +_eg "have a nice day"_ diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 1c3baa6e8..fe7d07b37 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -27,41 +27,85 @@ jobs: args: release --release-notes=CHANGELOG.md env: GITHUB_TOKEN: ${{secrets.UPLOAD_GITHUB_TOKEN}} - - name: Bump homebrew-core formula - uses: mislav/bump-homebrew-formula-action@v1 - if: "!contains(github.ref, '-')" # skip prereleases - with: - formula-name: gh - download-url: https://github.com/cli/cli.git - env: - COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }} - name: Checkout documentation site - if: "!contains(github.ref, '-')" # skip prereleases uses: actions/checkout@v2 with: repository: github/cli.github.com path: site fetch-depth: 0 token: ${{secrets.SITE_GITHUB_TOKEN}} - - name: Publish documentation site - if: "!contains(github.ref, '-')" # skip prereleases + - name: Update site man pages env: GIT_COMMITTER_NAME: cli automation GIT_AUTHOR_NAME: cli automation GIT_COMMITTER_EMAIL: noreply@github.com GIT_AUTHOR_EMAIL: noreply@github.com - run: make site-publish + run: make site-bump - name: Move project cards - if: "!contains(github.ref, '-')" # skip prereleases + continue-on-error: true env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} PENDING_COLUMN: 8189733 DONE_COLUMN: 7110130 run: | - curl -fsSL https://github.com/github/hub/raw/master/script/get | bash -s 2.14.1 - api() { bin/hub api -H 'accept: application/vnd.github.inertia-preview+json' "$@"; } + api() { gh api -H 'accept: application/vnd.github.inertia-preview+json' "$@"; } + api-write() { [[ $GITHUB_REF == *-* ]] && echo "skipping: api $*" || api "$@"; } cards=$(api projects/columns/$PENDING_COLUMN/cards | jq ".[].id") - for card in $cards; do api projects/columns/cards/$card/moves --field position=top --field column_id=$DONE_COLUMN; done + for card in $cards; do + api-write projects/columns/cards/$card/moves -f position=top -F column_id=$DONE_COLUMN + done + + - name: Install packaging dependencies + run: sudo apt-get install -y createrepo rpm reprepro + - name: Set up GPG + run: | + gpg --import --no-tty --batch --yes < script/pubkey.asc + echo "${{secrets.GPG_KEY}}" | base64 -d | gpg --import --no-tty --batch --yes + echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf + gpg-connect-agent RELOADAGENT /bye + echo "${{secrets.GPG_PASSPHRASE}}" | /usr/lib/gnupg2/gpg-preset-passphrase --preset 867DAD5051270B843EF54F6186FA10E3A1D22DC5 + - name: Sign RPMs + run: | + cp script/rpmmacros ~/.rpmmacros + rpmsign --addsign dist/*.rpm + - name: Run createrepo + run: | + mkdir -p site/packages/rpm + cp dist/*.rpm site/packages/rpm/ + createrepo site/packages/rpm + pushd site/packages/rpm + gpg --yes --detach-sign --armor repodata/repomd.xml + popd + - name: Run reprepro + env: + RELEASES: "focal stable bionic trusty precise xenial" + run: | + mkdir -p upload + for release in $RELEASES; do + for file in dist/*.deb; do + reprepro --confdir="+b/script" includedeb "$release" "$file" + done + done + cp -a dists/ pool/ upload/ + mkdir -p site/packages + cp -a upload/* site/packages/ + - name: Publish site + env: + GIT_COMMITTER_NAME: cli automation + GIT_AUTHOR_NAME: cli automation + GIT_COMMITTER_EMAIL: noreply@github.com + GIT_AUTHOR_EMAIL: noreply@github.com + working-directory: ./site + run: | + git add packages + git commit -m "Add rpm and deb packages for ${GITHUB_REF#refs/tags/}" + if [[ $GITHUB_REF == *-* ]]; then + git log --oneline @{upstream}.. + git diff --name-status @{upstream}.. + else + git push + fi + msi: needs: goreleaser runs-on: windows-latest @@ -72,8 +116,7 @@ jobs: id: download_exe shell: bash run: | - curl -fsSL https://github.com/github/hub/raw/master/script/get | bash -s 2.14.1 - bin/hub release download "${GITHUB_REF#refs/tags/}" -i '*windows_amd64*.zip' + hub release download "${GITHUB_REF#refs/tags/}" -i '*windows_amd64*.zip' printf "::set-output name=zip::%s\n" *.zip unzip -o *.zip && rm -v *.zip env: @@ -106,6 +149,14 @@ jobs: -Executable "${{ steps.buildmsi.outputs.msi }}" - name: Upload MSI shell: bash - run: bin/hub release edit "${GITHUB_REF#refs/tags/}" -m "" -a "${{ steps.buildmsi.outputs.msi }}" + run: hub release edit "${GITHUB_REF#refs/tags/}" -m "" --draft=false -a "${{ steps.buildmsi.outputs.msi }}" env: GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}} + - name: Bump homebrew-core formula + uses: mislav/bump-homebrew-formula-action@v1 + if: "!contains(github.ref, '-')" # skip prereleases + with: + formula-name: gh + download-url: https://github.com/cli/cli.git + env: + COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }} diff --git a/.goreleaser.yml b/.goreleaser.yml index fa6883834..e3f047c19 100644 --- a/.goreleaser.yml +++ b/.goreleaser.yml @@ -2,6 +2,7 @@ project_name: gh release: prerelease: auto + draft: true # we only publish after the Windows MSI gets uploaded before: hooks: @@ -49,32 +50,6 @@ archives: files: - LICENSE -brews: - - name: gh - ids: [nix] - github: - owner: github - name: homebrew-gh - skip_upload: auto - description: GitHub CLI - homepage: https://github.com/cli/cli - folder: Formula - custom_block: | - head do - url "https://github.com/cli/cli.git", :branch => "trunk" - depends_on "go" - end - install: | - system "make", "bin/gh", "manpages" if build.head? - bin.install "bin/gh" - man1.install Dir["./share/man/man1/gh*.1"] - (bash_completion/"gh.sh").write `#{bin}/gh completion -s bash` - (zsh_completion/"_gh").write `#{bin}/gh completion -s zsh` - (fish_completion/"gh.fish").write `#{bin}/gh completion -s fish` - test: | - help_text = shell_output("#{bin}/gh --help") - assert_includes help_text, "Usage:" - nfpms: - license: MIT maintainer: GitHub diff --git a/Makefile b/Makefile index 0bffa784d..51af20952 100644 --- a/Makefile +++ b/Makefile @@ -22,8 +22,8 @@ endif GO_LDFLAGS := -X github.com/cli/cli/command.Version=$(GH_VERSION) $(GO_LDFLAGS) GO_LDFLAGS := -X github.com/cli/cli/command.BuildDate=$(BUILD_DATE) $(GO_LDFLAGS) ifdef GH_OAUTH_CLIENT_SECRET - GO_LDFLAGS := -X github.com/cli/cli/internal/config.oauthClientID=$(GH_OAUTH_CLIENT_ID) $(GO_LDFLAGS) - GO_LDFLAGS := -X github.com/cli/cli/internal/config.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(GO_LDFLAGS) + GO_LDFLAGS := -X github.com/cli/cli/internal/authflow.oauthClientID=$(GH_OAUTH_CLIENT_ID) $(GO_LDFLAGS) + GO_LDFLAGS := -X github.com/cli/cli/internal/authflow.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(GO_LDFLAGS) endif bin/gh: $(BUILD_FILES) @@ -46,15 +46,14 @@ site-docs: site git -C site commit -m 'update help docs' || true .PHONY: site-docs -site-publish: site-docs +site-bump: site-docs ifndef GITHUB_REF $(error GITHUB_REF is not set) endif sed -i.bak -E 's/(assign version = )".+"/\1"$(GITHUB_REF:refs/tags/v%=%)"/' site/index.html rm -f site/index.html.bak git -C site commit -m '$(GITHUB_REF:refs/tags/v%=%)' index.html - git -C site push -.PHONY: site-publish +.PHONY: site-bump .PHONY: manpages diff --git a/README.md b/README.md index 4fd0311e0..e3aa83aa4 100644 --- a/README.md +++ b/README.md @@ -9,32 +9,29 @@ the terminal next to where you are already working with `git` and your code. While in beta, GitHub CLI is available for repos hosted on GitHub.com only. It currently does not support repositories hosted on GitHub Enterprise Server or other hosting providers. We are planning on adding support for GitHub Enterprise Server after GitHub CLI is out of beta (likely towards the end of 2020), and we want to ensure that the API endpoints we use are more widely available for GHES versions that most GitHub customers are on. -## We need your feedback +## We want your feedback -GitHub CLI is currently in its early development stages, and we're hoping to get feedback from people using it. - -If you've installed and used `gh`, we'd love for you to take a short survey here (no more than five minutes): https://forms.gle/umxd3h31c7aMQFKG7 - -And if you spot bugs or have features that you'd really like to see in `gh`, please check out the [contributing page][] +We'd love to hear your feedback about `gh`. If you spot bugs or have features that you'd really like to see in `gh`, please check out the [contributing page][]. ## Usage - `gh pr [status, list, view, checkout, create]` - `gh issue [status, list, view, create]` - `gh repo [view, create, clone, fork]` +- `gh auth [login, logout, refresh, status]` - `gh config [get, set]` - `gh help` ## Documentation -Read the [official docs](https://cli.github.com/manual/) for more information. +Read the [official docs][] for more information. ## Comparison with hub For many years, [hub][] was the unofficial GitHub CLI tool. `gh` is a new project that helps us explore what an official GitHub CLI tool can look like with a fundamentally different design. While both tools bring GitHub to the terminal, `hub` behaves as a proxy to `git`, and `gh` is a standalone -tool. Check out our [more detailed explanation](/docs/gh-vs-hub.md) to learn more. +tool. Check out our [more detailed explanation][gh-vs-hub] to learn more. @@ -72,6 +69,10 @@ Upgrade: sudo port selfupdate && sudo port upgrade gh ``` +### Linux + +See [Linux installation docs](/docs/install_linux.md). + ### Windows `gh` is available via [scoop][], [Chocolatey][], and as downloadable MSI. @@ -109,61 +110,20 @@ choco upgrade gh MSI installers are available for download on the [releases page][]. -### Debian/Ubuntu Linux - -Install and upgrade: - -1. Download the `.deb` file from the [releases page][]; -2. Install the downloaded file: `sudo apt install ./gh_*_linux_amd64.deb` - -### Fedora Linux - -Install and upgrade: - -1. Download the `.rpm` file from the [releases page][]; -2. Install the downloaded file: `sudo dnf install gh_*_linux_amd64.rpm` - -### Centos Linux - -Install and upgrade: - -1. Download the `.rpm` file from the [releases page][]; -2. Install the downloaded file: `sudo yum localinstall gh_*_linux_amd64.rpm` - -### openSUSE/SUSE Linux - -Install and upgrade: - -1. Download the `.rpm` file from the [releases page][]; -2. Install the downloaded file: `sudo zypper in gh_*_linux_amd64.rpm` - -### Arch Linux - -Arch Linux users can install from the [community repo](https://www.archlinux.org/packages/community/x86_64/github-cli/): - -```bash -pacman -S github-cli -``` - -### Android - -Android users can install via Termux: - -```bash -pkg install gh -``` - ### Other platforms Download packaged binaries from the [releases page][]. ### Build from source -See here on how to [build GitHub CLI from source](/docs/source.md). +See here on how to [build GitHub CLI from source][build from source]. -[docs]: https://cli.github.com/manual + +[official docs]: https://cli.github.com/manual [scoop]: https://scoop.sh [Chocolatey]: https://chocolatey.org [releases page]: https://github.com/cli/cli/releases/latest [hub]: https://github.com/github/hub [contributing page]: https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md +[gh-vs-hub]: /docs/gh-vs-hub.md +[build from source]: /docs/source.md diff --git a/api/client.go b/api/client.go index 2556f6317..d4a32d87d 100644 --- a/api/client.go +++ b/api/client.go @@ -45,7 +45,9 @@ func NewClientFromHTTP(httpClient *http.Client) *Client { func AddHeader(name, value string) ClientOption { return func(tr http.RoundTripper) http.RoundTripper { return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { - req.Header.Add(name, value) + if req.Header.Get(name) == "" { + req.Header.Add(name, value) + } return tr.RoundTrip(req) }} } @@ -55,11 +57,16 @@ func AddHeader(name, value string) ClientOption { func AddHeaderFunc(name string, getValue func(*http.Request) (string, error)) ClientOption { return func(tr http.RoundTripper) http.RoundTripper { return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) { + if req.Header.Get(name) != "" { + return tr.RoundTrip(req) + } value, err := getValue(req) if err != nil { return nil, err } - req.Header.Add(name, value) + if value != "" { + req.Header.Add(name, value) + } return tr.RoundTrip(req) }} } @@ -68,14 +75,15 @@ func AddHeaderFunc(name string, getValue func(*http.Request) (string, error)) Cl // VerboseLog enables request/response logging within a RoundTripper func VerboseLog(out io.Writer, logTraffic bool, colorize bool) ClientOption { logger := &httpretty.Logger{ - Time: true, - TLS: false, - Colors: colorize, - RequestHeader: logTraffic, - RequestBody: logTraffic, - ResponseHeader: logTraffic, - ResponseBody: logTraffic, - Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}}, + Time: true, + TLS: false, + Colors: colorize, + RequestHeader: logTraffic, + RequestBody: logTraffic, + ResponseHeader: logTraffic, + ResponseBody: logTraffic, + Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}}, + MaxResponseBody: 10000, } logger.SetOutput(out) logger.SetBodyFilter(func(h http.Header) (skip bool, err error) { @@ -190,7 +198,9 @@ type HTTPError struct { } func (err HTTPError) Error() string { - if err.Message != "" { + if msgs := strings.SplitN(err.Message, "\n", 2); len(msgs) > 1 { + return fmt.Sprintf("HTTP %d: %s (%s)\n%s", err.StatusCode, msgs[0], err.RequestURL, msgs[1]) + } else if err.Message != "" { return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL) } return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL) @@ -222,7 +232,7 @@ func (c Client) HasMinimumScopes(hostname string) error { }() if res.StatusCode != 200 { - return handleHTTPError(res) + return HandleHTTPError(res) } hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",") @@ -298,7 +308,7 @@ func (c Client) REST(hostname string, method string, p string, body io.Reader, d success := resp.StatusCode >= 200 && resp.StatusCode < 300 if !success { - return handleHTTPError(resp) + return HandleHTTPError(resp) } if resp.StatusCode == http.StatusNoContent { @@ -322,7 +332,7 @@ func handleResponse(resp *http.Response, data interface{}) error { success := resp.StatusCode >= 200 && resp.StatusCode < 300 if !success { - return handleHTTPError(resp) + return HandleHTTPError(resp) } body, err := ioutil.ReadAll(resp.Body) @@ -342,13 +352,18 @@ func handleResponse(resp *http.Response, data interface{}) error { return nil } -func handleHTTPError(resp *http.Response) error { +func HandleHTTPError(resp *http.Response) error { httpError := HTTPError{ StatusCode: resp.StatusCode, RequestURL: resp.Request.URL, OAuthScopes: resp.Header.Get("X-Oauth-Scopes"), } + if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) { + httpError.Message = resp.Status + return httpError + } + body, err := ioutil.ReadAll(resp.Body) if err != nil { httpError.Message = err.Error() @@ -357,14 +372,57 @@ func handleHTTPError(resp *http.Response) error { var parsedBody struct { Message string `json:"message"` + Errors []json.RawMessage } - if err := json.Unmarshal(body, &parsedBody); err == nil { - httpError.Message = parsedBody.Message + if err := json.Unmarshal(body, &parsedBody); err != nil { + return httpError } + type errorObject struct { + Message string + Resource string + Field string + Code string + } + + messages := []string{parsedBody.Message} + for _, raw := range parsedBody.Errors { + switch raw[0] { + case '"': + var errString string + _ = json.Unmarshal(raw, &errString) + messages = append(messages, errString) + case '{': + var errInfo errorObject + _ = json.Unmarshal(raw, &errInfo) + msg := errInfo.Message + if errInfo.Code != "custom" { + msg = fmt.Sprintf("%s.%s %s", errInfo.Resource, errInfo.Field, errorCodeToMessage(errInfo.Code)) + } + if msg != "" { + messages = append(messages, msg) + } + } + } + httpError.Message = strings.Join(messages, "\n") + return httpError } +func errorCodeToMessage(code string) string { + // https://docs.github.com/en/rest/overview/resources-in-the-rest-api#client-errors + switch code { + case "missing", "missing_field": + return "is missing" + case "invalid", "unprocessable": + return "is invalid" + case "already_exists": + return "already exists" + default: + return code + } +} + var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`) func inspectableMIMEType(t string) bool { diff --git a/api/client_test.go b/api/client_test.go index 063d4648c..d2925bd20 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -76,10 +76,19 @@ func TestRESTGetDelete(t *testing.T) { } func TestRESTError(t *testing.T) { - http := &httpmock.Registry{} - client := NewClient(ReplaceTripper(http)) + fakehttp := &httpmock.Registry{} + client := NewClient(ReplaceTripper(fakehttp)) - http.StubResponse(422, bytes.NewBufferString(`{"message": "OH NO"}`)) + fakehttp.Register(httpmock.MatchAny, func(req *http.Request) (*http.Response, error) { + return &http.Response{ + Request: req, + StatusCode: 422, + Body: ioutil.NopCloser(bytes.NewBufferString(`{"message": "OH NO"}`)), + Header: map[string][]string{ + "Content-Type": {"application/json; charset=utf-8"}, + }, + }, nil + }) var httpErr HTTPError err := client.REST("github.com", "DELETE", "repos/branch", nil, nil) diff --git a/api/queries_pr.go b/api/queries_pr.go index 18be5e1fa..4e93cb293 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -7,6 +7,7 @@ import ( "io" "net/http" "strings" + "time" "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/internal/ghrepo" @@ -72,12 +73,19 @@ type PullRequest struct { TotalCount int Nodes []struct { Commit struct { + Oid string StatusCheckRollup struct { Contexts struct { Nodes []struct { - State string - Status string - Conclusion string + Name string + Context string + State string + Status string + Conclusion string + StartedAt time.Time + CompletedAt time.Time + DetailsURL string + TargetURL string } } } @@ -227,7 +235,7 @@ func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.Rea if resp.StatusCode == 404 { return nil, &NotFoundError{errors.New("pull request not found")} } else if resp.StatusCode != 200 { - return nil, handleHTTPError(resp) + return nil, HandleHTTPError(resp) } return resp.Body, nil @@ -275,8 +283,8 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu state } ...on CheckRun { - status conclusion + status } } } @@ -418,8 +426,32 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu author { login } - commits { + commits(last: 1) { totalCount + nodes { + commit { + oid + statusCheckRollup { + contexts(last: 100) { + nodes { + ...on StatusContext { + context + state + targetUrl + } + ...on CheckRun { + name + status + conclusion + startedAt + completedAt + detailsUrl + } + } + } + } + } + } } baseRefName headRefName @@ -524,8 +556,32 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea author { login } - commits { + commits(last: 1) { totalCount + nodes { + commit { + oid + statusCheckRollup { + contexts(last: 100) { + nodes { + ...on StatusContext { + context + state + targetUrl + } + ...on CheckRun { + name + status + conclusion + startedAt + completedAt + detailsUrl + } + } + } + } + } + } } url baseRefName @@ -981,11 +1037,18 @@ func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m } `graphql:"mergePullRequest(input: $input)"` } + input := githubv4.MergePullRequestInput{ + PullRequestID: pr.ID, + MergeMethod: &mergeMethod, + } + + if m == PullRequestMergeMethodSquash { + commitHeadline := githubv4.String(fmt.Sprintf("%s (#%d)", pr.Title, pr.Number)) + input.CommitHeadline = &commitHeadline + } + variables := map[string]interface{}{ - "input": githubv4.MergePullRequestInput{ - PullRequestID: pr.ID, - MergeMethod: &mergeMethod, - }, + "input": input, } gql := graphQLClient(client.http, repo.RepoHost()) diff --git a/api/queries_repo.go b/api/queries_repo.go index 5a9fd587e..e9d8534a5 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "sort" "strings" "time" @@ -127,6 +128,19 @@ func RepoDefaultBranch(client *Client, repo ghrepo.Interface) (string, error) { return r.DefaultBranchRef.Name, nil } +func CanPushToRepo(httpClient *http.Client, repo ghrepo.Interface) (bool, error) { + if r, ok := repo.(*Repository); ok && r.ViewerPermission != "" { + return r.ViewerCanPush(), nil + } + + apiClient := NewClientFromHTTP(httpClient) + r, err := GitHubRepo(apiClient, repo) + if err != nil { + return false, err + } + return r.ViewerCanPush(), nil +} + // RepoParent finds out the parent repository of a fork func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error) { var query struct { diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 6920b8cc0..71b5fd149 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -50,6 +50,20 @@ func main() { rootCmd := root.NewCmdRoot(cmdFactory, command.Version, command.BuildDate) + cfg, err := cmdFactory.Config() + if err != nil { + fmt.Fprintf(stderr, "failed to read configuration: %s\n", err) + os.Exit(2) + } + + if prompt, _ := cfg.Get("", "prompt"); prompt == config.PromptsDisabled { + cmdFactory.IOStreams.SetNeverPrompt(true) + } + + if pager, _ := cfg.Get("", "pager"); pager != "" { + cmdFactory.IOStreams.SetPager(pager) + } + expandedArgs := []string{} if len(os.Args) > 0 { expandedArgs = os.Args[1:] @@ -60,12 +74,6 @@ func main() { originalArgs := expandedArgs isShell := false - cfg, err := cmdFactory.Config() - if err != nil { - fmt.Fprintf(stderr, "failed to read configuration: %s\n", err) - os.Exit(2) - } - expandedArgs, isShell, err = expand.ExpandAlias(cfg, os.Args, nil) if err != nil { fmt.Fprintf(stderr, "failed to process aliases: %s\n", err) @@ -97,23 +105,11 @@ func main() { } } - authCheckEnabled := cmdutil.IsAuthCheckEnabled(cmd) - - // TODO support other names - ghtoken := os.Getenv("GITHUB_TOKEN") - if ghtoken != "" { - authCheckEnabled = false - } - + authCheckEnabled := os.Getenv("GITHUB_TOKEN") == "" && + os.Getenv("GITHUB_ENTERPRISE_TOKEN") == "" && + cmd != nil && cmdutil.IsAuthCheckEnabled(cmd) if authCheckEnabled { - hasAuth := false - - cfg, err := cmdFactory.Config() - if err == nil { - hasAuth = cmdutil.CheckAuth(cfg) - } - - if !hasAuth { + if !cmdutil.CheckAuth(cfg) { fmt.Fprintln(stderr, utils.Bold("Welcome to GitHub CLI!")) fmt.Fprintln(stderr) fmt.Fprintln(stderr, "To authenticate, please run `gh auth login`.") diff --git a/docs/install_linux.md b/docs/install_linux.md new file mode 100644 index 000000000..4a21c0094 --- /dev/null +++ b/docs/install_linux.md @@ -0,0 +1,95 @@ +# Installing gh on Linux + +Packages downloaded from https://cli.github.com or from https://github.com/cli/cli/releases +are considered official binaries. We focus on a couple of popular Linux distros and +the following CPU architectures: `i386`, `amd64`, `arm64`. + +Other sources for installation are community-maintained and thus might lag behind +our release schedule. + +## Official sources + +### Debian, Ubuntu 20.04 Linux (apt) + +Install: + +```bash +sudo apt-key adv --keyserver keyserver.ubuntu.com --recv-key C99B11DEB97541F0 +sudo apt-add-repository -u https://cli.github.com/packages +sudo apt install gh +``` + +Upgrade: + +```bash +sudo apt update +sudo apt install gh +``` + +### Fedora, Centos, Red Hat Linux (dnf) + +Install: + +```bash +sudo dnf config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo +sudo dnf install gh +``` + +Upgrade: + +```bash +sudo dnf install gh +``` + +### openSUSE/SUSE Linux (zypper) + +Install: + +```bash +sudo zypper addrepo https://cli.github.com/packages/rpm/gh-cli.repo +sudo zypper ref +sudo zypper install gh +``` + +Upgrade: + +```bash +sudo zypper ref +sudo zypper update gh +``` + +## Manual installation + +* [Download release binaries][releases page] that match your platform; or +* [Build from source](./source.md). + +### openSUSE/SUSE Linux (zypper) + +Install and upgrade: + +1. Download the `.rpm` file from the [releases page][]; +2. Install the downloaded file: `sudo zypper in gh_*_linux_amd64.rpm` + +## Community-supported methods + +Our team does do not directly maintain the following packages or repositories. + +### Arch Linux + +Arch Linux users can install from the [community repo][arch linux repo]: + +```bash +pacman -S github-cli +``` + +### Android + +Android users can install via Termux: + +```bash +pkg install gh +``` + + +[releases page]: https://github.com/cli/cli/releases/latest +[arch linux repo]: https://www.archlinux.org/packages/community/x86_64/github-cli diff --git a/git/url.go b/git/url.go index 51938f934..7d4aac4d7 100644 --- a/git/url.go +++ b/git/url.go @@ -2,24 +2,35 @@ package git import ( "net/url" - "regexp" "strings" ) -var ( - protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://") -) - func IsURL(u string) bool { - return strings.HasPrefix(u, "git@") || protocolRe.MatchString(u) + return strings.HasPrefix(u, "git@") || isSupportedProtocol(u) +} + +func isSupportedProtocol(u string) bool { + return strings.HasPrefix(u, "ssh:") || + strings.HasPrefix(u, "git+ssh:") || + strings.HasPrefix(u, "git:") || + strings.HasPrefix(u, "http:") || + strings.HasPrefix(u, "https:") +} + +func isPossibleProtocol(u string) bool { + return isSupportedProtocol(u) || + strings.HasPrefix(u, "ftp:") || + strings.HasPrefix(u, "ftps:") || + strings.HasPrefix(u, "file:") } // ParseURL normalizes git remote urls func ParseURL(rawURL string) (u *url.URL, err error) { - if !protocolRe.MatchString(rawURL) && - strings.Contains(rawURL, ":") && + if !isPossibleProtocol(rawURL) && + strings.ContainsRune(rawURL, ':') && // not a Windows path - !strings.Contains(rawURL, "\\") { + !strings.ContainsRune(rawURL, '\\') { + // support scp-like syntax for ssh protocol rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1) } diff --git a/git/url_test.go b/git/url_test.go new file mode 100644 index 000000000..679739b41 --- /dev/null +++ b/git/url_test.go @@ -0,0 +1,195 @@ +package git + +import "testing" + +func TestIsURL(t *testing.T) { + tests := []struct { + name string + url string + want bool + }{ + { + name: "scp-like", + url: "git@example.com:owner/repo", + want: true, + }, + { + name: "scp-like with no user", + url: "example.com:owner/repo", + want: false, + }, + { + name: "ssh", + url: "ssh://git@example.com/owner/repo", + want: true, + }, + { + name: "git", + url: "git://example.com/owner/repo", + want: true, + }, + { + name: "https", + url: "https://example.com/owner/repo.git", + want: true, + }, + { + name: "no protocol", + url: "example.com/owner/repo", + want: false, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := IsURL(tt.url); got != tt.want { + t.Errorf("IsURL() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestParseURL(t *testing.T) { + type url struct { + Scheme string + User string + Host string + Path string + } + tests := []struct { + name string + url string + want url + wantErr bool + }{ + { + name: "HTTPS", + url: "https://example.com/owner/repo.git", + want: url{ + Scheme: "https", + User: "", + Host: "example.com", + Path: "/owner/repo.git", + }, + }, + { + name: "HTTP", + url: "http://example.com/owner/repo.git", + want: url{ + Scheme: "http", + User: "", + Host: "example.com", + Path: "/owner/repo.git", + }, + }, + { + name: "git", + url: "git://example.com/owner/repo.git", + want: url{ + Scheme: "git", + User: "", + Host: "example.com", + Path: "/owner/repo.git", + }, + }, + { + name: "ssh", + url: "ssh://git@example.com/owner/repo.git", + want: url{ + Scheme: "ssh", + User: "git", + Host: "example.com", + Path: "/owner/repo.git", + }, + }, + { + name: "ssh with port", + url: "ssh://git@example.com:443/owner/repo.git", + want: url{ + Scheme: "ssh", + User: "git", + Host: "example.com", + Path: "/owner/repo.git", + }, + }, + { + name: "git+ssh", + url: "git+ssh://example.com/owner/repo.git", + want: url{ + Scheme: "ssh", + User: "", + Host: "example.com", + Path: "/owner/repo.git", + }, + }, + { + name: "scp-like", + url: "git@example.com:owner/repo.git", + want: url{ + Scheme: "ssh", + User: "git", + Host: "example.com", + Path: "/owner/repo.git", + }, + }, + { + name: "scp-like, leading slash", + url: "git@example.com:/owner/repo.git", + want: url{ + Scheme: "ssh", + User: "git", + Host: "example.com", + Path: "/owner/repo.git", + }, + }, + { + name: "file protocol", + url: "file:///example.com/owner/repo.git", + want: url{ + Scheme: "file", + User: "", + Host: "", + Path: "/example.com/owner/repo.git", + }, + }, + { + name: "file path", + url: "/example.com/owner/repo.git", + want: url{ + Scheme: "", + User: "", + Host: "", + Path: "/example.com/owner/repo.git", + }, + }, + { + name: "Windows file path", + url: "C:\\example.com\\owner\\repo.git", + want: url{ + Scheme: "c", + User: "", + Host: "", + Path: "", + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + u, err := ParseURL(tt.url) + if (err != nil) != tt.wantErr { + t.Fatalf("got error: %v", err) + } + if u.Scheme != tt.want.Scheme { + t.Errorf("expected scheme %q, got %q", tt.want.Scheme, u.Scheme) + } + if u.User.Username() != tt.want.User { + t.Errorf("expected user %q, got %q", tt.want.User, u.User.Username()) + } + if u.Host != tt.want.Host { + t.Errorf("expected host %q, got %q", tt.want.Host, u.Host) + } + if u.Path != tt.want.Path { + t.Errorf("expected path %q, got %q", tt.want.Path, u.Path) + } + }) + } +} diff --git a/go.mod b/go.mod index 7f3b3a628..47afd50ae 100644 --- a/go.mod +++ b/go.mod @@ -3,28 +3,30 @@ module github.com/cli/cli go 1.13 require ( - github.com/AlecAivazis/survey/v2 v2.0.7 + github.com/AlecAivazis/survey/v2 v2.1.1 github.com/MakeNowJust/heredoc v1.0.0 github.com/briandowns/spinner v1.11.1 github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684 - github.com/google/go-cmp v0.4.1 + github.com/google/go-cmp v0.5.2 github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 - github.com/hashicorp/go-version v1.2.0 - github.com/henvic/httpretty v0.0.5 + github.com/hashicorp/go-version v1.2.1 + github.com/henvic/httpretty v0.0.6 github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 - github.com/mattn/go-colorable v0.1.6 + github.com/mattn/go-colorable v0.1.7 github.com/mattn/go-isatty v0.0.12 - github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b + github.com/mattn/go-runewidth v0.0.9 + github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d github.com/mitchellh/go-homedir v1.1.0 - github.com/shurcooL/githubv4 v0.0.0-20200627185320-e003124d66e4 + github.com/rivo/uniseg v0.1.0 + github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5 github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f github.com/spf13/cobra v1.0.0 github.com/spf13/pflag v1.0.5 - github.com/stretchr/testify v1.5.1 - golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 - golang.org/x/text v0.3.2 + github.com/stretchr/testify v1.6.1 + golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a + golang.org/x/text v0.3.3 // indirect gopkg.in/yaml.v2 v2.2.8 // indirect - gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86 + gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 ) replace github.com/shurcooL/graphql => github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e diff --git a/go.sum b/go.sum index 00b851929..f9f02eabe 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,6 @@ cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw= -github.com/AlecAivazis/survey/v2 v2.0.7 h1:+f825XHLse/hWd2tE/V5df04WFGimk34Eyg/z35w/rc= -github.com/AlecAivazis/survey/v2 v2.0.7/go.mod h1:mlizQTaPjnR4jcpwRSaSlkbsRfYFEyKgLQvYTzxxiHA= +github.com/AlecAivazis/survey/v2 v2.1.1 h1:LEMbHE0pLj75faaVEKClEX1TM4AJmmnOh9eimREzLWI= +github.com/AlecAivazis/survey/v2 v2.1.1/go.mod h1:9FJRdMdDm8rnT+zHVbvQT2RTSTLq0Ttd6q3Vl2fahjk= github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ= github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU= github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ= @@ -65,8 +65,8 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ= github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= -github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0= -github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM= +github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4= github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4= github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= @@ -76,11 +76,11 @@ github.com/graph-gophers/graphql-go v0.0.0-20200622220639-c1d9693c95a6/go.mod h1 github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs= github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk= github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY= -github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E= -github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= +github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI= +github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA= github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ= -github.com/henvic/httpretty v0.0.5 h1:XmOHN7HHt+ZhlLNzsMC54yncJDybipkP5NHOGVBOr1s= -github.com/henvic/httpretty v0.0.5/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= +github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs= +github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo= github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ= github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A= github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= @@ -107,6 +107,8 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE= github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE= github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= +github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw= +github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc= github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s= github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY= github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU= @@ -117,6 +119,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4= github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI= +github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE= github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s= github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc= github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y= @@ -147,13 +151,15 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8 github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk= github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA= github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU= +github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= +github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= -github.com/shurcooL/githubv4 v0.0.0-20200627185320-e003124d66e4 h1:cjmR6xY0f89IwBYMSwUrkFs4/1+KKw30Df3SqT7nZ6Q= -github.com/shurcooL/githubv4 v0.0.0-20200627185320-e003124d66e4/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= +github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5 h1:CA6Mjshr+g5YHENwllpQNR0UaYO7VGKo6TzJLM64WJQ= +github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo= github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo= github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo= @@ -176,8 +182,8 @@ github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= -github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4= -github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0= +github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U= github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc= github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU= @@ -194,6 +200,8 @@ golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 h1:8dUaAV7K4uHsF56JQWkpre golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI= golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM= +golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc= golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= @@ -227,8 +235,8 @@ golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HX golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= -golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs= -golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= @@ -253,6 +261,7 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10= gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= -gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86 h1:OfFoIUYv/me30yv7XlMy4F9RJw8DEm8WQ6QG1Ph4bH0= -gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= +gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4= diff --git a/internal/config/config_setup.go b/internal/authflow/flow.go similarity index 85% rename from internal/config/config_setup.go rename to internal/authflow/flow.go index 649e105b7..1c804931f 100644 --- a/internal/config/config_setup.go +++ b/internal/authflow/flow.go @@ -1,4 +1,4 @@ -package config +package authflow import ( "bufio" @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/auth" + "github.com/cli/cli/internal/config" "github.com/cli/cli/pkg/browser" "github.com/cli/cli/utils" "github.com/mattn/go-colorable" @@ -22,14 +23,9 @@ var ( oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b" ) -// IsGitHubApp reports whether an OAuth app is "GitHub CLI" or "GitHub CLI (dev)" -func IsGitHubApp(id string) bool { - // this intentionally doesn't use `oauthClientID` because that is a variable - // that can potentially be changed at build time via GH_OAUTH_CLIENT_ID - return id == "178c6fc778ccc68e1d6a" || id == "4d747ba5675d5d66553f" -} - -func AuthFlowWithConfig(cfg Config, hostname, notice string, additionalScopes []string) (string, error) { +func AuthFlowWithConfig(cfg config.Config, hostname, notice string, additionalScopes []string) (string, error) { + // TODO this probably shouldn't live in this package. It should probably be in a new package that + // depends on both iostreams and config. stderr := colorable.NewColorableStderr() token, userLogin, err := authFlow(hostname, stderr, notice, additionalScopes) diff --git a/internal/config/config_success.go b/internal/authflow/success.go similarity index 98% rename from internal/config/config_success.go rename to internal/authflow/success.go index c7a681bac..a546e58d7 100644 --- a/internal/config/config_success.go +++ b/internal/authflow/success.go @@ -1,4 +1,4 @@ -package config +package authflow const oauthSuccessPage = ` diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go index 2d7fd4b73..f40cb9097 100644 --- a/internal/config/config_file_test.go +++ b/internal/config/config_file_test.go @@ -110,14 +110,14 @@ github.com: _, err := ParseConfig("config.yml") assert.Nil(t, err) - expectedMain := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor:\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n" expectedHosts := `github.com: user: keiyuri oauth_token: "123456" ` - assert.Equal(t, expectedMain, mainBuf.String()) assert.Equal(t, expectedHosts, hostsBuf.String()) + assert.NotContains(t, mainBuf.String(), "github.com") + assert.NotContains(t, mainBuf.String(), "oauth_token") } func Test_parseConfigFile(t *testing.T) { diff --git a/internal/config/config_type.go b/internal/config/config_type.go index 28d91f9b0..2ff23e58c 100644 --- a/internal/config/config_type.go +++ b/internal/config/config_type.go @@ -10,15 +10,21 @@ import ( "gopkg.in/yaml.v3" ) -const defaultGitProtocol = "https" +const ( + defaultGitProtocol = "https" + PromptsDisabled = "disabled" + PromptsEnabled = "enabled" +) // This interface describes interacting with some persistent configuration for gh. type Config interface { Get(string, string) (string, error) + GetWithSource(string, string) (string, string, error) Set(string, string, string) error UnsetHost(string) Hosts() ([]string, error) Aliases() (*AliasConfig, error) + CheckWriteable(string, string) error Write() error } @@ -165,6 +171,24 @@ func NewBlankRoot() *yaml.Node { Kind: yaml.ScalarNode, Value: "", }, + { + HeadComment: "When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled", + Kind: yaml.ScalarNode, + Value: "prompt", + }, + { + Kind: yaml.ScalarNode, + Value: PromptsEnabled, + }, + { + HeadComment: "A pager program to send command output to. Example value: less", + Kind: yaml.ScalarNode, + Value: "pager", + }, + { + Kind: yaml.ScalarNode, + Value: "", + }, { HeadComment: "Aliases allow you to create nicknames for gh commands", Kind: yaml.ScalarNode, @@ -200,42 +224,51 @@ func (c *fileConfig) Root() *yaml.Node { } func (c *fileConfig) Get(hostname, key string) (string, error) { + val, _, err := c.GetWithSource(hostname, key) + return val, err +} + +func (c *fileConfig) GetWithSource(hostname, key string) (string, string, error) { if hostname != "" { var notFound *NotFoundError hostCfg, err := c.configForHost(hostname) if err != nil && !errors.As(err, ¬Found) { - return "", err + return "", "", err } var hostValue string if hostCfg != nil { hostValue, err = hostCfg.GetStringValue(key) if err != nil && !errors.As(err, ¬Found) { - return "", err + return "", "", err } } if hostValue != "" { - return hostValue, nil + // TODO: avoid hardcoding this + return hostValue, "~/.config/gh/hosts.yml", nil } } + // TODO: avoid hardcoding this + defaultSource := "~/.config/gh/config.yml" + value, err := c.GetStringValue(key) var notFound *NotFoundError if err != nil && errors.As(err, ¬Found) { - return defaultFor(key), nil + return defaultFor(key), defaultSource, nil } else if err != nil { - return "", err + return "", defaultSource, err } if value == "" { - return defaultFor(key), nil + return defaultFor(key), defaultSource, nil } - return value, nil + return value, defaultSource, nil } func (c *fileConfig) Set(hostname, key, value string) error { @@ -281,6 +314,11 @@ func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) { return nil, &NotFoundError{fmt.Errorf("could not find config entry for %q", hostname)} } +func (c *fileConfig) CheckWriteable(hostname, key string) error { + // TODO: check filesystem permissions + return nil +} + func (c *fileConfig) Write() error { mainData := yaml.Node{Kind: yaml.MappingNode} hostsData := yaml.Node{Kind: yaml.MappingNode} @@ -460,6 +498,8 @@ func defaultFor(key string) string { switch key { case "git_protocol": return defaultGitProtocol + case "prompt": + return PromptsEnabled default: return "" } diff --git a/internal/config/config_type_test.go b/internal/config/config_type_test.go index df33effa3..50e2b9f5a 100644 --- a/internal/config/config_type_test.go +++ b/internal/config/config_type_test.go @@ -4,6 +4,7 @@ import ( "bytes" "testing" + "github.com/MakeNowJust/heredoc" "github.com/stretchr/testify/assert" ) @@ -19,8 +20,8 @@ func Test_fileConfig_Set(t *testing.T) { assert.NoError(t, c.Set("github.com", "user", "hubot")) assert.NoError(t, c.Write()) - expected := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor: nano\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n" - assert.Equal(t, expected, mainBuf.String()) + assert.Contains(t, mainBuf.String(), "editor: nano") + assert.Contains(t, mainBuf.String(), "git_protocol: https") assert.Equal(t, `github.com: git_protocol: ssh user: hubot @@ -37,7 +38,19 @@ func Test_defaultConfig(t *testing.T) { cfg := NewBlankConfig() assert.NoError(t, cfg.Write()) - expected := "# What protocol to use when performing git operations. Supported values: ssh, https\ngit_protocol: https\n# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.\neditor:\n# Aliases allow you to create nicknames for gh commands\naliases:\n co: pr checkout\n" + expected := heredoc.Doc(` + # What protocol to use when performing git operations. Supported values: ssh, https + git_protocol: https + # What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment. + editor: + # When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled + prompt: enabled + # A pager program to send command output to. Example value: less + pager: + # Aliases allow you to create nicknames for gh commands + aliases: + co: pr checkout + `) assert.Equal(t, expected, mainBuf.String()) assert.Equal(t, "", hostsBuf.String()) diff --git a/internal/config/from_env.go b/internal/config/from_env.go new file mode 100644 index 000000000..fa930da06 --- /dev/null +++ b/internal/config/from_env.go @@ -0,0 +1,71 @@ +package config + +import ( + "fmt" + "os" + + "github.com/cli/cli/internal/ghinstance" +) + +const ( + GITHUB_TOKEN = "GITHUB_TOKEN" + GITHUB_ENTERPRISE_TOKEN = "GITHUB_ENTERPRISE_TOKEN" +) + +func InheritEnv(c Config) Config { + return &envConfig{Config: c} +} + +type envConfig struct { + Config +} + +func (c *envConfig) Hosts() ([]string, error) { + hasDefault := false + hosts, err := c.Config.Hosts() + for _, h := range hosts { + if h == ghinstance.Default() { + hasDefault = true + } + } + if (err != nil || !hasDefault) && os.Getenv(GITHUB_TOKEN) != "" { + hosts = append([]string{ghinstance.Default()}, hosts...) + return hosts, nil + } + return hosts, err +} + +func (c *envConfig) Get(hostname, key string) (string, error) { + val, _, err := c.GetWithSource(hostname, key) + return val, err +} + +func (c *envConfig) GetWithSource(hostname, key string) (string, string, error) { + if hostname != "" && key == "oauth_token" { + envName := GITHUB_TOKEN + if ghinstance.IsEnterprise(hostname) { + envName = GITHUB_ENTERPRISE_TOKEN + } + + if value := os.Getenv(envName); value != "" { + return value, envName, nil + } + } + + return c.Config.GetWithSource(hostname, key) +} + +func (c *envConfig) CheckWriteable(hostname, key string) error { + if hostname != "" && key == "oauth_token" { + envName := GITHUB_TOKEN + if ghinstance.IsEnterprise(hostname) { + envName = GITHUB_ENTERPRISE_TOKEN + } + + if os.Getenv(envName) != "" { + return fmt.Errorf("read-only token in %s cannot be modified", envName) + } + } + + return c.Config.CheckWriteable(hostname, key) +} diff --git a/internal/config/from_env_test.go b/internal/config/from_env_test.go new file mode 100644 index 000000000..412d37248 --- /dev/null +++ b/internal/config/from_env_test.go @@ -0,0 +1,160 @@ +package config + +import ( + "os" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/stretchr/testify/assert" +) + +func TestInheritEnv(t *testing.T) { + orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN") + orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN") + t.Cleanup(func() { + os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN) + os.Setenv("GITHUB_ENTERPRISE_TOKEN", orig_GITHUB_ENTERPRISE_TOKEN) + }) + + type wants struct { + hosts []string + token string + source string + writeable bool + } + + tests := []struct { + name string + baseConfig string + GITHUB_TOKEN string + GITHUB_ENTERPRISE_TOKEN string + hostname string + wants wants + }{ + { + name: "blank", + baseConfig: ``, + GITHUB_TOKEN: "", + GITHUB_ENTERPRISE_TOKEN: "", + hostname: "github.com", + wants: wants{ + hosts: []string(nil), + token: "", + source: "~/.config/gh/config.yml", + writeable: true, + }, + }, + { + name: "GITHUB_TOKEN over blank config", + baseConfig: ``, + GITHUB_TOKEN: "OTOKEN", + GITHUB_ENTERPRISE_TOKEN: "", + hostname: "github.com", + wants: wants{ + hosts: []string{"github.com"}, + token: "OTOKEN", + source: "GITHUB_TOKEN", + writeable: false, + }, + }, + { + name: "GITHUB_TOKEN not applicable to GHE", + baseConfig: ``, + GITHUB_TOKEN: "OTOKEN", + GITHUB_ENTERPRISE_TOKEN: "", + hostname: "example.org", + wants: wants{ + hosts: []string{"github.com"}, + token: "", + source: "~/.config/gh/config.yml", + writeable: true, + }, + }, + { + name: "GITHUB_ENTERPRISE_TOKEN over blank config", + baseConfig: ``, + GITHUB_TOKEN: "", + GITHUB_ENTERPRISE_TOKEN: "ENTOKEN", + hostname: "example.org", + wants: wants{ + hosts: []string(nil), + token: "ENTOKEN", + source: "GITHUB_ENTERPRISE_TOKEN", + writeable: false, + }, + }, + { + name: "token from file", + baseConfig: heredoc.Doc(` + hosts: + github.com: + oauth_token: OTOKEN + `), + GITHUB_TOKEN: "", + GITHUB_ENTERPRISE_TOKEN: "", + hostname: "github.com", + wants: wants{ + hosts: []string{"github.com"}, + token: "OTOKEN", + source: "~/.config/gh/hosts.yml", + writeable: true, + }, + }, + { + name: "GITHUB_TOKEN shadows token from file", + baseConfig: heredoc.Doc(` + hosts: + github.com: + oauth_token: OTOKEN + `), + GITHUB_TOKEN: "ENVTOKEN", + GITHUB_ENTERPRISE_TOKEN: "", + hostname: "github.com", + wants: wants{ + hosts: []string{"github.com"}, + token: "ENVTOKEN", + source: "GITHUB_TOKEN", + writeable: false, + }, + }, + { + name: "GITHUB_TOKEN adds host entry", + baseConfig: heredoc.Doc(` + hosts: + example.org: + oauth_token: OTOKEN + `), + GITHUB_TOKEN: "ENVTOKEN", + GITHUB_ENTERPRISE_TOKEN: "", + hostname: "github.com", + wants: wants{ + hosts: []string{"github.com", "example.org"}, + token: "ENVTOKEN", + source: "GITHUB_TOKEN", + writeable: false, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + os.Setenv("GITHUB_TOKEN", tt.GITHUB_TOKEN) + os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN) + + baseCfg := NewFromString(tt.baseConfig) + cfg := InheritEnv(baseCfg) + + hosts, _ := cfg.Hosts() + assert.Equal(t, tt.wants.hosts, hosts) + + val, source, _ := cfg.GetWithSource(tt.hostname, "oauth_token") + assert.Equal(t, tt.wants.token, val) + assert.Equal(t, tt.wants.source, source) + + val, _ = cfg.Get(tt.hostname, "oauth_token") + assert.Equal(t, tt.wants.token, val) + + err := cfg.CheckWriteable(tt.hostname, "oauth_token") + assert.Equal(t, tt.wants.writeable, err == nil) + }) + } +} diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go index 74fc44ea8..9ec4de435 100644 --- a/pkg/cmd/api/api.go +++ b/pkg/cmd/api/api.go @@ -13,6 +13,7 @@ import ( "sort" "strconv" "strings" + "syscall" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/ghinstance" @@ -117,7 +118,9 @@ original query accepts an '$endCursor: String' variable and that it fetches the `), Annotations: map[string]string{ "help:environment": heredoc.Doc(` - GITHUB_TOKEN: an authentication token for API requests. + GITHUB_TOKEN: an authentication token for github.com API requests. + + GITHUB_ENTERPRISE_TOKEN: an authentication token for API requests to GitHub Enterprise. `), }, Args: cobra.ExactArgs(1), @@ -194,6 +197,12 @@ func apiRun(opts *ApiOptions) error { headersOutputStream := opts.IO.Out if opts.Silent { opts.IO.Out = ioutil.Discard + } else { + err := opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() } host := ghinstance.OverridableDefault() @@ -263,12 +272,13 @@ func processResponse(resp *http.Response, opts *ApiOptions, headersOutputStream if isJSON && opts.IO.ColorEnabled() { err = jsoncolor.Write(opts.IO.Out, responseBody, " ") - if err != nil { - return - } } else { _, err = io.Copy(opts.IO.Out, responseBody) - if err != nil { + } + if err != nil { + if errors.Is(err, syscall.EPIPE) { + err = nil + } else { return } } diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go index 07e64a6dc..b6d2a2f99 100644 --- a/pkg/cmd/auth/login/login.go +++ b/pkg/cmd/auth/login/login.go @@ -4,12 +4,12 @@ import ( "errors" "fmt" "io/ioutil" - "os" "strings" "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" + "github.com/cli/cli/internal/authflow" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghinstance" "github.com/cli/cli/pkg/cmd/auth/client" @@ -24,9 +24,11 @@ type LoginOptions struct { IO *iostreams.IOStreams Config func() (config.Config, error) - Hostname string - Token string - OnlyValidate bool + Interactive bool + + Hostname string + Token string + Web bool } func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Command { @@ -35,6 +37,8 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm Config: f.Config, } + var tokenStdin bool + cmd := &cobra.Command{ Use: "login", Args: cobra.ExactArgs(0), @@ -57,34 +61,25 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm # => read token from mytoken.txt and authenticate against a GitHub Enterprise Server instance `), RunE: func(cmd *cobra.Command, args []string) error { - isTTY := opts.IO.IsStdinTTY() - - // TODO support other ways of naming - ghToken := os.Getenv("GITHUB_TOKEN") - - if !isTTY && (!cmd.Flags().Changed("with-token") && ghToken == "") { - return &cmdutil.FlagError{Err: errors.New("no terminal detected; please use '--with-token' or set GITHUB_TOKEN")} + if !opts.IO.CanPrompt() && !(tokenStdin || opts.Web) { + return &cmdutil.FlagError{Err: errors.New("--web or --with-token required when not running interactively")} } - wt, _ := cmd.Flags().GetBool("with-token") - if wt { + if tokenStdin && opts.Web { + return &cmdutil.FlagError{Err: errors.New("specify only one of --web or --with-token")} + } + + if tokenStdin { defer opts.IO.In.Close() token, err := ioutil.ReadAll(opts.IO.In) if err != nil { - return &cmdutil.FlagError{Err: fmt.Errorf("failed to read token from STDIN: %w", err)} + return fmt.Errorf("failed to read token from STDIN: %w", err) } - opts.Token = strings.TrimSpace(string(token)) - } else if ghToken != "" { - opts.OnlyValidate = true - opts.Token = ghToken } - if opts.Token != "" { - // Assume non-interactive if a token is specified - if opts.Hostname == "" { - opts.Hostname = ghinstance.Default() - } + if opts.IO.CanPrompt() && opts.Token == "" && !opts.Web { + opts.Interactive = true } if cmd.Flags().Changed("hostname") { @@ -93,6 +88,12 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm } } + if !opts.Interactive { + if opts.Hostname == "" { + opts.Hostname = ghinstance.Default() + } + } + if runF != nil { return runF(opts) } @@ -102,7 +103,8 @@ func NewCmdLogin(f *cmdutil.Factory, runF func(*LoginOptions) error) *cobra.Comm } cmd.Flags().StringVarP(&opts.Hostname, "hostname", "h", "", "The hostname of the GitHub instance to authenticate with") - cmd.Flags().Bool("with-token", false, "Read token from standard input") + cmd.Flags().BoolVar(&tokenStdin, "with-token", false, "Read token from standard input") + cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a browser to authenticate") return cmd } @@ -131,10 +133,6 @@ func loginRun(opts *LoginOptions) error { return err } - if opts.OnlyValidate { - return nil - } - return cfg.Write() } @@ -173,7 +171,7 @@ func loginRun(opts *LoginOptions) error { existingToken, _ := cfg.Get(hostname, "oauth_token") - if existingToken != "" { + if existingToken != "" && opts.Interactive { err := client.ValidateHostCfg(hostname, cfg) if err == nil { apiClient, err := client.ClientFromCfg(hostname, cfg) @@ -203,20 +201,28 @@ func loginRun(opts *LoginOptions) error { } } + if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { + return err + } + var authMode int - err = prompt.SurveyAskOne(&survey.Select{ - Message: "How would you like to authenticate?", - Options: []string{ - "Login with a web browser", - "Paste an authentication token", - }, - }, &authMode) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) + if opts.Web { + authMode = 0 + } else { + err = prompt.SurveyAskOne(&survey.Select{ + Message: "How would you like to authenticate?", + Options: []string{ + "Login with a web browser", + "Paste an authentication token", + }, + }, &authMode) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } } if authMode == 0 { - _, err := config.AuthFlowWithConfig(cfg, hostname, "", []string{}) + _, err := authflow.AuthFlowWithConfig(cfg, hostname, "", []string{}) if err != nil { return fmt.Errorf("failed to authenticate via web browser: %w", err) } @@ -248,28 +254,30 @@ func loginRun(opts *LoginOptions) error { } } - var gitProtocol string - err = prompt.SurveyAskOne(&survey.Select{ - Message: "Choose default git protocol", - Options: []string{ - "HTTPS", - "SSH", - }, - }, &gitProtocol) - if err != nil { - return fmt.Errorf("could not prompt: %w", err) + gitProtocol := "https" + if opts.Interactive { + err = prompt.SurveyAskOne(&survey.Select{ + Message: "Choose default git protocol", + Options: []string{ + "HTTPS", + "SSH", + }, + }, &gitProtocol) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + gitProtocol = strings.ToLower(gitProtocol) + + fmt.Fprintf(opts.IO.ErrOut, "- gh config set -h %s git_protocol %s\n", hostname, gitProtocol) + err = cfg.Set(hostname, "git_protocol", gitProtocol) + if err != nil { + return err + } + + fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", utils.GreenCheck()) } - gitProtocol = strings.ToLower(gitProtocol) - - fmt.Fprintf(opts.IO.ErrOut, "- gh config set -h %s git_protocol %s\n", hostname, gitProtocol) - err = cfg.Set(hostname, "git_protocol", gitProtocol) - if err != nil { - return err - } - - fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", utils.GreenCheck()) - apiClient, err := client.ClientFromCfg(hostname, cfg) if err != nil { return err diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go index 4af891b66..7166eac92 100644 --- a/pkg/cmd/auth/login/login_test.go +++ b/pkg/cmd/auth/login/login_test.go @@ -3,7 +3,6 @@ package login import ( "bytes" "net/http" - "os" "regexp" "testing" @@ -26,7 +25,6 @@ func Test_NewCmdLogin(t *testing.T) { stdinTTY bool wants LoginOptions wantsErr bool - ghtoken string }{ { name: "nontty, with-token", @@ -49,11 +47,13 @@ func Test_NewCmdLogin(t *testing.T) { }, { name: "nontty, hostname", + stdinTTY: false, cli: "--hostname claire.redfield", wantsErr: true, }, { name: "nontty", + stdinTTY: false, cli: "", wantsErr: true, }, @@ -81,8 +81,9 @@ func Test_NewCmdLogin(t *testing.T) { stdinTTY: true, cli: "--hostname barry.burton", wants: LoginOptions{ - Hostname: "barry.burton", - Token: "", + Hostname: "barry.burton", + Token: "", + Interactive: true, }, }, { @@ -90,46 +91,43 @@ func Test_NewCmdLogin(t *testing.T) { stdinTTY: true, cli: "", wants: LoginOptions{ - Hostname: "", - Token: "", + Hostname: "", + Token: "", + Interactive: true, }, }, { - name: "tty, GITHUB_TOKEN", + name: "tty web", stdinTTY: true, - cli: "", - ghtoken: "abc123", + cli: "--web", wants: LoginOptions{ - Hostname: "github.com", - Token: "abc123", - OnlyValidate: true, + Hostname: "github.com", + Web: true, }, }, { - name: "nontty, GITHUB_TOKEN", - stdinTTY: false, - cli: "", - ghtoken: "abc123", + name: "nontty web", + cli: "--web", wants: LoginOptions{ - Hostname: "github.com", - Token: "abc123", - OnlyValidate: true, + Hostname: "github.com", + Web: true, }, }, + { + name: "web and with-token", + cli: "--web --with-token", + wantsErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ghtoken := os.Getenv("GITHUB_TOKEN") - defer func() { - os.Setenv("GITHUB_TOKEN", ghtoken) - }() - os.Setenv("GITHUB_TOKEN", tt.ghtoken) io, stdin, _, _ := iostreams.Test() f := &cmdutil.Factory{ IOStreams: io, } + io.SetStdoutTTY(true) io.SetStdinTTY(tt.stdinTTY) if tt.stdin != "" { stdin.WriteString(tt.stdin) @@ -160,6 +158,8 @@ func Test_NewCmdLogin(t *testing.T) { assert.Equal(t, tt.wants.Token, gotOpts.Token) assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) + assert.Equal(t, tt.wants.Web, gotOpts.Web) + assert.Equal(t, tt.wants.Interactive, gotOpts.Interactive) }) } } @@ -288,6 +288,9 @@ func Test_loginRun_Survey(t *testing.T) { }{ { name: "already authenticated", + opts: &LoginOptions{ + Interactive: true, + }, cfg: func(cfg config.Config) { _ = cfg.Set("github.com", "oauth_token", "ghi789") }, @@ -306,7 +309,8 @@ func Test_loginRun_Survey(t *testing.T) { { name: "hostname set", opts: &LoginOptions{ - Hostname: "rebecca.chambers", + Hostname: "rebecca.chambers", + Interactive: true, }, wantHosts: "rebecca.chambers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", askStubs: func(as *prompt.AskStubber) { @@ -324,6 +328,9 @@ func Test_loginRun_Survey(t *testing.T) { { name: "choose enterprise", wantHosts: "brad.vickers:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", + opts: &LoginOptions{ + Interactive: true, + }, askStubs: func(as *prompt.AskStubber) { as.StubOne(1) // host type enterprise as.StubOne("brad.vickers") // hostname @@ -341,6 +348,9 @@ func Test_loginRun_Survey(t *testing.T) { { name: "choose github.com", wantHosts: "github.com:\n oauth_token: def456\n git_protocol: https\n user: jillv\n", + opts: &LoginOptions{ + Interactive: true, + }, askStubs: func(as *prompt.AskStubber) { as.StubOne(0) // host type github.com as.StubOne(1) // auth mode: token @@ -351,6 +361,9 @@ func Test_loginRun_Survey(t *testing.T) { { name: "sets git_protocol", wantHosts: "github.com:\n oauth_token: def456\n git_protocol: ssh\n user: jillv\n", + opts: &LoginOptions{ + Interactive: true, + }, askStubs: func(as *prompt.AskStubber) { as.StubOne(0) // host type github.com as.StubOne(1) // auth mode: token diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go index 6f047a079..78a3bb98f 100644 --- a/pkg/cmd/auth/logout/logout.go +++ b/pkg/cmd/auth/logout/logout.go @@ -4,7 +4,6 @@ import ( "errors" "fmt" "net/http" - "os" "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" @@ -49,6 +48,10 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co # => log out of specified host `), RunE: func(cmd *cobra.Command, args []string) error { + if opts.Hostname == "" && !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")} + } + if runF != nil { return runF(opts) } @@ -63,20 +66,8 @@ func NewCmdLogout(f *cmdutil.Factory, runF func(*LogoutOptions) error) *cobra.Co } func logoutRun(opts *LogoutOptions) error { - if os.Getenv("GITHUB_TOKEN") != "" { - return errors.New("GITHUB_TOKEN is set in your environment. If you no longer want to use it with gh, please unset it.") - } - - isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() - hostname := opts.Hostname - if !isTTY && hostname == "" { - return errors.New("--hostname required when not attached to a terminal") - } - - showConfirm := isTTY && hostname == "" - cfg, err := opts.Config() if err != nil { return err @@ -114,6 +105,10 @@ func logoutRun(opts *LogoutOptions) error { } } + if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { + return err + } + httpClient, err := opts.HttpClient() if err != nil { return err @@ -132,7 +127,7 @@ func logoutRun(opts *LogoutOptions) error { usernameStr = fmt.Sprintf(" account '%s'", username) } - if showConfirm { + if opts.IO.CanPrompt() { var keepGoing bool err := prompt.SurveyAskOne(&survey.Confirm{ Message: fmt.Sprintf("Are you sure you want to log out of %s%s?", hostname, usernameStr), @@ -153,6 +148,8 @@ func logoutRun(opts *LogoutOptions) error { return fmt.Errorf("failed to write config, authentication configuration not updated: %w", err) } + isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + if isTTY { fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s%s\n", utils.GreenCheck(), utils.Bold(hostname), usernameStr) diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go index 50e1bed2a..a9d14cc91 100644 --- a/pkg/cmd/auth/logout/logout_test.go +++ b/pkg/cmd/auth/logout/logout_test.go @@ -3,7 +3,6 @@ package logout import ( "bytes" "net/http" - "os" "regexp" "testing" @@ -18,24 +17,40 @@ import ( func Test_NewCmdLogout(t *testing.T) { tests := []struct { - name string - cli string - wants LogoutOptions + name string + cli string + wants LogoutOptions + wantsErr bool + tty bool }{ { - name: "with hostname", + name: "tty with hostname", + tty: true, cli: "--hostname harry.mason", wants: LogoutOptions{ Hostname: "harry.mason", }, }, { - name: "no arguments", + name: "tty no arguments", + tty: true, cli: "", wants: LogoutOptions{ Hostname: "", }, }, + { + name: "nontty with hostname", + cli: "--hostname harry.mason", + wants: LogoutOptions{ + Hostname: "harry.mason", + }, + }, + { + name: "nontty no arguments", + cli: "", + wantsErr: true, + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -43,6 +58,8 @@ func Test_NewCmdLogout(t *testing.T) { f := &cmdutil.Factory{ IOStreams: io, } + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) argv, err := shlex.Split(tt.cli) assert.NoError(t, err) @@ -61,6 +78,10 @@ func Test_NewCmdLogout(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } assert.NoError(t, err) assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) @@ -186,11 +207,6 @@ func Test_logoutRun_nontty(t *testing.T) { wantErr *regexp.Regexp ghtoken string }{ - { - name: "no arguments", - wantErr: regexp.MustCompile(`hostname required when not`), - opts: &LogoutOptions{}, - }, { name: "hostname, one host", opts: &LogoutOptions{ @@ -213,21 +229,10 @@ func Test_logoutRun_nontty(t *testing.T) { }, wantErr: regexp.MustCompile(`not logged in to any hosts`), }, - { - name: "gh token is set", - opts: &LogoutOptions{}, - ghtoken: "abc123", - wantErr: regexp.MustCompile(`GITHUB_TOKEN is set in your environment`), - }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ghtoken := os.Getenv("GITHUB_TOKEN") - defer func() { - os.Setenv("GITHUB_TOKEN", ghtoken) - }() - os.Setenv("GITHUB_TOKEN", tt.ghtoken) io, _, _, stderr := iostreams.Test() io.SetStdinTTY(false) @@ -256,7 +261,9 @@ func Test_logoutRun_nontty(t *testing.T) { assert.Equal(t, tt.wantErr == nil, err == nil) if err != nil { if tt.wantErr != nil { - assert.True(t, tt.wantErr.MatchString(err.Error())) + if !tt.wantErr.MatchString(err.Error()) { + t.Errorf("got error: %v", err) + } return } else { t.Fatalf("unexpected error: %s", err) diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go index 8a69ea0e2..e2a7829b4 100644 --- a/pkg/cmd/auth/refresh/refresh.go +++ b/pkg/cmd/auth/refresh/refresh.go @@ -1,11 +1,12 @@ package refresh import ( + "errors" "fmt" - "os" "github.com/AlecAivazis/survey/v2" "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/authflow" "github.com/cli/cli/internal/config" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" @@ -27,7 +28,7 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. IO: f.IOStreams, Config: f.Config, AuthFlow: func(cfg config.Config, hostname string, scopes []string) error { - _, err := config.AuthFlowWithConfig(cfg, hostname, "", scopes) + _, err := authflow.AuthFlowWithConfig(cfg, hostname, "", scopes) return err }, } @@ -49,6 +50,17 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. # => open a browser to ensure your authentication credentials have the correct minimum scopes `), RunE: func(cmd *cobra.Command, args []string) error { + isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() + + if !isTTY { + return fmt.Errorf("not attached to a terminal; in headless environments, GITHUB_TOKEN is recommended") + } + + if opts.Hostname == "" && !opts.IO.CanPrompt() { + // here, we know we are attached to a TTY but prompts are disabled + return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")} + } + if runF != nil { return runF(opts) } @@ -64,16 +76,6 @@ func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra. } func refreshRun(opts *RefreshOptions) error { - if os.Getenv("GITHUB_TOKEN") != "" { - return fmt.Errorf("GITHUB_TOKEN is present in your environment and is incompatible with this command. If you'd like to modify a personal access token, see https://github.com/settings/tokens") - } - - isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() - - if !isTTY { - return fmt.Errorf("not attached to a terminal; in headless environments, GITHUB_TOKEN is recommended") - } - cfg, err := opts.Config() if err != nil { return err @@ -112,5 +114,9 @@ func refreshRun(opts *RefreshOptions) error { } } + if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil { + return err + } + return opts.AuthFlow(cfg, hostname, opts.Scopes) } diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go index 3a22d507f..46db2af52 100644 --- a/pkg/cmd/auth/refresh/refresh_test.go +++ b/pkg/cmd/auth/refresh/refresh_test.go @@ -2,7 +2,6 @@ package refresh import ( "bytes" - "os" "regexp" "testing" @@ -15,34 +14,68 @@ import ( "github.com/stretchr/testify/assert" ) +// TODO prompt cfg test + func Test_NewCmdRefresh(t *testing.T) { tests := []struct { - name string - cli string - wants RefreshOptions + name string + cli string + wants RefreshOptions + wantsErr bool + tty bool + neverPrompt bool }{ { - name: "no arguments", + name: "tty no arguments", + tty: true, wants: RefreshOptions{ Hostname: "", }, }, { - name: "hostname", + name: "nontty no arguments", + wantsErr: true, + }, + { + name: "nontty hostname", + cli: "-h aline.cedrac", + wantsErr: true, + }, + { + name: "tty hostname", + tty: true, cli: "-h aline.cedrac", wants: RefreshOptions{ Hostname: "aline.cedrac", }, }, { - name: "one scope", + name: "prompts disabled, no args", + tty: true, + cli: "", + neverPrompt: true, + wantsErr: true, + }, + { + name: "prompts disabled, hostname", + tty: true, + cli: "-h aline.cedrac", + neverPrompt: true, + wants: RefreshOptions{ + Hostname: "aline.cedrac", + }, + }, + { + name: "tty one scope", + tty: true, cli: "--scopes repo:invite", wants: RefreshOptions{ Scopes: []string{"repo:invite"}, }, }, { - name: "scopes", + name: "tty scopes", + tty: true, cli: "--scopes repo:invite,read:public_key", wants: RefreshOptions{ Scopes: []string{"repo:invite", "read:public_key"}, @@ -56,6 +89,9 @@ func Test_NewCmdRefresh(t *testing.T) { f := &cmdutil.Factory{ IOStreams: io, } + io.SetStdinTTY(tt.tty) + io.SetStdoutTTY(tt.tty) + io.SetNeverPrompt(tt.neverPrompt) argv, err := shlex.Split(tt.cli) assert.NoError(t, err) @@ -74,11 +110,14 @@ func Test_NewCmdRefresh(t *testing.T) { cmd.SetErr(&bytes.Buffer{}) _, err = cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } assert.NoError(t, err) assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) assert.Equal(t, tt.wants.Scopes, gotOpts.Scopes) }) - } } @@ -94,22 +133,9 @@ func Test_refreshRun(t *testing.T) { askStubs func(*prompt.AskStubber) cfgHosts []string wantErr *regexp.Regexp - ghtoken string nontty bool wantAuthArgs authArgs }{ - { - name: "GITHUB_TOKEN set", - opts: &RefreshOptions{}, - ghtoken: "abc123", - wantErr: regexp.MustCompile(`GITHUB_TOKEN is present in your environment`), - }, - { - name: "non tty", - opts: &RefreshOptions{}, - nontty: true, - wantErr: regexp.MustCompile(`not attached to a terminal;`), - }, { name: "no hosts configured", opts: &RefreshOptions{}, @@ -193,11 +219,6 @@ func Test_refreshRun(t *testing.T) { return nil } - ghtoken := os.Getenv("GITHUB_TOKEN") - defer func() { - os.Setenv("GITHUB_TOKEN", ghtoken) - }() - os.Setenv("GITHUB_TOKEN", tt.ghtoken) io, _, _, _ := iostreams.Test() io.SetStdinTTY(!tt.nontty) diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go index 09d2f2fde..78b4b074e 100644 --- a/pkg/cmd/auth/status/status.go +++ b/pkg/cmd/auth/status/status.go @@ -4,13 +4,10 @@ import ( "errors" "fmt" "net/http" - "os" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" "github.com/cli/cli/internal/config" - "github.com/cli/cli/internal/ghinstance" - "github.com/cli/cli/pkg/cmd/auth/client" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" "github.com/cli/cli/utils" @@ -21,8 +18,8 @@ type StatusOptions struct { HttpClient func() (*http.Client, error) IO *iostreams.IOStreams Config func() (config.Config, error) - Token string - Hostname string + + Hostname string } func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Command { @@ -42,13 +39,6 @@ func NewCmdStatus(f *cmdutil.Factory, runF func(*StatusOptions) error) *cobra.Co report on any issues. `), RunE: func(cmd *cobra.Command, args []string) error { - // TODO support other names - opts.Token = os.Getenv("GITHUB_TOKEN") - - if opts.Token != "" && opts.Hostname == "" { - opts.Hostname = ghinstance.Default() - } - if runF != nil { return runF(opts) } @@ -72,52 +62,6 @@ func statusRun(opts *StatusOptions) error { stderr := opts.IO.ErrOut - if opts.Token != "" { - hostname := opts.Hostname - err := cfg.Set(opts.Hostname, "oauth_token", opts.Token) - if err != nil { - return err - } - - apiClient, err := client.ClientFromCfg(hostname, cfg) - if err != nil { - return err - } - - err = apiClient.HasMinimumScopes(hostname) - if err != nil { - var missingScopes *api.MissingScopesError - if errors.As(err, &missingScopes) { - fmt.Fprintf(stderr, "%s %s: %s\n", utils.Red("X"), hostname, err) - fmt.Fprintln(stderr, - "The token in GITHUB_TOKEN is valid but missing scopes that gh requires to function.") - } else { - fmt.Fprintf(stderr, "%s %s: authentication failed\n", utils.Red("X"), hostname) - fmt.Fprintln(stderr) - fmt.Fprintf(stderr, - "The token in GITHUB_TOKEN is invalid.\n") - } - fmt.Fprintf(stderr, - "Please visit https://%s/settings/tokens and create a new token with 'repo', 'read:org', and 'gist' scopes.\n", hostname) - return cmdutil.SilentError - } else { - username, err := api.CurrentLoginName(apiClient, hostname) - if err != nil { - return fmt.Errorf("%s %s: api call failed: %s\n", utils.Red("X"), hostname, err) - } - fmt.Fprintf(stderr, - "%s token valid for %s as %s\n", utils.GreenCheck(), hostname, utils.Bold(username)) - proto, _ := cfg.Get(hostname, "git_protocol") - if proto != "" { - fmt.Fprintln(stderr) - fmt.Fprintf(stderr, - "Git operations for %s configured to use %s protocol.\n", hostname, utils.Bold(proto)) - } - } - - return nil - } - statusInfo := map[string][]string{} hostnames, err := cfg.Hosts() @@ -140,6 +84,9 @@ func statusRun(opts *StatusOptions) error { continue } + _, tokenSource, _ := cfg.GetWithSource(hostname, "oauth_token") + tokenIsWriteable := cfg.CheckWriteable(hostname, "oauth_token") == nil + statusInfo[hostname] = []string{} addMsg := func(x string, ys ...interface{}) { statusInfo[hostname] = append(statusInfo[hostname], fmt.Sprintf(x, ys...)) @@ -149,32 +96,36 @@ func statusRun(opts *StatusOptions) error { if err != nil { var missingScopes *api.MissingScopesError if errors.As(err, &missingScopes) { - addMsg("%s %s: %s\n", utils.Red("X"), hostname, err) - addMsg("- To enable the missing scopes, please run %s %s\n", - utils.Bold("gh auth refresh -h"), - utils.Bold(hostname)) + addMsg("%s %s: %s", utils.Red("X"), hostname, err) + if tokenIsWriteable { + addMsg("- To request missing scopes, run: %s %s\n", + utils.Bold("gh auth refresh -h"), + utils.Bold(hostname)) + } } else { - addMsg("%s %s: authentication failed\n", utils.Red("X"), hostname) - addMsg("- The configured token for %s is no longer valid.", utils.Bold(hostname)) - addMsg("- To re-authenticate, please run %s %s", - utils.Bold("gh auth login -h"), utils.Bold(hostname)) - addMsg("- To forget about this host, please run %s %s", - utils.Bold("gh auth logout -h"), utils.Bold(hostname)) + addMsg("%s %s: authentication failed", utils.Red("X"), hostname) + addMsg("- The %s token in %s is no longer valid.", utils.Bold(hostname), tokenSource) + if tokenIsWriteable { + addMsg("- To re-authenticate, run: %s %s", + utils.Bold("gh auth login -h"), utils.Bold(hostname)) + addMsg("- To forget about this host, run: %s %s", + utils.Bold("gh auth logout -h"), utils.Bold(hostname)) + } } failed = true } else { username, err := api.CurrentLoginName(apiClient, hostname) if err != nil { - addMsg("%s %s: api call failed: %s\n", utils.Red("X"), hostname, err) + addMsg("%s %s: api call failed: %s", utils.Red("X"), hostname, err) } - addMsg("%s Logged in to %s as %s", utils.GreenCheck(), hostname, utils.Bold(username)) + addMsg("%s Logged in to %s as %s (%s)", utils.GreenCheck(), hostname, utils.Bold(username), tokenSource) proto, _ := cfg.Get(hostname, "git_protocol") if proto != "" { addMsg("%s Git operations for %s configured to use %s protocol.", utils.GreenCheck(), hostname, utils.Bold(proto)) } - addMsg("") } + addMsg("") // NB we could take this opportunity to add or fix the "user" key in the hosts config. I chose // not to since I wanted this command to be read-only. diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go index 4ab0d1b28..9aabf4fef 100644 --- a/pkg/cmd/auth/status/status_test.go +++ b/pkg/cmd/auth/status/status_test.go @@ -3,7 +3,6 @@ package status import ( "bytes" "net/http" - "os" "regexp" "testing" @@ -19,29 +18,10 @@ import ( func Test_NewCmdStatus(t *testing.T) { tests := []struct { - name string - cli string - wants StatusOptions - ghtoken string + name string + cli string + wants StatusOptions }{ - { - name: "ghtoken set", - cli: "", - wants: StatusOptions{ - Token: "abc123", - Hostname: "github.com", - }, - ghtoken: "abc123", - }, - { - name: "ghtoken set", - cli: "--hostname joel.miller", - wants: StatusOptions{ - Token: "def456", - Hostname: "joel.miller", - }, - ghtoken: "def456", - }, { name: "no arguments", cli: "", @@ -58,12 +38,6 @@ func Test_NewCmdStatus(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - ghtoken := os.Getenv("GITHUB_TOKEN") - defer func() { - os.Setenv("GITHUB_TOKEN", ghtoken) - }() - os.Setenv("GITHUB_TOKEN", tt.ghtoken) - f := &cmdutil.Factory{} argv, err := shlex.Split(tt.cli) @@ -86,7 +60,6 @@ func Test_NewCmdStatus(t *testing.T) { _, err = cmd.ExecuteC() assert.NoError(t, err) - assert.Equal(t, tt.wants.Token, gotOpts.Token) assert.Equal(t, tt.wants.Hostname, gotOpts.Hostname) }) } @@ -101,47 +74,6 @@ func Test_statusRun(t *testing.T) { wantErr *regexp.Regexp wantErrOut *regexp.Regexp }{ - { - name: "token set, bad token", - opts: &StatusOptions{ - Token: "abc123", - Hostname: "github.com", - }, - httpStubs: func(reg *httpmock.Registry) { - reg.Register( - httpmock.REST("GET", ""), - httpmock.StatusStringResponse(400, "no bueno"), - ) - }, - wantErr: regexp.MustCompile(``), - wantErrOut: regexp.MustCompile(`authentication failed`), - }, - { - name: "token set, missing scope", - opts: &StatusOptions{ - Token: "abc123", - Hostname: "github.com", - }, - httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,")) - }, - wantErr: regexp.MustCompile(``), - wantErrOut: regexp.MustCompile(`missing required scope 'read:org'`), - }, - { - name: "token set, good token", - opts: &StatusOptions{ - Token: "abc123", - Hostname: "github.com", - }, - httpStubs: func(reg *httpmock.Registry) { - reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,")) - reg.Register( - httpmock.GraphQL(`query UserCurrent\b`), - httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`)) - }, - wantErrOut: regexp.MustCompile(`token valid for github.com as.*tess`), - }, { name: "hostname set", opts: &StatusOptions{ diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go index 3fa86ddcb..e5e9d170a 100644 --- a/pkg/cmd/config/config.go +++ b/pkg/cmd/config/config.go @@ -18,6 +18,7 @@ func NewCmdConfig(f *cmdutil.Factory) *cobra.Command { Current respected settings: - git_protocol: "https" or "ssh". Default is "https". - editor: if unset, defaults to environment variables. + - prompt: "enabled" or "disabled". Toggles interactive prompting. `), } @@ -72,6 +73,8 @@ func NewCmdConfigSet(f *cmdutil.Factory) *cobra.Command { Example: heredoc.Doc(` $ gh config set editor vim $ gh config set editor "code --wait" + $ gh config set git_protocol ssh + $ gh config set prompt disabled `), Args: cobra.ExactArgs(2), RunE: func(cmd *cobra.Command, args []string) error { diff --git a/pkg/cmd/config/config_test.go b/pkg/cmd/config/config_test.go index dc7906232..20c7dd543 100644 --- a/pkg/cmd/config/config_test.go +++ b/pkg/cmd/config/config_test.go @@ -21,10 +21,15 @@ func genKey(host, key string) string { } func (c configStub) Get(host, key string) (string, error) { + val, _, err := c.GetWithSource(host, key) + return val, err +} + +func (c configStub) GetWithSource(host, key string) (string, string, error) { if v, found := c[genKey(host, key)]; found { - return v, nil + return v, "(memory)", nil } - return "", errors.New("not found") + return "", "", errors.New("not found") } func (c configStub) Set(host, key, value string) error { @@ -43,6 +48,10 @@ func (c configStub) Hosts() ([]string, error) { func (c configStub) UnsetHost(hostname string) { } +func (c configStub) CheckWriteable(host, key string) error { + return nil +} + func (c configStub) Write() error { c["_written"] = "true" return nil diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go index 6b46f74d0..ae8622b2a 100644 --- a/pkg/cmd/factory/default.go +++ b/pkg/cmd/factory/default.go @@ -27,6 +27,7 @@ func New(appVersion string) *cmdutil.Factory { cachedConfig = config.NewBlankConfig() configError = nil } + cachedConfig = config.InheritEnv(cachedConfig) return cachedConfig, configError } @@ -46,8 +47,7 @@ func New(appVersion string) *cmdutil.Factory { return nil, err } - // TODO: avoid setting Accept header for `api` command - return httpClient(io, cfg, appVersion, true), nil + return NewHTTPClient(io, cfg, appVersion, true), nil }, BaseRepo: func() (ghrepo.Interface, error) { remotes, err := remotesFunc() diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go index 1cccfda9c..47fbbefe8 100644 --- a/pkg/cmd/factory/http.go +++ b/pkg/cmd/factory/http.go @@ -13,7 +13,7 @@ import ( ) // generic authenticated HTTP client for commands -func httpClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, setAccept bool) *http.Client { +func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, setAccept bool) *http.Client { var opts []api.ClientOption if verbose := os.Getenv("DEBUG"); verbose != "" { logTraffic := strings.Contains(verbose, "api") @@ -23,18 +23,11 @@ func httpClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, s opts = append(opts, api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", appVersion)), api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) { - if token := os.Getenv("GITHUB_TOKEN"); token != "" { + hostname := ghinstance.NormalizeHostname(req.URL.Hostname()) + if token, err := cfg.Get(hostname, "oauth_token"); err == nil && token != "" { return fmt.Sprintf("token %s", token), nil } - - hostname := ghinstance.NormalizeHostname(req.URL.Hostname()) - token, err := cfg.Get(hostname, "oauth_token") - if err != nil || token == "" { - // Users shouldn't see this because of the pre-execute auth check on commands - return "", fmt.Errorf("authentication required for %s; please run `gh auth login -h %s`", hostname, hostname) - } - - return fmt.Sprintf("token %s", token), nil + return "", nil }), ) diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go index 8d47e4e27..2a8ce27c6 100644 --- a/pkg/cmd/issue/create/create.go +++ b/pkg/cmd/issue/create/create.go @@ -1,6 +1,7 @@ package create import ( + "errors" "fmt" "net/http" @@ -23,13 +24,14 @@ type CreateOptions struct { IO *iostreams.IOStreams BaseRepo func() (ghrepo.Interface, error) + RootDirOverride string + RepoOverride string WebMode bool - Title string - TitleProvided bool - Body string - BodyProvided bool + Title string + Body string + Interactive bool Assignees []string Labels []string @@ -59,10 +61,16 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co // support `-R, --repo` override opts.BaseRepo = f.BaseRepo - opts.TitleProvided = cmd.Flags().Changed("title") - opts.BodyProvided = cmd.Flags().Changed("body") + titleProvided := cmd.Flags().Changed("title") + bodyProvided := cmd.Flags().Changed("body") opts.RepoOverride, _ = cmd.Flags().GetString("repo") + opts.Interactive = !(titleProvided && bodyProvided) + + if opts.Interactive && !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("must provide --title and --body when not running interactively")} + } + if runF != nil { return runF(opts) } @@ -94,9 +102,10 @@ func createRun(opts *CreateOptions) error { } var nonLegacyTemplateFiles []string - if opts.RepoOverride == "" { + if opts.RootDirOverride != "" { + nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(opts.RootDirOverride, "ISSUE_TEMPLATE") + } else if opts.RepoOverride == "" { if rootDir, err := git.ToplevelDir(); err == nil { - // TODO: figure out how to stub this in tests nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE") } } @@ -148,13 +157,7 @@ func createRun(opts *CreateOptions) error { title := opts.Title body := opts.Body - interactive := !(opts.TitleProvided && opts.BodyProvided) - - if interactive && !isTerminal { - return fmt.Errorf("must provide --title and --body when not attached to a terminal") - } - - if interactive { + if opts.Interactive { var legacyTemplateFile *string if opts.RepoOverride == "" { if rootDir, err := git.ToplevelDir(); err == nil { diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go index b483d090b..ffa5d3365 100644 --- a/pkg/cmd/issue/create/create_test.go +++ b/pkg/cmd/issue/create/create_test.go @@ -16,6 +16,7 @@ import ( "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/httpmock" "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" "github.com/cli/cli/test" "github.com/google/shlex" "github.com/stretchr/testify/assert" @@ -29,6 +30,10 @@ func eq(t *testing.T, got interface{}, expected interface{}) { } func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) { + return runCommandWithRootDirOverridden(rt, isTTY, cli, "") +} + +func runCommandWithRootDirOverridden(rt http.RoundTripper, isTTY bool, cli string, rootDir string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) io.SetStdinTTY(isTTY) @@ -47,7 +52,10 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err }, } - cmd := NewCmdCreate(factory, nil) + cmd := NewCmdCreate(factory, func(opts *CreateOptions) error { + opts.RootDirOverride = rootDir + return createRun(opts) + }) argv, err := shlex.Split(cli) if err != nil { @@ -70,20 +78,12 @@ func TestIssueCreate_nontty_error(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { - "id": "REPOID", - "hasIssuesEnabled": true - } } } - `)) - _, err := runCommand(http, false, `-t hello`) if err == nil { t.Fatal("expected error running command `issue create`") } - assert.Equal(t, "must provide --title and --body when not attached to a terminal", err.Error()) - + assert.Equal(t, "must provide --title and --body when not running interactively", err.Error()) } func TestIssueCreate(t *testing.T) { @@ -126,6 +126,67 @@ func TestIssueCreate(t *testing.T) { eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") } +func TestIssueCreate_nonLegacyTemplate(t *testing.T) { + http := &httpmock.Registry{} + defer http.Verify(t) + + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "id": "REPOID", + "hasIssuesEnabled": true + } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createIssue": { "issue": { + "URL": "https://github.com/OWNER/REPO/issues/12" + } } } } + `)) + + as, teardown := prompt.InitAskStubber() + defer teardown() + as.Stub([]*prompt.QuestionStub{ + { + Name: "index", + Value: 1, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "body", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "confirmation", + Value: 0, + }, + }) + + output, err := runCommandWithRootDirOverridden(http, true, `-t hello`, "./fixtures/repoWithNonLegacyIssueTemplates") + if err != nil { + t.Errorf("error running command `issue create`: %v", err) + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body) + reqBody := struct { + Variables struct { + Input struct { + RepositoryID string + Title string + Body string + } + } + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") + eq(t, reqBody.Variables.Input.Title, "hello") + eq(t, reqBody.Variables.Input.Body, "I have a suggestion for an enhancement") + + eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n") +} + func TestIssueCreate_metadata(t *testing.T) { http := &httpmock.Registry{} defer http.Verify(t) diff --git a/pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md b/pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 000000000..d57082bcf --- /dev/null +++ b/pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,9 @@ +--- +name: Bug report +about: Report a bug or unexpected behavior +title: Bug Report +labels: bug + +--- + +I wanna report a bug \ No newline at end of file diff --git a/pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md b/pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md new file mode 100644 index 000000000..0fe84afa3 --- /dev/null +++ b/pkg/cmd/issue/create/fixtures/repoWithNonLegacyIssueTemplates/.github/ISSUE_TEMPLATE/enhancement.md @@ -0,0 +1,9 @@ +--- +name: Submit a request +about: Propose an improvement +title: Enhancement Proposal +labels: enhancement + +--- + +I have a suggestion for an enhancement \ No newline at end of file diff --git a/pkg/cmd/issue/list/fixtures/issueList.json b/pkg/cmd/issue/list/fixtures/issueList.json index 4b19f3930..1f878f7a5 100644 --- a/pkg/cmd/issue/list/fixtures/issueList.json +++ b/pkg/cmd/issue/list/fixtures/issueList.json @@ -9,6 +9,7 @@ "number": 1, "title": "number won", "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", "labels": { "nodes": [ { @@ -22,6 +23,7 @@ "number": 2, "title": "number too", "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", "labels": { "nodes": [ { @@ -35,6 +37,7 @@ "number": 4, "title": "number fore", "url": "https://wow.com", + "updatedAt": "2011-01-26T19:01:12Z", "labels": { "nodes": [ { diff --git a/pkg/cmd/issue/list/list.go b/pkg/cmd/issue/list/list.go index 1065e61be..017556af6 100644 --- a/pkg/cmd/issue/list/list.go +++ b/pkg/cmd/issue/list/list.go @@ -116,10 +116,16 @@ func listRun(opts *ListOptions) error { return err } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + if isTerminal { hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.Assignee != "" || opts.Author != "" || opts.Mention != "" || opts.Milestone != "" title := prShared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters) - fmt.Fprintf(opts.IO.ErrOut, "\n%s\n\n", title) + fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } issueShared.PrintIssues(opts.IO, "", len(listResult.Issues), listResult.Issues) diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go index 6646a10f7..ff202b6f3 100644 --- a/pkg/cmd/issue/list/list_test.go +++ b/pkg/cmd/issue/list/list_test.go @@ -7,8 +7,10 @@ import ( "net/http" "os/exec" "reflect" + "regexp" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" @@ -97,15 +99,19 @@ func TestIssueList_tty(t *testing.T) { t.Errorf("error running command `issue list`: %v", err) } - eq(t, output.Stderr(), ` -Showing 3 of 3 open issues in OWNER/REPO + out := output.String() + timeRE := regexp.MustCompile(`\d+ years`) + out = timeRE.ReplaceAllString(out, "X years") -`) + assert.Equal(t, heredoc.Doc(` - test.ExpectLines(t, output.String(), - "number won", - "number too", - "number fore") + Showing 3 of 3 open issues in OWNER/REPO + + #1 number won (label) about X years ago + #2 number too (label) about X years ago + #4 number fore (label) about X years ago + `), out) + assert.Equal(t, ``, output.Stderr()) } func TestIssueList_tty_withFlags(t *testing.T) { @@ -141,8 +147,8 @@ func TestIssueList_tty_withFlags(t *testing.T) { t.Errorf("error running command `issue list`: %v", err) } - eq(t, output.String(), "") - eq(t, output.Stderr(), ` + eq(t, output.Stderr(), "") + eq(t, output.String(), ` No issues match your search in OWNER/REPO `) diff --git a/pkg/cmd/issue/status/status.go b/pkg/cmd/issue/status/status.go index d1b68bc0d..42ba91823 100644 --- a/pkg/cmd/issue/status/status.go +++ b/pkg/cmd/issue/status/status.go @@ -68,6 +68,12 @@ func statusRun(opts *StatusOptions) error { return err } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + out := opts.IO.Out fmt.Fprintln(out, "") diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go index 6d2b0eeaa..2795ca23b 100644 --- a/pkg/cmd/issue/view/view.go +++ b/pkg/cmd/issue/view/view.go @@ -83,13 +83,21 @@ func viewRun(opts *ViewOptions) error { openURL := issue.URL if opts.WebMode { - fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", openURL) + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) + } return utils.OpenInBrowser(openURL) } + + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + if opts.IO.IsStdoutTTY() { return printHumanIssuePreview(opts.IO.Out, issue) } - return printRawIssuePreview(opts.IO.Out, issue) } diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go index e27505f92..0567c87ff 100644 --- a/pkg/cmd/issue/view/view_test.go +++ b/pkg/cmd/issue/view/view_test.go @@ -87,7 +87,7 @@ func TestIssueView_web(t *testing.T) { } eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n") + eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/123 in your browser.\n") if seenCmd == nil { t.Fatal("expected a command to run") @@ -120,7 +120,7 @@ func TestIssueView_web_numberArgWithHash(t *testing.T) { } eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n") + eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/123 in your browser.\n") if seenCmd == nil { t.Fatal("expected a command to run") diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go new file mode 100644 index 000000000..d3af8cbb0 --- /dev/null +++ b/pkg/cmd/pr/checks/checks.go @@ -0,0 +1,227 @@ +package checks + +import ( + "errors" + "fmt" + "net/http" + "sort" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/context" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/pr/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ChecksOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + Branch func() (string, error) + Remotes func() (context.Remotes, error) + + SelectorArg string +} + +func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Command { + opts := &ChecksOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Branch: f.Branch, + Remotes: f.Remotes, + BaseRepo: f.BaseRepo, + } + + cmd := &cobra.Command{ + Use: "checks", + Short: "Show CI status for a single pull request", + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 { + return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")} + } + + if len(args) > 0 { + opts.SelectorArg = args[0] + } + + if runF != nil { + return runF(opts) + } + + return checksRun(opts) + }, + } + + return cmd +} + +func checksRun(opts *ChecksOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + apiClient := api.NewClientFromHTTP(httpClient) + + pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg) + if err != nil { + return err + } + + if len(pr.Commits.Nodes) == 0 { + return nil + } + + rollup := pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes + if len(rollup) == 0 { + return nil + } + + passing := 0 + failing := 0 + pending := 0 + + type output struct { + mark string + bucket string + name string + elapsed string + link string + markColor func(string) string + } + + outputs := []output{} + + for _, c := range pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes { + mark := "✓" + bucket := "pass" + state := c.State + markColor := utils.Green + if state == "" { + if c.Status == "COMPLETED" { + state = c.Conclusion + } else { + state = c.Status + } + } + switch state { + case "SUCCESS", "NEUTRAL", "SKIPPED": + passing++ + case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED": + mark = "X" + markColor = utils.Red + failing++ + bucket = "fail" + case "EXPECTED", "REQUESTED", "QUEUED", "PENDING", "IN_PROGRESS", "STALE": + mark = "-" + markColor = utils.Yellow + pending++ + bucket = "pending" + default: + panic(fmt.Errorf("unsupported status: %q", state)) + } + + elapsed := "" + zeroTime := time.Time{} + + if c.StartedAt != zeroTime && c.CompletedAt != zeroTime { + e := c.CompletedAt.Sub(c.StartedAt) + if e > 0 { + elapsed = e.String() + } + } + + link := c.DetailsURL + if link == "" { + link = c.TargetURL + } + + name := c.Name + if name == "" { + name = c.Context + } + + outputs = append(outputs, output{mark, bucket, name, elapsed, link, markColor}) + } + + sort.Slice(outputs, func(i, j int) bool { + b0 := outputs[i].bucket + n0 := outputs[i].name + l0 := outputs[i].link + b1 := outputs[j].bucket + n1 := outputs[j].name + l1 := outputs[j].link + + if b0 == b1 { + if n0 == n1 { + return l0 < l1 + } else { + return n0 < n1 + } + } + + return (b0 == "fail") || (b0 == "pending" && b1 == "success") + }) + + tp := utils.NewTablePrinter(opts.IO) + + for _, o := range outputs { + if opts.IO.IsStdoutTTY() { + tp.AddField(o.mark, nil, o.markColor) + tp.AddField(o.name, nil, nil) + tp.AddField(o.elapsed, nil, nil) + tp.AddField(o.link, nil, nil) + } else { + tp.AddField(o.name, nil, nil) + tp.AddField(o.bucket, nil, nil) + if o.elapsed == "" { + tp.AddField("0", nil, nil) + } else { + tp.AddField(o.elapsed, nil, nil) + } + tp.AddField(o.link, nil, nil) + } + + tp.EndRow() + } + + summary := "" + if failing+passing+pending > 0 { + if failing > 0 { + summary = "Some checks were not successful" + } else if pending > 0 { + summary = "Some checks are still pending" + } else { + summary = "All checks were successful" + } + + tallies := fmt.Sprintf( + "%d failing, %d successful, and %d pending checks", + failing, passing, pending) + + summary = fmt.Sprintf("%s\n%s", utils.Bold(summary), tallies) + } + + if opts.IO.IsStdoutTTY() { + fmt.Fprintln(opts.IO.Out, summary) + fmt.Fprintln(opts.IO.Out) + } + + err = tp.Render() + if err != nil { + return err + } + + if failing+pending > 0 { + return cmdutil.SilentError + } + + return nil +} diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go new file mode 100644 index 000000000..43dbf8cda --- /dev/null +++ b/pkg/cmd/pr/checks/checks_test.go @@ -0,0 +1,211 @@ +package checks + +import ( + "bytes" + "net/http" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" +) + +func TestNewCmdChecks(t *testing.T) { + tests := []struct { + name string + cli string + wants ChecksOptions + }{ + { + name: "no arguments", + cli: "", + wants: ChecksOptions{}, + }, + { + name: "pr argument", + cli: "1234", + wants: ChecksOptions{ + SelectorArg: "1234", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + f := &cmdutil.Factory{ + IOStreams: io, + } + + argv, err := shlex.Split(tt.cli) + assert.NoError(t, err) + + var gotOpts *ChecksOptions + cmd := NewCmdChecks(f, func(opts *ChecksOptions) error { + gotOpts = opts + return nil + }) + cmd.SetArgs(argv) + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(&bytes.Buffer{}) + cmd.SetErr(&bytes.Buffer{}) + + _, err = cmd.ExecuteC() + assert.NoError(t, err) + + assert.Equal(t, tt.wants.SelectorArg, gotOpts.SelectorArg) + }) + } +} + +func Test_checksRun(t *testing.T) { + tests := []struct { + name string + fixture string + stubs func(*httpmock.Registry) + wantOut string + nontty bool + wantErr bool + }{ + { + name: "no commits", + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.JSONResponse( + bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 123 } + } } } + `))) + }, + }, + { + name: "no checks", + stubs: func(reg *httpmock.Registry) { + reg.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]} } + } } } + `)) + }, + }, + { + name: "some failing", + fixture: "./fixtures/someFailing.json", + wantOut: "Some checks were not successful\n1 failing, 1 successful, and 1 pending checks\n\nX sad tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n- slow tests 1m26s sweet link\n", + wantErr: true, + }, + { + name: "some pending", + fixture: "./fixtures/somePending.json", + wantOut: "Some checks are still pending\n0 failing, 2 successful, and 1 pending checks\n\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n- slow tests 1m26s sweet link\n", + wantErr: true, + }, + { + name: "all passing", + fixture: "./fixtures/allPassing.json", + wantOut: "All checks were successful\n0 failing, 3 successful, and 0 pending checks\n\n✓ awesome tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n", + }, + { + name: "with statuses", + fixture: "./fixtures/withStatuses.json", + wantOut: "Some checks were not successful\n1 failing, 2 successful, and 0 pending checks\n\nX a status sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n", + wantErr: true, + }, + { + name: "no commits", + nontty: true, + stubs: func(reg *httpmock.Registry) { + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), + httpmock.JSONResponse( + bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 123 } + } } } + `))) + }, + }, + { + name: "no checks", + nontty: true, + stubs: func(reg *httpmock.Registry) { + reg.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { + "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]} } + } } } + `)) + }, + }, + { + name: "some failing", + nontty: true, + fixture: "./fixtures/someFailing.json", + wantOut: "sad tests\tfail\t1m26s\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nslow tests\tpending\t1m26s\tsweet link\n", + wantErr: true, + }, + { + name: "some pending", + nontty: true, + fixture: "./fixtures/somePending.json", + wantOut: "cool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\nslow tests\tpending\t1m26s\tsweet link\n", + wantErr: true, + }, + { + name: "all passing", + nontty: true, + fixture: "./fixtures/allPassing.json", + wantOut: "awesome tests\tpass\t1m26s\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\n", + }, + { + name: "with statuses", + nontty: true, + fixture: "./fixtures/withStatuses.json", + wantOut: "a status\tfail\t0\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\n", + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, _ := iostreams.Test() + io.SetStdoutTTY(!tt.nontty) + + opts := &ChecksOptions{ + IO: io, + BaseRepo: func() (ghrepo.Interface, error) { + return ghrepo.New("OWNER", "REPO"), nil + }, + SelectorArg: "123", + } + + reg := &httpmock.Registry{} + if tt.stubs != nil { + tt.stubs(reg) + } else if tt.fixture != "" { + reg.Register( + httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse(tt.fixture)) + } else { + panic("need either stubs or fixture key") + } + + opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + err := checksRun(opts) + if tt.wantErr { + assert.Equal(t, "SilentError", err.Error()) + } else { + assert.NoError(t, err) + } + + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/pr/checks/fixtures/allPassing.json b/pkg/cmd/pr/checks/fixtures/allPassing.json new file mode 100644 index 000000000..c75e3fc29 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/allPassing.json @@ -0,0 +1,41 @@ +{ "data": { "repository": { "pullRequest": { + "number": 123, + "commits": { + "nodes": [ + { + "commit": { + "oid": "abc", + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "rad tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "awesome tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/checks/fixtures/someFailing.json b/pkg/cmd/pr/checks/fixtures/someFailing.json new file mode 100644 index 000000000..0e53cdb79 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/someFailing.json @@ -0,0 +1,41 @@ +{ "data": { "repository": { "pullRequest": { + "number": 123, + "commits": { + "nodes": [ + { + "commit": { + "oid": "abc", + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "FAILURE", + "status": "COMPLETED", + "name": "sad tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "", + "status": "IN_PROGRESS", + "name": "slow tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/checks/fixtures/somePending.json b/pkg/cmd/pr/checks/fixtures/somePending.json new file mode 100644 index 000000000..6e36a5cd3 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/somePending.json @@ -0,0 +1,41 @@ +{ "data": { "repository": { "pullRequest": { + "number": 123, + "commits": { + "nodes": [ + { + "commit": { + "oid": "abc", + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "rad tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "", + "status": "IN_PROGRESS", + "name": "slow tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/checks/fixtures/withStatuses.json b/pkg/cmd/pr/checks/fixtures/withStatuses.json new file mode 100644 index 000000000..0ce8b9c66 --- /dev/null +++ b/pkg/cmd/pr/checks/fixtures/withStatuses.json @@ -0,0 +1,38 @@ +{ "data": { "repository": { "pullRequest": { + "number": 123, + "commits": { + "nodes": [ + { + "commit": { + "oid": "abc", + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "cool tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "conclusion": "SUCCESS", + "status": "COMPLETED", + "name": "rad tests", + "completedAt": "2020-08-27T19:00:12Z", + "startedAt": "2020-08-27T18:58:46Z", + "detailsUrl": "sweet link" + }, + { + "state": "FAILURE", + "name": "a status", + "targetUrl": "sweet link" + } + ] + } + } + } + } + ]} +} } } } diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go index 6b144a5e9..d14be560c 100644 --- a/pkg/cmd/pr/create/create.go +++ b/pkg/cmd/pr/create/create.go @@ -28,17 +28,18 @@ type CreateOptions struct { Remotes func() (context.Remotes, error) Branch func() (string, error) - RepoOverride string + Interactive bool + + RootDirOverride string + RepoOverride string Autofill bool WebMode bool - IsDraft bool - Title string - TitleProvided bool - Body string - BodyProvided bool - BaseBranch string + IsDraft bool + Title string + Body string + BaseBranch string Reviewers []string Assignees []string @@ -69,13 +70,14 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co `), Args: cmdutil.NoArgsQuoteReminder, RunE: func(cmd *cobra.Command, args []string) error { - opts.TitleProvided = cmd.Flags().Changed("title") - opts.BodyProvided = cmd.Flags().Changed("body") + titleProvided := cmd.Flags().Changed("title") + bodyProvided := cmd.Flags().Changed("body") opts.RepoOverride, _ = cmd.Flags().GetString("repo") - isTerminal := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() - if !isTerminal && !opts.WebMode && !opts.TitleProvided && !opts.Autofill { - return errors.New("--title or --fill required when not attached to a terminal") + opts.Interactive = !(titleProvided && bodyProvided) + + if !opts.IO.CanPrompt() && !opts.WebMode && !titleProvided && !opts.Autofill { + return &cmdutil.FlagError{Err: errors.New("--title or --fill required when not running interactively")} } if opts.IsDraft && opts.WebMode { @@ -241,13 +243,14 @@ func createRun(opts *CreateOptions) error { Milestones: milestoneTitles, } - interactive := isTerminal && !(opts.TitleProvided && opts.BodyProvided) - - if !opts.WebMode && !opts.Autofill && interactive { + if !opts.WebMode && !opts.Autofill && opts.Interactive { var nonLegacyTemplateFiles []string var legacyTemplateFile *string - if rootDir, err := git.ToplevelDir(); err == nil { - // TODO: figure out how to stub this in tests + + if opts.RootDirOverride != "" { + nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE") + legacyTemplateFile = githubtemplate.FindLegacy(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE") + } else if rootDir, err := git.ToplevelDir(); err == nil { nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "PULL_REQUEST_TEMPLATE") legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "PULL_REQUEST_TEMPLATE") } diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go index e50a72c63..1eecab69b 100644 --- a/pkg/cmd/pr/create/create_test.go +++ b/pkg/cmd/pr/create/create_test.go @@ -5,6 +5,8 @@ import ( "encoding/json" "io/ioutil" "net/http" + "os" + "path" "reflect" "strings" "testing" @@ -31,6 +33,10 @@ func eq(t *testing.T, got interface{}, expected interface{}) { } func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, isTTY bool, cli string) (*test.CmdOut, error) { + return runCommandWithRootDirOverridden(rt, remotes, branch, isTTY, cli, "") +} + +func runCommandWithRootDirOverridden(rt http.RoundTripper, remotes context.Remotes, branch string, isTTY bool, cli string, rootDir string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() io.SetStdoutTTY(isTTY) io.SetStdinTTY(isTTY) @@ -60,7 +66,10 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, is }, } - cmd := NewCmdCreate(factory, nil) + cmd := NewCmdCreate(factory, func(opts *CreateOptions) error { + opts.RootDirOverride = rootDir + return createRun(opts) + }) cmd.PersistentFlags().StringP("repo", "R", "", "") argv, err := shlex.Split(cli) @@ -126,7 +135,7 @@ func TestPRCreate_nontty_insufficient_flags(t *testing.T) { t.Fatal("expected error") } - assert.Equal(t, "--title or --fill required when not attached to a terminal", err.Error()) + assert.Equal(t, "--title or --fill required when not running interactively", err.Error()) assert.Equal(t, "", output.String()) } @@ -239,6 +248,80 @@ func TestPRCreate(t *testing.T) { eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } +func TestPRCreate_nonLegacyTemplate(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.StubRepoResponse("OWNER", "REPO") + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "forks": { "nodes": [ + ] } } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "repository": { "pullRequests": { "nodes" : [ + ] } } } } + `)) + http.StubResponse(200, bytes.NewBufferString(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `)) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + cs.Stub("") // git config --get-regexp (determineTrackingBranch) + cs.Stub("") // git show-ref --verify (determineTrackingBranch) + cs.Stub("") // git status + cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log + cs.Stub("") // git push + + as, teardown := prompt.InitAskStubber() + defer teardown() + as.Stub([]*prompt.QuestionStub{ + { + Name: "index", + Value: 0, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "body", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "confirmation", + Value: 0, + }, + }) + + output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, `-t "my title"`, "./fixtures/repoWithNonLegacyPRTemplates") + require.NoError(t, err) + + bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body) + reqBody := struct { + Variables struct { + Input struct { + RepositoryID string + Title string + Body string + BaseRefName string + HeadRefName string + } + } + }{} + _ = json.Unmarshal(bodyBytes, &reqBody) + + eq(t, reqBody.Variables.Input.RepositoryID, "REPOID") + eq(t, reqBody.Variables.Input.Title, "my title") + eq(t, reqBody.Variables.Input.Body, "- commit 1\n- commit 0\n\nFixes a bug and Closes an issue") + eq(t, reqBody.Variables.Input.BaseRefName, "master") + eq(t, reqBody.Variables.Input.HeadRefName, "feature") + + eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") +} func TestPRCreate_metadata(t *testing.T) { http := initFakeHTTP() @@ -770,6 +853,77 @@ func TestPRCreate_survey_defaults_monocommit(t *testing.T) { eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") } +func TestPRCreate_survey_defaults_monocommit_template(t *testing.T) { + http := initFakeHTTP() + defer http.Verify(t) + + http.Register(httpmock.GraphQL(`query RepositoryNetwork\b`), httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE"))) + http.Register(httpmock.GraphQL(`query RepositoryFindFork\b`), httpmock.StringResponse(` + { "data": { "repository": { "forks": { "nodes": [ + ] } } } } + `)) + http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.StringResponse(` + { "data": { "repository": { "pullRequests": { "nodes" : [ + ] } } } } + `)) + http.Register(httpmock.GraphQL(`mutation PullRequestCreate\b`), httpmock.GraphQLMutation(` + { "data": { "createPullRequest": { "pullRequest": { + "URL": "https://github.com/OWNER/REPO/pull/12" + } } } } + `, func(inputs map[string]interface{}) { + eq(t, inputs["repositoryId"], "REPOID") + eq(t, inputs["title"], "the sky above the port") + eq(t, inputs["body"], "was the color of a television\n\n... turned to a dead channel") + eq(t, inputs["baseRefName"], "master") + eq(t, inputs["headRefName"], "feature") + })) + + cs, cmdTeardown := test.InitCmdStubber() + defer cmdTeardown() + + tmpdir, err := ioutil.TempDir("", "gh-cli") + if err != nil { + t.Fatal(err) + } + defer os.RemoveAll(tmpdir) + + templateFp := path.Join(tmpdir, ".github/PULL_REQUEST_TEMPLATE.md") + _ = os.MkdirAll(path.Dir(templateFp), 0700) + _ = ioutil.WriteFile(templateFp, []byte("... turned to a dead channel"), 0700) + + cs.Stub("") // git config --get-regexp (determineTrackingBranch) + cs.Stub("") // git show-ref --verify (determineTrackingBranch) + cs.Stub("") // git status + cs.Stub("1234567890,the sky above the port") // git log + cs.Stub("was the color of a television") // git show + cs.Stub(tmpdir) // git rev-parse + cs.Stub("") // git push + + as, surveyTeardown := prompt.InitAskStubber() + defer surveyTeardown() + + as.Stub([]*prompt.QuestionStub{ + { + Name: "title", + Default: true, + }, + { + Name: "body", + Default: true, + }, + }) + as.Stub([]*prompt.QuestionStub{ + { + Name: "confirmation", + Value: 0, + }, + }) + + output, err := runCommand(http, nil, "feature", true, ``) + eq(t, err, nil) + eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n") +} + func TestPRCreate_survey_autofill_nontty(t *testing.T) { http := initFakeHTTP() defer http.Verify(t) diff --git a/pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md b/pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md new file mode 100644 index 000000000..5e98de344 --- /dev/null +++ b/pkg/cmd/pr/create/fixtures/repoWithNonLegacyPRTemplates/.github/PULL_REQUEST_TEMPLATE/bug_fix.md @@ -0,0 +1,7 @@ +--- +name: "Bug fix" +about: Fix a bug + +--- + +Fixes a bug and Closes an issue \ No newline at end of file diff --git a/pkg/cmd/pr/diff/diff.go b/pkg/cmd/pr/diff/diff.go index c6902e5b5..2bea8072b 100644 --- a/pkg/cmd/pr/diff/diff.go +++ b/pkg/cmd/pr/diff/diff.go @@ -6,9 +6,8 @@ import ( "fmt" "io" "net/http" - "os" - "os/exec" "strings" + "syscall" "github.com/cli/cli/api" "github.com/cli/cli/context" @@ -16,7 +15,6 @@ import ( "github.com/cli/cli/pkg/cmd/pr/shared" "github.com/cli/cli/pkg/cmdutil" "github.com/cli/cli/pkg/iostreams" - "github.com/google/shlex" "github.com/spf13/cobra" ) @@ -93,15 +91,18 @@ func diffRun(opts *DiffOptions) error { } defer diff.Close() - if opts.UseColor == "never" { - _, err = io.Copy(opts.IO.Out, diff) + err = opts.IO.StartPager() + if err != nil { return err } + defer opts.IO.StopPager() - if opts.IO.IsStdoutTTY() { - if pager := os.Getenv("PAGER"); pager != "" { - return runPager(pager, diff, opts.IO.Out) + if opts.UseColor == "never" { + _, err = io.Copy(opts.IO.Out, diff) + if errors.Is(err, syscall.EPIPE) { + return nil } + return err } diffLines := bufio.NewScanner(diff) @@ -148,14 +149,3 @@ func isRemovalLine(dl string) bool { func validColorFlag(c string) bool { return c == "auto" || c == "always" || c == "never" } - -var runPager = func(pager string, diff io.Reader, out io.Writer) error { - args, err := shlex.Split(pager) - if err != nil { - return err - } - pagerCmd := exec.Command(args[0], args[1:]...) - pagerCmd.Stdin = diff - pagerCmd.Stdout = out - return pagerCmd.Run() -} diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go index dc93970b6..5e362ac4d 100644 --- a/pkg/cmd/pr/diff/diff_test.go +++ b/pkg/cmd/pr/diff/diff_test.go @@ -2,10 +2,8 @@ package diff import ( "bytes" - "io" "io/ioutil" "net/http" - "os" "testing" "github.com/cli/cli/context" @@ -214,13 +212,8 @@ func TestPRDiff_notty(t *testing.T) { } func TestPRDiff_tty(t *testing.T) { - pager := os.Getenv("PAGER") http := &httpmock.Registry{} - defer func() { - os.Setenv("PAGER", pager) - http.Verify(t) - }() - os.Setenv("PAGER", "") + defer http.Verify(t) http.StubResponse(200, bytes.NewBufferString(` { "data": { "repository": { "pullRequests": { "nodes": [ { "url": "https://github.com/OWNER/REPO/pull/123", @@ -237,38 +230,6 @@ func TestPRDiff_tty(t *testing.T) { assert.Contains(t, output.String(), "\x1b[32m+site: bin/gh\x1b[m") } -func TestPRDiff_pager(t *testing.T) { - realRunPager := runPager - pager := os.Getenv("PAGER") - http := &httpmock.Registry{} - defer func() { - runPager = realRunPager - os.Setenv("PAGER", pager) - http.Verify(t) - }() - runPager = func(pager string, diff io.Reader, out io.Writer) error { - _, err := io.Copy(out, diff) - return err - } - os.Setenv("PAGER", "fakepager") - http.StubResponse(200, bytes.NewBufferString(` - { "data": { "repository": { "pullRequests": { "nodes": [ - { "url": "https://github.com/OWNER/REPO/pull/123", - "number": 123, - "id": "foobar123", - "headRefName": "feature", - "baseRefName": "master" } - ] } } } }`)) - http.StubResponse(200, bytes.NewBufferString(testDiff)) - output, err := runCommand(http, nil, true, "") - if err != nil { - t.Fatalf("unexpected error: %s", err) - } - if diff := cmp.Diff(testDiff, output.String()); diff != "" { - t.Errorf("command output did not match:\n%s", diff) - } -} - const testDiff = `diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index 73974448..b7fc0154 100644 --- a/.github/workflows/releases.yml diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go index fd1ebb2b9..e4fe318c8 100644 --- a/pkg/cmd/pr/list/list.go +++ b/pkg/cmd/pr/list/list.go @@ -133,10 +133,16 @@ func listRun(opts *ListOptions) error { return err } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + if opts.IO.IsStdoutTTY() { hasFilters := opts.State != "open" || len(opts.Labels) > 0 || opts.BaseBranch != "" || opts.Assignee != "" title := shared.ListHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, hasFilters) - fmt.Fprintf(opts.IO.ErrOut, "\n%s\n\n", title) + fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title) } table := utils.NewTablePrinter(opts.IO) diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go index 5faf42b57..ab99e4aa9 100644 --- a/pkg/cmd/pr/list/list_test.go +++ b/pkg/cmd/pr/list/list_test.go @@ -6,10 +6,10 @@ import ( "net/http" "os/exec" "reflect" - "regexp" "strings" "testing" + "github.com/MakeNowJust/heredoc" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/internal/run" "github.com/cli/cli/pkg/cmdutil" @@ -76,23 +76,15 @@ func TestPRList(t *testing.T) { t.Fatal(err) } - assert.Equal(t, ` -Showing 3 of 3 open pull requests in OWNER/REPO + assert.Equal(t, heredoc.Doc(` -`, output.Stderr()) - - lines := strings.Split(output.String(), "\n") - res := []*regexp.Regexp{ - regexp.MustCompile(`#32.*New feature.*feature`), - regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), - regexp.MustCompile(`#28.*Improve documentation.*docs`), - } - - for i, r := range res { - if !r.MatchString(lines[i]) { - t.Errorf("%s did not match %s", lines[i], r) - } - } + Showing 3 of 3 open pull requests in OWNER/REPO + + #32 New feature feature + #29 Fixed bad bug hubot:bug-fix + #28 Improve documentation docs + `), output.String()) + assert.Equal(t, ``, output.Stderr()) } func TestPRList_nontty(t *testing.T) { @@ -130,8 +122,8 @@ func TestPRList_filtering(t *testing.T) { t.Fatal(err) } - eq(t, output.String(), "") - eq(t, output.Stderr(), ` + eq(t, output.Stderr(), "") + eq(t, output.String(), ` No pull requests match your search in OWNER/REPO `) @@ -150,19 +142,12 @@ func TestPRList_filteringRemoveDuplicate(t *testing.T) { t.Fatal(err) } - lines := strings.Split(output.String(), "\n") - - res := []*regexp.Regexp{ - regexp.MustCompile(`#32.*New feature.*feature`), - regexp.MustCompile(`#29.*Fixed bad bug.*hubot:bug-fix`), - regexp.MustCompile(`#28.*Improve documentation.*docs`), - } - - for i, r := range res { - if !r.MatchString(lines[i]) { - t.Errorf("%s did not match %s", lines[i], r) - } + out := output.String() + idx := strings.Index(out, "New feature") + if idx < 0 { + t.Fatalf("text %q not found in %q", "New feature", out) } + assert.Equal(t, idx, strings.LastIndex(out, "New feature")) } func TestPRList_filteringClosed(t *testing.T) { diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go index 94e0c959c..7347a21aa 100644 --- a/pkg/cmd/pr/merge/merge.go +++ b/pkg/cmd/pr/merge/merge.go @@ -86,8 +86,8 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm methodFlags++ } if methodFlags == 0 { - if !opts.IO.IsStdoutTTY() || !opts.IO.IsStdinTTY() { - return &cmdutil.FlagError{Err: errors.New("--merge, --rebase, or --squash required when not attached to a terminal")} + if !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("--merge, --rebase, or --squash required when not running interactively")} } opts.InteractiveMode = true } else if methodFlags > 1 { diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go index 2b5f89e7b..c29b794ae 100644 --- a/pkg/cmd/pr/merge/merge_test.go +++ b/pkg/cmd/pr/merge/merge_test.go @@ -53,7 +53,7 @@ func Test_NewCmdMerge(t *testing.T) { name: "insufficient flags in non-interactive mode", args: "123", isTTY: false, - wantErr: "--merge, --rebase, or --squash required when not attached to a terminal", + wantErr: "--merge, --rebase, or --squash required when not running interactively", }, { name: "multiple merge methods", @@ -189,6 +189,7 @@ func TestPrMerge(t *testing.T) { httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") })) http.Register( httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), @@ -234,6 +235,7 @@ func TestPrMerge_nontty(t *testing.T) { httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") })) http.Register( httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), @@ -276,6 +278,7 @@ func TestPrMerge_withRepoFlag(t *testing.T) { httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") })) http.Register( httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), @@ -310,6 +313,7 @@ func TestPrMerge_deleteBranch(t *testing.T) { httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { assert.Equal(t, "PR_10", input["pullRequestId"].(string)) assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") })) http.Register( httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), @@ -344,6 +348,7 @@ func TestPrMerge_deleteNonCurrentBranch(t *testing.T) { httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { assert.Equal(t, "PR_10", input["pullRequestId"].(string)) assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") })) http.Register( httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), @@ -376,6 +381,7 @@ func TestPrMerge_noPrNumberGiven(t *testing.T) { httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { assert.Equal(t, "PR_10", input["pullRequestId"].(string)) assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") })) http.Register( httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), @@ -421,6 +427,7 @@ func TestPrMerge_rebase(t *testing.T) { httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) assert.Equal(t, "REBASE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") })) http.Register( httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), @@ -465,6 +472,7 @@ func TestPrMerge_squash(t *testing.T) { httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) assert.Equal(t, "SQUASH", input["mergeMethod"].(string)) + assert.Equal(t, "The title of the PR (#3)", input["commitHeadline"].(string)) })) http.Register( httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), @@ -533,6 +541,7 @@ func TestPRMerge_interactive(t *testing.T) { httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) { assert.Equal(t, "THE-ID", input["pullRequestId"].(string)) assert.Equal(t, "MERGE", input["mergeMethod"].(string)) + assert.NotContains(t, input, "commitHeadline") })) http.Register( httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"), diff --git a/pkg/cmd/pr/pr.go b/pkg/cmd/pr/pr.go index a4b746656..e06eb8059 100644 --- a/pkg/cmd/pr/pr.go +++ b/pkg/cmd/pr/pr.go @@ -3,6 +3,7 @@ package pr import ( "github.com/MakeNowJust/heredoc" cmdCheckout "github.com/cli/cli/pkg/cmd/pr/checkout" + cmdChecks "github.com/cli/cli/pkg/cmd/pr/checks" cmdClose "github.com/cli/cli/pkg/cmd/pr/close" cmdCreate "github.com/cli/cli/pkg/cmd/pr/create" cmdDiff "github.com/cli/cli/pkg/cmd/pr/diff" @@ -51,6 +52,7 @@ func NewCmdPR(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(cmdReview.NewCmdReview(f, nil)) cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil)) cmd.AddCommand(cmdView.NewCmdView(f, nil)) + cmd.AddCommand(cmdChecks.NewCmdChecks(f, nil)) return cmd } diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go index 19f33ca84..d51d109da 100644 --- a/pkg/cmd/pr/review/review.go +++ b/pkg/cmd/pr/review/review.go @@ -104,8 +104,8 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co } if found == 0 && opts.Body == "" { - if !opts.IO.IsStdoutTTY() || !opts.IO.IsStdinTTY() { - return &cmdutil.FlagError{Err: errors.New("--approve, --request-changes, or --comment required when not attached to a tty")} + if !opts.IO.CanPrompt() { + return &cmdutil.FlagError{Err: errors.New("--approve, --request-changes, or --comment required when not running interactively")} } opts.InteractiveMode = true } else if found == 0 && opts.Body != "" { diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go index 1dfe4a53d..b96ab8089 100644 --- a/pkg/cmd/pr/review/review_test.go +++ b/pkg/cmd/pr/review/review_test.go @@ -60,7 +60,7 @@ func Test_NewCmdReview(t *testing.T) { name: "no arguments in non-interactive mode", args: "", isTTY: false, - wantErr: "--approve, --request-changes, or --comment required when not attached to a tty", + wantErr: "--approve, --request-changes, or --comment required when not running interactively", }, { name: "mutually exclusive review types", diff --git a/pkg/cmd/pr/shared/title_body_survey.go b/pkg/cmd/pr/shared/title_body_survey.go index 3afeba803..83a50e0ff 100644 --- a/pkg/cmd/pr/shared/title_body_survey.go +++ b/pkg/cmd/pr/shared/title_body_survey.go @@ -2,6 +2,7 @@ package shared import ( "fmt" + "strings" "github.com/AlecAivazis/survey/v2" "github.com/cli/cli/api" @@ -154,18 +155,26 @@ func TitleBodySurvey(io *iostreams.IOStreams, editorCommand string, issueState * templateContents := "" if providedBody == "" { + issueState.Body = defs.Body + if len(nonLegacyTemplatePaths) > 0 { var err error templateContents, err = selectTemplate(nonLegacyTemplatePaths, legacyTemplatePath, issueState.Type) if err != nil { return err } - issueState.Body = templateContents + } else if legacyTemplatePath != nil { templateContents = string(githubtemplate.ExtractContents(*legacyTemplatePath)) - issueState.Body = templateContents - } else { - issueState.Body = defs.Body + } + + if templateContents != "" { + if issueState.Body != "" { + // prevent excessive newlines between default body and template + issueState.Body = strings.TrimRight(issueState.Body, "\n") + issueState.Body += "\n\n" + } + issueState.Body += templateContents } } diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go index dffd2b412..2c012d712 100644 --- a/pkg/cmd/pr/status/status.go +++ b/pkg/cmd/pr/status/status.go @@ -97,6 +97,12 @@ func statusRun(opts *StatusOptions) error { return err } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + out := opts.IO.Out fmt.Fprintln(out, "") diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go index b653e5ffe..a1a15ec42 100644 --- a/pkg/cmd/pr/view/view.go +++ b/pkg/cmd/pr/view/view.go @@ -94,11 +94,17 @@ func viewRun(opts *ViewOptions) error { if opts.BrowserMode { if connectedToTerminal { - fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", openURL) + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL)) } return utils.OpenInBrowser(openURL) } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + if connectedToTerminal { return printHumanPrPreview(opts.IO.Out, pr) } diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go index fa9e3941a..6dc3561f7 100644 --- a/pkg/cmd/pr/view/view_test.go +++ b/pkg/cmd/pr/view/view_test.go @@ -507,7 +507,7 @@ func TestPRView_web_currentBranch(t *testing.T) { } eq(t, output.String(), "") - eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/pull/10 in your browser.\n") + eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/pull/10 in your browser.\n") if seenCmd == nil { t.Fatal("expected a command to run") diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go new file mode 100644 index 000000000..5534821c1 --- /dev/null +++ b/pkg/cmd/release/create/create.go @@ -0,0 +1,365 @@ +package create + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "os" + "os/exec" + "strings" + + "github.com/AlecAivazis/survey/v2" + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/config" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/internal/run" + "github.com/cli/cli/pkg/cmd/release/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/cli/cli/pkg/surveyext" + "github.com/cli/cli/pkg/text" + "github.com/spf13/cobra" +) + +type CreateOptions struct { + IO *iostreams.IOStreams + Config func() (config.Config, error) + HttpClient func() (*http.Client, error) + BaseRepo func() (ghrepo.Interface, error) + + TagName string + Target string + Name string + Body string + BodyProvided bool + Draft bool + Prerelease bool + + Assets []*shared.AssetForUpload + + // for interactive flow + SubmitAction string + // for interactive flow + ReleaseNotesAction string + + // the value from the --repo flag + RepoOverride string + + // maximum number of simultaneous uploads + Concurrency int +} + +func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command { + opts := &CreateOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + Config: f.Config, + } + + var notesFile string + + cmd := &cobra.Command{ + Use: "create [...]", + Short: "Create a new release", + Long: heredoc.Doc(` + Create a new GitHub Release for a repository. + + A list of asset files may be given to upload to the new release. To define a + display label for an asset, append text starting with '#' after the file name. + `), + Example: heredoc.Doc(` + # use release notes from a file + $ gh release create v1.2.3 -F changelog.md + + # upload a release asset with a display label + $ gh release create v1.2.3 '/path/to/asset.zip#My display label' + `), + Args: cobra.MinimumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + opts.RepoOverride, _ = cmd.Flags().GetString("repo") + + opts.TagName = args[0] + + var err error + opts.Assets, err = shared.AssetsFromArgs(args[1:]) + if err != nil { + return err + } + + opts.Concurrency = 5 + + opts.BodyProvided = cmd.Flags().Changed("notes") + if notesFile != "" { + var b []byte + if notesFile == "-" { + b, err = ioutil.ReadAll(opts.IO.In) + } else { + b, err = ioutil.ReadFile(notesFile) + } + if err != nil { + return err + } + opts.Body = string(b) + opts.BodyProvided = true + } + + if runF != nil { + return runF(opts) + } + return createRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.Draft, "draft", "d", false, "Save the release as a draft instead of publishing it") + cmd.Flags().BoolVarP(&opts.Prerelease, "prerelease", "p", false, "Mark the release as a prerelease") + cmd.Flags().StringVar(&opts.Target, "target", "", "Target `branch` or commit SHA (default: main branch)") + cmd.Flags().StringVarP(&opts.Name, "title", "t", "", "Release title") + cmd.Flags().StringVarP(&opts.Body, "notes", "n", "", "Release notes") + cmd.Flags().StringVarP(¬esFile, "notes-file", "F", "", "Read release notes from `file`") + + return cmd +} + +func createRun(opts *CreateOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + if !opts.BodyProvided && opts.IO.CanPrompt() { + editorCommand, err := cmdutil.DetermineEditor(opts.Config) + if err != nil { + return err + } + + var tagDescription string + var generatedChangelog string + if opts.RepoOverride == "" { + headRef := opts.TagName + tagDescription, _ = gitTagInfo(opts.TagName) + if tagDescription == "" { + if opts.Target != "" { + // TODO: use the remote-tracking version of the branch ref + headRef = opts.Target + } else { + headRef = "HEAD" + } + } + + if prevTag, err := detectPreviousTag(headRef); err == nil { + commits, _ := changelogForRange(fmt.Sprintf("%s..%s", prevTag, headRef)) + generatedChangelog = generateChangelog(commits) + } + } + + editorOptions := []string{"Write my own"} + if generatedChangelog != "" { + editorOptions = append(editorOptions, "Write using commit log as template") + } + if tagDescription != "" { + editorOptions = append(editorOptions, "Write using git tag message as template") + } + editorOptions = append(editorOptions, "Leave blank") + + qs := []*survey.Question{ + { + Name: "name", + Prompt: &survey.Input{ + Message: "Title (optional)", + Default: opts.Name, + }, + }, + { + Name: "releaseNotesAction", + Prompt: &survey.Select{ + Message: "Release notes", + Options: editorOptions, + }, + }, + } + err = prompt.SurveyAsk(qs, opts) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + var openEditor bool + var editorContents string + + switch opts.ReleaseNotesAction { + case "Write my own": + openEditor = true + case "Write using commit log as template": + openEditor = true + editorContents = generatedChangelog + case "Write using git tag message as template": + openEditor = true + editorContents = tagDescription + case "Leave blank": + openEditor = false + default: + return fmt.Errorf("invalid action: %v", opts.ReleaseNotesAction) + } + + if openEditor { + // TODO: consider using iostreams here + text, err := surveyext.Edit(editorCommand, "*.md", editorContents, os.Stdin, os.Stdout, os.Stderr, nil) + if err != nil { + return err + } + opts.Body = text + } + + qs = []*survey.Question{ + { + Name: "prerelease", + Prompt: &survey.Confirm{ + Message: "Is this a prerelease?", + Default: opts.Prerelease, + }, + }, + { + Name: "submitAction", + Prompt: &survey.Select{ + Message: "Submit?", + Options: []string{ + "Publish release", + "Save as draft", + "Cancel", + }, + }, + }, + } + + err = prompt.SurveyAsk(qs, opts) + if err != nil { + return fmt.Errorf("could not prompt: %w", err) + } + + switch opts.SubmitAction { + case "Publish release": + opts.Draft = false + case "Save as draft": + opts.Draft = true + case "Cancel": + return cmdutil.SilentError + default: + return fmt.Errorf("invalid action: %v", opts.SubmitAction) + } + } + + params := map[string]interface{}{ + "tag_name": opts.TagName, + "draft": opts.Draft, + "prerelease": opts.Prerelease, + "name": opts.Name, + "body": opts.Body, + } + if opts.Target != "" { + params["target_commitish"] = opts.Target + } + + hasAssets := len(opts.Assets) > 0 + + // Avoid publishing the release until all assets have finished uploading + if hasAssets { + params["draft"] = true + } + + newRelease, err := createRelease(httpClient, baseRepo, params) + if err != nil { + return err + } + + if hasAssets { + uploadURL := newRelease.UploadURL + if idx := strings.IndexRune(uploadURL, '{'); idx > 0 { + uploadURL = uploadURL[:idx] + } + + opts.IO.StartProgressIndicator() + err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if !opts.Draft { + rel, err := publishRelease(httpClient, newRelease.APIURL) + if err != nil { + return err + } + newRelease = rel + } + } + + fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.HTMLURL) + + return nil +} + +func gitTagInfo(tagName string) (string, error) { + cmd := exec.Command("git", "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)") + b, err := run.PrepareCmd(cmd).Output() + return string(b), err +} + +func detectPreviousTag(headRef string) (string, error) { + cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef)) + b, err := run.PrepareCmd(cmd).Output() + return strings.TrimSpace(string(b)), err +} + +type logEntry struct { + Subject string + Body string +} + +func changelogForRange(refRange string) ([]logEntry, error) { + cmd := exec.Command("git", "-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange) + b, err := run.PrepareCmd(cmd).Output() + if err != nil { + return nil, err + } + + var entries []logEntry + for _, cb := range bytes.Split(b, []byte{'\000'}) { + c := strings.ReplaceAll(string(cb), "\r\n", "\n") + c = strings.TrimPrefix(c, "\n") + if len(c) == 0 { + continue + } + parts := strings.SplitN(c, "\n\n", 2) + var body string + subject := strings.ReplaceAll(parts[0], "\n", " ") + if len(parts) > 1 { + body = parts[1] + } + entries = append(entries, logEntry{ + Subject: subject, + Body: body, + }) + } + + return entries, nil +} + +func generateChangelog(commits []logEntry) string { + var parts []string + for _, c := range commits { + // TODO: consider rendering "Merge pull request #123 from owner/branch" differently + parts = append(parts, fmt.Sprintf("* %s", c.Subject)) + if c.Body != "" { + parts = append(parts, text.Indent(c.Body, " ")) + } + } + return strings.Join(parts, "\n\n") +} diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go new file mode 100644 index 000000000..806801ec2 --- /dev/null +++ b/pkg/cmd/release/create/create_test.go @@ -0,0 +1,383 @@ +package create + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/release/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_NewCmdCreate(t *testing.T) { + tempDir := t.TempDir() + tf, err := ioutil.TempFile(tempDir, "release-create") + require.NoError(t, err) + fmt.Fprint(tf, "MY NOTES") + tf.Close() + af1, err := os.Create(filepath.Join(tempDir, "windows.zip")) + require.NoError(t, err) + af1.Close() + af2, err := os.Create(filepath.Join(tempDir, "linux.tgz")) + require.NoError(t, err) + af2.Close() + + tests := []struct { + name string + args string + isTTY bool + stdin string + want CreateOptions + wantErr string + }{ + { + name: "only tag name", + args: "v1.2.3", + isTTY: true, + want: CreateOptions{ + TagName: "v1.2.3", + Target: "", + Name: "", + Body: "", + BodyProvided: false, + Draft: false, + Prerelease: false, + RepoOverride: "", + Concurrency: 5, + Assets: []*shared.AssetForUpload(nil), + }, + }, + { + name: "asset files", + args: fmt.Sprintf("v1.2.3 '%s' '%s#Linux build'", af1.Name(), af2.Name()), + isTTY: true, + want: CreateOptions{ + TagName: "v1.2.3", + Target: "", + Name: "", + Body: "", + BodyProvided: false, + Draft: false, + Prerelease: false, + RepoOverride: "", + Concurrency: 5, + Assets: []*shared.AssetForUpload{ + { + Name: "windows.zip", + Label: "", + }, + { + Name: "linux.tgz", + Label: "Linux build", + }, + }, + }, + }, + { + name: "provide title and body", + args: "v1.2.3 -t mytitle -n mynotes", + isTTY: true, + want: CreateOptions{ + TagName: "v1.2.3", + Target: "", + Name: "mytitle", + Body: "mynotes", + BodyProvided: true, + Draft: false, + Prerelease: false, + RepoOverride: "", + Concurrency: 5, + Assets: []*shared.AssetForUpload(nil), + }, + }, + { + name: "notes from file", + args: fmt.Sprintf(`v1.2.3 -F '%s'`, tf.Name()), + isTTY: true, + want: CreateOptions{ + TagName: "v1.2.3", + Target: "", + Name: "", + Body: "MY NOTES", + BodyProvided: true, + Draft: false, + Prerelease: false, + RepoOverride: "", + Concurrency: 5, + Assets: []*shared.AssetForUpload(nil), + }, + }, + { + name: "notes from stdin", + args: "v1.2.3 -F -", + isTTY: true, + stdin: "MY NOTES", + want: CreateOptions{ + TagName: "v1.2.3", + Target: "", + Name: "", + Body: "MY NOTES", + BodyProvided: true, + Draft: false, + Prerelease: false, + RepoOverride: "", + Concurrency: 5, + Assets: []*shared.AssetForUpload(nil), + }, + }, + { + name: "set draft and prerelease", + args: "v1.2.3 -d -p", + isTTY: true, + want: CreateOptions{ + TagName: "v1.2.3", + Target: "", + Name: "", + Body: "", + BodyProvided: false, + Draft: true, + Prerelease: true, + RepoOverride: "", + Concurrency: 5, + Assets: []*shared.AssetForUpload(nil), + }, + }, + { + name: "no arguments", + args: "", + isTTY: true, + wantErr: "requires at least 1 arg(s), only received 0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, stdin, _, _ := iostreams.Test() + if tt.stdin == "" { + io.SetStdinTTY(tt.isTTY) + } else { + io.SetStdinTTY(false) + fmt.Fprint(stdin, tt.stdin) + } + io.SetStdoutTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + var opts *CreateOptions + cmd := NewCmdCreate(f, func(o *CreateOptions) error { + opts = o + return nil + }) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.TagName, opts.TagName) + assert.Equal(t, tt.want.Target, opts.Target) + assert.Equal(t, tt.want.Name, opts.Name) + assert.Equal(t, tt.want.Body, opts.Body) + assert.Equal(t, tt.want.BodyProvided, opts.BodyProvided) + assert.Equal(t, tt.want.Draft, opts.Draft) + assert.Equal(t, tt.want.Prerelease, opts.Prerelease) + assert.Equal(t, tt.want.Concurrency, opts.Concurrency) + assert.Equal(t, tt.want.RepoOverride, opts.RepoOverride) + + require.Equal(t, len(tt.want.Assets), len(opts.Assets)) + for i := range tt.want.Assets { + assert.Equal(t, tt.want.Assets[i].Name, opts.Assets[i].Name) + assert.Equal(t, tt.want.Assets[i].Label, opts.Assets[i].Label) + } + }) + } +} + +func Test_createRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + opts CreateOptions + wantParams interface{} + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "create a release", + isTTY: true, + opts: CreateOptions{ + TagName: "v1.2.3", + Name: "The Big 1.2", + Body: "* Fixed bugs", + BodyProvided: true, + Target: "", + }, + wantParams: map[string]interface{}{ + "tag_name": "v1.2.3", + "name": "The Big 1.2", + "body": "* Fixed bugs", + "draft": false, + "prerelease": false, + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: ``, + }, + { + name: "with target commitish", + isTTY: true, + opts: CreateOptions{ + TagName: "v1.2.3", + Name: "", + Body: "", + BodyProvided: true, + Target: "main", + }, + wantParams: map[string]interface{}{ + "tag_name": "v1.2.3", + "name": "", + "body": "", + "draft": false, + "prerelease": false, + "target_commitish": "main", + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: ``, + }, + { + name: "as draft", + isTTY: true, + opts: CreateOptions{ + TagName: "v1.2.3", + Name: "", + Body: "", + BodyProvided: true, + Draft: true, + Target: "", + }, + wantParams: map[string]interface{}{ + "tag_name": "v1.2.3", + "name": "", + "body": "", + "draft": true, + "prerelease": false, + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3\n", + wantStderr: ``, + }, + { + name: "publish after uploading files", + isTTY: true, + opts: CreateOptions{ + TagName: "v1.2.3", + Name: "", + Body: "", + BodyProvided: true, + Draft: false, + Target: "", + Assets: []*shared.AssetForUpload{ + { + Name: "ball.tgz", + Open: func() (io.ReadCloser, error) { + return ioutil.NopCloser(bytes.NewBufferString(`TARBALL`)), nil + }, + }, + }, + Concurrency: 1, + }, + wantParams: map[string]interface{}{ + "tag_name": "v1.2.3", + "name": "", + "body": "", + "draft": true, + "prerelease": false, + }, + wantStdout: "https://github.com/OWNER/REPO/releases/tag/v1.2.3-final\n", + wantStderr: ``, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + fakeHTTP := &httpmock.Registry{} + fakeHTTP.Register(httpmock.REST("POST", "repos/OWNER/REPO/releases"), httpmock.StatusStringResponse(201, `{ + "url": "https://api.github.com/releases/123", + "upload_url": "https://api.github.com/assets/upload", + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3" + }`)) + fakeHTTP.Register(httpmock.REST("POST", "assets/upload"), httpmock.StatusStringResponse(201, `{}`)) + fakeHTTP.Register(httpmock.REST("PATCH", "releases/123"), httpmock.StatusStringResponse(201, `{ + "html_url": "https://github.com/OWNER/REPO/releases/tag/v1.2.3-final" + }`)) + + tt.opts.IO = io + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: fakeHTTP}, nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + err := createRun(&tt.opts) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + bb, err := ioutil.ReadAll(fakeHTTP.Requests[0].Body) + require.NoError(t, err) + var params interface{} + err = json.Unmarshal(bb, ¶ms) + require.NoError(t, err) + assert.Equal(t, tt.wantParams, params) + + if len(tt.opts.Assets) > 0 { + q := fakeHTTP.Requests[1].URL.Query() + assert.Equal(t, tt.opts.Assets[0].Name, q.Get("name")) + assert.Equal(t, tt.opts.Assets[0].Label, q.Get("label")) + + bb, err := ioutil.ReadAll(fakeHTTP.Requests[2].Body) + require.NoError(t, err) + var updateParams interface{} + err = json.Unmarshal(bb, &updateParams) + require.NoError(t, err) + assert.Equal(t, map[string]interface{}{"draft": false}, updateParams) + } + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/release/create/http.go b/pkg/cmd/release/create/http.go new file mode 100644 index 000000000..b8c838579 --- /dev/null +++ b/pkg/cmd/release/create/http.go @@ -0,0 +1,78 @@ +package create + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/release/shared" +) + +func createRelease(httpClient *http.Client, repo ghrepo.Interface, params map[string]interface{}) (*shared.Release, error) { + bodyBytes, err := json.Marshal(params) + if err != nil { + return nil, err + } + + path := fmt.Sprintf("repos/%s/%s/releases", repo.RepoOwner(), repo.RepoName()) + url := ghinstance.RESTPrefix(repo.RepoHost()) + path + req, err := http.NewRequest("POST", url, bytes.NewBuffer(bodyBytes)) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var newRelease shared.Release + err = json.Unmarshal(b, &newRelease) + return &newRelease, err +} + +func publishRelease(httpClient *http.Client, releaseURL string) (*shared.Release, error) { + req, err := http.NewRequest("PATCH", releaseURL, bytes.NewBufferString(`{"draft":false}`)) + if err != nil { + return nil, err + } + + req.Header.Add("Content-Type", "application/json") + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + + defer resp.Body.Close() + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var release shared.Release + err = json.Unmarshal(b, &release) + return &release, err +} diff --git a/pkg/cmd/release/delete/delete.go b/pkg/cmd/release/delete/delete.go new file mode 100644 index 000000000..0c78357c5 --- /dev/null +++ b/pkg/cmd/release/delete/delete.go @@ -0,0 +1,119 @@ +package delete + +import ( + "fmt" + "net/http" + + "github.com/AlecAivazis/survey/v2" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/release/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/prompt" + "github.com/spf13/cobra" +) + +type DeleteOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + TagName string + SkipConfirm bool +} + +func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command { + opts := &DeleteOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a release", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.TagName = args[0] + + if runF != nil { + return runF(opts) + } + return deleteRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.SkipConfirm, "yes", "y", false, "Skip the confirmation prompt") + + return cmd +} + +func deleteRun(opts *DeleteOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + + if !opts.SkipConfirm && opts.IO.CanPrompt() { + var confirmed bool + err := prompt.SurveyAskOne(&survey.Confirm{ + Message: fmt.Sprintf("Delete release %s in %s?", release.TagName, ghrepo.FullName(baseRepo)), + Default: true, + }, &confirmed) + if err != nil { + return err + } + + if !confirmed { + return cmdutil.SilentError + } + } + + err = deleteRelease(httpClient, release.APIURL) + if err != nil { + return err + } + + if !opts.IO.IsStdoutTTY() || !opts.IO.IsStderrTTY() { + return nil + } + + iofmt := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.ErrOut, "%s Deleted release %s\n", iofmt.SuccessIcon(), release.TagName) + if !release.IsDraft { + fmt.Fprintf(opts.IO.ErrOut, "%s Note that the %s git tag still remains in the repository\n", iofmt.WarningIcon(), release.TagName) + } + + return nil +} + +func deleteRelease(httpClient *http.Client, releaseURL string) error { + req, err := http.NewRequest("DELETE", releaseURL, nil) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + return nil +} diff --git a/pkg/cmd/release/delete/delete_test.go b/pkg/cmd/release/delete/delete_test.go new file mode 100644 index 000000000..baeb1a379 --- /dev/null +++ b/pkg/cmd/release/delete/delete_test.go @@ -0,0 +1,160 @@ +package delete + +import ( + "bytes" + "io/ioutil" + "net/http" + "testing" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_NewCmdDelete(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + want DeleteOptions + wantErr string + }{ + { + name: "version argument", + args: "v1.2.3", + isTTY: true, + want: DeleteOptions{ + TagName: "v1.2.3", + SkipConfirm: false, + }, + }, + { + name: "skip confirm", + args: "v1.2.3 -y", + isTTY: true, + want: DeleteOptions{ + TagName: "v1.2.3", + SkipConfirm: true, + }, + }, + { + name: "no arguments", + args: "", + isTTY: true, + wantErr: "accepts 1 arg(s), received 0", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + var opts *DeleteOptions + cmd := NewCmdDelete(f, func(o *DeleteOptions) error { + opts = o + return nil + }) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.TagName, opts.TagName) + assert.Equal(t, tt.want.SkipConfirm, opts.SkipConfirm) + }) + } +} + +func Test_deleteRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + opts DeleteOptions + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "skipping confirmation", + isTTY: true, + opts: DeleteOptions{ + TagName: "v1.2.3", + SkipConfirm: true, + }, + wantStdout: ``, + wantStderr: heredoc.Doc(` + ✓ Deleted release v1.2.3 + ! Note that the v1.2.3 git tag still remains in the repository + `), + }, + { + name: "non-interactive", + isTTY: false, + opts: DeleteOptions{ + TagName: "v1.2.3", + SkipConfirm: false, + }, + wantStdout: ``, + wantStderr: ``, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + fakeHTTP := &httpmock.Registry{} + fakeHTTP.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StringResponse(`{ + "tag_name": "v1.2.3", + "draft": false, + "url": "https://api.github.com/repos/OWNER/REPO/releases/23456" + }`)) + fakeHTTP.Register(httpmock.REST("DELETE", "repos/OWNER/REPO/releases/23456"), httpmock.StatusStringResponse(204, "")) + + tt.opts.IO = io + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: fakeHTTP}, nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + err := deleteRun(&tt.opts) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/release/download/download.go b/pkg/cmd/release/download/download.go new file mode 100644 index 000000000..d8c28e289 --- /dev/null +++ b/pkg/cmd/release/download/download.go @@ -0,0 +1,208 @@ +package download + +import ( + "errors" + "io" + "net/http" + "os" + "path/filepath" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/release/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/spf13/cobra" +) + +type DownloadOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + TagName string + FilePatterns []string + Destination string + + // maximum number of simultaneous downloads + Concurrency int +} + +func NewCmdDownload(f *cmdutil.Factory, runF func(*DownloadOptions) error) *cobra.Command { + opts := &DownloadOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "download []", + Short: "Download release assets", + Long: heredoc.Doc(` + Download assets from a GitHub release. + + Without an explicit tag name argument, assets are downloaded from the + latest release in the project. In this case, '--pattern' is required. + `), + Example: heredoc.Doc(` + # download all assets from a specific release + $ gh release download v1.2.3 + + # download only Debian packages for the latest release + $ gh release download --pattern '*.deb' + + # specify multiple file patterns + $ gh release download -p '*.deb' -p '*.rpm' + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) == 0 { + if len(opts.FilePatterns) == 0 { + return &cmdutil.FlagError{Err: errors.New("the '--pattern' flag is required when downloading the latest release")} + } + } else { + opts.TagName = args[0] + } + + opts.Concurrency = 5 + + if runF != nil { + return runF(opts) + } + return downloadRun(opts) + }, + } + + cmd.Flags().StringVarP(&opts.Destination, "dir", "D", ".", "The directory to download files into") + cmd.Flags().StringArrayVarP(&opts.FilePatterns, "pattern", "p", nil, "Download only assets that match a glob pattern") + + return cmd +} + +func downloadRun(opts *DownloadOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + var release *shared.Release + + if opts.TagName == "" { + release, err = shared.FetchLatestRelease(httpClient, baseRepo) + if err != nil { + return err + } + } else { + release, err = shared.FetchRelease(httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + } + + var toDownload []shared.ReleaseAsset + for _, a := range release.Assets { + if len(opts.FilePatterns) > 0 && !matchAny(opts.FilePatterns, a.Name) { + continue + } + toDownload = append(toDownload, a) + } + + if len(toDownload) == 0 { + if len(release.Assets) > 0 { + return errors.New("no assets match the file pattern") + } + return errors.New("no assets to download") + } + + if opts.Destination != "." { + err := os.MkdirAll(opts.Destination, 0755) + if err != nil { + return err + } + } + + opts.IO.StartProgressIndicator() + err = downloadAssets(httpClient, toDownload, opts.Destination, opts.Concurrency) + opts.IO.StopProgressIndicator() + return err +} + +func matchAny(patterns []string, name string) bool { + for _, p := range patterns { + if isMatch, err := filepath.Match(p, name); err == nil && isMatch { + return true + } + } + return false +} + +func downloadAssets(httpClient *http.Client, toDownload []shared.ReleaseAsset, destDir string, numWorkers int) error { + if numWorkers == 0 { + return errors.New("the number of concurrent workers needs to be greater than 0") + } + + jobs := make(chan shared.ReleaseAsset, len(toDownload)) + results := make(chan error, len(toDownload)) + + if len(toDownload) < numWorkers { + numWorkers = len(toDownload) + } + + for w := 1; w <= numWorkers; w++ { + go func() { + for a := range jobs { + results <- downloadAsset(httpClient, a.APIURL, filepath.Join(destDir, a.Name)) + } + }() + } + + for _, a := range toDownload { + jobs <- a + } + close(jobs) + + var downloadError error + for i := 0; i < len(toDownload); i++ { + if err := <-results; err != nil { + downloadError = err + } + } + + return downloadError +} + +func downloadAsset(httpClient *http.Client, assetURL, destinationPath string) error { + req, err := http.NewRequest("GET", assetURL, nil) + if err != nil { + return err + } + + req.Header.Set("Accept", "application/octet-stream") + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return api.HandleHTTPError(resp) + } + + f, err := os.OpenFile(destinationPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) + if err != nil { + return err + } + defer f.Close() + + _, err = io.Copy(f, resp.Body) + return err +} diff --git a/pkg/cmd/release/download/download_test.go b/pkg/cmd/release/download/download_test.go new file mode 100644 index 000000000..9fa8677c6 --- /dev/null +++ b/pkg/cmd/release/download/download_test.go @@ -0,0 +1,254 @@ +package download + +import ( + "bytes" + "io/ioutil" + "net/http" + "os" + "path/filepath" + "testing" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_NewCmdDownload(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + want DownloadOptions + wantErr string + }{ + { + name: "version argument", + args: "v1.2.3", + isTTY: true, + want: DownloadOptions{ + TagName: "v1.2.3", + FilePatterns: []string(nil), + Destination: ".", + Concurrency: 5, + }, + }, + { + name: "version and file pattern", + args: "v1.2.3 -p *.tgz", + isTTY: true, + want: DownloadOptions{ + TagName: "v1.2.3", + FilePatterns: []string{"*.tgz"}, + Destination: ".", + Concurrency: 5, + }, + }, + { + name: "multiple file patterns", + args: "v1.2.3 -p 1 -p 2,3", + isTTY: true, + want: DownloadOptions{ + TagName: "v1.2.3", + FilePatterns: []string{"1", "2,3"}, + Destination: ".", + Concurrency: 5, + }, + }, + { + name: "version and destination", + args: "v1.2.3 -D tmp/assets", + isTTY: true, + want: DownloadOptions{ + TagName: "v1.2.3", + FilePatterns: []string(nil), + Destination: "tmp/assets", + Concurrency: 5, + }, + }, + { + name: "download latest", + args: "-p *", + isTTY: true, + want: DownloadOptions{ + TagName: "", + FilePatterns: []string{"*"}, + Destination: ".", + Concurrency: 5, + }, + }, + { + name: "no arguments", + args: "", + isTTY: true, + wantErr: "the '--pattern' flag is required when downloading the latest release", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + var opts *DownloadOptions + cmd := NewCmdDownload(f, func(o *DownloadOptions) error { + opts = o + return nil + }) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.TagName, opts.TagName) + assert.Equal(t, tt.want.FilePatterns, opts.FilePatterns) + assert.Equal(t, tt.want.Destination, opts.Destination) + assert.Equal(t, tt.want.Concurrency, opts.Concurrency) + }) + } +} + +func Test_downloadRun(t *testing.T) { + tests := []struct { + name string + isTTY bool + opts DownloadOptions + wantErr string + wantStdout string + wantStderr string + wantFiles []string + }{ + { + name: "download all assets", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + Destination: ".", + Concurrency: 2, + }, + wantStdout: ``, + wantStderr: ``, + wantFiles: []string{ + "linux.tgz", + "windows-32bit.zip", + "windows-64bit.zip", + }, + }, + { + name: "download assets matching pattern into destination directory", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + FilePatterns: []string{"windows-*.zip"}, + Destination: "tmp/assets", + Concurrency: 2, + }, + wantStdout: ``, + wantStderr: ``, + wantFiles: []string{ + "tmp/assets/windows-32bit.zip", + "tmp/assets/windows-64bit.zip", + }, + }, + { + name: "no match for pattern", + isTTY: true, + opts: DownloadOptions{ + TagName: "v1.2.3", + FilePatterns: []string{"linux*.zip"}, + Destination: ".", + Concurrency: 2, + }, + wantStdout: ``, + wantStderr: ``, + wantErr: "no assets match the file pattern", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + tt.opts.Destination = filepath.Join(tempDir, tt.opts.Destination) + + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + fakeHTTP := &httpmock.Registry{} + fakeHTTP.Register(httpmock.REST("GET", "repos/OWNER/REPO/releases/tags/v1.2.3"), httpmock.StringResponse(`{ + "assets": [ + { "name": "windows-32bit.zip", "size": 12, + "url": "https://api.github.com/assets/1234" }, + { "name": "windows-64bit.zip", "size": 34, + "url": "https://api.github.com/assets/3456" }, + { "name": "linux.tgz", "size": 56, + "url": "https://api.github.com/assets/5678" } + ] + }`)) + fakeHTTP.Register(httpmock.REST("GET", "assets/1234"), httpmock.StringResponse(`1234`)) + fakeHTTP.Register(httpmock.REST("GET", "assets/3456"), httpmock.StringResponse(`3456`)) + fakeHTTP.Register(httpmock.REST("GET", "assets/5678"), httpmock.StringResponse(`5678`)) + + tt.opts.IO = io + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: fakeHTTP}, nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + err := downloadRun(&tt.opts) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, "application/octet-stream", fakeHTTP.Requests[1].Header.Get("Accept")) + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + + downloadedFiles, err := listFiles(tempDir) + require.NoError(t, err) + assert.Equal(t, tt.wantFiles, downloadedFiles) + }) + } +} + +func listFiles(dir string) ([]string, error) { + var files []string + err := filepath.Walk(dir, func(p string, f os.FileInfo, err error) error { + if !f.IsDir() { + rp, err := filepath.Rel(dir, p) + if err != nil { + return err + } + files = append(files, filepath.ToSlash(rp)) + } + return err + }) + return files, err +} diff --git a/pkg/cmd/release/list/http.go b/pkg/cmd/release/list/http.go new file mode 100644 index 000000000..763963ff1 --- /dev/null +++ b/pkg/cmd/release/list/http.go @@ -0,0 +1,72 @@ +package list + +import ( + "context" + "net/http" + "time" + + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/shurcooL/githubv4" + "github.com/shurcooL/graphql" +) + +type Release struct { + Name string + TagName string + IsDraft bool + IsPrerelease bool + CreatedAt time.Time + PublishedAt time.Time +} + +func fetchReleases(httpClient *http.Client, repo ghrepo.Interface, limit int) ([]Release, error) { + var query struct { + Repository struct { + Releases struct { + Nodes []Release + PageInfo struct { + HasNextPage bool + EndCursor string + } + } `graphql:"releases(first: $perPage, orderBy: {field: CREATED_AT, direction: DESC}, after: $endCursor)"` + } `graphql:"repository(owner: $owner, name: $name)"` + } + + perPage := limit + if limit > 100 { + perPage = 100 + } + + variables := map[string]interface{}{ + "owner": githubv4.String(repo.RepoOwner()), + "name": githubv4.String(repo.RepoName()), + "perPage": githubv4.Int(perPage), + "endCursor": (*githubv4.String)(nil), + } + + gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), httpClient) + + var releases []Release +loop: + for { + err := gql.QueryNamed(context.Background(), "RepositoryReleaseList", &query, variables) + if err != nil { + return nil, err + } + + for _, r := range query.Repository.Releases.Nodes { + releases = append(releases, r) + if len(releases) == limit { + break loop + } + } + + if !query.Repository.Releases.PageInfo.HasNextPage { + break + } + variables["endCursor"] = githubv4.String(query.Repository.Releases.PageInfo.EndCursor) + } + + return releases, nil +} diff --git a/pkg/cmd/release/list/list.go b/pkg/cmd/release/list/list.go new file mode 100644 index 000000000..caaab772d --- /dev/null +++ b/pkg/cmd/release/list/list.go @@ -0,0 +1,115 @@ +package list + +import ( + "fmt" + "net/http" + "time" + + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/pkg/text" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ListOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + LimitResults int +} + +func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command { + opts := &ListOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "list", + Short: "List releases in a repository", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if runF != nil { + return runF(opts) + } + return listRun(opts) + }, + } + + cmd.Flags().IntVarP(&opts.LimitResults, "limit", "L", 30, "Maximum number of items to fetch") + + return cmd +} + +func listRun(opts *ListOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + releases, err := fetchReleases(httpClient, baseRepo, opts.LimitResults) + if err != nil { + return err + } + + now := time.Now() + table := utils.NewTablePrinter(opts.IO) + iofmt := opts.IO.ColorScheme() + seenLatest := false + for _, rel := range releases { + title := text.ReplaceExcessiveWhitespace(rel.Name) + if title == "" { + title = rel.TagName + } + table.AddField(title, nil, nil) + + badge := "" + var badgeColor func(string) string + if !rel.IsDraft && !rel.IsPrerelease && !seenLatest { + badge = "Latest" + badgeColor = iofmt.Green + seenLatest = true + } else if rel.IsDraft { + badge = "Draft" + badgeColor = iofmt.Red + } else if rel.IsPrerelease { + badge = "Pre-release" + badgeColor = iofmt.Yellow + } + table.AddField(badge, nil, badgeColor) + + tagName := rel.TagName + if table.IsTTY() { + tagName = fmt.Sprintf("(%s)", tagName) + } + table.AddField(tagName, nil, nil) + + pubDate := rel.PublishedAt + if rel.PublishedAt.IsZero() { + pubDate = rel.CreatedAt + } + publishedAt := pubDate.Format(time.RFC3339) + if table.IsTTY() { + publishedAt = utils.FuzzyAgo(now.Sub(pubDate)) + } + table.AddField(publishedAt, nil, iofmt.Gray) + table.EndRow() + } + err = table.Render() + if err != nil { + return err + } + + return nil +} diff --git a/pkg/cmd/release/list/list_test.go b/pkg/cmd/release/list/list_test.go new file mode 100644 index 000000000..8f17dc6ce --- /dev/null +++ b/pkg/cmd/release/list/list_test.go @@ -0,0 +1,189 @@ +package list + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_NewCmdList(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + want ListOptions + wantErr string + }{ + { + name: "no arguments", + args: "", + isTTY: true, + want: ListOptions{ + LimitResults: 30, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + var opts *ListOptions + cmd := NewCmdList(f, func(o *ListOptions) error { + opts = o + return nil + }) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.LimitResults, opts.LimitResults) + }) + } +} + +func Test_listRun(t *testing.T) { + frozenTime, err := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00") + require.NoError(t, err) + + tests := []struct { + name string + isTTY bool + opts ListOptions + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "list releases", + isTTY: true, + opts: ListOptions{ + LimitResults: 30, + }, + wantStdout: heredoc.Doc(` + v1.1.0 Draft (v1.1.0) about 1 day ago + The big 1.0 Latest (v1.0.0) about 1 day ago + 1.0 release candidate Pre-release (v1.0.0-pre.2) about 1 day ago + New features (v0.9.2) about 1 day ago + `), + wantStderr: ``, + }, + { + name: "machine-readable", + isTTY: false, + opts: ListOptions{ + LimitResults: 30, + }, + wantStdout: heredoc.Doc(` + v1.1.0 Draft v1.1.0 2020-08-31T15:44:24+02:00 + The big 1.0 Latest v1.0.0 2020-08-31T15:44:24+02:00 + 1.0 release candidate Pre-release v1.0.0-pre.2 2020-08-31T15:44:24+02:00 + New features v0.9.2 2020-08-31T15:44:24+02:00 + `), + wantStderr: ``, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + createdAt := frozenTime + if tt.isTTY { + createdAt = time.Now().Add(time.Duration(-24) * time.Hour) + } + + fakeHTTP := &httpmock.Registry{} + fakeHTTP.Register(httpmock.GraphQL(`\bRepositoryReleaseList\(`), httpmock.StringResponse(fmt.Sprintf(` + { "data": { "repository": { "releases": { + "nodes": [ + { + "name": "", + "tagName": "v1.1.0", + "isDraft": true, + "isPrerelease": false, + "createdAt": "%[1]s", + "publishedAt": "%[1]s" + }, + { + "name": "The big 1.0", + "tagName": "v1.0.0", + "isDraft": false, + "isPrerelease": false, + "createdAt": "%[1]s", + "publishedAt": "%[1]s" + }, + { + "name": "1.0 release candidate", + "tagName": "v1.0.0-pre.2", + "isDraft": false, + "isPrerelease": true, + "createdAt": "%[1]s", + "publishedAt": "%[1]s" + }, + { + "name": "New features", + "tagName": "v0.9.2", + "isDraft": false, + "isPrerelease": false, + "createdAt": "%[1]s", + "publishedAt": "%[1]s" + } + ] + } } } }`, createdAt.Format(time.RFC3339)))) + + tt.opts.IO = io + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: fakeHTTP}, nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + err := listRun(&tt.opts) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} diff --git a/pkg/cmd/release/release.go b/pkg/cmd/release/release.go new file mode 100644 index 000000000..facd0a1e3 --- /dev/null +++ b/pkg/cmd/release/release.go @@ -0,0 +1,33 @@ +package release + +import ( + cmdCreate "github.com/cli/cli/pkg/cmd/release/create" + cmdDelete "github.com/cli/cli/pkg/cmd/release/delete" + cmdDownload "github.com/cli/cli/pkg/cmd/release/download" + cmdList "github.com/cli/cli/pkg/cmd/release/list" + cmdUpload "github.com/cli/cli/pkg/cmd/release/upload" + cmdView "github.com/cli/cli/pkg/cmd/release/view" + "github.com/cli/cli/pkg/cmdutil" + "github.com/spf13/cobra" +) + +func NewCmdRelease(f *cmdutil.Factory) *cobra.Command { + cmd := &cobra.Command{ + Use: "release ", + Short: "Manage GitHub releases", + Annotations: map[string]string{ + "IsCore": "true", + }, + } + + cmdutil.EnableRepoOverride(cmd, f) + + cmd.AddCommand(cmdCreate.NewCmdCreate(f, nil)) + cmd.AddCommand(cmdDelete.NewCmdDelete(f, nil)) + cmd.AddCommand(cmdDownload.NewCmdDownload(f, nil)) + cmd.AddCommand(cmdList.NewCmdList(f, nil)) + cmd.AddCommand(cmdView.NewCmdView(f, nil)) + cmd.AddCommand(cmdUpload.NewCmdUpload(f, nil)) + + return cmd +} diff --git a/pkg/cmd/release/shared/fetch.go b/pkg/cmd/release/shared/fetch.go new file mode 100644 index 000000000..4cc31ff48 --- /dev/null +++ b/pkg/cmd/release/shared/fetch.go @@ -0,0 +1,163 @@ +package shared + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "time" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" +) + +type Release struct { + TagName string `json:"tag_name"` + Name string `json:"name"` + Body string `json:"body"` + IsDraft bool `json:"draft"` + IsPrerelease bool `json:"prerelease"` + CreatedAt time.Time `json:"created_at"` + PublishedAt time.Time `json:"published_at"` + + APIURL string `json:"url"` + UploadURL string `json:"upload_url"` + HTMLURL string `json:"html_url"` + Assets []ReleaseAsset + + Author struct { + Login string + } +} + +type ReleaseAsset struct { + Name string + Size int64 + State string + APIURL string `json:"url"` +} + +// FetchRelease finds a repository release by its tagName. +func FetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*Release, error) { + path := fmt.Sprintf("repos/%s/%s/releases/tags/%s", baseRepo.RepoOwner(), baseRepo.RepoName(), tagName) + url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode == 404 { + if canPush, err := api.CanPushToRepo(httpClient, baseRepo); err == nil && canPush { + return FindDraftRelease(httpClient, baseRepo, tagName) + } else if err != nil { + return nil, err + } + } + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var release Release + err = json.Unmarshal(b, &release) + if err != nil { + return nil, err + } + + return &release, nil +} + +// FetchLatestRelease finds the latest published release for a repository. +func FetchLatestRelease(httpClient *http.Client, baseRepo ghrepo.Interface) (*Release, error) { + path := fmt.Sprintf("repos/%s/%s/releases/latest", baseRepo.RepoOwner(), baseRepo.RepoName()) + url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var release Release + err = json.Unmarshal(b, &release) + if err != nil { + return nil, err + } + + return &release, nil +} + +// FindDraftRelease returns the latest draft release that matches tagName. +func FindDraftRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName string) (*Release, error) { + path := fmt.Sprintf("repos/%s/%s/releases", baseRepo.RepoOwner(), baseRepo.RepoName()) + url := ghinstance.RESTPrefix(baseRepo.RepoHost()) + path + + perPage := 100 + page := 1 + for { + req, err := http.NewRequest("GET", fmt.Sprintf("%s?per_page=%d&page=%d", url, perPage, page), nil) + if err != nil { + return nil, err + } + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode > 299 { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var releases []Release + err = json.Unmarshal(b, &releases) + if err != nil { + return nil, err + } + + for _, r := range releases { + if r.IsDraft && r.TagName == tagName { + return &r, nil + } + } + + if len(releases) < perPage { + break + } + page++ + } + + return nil, errors.New("release not found") +} diff --git a/pkg/cmd/release/shared/upload.go b/pkg/cmd/release/shared/upload.go new file mode 100644 index 000000000..f98f46b92 --- /dev/null +++ b/pkg/cmd/release/shared/upload.go @@ -0,0 +1,215 @@ +package shared + +import ( + "encoding/json" + "errors" + "io" + "io/ioutil" + "mime" + "net/http" + "net/url" + "os" + "path" + "strings" + "time" + + "github.com/cli/cli/api" +) + +type AssetForUpload struct { + Name string + Label string + + Size int64 + MIMEType string + Open func() (io.ReadCloser, error) + + ExistingURL string +} + +func AssetsFromArgs(args []string) (assets []*AssetForUpload, err error) { + for _, arg := range args { + var label string + fn := arg + if idx := strings.IndexRune(arg, '#'); idx > 0 { + fn = arg[0:idx] + label = arg[idx+1:] + } + + var fi os.FileInfo + fi, err = os.Stat(fn) + if err != nil { + return + } + + assets = append(assets, &AssetForUpload{ + Open: func() (io.ReadCloser, error) { + return os.Open(fn) + }, + Size: fi.Size(), + Name: fi.Name(), + Label: label, + MIMEType: typeForFilename(fi.Name()), + }) + } + return +} + +func typeForFilename(fn string) string { + ext := fileExt(fn) + switch ext { + case ".zip": + return "application/zip" + case ".js": + return "application/javascript" + case ".tgz", ".tar.gz": + return "application/x-gtar" + case ".bz2": + return "application/x-bzip2" + case ".dmg": + return "application/x-apple-diskimage" + case ".rpm": + return "application/x-rpm" + case ".deb": + return "application/x-debian-package" + } + + t := mime.TypeByExtension(ext) + if t == "" { + return "application/octet-stream" + } + return t +} + +func fileExt(fn string) string { + fn = strings.ToLower(fn) + if strings.HasSuffix(fn, ".tar.gz") { + return ".tar.gz" + } + return path.Ext(fn) +} + +func ConcurrentUpload(httpClient *http.Client, uploadURL string, numWorkers int, assets []*AssetForUpload) error { + if numWorkers == 0 { + return errors.New("the number of concurrent workers needs to be greater than 0") + } + + jobs := make(chan AssetForUpload, len(assets)) + results := make(chan error, len(assets)) + + if len(assets) < numWorkers { + numWorkers = len(assets) + } + + for w := 1; w <= numWorkers; w++ { + go func() { + for a := range jobs { + results <- uploadWithDelete(httpClient, uploadURL, a) + } + }() + } + + for _, a := range assets { + jobs <- *a + } + close(jobs) + + var uploadError error + for i := 0; i < len(assets); i++ { + if err := <-results; err != nil { + uploadError = err + } + } + return uploadError +} + +const maxRetries = 3 + +func uploadWithDelete(httpClient *http.Client, uploadURL string, a AssetForUpload) error { + if a.ExistingURL != "" { + err := deleteAsset(httpClient, a.ExistingURL) + if err != nil { + return err + } + } + + retries := 0 + for { + var httpError api.HTTPError + _, err := uploadAsset(httpClient, uploadURL, a) + // retry upload several times upon receiving HTTP 5xx + if err == nil || !errors.As(err, &httpError) || httpError.StatusCode < 500 || retries < maxRetries { + return err + } + retries++ + time.Sleep(time.Duration(retries) * time.Second) + } +} + +func uploadAsset(httpClient *http.Client, uploadURL string, asset AssetForUpload) (*ReleaseAsset, error) { + u, err := url.Parse(uploadURL) + if err != nil { + return nil, err + } + params := u.Query() + params.Set("name", asset.Name) + params.Set("label", asset.Label) + u.RawQuery = params.Encode() + + f, err := asset.Open() + if err != nil { + return nil, err + } + defer f.Close() + + req, err := http.NewRequest("POST", u.String(), f) + if err != nil { + return nil, err + } + req.ContentLength = asset.Size + req.Header.Set("Content-Type", asset.MIMEType) + + resp, err := httpClient.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + return nil, api.HandleHTTPError(resp) + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + var newAsset ReleaseAsset + err = json.Unmarshal(b, &newAsset) + if err != nil { + return nil, err + } + + return &newAsset, nil +} + +func deleteAsset(httpClient *http.Client, assetURL string) error { + req, err := http.NewRequest("DELETE", assetURL, nil) + if err != nil { + return err + } + + resp, err := httpClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + return api.HandleHTTPError(resp) + } + + return nil +} diff --git a/pkg/cmd/release/shared/upload_test.go b/pkg/cmd/release/shared/upload_test.go new file mode 100644 index 000000000..da1aa8918 --- /dev/null +++ b/pkg/cmd/release/shared/upload_test.go @@ -0,0 +1,69 @@ +package shared + +import "testing" + +func Test_typeForFilename(t *testing.T) { + tests := []struct { + name string + file string + want string + }{ + { + name: "tar", + file: "ball.tar", + want: "application/x-tar", + }, + { + name: "tgz", + file: "ball.tgz", + want: "application/x-gtar", + }, + { + name: "tar.gz", + file: "ball.tar.gz", + want: "application/x-gtar", + }, + { + name: "bz2", + file: "ball.tar.bz2", + want: "application/x-bzip2", + }, + { + name: "zip", + file: "archive.zip", + want: "application/zip", + }, + { + name: "js", + file: "app.js", + want: "application/javascript", + }, + { + name: "dmg", + file: "apple.dmg", + want: "application/x-apple-diskimage", + }, + { + name: "rpm", + file: "package.rpm", + want: "application/x-rpm", + }, + { + name: "deb", + file: "package.deb", + want: "application/x-debian-package", + }, + { + name: "no extension", + file: "myfile", + want: "application/octet-stream", + }, + } + for _, tt := range tests { + t.Run(tt.file, func(t *testing.T) { + if got := typeForFilename(tt.file); got != tt.want { + t.Errorf("typeForFilename() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/release/upload/upload.go b/pkg/cmd/release/upload/upload.go new file mode 100644 index 000000000..79e67f7ea --- /dev/null +++ b/pkg/cmd/release/upload/upload.go @@ -0,0 +1,123 @@ +package upload + +import ( + "fmt" + "net/http" + "strings" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/release/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type UploadOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + TagName string + Assets []*shared.AssetForUpload + + // maximum number of simultaneous uploads + Concurrency int + OverwriteExisting bool +} + +func NewCmdUpload(f *cmdutil.Factory, runF func(*UploadOptions) error) *cobra.Command { + opts := &UploadOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "upload ...", + Short: "Upload assets to a release", + Long: heredoc.Doc(` + Upload asset files to a GitHub Release. + + To define a display label for an asset, append text starting with '#' after the + file name. + `), + Args: cobra.MinimumNArgs(2), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + opts.TagName = args[0] + + var err error + opts.Assets, err = shared.AssetsFromArgs(args[1:]) + if err != nil { + return err + } + + opts.Concurrency = 5 + + if runF != nil { + return runF(opts) + } + return uploadRun(opts) + }, + } + + cmd.Flags().BoolVar(&opts.OverwriteExisting, "clobber", false, "Overwrite existing assets of the same name") + + return cmd +} + +func uploadRun(opts *UploadOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + release, err := shared.FetchRelease(httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + + uploadURL := release.UploadURL + if idx := strings.IndexRune(uploadURL, '{'); idx > 0 { + uploadURL = uploadURL[:idx] + } + + var existingNames []string + for _, a := range opts.Assets { + for _, ea := range release.Assets { + if ea.Name == a.Name { + a.ExistingURL = ea.APIURL + existingNames = append(existingNames, ea.Name) + break + } + } + } + + if len(existingNames) > 0 && !opts.OverwriteExisting { + return fmt.Errorf("asset under the same name already exists: %v", existingNames) + } + + opts.IO.StartProgressIndicator() + err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + + if opts.IO.IsStdoutTTY() { + iofmt := opts.IO.ColorScheme() + fmt.Fprintf(opts.IO.Out, "Successfully uploaded %s to %s\n", + utils.Pluralize(len(opts.Assets), "asset"), + iofmt.Bold(release.TagName)) + } + + return nil +} diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go new file mode 100644 index 000000000..8eb8ce03c --- /dev/null +++ b/pkg/cmd/release/view/view.go @@ -0,0 +1,193 @@ +package view + +import ( + "fmt" + "io" + "net/http" + "strings" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmd/release/shared" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type ViewOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + TagName string + WebMode bool +} + +func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command { + opts := &ViewOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + } + + cmd := &cobra.Command{ + Use: "view []", + Short: "View information about a release", + Long: heredoc.Doc(` + View information about a GitHub Release. + + Without an explicit tag name argument, the latest release in the project + is shown. + `), + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + // support `-R, --repo` override + opts.BaseRepo = f.BaseRepo + + if len(args) > 0 { + opts.TagName = args[0] + } + + if runF != nil { + return runF(opts) + } + return viewRun(opts) + }, + } + + cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the release in the browser") + + return cmd +} + +func viewRun(opts *ViewOptions) error { + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + baseRepo, err := opts.BaseRepo() + if err != nil { + return err + } + + var release *shared.Release + + if opts.TagName == "" { + release, err = shared.FetchLatestRelease(httpClient, baseRepo) + if err != nil { + return err + } + } else { + release, err = shared.FetchRelease(httpClient, baseRepo, opts.TagName) + if err != nil { + return err + } + } + + if opts.WebMode { + if opts.IO.IsStdoutTTY() { + fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(release.HTMLURL)) + } + return utils.OpenInBrowser(release.HTMLURL) + } + + if opts.IO.IsStdoutTTY() { + if err := renderReleaseTTY(opts.IO, release); err != nil { + return err + } + } else { + if err := renderReleasePlain(opts.IO.Out, release); err != nil { + return err + } + } + + return nil +} + +func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error { + iofmt := io.ColorScheme() + w := io.Out + + fmt.Fprintf(w, "%s\n", iofmt.Bold(release.TagName)) + if release.IsDraft { + fmt.Fprintf(w, "%s • ", iofmt.Red("Draft")) + } else if release.IsPrerelease { + fmt.Fprintf(w, "%s • ", iofmt.Yellow("Pre-release")) + } + if release.IsDraft { + fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s created this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.CreatedAt))))) + } else { + fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.PublishedAt))))) + } + + renderedDescription, err := utils.RenderMarkdown(release.Body) + if err != nil { + return err + } + fmt.Fprintln(w, renderedDescription) + + if len(release.Assets) > 0 { + fmt.Fprintf(w, "%s\n", iofmt.Bold("Assets")) + table := utils.NewTablePrinter(io) + for _, a := range release.Assets { + table.AddField(a.Name, nil, nil) + table.AddField(humanFileSize(a.Size), nil, nil) + table.EndRow() + } + err := table.Render() + if err != nil { + return err + } + fmt.Fprint(w, "\n") + } + + fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.HTMLURL))) + return nil +} + +func renderReleasePlain(w io.Writer, release *shared.Release) error { + fmt.Fprintf(w, "title:\t%s\n", release.Name) + fmt.Fprintf(w, "tag:\t%s\n", release.TagName) + fmt.Fprintf(w, "draft:\t%v\n", release.IsDraft) + fmt.Fprintf(w, "prerelease:\t%v\n", release.IsPrerelease) + fmt.Fprintf(w, "author:\t%s\n", release.Author.Login) + fmt.Fprintf(w, "created:\t%s\n", release.CreatedAt.Format(time.RFC3339)) + if !release.IsDraft { + fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339)) + } + fmt.Fprintf(w, "url:\t%s\n", release.HTMLURL) + for _, a := range release.Assets { + fmt.Fprintf(w, "asset:\t%s\n", a.Name) + } + fmt.Fprint(w, "--\n") + fmt.Fprint(w, release.Body) + return nil +} + +func humanFileSize(s int64) string { + if s < 1024 { + return fmt.Sprintf("%d B", s) + } + + kb := float64(s) / 1024 + if kb < 1024 { + return fmt.Sprintf("%s KiB", floatToString(kb, 2)) + } + + mb := kb / 1024 + if mb < 1024 { + return fmt.Sprintf("%s MiB", floatToString(mb, 2)) + } + + gb := mb / 1024 + return fmt.Sprintf("%s GiB", floatToString(gb, 2)) +} + +// render float to fixed precision using truncation instead of rounding +func floatToString(f float64, p uint8) string { + fs := fmt.Sprintf("%#f%0*s", f, p, "") + idx := strings.IndexRune(fs, '.') + return fs[:idx+int(p)+1] +} diff --git a/pkg/cmd/release/view/view_test.go b/pkg/cmd/release/view/view_test.go new file mode 100644 index 000000000..dba25b680 --- /dev/null +++ b/pkg/cmd/release/view/view_test.go @@ -0,0 +1,289 @@ +package view + +import ( + "bytes" + "fmt" + "io/ioutil" + "net/http" + "testing" + "time" + + "github.com/MakeNowJust/heredoc" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/httpmock" + "github.com/cli/cli/pkg/iostreams" + "github.com/google/shlex" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_NewCmdView(t *testing.T) { + tests := []struct { + name string + args string + isTTY bool + want ViewOptions + wantErr string + }{ + { + name: "version argument", + args: "v1.2.3", + isTTY: true, + want: ViewOptions{ + TagName: "v1.2.3", + WebMode: false, + }, + }, + { + name: "no arguments", + args: "", + isTTY: true, + want: ViewOptions{ + TagName: "", + WebMode: false, + }, + }, + { + name: "web mode", + args: "-w", + isTTY: true, + want: ViewOptions{ + TagName: "", + WebMode: true, + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, _, _ := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + f := &cmdutil.Factory{ + IOStreams: io, + } + + var opts *ViewOptions + cmd := NewCmdView(f, func(o *ViewOptions) error { + opts = o + return nil + }) + cmd.PersistentFlags().StringP("repo", "R", "", "") + + argv, err := shlex.Split(tt.args) + require.NoError(t, err) + cmd.SetArgs(argv) + + cmd.SetIn(&bytes.Buffer{}) + cmd.SetOut(ioutil.Discard) + cmd.SetErr(ioutil.Discard) + + _, err = cmd.ExecuteC() + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.want.TagName, opts.TagName) + assert.Equal(t, tt.want.WebMode, opts.WebMode) + }) + } +} + +func Test_viewRun(t *testing.T) { + oneHourAgo := time.Now().Add(time.Duration(-24) * time.Hour) + frozenTime, err := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00") + require.NoError(t, err) + + tests := []struct { + name string + isTTY bool + releasedAt time.Time + opts ViewOptions + wantErr string + wantStdout string + wantStderr string + }{ + { + name: "view specific release", + isTTY: true, + releasedAt: oneHourAgo, + opts: ViewOptions{ + TagName: "v1.2.3", + }, + wantStdout: heredoc.Doc(` + v1.2.3 + MonaLisa released this about 1 day ago + + + • Fixed bugs + + + Assets + windows.zip 12 B + linux.tgz 34 B + + View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3 + `), + wantStderr: ``, + }, + { + name: "view latest release", + isTTY: true, + releasedAt: oneHourAgo, + opts: ViewOptions{ + TagName: "", + }, + wantStdout: heredoc.Doc(` + v1.2.3 + MonaLisa released this about 1 day ago + + + • Fixed bugs + + + Assets + windows.zip 12 B + linux.tgz 34 B + + View on GitHub: https://github.com/OWNER/REPO/releases/tags/v1.2.3 + `), + wantStderr: ``, + }, + { + name: "view machine-readable", + isTTY: false, + releasedAt: frozenTime, + opts: ViewOptions{ + TagName: "v1.2.3", + }, + wantStdout: heredoc.Doc(` + title: + tag: v1.2.3 + draft: false + prerelease: false + author: MonaLisa + created: 2020-08-31T15:44:24+02:00 + published: 2020-08-31T15:44:24+02:00 + url: https://github.com/OWNER/REPO/releases/tags/v1.2.3 + asset: windows.zip + asset: linux.tgz + -- + * Fixed bugs + `), + wantStderr: ``, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(tt.isTTY) + io.SetStdinTTY(tt.isTTY) + io.SetStderrTTY(tt.isTTY) + + path := "repos/OWNER/REPO/releases/tags/v1.2.3" + if tt.opts.TagName == "" { + path = "repos/OWNER/REPO/releases/latest" + } + + fakeHTTP := &httpmock.Registry{} + fakeHTTP.Register(httpmock.REST("GET", path), httpmock.StringResponse(fmt.Sprintf(`{ + "tag_name": "v1.2.3", + "draft": false, + "author": { "login": "MonaLisa" }, + "body": "* Fixed bugs\n", + "created_at": "%[1]s", + "published_at": "%[1]s", + "html_url": "https://github.com/OWNER/REPO/releases/tags/v1.2.3", + "assets": [ + { "name": "windows.zip", "size": 12 }, + { "name": "linux.tgz", "size": 34 } + ] + }`, tt.releasedAt.Format(time.RFC3339)))) + + tt.opts.IO = io + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: fakeHTTP}, nil + } + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + return ghrepo.FromFullName("OWNER/REPO") + } + + err := viewRun(&tt.opts) + if tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + return + } else { + require.NoError(t, err) + } + + assert.Equal(t, tt.wantStdout, stdout.String()) + assert.Equal(t, tt.wantStderr, stderr.String()) + }) + } +} + +func Test_humanFileSize(t *testing.T) { + tests := []struct { + name string + size int64 + want string + }{ + { + name: "min bytes", + size: 1, + want: "1 B", + }, + { + name: "max bytes", + size: 1023, + want: "1023 B", + }, + { + name: "min kibibytes", + size: 1024, + want: "1.00 KiB", + }, + { + name: "max kibibytes", + size: 1024*1024 - 1, + want: "1023.99 KiB", + }, + { + name: "min mibibytes", + size: 1024 * 1024, + want: "1.00 MiB", + }, + { + name: "fractional mibibytes", + size: 1024*1024*12 + 1024*350, + want: "12.34 MiB", + }, + { + name: "max mibibytes", + size: 1024*1024*1024 - 1, + want: "1023.99 MiB", + }, + { + name: "min gibibytes", + size: 1024 * 1024 * 1024, + want: "1.00 GiB", + }, + { + name: "fractional gibibytes", + size: 1024 * 1024 * 1024 * 1.5, + want: "1.50 GiB", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := humanFileSize(tt.size); got != tt.want { + t.Errorf("humanFileSize() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go index fc53b460e..fb37e27ed 100644 --- a/pkg/cmd/repo/create/create.go +++ b/pkg/cmd/repo/create/create.go @@ -73,6 +73,16 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co opts.Name = args[0] } + if !opts.IO.CanPrompt() { + if opts.Name == "" { + return &cmdutil.FlagError{Err: errors.New("name argument required when not running interactively")} + } + + if !opts.Internal && !opts.Private && !opts.Public { + return &cmdutil.FlagError{Err: errors.New("--public, --private, or --internal required when not running interactively")} + } + } + if runF != nil { return runF(opts) } @@ -264,7 +274,7 @@ func createRun(opts *CreateOptions) error { if isTTY { fmt.Fprintf(stderr, "%s Added remote %s\n", greenCheck, remoteURL) } - } else if isTTY { + } else if opts.IO.CanPrompt() { doSetup := createLocalDirectory if !doSetup { err := prompt.Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &doSetup) diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go index 8eb1a2423..1fd99c035 100644 --- a/pkg/cmd/repo/create/create_test.go +++ b/pkg/cmd/repo/create/create_test.go @@ -22,6 +22,8 @@ import ( func runCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) { io, _, stdout, stderr := iostreams.Test() + io.SetStdoutTTY(true) + io.SetStdinTTY(true) fac := &cmdutil.Factory{ IOStreams: io, HttpClient: func() (*http.Client, error) { @@ -109,8 +111,8 @@ func TestRepoCreate(t *testing.T) { t.Errorf("error running command `repo create`: %v", err) } - assert.Equal(t, "https://github.com/OWNER/REPO\n", output.String()) - assert.Equal(t, "", output.Stderr()) + assert.Equal(t, "", output.String()) + assert.Equal(t, "✓ Created repository OWNER/REPO on GitHub\n✓ Added remote https://github.com/OWNER/REPO.git\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") @@ -191,8 +193,8 @@ func TestRepoCreate_org(t *testing.T) { t.Errorf("error running command `repo create`: %v", err) } - assert.Equal(t, "https://github.com/ORG/REPO\n", output.String()) - assert.Equal(t, "", output.Stderr()) + assert.Equal(t, "", output.String()) + assert.Equal(t, "✓ Created repository ORG/REPO on GitHub\n✓ Added remote https://github.com/ORG/REPO.git\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") @@ -273,8 +275,8 @@ func TestRepoCreate_orgWithTeam(t *testing.T) { t.Errorf("error running command `repo create`: %v", err) } - assert.Equal(t, "https://github.com/ORG/REPO\n", output.String()) - assert.Equal(t, "", output.Stderr()) + assert.Equal(t, "", output.String()) + assert.Equal(t, "✓ Created repository ORG/REPO on GitHub\n✓ Added remote https://github.com/ORG/REPO.git\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") @@ -363,8 +365,8 @@ func TestRepoCreate_template(t *testing.T) { t.Errorf("error running command `repo create`: %v", err) } - assert.Equal(t, "https://github.com/OWNER/REPO\n", output.String()) - assert.Equal(t, "", output.Stderr()) + assert.Equal(t, "", output.String()) + assert.Equal(t, "✓ Created repository OWNER/REPO on GitHub\n✓ Added remote https://github.com/OWNER/REPO.git\n", output.Stderr()) if seenCmd == nil { t.Fatal("expected a command to run") diff --git a/pkg/cmd/repo/credits/credits.go b/pkg/cmd/repo/credits/credits.go index 40aff56d1..c69ce1f3e 100644 --- a/pkg/cmd/repo/credits/credits.go +++ b/pkg/cmd/repo/credits/credits.go @@ -135,6 +135,7 @@ func creditsRun(opts *CreditsOptions) error { type Contributor struct { Login string + Type string } type Result []Contributor @@ -161,6 +162,10 @@ func creditsRun(opts *CreditsOptions) error { logins := []string{} for x, c := range result { + if c.Type != "User" { + continue + } + if isTTY && !static { logins = append(logins, getColor(x)(c.Login)) } else { diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go index 04f76fbb8..b22e2501c 100644 --- a/pkg/cmd/repo/fork/fork.go +++ b/pkg/cmd/repo/fork/fork.go @@ -55,7 +55,7 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman With no argument, creates a fork of the current repository. Otherwise, forks the specified repository.`, RunE: func(cmd *cobra.Command, args []string) error { - promptOk := opts.IO.IsStdoutTTY() && opts.IO.IsStdinTTY() + promptOk := opts.IO.CanPrompt() if len(args) > 0 { opts.Repository = args[0] } diff --git a/pkg/cmd/repo/garden/garden.go b/pkg/cmd/repo/garden/garden.go new file mode 100644 index 000000000..46761b253 --- /dev/null +++ b/pkg/cmd/repo/garden/garden.go @@ -0,0 +1,480 @@ +package garden + +import ( + "bytes" + "errors" + "fmt" + "io" + "math/rand" + "net/http" + "os/exec" + "runtime" + "strconv" + "strings" + + "github.com/cli/cli/api" + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" + "github.com/cli/cli/pkg/cmdutil" + "github.com/cli/cli/pkg/iostreams" + "github.com/cli/cli/utils" + "github.com/spf13/cobra" +) + +type Geometry struct { + Width int + Height int + Density float64 + Repository ghrepo.Interface +} + +type Player struct { + X int + Y int + Char string + Geo *Geometry + ShoeMoistureContent int +} + +type Commit struct { + Email string + Handle string + Sha string + Char string +} + +type Cell struct { + Char string + StatusLine string +} + +const ( + DirUp = iota + DirDown + DirLeft + DirRight +) + +type Direction = int + +func (p *Player) move(direction Direction) bool { + switch direction { + case DirUp: + if p.Y == 0 { + return false + } + p.Y-- + case DirDown: + if p.Y == p.Geo.Height-1 { + return false + } + p.Y++ + case DirLeft: + if p.X == 0 { + return false + } + p.X-- + case DirRight: + if p.X == p.Geo.Width-1 { + return false + } + p.X++ + } + + return true +} + +type GardenOptions struct { + HttpClient func() (*http.Client, error) + IO *iostreams.IOStreams + BaseRepo func() (ghrepo.Interface, error) + + RepoArg string +} + +func NewCmdGarden(f *cmdutil.Factory, runF func(*GardenOptions) error) *cobra.Command { + opts := GardenOptions{ + IO: f.IOStreams, + HttpClient: f.HttpClient, + BaseRepo: f.BaseRepo, + } + + cmd := &cobra.Command{ + Use: "garden []", + Short: "Explore a git repository as a garden", + Long: "Use arrow keys, WASD or vi keys to move. q to quit.", + Hidden: true, + RunE: func(c *cobra.Command, args []string) error { + if len(args) > 0 { + opts.RepoArg = args[0] + } + if runF != nil { + return runF(&opts) + } + return gardenRun(&opts) + }, + } + + return cmd +} + +func gardenRun(opts *GardenOptions) error { + out := opts.IO.Out + + if runtime.GOOS == "windows" { + return errors.New("sorry :( this command only works on linux and macos") + } + + if !opts.IO.IsStdoutTTY() { + return errors.New("must be connected to a terminal") + } + + httpClient, err := opts.HttpClient() + if err != nil { + return err + } + + var toView ghrepo.Interface + apiClient := api.NewClientFromHTTP(httpClient) + if opts.RepoArg == "" { + var err error + toView, err = opts.BaseRepo() + if err != nil { + return err + } + } else { + var err error + viewURL := opts.RepoArg + if !strings.Contains(viewURL, "/") { + currentUser, err := api.CurrentLoginName(apiClient, ghinstance.Default()) + if err != nil { + return err + } + viewURL = currentUser + "/" + viewURL + } + toView, err = ghrepo.FromFullName(viewURL) + if err != nil { + return fmt.Errorf("argument error: %w", err) + } + } + + seed := computeSeed(ghrepo.FullName(toView)) + rand.Seed(seed) + + termWidth, termHeight, err := utils.TerminalSize(out) + if err != nil { + return err + } + + termWidth -= 10 + termHeight -= 10 + + geo := &Geometry{ + Width: termWidth, + Height: termHeight, + Repository: toView, + // TODO based on number of commits/cells instead of just hardcoding + Density: 0.3, + } + + maxCommits := (geo.Width * geo.Height) / 2 + + opts.IO.StartProgressIndicator() + fmt.Fprintln(out, "gathering commits; this could take a minute...") + commits, err := getCommits(httpClient, toView, maxCommits) + opts.IO.StopProgressIndicator() + if err != nil { + return err + } + player := &Player{0, 0, utils.Bold("@"), geo, 0} + + garden := plantGarden(commits, geo) + clear(opts.IO) + drawGarden(out, garden, player) + + // thanks stackoverflow https://stackoverflow.com/a/17278776 + if runtime.GOOS == "darwin" { + _ = exec.Command("stty", "-f", "/dev/tty", "cbreak", "min", "1").Run() + _ = exec.Command("stty", "-f", "/dev/tty", "-echo").Run() + } else { + _ = exec.Command("stty", "-F", "/dev/tty", "cbreak", "min", "1").Run() + _ = exec.Command("stty", "-F", "/dev/tty", "-echo").Run() + } + + var b []byte = make([]byte, 3) + for { + _, _ = opts.IO.In.Read(b) + + oldX := player.X + oldY := player.Y + moved := false + quitting := false + continuing := false + + switch { + case isLeft(b): + moved = player.move(DirLeft) + case isRight(b): + moved = player.move(DirRight) + case isUp(b): + moved = player.move(DirUp) + case isDown(b): + moved = player.move(DirDown) + case isQuit(b): + quitting = true + default: + continuing = true + } + + if quitting { + break + } + + if !moved || continuing { + continue + } + + underPlayer := garden[player.Y][player.X] + previousCell := garden[oldY][oldX] + + // print whatever was just under player + + fmt.Fprint(out, "\033[;H") // move to top left + for x := 0; x < oldX && x < player.Geo.Width; x++ { + fmt.Fprint(out, "\033[C") + } + for y := 0; y < oldY && y < player.Geo.Height; y++ { + fmt.Fprint(out, "\033[B") + } + fmt.Fprint(out, previousCell.Char) + + // print player character + fmt.Fprint(out, "\033[;H") // move to top left + for x := 0; x < player.X && x < player.Geo.Width; x++ { + fmt.Fprint(out, "\033[C") + } + for y := 0; y < player.Y && y < player.Geo.Height; y++ { + fmt.Fprint(out, "\033[B") + } + fmt.Fprint(out, player.Char) + + // handle stream wettening + + if strings.Contains(underPlayer.StatusLine, "stream") { + player.ShoeMoistureContent = 5 + } else { + if player.ShoeMoistureContent > 0 { + player.ShoeMoistureContent-- + } + } + + // status line stuff + sl := statusLine(garden, player) + + fmt.Fprint(out, "\033[;H") // move to top left + for y := 0; y < player.Geo.Height-1; y++ { + fmt.Fprint(out, "\033[B") + } + fmt.Fprintln(out) + fmt.Fprintln(out) + + fmt.Fprint(out, utils.Bold(sl)) + } + + clear(opts.IO) + fmt.Fprint(out, "\033[?25h") + fmt.Fprintln(out) + fmt.Fprintln(out, utils.Bold("You turn and walk away from the wildflower garden...")) + + return nil +} + +func isLeft(b []byte) bool { + left := []byte{27, 91, 68} + r := rune(b[0]) + return bytes.EqualFold(b, left) || r == 'a' || r == 'h' +} + +func isRight(b []byte) bool { + right := []byte{27, 91, 67} + r := rune(b[0]) + return bytes.EqualFold(b, right) || r == 'd' || r == 'l' +} + +func isDown(b []byte) bool { + down := []byte{27, 91, 66} + r := rune(b[0]) + return bytes.EqualFold(b, down) || r == 's' || r == 'j' +} + +func isUp(b []byte) bool { + up := []byte{27, 91, 65} + r := rune(b[0]) + return bytes.EqualFold(b, up) || r == 'w' || r == 'k' +} + +func isQuit(b []byte) bool { + return rune(b[0]) == 'q' +} + +func plantGarden(commits []*Commit, geo *Geometry) [][]*Cell { + cellIx := 0 + grassCell := &Cell{RGB(0, 200, 0, ","), "You're standing on a patch of grass in a field of wildflowers."} + garden := [][]*Cell{} + streamIx := rand.Intn(geo.Width - 1) + if streamIx == geo.Width/2 { + streamIx-- + } + tint := 0 + for y := 0; y < geo.Height; y++ { + if cellIx == len(commits)-1 { + break + } + garden = append(garden, []*Cell{}) + for x := 0; x < geo.Width; x++ { + if (y > 0 && (x == 0 || x == geo.Width-1)) || y == geo.Height-1 { + garden[y] = append(garden[y], &Cell{ + Char: RGB(0, 150, 0, "^"), + StatusLine: "You're standing under a tall, leafy tree.", + }) + continue + } + if x == streamIx { + garden[y] = append(garden[y], &Cell{ + Char: RGB(tint, tint, 255, "#"), + StatusLine: "You're standing in a shallow stream. It's refreshing.", + }) + tint += 15 + streamIx-- + if rand.Float64() < 0.5 { + streamIx++ + } + if streamIx < 0 { + streamIx = 0 + } + if streamIx > geo.Width { + streamIx = geo.Width + } + continue + } + if y == 0 && (x < geo.Width/2 || x > geo.Width/2) { + garden[y] = append(garden[y], &Cell{ + Char: RGB(0, 200, 0, ","), + StatusLine: "You're standing by a wildflower garden. There is a light breeze.", + }) + continue + } else if y == 0 && x == geo.Width/2 { + garden[y] = append(garden[y], &Cell{ + Char: RGB(139, 69, 19, "+"), + StatusLine: fmt.Sprintf("You're standing in front of a weather-beaten sign that says %s.", ghrepo.FullName(geo.Repository)), + }) + continue + } + + if cellIx == len(commits)-1 { + garden[y] = append(garden[y], grassCell) + continue + } + + chance := rand.Float64() + if chance <= geo.Density { + commit := commits[cellIx] + garden[y] = append(garden[y], &Cell{ + Char: commits[cellIx].Char, + StatusLine: fmt.Sprintf("You're standing at a flower called %s planted by %s.", commit.Sha[0:6], commit.Handle), + }) + cellIx++ + } else { + garden[y] = append(garden[y], grassCell) + } + } + } + + return garden +} + +func drawGarden(out io.Writer, garden [][]*Cell, player *Player) { + fmt.Fprint(out, "\033[?25l") // hide cursor. it needs to be restored at command exit. + sl := "" + for y, gardenRow := range garden { + for x, gardenCell := range gardenRow { + char := "" + underPlayer := (player.X == x && player.Y == y) + if underPlayer { + sl = gardenCell.StatusLine + char = utils.Bold(player.Char) + + if strings.Contains(gardenCell.StatusLine, "stream") { + player.ShoeMoistureContent = 5 + } + } else { + char = gardenCell.Char + } + + fmt.Fprint(out, char) + } + fmt.Fprintln(out) + } + + fmt.Println() + fmt.Fprintln(out, utils.Bold(sl)) +} + +func statusLine(garden [][]*Cell, player *Player) string { + statusLine := garden[player.Y][player.X].StatusLine + " " + if player.ShoeMoistureContent > 1 { + statusLine += "\nYour shoes squish with water from the stream." + } else if player.ShoeMoistureContent == 1 { + statusLine += "\nYour shoes seem to have dried out." + } else { + statusLine += "\n " + } + + return statusLine +} + +func shaToColorFunc(sha string) func(string) string { + return func(c string) string { + red, err := strconv.ParseInt(sha[0:2], 16, 64) + if err != nil { + panic(err) + } + + green, err := strconv.ParseInt(sha[2:4], 16, 64) + if err != nil { + panic(err) + } + + blue, err := strconv.ParseInt(sha[4:6], 16, 64) + if err != nil { + panic(err) + } + + return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", red, green, blue, c) + } +} + +func computeSeed(seed string) int64 { + lol := "" + + for _, r := range seed { + lol += fmt.Sprintf("%d", int(r)) + } + + result, err := strconv.ParseInt(lol[0:10], 10, 64) + if err != nil { + panic(err) + } + + return result +} + +func clear(io *iostreams.IOStreams) { + cmd := exec.Command("clear") + cmd.Stdout = io.Out + _ = cmd.Run() +} + +func RGB(r, g, b int, x string) string { + return fmt.Sprintf("\033[38;2;%d;%d;%dm%s\033[0m", r, g, b, x) +} diff --git a/pkg/cmd/repo/garden/http.go b/pkg/cmd/repo/garden/http.go new file mode 100644 index 000000000..ff7f47fe0 --- /dev/null +++ b/pkg/cmd/repo/garden/http.go @@ -0,0 +1,105 @@ +package garden + +import ( + "encoding/json" + "errors" + "fmt" + "io/ioutil" + "net/http" + "strings" + "time" + + "github.com/cli/cli/internal/ghinstance" + "github.com/cli/cli/internal/ghrepo" +) + +func getCommits(client *http.Client, repo ghrepo.Interface, maxCommits int) ([]*Commit, error) { + type Item struct { + Author struct { + Login string + } + Sha string + } + + type Result []Item + + commits := []*Commit{} + + pathF := func(page int) string { + return fmt.Sprintf("repos/%s/%s/commits?per_page=100&page=%d", repo.RepoOwner(), repo.RepoName(), page) + } + + page := 1 + paginating := true + for paginating { + if len(commits) >= maxCommits { + break + } + result := Result{} + resp, err := getResponse(client, pathF(page), &result) + if err != nil { + return nil, err + } + for _, r := range result { + colorFunc := shaToColorFunc(r.Sha) + handle := r.Author.Login + if handle == "" { + handle = "a mysterious stranger" + } + commits = append(commits, &Commit{ + Handle: handle, + Sha: r.Sha, + Char: colorFunc(string(handle[0])), + }) + } + link := resp.Header["Link"] + if !strings.Contains(link[0], "last") { + paginating = false + } + page++ + time.Sleep(500) + } + + // reverse to get older commits first + for i, j := 0, len(commits)-1; i < j; i, j = i+1, j-1 { + commits[i], commits[j] = commits[j], commits[i] + } + + return commits, nil +} + +func getResponse(client *http.Client, path string, data interface{}) (*http.Response, error) { + url := ghinstance.RESTPrefix(ghinstance.OverridableDefault()) + path + req, err := http.NewRequest("GET", url, nil) + if err != nil { + return nil, err + } + + req.Header.Set("Content-Type", "application/json; charset=utf-8") + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + success := resp.StatusCode >= 200 && resp.StatusCode < 300 + if !success { + return nil, errors.New("api call failed") + } + + if resp.StatusCode == http.StatusNoContent { + return resp, nil + } + + b, err := ioutil.ReadAll(resp.Body) + if err != nil { + return nil, err + } + + err = json.Unmarshal(b, &data) + if err != nil { + return nil, err + } + + return resp, nil +} diff --git a/pkg/cmd/repo/repo.go b/pkg/cmd/repo/repo.go index 551c96641..02e6c368b 100644 --- a/pkg/cmd/repo/repo.go +++ b/pkg/cmd/repo/repo.go @@ -6,6 +6,7 @@ import ( repoCreateCmd "github.com/cli/cli/pkg/cmd/repo/create" creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" repoForkCmd "github.com/cli/cli/pkg/cmd/repo/fork" + gardenCmd "github.com/cli/cli/pkg/cmd/repo/garden" repoViewCmd "github.com/cli/cli/pkg/cmd/repo/view" "github.com/cli/cli/pkg/cmdutil" "github.com/spf13/cobra" @@ -36,6 +37,7 @@ func NewCmdRepo(f *cmdutil.Factory) *cobra.Command { cmd.AddCommand(repoCloneCmd.NewCmdClone(f, nil)) cmd.AddCommand(repoCreateCmd.NewCmdCreate(f, nil)) cmd.AddCommand(creditsCmd.NewCmdRepoCredits(f, nil)) + cmd.AddCommand(gardenCmd.NewCmdGarden(f, nil)) return cmd } diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go index adcf384a4..95da1ad0c 100644 --- a/pkg/cmd/repo/view/view.go +++ b/pkg/cmd/repo/view/view.go @@ -1,10 +1,12 @@ package view import ( + "errors" "fmt" - "html/template" "net/http" "strings" + "syscall" + "text/template" "github.com/MakeNowJust/heredoc" "github.com/cli/cli/api" @@ -107,6 +109,12 @@ func viewRun(opts *ViewOptions) error { return err } + err = opts.IO.StartPager() + if err != nil { + return err + } + defer opts.IO.StopPager() + stdout := opts.IO.Out if !opts.IO.IsStdoutTTY() { @@ -166,7 +174,7 @@ func viewRun(opts *ViewOptions) error { } err = tmpl.Execute(stdout, repoData) - if err != nil { + if err != nil && !errors.Is(err, syscall.EPIPE) { return err } diff --git a/pkg/cmd/repo/view/view_test.go b/pkg/cmd/repo/view/view_test.go index 7dba0f6df..626ff62ed 100644 --- a/pkg/cmd/repo/view/view_test.go +++ b/pkg/cmd/repo/view/view_test.go @@ -515,3 +515,86 @@ func Test_ViewRun_WithoutUsername(t *testing.T) { assert.Equal(t, "", stderr.String()) reg.Verify(t) } + +func Test_ViewRun_HandlesSpecialCharacters(t *testing.T) { + tests := []struct { + name string + opts *ViewOptions + repoName string + stdoutTTY bool + wantOut string + wantStderr string + wantErr bool + }{ + { + name: "nontty", + wantOut: heredoc.Doc(` + name: OWNER/REPO + description: Some basic special characters " & / < > ' + -- + # < is always > than & ' and " + `), + }, + { + name: "no args", + stdoutTTY: true, + wantOut: heredoc.Doc(` + OWNER/REPO + Some basic special characters " & / < > ' + + + # < is always > than & ' and " + + + + View this repository on GitHub: https://github.com/OWNER/REPO + `), + }, + } + for _, tt := range tests { + if tt.opts == nil { + tt.opts = &ViewOptions{} + } + + if tt.repoName == "" { + tt.repoName = "OWNER/REPO" + } + + tt.opts.BaseRepo = func() (ghrepo.Interface, error) { + repo, _ := ghrepo.FromFullName(tt.repoName) + return repo, nil + } + + reg := &httpmock.Registry{} + reg.Register( + httpmock.GraphQL(`query RepositoryInfo\b`), + httpmock.StringResponse(` + { "data": { + "repository": { + "description": "Some basic special characters \" & / < > '" + } } }`)) + reg.Register( + httpmock.REST("GET", fmt.Sprintf("repos/%s/readme", tt.repoName)), + httpmock.StringResponse(` + { "name": "readme.md", + "content": "IyA8IGlzIGFsd2F5cyA+IHRoYW4gJiAnIGFuZCAi"}`)) + + tt.opts.HttpClient = func() (*http.Client, error) { + return &http.Client{Transport: reg}, nil + } + + io, _, stdout, stderr := iostreams.Test() + tt.opts.IO = io + + t.Run(tt.name, func(t *testing.T) { + io.SetStdoutTTY(tt.stdoutTTY) + + if err := viewRun(tt.opts); (err != nil) != tt.wantErr { + t.Errorf("viewRun() error = %v, wantErr %v", err, tt.wantErr) + } + assert.Equal(t, tt.wantStderr, stderr.String()) + assert.Equal(t, tt.wantOut, stdout.String()) + reg.Verify(t) + }) + } +} diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go index 5570fbaaa..400be3834 100644 --- a/pkg/cmd/root/help.go +++ b/pkg/cmd/root/help.go @@ -3,9 +3,9 @@ package root import ( "bytes" "fmt" - "regexp" "strings" + "github.com/cli/cli/pkg/text" "github.com/cli/cli/utils" "github.com/spf13/cobra" ) @@ -28,7 +28,7 @@ func rootUsageFunc(command *cobra.Command) error { flagUsages := command.LocalFlags().FlagUsages() if flagUsages != "" { command.Println("\n\nFlags:") - command.Print(indent(dedent(flagUsages), " ")) + command.Print(text.Indent(dedent(flagUsages), " ")) } return nil } @@ -139,7 +139,7 @@ func rootHelpFunc(command *cobra.Command, args []string) { helpEntries = append(helpEntries, helpEntry{"ENVIRONMENT VARIABLES", command.Annotations["help:environment"]}) } helpEntries = append(helpEntries, helpEntry{"LEARN MORE", ` -Use "gh --help" for more information about a command. +Use 'gh --help' for more information about a command. Read the manual at https://cli.github.com/manual`}) if _, ok := command.Annotations["help:feedback"]; ok { helpEntries = append(helpEntries, helpEntry{"FEEDBACK", command.Annotations["help:feedback"]}) @@ -150,7 +150,7 @@ Read the manual at https://cli.github.com/manual`}) if e.Title != "" { // If there is a title, add indentation to each line in the body fmt.Fprintln(out, utils.Bold(e.Title)) - fmt.Fprintln(out, indent(strings.Trim(e.Body, "\r\n"), " ")) + fmt.Fprintln(out, text.Indent(strings.Trim(e.Body, "\r\n"), " ")) } else { // If there is no title print the body as is fmt.Fprintln(out, e.Body) @@ -165,15 +165,6 @@ func rpad(s string, padding int) string { return fmt.Sprintf(template, s) } -var lineRE = regexp.MustCompile(`(?m)^`) - -func indent(s, indent string) string { - if len(strings.TrimSpace(s)) == 0 { - return s - } - return lineRE.ReplaceAllLiteralString(s, indent) -} - func dedent(s string) string { lines := strings.Split(s, "\n") minIndent := -1 diff --git a/pkg/cmd/root/help_test.go b/pkg/cmd/root/help_test.go index cbda48fe8..d24a84a0d 100644 --- a/pkg/cmd/root/help_test.go +++ b/pkg/cmd/root/help_test.go @@ -44,47 +44,3 @@ func TestDedent(t *testing.T) { } } } - -func Test_indent(t *testing.T) { - type args struct { - s string - indent string - } - tests := []struct { - name string - args args - want string - }{ - { - name: "empty", - args: args{ - s: "", - indent: "--", - }, - want: "", - }, - { - name: "blank", - args: args{ - s: "\n", - indent: "--", - }, - want: "\n", - }, - { - name: "indent", - args: args{ - s: "one\ntwo\nthree", - indent: "--", - }, - want: "--one\n--two\n--three", - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - if got := indent(tt.args.s, tt.args.indent); got != tt.want { - t.Errorf("indent() = %q, want %q", got, tt.want) - } - }) - } -} diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go new file mode 100644 index 000000000..aacdfc5e5 --- /dev/null +++ b/pkg/cmd/root/help_topic.go @@ -0,0 +1,65 @@ +package root + +import ( + "github.com/MakeNowJust/heredoc" + "github.com/spf13/cobra" +) + +func NewHelpTopic(topic string) *cobra.Command { + topicContent := make(map[string]string) + + topicContent["environment"] = heredoc.Doc(` + GITHUB_TOKEN: an authentication token for github.com API requests. Setting this avoids + being prompted to authenticate and takes precedence over previously stored credentials. + + GITHUB_ENTERPRISE_TOKEN: an authentication token for API requests to GitHub Enterprise. + + GH_REPO: specify the GitHub repository in the "[HOST/]OWNER/REPO" format for commands + that otherwise operate on a local repository. + + GH_HOST: specify the GitHub hostname for commands that would otherwise assume + the "github.com" host when not in a context of an existing repository. + + GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use + for authoring text. + + BROWSER: the web browser to use for opening links. + + DEBUG: set to any value to enable verbose output to standard error. Include values "api" + or "oauth" to print detailed information about HTTP requests or authentication flow. + + PAGER: a terminal paging program to send standard output to, e.g. "less". + + GLAMOUR_STYLE: the style to use for rendering Markdown. See + https://github.com/charmbracelet/glamour#styles + + NO_COLOR: set to any value to avoid printing ANSI escape sequences for color output. + + CLICOLOR: set to "0" to disable printing ANSI colors in output. + + CLICOLOR_FORCE: set to a value other than "0" to keep ANSI colors in output + even when the output is piped. + `) + + cmd := &cobra.Command{ + Use: topic, + Long: topicContent[topic], + Hidden: true, + Args: cobra.NoArgs, + Run: helpTopicHelpFunc, + } + + cmd.SetHelpFunc(helpTopicHelpFunc) + cmd.SetUsageFunc(helpTopicUsageFunc) + + return cmd +} + +func helpTopicHelpFunc(command *cobra.Command, args []string) { + command.Print(command.Long) +} + +func helpTopicUsageFunc(command *cobra.Command) error { + command.Printf("Usage: gh help %s", command.Use) + return nil +} diff --git a/pkg/cmd/root/help_topic_test.go b/pkg/cmd/root/help_topic_test.go new file mode 100644 index 000000000..f194541ac --- /dev/null +++ b/pkg/cmd/root/help_topic_test.go @@ -0,0 +1,79 @@ +package root + +import ( + "testing" + + "github.com/cli/cli/pkg/iostreams" + "github.com/stretchr/testify/assert" +) + +func TestNewHelpTopic(t *testing.T) { + tests := []struct { + name string + topic string + args []string + flags []string + wantsErr bool + }{ + { + name: "valid topic", + topic: "environment", + args: []string{}, + flags: []string{}, + wantsErr: false, + }, + { + name: "invalid topic", + topic: "invalid", + args: []string{}, + flags: []string{}, + wantsErr: false, + }, + { + name: "more than zero args", + topic: "environment", + args: []string{"invalid"}, + flags: []string{}, + wantsErr: true, + }, + { + name: "more than zero flags", + topic: "environment", + args: []string{}, + flags: []string{"--invalid"}, + wantsErr: true, + }, + { + name: "help arg", + topic: "environment", + args: []string{"help"}, + flags: []string{}, + wantsErr: true, + }, + { + name: "help flag", + topic: "environment", + args: []string{}, + flags: []string{"--help"}, + wantsErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, _, stdout, stderr := iostreams.Test() + + cmd := NewHelpTopic(tt.topic) + cmd.SetArgs(append(tt.args, tt.flags...)) + cmd.SetOut(stdout) + cmd.SetErr(stderr) + + _, err := cmd.ExecuteC() + if tt.wantsErr { + assert.Error(t, err) + return + } + assert.NoError(t, err) + }) + } +} diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go index fe2d23d8c..e3257ed09 100644 --- a/pkg/cmd/root/root.go +++ b/pkg/cmd/root/root.go @@ -2,6 +2,7 @@ package root import ( "fmt" + "net/http" "regexp" "strings" @@ -13,9 +14,11 @@ import ( apiCmd "github.com/cli/cli/pkg/cmd/api" authCmd "github.com/cli/cli/pkg/cmd/auth" configCmd "github.com/cli/cli/pkg/cmd/config" + "github.com/cli/cli/pkg/cmd/factory" gistCmd "github.com/cli/cli/pkg/cmd/gist" issueCmd "github.com/cli/cli/pkg/cmd/issue" prCmd "github.com/cli/cli/pkg/cmd/pr" + releaseCmd "github.com/cli/cli/pkg/cmd/release" repoCmd "github.com/cli/cli/pkg/cmd/repo" creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits" "github.com/cli/cli/pkg/cmdutil" @@ -38,35 +41,10 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { `), Annotations: map[string]string{ "help:feedback": heredoc.Doc(` - Open an issue using “gh issue create -R cli/cli” + Open an issue using 'gh issue create -R cli/cli' `), "help:environment": heredoc.Doc(` - GITHUB_TOKEN: an authentication token for API requests. Setting this avoids being - prompted to authenticate and overrides any previously stored credentials. - - GH_REPO: specify the GitHub repository in the "[HOST/]OWNER/REPO" format for commands - that otherwise operate on a local repository. - - GH_HOST: specify the GitHub hostname for commands that would otherwise assume - the "github.com" host when not in a context of an existing repository. - - GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use - for authoring text. - - BROWSER: the web browser to use for opening links. - - DEBUG: set to any value to enable verbose output to standard error. Include values "api" - or "oauth" to print detailed information about HTTP requests or authentication flow. - - GLAMOUR_STYLE: the style to use for rendering Markdown. See - https://github.com/charmbracelet/glamour#styles - - NO_COLOR: set to any value to avoid printing ANSI escape sequences for color output. - - CLICOLOR: set to "0" to disable printing ANSI colors in output. - - CLICOLOR_FORCE: set to a value other than "0" to keep ANSI colors in output - even when the output is piped. + See 'gh help environment' for the list of supported environment variables. `), }, } @@ -104,22 +82,36 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command { cmdutil.DisableAuthCheck(cmd) - // CHILD COMMANDS - + // Child commands cmd.AddCommand(aliasCmd.NewCmdAlias(f)) - cmd.AddCommand(apiCmd.NewCmdApi(f, nil)) cmd.AddCommand(authCmd.NewCmdAuth(f)) cmd.AddCommand(configCmd.NewCmdConfig(f)) cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil)) cmd.AddCommand(gistCmd.NewCmdGist(f)) cmd.AddCommand(NewCmdCompletion(f.IOStreams)) + // Help topics + cmd.AddCommand(NewHelpTopic("environment")) + + // the `api` command should not inherit any extra HTTP headers + bareHTTPCmdFactory := *f + bareHTTPCmdFactory.HttpClient = func() (*http.Client, error) { + cfg, err := bareHTTPCmdFactory.Config() + if err != nil { + return nil, err + } + return factory.NewHTTPClient(bareHTTPCmdFactory.IOStreams, cfg, version, false), nil + } + + cmd.AddCommand(apiCmd.NewCmdApi(&bareHTTPCmdFactory, nil)) + // below here at the commands that require the "intelligent" BaseRepo resolver repoResolvingCmdFactory := *f repoResolvingCmdFactory.BaseRepo = resolvedBaseRepo(f) cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory)) cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory)) + cmd.AddCommand(releaseCmd.NewCmdRelease(&repoResolvingCmdFactory)) cmd.AddCommand(repoCmd.NewCmdRepo(&repoResolvingCmdFactory)) return cmd diff --git a/pkg/cmdutil/repo_override.go b/pkg/cmdutil/repo_override.go index 8b3d36489..aede57ea6 100644 --- a/pkg/cmdutil/repo_override.go +++ b/pkg/cmdutil/repo_override.go @@ -8,7 +8,7 @@ import ( ) func EnableRepoOverride(cmd *cobra.Command, f *Factory) { - cmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `[HOST/]OWNER/REPO` format") + cmd.PersistentFlags().StringP("repo", "R", "", "Select another repository using the `OWNER/REPO` format") cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) { repoOverride, _ := cmd.Flags().GetString("repo") diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go index c5e476791..92f4f31ae 100644 --- a/pkg/iostreams/color.go +++ b/pkg/iostreams/color.go @@ -1,6 +1,21 @@ package iostreams -import "os" +import ( + "os" + + "github.com/mgutz/ansi" +) + +var ( + magenta = ansi.ColorFunc("magenta") + cyan = ansi.ColorFunc("cyan") + red = ansi.ColorFunc("red") + yellow = ansi.ColorFunc("yellow") + blue = ansi.ColorFunc("blue") + green = ansi.ColorFunc("green") + gray = ansi.ColorFunc("black+h") + bold = ansi.ColorFunc("default+b") +) func EnvColorDisabled() bool { return os.Getenv("NO_COLOR") != "" || os.Getenv("CLICOLOR") == "0" @@ -9,3 +24,75 @@ func EnvColorDisabled() bool { func EnvColorForced() bool { return os.Getenv("CLICOLOR_FORCE") != "" && os.Getenv("CLICOLOR_FORCE") != "0" } + +func NewColorScheme(enabled bool) *ColorScheme { + return &ColorScheme{enabled: enabled} +} + +type ColorScheme struct { + enabled bool +} + +func (c *ColorScheme) Bold(t string) string { + if !c.enabled { + return t + } + return bold(t) +} + +func (c *ColorScheme) Red(t string) string { + if !c.enabled { + return t + } + return red(t) +} + +func (c *ColorScheme) Yellow(t string) string { + if !c.enabled { + return t + } + return yellow(t) +} + +func (c *ColorScheme) Green(t string) string { + if !c.enabled { + return t + } + return green(t) +} + +func (c *ColorScheme) Gray(t string) string { + if !c.enabled { + return t + } + return gray(t) +} + +func (c *ColorScheme) Magenta(t string) string { + if !c.enabled { + return t + } + return magenta(t) +} + +func (c *ColorScheme) Cyan(t string) string { + if !c.enabled { + return t + } + return cyan(t) +} + +func (c *ColorScheme) Blue(t string) string { + if !c.enabled { + return t + } + return blue(t) +} + +func (c *ColorScheme) SuccessIcon() string { + return c.Green("✓") +} + +func (c *ColorScheme) WarningIcon() string { + return c.Yellow("!") +} diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go index 6dc89cb57..f4ed4b032 100644 --- a/pkg/iostreams/iostreams.go +++ b/pkg/iostreams/iostreams.go @@ -9,7 +9,10 @@ import ( "os/exec" "strconv" "strings" + "time" + "github.com/briandowns/spinner" + "github.com/google/shlex" "github.com/mattn/go-colorable" "github.com/mattn/go-isatty" "golang.org/x/crypto/ssh/terminal" @@ -20,14 +23,24 @@ type IOStreams struct { Out io.Writer ErrOut io.Writer + // the original (non-colorable) output stream + originalOut io.Writer colorEnabled bool + progressIndicatorEnabled bool + progressIndicator *spinner.Spinner + stdinTTYOverride bool stdinIsTTY bool stdoutTTYOverride bool stdoutIsTTY bool stderrTTYOverride bool stderrIsTTY bool + + pagerCommand string + pagerProcess *os.Process + + neverPrompt bool } func (s *IOStreams) ColorEnabled() bool { @@ -79,17 +92,101 @@ func (s *IOStreams) IsStderrTTY() bool { return false } -func (s *IOStreams) TerminalWidth() int { - defaultWidth := 80 - if s.stdoutTTYOverride { - return defaultWidth +func (s *IOStreams) SetPager(cmd string) { + s.pagerCommand = cmd +} + +func (s *IOStreams) StartPager() error { + if s.pagerCommand == "" || !s.IsStdoutTTY() { + return nil } - if w, _, err := terminalSize(s.Out); err == nil { + pagerArgs, err := shlex.Split(s.pagerCommand) + if err != nil { + return err + } + + pagerEnv := os.Environ() + for i := len(pagerEnv) - 1; i >= 0; i-- { + if strings.HasPrefix(pagerEnv[i], "PAGER=") { + pagerEnv = append(pagerEnv[0:i], pagerEnv[i+1:]...) + } + } + if _, ok := os.LookupEnv("LESS"); !ok { + pagerEnv = append(pagerEnv, "LESS=FRX") + } + if _, ok := os.LookupEnv("LV"); !ok { + pagerEnv = append(pagerEnv, "LV=-c") + } + + pagerCmd := exec.Command(pagerArgs[0], pagerArgs[1:]...) + pagerCmd.Env = pagerEnv + pagerCmd.Stdout = s.Out + pagerCmd.Stderr = s.ErrOut + pagedOut, err := pagerCmd.StdinPipe() + if err != nil { + return err + } + s.Out = pagedOut + err = pagerCmd.Start() + if err != nil { + return err + } + s.pagerProcess = pagerCmd.Process + return nil +} + +func (s *IOStreams) StopPager() { + if s.pagerProcess == nil { + return + } + + s.Out.(io.ReadCloser).Close() + _, _ = s.pagerProcess.Wait() + s.pagerProcess = nil +} + +func (s *IOStreams) CanPrompt() bool { + if s.neverPrompt { + return false + } + + return s.IsStdinTTY() && s.IsStdoutTTY() +} + +func (s *IOStreams) SetNeverPrompt(v bool) { + s.neverPrompt = v +} + +func (s *IOStreams) StartProgressIndicator() { + if !s.progressIndicatorEnabled { + return + } + sp := spinner.New(spinner.CharSets[11], 400*time.Millisecond, spinner.WithWriter(s.ErrOut)) + sp.Start() + s.progressIndicator = sp +} + +func (s *IOStreams) StopProgressIndicator() { + if s.progressIndicator == nil { + return + } + s.progressIndicator.Stop() + s.progressIndicator = nil +} + +func (s *IOStreams) TerminalWidth() int { + defaultWidth := 80 + out := s.Out + if s.originalOut != nil { + out = s.originalOut + } + + if w, _, err := terminalSize(out); err == nil { return w } - if isCygwinTerminal(s.Out) { + if isCygwinTerminal(out) { tputCmd := exec.Command("tput", "cols") tputCmd.Stdin = os.Stdin if out, err := tputCmd.Output(); err == nil { @@ -102,15 +199,25 @@ func (s *IOStreams) TerminalWidth() int { return defaultWidth } +func (s *IOStreams) ColorScheme() *ColorScheme { + return NewColorScheme(s.ColorEnabled()) +} + func System() *IOStreams { stdoutIsTTY := isTerminal(os.Stdout) stderrIsTTY := isTerminal(os.Stderr) io := &IOStreams{ In: os.Stdin, + originalOut: os.Stdout, Out: colorable.NewColorable(os.Stdout), ErrOut: colorable.NewColorable(os.Stderr), colorEnabled: EnvColorForced() || (!EnvColorDisabled() && stdoutIsTTY), + pagerCommand: os.Getenv("PAGER"), + } + + if stdoutIsTTY && stderrIsTTY { + io.progressIndicatorEnabled = true } // prevent duplicate isTerminal queries now that we know the answer diff --git a/pkg/surveyext/editor.go b/pkg/surveyext/editor.go index 9c1fca2e1..038cc9036 100644 --- a/pkg/surveyext/editor.go +++ b/pkg/surveyext/editor.go @@ -5,16 +5,12 @@ package surveyext // To see what we extended, search through for EXTENDED comments. import ( - "bytes" - "io/ioutil" "os" - "os/exec" "path/filepath" "runtime" "github.com/AlecAivazis/survey/v2" "github.com/AlecAivazis/survey/v2/terminal" - shellquote "github.com/kballard/go-shellquote" ) var ( @@ -139,63 +135,11 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int continue } - // prepare the temp file - pattern := e.FileName - if pattern == "" { - pattern = "survey*.txt" - } - f, err := ioutil.TempFile("", pattern) - if err != nil { - return "", err - } - defer os.Remove(f.Name()) - - // write utf8 BOM header - // The reason why we do this is because notepad.exe on Windows determines the - // encoding of an "empty" text file by the locale, for example, GBK in China, - // while golang string only handles utf8 well. However, a text file with utf8 - // BOM header is not considered "empty" on Windows, and the encoding will then - // be determined utf8 by notepad.exe, instead of GBK or other encodings. - if _, err := f.Write(bom); err != nil { - return "", err - } - - // write initial value - if _, err := f.WriteString(initialValue); err != nil { - return "", err - } - - // close the fd to prevent the editor unable to save file - if err := f.Close(); err != nil { - return "", err - } - stdio := e.Stdio() - - args, err := shellquote.Split(e.editorCommand()) + text, err := Edit(e.editorCommand(), e.FileName, initialValue, stdio.In, stdio.Out, stdio.Err, cursor) if err != nil { return "", err } - args = append(args, f.Name()) - - // open the editor - cmd := exec.Command(args[0], args[1:]...) - cmd.Stdin = stdio.In - cmd.Stdout = stdio.Out - cmd.Stderr = stdio.Err - cursor.Show() - if err := cmd.Run(); err != nil { - return "", err - } - - // raw is a BOM-unstripped UTF8 byte slice - raw, err := ioutil.ReadFile(f.Name()) - if err != nil { - return "", err - } - - // strip BOM header - text := string(bytes.TrimPrefix(raw, bom)) // check length, return default value on empty if len(text) == 0 && !e.AppendDefault { diff --git a/pkg/surveyext/editor_manual.go b/pkg/surveyext/editor_manual.go new file mode 100644 index 000000000..faa2345ad --- /dev/null +++ b/pkg/surveyext/editor_manual.go @@ -0,0 +1,80 @@ +package surveyext + +import ( + "bytes" + "io" + "io/ioutil" + "os" + "os/exec" + + shellquote "github.com/kballard/go-shellquote" +) + +type showable interface { + Show() +} + +func Edit(editorCommand, fn, initialValue string, stdin io.Reader, stdout io.Writer, stderr io.Writer, cursor showable) (string, error) { + // prepare the temp file + pattern := fn + if pattern == "" { + pattern = "survey*.txt" + } + f, err := ioutil.TempFile("", pattern) + if err != nil { + return "", err + } + defer os.Remove(f.Name()) + + // write utf8 BOM header + // The reason why we do this is because notepad.exe on Windows determines the + // encoding of an "empty" text file by the locale, for example, GBK in China, + // while golang string only handles utf8 well. However, a text file with utf8 + // BOM header is not considered "empty" on Windows, and the encoding will then + // be determined utf8 by notepad.exe, instead of GBK or other encodings. + if _, err := f.Write(bom); err != nil { + return "", err + } + + // write initial value + if _, err := f.WriteString(initialValue); err != nil { + return "", err + } + + // close the fd to prevent the editor unable to save file + if err := f.Close(); err != nil { + return "", err + } + + if editorCommand == "" { + editorCommand = defaultEditor + } + args, err := shellquote.Split(editorCommand) + if err != nil { + return "", err + } + args = append(args, f.Name()) + + cmd := exec.Command(args[0], args[1:]...) + cmd.Stdin = stdin + cmd.Stdout = stdout + cmd.Stderr = stderr + + if cursor != nil { + cursor.Show() + } + + // open the editor + if err := cmd.Run(); err != nil { + return "", err + } + + // raw is a BOM-unstripped UTF8 byte slice + raw, err := ioutil.ReadFile(f.Name()) + if err != nil { + return "", err + } + + // strip BOM header + return string(bytes.TrimPrefix(raw, bom)), nil +} diff --git a/pkg/text/indent.go b/pkg/text/indent.go new file mode 100644 index 000000000..f63539778 --- /dev/null +++ b/pkg/text/indent.go @@ -0,0 +1,15 @@ +package text + +import ( + "regexp" + "strings" +) + +var lineRE = regexp.MustCompile(`(?m)^`) + +func Indent(s, indent string) string { + if len(strings.TrimSpace(s)) == 0 { + return s + } + return lineRE.ReplaceAllLiteralString(s, indent) +} diff --git a/pkg/text/indent_test.go b/pkg/text/indent_test.go new file mode 100644 index 000000000..c93310114 --- /dev/null +++ b/pkg/text/indent_test.go @@ -0,0 +1,47 @@ +package text + +import "testing" + +func Test_Indent(t *testing.T) { + type args struct { + s string + indent string + } + tests := []struct { + name string + args args + want string + }{ + { + name: "empty", + args: args{ + s: "", + indent: "--", + }, + want: "", + }, + { + name: "blank", + args: args{ + s: "\n", + indent: "--", + }, + want: "\n", + }, + { + name: "indent", + args: args{ + s: "one\ntwo\nthree", + indent: "--", + }, + want: "--one\n--two\n--three", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Indent(tt.args.s, tt.args.indent); got != tt.want { + t.Errorf("indent() = %q, want %q", got, tt.want) + } + }) + } +} diff --git a/pkg/text/truncate.go b/pkg/text/truncate.go index f0d8abe62..98a924c3e 100644 --- a/pkg/text/truncate.go +++ b/pkg/text/truncate.go @@ -1,63 +1,75 @@ package text import ( - "golang.org/x/text/width" + runewidth "github.com/mattn/go-runewidth" + "github.com/rivo/uniseg" ) -// DisplayWidth calculates what the rendered width of a string may be -func DisplayWidth(s string) int { - w := 0 - for _, r := range s { - w += runeDisplayWidth(r) - } - return w -} - const ( ellipsisWidth = 3 minWidthForEllipsis = 5 ) +// DisplayWidth calculates what the rendered width of a string may be +func DisplayWidth(s string) int { + g := uniseg.NewGraphemes(s) + w := 0 + for g.Next() { + w += graphemeWidth(g) + } + return w +} + // Truncate shortens a string to fit the maximum display width -func Truncate(max int, s string) string { +func Truncate(maxWidth int, s string) string { w := DisplayWidth(s) - if w <= max { + if w <= maxWidth { return s } useEllipsis := false - if max >= minWidthForEllipsis { + if maxWidth >= minWidthForEllipsis { useEllipsis = true - max -= ellipsisWidth + maxWidth -= ellipsisWidth } - cw := 0 - ri := 0 - for _, r := range s { - rw := runeDisplayWidth(r) - if cw+rw > max { + g := uniseg.NewGraphemes(s) + r := "" + rWidth := 0 + for { + g.Next() + gWidth := graphemeWidth(g) + + if rWidth+gWidth <= maxWidth { + r += g.Str() + rWidth += gWidth + continue + } else { break } - cw += rw - ri++ } - res := string([]rune(s)[:ri]) if useEllipsis { - res += "..." + r += "..." } - if cw < max { - // compensate if truncating a wide character left an odd space - res += " " + + if rWidth < maxWidth { + r += " " } - return res + + return r } -func runeDisplayWidth(r rune) int { - switch width.LookupRune(r).Kind() { - case width.EastAsianWide, width.EastAsianAmbiguous, width.EastAsianFullwidth: - return 2 - default: - return 1 +// graphemeWidth calculates what the rendered width of a grapheme may be +func graphemeWidth(g *uniseg.Graphemes) int { + // If grapheme spans one rune use that rune width + // If grapheme spans multiple runes use the first non-zero rune width + w := 0 + for _, r := range g.Runes() { + w = runewidth.RuneWidth(r) + if w > 0 { + break + } } + return w } diff --git a/pkg/text/truncate_test.go b/pkg/text/truncate_test.go index dbc0ec060..95119e2ff 100644 --- a/pkg/text/truncate_test.go +++ b/pkg/text/truncate_test.go @@ -1,6 +1,8 @@ package text -import "testing" +import ( + "testing" +) func TestTruncate(t *testing.T) { type args struct { @@ -60,6 +62,22 @@ func TestTruncate(t *testing.T) { }, want: "a프로젝... ", }, + { + name: "Emoji", + args: args{ + max: 11, + s: "💡💡💡💡💡💡💡💡💡💡💡💡", + }, + want: "💡💡💡💡...", + }, + { + name: "Accented characters", + args: args{ + max: 11, + s: "é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́é́́", + }, + want: "é́́é́́é́́é́́é́́é́́é́́é́́...", + }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -69,3 +87,74 @@ func TestTruncate(t *testing.T) { }) } } + +func TestDisplayWidth(t *testing.T) { + tests := []struct { + name string + text string + want int + }{ + { + name: "check mark", + text: `✓`, + want: 1, + }, + { + name: "bullet icon", + text: `•`, + want: 1, + }, + { + name: "middle dot", + text: `·`, + want: 1, + }, + { + name: "ellipsis", + text: `…`, + want: 1, + }, + { + name: "right arrow", + text: `→`, + want: 1, + }, + { + name: "smart double quotes", + text: `“”`, + want: 2, + }, + { + name: "smart single quotes", + text: `‘’`, + want: 2, + }, + { + name: "em dash", + text: `—`, + want: 1, + }, + { + name: "en dash", + text: `–`, + want: 1, + }, + { + name: "emoji", + text: `👍`, + want: 2, + }, + { + name: "accent character", + text: `é́́`, + want: 1, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DisplayWidth(tt.text); got != tt.want { + t.Errorf("DisplayWidth() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/script/distributions b/script/distributions new file mode 100644 index 000000000..1f8ec6ddb --- /dev/null +++ b/script/distributions @@ -0,0 +1,52 @@ +Origin: gh +Label: gh +Codename: stable +Architectures: i386 amd64 arm64 +Components: main +Description: The GitHub CLI - debian stable repo +SignWith: C99B11DEB97541F0 + +Origin: gh +Label: gh +Codename: focal +Architectures: i386 amd64 arm64 +Components: main +Description: The GitHub CLI - ubuntu focal repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu + +Origin: gh +Label: gh +Codename: precise +Architectures: i386 amd64 arm64 +Components: main +Description: The GitHub CLI - ubuntu precise repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu + +Origin: gh +Label: gh +Codename: bionic +Architectures: i386 amd64 arm64 +Components: main +Description: The GitHub CLI - ubuntu bionic repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu + +Origin: gh +Label: gh +Codename: trusty +Architectures: i386 amd64 arm64 +Components: main +Description: The GitHub CLI - ubuntu trusty repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu + +Origin: gh +Label: gh +Codename: xenial +Architectures: i386 amd64 arm64 +Components: main +Description: The GitHub CLI - ubuntu xenial repo +SignWith: C99B11DEB97541F0 +DebOverride: override.ubuntu diff --git a/script/override.ubuntu b/script/override.ubuntu new file mode 100644 index 000000000..89ab311a7 --- /dev/null +++ b/script/override.ubuntu @@ -0,0 +1,2 @@ +gh Priority optional +gh Section Development diff --git a/script/pubkey.asc b/script/pubkey.asc new file mode 100644 index 000000000..9c313b43d --- /dev/null +++ b/script/pubkey.asc @@ -0,0 +1,41 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mQGNBF9PzXUBDADjf/5plZnxldgufVJUC0xpwITJHB3iUvpTwYEgBaOi7WE+JkHb ++SN+2PFAumVftvOiCrt9TnrXDeUDGMsfiUa0zXsaU8pC1IcmnbYCjZqfWOmBUGoR +iGrgZxSnXQuJOIsK5R8ST9G6v7BCKTgBnTmjIeCLCEOg0GU0avrBnmszOUjDabBl +tvm/KC1lSsTfQjrn3j7LGbyVfTZ/nhIHnVUOjU4NcY9hd5xXydF0wpZ0pr28dcHI +O5X9YZSq+w8bNlGBQMa/RhGmRBIMAEKeoLFh6q/CuToe/5x8xgowJZDkSNjVmaeR +Acbdyhn2FmLyF/jFNXZ1DveX933N0MjQ/NCGgxpKbVVG+5BRSUA3Z53yvdzC61kO +8r88ZEXli4uBCNSRGcpjsS+EyV8ydMxC4uhRrbaa8b3xI0yHS9/VJ3+1vUuo4vxZ +sgbsmzErGOfyRaaF6SMJcQKf67Xk2whuDNQvRCwA8w7ZAK+XtyJ1M39fgDjRLVu6 +LK2A64jDiKPrOU0AEQEAAbQfTmF0ZSBTbWl0aCA8dmlsbWlibUBnaXRodWIuY29t +PokB1AQTAQoAPhYhBCyjIFbtIGy4H0SoysmbEd65dUHwBQJfT811AhsDBQkDwmcA +BQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEMmbEd65dUHwdWQL/1ztvcwtZUdv +c4XgwAU5DbGWxuVTAhMj+Y0o8Es3EZot6Ck9jhKUVHSF101Bbs0Nl1iOupr5P3yE +cKgsK5hB+g0Y503DsNjz3i6OyWuHVeIWmk9pXQF/ly3VOgj0atJwiR8RV79/iw+i +zcDkbKQ++3bIgLDVQ/b/EwJRZnxbBDNMRHOzxFR+JwazwFxTI5pGk0vcG6yFtKnI +4fvmVE3WjmCOaGk8umuQJ/UG/F/oWx46gnF/+VG7xIK0lbOj4GDuLRGeRftTLREw +5FTakC8vbddBfjpgOI6wAmE7UEU4L3S7wRvZeOrHzNCO5qBofTGbZVKZrkEHVCO5 +scuz04ESpUyGtV7QXVETWeo+hm8HD91pCdlpSOIpuE0kgUfifqYsDvuXMW7tb5+G +AdRjG5mTACoF+SrcfSapg2vqOvxBNB9DAVSUdsIKucepJy3n5Wp72+sGDZsIj/H1 +lCZ6Ycw5U/kqTQ3Vah4zSFH973C49+T5eTvtTjXL7TedfsF7JyxrirkBjQRfT811 +AQwAtY1zvmSpRIzN3uZWisOXrgW4AiLfbJYEpII9URuyzS3J9tYNVQMsvMQgNqaZ +fin0PY4sxj6f3WZSl0Ohc11vSjqMjaZ5YlEzcd3mG1dQCqnQmTmKFk7CgIZNU73t +4f8lKL26HMMfG0kiVZrYM6CxDK6CrX47yH5QSfVCpQBG1Dl28VdjGsfiqL3U3m3S +62p70guWGreqCJ+8GmhxhOFCDphudYVQxvIdnVw9FDetXfhYv/aJ74zwG+IYdjdf +Wxu9uqXjNKBmz6m1DMRYwgP8zWfS48lEnR/uNIkM13GEKOq1qjsEwRLXDBYS38TS +OsU4c8gS+dbCroUfbj5T332VfhSijf9yPmgqoXq9uhFs4uBOQPFDJhYLghHC/Etx +DtgqNvs+TQ+71aTEN+7PDcxmiJBT2Hecu//tOPAG6iqAtEJ0CSNt7ioKGaybkBg8 +RXv20ztJ2vUR8iq7DeoAOOBJsLXfjwFsEPT9zp0dzTLNL2g9mULVopNxE5YBdTol +McWTABEBAAGJAbwEGAEKACYWIQQsoyBW7SBsuB9EqMrJmxHeuXVB8AUCX0/NdQIb +DAUJA8JnAAAKCRDJmxHeuXVB8E/MC/9OWTFwggfzOTzrBT4eRohkChDwHF77WzEZ +vneaUEOgrDdkfZ4/LX/38HePae4/sIHvYMyEpqMMJor3SDLi71bPBmM4Hz5gt6Zx +9CA1CUPJ9QFHHbxMufhW/0Fdhg4cFis6gC9TK8CyjRyURAXGAEUWxGbtUV3z8k5+ +Vu6z/RNHAjcuCzwm1FgX6EaAQt14hI9DqX8YPVE+c6rhSTVuQcnvvIMRmmnirs9q +XyOoWgNBDdBsEyusOg04YWu+22nfmszrXDq4QUMkIfjeMoM/bYsF6D23ZTD4imZj +LiZe/dWok6xgMOwwBhrYP71qORwChebooVX6SylkMnfT2xp/qIeAidNSioYC2fIp +ue1p4NJ+4UaLLniQAlJ8TNoYoZ+UZDIftRYCfV+U1V9mqZtWZ9Qeuv8p6iboCVag +QomPPf2XCc2VlhOraWGGC7afWIUnEz9srg8OR9IzrITGtSF7SMHB/dB2hL/tFyFs +thQyULWTJXRidPWVyWWqegyubCbY0cY= +=ki4q +-----END PGP PUBLIC KEY BLOCK----- diff --git a/script/rpmmacros b/script/rpmmacros new file mode 100644 index 000000000..fdfb48a29 --- /dev/null +++ b/script/rpmmacros @@ -0,0 +1 @@ +%_gpg_name Nate Smith diff --git a/utils/utils.go b/utils/utils.go index cee56bf58..208b7a44e 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -118,3 +118,11 @@ func DisplayURL(urlStr string) string { func GreenCheck() string { return Green("✓") } + +func YellowDash() string { + return Yellow("-") +} + +func RedX() string { + return Red("X") +}