Merge remote-tracking branch 'origin' into color-env
This commit is contained in:
commit
086d8ed29a
113 changed files with 6735 additions and 898 deletions
5
.github/CONTRIBUTING.md
vendored
5
.github/CONTRIBUTING.md
vendored
|
|
@ -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
28
.github/ISSUE_TEMPLATE/feedback.md
vendored
Normal 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"_
|
||||
89
.github/workflows/releases.yml
vendored
89
.github/workflows/releases.yml
vendored
|
|
@ -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 }}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
9
Makefile
9
Makefile
|
|
@ -22,8 +22,8 @@ endif
|
|||
GO_LDFLAGS := -X github.com/cli/cli/command.Version=$(GH_VERSION) $(GO_LDFLAGS)
|
||||
GO_LDFLAGS := -X github.com/cli/cli/command.BuildDate=$(BUILD_DATE) $(GO_LDFLAGS)
|
||||
ifdef GH_OAUTH_CLIENT_SECRET
|
||||
GO_LDFLAGS := -X github.com/cli/cli/internal/config.oauthClientID=$(GH_OAUTH_CLIENT_ID) $(GO_LDFLAGS)
|
||||
GO_LDFLAGS := -X github.com/cli/cli/internal/config.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(GO_LDFLAGS)
|
||||
GO_LDFLAGS := -X github.com/cli/cli/internal/authflow.oauthClientID=$(GH_OAUTH_CLIENT_ID) $(GO_LDFLAGS)
|
||||
GO_LDFLAGS := -X github.com/cli/cli/internal/authflow.oauthClientSecret=$(GH_OAUTH_CLIENT_SECRET) $(GO_LDFLAGS)
|
||||
endif
|
||||
|
||||
bin/gh: $(BUILD_FILES)
|
||||
|
|
@ -46,15 +46,14 @@ site-docs: site
|
|||
git -C site commit -m 'update help docs' || true
|
||||
.PHONY: site-docs
|
||||
|
||||
site-publish: site-docs
|
||||
site-bump: site-docs
|
||||
ifndef GITHUB_REF
|
||||
$(error GITHUB_REF is not set)
|
||||
endif
|
||||
sed -i.bak -E 's/(assign version = )".+"/\1"$(GITHUB_REF:refs/tags/v%=%)"/' site/index.html
|
||||
rm -f site/index.html.bak
|
||||
git -C site commit -m '$(GITHUB_REF:refs/tags/v%=%)' index.html
|
||||
git -C site push
|
||||
.PHONY: site-publish
|
||||
.PHONY: site-bump
|
||||
|
||||
|
||||
.PHONY: manpages
|
||||
|
|
|
|||
68
README.md
68
README.md
|
|
@ -9,32 +9,29 @@ the terminal next to where you are already working with `git` and your code.
|
|||
|
||||
While in beta, GitHub CLI is available for repos hosted on GitHub.com only. It currently does not support repositories hosted on GitHub Enterprise Server or other hosting providers. We are planning on adding support for GitHub Enterprise Server after GitHub CLI is out of beta (likely towards the end of 2020), and we want to ensure that the API endpoints we use are more widely available for GHES versions that most GitHub customers are on.
|
||||
|
||||
## We need your feedback
|
||||
## We want your feedback
|
||||
|
||||
GitHub CLI is currently in its early development stages, and we're hoping to get feedback from people using it.
|
||||
|
||||
If you've installed and used `gh`, we'd love for you to take a short survey here (no more than five minutes): https://forms.gle/umxd3h31c7aMQFKG7
|
||||
|
||||
And if you spot bugs or have features that you'd really like to see in `gh`, please check out the [contributing page][]
|
||||
We'd love to hear your feedback about `gh`. If you spot bugs or have features that you'd really like to see in `gh`, please check out the [contributing page][].
|
||||
|
||||
## Usage
|
||||
|
||||
- `gh pr [status, list, view, checkout, create]`
|
||||
- `gh issue [status, list, view, create]`
|
||||
- `gh repo [view, create, clone, fork]`
|
||||
- `gh auth [login, logout, refresh, status]`
|
||||
- `gh config [get, set]`
|
||||
- `gh help`
|
||||
|
||||
## Documentation
|
||||
|
||||
Read the [official docs](https://cli.github.com/manual/) for more information.
|
||||
Read the [official docs][] for more information.
|
||||
|
||||
## Comparison with hub
|
||||
|
||||
For many years, [hub][] was the unofficial GitHub CLI tool. `gh` is a new project that helps us explore
|
||||
what an official GitHub CLI tool can look like with a fundamentally different design. While both
|
||||
tools bring GitHub to the terminal, `hub` behaves as a proxy to `git`, and `gh` is a standalone
|
||||
tool. Check out our [more detailed explanation](/docs/gh-vs-hub.md) to learn more.
|
||||
tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
|
||||
|
||||
|
||||
<!-- 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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
95
docs/install_linux.md
Normal 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
|
||||
29
git/url.go
29
git/url.go
|
|
@ -2,24 +2,35 @@ package git
|
|||
|
||||
import (
|
||||
"net/url"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
var (
|
||||
protocolRe = regexp.MustCompile("^[a-zA-Z_+-]+://")
|
||||
)
|
||||
|
||||
func IsURL(u string) bool {
|
||||
return strings.HasPrefix(u, "git@") || protocolRe.MatchString(u)
|
||||
return strings.HasPrefix(u, "git@") || isSupportedProtocol(u)
|
||||
}
|
||||
|
||||
func isSupportedProtocol(u string) bool {
|
||||
return strings.HasPrefix(u, "ssh:") ||
|
||||
strings.HasPrefix(u, "git+ssh:") ||
|
||||
strings.HasPrefix(u, "git:") ||
|
||||
strings.HasPrefix(u, "http:") ||
|
||||
strings.HasPrefix(u, "https:")
|
||||
}
|
||||
|
||||
func isPossibleProtocol(u string) bool {
|
||||
return isSupportedProtocol(u) ||
|
||||
strings.HasPrefix(u, "ftp:") ||
|
||||
strings.HasPrefix(u, "ftps:") ||
|
||||
strings.HasPrefix(u, "file:")
|
||||
}
|
||||
|
||||
// ParseURL normalizes git remote urls
|
||||
func ParseURL(rawURL string) (u *url.URL, err error) {
|
||||
if !protocolRe.MatchString(rawURL) &&
|
||||
strings.Contains(rawURL, ":") &&
|
||||
if !isPossibleProtocol(rawURL) &&
|
||||
strings.ContainsRune(rawURL, ':') &&
|
||||
// not a Windows path
|
||||
!strings.Contains(rawURL, "\\") {
|
||||
!strings.ContainsRune(rawURL, '\\') {
|
||||
// support scp-like syntax for ssh protocol
|
||||
rawURL = "ssh://" + strings.Replace(rawURL, ":", "/", 1)
|
||||
}
|
||||
|
||||
|
|
|
|||
195
git/url_test.go
Normal file
195
git/url_test.go
Normal 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
24
go.mod
|
|
@ -3,28 +3,30 @@ module github.com/cli/cli
|
|||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.0.7
|
||||
github.com/AlecAivazis/survey/v2 v2.1.1
|
||||
github.com/MakeNowJust/heredoc v1.0.0
|
||||
github.com/briandowns/spinner v1.11.1
|
||||
github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684
|
||||
github.com/google/go-cmp v0.4.1
|
||||
github.com/google/go-cmp v0.5.2
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
github.com/hashicorp/go-version v1.2.0
|
||||
github.com/henvic/httpretty v0.0.5
|
||||
github.com/hashicorp/go-version v1.2.1
|
||||
github.com/henvic/httpretty v0.0.6
|
||||
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
|
||||
github.com/mattn/go-colorable v0.1.6
|
||||
github.com/mattn/go-colorable v0.1.7
|
||||
github.com/mattn/go-isatty v0.0.12
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b
|
||||
github.com/mattn/go-runewidth v0.0.9
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
|
||||
github.com/mitchellh/go-homedir v1.1.0
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200627185320-e003124d66e4
|
||||
github.com/rivo/uniseg v0.1.0
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5
|
||||
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
|
||||
github.com/spf13/cobra v1.0.0
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.5.1
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
|
||||
golang.org/x/text v0.3.2
|
||||
github.com/stretchr/testify v1.6.1
|
||||
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
|
||||
golang.org/x/text v0.3.3 // indirect
|
||||
gopkg.in/yaml.v2 v2.2.8 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
|
||||
)
|
||||
|
||||
replace github.com/shurcooL/graphql => github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e
|
||||
|
|
|
|||
41
go.sum
41
go.sum
|
|
@ -1,6 +1,6 @@
|
|||
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||
github.com/AlecAivazis/survey/v2 v2.0.7 h1:+f825XHLse/hWd2tE/V5df04WFGimk34Eyg/z35w/rc=
|
||||
github.com/AlecAivazis/survey/v2 v2.0.7/go.mod h1:mlizQTaPjnR4jcpwRSaSlkbsRfYFEyKgLQvYTzxxiHA=
|
||||
github.com/AlecAivazis/survey/v2 v2.1.1 h1:LEMbHE0pLj75faaVEKClEX1TM4AJmmnOh9eimREzLWI=
|
||||
github.com/AlecAivazis/survey/v2 v2.1.1/go.mod h1:9FJRdMdDm8rnT+zHVbvQT2RTSTLq0Ttd6q3Vl2fahjk=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
|
||||
|
|
@ -65,8 +65,8 @@ github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5y
|
|||
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
|
||||
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
|
||||
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
github.com/google/go-cmp v0.4.1 h1:/exdXoGamhu5ONeUJH0deniYLWYvQwW66yvlfiiKTu0=
|
||||
github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/go-cmp v0.5.2 h1:X2ev0eStA3AbceY54o37/0PQ/UWqKEiiO2dKL5OPaFM=
|
||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4=
|
||||
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
|
||||
|
|
@ -76,11 +76,11 @@ github.com/graph-gophers/graphql-go v0.0.0-20200622220639-c1d9693c95a6/go.mod h1
|
|||
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
|
||||
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
|
||||
github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t/GYV9WGTSLVdBhOQFDY=
|
||||
github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E=
|
||||
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/go-version v1.2.1 h1:zEfKbn2+PDgroKdiOzqiE8rsmLqU2uwi5PB5pBJ3TkI=
|
||||
github.com/hashicorp/go-version v1.2.1/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
|
||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
||||
github.com/henvic/httpretty v0.0.5 h1:XmOHN7HHt+ZhlLNzsMC54yncJDybipkP5NHOGVBOr1s=
|
||||
github.com/henvic/httpretty v0.0.5/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
|
||||
github.com/henvic/httpretty v0.0.6 h1:JdzGzKZBajBfnvlMALXXMVQWxWMF/ofTy8C3/OSUTxs=
|
||||
github.com/henvic/httpretty v0.0.6/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
|
||||
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
|
||||
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
|
||||
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
|
||||
|
|
@ -107,6 +107,8 @@ github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx
|
|||
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
|
||||
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
|
||||
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-colorable v0.1.7 h1:bQGKb3vps/j0E9GfJQ03JyhRuxsvdAanXlT9BTw3mdw=
|
||||
github.com/mattn/go-colorable v0.1.7/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
github.com/mattn/go-isatty v0.0.12/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
|
||||
|
|
@ -117,6 +119,8 @@ github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m
|
|||
github.com/matttproud/golang_protobuf_extensions v1.0.1/go.mod h1:D8He9yQNgCq6Z5Ld7szi9bcBfOoFv/3dc6xSMkL2PC0=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b h1:j7+1HpAFS1zy5+Q4qx1fWh90gTKwiN4QCGoY9TWyyO4=
|
||||
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
|
||||
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2 h1:5lPfLTTAvAbtS0VqT+94yOtFnGfUWYyx0+iToC3Os3s=
|
||||
github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/leAFZyRl6bYmGDlGc=
|
||||
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
|
||||
|
|
@ -147,13 +151,15 @@ github.com/prometheus/common v0.4.0/go.mod h1:TNfzLD0ON7rHzMJeJkieUDPYmFC7Snx/y8
|
|||
github.com/prometheus/procfs v0.0.0-20181005140218-185b4288413d/go.mod h1:c3At6R/oaqEKCNdg8wHV1ftS6bRYblBhIjjI8uT2IGk=
|
||||
github.com/prometheus/procfs v0.0.0-20190507164030-5867b95ac084/go.mod h1:TjEm7ze935MbeOT/UhFTIMYKhuLP4wbCsTZCD3I8kEA=
|
||||
github.com/prometheus/tsdb v0.7.1/go.mod h1:qhTCs0VvXwvX/y3TZrWD7rabWM+ijKTux40TwIPHuXU=
|
||||
github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY=
|
||||
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rogpeppe/fastuuid v0.0.0-20150106093220-6724a57986af/go.mod h1:XWv6SoW27p1b0cqNHllgS5HIMJraePCO15w5zCzIWYg=
|
||||
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
|
||||
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
|
||||
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200627185320-e003124d66e4 h1:cjmR6xY0f89IwBYMSwUrkFs4/1+KKw30Df3SqT7nZ6Q=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200627185320-e003124d66e4/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5 h1:CA6Mjshr+g5YHENwllpQNR0UaYO7VGKo6TzJLM64WJQ=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
|
||||
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
|
||||
|
|
@ -176,8 +182,8 @@ github.com/stretchr/testify v1.2.1/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
|
|||
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
|
||||
github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
|
||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||
github.com/stretchr/testify v1.5.1 h1:nOGnQDM7FYENwehXlg/kFVnos3rEvtKTjRvOWSzb6H4=
|
||||
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
|
||||
github.com/stretchr/testify v1.6.1 h1:hDPOHmpOpP40lSULcqw7IrRb/u7w6RpDC9399XyoNd0=
|
||||
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
|
||||
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
|
||||
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
|
||||
|
|
@ -194,6 +200,8 @@ golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5 h1:8dUaAV7K4uHsF56JQWkpre
|
|||
golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9 h1:psW17arqaxU48Z5kZ0CQnkZWQJsqcURM6tKiBApRjXI=
|
||||
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a h1:vclmkQCjlDX5OydZ9wv8rBCcS0QyQY66Mpf/7BZbInM=
|
||||
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||
|
|
@ -227,8 +235,8 @@ golang.org/x/sys v0.0.0-20200413165638-669c56c373c4 h1:opSr2sbRXk5X5/givKrrKj9HX
|
|||
golang.org/x/sys v0.0.0-20200413165638-669c56c373c4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
|
||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
|
||||
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
|
||||
golang.org/x/text v0.3.3 h1:cokOdA+Jmi5PJGXLlLllQSgYigAEfHXJAERHVMaCc2k=
|
||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||
|
|
@ -253,6 +261,7 @@ gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw=
|
|||
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v2 v2.2.8 h1:obN1ZagJSUGI0Ek/LBmuj4SNLPfIny3KsKFopxRdj10=
|
||||
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86 h1:OfFoIUYv/me30yv7XlMy4F9RJw8DEm8WQ6QG1Ph4bH0=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200506231410-2ff61e1afc86/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ=
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package config
|
||||
package authflow
|
||||
|
||||
const oauthSuccessPage = `
|
||||
<!doctype html>
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -10,15 +10,21 @@ import (
|
|||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const defaultGitProtocol = "https"
|
||||
const (
|
||||
defaultGitProtocol = "https"
|
||||
PromptsDisabled = "disabled"
|
||||
PromptsEnabled = "enabled"
|
||||
)
|
||||
|
||||
// This interface describes interacting with some persistent configuration for gh.
|
||||
type Config interface {
|
||||
Get(string, string) (string, error)
|
||||
GetWithSource(string, string) (string, string, error)
|
||||
Set(string, string, string) error
|
||||
UnsetHost(string)
|
||||
Hosts() ([]string, error)
|
||||
Aliases() (*AliasConfig, error)
|
||||
CheckWriteable(string, string) error
|
||||
Write() error
|
||||
}
|
||||
|
||||
|
|
@ -165,6 +171,24 @@ func NewBlankRoot() *yaml.Node {
|
|||
Kind: yaml.ScalarNode,
|
||||
Value: "",
|
||||
},
|
||||
{
|
||||
HeadComment: "When to interactively prompt. This is a global config that cannot be overriden by hostname. Supported values: enabled, disabled",
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: "prompt",
|
||||
},
|
||||
{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: PromptsEnabled,
|
||||
},
|
||||
{
|
||||
HeadComment: "A pager program to send command output to. Example value: less",
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: "pager",
|
||||
},
|
||||
{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: "",
|
||||
},
|
||||
{
|
||||
HeadComment: "Aliases allow you to create nicknames for gh commands",
|
||||
Kind: yaml.ScalarNode,
|
||||
|
|
@ -200,42 +224,51 @@ func (c *fileConfig) Root() *yaml.Node {
|
|||
}
|
||||
|
||||
func (c *fileConfig) Get(hostname, key string) (string, error) {
|
||||
val, _, err := c.GetWithSource(hostname, key)
|
||||
return val, err
|
||||
}
|
||||
|
||||
func (c *fileConfig) GetWithSource(hostname, key string) (string, string, error) {
|
||||
if hostname != "" {
|
||||
var notFound *NotFoundError
|
||||
|
||||
hostCfg, err := c.configForHost(hostname)
|
||||
if err != nil && !errors.As(err, ¬Found) {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
var hostValue string
|
||||
if hostCfg != nil {
|
||||
hostValue, err = hostCfg.GetStringValue(key)
|
||||
if err != nil && !errors.As(err, ¬Found) {
|
||||
return "", err
|
||||
return "", "", err
|
||||
}
|
||||
}
|
||||
|
||||
if hostValue != "" {
|
||||
return hostValue, nil
|
||||
// TODO: avoid hardcoding this
|
||||
return hostValue, "~/.config/gh/hosts.yml", nil
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: avoid hardcoding this
|
||||
defaultSource := "~/.config/gh/config.yml"
|
||||
|
||||
value, err := c.GetStringValue(key)
|
||||
|
||||
var notFound *NotFoundError
|
||||
|
||||
if err != nil && errors.As(err, ¬Found) {
|
||||
return defaultFor(key), nil
|
||||
return defaultFor(key), defaultSource, nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
return "", defaultSource, err
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
return defaultFor(key), nil
|
||||
return defaultFor(key), defaultSource, nil
|
||||
}
|
||||
|
||||
return value, nil
|
||||
return value, defaultSource, nil
|
||||
}
|
||||
|
||||
func (c *fileConfig) Set(hostname, key, value string) error {
|
||||
|
|
@ -281,6 +314,11 @@ func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
|
|||
return nil, &NotFoundError{fmt.Errorf("could not find config entry for %q", hostname)}
|
||||
}
|
||||
|
||||
func (c *fileConfig) CheckWriteable(hostname, key string) error {
|
||||
// TODO: check filesystem permissions
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *fileConfig) Write() error {
|
||||
mainData := yaml.Node{Kind: yaml.MappingNode}
|
||||
hostsData := yaml.Node{Kind: yaml.MappingNode}
|
||||
|
|
@ -460,6 +498,8 @@ func defaultFor(key string) string {
|
|||
switch key {
|
||||
case "git_protocol":
|
||||
return defaultGitProtocol
|
||||
case "prompt":
|
||||
return PromptsEnabled
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
||||
|
|
|
|||
71
internal/config/from_env.go
Normal file
71
internal/config/from_env.go
Normal 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)
|
||||
}
|
||||
160
internal/config/from_env_test.go
Normal file
160
internal/config/from_env_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}),
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
name: Bug report
|
||||
about: Report a bug or unexpected behavior
|
||||
title: Bug Report
|
||||
labels: bug
|
||||
|
||||
---
|
||||
|
||||
I wanna report a bug
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
---
|
||||
name: Submit a request
|
||||
about: Propose an improvement
|
||||
title: Enhancement Proposal
|
||||
labels: enhancement
|
||||
|
||||
---
|
||||
|
||||
I have a suggestion for an enhancement
|
||||
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
`)
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
227
pkg/cmd/pr/checks/checks.go
Normal 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
|
||||
}
|
||||
211
pkg/cmd/pr/checks/checks_test.go
Normal file
211
pkg/cmd/pr/checks/checks_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
41
pkg/cmd/pr/checks/fixtures/allPassing.json
Normal file
41
pkg/cmd/pr/checks/fixtures/allPassing.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
} } } }
|
||||
41
pkg/cmd/pr/checks/fixtures/someFailing.json
Normal file
41
pkg/cmd/pr/checks/fixtures/someFailing.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
} } } }
|
||||
41
pkg/cmd/pr/checks/fixtures/somePending.json
Normal file
41
pkg/cmd/pr/checks/fixtures/somePending.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
} } } }
|
||||
38
pkg/cmd/pr/checks/fixtures/withStatuses.json
Normal file
38
pkg/cmd/pr/checks/fixtures/withStatuses.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]}
|
||||
} } } }
|
||||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
---
|
||||
name: "Bug fix"
|
||||
about: Fix a bug
|
||||
|
||||
---
|
||||
|
||||
Fixes a bug and Closes an issue
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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, "")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
365
pkg/cmd/release/create/create.go
Normal file
365
pkg/cmd/release/create/create.go
Normal 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(¬esFile, "notes-file", "F", "", "Read release notes from `file`")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func createRun(opts *CreateOptions) error {
|
||||
httpClient, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
baseRepo, err := opts.BaseRepo()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !opts.BodyProvided && opts.IO.CanPrompt() {
|
||||
editorCommand, err := cmdutil.DetermineEditor(opts.Config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var tagDescription string
|
||||
var generatedChangelog string
|
||||
if opts.RepoOverride == "" {
|
||||
headRef := opts.TagName
|
||||
tagDescription, _ = gitTagInfo(opts.TagName)
|
||||
if tagDescription == "" {
|
||||
if opts.Target != "" {
|
||||
// TODO: use the remote-tracking version of the branch ref
|
||||
headRef = opts.Target
|
||||
} else {
|
||||
headRef = "HEAD"
|
||||
}
|
||||
}
|
||||
|
||||
if prevTag, err := detectPreviousTag(headRef); err == nil {
|
||||
commits, _ := changelogForRange(fmt.Sprintf("%s..%s", prevTag, headRef))
|
||||
generatedChangelog = generateChangelog(commits)
|
||||
}
|
||||
}
|
||||
|
||||
editorOptions := []string{"Write my own"}
|
||||
if generatedChangelog != "" {
|
||||
editorOptions = append(editorOptions, "Write using commit log as template")
|
||||
}
|
||||
if tagDescription != "" {
|
||||
editorOptions = append(editorOptions, "Write using git tag message as template")
|
||||
}
|
||||
editorOptions = append(editorOptions, "Leave blank")
|
||||
|
||||
qs := []*survey.Question{
|
||||
{
|
||||
Name: "name",
|
||||
Prompt: &survey.Input{
|
||||
Message: "Title (optional)",
|
||||
Default: opts.Name,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "releaseNotesAction",
|
||||
Prompt: &survey.Select{
|
||||
Message: "Release notes",
|
||||
Options: editorOptions,
|
||||
},
|
||||
},
|
||||
}
|
||||
err = prompt.SurveyAsk(qs, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not prompt: %w", err)
|
||||
}
|
||||
|
||||
var openEditor bool
|
||||
var editorContents string
|
||||
|
||||
switch opts.ReleaseNotesAction {
|
||||
case "Write my own":
|
||||
openEditor = true
|
||||
case "Write using commit log as template":
|
||||
openEditor = true
|
||||
editorContents = generatedChangelog
|
||||
case "Write using git tag message as template":
|
||||
openEditor = true
|
||||
editorContents = tagDescription
|
||||
case "Leave blank":
|
||||
openEditor = false
|
||||
default:
|
||||
return fmt.Errorf("invalid action: %v", opts.ReleaseNotesAction)
|
||||
}
|
||||
|
||||
if openEditor {
|
||||
// TODO: consider using iostreams here
|
||||
text, err := surveyext.Edit(editorCommand, "*.md", editorContents, os.Stdin, os.Stdout, os.Stderr, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Body = text
|
||||
}
|
||||
|
||||
qs = []*survey.Question{
|
||||
{
|
||||
Name: "prerelease",
|
||||
Prompt: &survey.Confirm{
|
||||
Message: "Is this a prerelease?",
|
||||
Default: opts.Prerelease,
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "submitAction",
|
||||
Prompt: &survey.Select{
|
||||
Message: "Submit?",
|
||||
Options: []string{
|
||||
"Publish release",
|
||||
"Save as draft",
|
||||
"Cancel",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err = prompt.SurveyAsk(qs, opts)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not prompt: %w", err)
|
||||
}
|
||||
|
||||
switch opts.SubmitAction {
|
||||
case "Publish release":
|
||||
opts.Draft = false
|
||||
case "Save as draft":
|
||||
opts.Draft = true
|
||||
case "Cancel":
|
||||
return cmdutil.SilentError
|
||||
default:
|
||||
return fmt.Errorf("invalid action: %v", opts.SubmitAction)
|
||||
}
|
||||
}
|
||||
|
||||
params := map[string]interface{}{
|
||||
"tag_name": opts.TagName,
|
||||
"draft": opts.Draft,
|
||||
"prerelease": opts.Prerelease,
|
||||
"name": opts.Name,
|
||||
"body": opts.Body,
|
||||
}
|
||||
if opts.Target != "" {
|
||||
params["target_commitish"] = opts.Target
|
||||
}
|
||||
|
||||
hasAssets := len(opts.Assets) > 0
|
||||
|
||||
// Avoid publishing the release until all assets have finished uploading
|
||||
if hasAssets {
|
||||
params["draft"] = true
|
||||
}
|
||||
|
||||
newRelease, err := createRelease(httpClient, baseRepo, params)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if hasAssets {
|
||||
uploadURL := newRelease.UploadURL
|
||||
if idx := strings.IndexRune(uploadURL, '{'); idx > 0 {
|
||||
uploadURL = uploadURL[:idx]
|
||||
}
|
||||
|
||||
opts.IO.StartProgressIndicator()
|
||||
err = shared.ConcurrentUpload(httpClient, uploadURL, opts.Concurrency, opts.Assets)
|
||||
opts.IO.StopProgressIndicator()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if !opts.Draft {
|
||||
rel, err := publishRelease(httpClient, newRelease.APIURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
newRelease = rel
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.HTMLURL)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func gitTagInfo(tagName string) (string, error) {
|
||||
cmd := exec.Command("git", "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)")
|
||||
b, err := run.PrepareCmd(cmd).Output()
|
||||
return string(b), err
|
||||
}
|
||||
|
||||
func detectPreviousTag(headRef string) (string, error) {
|
||||
cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef))
|
||||
b, err := run.PrepareCmd(cmd).Output()
|
||||
return strings.TrimSpace(string(b)), err
|
||||
}
|
||||
|
||||
type logEntry struct {
|
||||
Subject string
|
||||
Body string
|
||||
}
|
||||
|
||||
func changelogForRange(refRange string) ([]logEntry, error) {
|
||||
cmd := exec.Command("git", "-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange)
|
||||
b, err := run.PrepareCmd(cmd).Output()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var entries []logEntry
|
||||
for _, cb := range bytes.Split(b, []byte{'\000'}) {
|
||||
c := strings.ReplaceAll(string(cb), "\r\n", "\n")
|
||||
c = strings.TrimPrefix(c, "\n")
|
||||
if len(c) == 0 {
|
||||
continue
|
||||
}
|
||||
parts := strings.SplitN(c, "\n\n", 2)
|
||||
var body string
|
||||
subject := strings.ReplaceAll(parts[0], "\n", " ")
|
||||
if len(parts) > 1 {
|
||||
body = parts[1]
|
||||
}
|
||||
entries = append(entries, logEntry{
|
||||
Subject: subject,
|
||||
Body: body,
|
||||
})
|
||||
}
|
||||
|
||||
return entries, nil
|
||||
}
|
||||
|
||||
func generateChangelog(commits []logEntry) string {
|
||||
var parts []string
|
||||
for _, c := range commits {
|
||||
// TODO: consider rendering "Merge pull request #123 from owner/branch" differently
|
||||
parts = append(parts, fmt.Sprintf("* %s", c.Subject))
|
||||
if c.Body != "" {
|
||||
parts = append(parts, text.Indent(c.Body, " "))
|
||||
}
|
||||
}
|
||||
return strings.Join(parts, "\n\n")
|
||||
}
|
||||
383
pkg/cmd/release/create/create_test.go
Normal file
383
pkg/cmd/release/create/create_test.go
Normal 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, ¶ms)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, tt.wantParams, params)
|
||||
|
||||
if len(tt.opts.Assets) > 0 {
|
||||
q := fakeHTTP.Requests[1].URL.Query()
|
||||
assert.Equal(t, tt.opts.Assets[0].Name, q.Get("name"))
|
||||
assert.Equal(t, tt.opts.Assets[0].Label, q.Get("label"))
|
||||
|
||||
bb, err := ioutil.ReadAll(fakeHTTP.Requests[2].Body)
|
||||
require.NoError(t, err)
|
||||
var updateParams interface{}
|
||||
err = json.Unmarshal(bb, &updateParams)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, map[string]interface{}{"draft": false}, updateParams)
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.wantStdout, stdout.String())
|
||||
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
78
pkg/cmd/release/create/http.go
Normal file
78
pkg/cmd/release/create/http.go
Normal 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
|
||||
}
|
||||
119
pkg/cmd/release/delete/delete.go
Normal file
119
pkg/cmd/release/delete/delete.go
Normal 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
|
||||
}
|
||||
160
pkg/cmd/release/delete/delete_test.go
Normal file
160
pkg/cmd/release/delete/delete_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
208
pkg/cmd/release/download/download.go
Normal file
208
pkg/cmd/release/download/download.go
Normal 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
|
||||
}
|
||||
254
pkg/cmd/release/download/download_test.go
Normal file
254
pkg/cmd/release/download/download_test.go
Normal 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
|
||||
}
|
||||
72
pkg/cmd/release/list/http.go
Normal file
72
pkg/cmd/release/list/http.go
Normal 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
|
||||
}
|
||||
115
pkg/cmd/release/list/list.go
Normal file
115
pkg/cmd/release/list/list.go
Normal 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
|
||||
}
|
||||
189
pkg/cmd/release/list/list_test.go
Normal file
189
pkg/cmd/release/list/list_test.go
Normal 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())
|
||||
})
|
||||
}
|
||||
}
|
||||
33
pkg/cmd/release/release.go
Normal file
33
pkg/cmd/release/release.go
Normal 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
|
||||
}
|
||||
163
pkg/cmd/release/shared/fetch.go
Normal file
163
pkg/cmd/release/shared/fetch.go
Normal 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")
|
||||
}
|
||||
215
pkg/cmd/release/shared/upload.go
Normal file
215
pkg/cmd/release/shared/upload.go
Normal 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
|
||||
}
|
||||
69
pkg/cmd/release/shared/upload_test.go
Normal file
69
pkg/cmd/release/shared/upload_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
123
pkg/cmd/release/upload/upload.go
Normal file
123
pkg/cmd/release/upload/upload.go
Normal 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
|
||||
}
|
||||
193
pkg/cmd/release/view/view.go
Normal file
193
pkg/cmd/release/view/view.go
Normal 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]
|
||||
}
|
||||
289
pkg/cmd/release/view/view_test.go
Normal file
289
pkg/cmd/release/view/view_test.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
}
|
||||
|
|
|
|||
480
pkg/cmd/repo/garden/garden.go
Normal file
480
pkg/cmd/repo/garden/garden.go
Normal 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
105
pkg/cmd/repo/garden/http.go
Normal 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
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
65
pkg/cmd/root/help_topic.go
Normal file
65
pkg/cmd/root/help_topic.go
Normal 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
|
||||
}
|
||||
79
pkg/cmd/root/help_topic_test.go
Normal file
79
pkg/cmd/root/help_topic_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue