Merge remote-tracking branch 'origin' into color-env

This commit is contained in:
Mislav Marohnić 2020-09-16 16:51:38 +02:00
commit 086d8ed29a
113 changed files with 6735 additions and 898 deletions

View file

@ -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`

28
.github/ISSUE_TEMPLATE/feedback.md vendored Normal file
View file

@ -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"_

View file

@ -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 }}

View file

@ -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

View file

@ -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

View file

@ -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.
<!-- this anchor is linked to from elsewhere, so avoid renaming it -->
@ -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

View file

@ -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 {

View file

@ -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)

View file

@ -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())

View file

@ -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 {

View file

@ -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`.")

95
docs/install_linux.md Normal file
View file

@ -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

View file

@ -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)
}

195
git/url_test.go Normal file
View file

@ -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)
}
})
}
}

24
go.mod
View file

@ -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

41
go.sum
View file

@ -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=

View file

@ -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)

View file

@ -1,4 +1,4 @@
package config
package authflow
const oauthSuccessPage = `
<!doctype html>

View file

@ -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) {

View file

@ -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, &notFound) {
return "", err
return "", "", err
}
var hostValue string
if hostCfg != nil {
hostValue, err = hostCfg.GetStringValue(key)
if err != nil && !errors.As(err, &notFound) {
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, &notFound) {
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 ""
}

View file

@ -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())

View file

@ -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)
}

View file

@ -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)
})
}
}

View file

@ -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
}
}

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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)

View file

@ -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)
}

View file

@ -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)

View file

@ -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.

View file

@ -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{

View file

@ -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 {

View file

@ -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

View file

@ -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()

View file

@ -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
}),
)

View file

@ -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 {

View file

@ -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)

View file

@ -0,0 +1,9 @@
---
name: Bug report
about: Report a bug or unexpected behavior
title: Bug Report
labels: bug
---
I wanna report a bug

View file

@ -0,0 +1,9 @@
---
name: Submit a request
about: Propose an improvement
title: Enhancement Proposal
labels: enhancement
---
I have a suggestion for an enhancement

View file

@ -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": [
{

View file

@ -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)

View file

@ -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
`)

View file

@ -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, "")

View file

@ -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)
}

View file

@ -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")

227
pkg/cmd/pr/checks/checks.go Normal file
View file

@ -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
}

View file

@ -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)
})
}
}

View file

@ -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"
}
]
}
}
}
}
]}
} } } }

View file

@ -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"
}
]
}
}
}
}
]}
} } } }

View file

@ -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"
}
]
}
}
}
}
]}
} } } }

View file

@ -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"
}
]
}
}
}
}
]}
} } } }

View file

@ -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")
}

View file

@ -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)

View file

@ -0,0 +1,7 @@
---
name: "Bug fix"
about: Fix a bug
---
Fixes a bug and Closes an issue

View file

@ -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()
}

View file

@ -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

View file

@ -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)

View file

@ -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) {

View file

@ -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 {

View file

@ -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"),

View file

@ -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
}

View file

@ -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 != "" {

View file

@ -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",

View file

@ -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
}
}

View file

@ -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, "")

View file

@ -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)
}

View file

@ -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")

View file

@ -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 <tag> [<files>...]",
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(&notesFile, "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")
}

View file

@ -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, &params)
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())
})
}
}

View file

@ -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
}

View file

@ -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 <tag>",
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
}

View file

@ -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())
})
}
}

View file

@ -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 [<tag>]",
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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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())
})
}
}

View file

@ -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 <command>",
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
}

View file

@ -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")
}

View file

@ -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
}

View file

@ -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)
}
})
}
}

View file

@ -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 <tag> <files>...",
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
}

View file

@ -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 [<tag>]",
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]
}

View file

@ -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)
}
})
}
}

View file

@ -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)

View file

@ -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")

View file

@ -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 {

View file

@ -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]
}

View file

@ -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 [<repository>]",
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)
}

105
pkg/cmd/repo/garden/http.go Normal file
View file

@ -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
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
})
}
}

View file

@ -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 <command> <subcommand> --help" for more information about a command.
Use 'gh <command> <subcommand> --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

View file

@ -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)
}
})
}
}

View file

@ -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
}

View file

@ -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)
})
}
}

View file

@ -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

View file

@ -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")

Some files were not shown because too many files have changed in this diff Show more