Merge remote-tracking branch 'origin/trunk' into repo-fork-gitflags
This commit is contained in:
commit
a27a94f8b5
220 changed files with 13685 additions and 4420 deletions
16
.github/CONTRIBUTING.md
vendored
16
.github/CONTRIBUTING.md
vendored
|
|
@ -26,11 +26,15 @@ Prerequisites:
|
|||
- Go 1.13+ for building the binary
|
||||
- Go 1.15+ for running the test suite
|
||||
|
||||
Build with: `make` or `go build -o bin/gh ./cmd/gh`
|
||||
Build with:
|
||||
* Unix-like systems: `make`
|
||||
* Windows: `go run script/build.go`
|
||||
|
||||
Run the new binary as: `./bin/gh`
|
||||
Run the new binary as:
|
||||
* Unix-like systems: `bin/gh`
|
||||
* Windows: `bin\gh`
|
||||
|
||||
Run tests with: `make test` or `go test ./...`
|
||||
Run tests with: `go test ./...`
|
||||
|
||||
## Submitting a pull request
|
||||
|
||||
|
|
@ -44,6 +48,10 @@ Please note that this project adheres to a [Contributor Code of Conduct][code-of
|
|||
|
||||
We generate manual pages from source on every release. You do not need to submit pull requests for documentation specifically; manual pages for commands will automatically get updated after your pull requests gets accepted.
|
||||
|
||||
## Design guidelines
|
||||
|
||||
You may reference the [CLI Design System][] when suggesting features, and are welcome to use our [Google Docs Template][] to suggest designs.
|
||||
|
||||
## Resources
|
||||
|
||||
- [How to Contribute to Open Source][]
|
||||
|
|
@ -61,3 +69,5 @@ We generate manual pages from source on every release. You do not need to submit
|
|||
[How to Contribute to Open Source]: https://opensource.guide/how-to-contribute/
|
||||
[Using Pull Requests]: https://docs.github.com/en/free-pro-team@latest/github/collaborating-with-issues-and-pull-requests/about-pull-requests
|
||||
[GitHub Help]: https://docs.github.com/
|
||||
[CLI Design System]: https://primer.style/cli/
|
||||
[Google Docs Template]: https://docs.google.com/document/d/1JIRErIUuJ6fTgabiFYfCH3x91pyHuytbfa0QLnTfXKM/edit#heading=h.or54sa47ylpg
|
||||
|
|
|
|||
1
.github/workflows/codeql.yml
vendored
1
.github/workflows/codeql.yml
vendored
|
|
@ -2,6 +2,7 @@ name: Code Scanning
|
|||
|
||||
on:
|
||||
push:
|
||||
pull_request:
|
||||
schedule:
|
||||
- cron: "0 0 * * 0"
|
||||
|
||||
|
|
|
|||
2
.github/workflows/go.yml
vendored
2
.github/workflows/go.yml
vendored
|
|
@ -39,4 +39,6 @@ jobs:
|
|||
uses: actions/checkout@v2
|
||||
|
||||
- name: Build
|
||||
env:
|
||||
CGO_ENABLED: '0'
|
||||
run: go build -v ./cmd/gh
|
||||
|
|
|
|||
8
.github/workflows/lint.yml
vendored
8
.github/workflows/lint.yml
vendored
|
|
@ -29,7 +29,7 @@ jobs:
|
|||
go mod verify
|
||||
go mod download
|
||||
|
||||
LINT_VERSION=1.29.0
|
||||
LINT_VERSION=1.34.1
|
||||
curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \
|
||||
tar xz --strip-components 1 --wildcards \*/golangci-lint
|
||||
mkdir -p bin && mv golangci-lint bin/
|
||||
|
|
@ -50,10 +50,6 @@ jobs:
|
|||
assert-nothing-changed go fmt ./...
|
||||
assert-nothing-changed go mod tidy
|
||||
|
||||
while read -r file linter msg; do
|
||||
IFS=: read -ra f <<<"$file"
|
||||
printf '::error file=%s,line=%s,col=%s::%s\n' "${f[0]}" "${f[1]}" "${f[2]}" "[$linter] $msg"
|
||||
STATUS=1
|
||||
done < <(bin/golangci-lint run --out-format tab)
|
||||
bin/golangci-lint run --out-format github-actions || STATUS=$?
|
||||
|
||||
exit $STATUS
|
||||
|
|
|
|||
11
.github/workflows/releases.yml
vendored
11
.github/workflows/releases.yml
vendored
|
|
@ -17,7 +17,7 @@ jobs:
|
|||
go-version: 1.15
|
||||
- name: Generate changelog
|
||||
run: |
|
||||
echo ::set-env name=GORELEASER_CURRENT_TAG::${GITHUB_REF#refs/tags/}
|
||||
echo "GORELEASER_CURRENT_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
|
||||
git fetch --unshallow
|
||||
script/changelog | tee CHANGELOG.md
|
||||
- name: Run GoReleaser
|
||||
|
|
@ -50,10 +50,11 @@ jobs:
|
|||
run: |
|
||||
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")
|
||||
cards=$(api --paginate projects/columns/$PENDING_COLUMN/cards | jq ".[].id")
|
||||
for card in $cards; do
|
||||
api-write projects/columns/cards/$card/moves -f position=top -F column_id=$DONE_COLUMN
|
||||
api-write --silent projects/columns/cards/$card/moves -f position=top -F column_id=$DONE_COLUMN
|
||||
done
|
||||
echo "moved ${#cards[@]} cards to the Done column"
|
||||
|
||||
- name: Install packaging dependencies
|
||||
run: sudo apt-get install -y createrepo rpm reprepro
|
||||
|
|
@ -126,8 +127,8 @@ jobs:
|
|||
- name: Prepare PATH
|
||||
shell: bash
|
||||
run: |
|
||||
echo "::add-path::$WIX\\bin"
|
||||
echo "::add-path::C:\\Program Files\\go-msi"
|
||||
echo "$WIX\\bin" >> $GITHUB_PATH
|
||||
echo "C:\\Program Files\\go-msi" >> $GITHUB_PATH
|
||||
- name: Build MSI
|
||||
id: buildmsi
|
||||
shell: bash
|
||||
|
|
|
|||
4
.gitignore
vendored
4
.gitignore
vendored
|
|
@ -6,6 +6,7 @@
|
|||
/site
|
||||
.github/**/node_modules
|
||||
/CHANGELOG.md
|
||||
/script/build
|
||||
|
||||
# VS Code
|
||||
.vscode
|
||||
|
|
@ -16,4 +17,7 @@
|
|||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# vim
|
||||
*.swp
|
||||
|
||||
vendor/
|
||||
|
|
|
|||
|
|
@ -15,7 +15,7 @@ builds:
|
|||
binary: bin/gh
|
||||
main: ./cmd/gh
|
||||
ldflags:
|
||||
- -s -w -X github.com/cli/cli/command.Version={{.Version}} -X github.com/cli/cli/command.BuildDate={{time "2006-01-02"}}
|
||||
- -s -w -X github.com/cli/cli/internal/build.Version={{.Version}} -X github.com/cli/cli/internal/build.Date={{time "2006-01-02"}}
|
||||
- -X main.updaterEnabled=cli/cli
|
||||
id: macos
|
||||
goos: [darwin]
|
||||
|
|
@ -24,7 +24,9 @@ builds:
|
|||
- <<: *build_defaults
|
||||
id: linux
|
||||
goos: [linux]
|
||||
goarch: [386, amd64, arm64]
|
||||
goarch: [386, arm, amd64, arm64]
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
|
||||
- <<: *build_defaults
|
||||
id: windows
|
||||
|
|
|
|||
76
Makefile
76
Makefile
|
|
@ -1,55 +1,47 @@
|
|||
BUILD_FILES = $(shell go list -f '{{range .GoFiles}}{{$$.Dir}}/{{.}}\
|
||||
{{end}}' ./...)
|
||||
CGO_CPPFLAGS ?= ${CPPFLAGS}
|
||||
export CGO_CPPFLAGS
|
||||
CGO_CFLAGS ?= ${CFLAGS}
|
||||
export CGO_CFLAGS
|
||||
CGO_LDFLAGS ?= $(filter -g -L% -l% -O%,${LDFLAGS})
|
||||
export CGO_LDFLAGS
|
||||
|
||||
GH_VERSION ?= $(shell git describe --tags 2>/dev/null || git rev-parse --short HEAD)
|
||||
DATE_FMT = +%Y-%m-%d
|
||||
ifdef SOURCE_DATE_EPOCH
|
||||
BUILD_DATE ?= $(shell date -u -d "@$(SOURCE_DATE_EPOCH)" "$(DATE_FMT)" 2>/dev/null || date -u -r "$(SOURCE_DATE_EPOCH)" "$(DATE_FMT)" 2>/dev/null || date -u "$(DATE_FMT)")
|
||||
else
|
||||
BUILD_DATE ?= $(shell date "$(DATE_FMT)")
|
||||
endif
|
||||
## The following tasks delegate to `script/build.go` so they can be run cross-platform.
|
||||
|
||||
ifndef CGO_CPPFLAGS
|
||||
export CGO_CPPFLAGS := $(CPPFLAGS)
|
||||
endif
|
||||
ifndef CGO_CFLAGS
|
||||
export CGO_CFLAGS := $(CFLAGS)
|
||||
endif
|
||||
ifndef CGO_LDFLAGS
|
||||
export CGO_LDFLAGS := $(LDFLAGS)
|
||||
endif
|
||||
.PHONY: bin/gh
|
||||
bin/gh: script/build
|
||||
@script/build bin/gh
|
||||
|
||||
GO_LDFLAGS := -X github.com/cli/cli/internal/build.Version=$(GH_VERSION) $(GO_LDFLAGS)
|
||||
GO_LDFLAGS := -X github.com/cli/cli/internal/build.Date=$(BUILD_DATE) $(GO_LDFLAGS)
|
||||
ifdef GH_OAUTH_CLIENT_SECRET
|
||||
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
|
||||
script/build: script/build.go
|
||||
go build -o script/build script/build.go
|
||||
|
||||
bin/gh: $(BUILD_FILES)
|
||||
@go build -trimpath -ldflags "$(GO_LDFLAGS)" -o "$@" ./cmd/gh
|
||||
|
||||
clean:
|
||||
rm -rf ./bin ./share
|
||||
.PHONY: clean
|
||||
clean: script/build
|
||||
@script/build clean
|
||||
|
||||
.PHONY: manpages
|
||||
manpages: script/build
|
||||
@script/build manpages
|
||||
|
||||
# just a convenience task around `go test`
|
||||
.PHONY: test
|
||||
test:
|
||||
go test ./...
|
||||
.PHONY: test
|
||||
|
||||
## Site-related tasks are exclusively intended for use by the GitHub CLI team and for our release automation.
|
||||
|
||||
site:
|
||||
git clone https://github.com/github/cli.github.com.git "$@"
|
||||
|
||||
.PHONY: site-docs
|
||||
site-docs: site
|
||||
git -C site pull
|
||||
git -C site rm 'manual/gh*.md' 2>/dev/null || true
|
||||
go run ./cmd/gen-docs --website --doc-path site/manual
|
||||
for f in site/manual/gh*.md; do sed -i.bak -e '/^### SEE ALSO/,$$d' "$$f"; done
|
||||
rm -f site/manual/*.bak
|
||||
git -C site add 'manual/gh*.md'
|
||||
git -C site commit -m 'update help docs' || true
|
||||
.PHONY: site-docs
|
||||
|
||||
.PHONY: site-bump
|
||||
site-bump: site-docs
|
||||
ifndef GITHUB_REF
|
||||
$(error GITHUB_REF is not set)
|
||||
|
|
@ -57,9 +49,21 @@ 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
|
||||
.PHONY: site-bump
|
||||
|
||||
## Install/uninstall tasks are here for use on *nix platform. On Windows, there is no equivalent.
|
||||
|
||||
.PHONY: manpages
|
||||
manpages:
|
||||
go run ./cmd/gen-docs --man-page --doc-path ./share/man/man1/
|
||||
DESTDIR :=
|
||||
prefix := /usr/local
|
||||
bindir := ${prefix}/bin
|
||||
mandir := ${prefix}/share/man
|
||||
|
||||
.PHONY: install
|
||||
install: bin/gh manpages
|
||||
install -d ${DESTDIR}${bindir}
|
||||
install -m755 bin/gh ${DESTDIR}${bindir}/
|
||||
install -d ${DESTDIR}${mandir}/man1
|
||||
install -m644 ./share/man/man1/* ${DESTDIR}${mandir}/man1/
|
||||
|
||||
.PHONY: uninstall
|
||||
uninstall:
|
||||
rm -f ${DESTDIR}${bindir}/gh ${DESTDIR}${mandir}/man1/gh.1 ${DESTDIR}${mandir}/man1/gh-*.1
|
||||
|
|
|
|||
27
README.md
27
README.md
|
|
@ -42,22 +42,22 @@ For more information and distro-specific instructions, see the [Linux installati
|
|||
|
||||
### Windows
|
||||
|
||||
`gh` is available via [scoop][], [Chocolatey][], and as downloadable MSI.
|
||||
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], and as downloadable MSI.
|
||||
|
||||
|
||||
#### WinGet
|
||||
|
||||
| Install: | Upgrade: |
|
||||
| ------------------- | --------------------|
|
||||
| `winget install gh` | `winget install gh` |
|
||||
|
||||
<i>WinGet does not have a specialized `upgrade` command yet, but the `install` command should work for upgrading to a newer version of GitHub CLI.</i>
|
||||
|
||||
#### scoop
|
||||
|
||||
Install:
|
||||
|
||||
```powershell
|
||||
scoop bucket add github-gh https://github.com/cli/scoop-gh.git
|
||||
scoop install gh
|
||||
```
|
||||
|
||||
Upgrade:
|
||||
|
||||
```powershell
|
||||
scoop update gh
|
||||
```
|
||||
| Install: | Upgrade: |
|
||||
| ------------------ | ------------------ |
|
||||
| `scoop install gh` | `scoop update gh` |
|
||||
|
||||
#### Chocolatey
|
||||
|
||||
|
|
@ -88,6 +88,7 @@ tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
|
|||
[manual]: https://cli.github.com/manual/
|
||||
[Homebrew]: https://brew.sh
|
||||
[MacPorts]: https://www.macports.org
|
||||
[winget]: https://github.com/microsoft/winget-cli
|
||||
[scoop]: https://scoop.sh
|
||||
[Chocolatey]: https://chocolatey.org
|
||||
[releases page]: https://github.com/cli/cli/releases/latest
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import (
|
|||
func makeCachedClient(httpClient *http.Client, cacheTTL time.Duration) *http.Client {
|
||||
cacheDir := filepath.Join(os.TempDir(), "gh-cli-cache")
|
||||
return &http.Client{
|
||||
Transport: CacheReponse(cacheTTL, cacheDir)(httpClient.Transport),
|
||||
Transport: CacheResponse(cacheTTL, cacheDir)(httpClient.Transport),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -39,8 +39,8 @@ func isCacheableResponse(res *http.Response) bool {
|
|||
return res.StatusCode < 500 && res.StatusCode != 403
|
||||
}
|
||||
|
||||
// CacheReponse produces a RoundTripper that caches HTTP responses to disk for a specified amount of time
|
||||
func CacheReponse(ttl time.Duration, dir string) ClientOption {
|
||||
// CacheResponse produces a RoundTripper that caches HTTP responses to disk for a specified amount of time
|
||||
func CacheResponse(ttl time.Duration, dir string) ClientOption {
|
||||
fs := fileStorage{
|
||||
dir: dir,
|
||||
ttl: ttl,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import (
|
|||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_CacheReponse(t *testing.T) {
|
||||
func Test_CacheResponse(t *testing.T) {
|
||||
counter := 0
|
||||
fakeHTTP := funcTripper{
|
||||
roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||
|
|
@ -32,7 +32,7 @@ func Test_CacheReponse(t *testing.T) {
|
|||
}
|
||||
|
||||
cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache")
|
||||
httpClient := NewHTTPClient(ReplaceTripper(fakeHTTP), CacheReponse(time.Minute, cacheDir))
|
||||
httpClient := NewHTTPClient(ReplaceTripper(fakeHTTP), CacheResponse(time.Minute, cacheDir))
|
||||
|
||||
do := func(method, url string, body io.Reader) (string, error) {
|
||||
req, err := http.NewRequest(method, url, body)
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package api
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
|
|
@ -99,58 +98,6 @@ func ReplaceTripper(tr http.RoundTripper) ClientOption {
|
|||
}
|
||||
}
|
||||
|
||||
var issuedScopesWarning bool
|
||||
|
||||
const (
|
||||
httpOAuthAppID = "X-Oauth-Client-Id"
|
||||
httpOAuthScopes = "X-Oauth-Scopes"
|
||||
)
|
||||
|
||||
// CheckScopes checks whether an OAuth scope is present in a response
|
||||
func CheckScopes(wantedScope string, cb func(string) error) ClientOption {
|
||||
wantedCandidates := []string{wantedScope}
|
||||
if strings.HasPrefix(wantedScope, "read:") {
|
||||
wantedCandidates = append(wantedCandidates, "admin:"+strings.TrimPrefix(wantedScope, "read:"))
|
||||
}
|
||||
|
||||
return func(tr http.RoundTripper) http.RoundTripper {
|
||||
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
|
||||
res, err := tr.RoundTrip(req)
|
||||
if err != nil || res.StatusCode > 299 || issuedScopesWarning {
|
||||
return res, err
|
||||
}
|
||||
|
||||
_, hasHeader := res.Header[httpOAuthAppID]
|
||||
if !hasHeader {
|
||||
return res, nil
|
||||
}
|
||||
|
||||
appID := res.Header.Get(httpOAuthAppID)
|
||||
hasScopes := strings.Split(res.Header.Get(httpOAuthScopes), ",")
|
||||
|
||||
hasWanted := false
|
||||
outer:
|
||||
for _, s := range hasScopes {
|
||||
for _, w := range wantedCandidates {
|
||||
if w == strings.TrimSpace(s) {
|
||||
hasWanted = true
|
||||
break outer
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !hasWanted {
|
||||
if err := cb(appID); err != nil {
|
||||
return res, err
|
||||
}
|
||||
issuedScopesWarning = true
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}}
|
||||
}
|
||||
}
|
||||
|
||||
type funcTripper struct {
|
||||
roundTrip func(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
|
@ -207,7 +154,20 @@ func (err HTTPError) Error() string {
|
|||
}
|
||||
|
||||
type MissingScopesError struct {
|
||||
error
|
||||
MissingScopes []string
|
||||
}
|
||||
|
||||
func (e MissingScopesError) Error() string {
|
||||
var missing []string
|
||||
for _, s := range e.MissingScopes {
|
||||
missing = append(missing, fmt.Sprintf("'%s'", s))
|
||||
}
|
||||
scopes := strings.Join(missing, ", ")
|
||||
|
||||
if len(e.MissingScopes) == 1 {
|
||||
return "missing required scope " + scopes
|
||||
}
|
||||
return "missing required scopes " + scopes
|
||||
}
|
||||
|
||||
func (c Client) HasMinimumScopes(hostname string) error {
|
||||
|
|
@ -235,31 +195,34 @@ func (c Client) HasMinimumScopes(hostname string) error {
|
|||
return HandleHTTPError(res)
|
||||
}
|
||||
|
||||
hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",")
|
||||
scopesHeader := res.Header.Get("X-Oauth-Scopes")
|
||||
if scopesHeader == "" {
|
||||
// if the token reports no scopes, assume that it's an integration token and give up on
|
||||
// detecting its capabilities
|
||||
return nil
|
||||
}
|
||||
|
||||
search := map[string]bool{
|
||||
"repo": false,
|
||||
"read:org": false,
|
||||
"admin:org": false,
|
||||
}
|
||||
|
||||
for _, s := range hasScopes {
|
||||
for _, s := range strings.Split(scopesHeader, ",") {
|
||||
search[strings.TrimSpace(s)] = true
|
||||
}
|
||||
|
||||
errorMsgs := []string{}
|
||||
var missingScopes []string
|
||||
if !search["repo"] {
|
||||
errorMsgs = append(errorMsgs, "missing required scope 'repo'")
|
||||
missingScopes = append(missingScopes, "repo")
|
||||
}
|
||||
|
||||
if !search["read:org"] && !search["admin:org"] {
|
||||
errorMsgs = append(errorMsgs, "missing required scope 'read:org'")
|
||||
missingScopes = append(missingScopes, "read:org")
|
||||
}
|
||||
|
||||
if len(errorMsgs) > 0 {
|
||||
return &MissingScopesError{error: errors.New(strings.Join(errorMsgs, ";"))}
|
||||
if len(missingScopes) > 0 {
|
||||
return &MissingScopesError{MissingScopes: missingScopes}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,19 +5,12 @@ import (
|
|||
"errors"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func eq(t *testing.T, got interface{}, expected interface{}) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, expected) {
|
||||
t.Errorf("expected: %v, got: %v", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGraphQL(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := NewClient(
|
||||
|
|
@ -32,15 +25,19 @@ func TestGraphQL(t *testing.T) {
|
|||
}
|
||||
}{}
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`{"data":{"viewer":{"login":"hubot"}}}`))
|
||||
http.Register(
|
||||
httpmock.GraphQL("QUERY"),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"hubot"}}}`),
|
||||
)
|
||||
|
||||
err := client.GraphQL("github.com", "QUERY", vars, &response)
|
||||
eq(t, err, nil)
|
||||
eq(t, response.Viewer.Login, "hubot")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hubot", response.Viewer.Login)
|
||||
|
||||
req := http.Requests[0]
|
||||
reqBody, _ := ioutil.ReadAll(req.Body)
|
||||
eq(t, string(reqBody), `{"query":"QUERY","variables":{"name":"Mona"}}`)
|
||||
eq(t, req.Header.Get("Authorization"), "token OTOKEN")
|
||||
assert.Equal(t, `{"query":"QUERY","variables":{"name":"Mona"}}`, string(reqBody))
|
||||
assert.Equal(t, "token OTOKEN", req.Header.Get("Authorization"))
|
||||
}
|
||||
|
||||
func TestGraphQLError(t *testing.T) {
|
||||
|
|
@ -48,12 +45,17 @@ func TestGraphQLError(t *testing.T) {
|
|||
client := NewClient(ReplaceTripper(http))
|
||||
|
||||
response := struct{}{}
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
{ "errors": [
|
||||
{"message":"OH NO"},
|
||||
{"message":"this is fine"}
|
||||
]
|
||||
}`))
|
||||
|
||||
http.Register(
|
||||
httpmock.GraphQL(""),
|
||||
httpmock.StringResponse(`
|
||||
{ "errors": [
|
||||
{"message":"OH NO"},
|
||||
{"message":"this is fine"}
|
||||
]
|
||||
}
|
||||
`),
|
||||
)
|
||||
|
||||
err := client.GraphQL("github.com", "", nil, &response)
|
||||
if err == nil || err.Error() != "GraphQL error: OH NO\nthis is fine" {
|
||||
|
|
@ -68,11 +70,14 @@ func TestRESTGetDelete(t *testing.T) {
|
|||
ReplaceTripper(http),
|
||||
)
|
||||
|
||||
http.StubResponse(204, bytes.NewBuffer([]byte{}))
|
||||
http.Register(
|
||||
httpmock.REST("DELETE", "applications/CLIENTID/grant"),
|
||||
httpmock.StatusStringResponse(204, "{}"),
|
||||
)
|
||||
|
||||
r := bytes.NewReader([]byte(`{}`))
|
||||
err := client.REST("github.com", "DELETE", "applications/CLIENTID/grant", r, nil)
|
||||
eq(t, err, nil)
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestRESTError(t *testing.T) {
|
||||
|
|
@ -105,97 +110,66 @@ func TestRESTError(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_CheckScopes(t *testing.T) {
|
||||
func Test_HasMinimumScopes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
wantScope string
|
||||
responseApp string
|
||||
responseScopes string
|
||||
responseError error
|
||||
expectCallback bool
|
||||
name string
|
||||
header string
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "missing read:org",
|
||||
wantScope: "read:org",
|
||||
responseApp: "APPID",
|
||||
responseScopes: "repo, gist",
|
||||
expectCallback: true,
|
||||
name: "no scopes",
|
||||
header: "",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "has read:org",
|
||||
wantScope: "read:org",
|
||||
responseApp: "APPID",
|
||||
responseScopes: "repo, read:org, gist",
|
||||
expectCallback: false,
|
||||
name: "default scopes",
|
||||
header: "repo, read:org",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "has admin:org",
|
||||
wantScope: "read:org",
|
||||
responseApp: "APPID",
|
||||
responseScopes: "repo, admin:org, gist",
|
||||
expectCallback: false,
|
||||
name: "admin:org satisfies read:org",
|
||||
header: "repo, admin:org",
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "no scopes in response",
|
||||
wantScope: "read:org",
|
||||
responseApp: "",
|
||||
responseScopes: "",
|
||||
expectCallback: false,
|
||||
name: "insufficient scope",
|
||||
header: "repo",
|
||||
wantErr: "missing required scope 'read:org'",
|
||||
},
|
||||
{
|
||||
name: "errored response",
|
||||
wantScope: "read:org",
|
||||
responseApp: "",
|
||||
responseScopes: "",
|
||||
responseError: errors.New("Network Failed"),
|
||||
expectCallback: false,
|
||||
name: "insufficient scopes",
|
||||
header: "gist",
|
||||
wantErr: "missing required scopes 'repo', 'read:org'",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
tr := &httpmock.Registry{}
|
||||
tr.Register(httpmock.MatchAny, func(*http.Request) (*http.Response, error) {
|
||||
if tt.responseError != nil {
|
||||
return nil, tt.responseError
|
||||
}
|
||||
if tt.responseScopes == "" {
|
||||
return &http.Response{StatusCode: 200}, nil
|
||||
}
|
||||
fakehttp := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(fakehttp))
|
||||
|
||||
fakehttp.Register(httpmock.REST("GET", ""), func(req *http.Request) (*http.Response, error) {
|
||||
return &http.Response{
|
||||
Request: req,
|
||||
StatusCode: 200,
|
||||
Header: http.Header{
|
||||
"X-Oauth-Client-Id": []string{tt.responseApp},
|
||||
"X-Oauth-Scopes": []string{tt.responseScopes},
|
||||
Body: ioutil.NopCloser(&bytes.Buffer{}),
|
||||
Header: map[string][]string{
|
||||
"X-Oauth-Scopes": {tt.header},
|
||||
},
|
||||
}, nil
|
||||
})
|
||||
|
||||
callbackInvoked := false
|
||||
var gotAppID string
|
||||
fn := CheckScopes(tt.wantScope, func(appID string) error {
|
||||
callbackInvoked = true
|
||||
gotAppID = appID
|
||||
return nil
|
||||
})
|
||||
|
||||
rt := fn(tr)
|
||||
req, err := http.NewRequest("GET", "https://api.github.com/hello", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
err := client.HasMinimumScopes("github.com")
|
||||
if tt.wantErr == "" {
|
||||
if err != nil {
|
||||
t.Errorf("error: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err.Error() != tt.wantErr {
|
||||
t.Errorf("want %q, got %q", tt.wantErr, err.Error())
|
||||
|
||||
issuedScopesWarning = false
|
||||
_, err = rt.RoundTrip(req)
|
||||
if err != nil && !errors.Is(err, tt.responseError) {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if tt.expectCallback != callbackInvoked {
|
||||
t.Fatalf("expected CheckScopes callback: %v", tt.expectCallback)
|
||||
}
|
||||
if tt.expectCallback && gotAppID != tt.responseApp {
|
||||
t.Errorf("unexpected app ID: %q", gotAppID)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package api
|
|||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestPullRequest_ChecksStatus(t *testing.T) {
|
||||
|
|
@ -31,11 +33,11 @@ func TestPullRequest_ChecksStatus(t *testing.T) {
|
|||
} }] } }
|
||||
`
|
||||
err := json.Unmarshal([]byte(payload), &pr)
|
||||
eq(t, err, nil)
|
||||
assert.NoError(t, err)
|
||||
|
||||
checks := pr.ChecksStatus()
|
||||
eq(t, checks.Total, 8)
|
||||
eq(t, checks.Pending, 3)
|
||||
eq(t, checks.Failing, 3)
|
||||
eq(t, checks.Passing, 2)
|
||||
assert.Equal(t, 8, checks.Total)
|
||||
assert.Equal(t, 3, checks.Pending)
|
||||
assert.Equal(t, 3, checks.Failing)
|
||||
assert.Equal(t, 2, checks.Passing)
|
||||
}
|
||||
|
|
|
|||
182
api/queries_comments.go
Normal file
182
api/queries_comments.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/shurcooL/githubv4"
|
||||
"github.com/shurcooL/graphql"
|
||||
)
|
||||
|
||||
type Comments struct {
|
||||
Nodes []Comment
|
||||
TotalCount int
|
||||
PageInfo PageInfo
|
||||
}
|
||||
|
||||
type Comment struct {
|
||||
Author Author
|
||||
AuthorAssociation string
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
IncludesCreatedEdit bool
|
||||
ReactionGroups ReactionGroups
|
||||
}
|
||||
|
||||
type PageInfo struct {
|
||||
HasNextPage bool
|
||||
EndCursor string
|
||||
}
|
||||
|
||||
func CommentsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) (*Comments, error) {
|
||||
type response struct {
|
||||
Repository struct {
|
||||
Issue struct {
|
||||
Comments Comments `graphql:"comments(first: 100, after: $endCursor)"`
|
||||
} `graphql:"issue(number: $number)"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"repo": githubv4.String(repo.RepoName()),
|
||||
"number": githubv4.Int(issue.Number),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
|
||||
var comments []Comment
|
||||
for {
|
||||
var query response
|
||||
err := gql.QueryNamed(context.Background(), "CommentsForIssue", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comments = append(comments, query.Repository.Issue.Comments.Nodes...)
|
||||
if !query.Repository.Issue.Comments.PageInfo.HasNextPage {
|
||||
break
|
||||
}
|
||||
variables["endCursor"] = githubv4.String(query.Repository.Issue.Comments.PageInfo.EndCursor)
|
||||
}
|
||||
|
||||
return &Comments{Nodes: comments, TotalCount: len(comments)}, nil
|
||||
}
|
||||
|
||||
func CommentsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) (*Comments, error) {
|
||||
type response struct {
|
||||
Repository struct {
|
||||
PullRequest struct {
|
||||
Comments Comments `graphql:"comments(first: 100, after: $endCursor)"`
|
||||
} `graphql:"pullRequest(number: $number)"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"repo": githubv4.String(repo.RepoName()),
|
||||
"number": githubv4.Int(pr.Number),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
|
||||
var comments []Comment
|
||||
for {
|
||||
var query response
|
||||
err := gql.QueryNamed(context.Background(), "CommentsForPullRequest", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
comments = append(comments, query.Repository.PullRequest.Comments.Nodes...)
|
||||
if !query.Repository.PullRequest.Comments.PageInfo.HasNextPage {
|
||||
break
|
||||
}
|
||||
variables["endCursor"] = githubv4.String(query.Repository.PullRequest.Comments.PageInfo.EndCursor)
|
||||
}
|
||||
|
||||
return &Comments{Nodes: comments, TotalCount: len(comments)}, nil
|
||||
}
|
||||
|
||||
type CommentCreateInput struct {
|
||||
Body string
|
||||
SubjectId string
|
||||
}
|
||||
|
||||
func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (string, error) {
|
||||
var mutation struct {
|
||||
AddComment struct {
|
||||
CommentEdge struct {
|
||||
Node struct {
|
||||
URL string
|
||||
}
|
||||
}
|
||||
} `graphql:"addComment(input: $input)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"input": githubv4.AddCommentInput{
|
||||
Body: githubv4.String(params.Body),
|
||||
SubjectID: graphql.ID(params.SubjectId),
|
||||
},
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repoHost)
|
||||
err := gql.MutateNamed(context.Background(), "CommentCreate", &mutation, variables)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return mutation.AddComment.CommentEdge.Node.URL, nil
|
||||
}
|
||||
|
||||
func commentsFragment() string {
|
||||
return `comments(last: 1) {
|
||||
nodes {
|
||||
author {
|
||||
login
|
||||
}
|
||||
authorAssociation
|
||||
body
|
||||
createdAt
|
||||
includesCreatedEdit
|
||||
` + reactionGroupsFragment() + `
|
||||
}
|
||||
totalCount
|
||||
}`
|
||||
}
|
||||
|
||||
func (c Comment) AuthorLogin() string {
|
||||
return c.Author.Login
|
||||
}
|
||||
|
||||
func (c Comment) Association() string {
|
||||
return c.AuthorAssociation
|
||||
}
|
||||
|
||||
func (c Comment) Content() string {
|
||||
return c.Body
|
||||
}
|
||||
|
||||
func (c Comment) Created() time.Time {
|
||||
return c.CreatedAt
|
||||
}
|
||||
|
||||
func (c Comment) IsEdited() bool {
|
||||
return c.IncludesCreatedEdit
|
||||
}
|
||||
|
||||
func (c Comment) Reactions() ReactionGroups {
|
||||
return c.ReactionGroups
|
||||
}
|
||||
|
||||
func (c Comment) Status() string {
|
||||
return ""
|
||||
}
|
||||
|
||||
func (c Comment) Link() string {
|
||||
return ""
|
||||
}
|
||||
|
|
@ -33,12 +33,8 @@ type Issue struct {
|
|||
Body string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
Comments struct {
|
||||
TotalCount int
|
||||
}
|
||||
Author struct {
|
||||
Login string
|
||||
}
|
||||
Comments Comments
|
||||
Author Author
|
||||
Assignees struct {
|
||||
Nodes []struct {
|
||||
Login string
|
||||
|
|
@ -65,12 +61,17 @@ type Issue struct {
|
|||
Milestone struct {
|
||||
Title string
|
||||
}
|
||||
ReactionGroups ReactionGroups
|
||||
}
|
||||
|
||||
type IssuesDisabledError struct {
|
||||
error
|
||||
}
|
||||
|
||||
type Author struct {
|
||||
Login string
|
||||
}
|
||||
|
||||
const fragments = `
|
||||
fragment issue on Issue {
|
||||
number
|
||||
|
|
@ -78,7 +79,7 @@ const fragments = `
|
|||
url
|
||||
state
|
||||
updatedAt
|
||||
labels(first: 3) {
|
||||
labels(first: 100) {
|
||||
nodes {
|
||||
name
|
||||
}
|
||||
|
|
@ -341,7 +342,22 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
|
|||
author {
|
||||
login
|
||||
}
|
||||
comments {
|
||||
comments(last: 1) {
|
||||
nodes {
|
||||
author {
|
||||
login
|
||||
}
|
||||
authorAssociation
|
||||
body
|
||||
createdAt
|
||||
includesCreatedEdit
|
||||
reactionGroups {
|
||||
content
|
||||
users {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
number
|
||||
|
|
@ -370,9 +386,15 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
|
|||
}
|
||||
totalCount
|
||||
}
|
||||
milestone{
|
||||
milestone {
|
||||
title
|
||||
}
|
||||
reactionGroups {
|
||||
content
|
||||
users {
|
||||
totalCount
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
|
@ -459,3 +481,11 @@ func milestoneNodeIdToDatabaseId(nodeId string) (string, error) {
|
|||
}
|
||||
return splitted[1], nil
|
||||
}
|
||||
|
||||
func (i Issue) Link() string {
|
||||
return i.URL
|
||||
}
|
||||
|
||||
func (i Issue) Identifier() string {
|
||||
return i.ID
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"io/ioutil"
|
||||
"testing"
|
||||
|
|
@ -16,30 +15,36 @@ func TestIssueList(t *testing.T) {
|
|||
http := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issues": {
|
||||
"nodes": [],
|
||||
"pageInfo": {
|
||||
"hasNextPage": true,
|
||||
"endCursor": "ENDCURSOR"
|
||||
}
|
||||
}
|
||||
} } }
|
||||
`))
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issues": {
|
||||
"nodes": [],
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
"endCursor": "ENDCURSOR"
|
||||
}
|
||||
}
|
||||
} } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query IssueList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issues": {
|
||||
"nodes": [],
|
||||
"pageInfo": {
|
||||
"hasNextPage": true,
|
||||
"endCursor": "ENDCURSOR"
|
||||
}
|
||||
}
|
||||
} } }
|
||||
`),
|
||||
)
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query IssueList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issues": {
|
||||
"nodes": [],
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
"endCursor": "ENDCURSOR"
|
||||
}
|
||||
}
|
||||
} } }
|
||||
`),
|
||||
)
|
||||
|
||||
repo, _ := ghrepo.FromFullName("OWNER/REPO")
|
||||
_, err := IssueList(client, repo, "open", []string{}, "", 251, "", "", "")
|
||||
|
|
@ -75,44 +80,51 @@ func TestIssueList_pagination(t *testing.T) {
|
|||
http := &httpmock.Registry{}
|
||||
client := NewClient(ReplaceTripper(http))
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issues": {
|
||||
"nodes": [
|
||||
{
|
||||
"title": "issue1",
|
||||
"labels": { "nodes": [ { "name": "bug" } ], "totalCount": 1 },
|
||||
"assignees": { "nodes": [ { "login": "user1" } ], "totalCount": 1 }
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query IssueList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issues": {
|
||||
"nodes": [
|
||||
{
|
||||
"title": "issue1",
|
||||
"labels": { "nodes": [ { "name": "bug" } ], "totalCount": 1 },
|
||||
"assignees": { "nodes": [ { "login": "user1" } ], "totalCount": 1 }
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": true,
|
||||
"endCursor": "ENDCURSOR"
|
||||
},
|
||||
"totalCount": 2
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": true,
|
||||
"endCursor": "ENDCURSOR"
|
||||
},
|
||||
"totalCount": 2
|
||||
}
|
||||
} } }
|
||||
`))
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issues": {
|
||||
"nodes": [
|
||||
{
|
||||
"title": "issue2",
|
||||
"labels": { "nodes": [ { "name": "enhancement" } ], "totalCount": 1 },
|
||||
"assignees": { "nodes": [ { "login": "user2" } ], "totalCount": 1 }
|
||||
} } }
|
||||
`),
|
||||
)
|
||||
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query IssueList\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issues": {
|
||||
"nodes": [
|
||||
{
|
||||
"title": "issue2",
|
||||
"labels": { "nodes": [ { "name": "enhancement" } ], "totalCount": 1 },
|
||||
"assignees": { "nodes": [ { "login": "user2" } ], "totalCount": 1 }
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
"endCursor": "ENDCURSOR"
|
||||
},
|
||||
"totalCount": 2
|
||||
}
|
||||
],
|
||||
"pageInfo": {
|
||||
"hasNextPage": false,
|
||||
"endCursor": "ENDCURSOR"
|
||||
},
|
||||
"totalCount": 2
|
||||
}
|
||||
} } }
|
||||
`))
|
||||
} } }
|
||||
`),
|
||||
)
|
||||
|
||||
repo := ghrepo.New("OWNER", "REPO")
|
||||
res, err := IssueList(client, repo, "", nil, "", 0, "", "", "")
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -14,19 +15,6 @@ import (
|
|||
"github.com/shurcooL/githubv4"
|
||||
)
|
||||
|
||||
type PullRequestReviewState int
|
||||
|
||||
const (
|
||||
ReviewApprove PullRequestReviewState = iota
|
||||
ReviewRequestChanges
|
||||
ReviewComment
|
||||
)
|
||||
|
||||
type PullRequestReviewInput struct {
|
||||
Body string
|
||||
State PullRequestReviewState
|
||||
}
|
||||
|
||||
type PullRequestsPayload struct {
|
||||
ViewerCreated PullRequestAndTotalCount
|
||||
ReviewRequested PullRequestAndTotalCount
|
||||
|
|
@ -102,14 +90,6 @@ type PullRequest struct {
|
|||
}
|
||||
TotalCount int
|
||||
}
|
||||
Reviews struct {
|
||||
Nodes []struct {
|
||||
Author struct {
|
||||
Login string
|
||||
}
|
||||
State string
|
||||
}
|
||||
}
|
||||
Assignees struct {
|
||||
Nodes []struct {
|
||||
Login string
|
||||
|
|
@ -136,6 +116,9 @@ type PullRequest struct {
|
|||
Milestone struct {
|
||||
Title string
|
||||
}
|
||||
Comments Comments
|
||||
ReactionGroups ReactionGroups
|
||||
Reviews PullRequestReviews
|
||||
}
|
||||
|
||||
type NotFoundError struct {
|
||||
|
|
@ -153,6 +136,14 @@ func (pr PullRequest) HeadLabel() string {
|
|||
return pr.HeadRefName
|
||||
}
|
||||
|
||||
func (pr PullRequest) Link() string {
|
||||
return pr.URL
|
||||
}
|
||||
|
||||
func (pr PullRequest) Identifier() string {
|
||||
return pr.ID
|
||||
}
|
||||
|
||||
type PullRequestReviewStatus struct {
|
||||
ChangesRequested bool
|
||||
Approved bool
|
||||
|
|
@ -217,6 +208,18 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
|
|||
return
|
||||
}
|
||||
|
||||
func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
|
||||
published := []PullRequestReview{}
|
||||
for _, prr := range pr.Reviews.Nodes {
|
||||
//Dont display pending reviews
|
||||
//Dont display commenting reviews without top level comment body
|
||||
if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") {
|
||||
published = append(published, prr)
|
||||
}
|
||||
}
|
||||
return PullRequestReviews{Nodes: published, TotalCount: len(published)}
|
||||
}
|
||||
|
||||
func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) {
|
||||
url := fmt.Sprintf("%srepos/%s/pulls/%d",
|
||||
ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber)
|
||||
|
|
@ -567,15 +570,6 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
|
|||
}
|
||||
totalCount
|
||||
}
|
||||
reviews(last: 100) {
|
||||
nodes {
|
||||
author {
|
||||
login
|
||||
}
|
||||
state
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
assignees(first: 100) {
|
||||
nodes {
|
||||
login
|
||||
|
|
@ -602,6 +596,8 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
|
|||
milestone{
|
||||
title
|
||||
}
|
||||
` + commentsFragment() + `
|
||||
` + reactionGroupsFragment() + `
|
||||
}
|
||||
}
|
||||
}`
|
||||
|
|
@ -621,11 +617,10 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
|
|||
return &resp.Repository.PullRequest, nil
|
||||
}
|
||||
|
||||
func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, headBranch string) (*PullRequest, error) {
|
||||
func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, headBranch string, stateFilters []string) (*PullRequest, error) {
|
||||
type response struct {
|
||||
Repository struct {
|
||||
PullRequests struct {
|
||||
ID githubv4.ID
|
||||
Nodes []PullRequest
|
||||
}
|
||||
}
|
||||
|
|
@ -637,9 +632,9 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
|
|||
}
|
||||
|
||||
query := `
|
||||
query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!) {
|
||||
query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!, $states: [PullRequestState!]) {
|
||||
repository(owner: $owner, name: $repo) {
|
||||
pullRequests(headRefName: $headRefName, states: OPEN, first: 30) {
|
||||
pullRequests(headRefName: $headRefName, states: $states, first: 30, orderBy: { field: CREATED_AT, direction: DESC }) {
|
||||
nodes {
|
||||
id
|
||||
number
|
||||
|
|
@ -677,15 +672,6 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
|
|||
}
|
||||
totalCount
|
||||
}
|
||||
reviews(last: 100) {
|
||||
nodes {
|
||||
author {
|
||||
login
|
||||
}
|
||||
state
|
||||
}
|
||||
totalCount
|
||||
}
|
||||
assignees(first: 100) {
|
||||
nodes {
|
||||
login
|
||||
|
|
@ -712,6 +698,8 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
|
|||
milestone{
|
||||
title
|
||||
}
|
||||
` + commentsFragment() + `
|
||||
` + reactionGroupsFragment() + `
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -726,6 +714,7 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
|
|||
"owner": repo.RepoOwner(),
|
||||
"repo": repo.RepoName(),
|
||||
"headRefName": branchWithoutOwner,
|
||||
"states": stateFilters,
|
||||
}
|
||||
|
||||
var resp response
|
||||
|
|
@ -734,18 +723,23 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
|
|||
return nil, err
|
||||
}
|
||||
|
||||
for _, pr := range resp.Repository.PullRequests.Nodes {
|
||||
if pr.HeadLabel() == headBranch {
|
||||
if baseBranch != "" {
|
||||
if pr.BaseRefName != baseBranch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
prs := resp.Repository.PullRequests.Nodes
|
||||
sortPullRequestsByState(prs)
|
||||
|
||||
for _, pr := range prs {
|
||||
if pr.HeadLabel() == headBranch && (baseBranch == "" || pr.BaseRefName == baseBranch) {
|
||||
return &pr, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, &NotFoundError{fmt.Errorf("no open pull requests found for branch %q", headBranch)}
|
||||
return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranch)}
|
||||
}
|
||||
|
||||
// sortPullRequestsByState sorts a PullRequest slice by open-first
|
||||
func sortPullRequestsByState(prs []PullRequest) {
|
||||
sort.SliceStable(prs, func(a, b int) bool {
|
||||
return prs[a].State == "OPEN"
|
||||
})
|
||||
}
|
||||
|
||||
// CreatePullRequest creates a pull request in a GitHub repository
|
||||
|
|
@ -765,7 +759,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
|
|||
}
|
||||
for key, val := range params {
|
||||
switch key {
|
||||
case "title", "body", "draft", "baseRefName", "headRefName":
|
||||
case "title", "body", "draft", "baseRefName", "headRefName", "maintainerCanModify":
|
||||
inputParams[key] = val
|
||||
}
|
||||
}
|
||||
|
|
@ -850,34 +844,6 @@ func isBlank(v interface{}) bool {
|
|||
}
|
||||
}
|
||||
|
||||
func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
|
||||
var mutation struct {
|
||||
AddPullRequestReview struct {
|
||||
ClientMutationID string
|
||||
} `graphql:"addPullRequestReview(input:$input)"`
|
||||
}
|
||||
|
||||
state := githubv4.PullRequestReviewEventComment
|
||||
switch input.State {
|
||||
case ReviewApprove:
|
||||
state = githubv4.PullRequestReviewEventApprove
|
||||
case ReviewRequestChanges:
|
||||
state = githubv4.PullRequestReviewEventRequestChanges
|
||||
}
|
||||
|
||||
body := githubv4.String(input.Body)
|
||||
variables := map[string]interface{}{
|
||||
"input": githubv4.AddPullRequestReviewInput{
|
||||
PullRequestID: pr.ID,
|
||||
Event: &state,
|
||||
Body: &body,
|
||||
},
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables)
|
||||
}
|
||||
|
||||
func PullRequestList(client *Client, repo ghrepo.Interface, vars map[string]interface{}, limit int) (*PullRequestAndTotalCount, error) {
|
||||
type prBlock struct {
|
||||
Edges []struct {
|
||||
|
|
|
|||
135
api/queries_pr_review.go
Normal file
135
api/queries_pr_review.go
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/shurcooL/githubv4"
|
||||
)
|
||||
|
||||
type PullRequestReviewState int
|
||||
|
||||
const (
|
||||
ReviewApprove PullRequestReviewState = iota
|
||||
ReviewRequestChanges
|
||||
ReviewComment
|
||||
)
|
||||
|
||||
type PullRequestReviewInput struct {
|
||||
Body string
|
||||
State PullRequestReviewState
|
||||
}
|
||||
|
||||
type PullRequestReviews struct {
|
||||
Nodes []PullRequestReview
|
||||
PageInfo PageInfo
|
||||
TotalCount int
|
||||
}
|
||||
|
||||
type PullRequestReview struct {
|
||||
Author Author
|
||||
AuthorAssociation string
|
||||
Body string
|
||||
CreatedAt time.Time
|
||||
IncludesCreatedEdit bool
|
||||
ReactionGroups ReactionGroups
|
||||
State string
|
||||
URL string
|
||||
}
|
||||
|
||||
func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
|
||||
var mutation struct {
|
||||
AddPullRequestReview struct {
|
||||
ClientMutationID string
|
||||
} `graphql:"addPullRequestReview(input:$input)"`
|
||||
}
|
||||
|
||||
state := githubv4.PullRequestReviewEventComment
|
||||
switch input.State {
|
||||
case ReviewApprove:
|
||||
state = githubv4.PullRequestReviewEventApprove
|
||||
case ReviewRequestChanges:
|
||||
state = githubv4.PullRequestReviewEventRequestChanges
|
||||
}
|
||||
|
||||
body := githubv4.String(input.Body)
|
||||
variables := map[string]interface{}{
|
||||
"input": githubv4.AddPullRequestReviewInput{
|
||||
PullRequestID: pr.ID,
|
||||
Event: &state,
|
||||
Body: &body,
|
||||
},
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables)
|
||||
}
|
||||
|
||||
func ReviewsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) (*PullRequestReviews, error) {
|
||||
type response struct {
|
||||
Repository struct {
|
||||
PullRequest struct {
|
||||
Reviews PullRequestReviews `graphql:"reviews(first: 100, after: $endCursor)"`
|
||||
} `graphql:"pullRequest(number: $number)"`
|
||||
} `graphql:"repository(owner: $owner, name: $repo)"`
|
||||
}
|
||||
|
||||
variables := map[string]interface{}{
|
||||
"owner": githubv4.String(repo.RepoOwner()),
|
||||
"repo": githubv4.String(repo.RepoName()),
|
||||
"number": githubv4.Int(pr.Number),
|
||||
"endCursor": (*githubv4.String)(nil),
|
||||
}
|
||||
|
||||
gql := graphQLClient(client.http, repo.RepoHost())
|
||||
|
||||
var reviews []PullRequestReview
|
||||
for {
|
||||
var query response
|
||||
err := gql.QueryNamed(context.Background(), "ReviewsForPullRequest", &query, variables)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reviews = append(reviews, query.Repository.PullRequest.Reviews.Nodes...)
|
||||
if !query.Repository.PullRequest.Reviews.PageInfo.HasNextPage {
|
||||
break
|
||||
}
|
||||
variables["endCursor"] = githubv4.String(query.Repository.PullRequest.Reviews.PageInfo.EndCursor)
|
||||
}
|
||||
|
||||
return &PullRequestReviews{Nodes: reviews, TotalCount: len(reviews)}, nil
|
||||
}
|
||||
|
||||
func (prr PullRequestReview) AuthorLogin() string {
|
||||
return prr.Author.Login
|
||||
}
|
||||
|
||||
func (prr PullRequestReview) Association() string {
|
||||
return prr.AuthorAssociation
|
||||
}
|
||||
|
||||
func (prr PullRequestReview) Content() string {
|
||||
return prr.Body
|
||||
}
|
||||
|
||||
func (prr PullRequestReview) Created() time.Time {
|
||||
return prr.CreatedAt
|
||||
}
|
||||
|
||||
func (prr PullRequestReview) IsEdited() bool {
|
||||
return prr.IncludesCreatedEdit
|
||||
}
|
||||
|
||||
func (prr PullRequestReview) Reactions() ReactionGroups {
|
||||
return prr.ReactionGroups
|
||||
}
|
||||
|
||||
func (prr PullRequestReview) Status() string {
|
||||
return prr.State
|
||||
}
|
||||
|
||||
func (prr PullRequestReview) Link() string {
|
||||
return prr.URL
|
||||
}
|
||||
|
|
@ -138,3 +138,32 @@ func Test_determinePullRequestFeatures(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_sortPullRequestsByState(t *testing.T) {
|
||||
prs := []PullRequest{
|
||||
{
|
||||
BaseRefName: "test1",
|
||||
State: "MERGED",
|
||||
},
|
||||
{
|
||||
BaseRefName: "test2",
|
||||
State: "CLOSED",
|
||||
},
|
||||
{
|
||||
BaseRefName: "test3",
|
||||
State: "OPEN",
|
||||
},
|
||||
}
|
||||
|
||||
sortPullRequestsByState(prs)
|
||||
|
||||
if prs[0].BaseRefName != "test3" {
|
||||
t.Errorf("prs[0]: got %s, want %q", prs[0].BaseRefName, "test3")
|
||||
}
|
||||
if prs[1].BaseRefName != "test1" {
|
||||
t.Errorf("prs[1]: got %s, want %q", prs[1].BaseRefName, "test1")
|
||||
}
|
||||
if prs[2].BaseRefName != "test2" {
|
||||
t.Errorf("prs[2]: got %s, want %q", prs[2].BaseRefName, "test2")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ type Repository struct {
|
|||
|
||||
IsPrivate bool
|
||||
HasIssuesEnabled bool
|
||||
HasWikiEnabled bool
|
||||
ViewerPermission string
|
||||
DefaultBranchRef BranchRef
|
||||
|
||||
|
|
@ -94,6 +95,7 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
|
|||
owner { login }
|
||||
hasIssuesEnabled
|
||||
description
|
||||
hasWikiEnabled
|
||||
viewerPermission
|
||||
defaultBranchRef {
|
||||
name
|
||||
|
|
@ -464,6 +466,28 @@ func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) {
|
|||
return "", errors.New("not found")
|
||||
}
|
||||
|
||||
func (m *RepoMetadataResult) Merge(m2 *RepoMetadataResult) {
|
||||
if len(m2.AssignableUsers) > 0 || len(m.AssignableUsers) == 0 {
|
||||
m.AssignableUsers = m2.AssignableUsers
|
||||
}
|
||||
|
||||
if len(m2.Teams) > 0 || len(m.Teams) == 0 {
|
||||
m.Teams = m2.Teams
|
||||
}
|
||||
|
||||
if len(m2.Labels) > 0 || len(m.Labels) == 0 {
|
||||
m.Labels = m2.Labels
|
||||
}
|
||||
|
||||
if len(m2.Projects) > 0 || len(m.Projects) == 0 {
|
||||
m.Projects = m2.Projects
|
||||
}
|
||||
|
||||
if len(m2.Milestones) > 0 || len(m.Milestones) == 0 {
|
||||
m.Milestones = m2.Milestones
|
||||
}
|
||||
}
|
||||
|
||||
type RepoMetadataInput struct {
|
||||
Assignees bool
|
||||
Reviewers bool
|
||||
|
|
|
|||
40
api/reaction_groups.go
Normal file
40
api/reaction_groups.go
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
package api
|
||||
|
||||
type ReactionGroups []ReactionGroup
|
||||
|
||||
type ReactionGroup struct {
|
||||
Content string
|
||||
Users ReactionGroupUsers
|
||||
}
|
||||
|
||||
type ReactionGroupUsers struct {
|
||||
TotalCount int
|
||||
}
|
||||
|
||||
func (rg ReactionGroup) Count() int {
|
||||
return rg.Users.TotalCount
|
||||
}
|
||||
|
||||
func (rg ReactionGroup) Emoji() string {
|
||||
return reactionEmoji[rg.Content]
|
||||
}
|
||||
|
||||
var reactionEmoji = map[string]string{
|
||||
"THUMBS_UP": "\U0001f44d",
|
||||
"THUMBS_DOWN": "\U0001f44e",
|
||||
"LAUGH": "\U0001f604",
|
||||
"HOORAY": "\U0001f389",
|
||||
"CONFUSED": "\U0001f615",
|
||||
"HEART": "\u2764\ufe0f",
|
||||
"ROCKET": "\U0001f680",
|
||||
"EYES": "\U0001f440",
|
||||
}
|
||||
|
||||
func reactionGroupsFragment() string {
|
||||
return `reactionGroups {
|
||||
content
|
||||
users {
|
||||
totalCount
|
||||
}
|
||||
}`
|
||||
}
|
||||
100
api/reaction_groups_test.go
Normal file
100
api/reaction_groups_test.go
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_String(t *testing.T) {
|
||||
tests := map[string]struct {
|
||||
rg ReactionGroup
|
||||
emoji string
|
||||
count int
|
||||
}{
|
||||
"empty reaction group": {
|
||||
rg: ReactionGroup{},
|
||||
emoji: "",
|
||||
count: 0,
|
||||
},
|
||||
"unknown reaction group": {
|
||||
rg: ReactionGroup{
|
||||
Content: "UNKNOWN",
|
||||
Users: ReactionGroupUsers{TotalCount: 1},
|
||||
},
|
||||
emoji: "",
|
||||
count: 1,
|
||||
},
|
||||
"thumbs up reaction group": {
|
||||
rg: ReactionGroup{
|
||||
Content: "THUMBS_UP",
|
||||
Users: ReactionGroupUsers{TotalCount: 2},
|
||||
},
|
||||
emoji: "\U0001f44d",
|
||||
count: 2,
|
||||
},
|
||||
"thumbs down reaction group": {
|
||||
rg: ReactionGroup{
|
||||
Content: "THUMBS_DOWN",
|
||||
Users: ReactionGroupUsers{TotalCount: 3},
|
||||
},
|
||||
emoji: "\U0001f44e",
|
||||
count: 3,
|
||||
},
|
||||
"laugh reaction group": {
|
||||
rg: ReactionGroup{
|
||||
Content: "LAUGH",
|
||||
Users: ReactionGroupUsers{TotalCount: 4},
|
||||
},
|
||||
emoji: "\U0001f604",
|
||||
count: 4,
|
||||
},
|
||||
"hooray reaction group": {
|
||||
rg: ReactionGroup{
|
||||
Content: "HOORAY",
|
||||
Users: ReactionGroupUsers{TotalCount: 5},
|
||||
},
|
||||
emoji: "\U0001f389",
|
||||
count: 5,
|
||||
},
|
||||
"confused reaction group": {
|
||||
rg: ReactionGroup{
|
||||
Content: "CONFUSED",
|
||||
Users: ReactionGroupUsers{TotalCount: 6},
|
||||
},
|
||||
emoji: "\U0001f615",
|
||||
count: 6,
|
||||
},
|
||||
"heart reaction group": {
|
||||
rg: ReactionGroup{
|
||||
Content: "HEART",
|
||||
Users: ReactionGroupUsers{TotalCount: 7},
|
||||
},
|
||||
emoji: "\u2764\ufe0f",
|
||||
count: 7,
|
||||
},
|
||||
"rocket reaction group": {
|
||||
rg: ReactionGroup{
|
||||
Content: "ROCKET",
|
||||
Users: ReactionGroupUsers{TotalCount: 8},
|
||||
},
|
||||
emoji: "\U0001f680",
|
||||
count: 8,
|
||||
},
|
||||
"eyes reaction group": {
|
||||
rg: ReactionGroup{
|
||||
Content: "EYES",
|
||||
Users: ReactionGroupUsers{TotalCount: 9},
|
||||
},
|
||||
emoji: "\U0001f440",
|
||||
count: 9,
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
assert.Equal(t, tt.emoji, tt.rg.Emoji())
|
||||
assert.Equal(t, tt.count, tt.rg.Count())
|
||||
})
|
||||
}
|
||||
}
|
||||
275
auth/oauth.go
275
auth/oauth.go
|
|
@ -1,275 +0,0 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
)
|
||||
|
||||
func randomString(length int) (string, error) {
|
||||
b := make([]byte, length/2)
|
||||
_, err := rand.Read(b)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return hex.EncodeToString(b), nil
|
||||
}
|
||||
|
||||
// OAuthFlow represents the setup for authenticating with GitHub
|
||||
type OAuthFlow struct {
|
||||
Hostname string
|
||||
ClientID string
|
||||
ClientSecret string
|
||||
Scopes []string
|
||||
OpenInBrowser func(string, string) error
|
||||
WriteSuccessHTML func(io.Writer)
|
||||
VerboseStream io.Writer
|
||||
HTTPClient *http.Client
|
||||
TimeNow func() time.Time
|
||||
TimeSleep func(time.Duration)
|
||||
}
|
||||
|
||||
func detectDeviceFlow(statusCode int, values url.Values) (bool, error) {
|
||||
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden ||
|
||||
statusCode == http.StatusNotFound || statusCode == http.StatusUnprocessableEntity ||
|
||||
(statusCode == http.StatusOK && values == nil) ||
|
||||
(statusCode == http.StatusBadRequest && values != nil && values.Get("error") == "unauthorized_client") {
|
||||
return true, nil
|
||||
} else if statusCode != http.StatusOK {
|
||||
if values != nil && values.Get("error_description") != "" {
|
||||
return false, fmt.Errorf("HTTP %d: %s", statusCode, values.Get("error_description"))
|
||||
}
|
||||
return false, fmt.Errorf("error: HTTP %d", statusCode)
|
||||
}
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ObtainAccessToken guides the user through the browser OAuth flow on GitHub
|
||||
// and returns the OAuth access token upon completion.
|
||||
func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
|
||||
// first, check if OAuth Device Flow is supported
|
||||
initURL := fmt.Sprintf("https://%s/login/device/code", oa.Hostname)
|
||||
tokenURL := fmt.Sprintf("https://%s/login/oauth/access_token", oa.Hostname)
|
||||
|
||||
oa.logf("POST %s\n", initURL)
|
||||
resp, err := oa.HTTPClient.PostForm(initURL, url.Values{
|
||||
"client_id": {oa.ClientID},
|
||||
"scope": {strings.Join(oa.Scopes, " ")},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
var values url.Values
|
||||
if strings.Contains(resp.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
|
||||
var bb []byte
|
||||
bb, err = ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
values, err = url.ParseQuery(string(bb))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if doFallback, err := detectDeviceFlow(resp.StatusCode, values); doFallback {
|
||||
// OAuth Device Flow is not available; continue with OAuth browser flow with a
|
||||
// local server endpoint as callback target
|
||||
return oa.localServerFlow()
|
||||
} else if err != nil {
|
||||
return "", fmt.Errorf("%v (%s)", err, initURL)
|
||||
}
|
||||
|
||||
timeNow := oa.TimeNow
|
||||
if timeNow == nil {
|
||||
timeNow = time.Now
|
||||
}
|
||||
timeSleep := oa.TimeSleep
|
||||
if timeSleep == nil {
|
||||
timeSleep = time.Sleep
|
||||
}
|
||||
|
||||
intervalSeconds, err := strconv.Atoi(values.Get("interval"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not parse interval=%q as integer: %w", values.Get("interval"), err)
|
||||
}
|
||||
checkInterval := time.Duration(intervalSeconds) * time.Second
|
||||
|
||||
expiresIn, err := strconv.Atoi(values.Get("expires_in"))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not parse expires_in=%q as integer: %w", values.Get("expires_in"), err)
|
||||
}
|
||||
expiresAt := timeNow().Add(time.Duration(expiresIn) * time.Second)
|
||||
|
||||
err = oa.OpenInBrowser(values.Get("verification_uri"), values.Get("user_code"))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for {
|
||||
timeSleep(checkInterval)
|
||||
accessToken, err = oa.deviceFlowPing(tokenURL, values.Get("device_code"))
|
||||
if accessToken == "" && err == nil {
|
||||
if timeNow().After(expiresAt) {
|
||||
err = errors.New("authentication timed out")
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func (oa *OAuthFlow) deviceFlowPing(tokenURL, deviceCode string) (accessToken string, err error) {
|
||||
oa.logf("POST %s\n", tokenURL)
|
||||
resp, err := oa.HTTPClient.PostForm(tokenURL, url.Values{
|
||||
"client_id": {oa.ClientID},
|
||||
"device_code": {deviceCode},
|
||||
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return "", fmt.Errorf("error: HTTP %d (%s)", resp.StatusCode, tokenURL)
|
||||
}
|
||||
|
||||
bb, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
values, err := url.ParseQuery(string(bb))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if accessToken := values.Get("access_token"); accessToken != "" {
|
||||
return accessToken, nil
|
||||
}
|
||||
|
||||
errorType := values.Get("error")
|
||||
if errorType == "authorization_pending" {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
if errorDescription := values.Get("error_description"); errorDescription != "" {
|
||||
return "", errors.New(errorDescription)
|
||||
}
|
||||
return "", errors.New("OAuth device flow error")
|
||||
}
|
||||
|
||||
func (oa *OAuthFlow) localServerFlow() (accessToken string, err error) {
|
||||
state, _ := randomString(20)
|
||||
|
||||
code := ""
|
||||
listener, err := net.Listen("tcp4", "127.0.0.1:0")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
port := listener.Addr().(*net.TCPAddr).Port
|
||||
|
||||
scopes := "repo"
|
||||
if oa.Scopes != nil {
|
||||
scopes = strings.Join(oa.Scopes, " ")
|
||||
}
|
||||
|
||||
localhost := "127.0.0.1"
|
||||
callbackPath := "/callback"
|
||||
if ghinstance.IsEnterprise(oa.Hostname) {
|
||||
// the OAuth app on Enterprise hosts is still registered with a legacy callback URL
|
||||
// see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650
|
||||
localhost = "localhost"
|
||||
callbackPath = "/"
|
||||
}
|
||||
|
||||
q := url.Values{}
|
||||
q.Set("client_id", oa.ClientID)
|
||||
q.Set("redirect_uri", fmt.Sprintf("http://%s:%d%s", localhost, port, callbackPath))
|
||||
q.Set("scope", scopes)
|
||||
q.Set("state", state)
|
||||
|
||||
startURL := fmt.Sprintf("https://%s/login/oauth/authorize?%s", oa.Hostname, q.Encode())
|
||||
oa.logf("open %s\n", startURL)
|
||||
err = oa.OpenInBrowser(startURL, "")
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
_ = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
oa.logf("server handler: %s\n", r.URL.Path)
|
||||
if r.URL.Path != callbackPath {
|
||||
w.WriteHeader(404)
|
||||
return
|
||||
}
|
||||
defer listener.Close()
|
||||
rq := r.URL.Query()
|
||||
if state != rq.Get("state") {
|
||||
fmt.Fprintf(w, "Error: state mismatch")
|
||||
return
|
||||
}
|
||||
code = rq.Get("code")
|
||||
oa.logf("server received code %q\n", code)
|
||||
w.Header().Add("content-type", "text/html")
|
||||
if oa.WriteSuccessHTML != nil {
|
||||
oa.WriteSuccessHTML(w)
|
||||
} else {
|
||||
fmt.Fprintf(w, "<p>You have successfully authenticated. You may now close this page.</p>")
|
||||
}
|
||||
}))
|
||||
|
||||
tokenURL := fmt.Sprintf("https://%s/login/oauth/access_token", oa.Hostname)
|
||||
oa.logf("POST %s\n", tokenURL)
|
||||
resp, err := oa.HTTPClient.PostForm(tokenURL,
|
||||
url.Values{
|
||||
"client_id": {oa.ClientID},
|
||||
"client_secret": {oa.ClientSecret},
|
||||
"code": {code},
|
||||
"state": {state},
|
||||
})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
err = fmt.Errorf("HTTP %d error while obtaining OAuth access token", resp.StatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
body, err := ioutil.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
tokenValues, err := url.ParseQuery(string(body))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
accessToken = tokenValues.Get("access_token")
|
||||
if accessToken == "" {
|
||||
err = errors.New("the access token could not be read from HTTP response")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
func (oa *OAuthFlow) logf(format string, args ...interface{}) {
|
||||
if oa.VerboseStream == nil {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(oa.VerboseStream, format, args...)
|
||||
}
|
||||
|
|
@ -1,258 +0,0 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type roundTripper func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (rt roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
|
||||
return rt(req)
|
||||
}
|
||||
|
||||
func TestObtainAccessToken_deviceFlow(t *testing.T) {
|
||||
requestCount := 0
|
||||
rt := func(req *http.Request) (*http.Response, error) {
|
||||
route := fmt.Sprintf("%s %s", req.Method, req.URL)
|
||||
switch route {
|
||||
case "POST https://github.com/login/device/code":
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.PostForm.Get("client_id") != "CLIENT-ID" {
|
||||
t.Errorf("expected POST /login/device/code to supply client_id=%q, got %q", "CLIENT-ID", req.PostForm.Get("client_id"))
|
||||
}
|
||||
if req.PostForm.Get("scope") != "repo gist" {
|
||||
t.Errorf("expected POST /login/device/code to supply scope=%q, got %q", "repo gist", req.PostForm.Get("scope"))
|
||||
}
|
||||
|
||||
responseData := url.Values{}
|
||||
responseData.Set("device_code", "DEVICE-CODE")
|
||||
responseData.Set("user_code", "1234-ABCD")
|
||||
responseData.Set("verification_uri", "https://github.com/login/device")
|
||||
responseData.Set("interval", "5")
|
||||
responseData.Set("expires_in", "899")
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(responseData.Encode())),
|
||||
Header: http.Header{
|
||||
"Content-Type": []string{"application/x-www-form-urlencoded; charset=utf-8"},
|
||||
},
|
||||
}, nil
|
||||
case "POST https://github.com/login/oauth/access_token":
|
||||
if err := req.ParseForm(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if req.PostForm.Get("client_id") != "CLIENT-ID" {
|
||||
t.Errorf("expected POST /login/oauth/access_token to supply client_id=%q, got %q", "CLIENT-ID", req.PostForm.Get("client_id"))
|
||||
}
|
||||
if req.PostForm.Get("device_code") != "DEVICE-CODE" {
|
||||
t.Errorf("expected POST /login/oauth/access_token to supply device_code=%q, got %q", "DEVICE-CODE", req.PostForm.Get("scope"))
|
||||
}
|
||||
if req.PostForm.Get("grant_type") != "urn:ietf:params:oauth:grant-type:device_code" {
|
||||
t.Errorf("expected POST /login/oauth/access_token to supply grant_type=%q, got %q", "urn:ietf:params:oauth:grant-type:device_code", req.PostForm.Get("grant_type"))
|
||||
}
|
||||
|
||||
responseData := url.Values{}
|
||||
requestCount++
|
||||
if requestCount == 1 {
|
||||
responseData.Set("error", "authorization_pending")
|
||||
} else {
|
||||
responseData.Set("access_token", "OTOKEN")
|
||||
}
|
||||
|
||||
return &http.Response{
|
||||
StatusCode: 200,
|
||||
Body: ioutil.NopCloser(bytes.NewBufferString(responseData.Encode())),
|
||||
}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unstubbed HTTP request: %v", route)
|
||||
}
|
||||
}
|
||||
httpClient := &http.Client{
|
||||
Transport: roundTripper(rt),
|
||||
}
|
||||
|
||||
slept := time.Duration(0)
|
||||
var browseURL string
|
||||
var browseCode string
|
||||
|
||||
oa := &OAuthFlow{
|
||||
Hostname: "github.com",
|
||||
ClientID: "CLIENT-ID",
|
||||
ClientSecret: "CLIENT-SEKRIT",
|
||||
Scopes: []string{"repo", "gist"},
|
||||
OpenInBrowser: func(url, code string) error {
|
||||
browseURL = url
|
||||
browseCode = code
|
||||
return nil
|
||||
},
|
||||
HTTPClient: httpClient,
|
||||
TimeNow: time.Now,
|
||||
TimeSleep: func(d time.Duration) {
|
||||
slept += d
|
||||
},
|
||||
}
|
||||
|
||||
token, err := oa.ObtainAccessToken()
|
||||
if err != nil {
|
||||
t.Fatalf("ObtainAccessToken error: %v", err)
|
||||
}
|
||||
|
||||
if token != "OTOKEN" {
|
||||
t.Errorf("expected token %q, got %q", "OTOKEN", token)
|
||||
}
|
||||
if requestCount != 2 {
|
||||
t.Errorf("expected 2 HTTP pings for token, got %d", requestCount)
|
||||
}
|
||||
if slept.String() != "10s" {
|
||||
t.Errorf("expected total sleep duration of %s, got %s", "10s", slept.String())
|
||||
}
|
||||
if browseURL != "https://github.com/login/device" {
|
||||
t.Errorf("expected to open browser at %s, got %s", "https://github.com/login/device", browseURL)
|
||||
}
|
||||
if browseCode != "1234-ABCD" {
|
||||
t.Errorf("expected to provide user with one-time code %q, got %q", "1234-ABCD", browseCode)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_detectDeviceFlow(t *testing.T) {
|
||||
type args struct {
|
||||
statusCode int
|
||||
values url.Values
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
doFallback bool
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "success",
|
||||
args: args{
|
||||
statusCode: 200,
|
||||
values: url.Values{},
|
||||
},
|
||||
doFallback: false,
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "wrong response type",
|
||||
args: args{
|
||||
statusCode: 200,
|
||||
values: nil,
|
||||
},
|
||||
doFallback: true,
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "401 unauthorized",
|
||||
args: args{
|
||||
statusCode: 401,
|
||||
values: nil,
|
||||
},
|
||||
doFallback: true,
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "403 forbidden",
|
||||
args: args{
|
||||
statusCode: 403,
|
||||
values: nil,
|
||||
},
|
||||
doFallback: true,
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "404 not found",
|
||||
args: args{
|
||||
statusCode: 404,
|
||||
values: nil,
|
||||
},
|
||||
doFallback: true,
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "422 unprocessable",
|
||||
args: args{
|
||||
statusCode: 422,
|
||||
values: nil,
|
||||
},
|
||||
doFallback: true,
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "402 payment required",
|
||||
args: args{
|
||||
statusCode: 402,
|
||||
values: nil,
|
||||
},
|
||||
doFallback: false,
|
||||
wantErr: "error: HTTP 402",
|
||||
},
|
||||
{
|
||||
name: "400 bad request",
|
||||
args: args{
|
||||
statusCode: 400,
|
||||
values: nil,
|
||||
},
|
||||
doFallback: false,
|
||||
wantErr: "error: HTTP 400",
|
||||
},
|
||||
{
|
||||
name: "400 with values",
|
||||
args: args{
|
||||
statusCode: 400,
|
||||
values: url.Values{
|
||||
"error": []string{"blah"},
|
||||
},
|
||||
},
|
||||
doFallback: false,
|
||||
wantErr: "error: HTTP 400",
|
||||
},
|
||||
{
|
||||
name: "400 with unauthorized_client",
|
||||
args: args{
|
||||
statusCode: 400,
|
||||
values: url.Values{
|
||||
"error": []string{"unauthorized_client"},
|
||||
},
|
||||
},
|
||||
doFallback: true,
|
||||
wantErr: "",
|
||||
},
|
||||
{
|
||||
name: "400 with error_description",
|
||||
args: args{
|
||||
statusCode: 400,
|
||||
values: url.Values{
|
||||
"error_description": []string{"HI"},
|
||||
},
|
||||
},
|
||||
doFallback: false,
|
||||
wantErr: "HTTP 400: HI",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := detectDeviceFlow(tt.args.statusCode, tt.args.values)
|
||||
if (err != nil) != (tt.wantErr != "") {
|
||||
t.Errorf("detectDeviceFlow() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if tt.wantErr != "" && err.Error() != tt.wantErr {
|
||||
t.Errorf("error = %q, wantErr = %q", err, tt.wantErr)
|
||||
}
|
||||
if got != tt.doFallback {
|
||||
t.Errorf("detectDeviceFlow() = %v, want %v", got, tt.doFallback)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -5,15 +5,14 @@ import (
|
|||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/internal/docs"
|
||||
"github.com/cli/cli/pkg/cmd/root"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/spf13/cobra/doc"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
var flagError pflag.ErrorHandling
|
||||
docCmd := pflag.NewFlagSet("", flagError)
|
||||
manPage := docCmd.BoolP("man-page", "", false, "Generate manual pages")
|
||||
|
|
@ -39,6 +38,7 @@ func main() {
|
|||
|
||||
io, _, _, _ := iostreams.Test()
|
||||
rootCmd := root.NewCmdRoot(&cmdutil.Factory{IOStreams: io}, "", "")
|
||||
rootCmd.InitDefaultHelpCmd()
|
||||
|
||||
err := os.MkdirAll(*dir, 0755)
|
||||
if err != nil {
|
||||
|
|
@ -46,20 +46,20 @@ func main() {
|
|||
}
|
||||
|
||||
if *website {
|
||||
err = doc.GenMarkdownTreeCustom(rootCmd, *dir, filePrepender, linkHandler)
|
||||
err = docs.GenMarkdownTreeCustom(rootCmd, *dir, filePrepender, linkHandler)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
if *manPage {
|
||||
header := &doc.GenManHeader{
|
||||
header := &docs.GenManHeader{
|
||||
Title: "gh",
|
||||
Section: "1",
|
||||
Source: "", //source and manual are just put at the top of the manpage, before name
|
||||
Manual: "", //if source is an empty string, it's set to "Auto generated by spf13/cobra"
|
||||
Source: "",
|
||||
Manual: "",
|
||||
}
|
||||
err = doc.GenManTree(rootCmd, header, *dir)
|
||||
err = docs.GenManTree(rootCmd, header, *dir)
|
||||
if err != nil {
|
||||
fatal(err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,12 +16,14 @@ import (
|
|||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"github.com/cli/cli/internal/run"
|
||||
"github.com/cli/cli/internal/update"
|
||||
"github.com/cli/cli/pkg/cmd/alias/expand"
|
||||
"github.com/cli/cli/pkg/cmd/factory"
|
||||
"github.com/cli/cli/pkg/cmd/root"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/update"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/cli/safeexec"
|
||||
"github.com/mattn/go-colorable"
|
||||
"github.com/mgutz/ansi"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
@ -63,6 +65,12 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
// Enable running gh from Windows File Explorer's address bar. Without this, the user is told to stop and run from a
|
||||
// terminal. With this, a user can clone a repo (or take other actions) directly from explorer.
|
||||
if len(os.Args) > 1 && os.Args[1] != "" {
|
||||
cobra.MousetrapHelpText = ""
|
||||
}
|
||||
|
||||
rootCmd := root.NewCmdRoot(cmdFactory, buildVersion, buildDate)
|
||||
|
||||
cfg, err := cmdFactory.Config()
|
||||
|
|
@ -71,7 +79,7 @@ func main() {
|
|||
os.Exit(2)
|
||||
}
|
||||
|
||||
if prompt, _ := cfg.Get("", "prompt"); prompt == config.PromptsDisabled {
|
||||
if prompt, _ := cfg.Get("", "prompt"); prompt == "disabled" {
|
||||
cmdFactory.IOStreams.SetNeverPrompt(true)
|
||||
}
|
||||
|
||||
|
|
@ -100,7 +108,13 @@ func main() {
|
|||
}
|
||||
|
||||
if isShell {
|
||||
externalCmd := exec.Command(expandedArgs[0], expandedArgs[1:]...)
|
||||
exe, err := safeexec.LookPath(expandedArgs[0])
|
||||
if err != nil {
|
||||
fmt.Fprintf(stderr, "failed to run external command: %s", err)
|
||||
os.Exit(3)
|
||||
}
|
||||
|
||||
externalCmd := exec.Command(exe, expandedArgs[1:]...)
|
||||
externalCmd.Stderr = os.Stderr
|
||||
externalCmd.Stdout = os.Stdout
|
||||
externalCmd.Stdin = os.Stdin
|
||||
|
|
@ -120,17 +134,13 @@ func main() {
|
|||
}
|
||||
}
|
||||
|
||||
authCheckEnabled := os.Getenv("GITHUB_TOKEN") == "" &&
|
||||
os.Getenv("GITHUB_ENTERPRISE_TOKEN") == "" &&
|
||||
cmd != nil && cmdutil.IsAuthCheckEnabled(cmd)
|
||||
if authCheckEnabled {
|
||||
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`.")
|
||||
fmt.Fprintln(stderr, "You can also set the GITHUB_TOKEN environment variable, if preferred.")
|
||||
os.Exit(4)
|
||||
}
|
||||
cs := cmdFactory.IOStreams.ColorScheme()
|
||||
|
||||
if cmd != nil && cmdutil.IsAuthCheckEnabled(cmd) && !cmdutil.CheckAuth(cfg) {
|
||||
fmt.Fprintln(stderr, cs.Bold("Welcome to GitHub CLI!"))
|
||||
fmt.Fprintln(stderr)
|
||||
fmt.Fprintln(stderr, "To authenticate, please run `gh auth login`.")
|
||||
os.Exit(4)
|
||||
}
|
||||
|
||||
rootCmd.SetArgs(expandedArgs)
|
||||
|
|
@ -191,6 +201,9 @@ func shouldCheckForUpdate() bool {
|
|||
if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
|
||||
return false
|
||||
}
|
||||
if os.Getenv("CODESPACES") != "" {
|
||||
return false
|
||||
}
|
||||
return updaterEnabled != "" && !isCI() && !isCompletionCommand() && utils.IsTerminal(os.Stderr)
|
||||
}
|
||||
|
||||
|
|
@ -229,7 +242,7 @@ func basicClient(currentVersion string) (*api.Client, error) {
|
|||
}
|
||||
opts = append(opts, api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", currentVersion)))
|
||||
|
||||
token := os.Getenv("GITHUB_TOKEN")
|
||||
token, _ := config.AuthTokenFromEnv(ghinstance.Default())
|
||||
if token == "" {
|
||||
if c, err := config.ParseDefaultConfig(); err == nil {
|
||||
token, _ = c.Get(ghinstance.Default(), "oauth_token")
|
||||
|
|
@ -244,5 +257,5 @@ func basicClient(currentVersion string) (*api.Client, error) {
|
|||
func apiVerboseLog() api.ClientOption {
|
||||
logTraffic := strings.Contains(os.Getenv("DEBUG"), "api")
|
||||
colorize := utils.IsTerminal(os.Stderr)
|
||||
return api.VerboseLog(utils.NewColorable(os.Stderr), logTraffic, colorize)
|
||||
return api.VerboseLog(colorable.NewColorable(os.Stderr), logTraffic, colorize)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,22 +1,14 @@
|
|||
package context
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/git"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func eq(t *testing.T, got interface{}, expected interface{}) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, expected) {
|
||||
t.Errorf("expected: %v, got: %v", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Remotes_FindByName(t *testing.T) {
|
||||
list := Remotes{
|
||||
&Remote{Remote: &git.Remote{Name: "mona"}, Repo: ghrepo.New("monalisa", "myfork")},
|
||||
|
|
@ -25,15 +17,15 @@ func Test_Remotes_FindByName(t *testing.T) {
|
|||
}
|
||||
|
||||
r, err := list.FindByName("upstream", "origin")
|
||||
eq(t, err, nil)
|
||||
eq(t, r.Name, "upstream")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "upstream", r.Name)
|
||||
|
||||
r, err = list.FindByName("nonexist", "*")
|
||||
eq(t, err, nil)
|
||||
eq(t, r.Name, "mona")
|
||||
r, err = list.FindByName("nonexistent", "*")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "mona", r.Name)
|
||||
|
||||
_, err = list.FindByName("nonexist")
|
||||
eq(t, err, errors.New(`no GitHub remotes found`))
|
||||
_, err = list.FindByName("nonexistent")
|
||||
assert.Error(t, err, "no GitHub remotes found")
|
||||
}
|
||||
|
||||
func Test_translateRemotes(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Installing gh on Linux
|
||||
# Installing gh on Linux and FreeBSD
|
||||
|
||||
Packages downloaded from https://cli.github.com or from https://github.com/cli/cli/releases
|
||||
are considered official binaries. We focus on popular Linux distros and
|
||||
|
|
@ -81,9 +81,9 @@ 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
|
||||
## Unofficial, Community-supported methods
|
||||
|
||||
Our team does not directly maintain the following packages or repositories. They are unofficial and we are unable to provide support or guarantees for them.
|
||||
The core GitHub CLI team does not maintain the following packages or repositories. They are unofficial and we are unable to provide support or guarantees for them. They are linked here as a convenience and their presence does not imply continued oversight from the CLI core team. Users who choose to use them do so at their own risk.
|
||||
|
||||
### Arch Linux
|
||||
|
||||
|
|
@ -95,12 +95,41 @@ sudo pacman -S github-cli
|
|||
|
||||
### Android
|
||||
|
||||
Android users can install via Termux:
|
||||
Android 7+ users can install via [Termux](https://wiki.termux.com/wiki/Main_Page):
|
||||
|
||||
```bash
|
||||
pkg install gh
|
||||
```
|
||||
|
||||
### FreeBSD
|
||||
|
||||
FreeBSD users can install from the [ports collection](https://www.freshports.org/devel/gh/):
|
||||
|
||||
```bash
|
||||
cd /usr/ports/devel/gh/ && make install clean
|
||||
```
|
||||
|
||||
Or via [pkg(8)](https://www.freebsd.org/cgi/man.cgi?pkg(8)):
|
||||
|
||||
```bash
|
||||
pkg install gh
|
||||
```
|
||||
|
||||
### Gentoo
|
||||
|
||||
Gentoo Linux users can install from the [main portage tree](https://packages.gentoo.org/packages/dev-util/github-cli):
|
||||
|
||||
``` bash
|
||||
emerge -av github-cli
|
||||
```
|
||||
|
||||
Upgrading can be done by updating the portage tree and then requesting an upgrade:
|
||||
|
||||
``` bash
|
||||
emerge --sync
|
||||
emerge -u github-cli
|
||||
```
|
||||
|
||||
### Kiss Linux
|
||||
|
||||
Kiss Linux users can install from the [community repos](https://github.com/kisslinux/community):
|
||||
|
|
@ -117,6 +146,24 @@ Nix/NixOS users can install from [nixpkgs](https://search.nixos.org/packages?sho
|
|||
nix-env -iA nixos.gitAndTools.gh
|
||||
```
|
||||
|
||||
### openSUSE Tumbleweed
|
||||
|
||||
openSUSE Tumbleweed users can install from the [offical distribution repo](https://software.opensuse.org/package/gh):
|
||||
```bash
|
||||
sudo zypper in gh
|
||||
```
|
||||
|
||||
### Snaps
|
||||
|
||||
Many Linux distro users can install using Snapd from the [Snap Store](https://snapcraft.io/gh) or the associated [repo](https://github.com/casperdcl/cli/tree/snap)
|
||||
|
||||
```bash
|
||||
sudo snap install --edge gh && snap connect gh:ssh-keys
|
||||
```
|
||||
> Snaps are auto-updated every 6 hours. `Snapd` is required and is available on a wide range of Linux distros.
|
||||
> Find out which distros have Snapd pre-installed and how to install it in the [Snapcraft Installation Docs](https://snapcraft.io/docs/installing-snapd)
|
||||
>
|
||||
> **Note:** `snap connect gh:ssh-keys` is needed for all authentication and SSH needs.
|
||||
|
||||
[releases page]: https://github.com/cli/cli/releases/latest
|
||||
[arch linux repo]: https://www.archlinux.org/packages/community/x86_64/github-cli
|
||||
|
|
|
|||
|
|
@ -15,14 +15,13 @@ To test out the build system, publish a prerelease tag with a name such as `vX.Y
|
|||
|
||||
1. `git tag v1.2.3 && git push origin v1.2.3`
|
||||
2. Wait several minutes for builds to run: <https://github.com/cli/cli/actions>
|
||||
3. Check <https://github.com/cli/cli/releases>
|
||||
3. Verify release is displayed and has correct assets: <https://github.com/cli/cli/releases>
|
||||
4. Scan generated release notes and optionally add a human touch by grouping items under topic sections
|
||||
5. Verify the marketing site was updated: <https://cli.github.com>
|
||||
6. (Optional) Delete any pre-releases related to this release.
|
||||
6. (Optional) Delete any pre-releases related to this release
|
||||
|
||||
A successful build will result in changes across several repositories:
|
||||
* <https://github.com/github/cli.github.com>
|
||||
* <https://github.com/github/homebrew-gh>
|
||||
* <https://github.com/Homebrew/homebrew-core/pulls>
|
||||
* <https://github.com/cli/scoop-gh>
|
||||
|
||||
|
|
|
|||
|
|
@ -15,16 +15,25 @@
|
|||
$ cd gh-cli
|
||||
```
|
||||
|
||||
2. Build the project
|
||||
|
||||
```
|
||||
$ make
|
||||
```
|
||||
|
||||
3. Move the resulting `bin/gh` executable to somewhere in your PATH
|
||||
2. Build and install
|
||||
|
||||
#### Unix-like systems
|
||||
```sh
|
||||
$ sudo mv ./bin/gh /usr/local/bin/
|
||||
# installs to '/usr/local' by default; sudo may be required
|
||||
$ make install
|
||||
|
||||
# or, install to a different location
|
||||
$ make install prefix=/path/to/gh
|
||||
```
|
||||
|
||||
4. Run `gh version` to check if it worked.
|
||||
#### Windows
|
||||
```sh
|
||||
# build the `bin\gh.exe` binary
|
||||
> go run script/build.go
|
||||
```
|
||||
There is no install step available on Windows.
|
||||
|
||||
3. Run `gh version` to check if it worked.
|
||||
|
||||
#### Windows
|
||||
Run `bin\gh version` to check if it worked.
|
||||
|
|
|
|||
|
|
@ -2,19 +2,37 @@
|
|||
|
||||
As we get more issues and pull requests opened on the GitHub CLI, we've decided on a weekly rotation
|
||||
triage role. The initial expectation is that the person in the role for the week spends no more than
|
||||
1-2 hours a day on this work; we can refine that as needed. Below is a basic timeline for a typical
|
||||
triage day.
|
||||
2 hours a day on this work; we can refine that as needed.
|
||||
|
||||
1. Note the time
|
||||
2. Open every new [issue](https://github.com/cli/cli/issues?q=is%3Aopen+is%3Aissue)/[pr](https://github.com/cli/cli/pulls?q=is%3Apr+is%3Aopen+draft%3Afalse) in a tab
|
||||
3. Go through each one and look for things that should be closed outright (See the PR and Issue section below for more details.)
|
||||
4. Go through again and look for issues that are worth keeping around, update each one with labels/pings
|
||||
5. Go through again and look for PRs that solve a useful problem but lack obvious things like tests or passing builds; request changes on those
|
||||
6. Mark any remaining PRs (i.e. ones that look worth merging with a cursory glance) as `community` PRs and move to Needs Review
|
||||
7. Look for [issues](https://github.com/cli/cli/issues?q=is%3Aopen+is%3Aissue) and [PRs](https://github.com/cli/cli/pulls?q=is%3Apr+is%3Aopen+draft%3Afalse+sort%3Aupdated-desc) updated in the last day and see if they need a response.
|
||||
8. Check the clock at each step and just bail out when an hour passes
|
||||
## Expectations for incoming issues
|
||||
|
||||
# Incoming issues
|
||||
All incoming issues need either an **enhancement**, **bug**, or **docs** label.
|
||||
|
||||
To be considered triaged, **enhancement** issues require at least one of the following additional labels:
|
||||
|
||||
- **core**: work reserved for the core CLI team
|
||||
- **help wanted**: work that we would accept contributions for
|
||||
- **needs-design**: work that requires input from a UX designer before it can move forward
|
||||
- **needs-investigation**: work that requires a mystery be solved by the core team before it can move forward
|
||||
- **needs-user-input**: work that requires more information from the reporter before it can move forward
|
||||
|
||||
To be considered triaged, **bug** issues require a severity label: one of **p1**, **p2**, or **p3**
|
||||
|
||||
For a more detailed breakdown of **how** to triage an issue, see the _Issue triage flowchart_ below.
|
||||
|
||||
## Expectations for community pull requests
|
||||
|
||||
To be considered triaged, incoming pull requests should:
|
||||
|
||||
- be checked for a corresponding **help wanted** issue
|
||||
- be checked for basic quality: are the builds passing? have tests been added?
|
||||
- be checked for redundancy: is there already a PR dealing with this?
|
||||
|
||||
Once a pull request has been triaged, it should be moved to the **Needs Review** column of the project board.
|
||||
|
||||
For a more detailed breakdown of **how** to triage an issue, see the _PR triage flowchart_ below.
|
||||
|
||||
## Issue triage flowchart
|
||||
|
||||
- can this be closed outright?
|
||||
- e.g. spam/junk
|
||||
|
|
@ -22,14 +40,14 @@ triage day.
|
|||
- do we not want to do it?
|
||||
- e.g. have already discussed not wanting to do or duplicate issue
|
||||
- comment and close
|
||||
- do we want someone in the community to do it?
|
||||
- are we ok with outside contribution for this?
|
||||
- e.g. the task is relatively straightforward, but no people on our team have the bandwidth to take it on at the moment
|
||||
- ensure that the thread contains all the context necessary for someone new to pick this up
|
||||
- add `help wanted` label
|
||||
- consider adding `good first issue` label
|
||||
- do we want to do it?
|
||||
- comment acknowledging it
|
||||
- label appropriately
|
||||
- add `core` label
|
||||
- add to project TODO column if this is something that should ship soon
|
||||
- is it intriguing, but requires discussion?
|
||||
- label `needs-design` if design input is needed, ping
|
||||
|
|
@ -40,9 +58,7 @@ triage day.
|
|||
- is it a usage/support question?
|
||||
- offer some instructions/workaround and close
|
||||
|
||||
# Incoming PRs
|
||||
|
||||
just imagine a flowchart
|
||||
## Pull request triage flowchart
|
||||
|
||||
- can it be closed outright?
|
||||
- e.g. spam/junk
|
||||
|
|
@ -53,10 +69,9 @@ just imagine a flowchart
|
|||
- request an issue
|
||||
- close
|
||||
- is it something we want to include?
|
||||
- add `community` label
|
||||
- add to `needs review` column
|
||||
|
||||
# Weekly PR audit
|
||||
## Weekly PR audit
|
||||
|
||||
In the interest of not letting our open PR list get out of hand (20+ total PRs _or_ multiple PRs
|
||||
over a few months old), try to audit open PRs each week with the goal of getting them merged and/or
|
||||
|
|
@ -69,6 +84,12 @@ For each PR, ask:
|
|||
- is this really close but author is absent? push commits to finish, request review
|
||||
- is this waiting on triage? go through the PR triage flow
|
||||
|
||||
## Useful aliases
|
||||
|
||||
This gist has some useful aliases for first responders:
|
||||
|
||||
https://gist.github.com/vilmibm/ee6ed8a783e4fef5b69b2ed42d743b1a
|
||||
|
||||
## Examples
|
||||
|
||||
We want our project to be a safe and encouraging open-source environment. Below are some examples
|
||||
|
|
|
|||
104
git/git.go
104
git/git.go
|
|
@ -4,14 +4,17 @@ import (
|
|||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/internal/run"
|
||||
"github.com/cli/safeexec"
|
||||
)
|
||||
|
||||
// ErrNotOnAnyBranch indicates that the user is in detached HEAD state
|
||||
|
|
@ -36,7 +39,10 @@ func (r TrackingRef) String() string {
|
|||
// ShowRefs resolves fully-qualified refs to commit hashes
|
||||
func ShowRefs(ref ...string) ([]Ref, error) {
|
||||
args := append([]string{"show-ref", "--verify", "--"}, ref...)
|
||||
showRef := exec.Command("git", args...)
|
||||
showRef, err := GitCommand(args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err := run.PrepareCmd(showRef).Output()
|
||||
|
||||
var refs []Ref
|
||||
|
|
@ -56,7 +62,10 @@ func ShowRefs(ref ...string) ([]Ref, error) {
|
|||
|
||||
// CurrentBranch reads the checked-out branch for the git repository
|
||||
func CurrentBranch() (string, error) {
|
||||
refCmd := GitCommand("symbolic-ref", "--quiet", "HEAD")
|
||||
refCmd, err := GitCommand("symbolic-ref", "--quiet", "HEAD")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
output, err := run.PrepareCmd(refCmd).Output()
|
||||
if err == nil {
|
||||
|
|
@ -77,13 +86,19 @@ func CurrentBranch() (string, error) {
|
|||
}
|
||||
|
||||
func listRemotes() ([]string, error) {
|
||||
remoteCmd := exec.Command("git", "remote", "-v")
|
||||
remoteCmd, err := GitCommand("remote", "-v")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err := run.PrepareCmd(remoteCmd).Output()
|
||||
return outputLines(output), err
|
||||
}
|
||||
|
||||
func Config(name string) (string, error) {
|
||||
configCmd := exec.Command("git", "config", name)
|
||||
configCmd, err := GitCommand("config", name)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := run.PrepareCmd(configCmd).Output()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("unknown config key: %s", name)
|
||||
|
|
@ -93,12 +108,23 @@ func Config(name string) (string, error) {
|
|||
|
||||
}
|
||||
|
||||
var GitCommand = func(args ...string) *exec.Cmd {
|
||||
return exec.Command("git", args...)
|
||||
var GitCommand = func(args ...string) (*exec.Cmd, error) {
|
||||
gitExe, err := safeexec.LookPath("git")
|
||||
if err != nil {
|
||||
programName := "git"
|
||||
if runtime.GOOS == "windows" {
|
||||
programName = "Git for Windows"
|
||||
}
|
||||
return nil, fmt.Errorf("unable to find git executable in PATH; please install %s before retrying", programName)
|
||||
}
|
||||
return exec.Command(gitExe, args...), nil
|
||||
}
|
||||
|
||||
func UncommittedChangeCount() (int, error) {
|
||||
statusCmd := GitCommand("status", "--porcelain")
|
||||
statusCmd, err := GitCommand("status", "--porcelain")
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
output, err := run.PrepareCmd(statusCmd).Output()
|
||||
if err != nil {
|
||||
return 0, err
|
||||
|
|
@ -122,10 +148,13 @@ type Commit struct {
|
|||
}
|
||||
|
||||
func Commits(baseRef, headRef string) ([]*Commit, error) {
|
||||
logCmd := GitCommand(
|
||||
logCmd, err := GitCommand(
|
||||
"-c", "log.ShowSignature=false",
|
||||
"log", "--pretty=format:%H,%s",
|
||||
"--cherry", fmt.Sprintf("%s...%s", baseRef, headRef))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, err := run.PrepareCmd(logCmd).Output()
|
||||
if err != nil {
|
||||
return []*Commit{}, err
|
||||
|
|
@ -153,7 +182,10 @@ func Commits(baseRef, headRef string) ([]*Commit, error) {
|
|||
}
|
||||
|
||||
func CommitBody(sha string) (string, error) {
|
||||
showCmd := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:%b", sha)
|
||||
showCmd, err := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:%b", sha)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := run.PrepareCmd(showCmd).Output()
|
||||
if err != nil {
|
||||
return "", err
|
||||
|
|
@ -162,10 +194,13 @@ func CommitBody(sha string) (string, error) {
|
|||
}
|
||||
|
||||
// Push publishes a git ref to a remote and sets up upstream configuration
|
||||
func Push(remote string, ref string) error {
|
||||
pushCmd := GitCommand("push", "--set-upstream", remote, ref)
|
||||
pushCmd.Stdout = os.Stdout
|
||||
pushCmd.Stderr = os.Stderr
|
||||
func Push(remote string, ref string, cmdOut, cmdErr io.Writer) error {
|
||||
pushCmd, err := GitCommand("push", "--set-upstream", remote, ref)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
pushCmd.Stdout = cmdOut
|
||||
pushCmd.Stderr = cmdErr
|
||||
return run.PrepareCmd(pushCmd).Run()
|
||||
}
|
||||
|
||||
|
|
@ -178,7 +213,10 @@ type BranchConfig struct {
|
|||
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config
|
||||
func ReadBranchConfig(branch string) (cfg BranchConfig) {
|
||||
prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
|
||||
configCmd := GitCommand("config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix))
|
||||
configCmd, err := GitCommand("config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix))
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
output, err := run.PrepareCmd(configCmd).Output()
|
||||
if err != nil {
|
||||
return
|
||||
|
|
@ -208,21 +246,28 @@ func ReadBranchConfig(branch string) (cfg BranchConfig) {
|
|||
}
|
||||
|
||||
func DeleteLocalBranch(branch string) error {
|
||||
branchCmd := GitCommand("branch", "-D", branch)
|
||||
err := run.PrepareCmd(branchCmd).Run()
|
||||
return err
|
||||
branchCmd, err := GitCommand("branch", "-D", branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return run.PrepareCmd(branchCmd).Run()
|
||||
}
|
||||
|
||||
func HasLocalBranch(branch string) bool {
|
||||
configCmd := GitCommand("rev-parse", "--verify", "refs/heads/"+branch)
|
||||
_, err := run.PrepareCmd(configCmd).Output()
|
||||
configCmd, err := GitCommand("rev-parse", "--verify", "refs/heads/"+branch)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
_, err = run.PrepareCmd(configCmd).Output()
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func CheckoutBranch(branch string) error {
|
||||
configCmd := GitCommand("checkout", branch)
|
||||
err := run.PrepareCmd(configCmd).Run()
|
||||
return err
|
||||
configCmd, err := GitCommand("checkout", branch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return run.PrepareCmd(configCmd).Run()
|
||||
}
|
||||
|
||||
func parseCloneArgs(extraArgs []string) (args []string, target string) {
|
||||
|
|
@ -251,7 +296,10 @@ func RunClone(cloneURL string, args []string) (target string, err error) {
|
|||
|
||||
cloneArgs = append([]string{"clone"}, cloneArgs...)
|
||||
|
||||
cloneCmd := GitCommand(cloneArgs...)
|
||||
cloneCmd, err := GitCommand(cloneArgs...)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
cloneCmd.Stdin = os.Stdin
|
||||
cloneCmd.Stdout = os.Stdout
|
||||
cloneCmd.Stderr = os.Stderr
|
||||
|
|
@ -261,7 +309,10 @@ func RunClone(cloneURL string, args []string) (target string, err error) {
|
|||
}
|
||||
|
||||
func AddUpstreamRemote(upstreamURL, cloneDir string) error {
|
||||
cloneCmd := GitCommand("-C", cloneDir, "remote", "add", "-f", "upstream", upstreamURL)
|
||||
cloneCmd, err := GitCommand("-C", cloneDir, "remote", "add", "-f", "upstream", upstreamURL)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
cloneCmd.Stdout = os.Stdout
|
||||
cloneCmd.Stderr = os.Stderr
|
||||
return run.PrepareCmd(cloneCmd).Run()
|
||||
|
|
@ -273,7 +324,10 @@ func isFilesystemPath(p string) bool {
|
|||
|
||||
// ToplevelDir returns the top-level directory path of the current repository
|
||||
func ToplevelDir() (string, error) {
|
||||
showCmd := exec.Command("git", "rev-parse", "--show-toplevel")
|
||||
showCmd, err := GitCommand("rev-parse", "--show-toplevel")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
output, err := run.PrepareCmd(showCmd).Output()
|
||||
return firstLine(output), err
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ package git
|
|||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
|
|
@ -45,7 +44,10 @@ func Remotes() (RemoteSet, error) {
|
|||
remotes := parseRemotes(list)
|
||||
|
||||
// this is affected by SetRemoteResolution
|
||||
remoteCmd := exec.Command("git", "config", "--get-regexp", `^remote\..*\.gh-resolved$`)
|
||||
remoteCmd, err := GitCommand("config", "--get-regexp", `^remote\..*\.gh-resolved$`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
output, _ := run.PrepareCmd(remoteCmd).Output()
|
||||
for _, l := range outputLines(output) {
|
||||
parts := strings.SplitN(l, " ", 2)
|
||||
|
|
@ -107,8 +109,11 @@ func parseRemotes(gitRemotes []string) (remotes RemoteSet) {
|
|||
|
||||
// AddRemote adds a new git remote and auto-fetches objects from it
|
||||
func AddRemote(name, u string) (*Remote, error) {
|
||||
addCmd := exec.Command("git", "remote", "add", "-f", name, u)
|
||||
err := run.PrepareCmd(addCmd).Run()
|
||||
addCmd, err := GitCommand("remote", "add", "-f", name, u)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
err = run.PrepareCmd(addCmd).Run()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -136,6 +141,9 @@ func AddRemote(name, u string) (*Remote, error) {
|
|||
}
|
||||
|
||||
func SetRemoteResolution(name, resolution string) error {
|
||||
addCmd := exec.Command("git", "config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution)
|
||||
addCmd, err := GitCommand("config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return run.PrepareCmd(addCmd).Run()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package git
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_parseRemotes(t *testing.T) {
|
||||
remoteList := []string{
|
||||
|
|
@ -12,20 +16,20 @@ func Test_parseRemotes(t *testing.T) {
|
|||
"zardoz\thttps://example.com/zed.git (push)",
|
||||
}
|
||||
r := parseRemotes(remoteList)
|
||||
eq(t, len(r), 4)
|
||||
assert.Equal(t, 4, len(r))
|
||||
|
||||
eq(t, r[0].Name, "mona")
|
||||
eq(t, r[0].FetchURL.String(), "ssh://git@github.com/monalisa/myfork.git")
|
||||
assert.Equal(t, "mona", r[0].Name)
|
||||
assert.Equal(t, "ssh://git@github.com/monalisa/myfork.git", r[0].FetchURL.String())
|
||||
if r[0].PushURL != nil {
|
||||
t.Errorf("expected no PushURL, got %q", r[0].PushURL)
|
||||
}
|
||||
eq(t, r[1].Name, "origin")
|
||||
eq(t, r[1].FetchURL.Path, "/monalisa/octo-cat.git")
|
||||
eq(t, r[1].PushURL.Path, "/monalisa/octo-cat-push.git")
|
||||
assert.Equal(t, "origin", r[1].Name)
|
||||
assert.Equal(t, "/monalisa/octo-cat.git", r[1].FetchURL.Path)
|
||||
assert.Equal(t, "/monalisa/octo-cat-push.git", r[1].PushURL.Path)
|
||||
|
||||
eq(t, r[2].Name, "upstream")
|
||||
eq(t, r[2].FetchURL.Host, "example.com")
|
||||
eq(t, r[2].PushURL.Host, "github.com")
|
||||
assert.Equal(t, "upstream", r[2].Name)
|
||||
assert.Equal(t, "example.com", r[2].FetchURL.Host)
|
||||
assert.Equal(t, "github.com", r[2].PushURL.Host)
|
||||
|
||||
eq(t, r[3].Name, "zardoz")
|
||||
assert.Equal(t, "zardoz", r[3].Name)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,15 +13,10 @@ import (
|
|||
)
|
||||
|
||||
var (
|
||||
sshHostRE,
|
||||
sshTokenRE *regexp.Regexp
|
||||
sshConfigLineRE = regexp.MustCompile(`\A\s*(?P<keyword>[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P<argument>.+)`)
|
||||
sshTokenRE = regexp.MustCompile(`%[%h]`)
|
||||
)
|
||||
|
||||
func init() {
|
||||
sshHostRE = regexp.MustCompile("(?i)^[ \t]*(host|hostname)[ \t]+(.+)$")
|
||||
sshTokenRE = regexp.MustCompile(`%[%h]`)
|
||||
}
|
||||
|
||||
// SSHAliasMap encapsulates the translation of SSH hostname aliases
|
||||
type SSHAliasMap map[string]string
|
||||
|
||||
|
|
@ -45,6 +40,103 @@ func (m SSHAliasMap) Translator() func(*url.URL) *url.URL {
|
|||
}
|
||||
}
|
||||
|
||||
type sshParser struct {
|
||||
homeDir string
|
||||
|
||||
aliasMap SSHAliasMap
|
||||
hosts []string
|
||||
|
||||
open func(string) (io.Reader, error)
|
||||
glob func(string) ([]string, error)
|
||||
}
|
||||
|
||||
func (p *sshParser) read(fileName string) error {
|
||||
var file io.Reader
|
||||
if p.open == nil {
|
||||
f, err := os.Open(fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
file = f
|
||||
} else {
|
||||
var err error
|
||||
file, err = p.open(fileName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if len(p.hosts) == 0 {
|
||||
p.hosts = []string{"*"}
|
||||
}
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
m := sshConfigLineRE.FindStringSubmatch(scanner.Text())
|
||||
if len(m) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
keyword, arguments := strings.ToLower(m[1]), m[2]
|
||||
switch keyword {
|
||||
case "host":
|
||||
p.hosts = strings.Fields(arguments)
|
||||
case "hostname":
|
||||
for _, host := range p.hosts {
|
||||
for _, name := range strings.Fields(arguments) {
|
||||
if p.aliasMap == nil {
|
||||
p.aliasMap = make(SSHAliasMap)
|
||||
}
|
||||
p.aliasMap[host] = sshExpandTokens(name, host)
|
||||
}
|
||||
}
|
||||
case "include":
|
||||
for _, arg := range strings.Fields(arguments) {
|
||||
path := p.absolutePath(fileName, arg)
|
||||
|
||||
var fileNames []string
|
||||
if p.glob == nil {
|
||||
paths, _ := filepath.Glob(path)
|
||||
for _, p := range paths {
|
||||
if s, err := os.Stat(p); err == nil && !s.IsDir() {
|
||||
fileNames = append(fileNames, p)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
var err error
|
||||
fileNames, err = p.glob(path)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
for _, fileName := range fileNames {
|
||||
_ = p.read(fileName)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
}
|
||||
|
||||
func (p *sshParser) absolutePath(parentFile, path string) string {
|
||||
if filepath.IsAbs(path) || strings.HasPrefix(filepath.ToSlash(path), "/") {
|
||||
return path
|
||||
}
|
||||
|
||||
if strings.HasPrefix(path, "~") {
|
||||
return filepath.Join(p.homeDir, strings.TrimPrefix(path, "~"))
|
||||
}
|
||||
|
||||
if strings.HasPrefix(filepath.ToSlash(parentFile), "/etc/ssh") {
|
||||
return filepath.Join("/etc/ssh", path)
|
||||
}
|
||||
|
||||
return filepath.Join(p.homeDir, ".ssh", path)
|
||||
}
|
||||
|
||||
// ParseSSHConfig constructs a map of SSH hostname aliases based on user and
|
||||
// system configuration files
|
||||
func ParseSSHConfig() SSHAliasMap {
|
||||
|
|
@ -52,54 +144,19 @@ func ParseSSHConfig() SSHAliasMap {
|
|||
"/etc/ssh_config",
|
||||
"/etc/ssh/ssh_config",
|
||||
}
|
||||
|
||||
p := sshParser{}
|
||||
|
||||
if homedir, err := homedir.Dir(); err == nil {
|
||||
userConfig := filepath.Join(homedir, ".ssh", "config")
|
||||
configFiles = append([]string{userConfig}, configFiles...)
|
||||
p.homeDir = homedir
|
||||
}
|
||||
|
||||
openFiles := make([]io.Reader, 0, len(configFiles))
|
||||
for _, file := range configFiles {
|
||||
f, err := os.Open(file)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
defer f.Close()
|
||||
openFiles = append(openFiles, f)
|
||||
_ = p.read(file)
|
||||
}
|
||||
return sshParse(openFiles...)
|
||||
}
|
||||
|
||||
func sshParse(r ...io.Reader) SSHAliasMap {
|
||||
config := make(SSHAliasMap)
|
||||
for _, file := range r {
|
||||
_ = sshParseConfig(config, file)
|
||||
}
|
||||
return config
|
||||
}
|
||||
|
||||
func sshParseConfig(c SSHAliasMap, file io.Reader) error {
|
||||
hosts := []string{"*"}
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
match := sshHostRE.FindStringSubmatch(line)
|
||||
if match == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
names := strings.Fields(match[2])
|
||||
if strings.EqualFold(match[1], "host") {
|
||||
hosts = names
|
||||
} else {
|
||||
for _, host := range hosts {
|
||||
for _, name := range names {
|
||||
c[host] = sshExpandTokens(name, host)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return scanner.Err()
|
||||
return p.aliasMap
|
||||
}
|
||||
|
||||
func sshExpandTokens(text, host string) string {
|
||||
|
|
|
|||
|
|
@ -1,31 +1,127 @@
|
|||
package git
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"reflect"
|
||||
"strings"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
)
|
||||
|
||||
// TODO: extract assertion helpers into a shared package
|
||||
func eq(t *testing.T, got interface{}, expected interface{}) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, expected) {
|
||||
t.Errorf("expected: %v, got: %v", expected, got)
|
||||
func Test_sshParser_read(t *testing.T) {
|
||||
testFiles := map[string]string{
|
||||
"/etc/ssh/config": heredoc.Doc(`
|
||||
Include sites/*
|
||||
`),
|
||||
"/etc/ssh/sites/cfg1": heredoc.Doc(`
|
||||
Host s1
|
||||
Hostname=site1.net
|
||||
`),
|
||||
"/etc/ssh/sites/cfg2": heredoc.Doc(`
|
||||
Host s2
|
||||
Hostname = site2.net
|
||||
`),
|
||||
"HOME/.ssh/config": heredoc.Doc(`
|
||||
Host *
|
||||
Host gh gittyhubby
|
||||
Hostname github.com
|
||||
#Hostname example.com
|
||||
Host ex
|
||||
Include ex_config/*
|
||||
`),
|
||||
"HOME/.ssh/ex_config/ex_cfg": heredoc.Doc(`
|
||||
Hostname example.com
|
||||
`),
|
||||
}
|
||||
globResults := map[string][]string{
|
||||
"/etc/ssh/sites/*": {"/etc/ssh/sites/cfg1", "/etc/ssh/sites/cfg2"},
|
||||
"HOME/.ssh/ex_config/*": {"HOME/.ssh/ex_config/ex_cfg"},
|
||||
}
|
||||
|
||||
p := &sshParser{
|
||||
homeDir: "HOME",
|
||||
open: func(s string) (io.Reader, error) {
|
||||
if contents, ok := testFiles[filepath.ToSlash(s)]; ok {
|
||||
return bytes.NewBufferString(contents), nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("no test file stub found: %q", s)
|
||||
}
|
||||
},
|
||||
glob: func(p string) ([]string, error) {
|
||||
if results, ok := globResults[filepath.ToSlash(p)]; ok {
|
||||
return results, nil
|
||||
} else {
|
||||
return nil, fmt.Errorf("no glob stubs found: %q", p)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
if err := p.read("/etc/ssh/config"); err != nil {
|
||||
t.Fatalf("read(global config) = %v", err)
|
||||
}
|
||||
if err := p.read("HOME/.ssh/config"); err != nil {
|
||||
t.Fatalf("read(user config) = %v", err)
|
||||
}
|
||||
|
||||
if got := p.aliasMap["gh"]; got != "github.com" {
|
||||
t.Errorf("expected alias %q to expand to %q, got %q", "gh", "github.com", got)
|
||||
}
|
||||
if got := p.aliasMap["gittyhubby"]; got != "github.com" {
|
||||
t.Errorf("expected alias %q to expand to %q, got %q", "gittyhubby", "github.com", got)
|
||||
}
|
||||
if got := p.aliasMap["example.com"]; got != "" {
|
||||
t.Errorf("expected alias %q to expand to %q, got %q", "example.com", "", got)
|
||||
}
|
||||
if got := p.aliasMap["ex"]; got != "example.com" {
|
||||
t.Errorf("expected alias %q to expand to %q, got %q", "ex", "example.com", got)
|
||||
}
|
||||
if got := p.aliasMap["s1"]; got != "site1.net" {
|
||||
t.Errorf("expected alias %q to expand to %q, got %q", "s1", "site1.net", got)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_sshParse(t *testing.T) {
|
||||
m := sshParse(strings.NewReader(`
|
||||
Host foo bar
|
||||
HostName example.com
|
||||
`), strings.NewReader(`
|
||||
Host bar baz
|
||||
hostname %%%h.net%%
|
||||
`))
|
||||
eq(t, m["foo"], "example.com")
|
||||
eq(t, m["bar"], "%bar.net%")
|
||||
eq(t, m["nonexist"], "")
|
||||
func Test_sshParser_absolutePath(t *testing.T) {
|
||||
dir := "HOME"
|
||||
p := &sshParser{homeDir: dir}
|
||||
|
||||
tests := map[string]struct {
|
||||
parentFile string
|
||||
arg string
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
"absolute path": {
|
||||
parentFile: "/etc/ssh/ssh_config",
|
||||
arg: "/etc/ssh/config",
|
||||
want: "/etc/ssh/config",
|
||||
},
|
||||
"system relative path": {
|
||||
parentFile: "/etc/ssh/config",
|
||||
arg: "configs/*.conf",
|
||||
want: filepath.Join("/etc", "ssh", "configs", "*.conf"),
|
||||
},
|
||||
"user relative path": {
|
||||
parentFile: filepath.Join(dir, ".ssh", "ssh_config"),
|
||||
arg: "configs/*.conf",
|
||||
want: filepath.Join(dir, ".ssh", "configs/*.conf"),
|
||||
},
|
||||
"shell-like ~ rerefence": {
|
||||
parentFile: filepath.Join(dir, ".ssh", "ssh_config"),
|
||||
arg: "~/.ssh/*.conf",
|
||||
want: filepath.Join(dir, ".ssh", "*.conf"),
|
||||
},
|
||||
}
|
||||
|
||||
for name, tt := range tests {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if got := p.absolutePath(tt.parentFile, tt.arg); got != tt.want {
|
||||
t.Errorf("absolutePath(): %q, wants %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_Translator(t *testing.T) {
|
||||
|
|
|
|||
15
go.mod
15
go.mod
|
|
@ -3,30 +3,33 @@ module github.com/cli/cli
|
|||
go 1.13
|
||||
|
||||
require (
|
||||
github.com/AlecAivazis/survey/v2 v2.1.1
|
||||
github.com/AlecAivazis/survey/v2 v2.2.7
|
||||
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/cli/oauth v0.8.0
|
||||
github.com/cli/safeexec v1.0.0
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0
|
||||
github.com/enescakir/emoji v1.0.0
|
||||
github.com/google/go-cmp v0.5.2
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
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.7
|
||||
github.com/mattn/go-colorable v0.1.8
|
||||
github.com/mattn/go-isatty v0.0.12
|
||||
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/muesli/termenv v0.7.2
|
||||
github.com/muesli/termenv v0.7.4
|
||||
github.com/rivo/uniseg v0.1.0
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b
|
||||
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
|
||||
github.com/spf13/cobra v1.1.1
|
||||
github.com/spf13/pflag v1.0.5
|
||||
github.com/stretchr/testify v1.6.1
|
||||
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
|
||||
golang.org/x/text v0.3.3 // indirect
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
|
||||
golang.org/x/text v0.3.4 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
|
||||
)
|
||||
|
||||
|
|
|
|||
30
go.sum
30
go.sum
|
|
@ -11,8 +11,8 @@ cloud.google.com/go/firestore v1.1.0/go.mod h1:ulACoGHTpvq5r8rxGJ4ddJZBZqakUQqCl
|
|||
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
|
||||
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
|
||||
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
|
||||
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/AlecAivazis/survey/v2 v2.2.7 h1:5NbxkF4RSKmpywYdcRgUmos1o+roJY8duCLZXbVjoig=
|
||||
github.com/AlecAivazis/survey/v2 v2.2.7/go.mod h1:9DYvHgXtiXm6nCn+jXnOXLKbH+Yo9u8fAS/SduGdoPk=
|
||||
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
|
||||
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
|
||||
|
|
@ -44,6 +44,12 @@ github.com/briandowns/spinner v1.11.1/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX
|
|||
github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc=
|
||||
github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684 h1:YMyvXRstOQc7n6eneHfudVMbARSCmZ7EZGjtTkkeB3A=
|
||||
github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684/go.mod h1:UA27Kwj3QHialP74iU6C+Gpc8Y7IOAKupeKMLLBURWM=
|
||||
github.com/cli/browser v1.0.0 h1:RIleZgXrhdiCVgFBSjtWwkLPUCWyhhhN5k5HGSBt1js=
|
||||
github.com/cli/browser v1.0.0/go.mod h1:IEWkHYbLjkhtjwwWlwTHW2lGxeS5gezEQBMLTwDHf5Q=
|
||||
github.com/cli/oauth v0.8.0 h1:YTFgPXSTvvDUFti3tR4o6q7Oll2SnQ9ztLwCAn4/IOA=
|
||||
github.com/cli/oauth v0.8.0/go.mod h1:qd/FX8ZBD6n1sVNQO3aIdRxeu5LGw9WhKnYhIIoC2A4=
|
||||
github.com/cli/safeexec v1.0.0 h1:0VngyaIyqACHdcMNWfo6+KdUYnqEr2Sg+bSP1pdF+dI=
|
||||
github.com/cli/safeexec v1.0.0/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
|
||||
github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e h1:aq/1jlmtZoS6nlSp3yLOTZQ50G+dzHdeRNENgE/iBew=
|
||||
github.com/cli/shurcooL-graphql v0.0.0-20200707151639-0f7232a2bf7e/go.mod h1:it23pLwxmz6OyM6I5O0ATIXQS1S190Nas26L5Kahp4U=
|
||||
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||
|
|
@ -163,8 +169,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-colorable v0.1.8 h1:c1ghPdyEDarC70ftn0y+A/Ee++9zz8ljHG1b13eJ0s8=
|
||||
github.com/mattn/go-colorable v0.1.8/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
|
||||
github.com/mattn/go-isatty v0.0.3/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
|
||||
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
|
||||
|
|
@ -196,8 +202,8 @@ github.com/muesli/reflow v0.1.0 h1:oQdpLfO56lr5pgLvqD0TcjW85rDjSYSBVdiG1Ch1ddM=
|
|||
github.com/muesli/reflow v0.1.0/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I=
|
||||
github.com/muesli/termenv v0.6.0 h1:zxvzTBmo4ZcxhNGGWeMz+Tttm51eF5bmPjfy4MCRYlk=
|
||||
github.com/muesli/termenv v0.6.0/go.mod h1:SohX91w6swWA4AYU+QmPx+aSgXhWO0juiyID9UZmbpA=
|
||||
github.com/muesli/termenv v0.7.2 h1:r1raklL3uKE7rOvWgSenmEm2px+dnc33OTisZ8YR1fw=
|
||||
github.com/muesli/termenv v0.7.2/go.mod h1:ct2L5N2lmix82RaY3bMWwVu/jUFc9Ule0KGDCiKYPh8=
|
||||
github.com/muesli/termenv v0.7.4 h1:/pBqvU5CpkY53tU0vVn+xgs2ZTX63aH5nY+SSps5Xa8=
|
||||
github.com/muesli/termenv v0.7.4/go.mod h1:pZ7qY9l3F7e5xsAOS0zCew2tME+p7bWeBkotCEcIIcc=
|
||||
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
|
||||
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
|
||||
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
|
||||
|
|
@ -231,8 +237,8 @@ github.com/ryanuber/columnize v0.0.0-20160712163229-9b3edd62028f/go.mod h1:sm1tb
|
|||
github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc=
|
||||
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-20200802174311-f27d2ca7f6d5 h1:CA6Mjshr+g5YHENwllpQNR0UaYO7VGKo6TzJLM64WJQ=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b h1:0/ecDXh/HTHRtSDSFnD2/Ta1yQ5J76ZspVY4u0/jGFk=
|
||||
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b/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=
|
||||
|
|
@ -279,8 +285,8 @@ golang.org/x/crypto v0.0.0-20190530122614-20be4c3c3ed5/go.mod h1:yigFU9vqHzYiE8U
|
|||
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/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/crypto v0.0.0-20201016220609-9e8e0b390897 h1:pLI5jrR7OSLijeIDcmRxNmw2api+jEfxLoykJVice/E=
|
||||
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
|
||||
|
|
@ -352,8 +358,8 @@ 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.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||
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/text v0.3.4 h1:0YWbFKbhXG/wIiuHDSKpS0Iy7FSA+u45VtBMfQcFTTc=
|
||||
golang.org/x/text v0.3.4/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
|
||||
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=
|
||||
|
|
|
|||
|
|
@ -9,11 +9,11 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/auth"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"github.com/cli/cli/pkg/browser"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/mattn/go-colorable"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/oauth"
|
||||
)
|
||||
|
||||
var (
|
||||
|
|
@ -23,12 +23,13 @@ var (
|
|||
oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b"
|
||||
)
|
||||
|
||||
func AuthFlowWithConfig(cfg config.Config, hostname, notice string, additionalScopes []string) (string, error) {
|
||||
func AuthFlowWithConfig(cfg config.Config, IO *iostreams.IOStreams, 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()
|
||||
stderr := IO.ErrOut
|
||||
cs := IO.ColorScheme()
|
||||
|
||||
token, userLogin, err := authFlow(hostname, stderr, notice, additionalScopes)
|
||||
token, userLogin, err := authFlow(hostname, IO, notice, additionalScopes)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
@ -48,65 +49,78 @@ func AuthFlowWithConfig(cfg config.Config, hostname, notice string, additionalSc
|
|||
}
|
||||
|
||||
fmt.Fprintf(stderr, "%s Authentication complete. %s to continue...\n",
|
||||
utils.GreenCheck(), utils.Bold("Press Enter"))
|
||||
_ = waitForEnter(os.Stdin)
|
||||
cs.SuccessIcon(), cs.Bold("Press Enter"))
|
||||
_ = waitForEnter(IO.In)
|
||||
|
||||
return token, nil
|
||||
}
|
||||
|
||||
func authFlow(oauthHost string, w io.Writer, notice string, additionalScopes []string) (string, string, error) {
|
||||
var verboseStream io.Writer
|
||||
if strings.Contains(os.Getenv("DEBUG"), "oauth") {
|
||||
verboseStream = w
|
||||
func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string) (string, string, error) {
|
||||
w := IO.ErrOut
|
||||
cs := IO.ColorScheme()
|
||||
|
||||
httpClient := http.DefaultClient
|
||||
if envDebug := os.Getenv("DEBUG"); envDebug != "" {
|
||||
logTraffic := strings.Contains(envDebug, "api") || strings.Contains(envDebug, "oauth")
|
||||
httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport)
|
||||
}
|
||||
|
||||
minimumScopes := []string{"repo", "read:org", "gist"}
|
||||
minimumScopes := []string{"repo", "read:org", "gist", "workflow"}
|
||||
scopes := append(minimumScopes, additionalScopes...)
|
||||
|
||||
flow := &auth.OAuthFlow{
|
||||
callbackURI := "http://127.0.0.1/callback"
|
||||
if ghinstance.IsEnterprise(oauthHost) {
|
||||
// the OAuth app on Enterprise hosts is still registered with a legacy callback URL
|
||||
// see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650
|
||||
callbackURI = "http://localhost/"
|
||||
}
|
||||
|
||||
flow := &oauth.Flow{
|
||||
Hostname: oauthHost,
|
||||
ClientID: oauthClientID,
|
||||
ClientSecret: oauthClientSecret,
|
||||
CallbackURI: callbackURI,
|
||||
Scopes: scopes,
|
||||
WriteSuccessHTML: func(w io.Writer) {
|
||||
fmt.Fprintln(w, oauthSuccessPage)
|
||||
DisplayCode: func(code, verificationURL string) error {
|
||||
fmt.Fprintf(w, "%s First copy your one-time code: %s\n", cs.Yellow("!"), cs.Bold(code))
|
||||
return nil
|
||||
},
|
||||
VerboseStream: verboseStream,
|
||||
HTTPClient: http.DefaultClient,
|
||||
OpenInBrowser: func(url, code string) error {
|
||||
if code != "" {
|
||||
fmt.Fprintf(w, "%s First copy your one-time code: %s\n", utils.Yellow("!"), utils.Bold(code))
|
||||
}
|
||||
fmt.Fprintf(w, "- %s to open %s in your browser... ", utils.Bold("Press Enter"), oauthHost)
|
||||
_ = waitForEnter(os.Stdin)
|
||||
BrowseURL: func(url string) error {
|
||||
fmt.Fprintf(w, "- %s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost)
|
||||
_ = waitForEnter(IO.In)
|
||||
|
||||
browseCmd, err := browser.Command(url)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
err = browseCmd.Run()
|
||||
if err != nil {
|
||||
fmt.Fprintf(w, "%s Failed opening a web browser at %s\n", utils.Red("!"), url)
|
||||
if err := browseCmd.Run(); err != nil {
|
||||
fmt.Fprintf(w, "%s Failed opening a web browser at %s\n", cs.Red("!"), url)
|
||||
fmt.Fprintf(w, " %s\n", err)
|
||||
fmt.Fprint(w, " Please try entering the URL in your browser manually\n")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
WriteSuccessHTML: func(w io.Writer) {
|
||||
fmt.Fprintln(w, oauthSuccessPage)
|
||||
},
|
||||
HTTPClient: httpClient,
|
||||
Stdin: IO.In,
|
||||
Stdout: w,
|
||||
}
|
||||
|
||||
fmt.Fprintln(w, notice)
|
||||
|
||||
token, err := flow.ObtainAccessToken()
|
||||
token, err := flow.DetectFlow()
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
userLogin, err := getViewer(oauthHost, token)
|
||||
userLogin, err := getViewer(oauthHost, token.Token)
|
||||
if err != nil {
|
||||
return "", "", err
|
||||
}
|
||||
|
||||
return token, userLogin, nil
|
||||
return token.Token, userLogin, nil
|
||||
}
|
||||
|
||||
func getViewer(hostname, token string) (string, error) {
|
||||
|
|
|
|||
|
|
@ -3,20 +3,12 @@ package config
|
|||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func eq(t *testing.T, got interface{}, expected interface{}) {
|
||||
t.Helper()
|
||||
if !reflect.DeepEqual(got, expected) {
|
||||
t.Errorf("expected: %v, got: %v", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_parseConfig(t *testing.T) {
|
||||
defer StubConfig(`---
|
||||
hosts:
|
||||
|
|
@ -25,13 +17,13 @@ hosts:
|
|||
oauth_token: OTOKEN
|
||||
`, "")()
|
||||
config, err := ParseConfig("config.yml")
|
||||
eq(t, err, nil)
|
||||
assert.NoError(t, err)
|
||||
user, err := config.Get("github.com", "user")
|
||||
eq(t, err, nil)
|
||||
eq(t, user, "monalisa")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "monalisa", user)
|
||||
token, err := config.Get("github.com", "oauth_token")
|
||||
eq(t, err, nil)
|
||||
eq(t, token, "OTOKEN")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "OTOKEN", token)
|
||||
}
|
||||
|
||||
func Test_parseConfig_multipleHosts(t *testing.T) {
|
||||
|
|
@ -45,13 +37,13 @@ hosts:
|
|||
oauth_token: OTOKEN
|
||||
`, "")()
|
||||
config, err := ParseConfig("config.yml")
|
||||
eq(t, err, nil)
|
||||
assert.NoError(t, err)
|
||||
user, err := config.Get("github.com", "user")
|
||||
eq(t, err, nil)
|
||||
eq(t, user, "monalisa")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "monalisa", user)
|
||||
token, err := config.Get("github.com", "oauth_token")
|
||||
eq(t, err, nil)
|
||||
eq(t, token, "OTOKEN")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "OTOKEN", token)
|
||||
}
|
||||
|
||||
func Test_parseConfig_hostsFile(t *testing.T) {
|
||||
|
|
@ -61,13 +53,13 @@ github.com:
|
|||
oauth_token: OTOKEN
|
||||
`)()
|
||||
config, err := ParseConfig("config.yml")
|
||||
eq(t, err, nil)
|
||||
assert.NoError(t, err)
|
||||
user, err := config.Get("github.com", "user")
|
||||
eq(t, err, nil)
|
||||
eq(t, user, "monalisa")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "monalisa", user)
|
||||
token, err := config.Get("github.com", "oauth_token")
|
||||
eq(t, err, nil)
|
||||
eq(t, token, "OTOKEN")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "OTOKEN", token)
|
||||
}
|
||||
|
||||
func Test_parseConfig_hostFallback(t *testing.T) {
|
||||
|
|
@ -83,16 +75,16 @@ example.com:
|
|||
git_protocol: https
|
||||
`)()
|
||||
config, err := ParseConfig("config.yml")
|
||||
eq(t, err, nil)
|
||||
assert.NoError(t, err)
|
||||
val, err := config.Get("example.com", "git_protocol")
|
||||
eq(t, err, nil)
|
||||
eq(t, val, "https")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https", val)
|
||||
val, err = config.Get("github.com", "git_protocol")
|
||||
eq(t, err, nil)
|
||||
eq(t, val, "ssh")
|
||||
val, err = config.Get("nonexist.io", "git_protocol")
|
||||
eq(t, err, nil)
|
||||
eq(t, val, "ssh")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ssh", val)
|
||||
val, err = config.Get("nonexistent.io", "git_protocol")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "ssh", val)
|
||||
}
|
||||
|
||||
func Test_ParseConfig_migrateConfig(t *testing.T) {
|
||||
|
|
@ -108,7 +100,7 @@ github.com:
|
|||
defer StubBackupConfig()()
|
||||
|
||||
_, err := ParseConfig("config.yml")
|
||||
assert.Nil(t, err)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expectedHosts := `github.com:
|
||||
user: keiyuri
|
||||
|
|
|
|||
|
|
@ -5,20 +5,87 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
defaultGitProtocol = "https"
|
||||
PromptsDisabled = "disabled"
|
||||
PromptsEnabled = "enabled"
|
||||
)
|
||||
type ConfigOption struct {
|
||||
Key string
|
||||
Description string
|
||||
DefaultValue string
|
||||
AllowedValues []string
|
||||
}
|
||||
|
||||
var configValues = map[string][]string{
|
||||
"git_protocol": {"ssh", "https"},
|
||||
"prompt": {"enabled", "disabled"},
|
||||
var configOptions = []ConfigOption{
|
||||
{
|
||||
Key: "git_protocol",
|
||||
Description: "the protocol to use for git clone and push operations",
|
||||
DefaultValue: "https",
|
||||
AllowedValues: []string{"https", "ssh"},
|
||||
},
|
||||
{
|
||||
Key: "editor",
|
||||
Description: "the text editor program to use for authoring text",
|
||||
DefaultValue: "",
|
||||
},
|
||||
{
|
||||
Key: "prompt",
|
||||
Description: "toggle interactive prompting in the terminal",
|
||||
DefaultValue: "enabled",
|
||||
AllowedValues: []string{"enabled", "disabled"},
|
||||
},
|
||||
{
|
||||
Key: "pager",
|
||||
Description: "the terminal pager program to send standard output to",
|
||||
DefaultValue: "",
|
||||
},
|
||||
}
|
||||
|
||||
func ConfigOptions() []ConfigOption {
|
||||
return configOptions
|
||||
}
|
||||
|
||||
func ValidateKey(key string) error {
|
||||
for _, configKey := range configOptions {
|
||||
if key == configKey.Key {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("invalid key")
|
||||
}
|
||||
|
||||
type InvalidValueError struct {
|
||||
ValidValues []string
|
||||
}
|
||||
|
||||
func (e InvalidValueError) Error() string {
|
||||
return "invalid value"
|
||||
}
|
||||
|
||||
func ValidateValue(key, value string) error {
|
||||
var validValues []string
|
||||
|
||||
for _, v := range configOptions {
|
||||
if v.Key == key {
|
||||
validValues = v.AllowedValues
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if validValues == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, v := range validValues {
|
||||
if v == value {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return &InvalidValueError{ValidValues: validValues}
|
||||
}
|
||||
|
||||
// This interface describes interacting with some persistent configuration for gh.
|
||||
|
|
@ -183,7 +250,7 @@ func NewBlankRoot() *yaml.Node {
|
|||
},
|
||||
{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: PromptsEnabled,
|
||||
Value: "enabled",
|
||||
},
|
||||
{
|
||||
HeadComment: "A pager program to send command output to, e.g. \"less\". Set the value to \"cat\" to disable the pager.",
|
||||
|
|
@ -276,33 +343,7 @@ func (c *fileConfig) GetWithSource(hostname, key string) (string, string, error)
|
|||
return value, defaultSource, nil
|
||||
}
|
||||
|
||||
type InvalidValueError struct {
|
||||
ValidValues []string
|
||||
}
|
||||
|
||||
func (e InvalidValueError) Error() string {
|
||||
return "invalid value"
|
||||
}
|
||||
|
||||
func validateConfigEntry(key, value string) error {
|
||||
validValues, found := configValues[key]
|
||||
if !found {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, v := range validValues {
|
||||
if v == value {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return &InvalidValueError{ValidValues: validValues}
|
||||
}
|
||||
|
||||
func (c *fileConfig) Set(hostname, key, value string) error {
|
||||
if err := validateConfigEntry(key, value); err != nil {
|
||||
return err
|
||||
}
|
||||
if hostname == "" {
|
||||
return c.SetStringValue(key, value)
|
||||
} else {
|
||||
|
|
@ -338,7 +379,7 @@ func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
|
|||
}
|
||||
|
||||
for _, hc := range hosts {
|
||||
if hc.Host == hostname {
|
||||
if strings.EqualFold(hc.Host, hostname) {
|
||||
return hc, nil
|
||||
}
|
||||
}
|
||||
|
|
@ -525,13 +566,10 @@ func (c *fileConfig) parseHosts(hostsEntry *yaml.Node) ([]*HostConfig, error) {
|
|||
}
|
||||
|
||||
func defaultFor(key string) string {
|
||||
// we only have a set default for one setting right now
|
||||
switch key {
|
||||
case "git_protocol":
|
||||
return defaultGitProtocol
|
||||
case "prompt":
|
||||
return PromptsEnabled
|
||||
default:
|
||||
return ""
|
||||
for _, co := range configOptions {
|
||||
if co.Key == key {
|
||||
return co.DefaultValue
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ func Test_fileConfig_Set(t *testing.T) {
|
|||
example.com:
|
||||
editor: vim
|
||||
`, hostsBuf.String())
|
||||
assert.EqualError(t, c.Set("github.com", "git_protocol", "sshpps"), "invalid value")
|
||||
}
|
||||
|
||||
func Test_defaultConfig(t *testing.T) {
|
||||
|
|
@ -56,30 +55,47 @@ func Test_defaultConfig(t *testing.T) {
|
|||
assert.Equal(t, "", hostsBuf.String())
|
||||
|
||||
proto, err := cfg.Get("", "git_protocol")
|
||||
assert.Nil(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "https", proto)
|
||||
|
||||
editor, err := cfg.Get("", "editor")
|
||||
assert.Nil(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", editor)
|
||||
|
||||
aliases, err := cfg.Aliases()
|
||||
assert.Nil(t, err)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, len(aliases.All()), 1)
|
||||
expansion, _ := aliases.Get("co")
|
||||
assert.Equal(t, expansion, "pr checkout")
|
||||
}
|
||||
|
||||
func Test_validateConfigEntry(t *testing.T) {
|
||||
err := validateConfigEntry("git_protocol", "sshpps")
|
||||
func Test_ValidateValue(t *testing.T) {
|
||||
err := ValidateValue("git_protocol", "sshpps")
|
||||
assert.EqualError(t, err, "invalid value")
|
||||
|
||||
err = validateConfigEntry("git_protocol", "ssh")
|
||||
assert.Nil(t, err)
|
||||
err = ValidateValue("git_protocol", "ssh")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = validateConfigEntry("editor", "vim")
|
||||
assert.Nil(t, err)
|
||||
err = ValidateValue("editor", "vim")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = validateConfigEntry("got", "123")
|
||||
assert.Nil(t, err)
|
||||
err = ValidateValue("got", "123")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func Test_ValidateKey(t *testing.T) {
|
||||
err := ValidateKey("invalid")
|
||||
assert.EqualError(t, err, "invalid key")
|
||||
|
||||
err = ValidateKey("git_protocol")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = ValidateKey("editor")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = ValidateKey("prompt")
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = ValidateKey("pager")
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,10 +8,20 @@ import (
|
|||
)
|
||||
|
||||
const (
|
||||
GH_TOKEN = "GH_TOKEN"
|
||||
GITHUB_TOKEN = "GITHUB_TOKEN"
|
||||
GH_ENTERPRISE_TOKEN = "GH_ENTERPRISE_TOKEN"
|
||||
GITHUB_ENTERPRISE_TOKEN = "GITHUB_ENTERPRISE_TOKEN"
|
||||
)
|
||||
|
||||
type ReadOnlyEnvError struct {
|
||||
Variable string
|
||||
}
|
||||
|
||||
func (e *ReadOnlyEnvError) Error() string {
|
||||
return fmt.Sprintf("read-only value in %s", e.Variable)
|
||||
}
|
||||
|
||||
func InheritEnv(c Config) Config {
|
||||
return &envConfig{Config: c}
|
||||
}
|
||||
|
|
@ -28,7 +38,8 @@ func (c *envConfig) Hosts() ([]string, error) {
|
|||
hasDefault = true
|
||||
}
|
||||
}
|
||||
if (err != nil || !hasDefault) && os.Getenv(GITHUB_TOKEN) != "" {
|
||||
token, _ := AuthTokenFromEnv(ghinstance.Default())
|
||||
if (err != nil || !hasDefault) && token != "" {
|
||||
hosts = append([]string{ghinstance.Default()}, hosts...)
|
||||
return hosts, nil
|
||||
}
|
||||
|
|
@ -42,13 +53,8 @@ func (c *envConfig) Get(hostname, key string) (string, error) {
|
|||
|
||||
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
|
||||
if token, env := AuthTokenFromEnv(hostname); token != "" {
|
||||
return token, env, nil
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -57,15 +63,33 @@ func (c *envConfig) GetWithSource(hostname, key string) (string, string, error)
|
|||
|
||||
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)
|
||||
if token, env := AuthTokenFromEnv(hostname); token != "" {
|
||||
return &ReadOnlyEnvError{Variable: env}
|
||||
}
|
||||
}
|
||||
|
||||
return c.Config.CheckWriteable(hostname, key)
|
||||
}
|
||||
|
||||
func AuthTokenFromEnv(hostname string) (string, string) {
|
||||
if ghinstance.IsEnterprise(hostname) {
|
||||
if token := os.Getenv(GH_ENTERPRISE_TOKEN); token != "" {
|
||||
return token, GH_ENTERPRISE_TOKEN
|
||||
}
|
||||
|
||||
return os.Getenv(GITHUB_ENTERPRISE_TOKEN), GITHUB_ENTERPRISE_TOKEN
|
||||
}
|
||||
|
||||
if token := os.Getenv(GH_TOKEN); token != "" {
|
||||
return token, GH_TOKEN
|
||||
}
|
||||
|
||||
return os.Getenv(GITHUB_TOKEN), GITHUB_TOKEN
|
||||
}
|
||||
|
||||
func AuthTokenProvidedFromEnv() bool {
|
||||
return os.Getenv(GH_ENTERPRISE_TOKEN) != "" ||
|
||||
os.Getenv(GITHUB_ENTERPRISE_TOKEN) != "" ||
|
||||
os.Getenv(GH_TOKEN) != "" ||
|
||||
os.Getenv(GITHUB_TOKEN) != ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,9 +11,13 @@ import (
|
|||
func TestInheritEnv(t *testing.T) {
|
||||
orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN")
|
||||
orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN")
|
||||
orig_GH_TOKEN := os.Getenv("GH_TOKEN")
|
||||
orig_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN")
|
||||
t.Cleanup(func() {
|
||||
os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN)
|
||||
os.Setenv("GITHUB_ENTERPRISE_TOKEN", orig_GITHUB_ENTERPRISE_TOKEN)
|
||||
os.Setenv("GH_TOKEN", orig_GH_TOKEN)
|
||||
os.Setenv("GH_ENTERPRISE_TOKEN", orig_GH_ENTERPRISE_TOKEN)
|
||||
})
|
||||
|
||||
type wants struct {
|
||||
|
|
@ -28,15 +32,15 @@ func TestInheritEnv(t *testing.T) {
|
|||
baseConfig string
|
||||
GITHUB_TOKEN string
|
||||
GITHUB_ENTERPRISE_TOKEN string
|
||||
GH_TOKEN string
|
||||
GH_ENTERPRISE_TOKEN string
|
||||
hostname string
|
||||
wants wants
|
||||
}{
|
||||
{
|
||||
name: "blank",
|
||||
baseConfig: ``,
|
||||
GITHUB_TOKEN: "",
|
||||
GITHUB_ENTERPRISE_TOKEN: "",
|
||||
hostname: "github.com",
|
||||
name: "blank",
|
||||
baseConfig: ``,
|
||||
hostname: "github.com",
|
||||
wants: wants{
|
||||
hosts: []string(nil),
|
||||
token: "",
|
||||
|
|
@ -45,11 +49,10 @@ func TestInheritEnv(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "GITHUB_TOKEN over blank config",
|
||||
baseConfig: ``,
|
||||
GITHUB_TOKEN: "OTOKEN",
|
||||
GITHUB_ENTERPRISE_TOKEN: "",
|
||||
hostname: "github.com",
|
||||
name: "GITHUB_TOKEN over blank config",
|
||||
baseConfig: ``,
|
||||
GITHUB_TOKEN: "OTOKEN",
|
||||
hostname: "github.com",
|
||||
wants: wants{
|
||||
hosts: []string{"github.com"},
|
||||
token: "OTOKEN",
|
||||
|
|
@ -58,11 +61,34 @@ func TestInheritEnv(t *testing.T) {
|
|||
},
|
||||
},
|
||||
{
|
||||
name: "GITHUB_TOKEN not applicable to GHE",
|
||||
baseConfig: ``,
|
||||
GITHUB_TOKEN: "OTOKEN",
|
||||
GITHUB_ENTERPRISE_TOKEN: "",
|
||||
hostname: "example.org",
|
||||
name: "GH_TOKEN over blank config",
|
||||
baseConfig: ``,
|
||||
GH_TOKEN: "OTOKEN",
|
||||
hostname: "github.com",
|
||||
wants: wants{
|
||||
hosts: []string{"github.com"},
|
||||
token: "OTOKEN",
|
||||
source: "GH_TOKEN",
|
||||
writeable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GITHUB_TOKEN not applicable to GHE",
|
||||
baseConfig: ``,
|
||||
GITHUB_TOKEN: "OTOKEN",
|
||||
hostname: "example.org",
|
||||
wants: wants{
|
||||
hosts: []string{"github.com"},
|
||||
token: "",
|
||||
source: "~/.config/gh/config.yml",
|
||||
writeable: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GH_TOKEN not applicable to GHE",
|
||||
baseConfig: ``,
|
||||
GH_TOKEN: "OTOKEN",
|
||||
hostname: "example.org",
|
||||
wants: wants{
|
||||
hosts: []string{"github.com"},
|
||||
token: "",
|
||||
|
|
@ -73,7 +99,6 @@ func TestInheritEnv(t *testing.T) {
|
|||
{
|
||||
name: "GITHUB_ENTERPRISE_TOKEN over blank config",
|
||||
baseConfig: ``,
|
||||
GITHUB_TOKEN: "",
|
||||
GITHUB_ENTERPRISE_TOKEN: "ENTOKEN",
|
||||
hostname: "example.org",
|
||||
wants: wants{
|
||||
|
|
@ -83,6 +108,18 @@ func TestInheritEnv(t *testing.T) {
|
|||
writeable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GH_ENTERPRISE_TOKEN over blank config",
|
||||
baseConfig: ``,
|
||||
GH_ENTERPRISE_TOKEN: "ENTOKEN",
|
||||
hostname: "example.org",
|
||||
wants: wants{
|
||||
hosts: []string(nil),
|
||||
token: "ENTOKEN",
|
||||
source: "GH_ENTERPRISE_TOKEN",
|
||||
writeable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "token from file",
|
||||
baseConfig: heredoc.Doc(`
|
||||
|
|
@ -90,9 +127,7 @@ func TestInheritEnv(t *testing.T) {
|
|||
github.com:
|
||||
oauth_token: OTOKEN
|
||||
`),
|
||||
GITHUB_TOKEN: "",
|
||||
GITHUB_ENTERPRISE_TOKEN: "",
|
||||
hostname: "github.com",
|
||||
hostname: "github.com",
|
||||
wants: wants{
|
||||
hosts: []string{"github.com"},
|
||||
token: "OTOKEN",
|
||||
|
|
@ -107,9 +142,8 @@ func TestInheritEnv(t *testing.T) {
|
|||
github.com:
|
||||
oauth_token: OTOKEN
|
||||
`),
|
||||
GITHUB_TOKEN: "ENVTOKEN",
|
||||
GITHUB_ENTERPRISE_TOKEN: "",
|
||||
hostname: "github.com",
|
||||
GITHUB_TOKEN: "ENVTOKEN",
|
||||
hostname: "github.com",
|
||||
wants: wants{
|
||||
hosts: []string{"github.com"},
|
||||
token: "ENVTOKEN",
|
||||
|
|
@ -117,6 +151,80 @@ func TestInheritEnv(t *testing.T) {
|
|||
writeable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GH_TOKEN shadows token from file",
|
||||
baseConfig: heredoc.Doc(`
|
||||
hosts:
|
||||
github.com:
|
||||
oauth_token: OTOKEN
|
||||
`),
|
||||
GH_TOKEN: "ENVTOKEN",
|
||||
hostname: "github.com",
|
||||
wants: wants{
|
||||
hosts: []string{"github.com"},
|
||||
token: "ENVTOKEN",
|
||||
source: "GH_TOKEN",
|
||||
writeable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GITHUB_ENTERPRISE_TOKEN shadows token from file",
|
||||
baseConfig: heredoc.Doc(`
|
||||
hosts:
|
||||
example.org:
|
||||
oauth_token: OTOKEN
|
||||
`),
|
||||
GITHUB_ENTERPRISE_TOKEN: "ENVTOKEN",
|
||||
hostname: "example.org",
|
||||
wants: wants{
|
||||
hosts: []string{"example.org"},
|
||||
token: "ENVTOKEN",
|
||||
source: "GITHUB_ENTERPRISE_TOKEN",
|
||||
writeable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GH_ENTERPRISE_TOKEN shadows token from file",
|
||||
baseConfig: heredoc.Doc(`
|
||||
hosts:
|
||||
example.org:
|
||||
oauth_token: OTOKEN
|
||||
`),
|
||||
GH_ENTERPRISE_TOKEN: "ENVTOKEN",
|
||||
hostname: "example.org",
|
||||
wants: wants{
|
||||
hosts: []string{"example.org"},
|
||||
token: "ENVTOKEN",
|
||||
source: "GH_ENTERPRISE_TOKEN",
|
||||
writeable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GH_TOKEN shadows token from GITHUB_TOKEN",
|
||||
baseConfig: ``,
|
||||
GH_TOKEN: "GHTOKEN",
|
||||
GITHUB_TOKEN: "GITHUBTOKEN",
|
||||
hostname: "github.com",
|
||||
wants: wants{
|
||||
hosts: []string{"github.com"},
|
||||
token: "GHTOKEN",
|
||||
source: "GH_TOKEN",
|
||||
writeable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GH_ENTERPRISE_TOKEN shadows token from GITHUB_ENTERPRISE_TOKEN",
|
||||
baseConfig: ``,
|
||||
GH_ENTERPRISE_TOKEN: "GHTOKEN",
|
||||
GITHUB_ENTERPRISE_TOKEN: "GITHUBTOKEN",
|
||||
hostname: "example.org",
|
||||
wants: wants{
|
||||
hosts: []string(nil),
|
||||
token: "GHTOKEN",
|
||||
source: "GH_ENTERPRISE_TOKEN",
|
||||
writeable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GITHUB_TOKEN adds host entry",
|
||||
baseConfig: heredoc.Doc(`
|
||||
|
|
@ -124,9 +232,8 @@ func TestInheritEnv(t *testing.T) {
|
|||
example.org:
|
||||
oauth_token: OTOKEN
|
||||
`),
|
||||
GITHUB_TOKEN: "ENVTOKEN",
|
||||
GITHUB_ENTERPRISE_TOKEN: "",
|
||||
hostname: "github.com",
|
||||
GITHUB_TOKEN: "ENVTOKEN",
|
||||
hostname: "github.com",
|
||||
wants: wants{
|
||||
hosts: []string{"github.com", "example.org"},
|
||||
token: "ENVTOKEN",
|
||||
|
|
@ -134,11 +241,29 @@ func TestInheritEnv(t *testing.T) {
|
|||
writeable: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "GH_TOKEN adds host entry",
|
||||
baseConfig: heredoc.Doc(`
|
||||
hosts:
|
||||
example.org:
|
||||
oauth_token: OTOKEN
|
||||
`),
|
||||
GH_TOKEN: "ENVTOKEN",
|
||||
hostname: "github.com",
|
||||
wants: wants{
|
||||
hosts: []string{"github.com", "example.org"},
|
||||
token: "ENVTOKEN",
|
||||
source: "GH_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)
|
||||
os.Setenv("GH_TOKEN", tt.GH_TOKEN)
|
||||
os.Setenv("GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN)
|
||||
|
||||
baseCfg := NewFromString(tt.baseConfig)
|
||||
cfg := InheritEnv(baseCfg)
|
||||
|
|
@ -154,7 +279,66 @@ func TestInheritEnv(t *testing.T) {
|
|||
assert.Equal(t, tt.wants.token, val)
|
||||
|
||||
err := cfg.CheckWriteable(tt.hostname, "oauth_token")
|
||||
assert.Equal(t, tt.wants.writeable, err == nil)
|
||||
if tt.wants.writeable != (err == nil) {
|
||||
t.Errorf("CheckWriteable() = %v, wants %v", err, tt.wants.writeable)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthTokenProvidedFromEnv(t *testing.T) {
|
||||
orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN")
|
||||
orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN")
|
||||
orig_GH_TOKEN := os.Getenv("GH_TOKEN")
|
||||
orig_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN")
|
||||
t.Cleanup(func() {
|
||||
os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN)
|
||||
os.Setenv("GITHUB_ENTERPRISE_TOKEN", orig_GITHUB_ENTERPRISE_TOKEN)
|
||||
os.Setenv("GH_TOKEN", orig_GH_TOKEN)
|
||||
os.Setenv("GH_ENTERPRISE_TOKEN", orig_GH_ENTERPRISE_TOKEN)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
GITHUB_TOKEN string
|
||||
GITHUB_ENTERPRISE_TOKEN string
|
||||
GH_TOKEN string
|
||||
GH_ENTERPRISE_TOKEN string
|
||||
provided bool
|
||||
}{
|
||||
{
|
||||
name: "no env tokens",
|
||||
provided: false,
|
||||
},
|
||||
{
|
||||
name: "GH_TOKEN",
|
||||
GH_TOKEN: "TOKEN",
|
||||
provided: true,
|
||||
},
|
||||
{
|
||||
name: "GITHUB_TOKEN",
|
||||
GITHUB_TOKEN: "TOKEN",
|
||||
provided: true,
|
||||
},
|
||||
{
|
||||
name: "GH_ENTERPRISE_TOKEN",
|
||||
GH_ENTERPRISE_TOKEN: "TOKEN",
|
||||
provided: true,
|
||||
},
|
||||
{
|
||||
name: "GITHUB_ENTERPRISE_TOKEN",
|
||||
GITHUB_ENTERPRISE_TOKEN: "TOKEN",
|
||||
provided: true,
|
||||
},
|
||||
}
|
||||
|
||||
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)
|
||||
os.Setenv("GH_TOKEN", tt.GH_TOKEN)
|
||||
os.Setenv("GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN)
|
||||
assert.Equal(t, tt.provided, AuthTokenProvidedFromEnv())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
51
internal/config/stub.go
Normal file
51
internal/config/stub.go
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
)
|
||||
|
||||
type ConfigStub map[string]string
|
||||
|
||||
func genKey(host, key string) string {
|
||||
if host != "" {
|
||||
return host + ":" + key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
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, "(memory)", nil
|
||||
}
|
||||
return "", "", errors.New("not found")
|
||||
}
|
||||
|
||||
func (c ConfigStub) Set(host, key, value string) error {
|
||||
c[genKey(host, key)] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c ConfigStub) Aliases() (*AliasConfig, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c ConfigStub) Hosts() ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
91
internal/docs/docs_test.go
Normal file
91
internal/docs/docs_test.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package docs
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func emptyRun(*cobra.Command, []string) {}
|
||||
|
||||
func init() {
|
||||
rootCmd.PersistentFlags().StringP("rootflag", "r", "two", "")
|
||||
rootCmd.PersistentFlags().StringP("strtwo", "t", "two", "help message for parent flag strtwo")
|
||||
|
||||
echoCmd.PersistentFlags().StringP("strone", "s", "one", "help message for flag strone")
|
||||
echoCmd.PersistentFlags().BoolP("persistentbool", "p", false, "help message for flag persistentbool")
|
||||
echoCmd.Flags().IntP("intone", "i", 123, "help message for flag intone")
|
||||
echoCmd.Flags().BoolP("boolone", "b", true, "help message for flag boolone")
|
||||
|
||||
timesCmd.PersistentFlags().StringP("strtwo", "t", "2", "help message for child flag strtwo")
|
||||
timesCmd.Flags().IntP("inttwo", "j", 234, "help message for flag inttwo")
|
||||
timesCmd.Flags().BoolP("booltwo", "c", false, "help message for flag booltwo")
|
||||
|
||||
printCmd.PersistentFlags().StringP("strthree", "s", "three", "help message for flag strthree")
|
||||
printCmd.Flags().IntP("intthree", "i", 345, "help message for flag intthree")
|
||||
printCmd.Flags().BoolP("boolthree", "b", true, "help message for flag boolthree")
|
||||
|
||||
echoCmd.AddCommand(timesCmd, echoSubCmd, deprecatedCmd)
|
||||
rootCmd.AddCommand(printCmd, echoCmd, dummyCmd)
|
||||
}
|
||||
|
||||
var rootCmd = &cobra.Command{
|
||||
Use: "root",
|
||||
Short: "Root short description",
|
||||
Long: "Root long description",
|
||||
Run: emptyRun,
|
||||
}
|
||||
|
||||
var echoCmd = &cobra.Command{
|
||||
Use: "echo [string to echo]",
|
||||
Aliases: []string{"say"},
|
||||
Short: "Echo anything to the screen",
|
||||
Long: "an utterly useless command for testing",
|
||||
Example: "Just run cobra-test echo",
|
||||
}
|
||||
|
||||
var echoSubCmd = &cobra.Command{
|
||||
Use: "echosub [string to print]",
|
||||
Short: "second sub command for echo",
|
||||
Long: "an absolutely utterly useless command for testing gendocs!.",
|
||||
Run: emptyRun,
|
||||
}
|
||||
|
||||
var timesCmd = &cobra.Command{
|
||||
Use: "times [# times] [string to echo]",
|
||||
SuggestFor: []string{"counts"},
|
||||
Short: "Echo anything to the screen more times",
|
||||
Long: `a slightly useless command for testing.`,
|
||||
Run: emptyRun,
|
||||
}
|
||||
|
||||
var deprecatedCmd = &cobra.Command{
|
||||
Use: "deprecated [can't do anything here]",
|
||||
Short: "A command which is deprecated",
|
||||
Long: `an absolutely utterly useless command for testing deprecation!.`,
|
||||
Deprecated: "Please use echo instead",
|
||||
}
|
||||
|
||||
var printCmd = &cobra.Command{
|
||||
Use: "print [string to print]",
|
||||
Short: "Print anything to the screen",
|
||||
Long: `an absolutely utterly useless command for testing.`,
|
||||
}
|
||||
|
||||
var dummyCmd = &cobra.Command{
|
||||
Use: "dummy [action]",
|
||||
Short: "Performs a dummy action",
|
||||
}
|
||||
|
||||
func checkStringContains(t *testing.T, got, expected string) {
|
||||
if !strings.Contains(got, expected) {
|
||||
t.Errorf("Expected to contain: \n %v\nGot:\n %v\n", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func checkStringOmits(t *testing.T, got, expected string) {
|
||||
if strings.Contains(got, expected) {
|
||||
t.Errorf("Expected to not contain: \n %v\nGot: %v", expected, got)
|
||||
}
|
||||
}
|
||||
242
internal/docs/man.go
Normal file
242
internal/docs/man.go
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cpuguy83/go-md2man/v2/md2man"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
// GenManTree will generate a man page for this command and all descendants
|
||||
// in the directory given. The header may be nil. This function may not work
|
||||
// correctly if your command names have `-` in them. If you have `cmd` with two
|
||||
// subcmds, `sub` and `sub-third`, and `sub` has a subcommand called `third`
|
||||
// it is undefined which help output will be in the file `cmd-sub-third.1`.
|
||||
func GenManTree(cmd *cobra.Command, header *GenManHeader, dir string) error {
|
||||
return GenManTreeFromOpts(cmd, GenManTreeOptions{
|
||||
Header: header,
|
||||
Path: dir,
|
||||
CommandSeparator: "-",
|
||||
})
|
||||
}
|
||||
|
||||
// GenManTreeFromOpts generates a man page for the command and all descendants.
|
||||
// The pages are written to the opts.Path directory.
|
||||
func GenManTreeFromOpts(cmd *cobra.Command, opts GenManTreeOptions) error {
|
||||
header := opts.Header
|
||||
if header == nil {
|
||||
header = &GenManHeader{}
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
|
||||
continue
|
||||
}
|
||||
if err := GenManTreeFromOpts(c, opts); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
section := "1"
|
||||
if header.Section != "" {
|
||||
section = header.Section
|
||||
}
|
||||
|
||||
separator := "_"
|
||||
if opts.CommandSeparator != "" {
|
||||
separator = opts.CommandSeparator
|
||||
}
|
||||
basename := strings.Replace(cmd.CommandPath(), " ", separator, -1)
|
||||
filename := filepath.Join(opts.Path, basename+"."+section)
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
headerCopy := *header
|
||||
return GenMan(cmd, &headerCopy, f)
|
||||
}
|
||||
|
||||
// GenManTreeOptions is the options for generating the man pages.
|
||||
// Used only in GenManTreeFromOpts.
|
||||
type GenManTreeOptions struct {
|
||||
Header *GenManHeader
|
||||
Path string
|
||||
CommandSeparator string
|
||||
}
|
||||
|
||||
// GenManHeader is a lot like the .TH header at the start of man pages. These
|
||||
// include the title, section, date, source, and manual. We will use the
|
||||
// current time if Date is unset.
|
||||
type GenManHeader struct {
|
||||
Title string
|
||||
Section string
|
||||
Date *time.Time
|
||||
date string
|
||||
Source string
|
||||
Manual string
|
||||
}
|
||||
|
||||
// GenMan will generate a man page for the given command and write it to
|
||||
// w. The header argument may be nil, however obviously w may not.
|
||||
func GenMan(cmd *cobra.Command, header *GenManHeader, w io.Writer) error {
|
||||
if header == nil {
|
||||
header = &GenManHeader{}
|
||||
}
|
||||
if err := fillHeader(header, cmd.CommandPath()); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
b := genMan(cmd, header)
|
||||
_, err := w.Write(md2man.Render(b))
|
||||
return err
|
||||
}
|
||||
|
||||
func fillHeader(header *GenManHeader, name string) error {
|
||||
if header.Title == "" {
|
||||
header.Title = strings.ToUpper(strings.Replace(name, " ", "\\-", -1))
|
||||
}
|
||||
if header.Section == "" {
|
||||
header.Section = "1"
|
||||
}
|
||||
if header.Date == nil {
|
||||
now := time.Now()
|
||||
if epoch := os.Getenv("SOURCE_DATE_EPOCH"); epoch != "" {
|
||||
unixEpoch, err := strconv.ParseInt(epoch, 10, 64)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid SOURCE_DATE_EPOCH: %v", err)
|
||||
}
|
||||
now = time.Unix(unixEpoch, 0)
|
||||
}
|
||||
header.Date = &now
|
||||
}
|
||||
header.date = (*header.Date).Format("Jan 2006")
|
||||
return nil
|
||||
}
|
||||
|
||||
func manPreamble(buf *bytes.Buffer, header *GenManHeader, cmd *cobra.Command, dashedName string) {
|
||||
description := cmd.Long
|
||||
if len(description) == 0 {
|
||||
description = cmd.Short
|
||||
}
|
||||
|
||||
buf.WriteString(fmt.Sprintf(`%% "%s" "%s" "%s" "%s" "%s"
|
||||
# NAME
|
||||
`, header.Title, header.Section, header.date, header.Source, header.Manual))
|
||||
buf.WriteString(fmt.Sprintf("%s \\- %s\n\n", dashedName, cmd.Short))
|
||||
buf.WriteString("# SYNOPSIS\n")
|
||||
buf.WriteString(fmt.Sprintf("**%s**\n\n", cmd.UseLine()))
|
||||
buf.WriteString("# DESCRIPTION\n")
|
||||
buf.WriteString(description + "\n\n")
|
||||
}
|
||||
|
||||
func manPrintFlags(buf *bytes.Buffer, flags *pflag.FlagSet) {
|
||||
flags.VisitAll(func(flag *pflag.Flag) {
|
||||
if len(flag.Deprecated) > 0 || flag.Hidden {
|
||||
return
|
||||
}
|
||||
format := ""
|
||||
if len(flag.Shorthand) > 0 && len(flag.ShorthandDeprecated) == 0 {
|
||||
format = fmt.Sprintf("**-%s**, **--%s**", flag.Shorthand, flag.Name)
|
||||
} else {
|
||||
format = fmt.Sprintf("**--%s**", flag.Name)
|
||||
}
|
||||
if len(flag.NoOptDefVal) > 0 {
|
||||
format += "["
|
||||
}
|
||||
if flag.Value.Type() == "string" {
|
||||
// put quotes on the value
|
||||
format += "=%q"
|
||||
} else {
|
||||
format += "=%s"
|
||||
}
|
||||
if len(flag.NoOptDefVal) > 0 {
|
||||
format += "]"
|
||||
}
|
||||
format += "\n\t%s\n\n"
|
||||
buf.WriteString(fmt.Sprintf(format, flag.DefValue, flag.Usage))
|
||||
})
|
||||
}
|
||||
|
||||
func manPrintOptions(buf *bytes.Buffer, command *cobra.Command) {
|
||||
flags := command.NonInheritedFlags()
|
||||
if flags.HasAvailableFlags() {
|
||||
buf.WriteString("# OPTIONS\n")
|
||||
manPrintFlags(buf, flags)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
flags = command.InheritedFlags()
|
||||
if flags.HasAvailableFlags() {
|
||||
buf.WriteString("# OPTIONS INHERITED FROM PARENT COMMANDS\n")
|
||||
manPrintFlags(buf, flags)
|
||||
buf.WriteString("\n")
|
||||
}
|
||||
}
|
||||
|
||||
func genMan(cmd *cobra.Command, header *GenManHeader) []byte {
|
||||
cmd.InitDefaultHelpCmd()
|
||||
cmd.InitDefaultHelpFlag()
|
||||
|
||||
// something like `rootcmd-subcmd1-subcmd2`
|
||||
dashCommandName := strings.Replace(cmd.CommandPath(), " ", "-", -1)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
|
||||
manPreamble(buf, header, cmd, dashCommandName)
|
||||
manPrintOptions(buf, cmd)
|
||||
if len(cmd.Example) > 0 {
|
||||
buf.WriteString("# EXAMPLE\n")
|
||||
buf.WriteString(fmt.Sprintf("```\n%s\n```\n", cmd.Example))
|
||||
}
|
||||
if hasSeeAlso(cmd) {
|
||||
buf.WriteString("# SEE ALSO\n")
|
||||
seealsos := make([]string, 0)
|
||||
if cmd.HasParent() {
|
||||
parentPath := cmd.Parent().CommandPath()
|
||||
dashParentPath := strings.Replace(parentPath, " ", "-", -1)
|
||||
seealso := fmt.Sprintf("**%s(%s)**", dashParentPath, header.Section)
|
||||
seealsos = append(seealsos, seealso)
|
||||
}
|
||||
children := cmd.Commands()
|
||||
sort.Sort(byName(children))
|
||||
for _, c := range children {
|
||||
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
|
||||
continue
|
||||
}
|
||||
seealso := fmt.Sprintf("**%s-%s(%s)**", dashCommandName, c.Name(), header.Section)
|
||||
seealsos = append(seealsos, seealso)
|
||||
}
|
||||
buf.WriteString(strings.Join(seealsos, ", ") + "\n")
|
||||
}
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// Test to see if we have a reason to print See Also information in docs
|
||||
// Basically this is a test for a parent command or a subcommand which is
|
||||
// both not deprecated and not the autogenerated help command.
|
||||
func hasSeeAlso(cmd *cobra.Command) bool {
|
||||
if cmd.HasParent() {
|
||||
return true
|
||||
}
|
||||
for _, c := range cmd.Commands() {
|
||||
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
|
||||
continue
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
type byName []*cobra.Command
|
||||
|
||||
func (s byName) Len() int { return len(s) }
|
||||
func (s byName) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
|
||||
func (s byName) Less(i, j int) bool { return s[i].Name() < s[j].Name() }
|
||||
191
internal/docs/man_test.go
Normal file
191
internal/docs/man_test.go
Normal file
|
|
@ -0,0 +1,191 @@
|
|||
package docs
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func translate(in string) string {
|
||||
return strings.Replace(in, "-", "\\-", -1)
|
||||
}
|
||||
|
||||
func TestGenManDoc(t *testing.T) {
|
||||
header := &GenManHeader{
|
||||
Title: "Project",
|
||||
Section: "2",
|
||||
}
|
||||
|
||||
// We generate on a subcommand so we have both subcommands and parents
|
||||
buf := new(bytes.Buffer)
|
||||
if err := GenMan(echoCmd, header, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
// Make sure parent has - in CommandPath() in SEE ALSO:
|
||||
parentPath := echoCmd.Parent().CommandPath()
|
||||
dashParentPath := strings.Replace(parentPath, " ", "-", -1)
|
||||
expected := translate(dashParentPath)
|
||||
expected = expected + "(" + header.Section + ")"
|
||||
checkStringContains(t, output, expected)
|
||||
|
||||
checkStringContains(t, output, translate(echoCmd.Name()))
|
||||
checkStringContains(t, output, translate(echoCmd.Name()))
|
||||
checkStringContains(t, output, "boolone")
|
||||
checkStringContains(t, output, "rootflag")
|
||||
checkStringContains(t, output, translate(rootCmd.Name()))
|
||||
checkStringContains(t, output, translate(echoSubCmd.Name()))
|
||||
checkStringOmits(t, output, translate(deprecatedCmd.Name()))
|
||||
}
|
||||
|
||||
func TestGenManNoHiddenParents(t *testing.T) {
|
||||
header := &GenManHeader{
|
||||
Title: "Project",
|
||||
Section: "2",
|
||||
}
|
||||
|
||||
// We generate on a subcommand so we have both subcommands and parents
|
||||
for _, name := range []string{"rootflag", "strtwo"} {
|
||||
f := rootCmd.PersistentFlags().Lookup(name)
|
||||
f.Hidden = true
|
||||
defer func() { f.Hidden = false }()
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
if err := GenMan(echoCmd, header, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
// Make sure parent has - in CommandPath() in SEE ALSO:
|
||||
parentPath := echoCmd.Parent().CommandPath()
|
||||
dashParentPath := strings.Replace(parentPath, " ", "-", -1)
|
||||
expected := translate(dashParentPath)
|
||||
expected = expected + "(" + header.Section + ")"
|
||||
checkStringContains(t, output, expected)
|
||||
|
||||
checkStringContains(t, output, translate(echoCmd.Name()))
|
||||
checkStringContains(t, output, translate(echoCmd.Name()))
|
||||
checkStringContains(t, output, "boolone")
|
||||
checkStringOmits(t, output, "rootflag")
|
||||
checkStringContains(t, output, translate(rootCmd.Name()))
|
||||
checkStringContains(t, output, translate(echoSubCmd.Name()))
|
||||
checkStringOmits(t, output, translate(deprecatedCmd.Name()))
|
||||
checkStringOmits(t, output, "OPTIONS INHERITED FROM PARENT COMMANDS")
|
||||
}
|
||||
|
||||
func TestGenManSeeAlso(t *testing.T) {
|
||||
rootCmd := &cobra.Command{Use: "root", Run: emptyRun}
|
||||
aCmd := &cobra.Command{Use: "aaa", Run: emptyRun, Hidden: true} // #229
|
||||
bCmd := &cobra.Command{Use: "bbb", Run: emptyRun}
|
||||
cCmd := &cobra.Command{Use: "ccc", Run: emptyRun}
|
||||
rootCmd.AddCommand(aCmd, bCmd, cCmd)
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
header := &GenManHeader{}
|
||||
if err := GenMan(rootCmd, header, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scanner := bufio.NewScanner(buf)
|
||||
|
||||
if err := assertLineFound(scanner, ".SH SEE ALSO"); err != nil {
|
||||
t.Fatalf("Couldn't find SEE ALSO section header: %v", err)
|
||||
}
|
||||
if err := assertNextLineEquals(scanner, ".PP"); err != nil {
|
||||
t.Fatalf("First line after SEE ALSO wasn't break-indent: %v", err)
|
||||
}
|
||||
if err := assertNextLineEquals(scanner, `\fBroot\-bbb(1)\fP, \fBroot\-ccc(1)\fP`); err != nil {
|
||||
t.Fatalf("Second line after SEE ALSO wasn't correct: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManPrintFlagsHidesShortDeprecated(t *testing.T) {
|
||||
c := &cobra.Command{}
|
||||
c.Flags().StringP("foo", "f", "default", "Foo flag")
|
||||
_ = c.Flags().MarkShorthandDeprecated("foo", "don't use it no more")
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
manPrintFlags(buf, c.Flags())
|
||||
|
||||
got := buf.String()
|
||||
expected := "**--foo**=\"default\"\n\tFoo flag\n\n"
|
||||
if got != expected {
|
||||
t.Errorf("Expected %v, got %v", expected, got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenManTree(t *testing.T) {
|
||||
c := &cobra.Command{Use: "do [OPTIONS] arg1 arg2"}
|
||||
header := &GenManHeader{Section: "2"}
|
||||
tmpdir, err := ioutil.TempDir("", "test-gen-man-tree")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create tmpdir: %s", err.Error())
|
||||
}
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
if err := GenManTree(c, header, tmpdir); err != nil {
|
||||
t.Fatalf("GenManTree failed: %s", err.Error())
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(tmpdir, "do.2")); err != nil {
|
||||
t.Fatalf("Expected file 'do.2' to exist")
|
||||
}
|
||||
|
||||
if header.Title != "" {
|
||||
t.Fatalf("Expected header.Title to be unmodified")
|
||||
}
|
||||
}
|
||||
|
||||
func assertLineFound(scanner *bufio.Scanner, expectedLine string) error {
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == expectedLine {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("scan failed: %s", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("hit EOF before finding %v", expectedLine)
|
||||
}
|
||||
|
||||
func assertNextLineEquals(scanner *bufio.Scanner, expectedLine string) error {
|
||||
if scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == expectedLine {
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("got %v, not %v", line, expectedLine)
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return fmt.Errorf("scan failed: %v", err)
|
||||
}
|
||||
|
||||
return fmt.Errorf("hit EOF before finding %v", expectedLine)
|
||||
}
|
||||
|
||||
func BenchmarkGenManToFile(b *testing.B) {
|
||||
file, err := ioutil.TempFile("", "")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer os.Remove(file.Name())
|
||||
defer file.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := GenMan(rootCmd, nil, file); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
114
internal/docs/markdown.go
Normal file
114
internal/docs/markdown.go
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func printOptions(buf *bytes.Buffer, cmd *cobra.Command, name string) error {
|
||||
flags := cmd.NonInheritedFlags()
|
||||
flags.SetOutput(buf)
|
||||
if flags.HasAvailableFlags() {
|
||||
buf.WriteString("### Options\n\n```\n")
|
||||
flags.PrintDefaults()
|
||||
buf.WriteString("```\n\n")
|
||||
}
|
||||
|
||||
parentFlags := cmd.InheritedFlags()
|
||||
parentFlags.SetOutput(buf)
|
||||
if parentFlags.HasAvailableFlags() {
|
||||
buf.WriteString("### Options inherited from parent commands\n\n```\n")
|
||||
parentFlags.PrintDefaults()
|
||||
buf.WriteString("```\n\n")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// GenMarkdown creates markdown output.
|
||||
func GenMarkdown(cmd *cobra.Command, w io.Writer) error {
|
||||
return GenMarkdownCustom(cmd, w, func(s string) string { return s })
|
||||
}
|
||||
|
||||
// GenMarkdownCustom creates custom markdown output.
|
||||
func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) string) error {
|
||||
cmd.InitDefaultHelpCmd()
|
||||
cmd.InitDefaultHelpFlag()
|
||||
|
||||
buf := new(bytes.Buffer)
|
||||
name := cmd.CommandPath()
|
||||
|
||||
buf.WriteString("## " + name + "\n\n")
|
||||
buf.WriteString(cmd.Short + "\n\n")
|
||||
if len(cmd.Long) > 0 {
|
||||
buf.WriteString("### Synopsis\n\n")
|
||||
buf.WriteString(cmd.Long + "\n\n")
|
||||
}
|
||||
|
||||
if cmd.Runnable() {
|
||||
buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.UseLine()))
|
||||
}
|
||||
|
||||
if len(cmd.Example) > 0 {
|
||||
buf.WriteString("### Examples\n\n")
|
||||
buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.Example))
|
||||
}
|
||||
|
||||
if err := printOptions(buf, cmd, name); err != nil {
|
||||
return err
|
||||
}
|
||||
_, err := buf.WriteTo(w)
|
||||
return err
|
||||
}
|
||||
|
||||
// GenMarkdownTree will generate a markdown page for this command and all
|
||||
// descendants in the directory given. The header may be nil.
|
||||
// This function may not work correctly if your command names have `-` in them.
|
||||
// If you have `cmd` with two subcmds, `sub` and `sub-third`,
|
||||
// and `sub` has a subcommand called `third`, it is undefined which
|
||||
// help output will be in the file `cmd-sub-third.1`.
|
||||
func GenMarkdownTree(cmd *cobra.Command, dir string) error {
|
||||
identity := func(s string) string { return s }
|
||||
emptyStr := func(s string) string { return "" }
|
||||
return GenMarkdownTreeCustom(cmd, dir, emptyStr, identity)
|
||||
}
|
||||
|
||||
// GenMarkdownTreeCustom is the the same as GenMarkdownTree, but
|
||||
// with custom filePrepender and linkHandler.
|
||||
func GenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender, linkHandler func(string) string) error {
|
||||
for _, c := range cmd.Commands() {
|
||||
_, forceGeneration := c.Annotations["markdown:generate"]
|
||||
if c.Hidden && !forceGeneration {
|
||||
continue
|
||||
}
|
||||
|
||||
if err := GenMarkdownTreeCustom(c, dir, filePrepender, linkHandler); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
basename := strings.Replace(cmd.CommandPath(), " ", "_", -1) + ".md"
|
||||
if basenameOverride, found := cmd.Annotations["markdown:basename"]; found {
|
||||
basename = basenameOverride + ".md"
|
||||
}
|
||||
|
||||
filename := filepath.Join(dir, basename)
|
||||
f, err := os.Create(filename)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
if _, err := io.WriteString(f, filePrepender(filename)); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := GenMarkdownCustom(cmd, f, linkHandler); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
99
internal/docs/markdown_test.go
Normal file
99
internal/docs/markdown_test.go
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
package docs
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestGenMdDoc(t *testing.T) {
|
||||
// We generate on subcommand so we have both subcommands and parents.
|
||||
buf := new(bytes.Buffer)
|
||||
if err := GenMarkdown(echoCmd, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
checkStringContains(t, output, echoCmd.Long)
|
||||
checkStringContains(t, output, echoCmd.Example)
|
||||
checkStringContains(t, output, "boolone")
|
||||
checkStringContains(t, output, "rootflag")
|
||||
checkStringOmits(t, output, rootCmd.Short)
|
||||
checkStringOmits(t, output, echoSubCmd.Short)
|
||||
checkStringOmits(t, output, deprecatedCmd.Short)
|
||||
checkStringContains(t, output, "Options inherited from parent commands")
|
||||
}
|
||||
|
||||
func TestGenMdDocWithNoLongOrSynopsis(t *testing.T) {
|
||||
// We generate on subcommand so we have both subcommands and parents.
|
||||
buf := new(bytes.Buffer)
|
||||
if err := GenMarkdown(dummyCmd, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
checkStringContains(t, output, dummyCmd.Example)
|
||||
checkStringContains(t, output, dummyCmd.Short)
|
||||
checkStringContains(t, output, "Options inherited from parent commands")
|
||||
checkStringOmits(t, output, "### Synopsis")
|
||||
}
|
||||
|
||||
func TestGenMdNoHiddenParents(t *testing.T) {
|
||||
// We generate on subcommand so we have both subcommands and parents.
|
||||
for _, name := range []string{"rootflag", "strtwo"} {
|
||||
f := rootCmd.PersistentFlags().Lookup(name)
|
||||
f.Hidden = true
|
||||
defer func() { f.Hidden = false }()
|
||||
}
|
||||
buf := new(bytes.Buffer)
|
||||
if err := GenMarkdown(echoCmd, buf); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := buf.String()
|
||||
|
||||
checkStringContains(t, output, echoCmd.Long)
|
||||
checkStringContains(t, output, echoCmd.Example)
|
||||
checkStringContains(t, output, "boolone")
|
||||
checkStringOmits(t, output, "rootflag")
|
||||
checkStringOmits(t, output, rootCmd.Short)
|
||||
checkStringOmits(t, output, echoSubCmd.Short)
|
||||
checkStringOmits(t, output, deprecatedCmd.Short)
|
||||
checkStringOmits(t, output, "Options inherited from parent commands")
|
||||
}
|
||||
|
||||
func TestGenMdTree(t *testing.T) {
|
||||
c := &cobra.Command{Use: "do [OPTIONS] arg1 arg2"}
|
||||
tmpdir, err := ioutil.TempDir("", "test-gen-md-tree")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create tmpdir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpdir)
|
||||
|
||||
if err := GenMarkdownTree(c, tmpdir); err != nil {
|
||||
t.Fatalf("GenMarkdownTree failed: %v", err)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(tmpdir, "do.md")); err != nil {
|
||||
t.Fatalf("Expected file 'do.md' to exist")
|
||||
}
|
||||
}
|
||||
|
||||
func BenchmarkGenMarkdownToFile(b *testing.B) {
|
||||
file, err := ioutil.TempFile("", "")
|
||||
if err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
defer os.Remove(file.Name())
|
||||
defer file.Close()
|
||||
|
||||
b.ResetTimer()
|
||||
for i := 0; i < b.N; i++ {
|
||||
if err := GenMarkdown(rootCmd, file); err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -139,7 +139,7 @@ func TestHostnameValidator(t *testing.T) {
|
|||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.Equal(t, nil, err)
|
||||
assert.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ package run
|
|||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
|
|
@ -23,7 +25,13 @@ var PrepareCmd = func(cmd *exec.Cmd) Runnable {
|
|||
// SetPrepareCmd overrides PrepareCmd and returns a func to revert it back
|
||||
func SetPrepareCmd(fn func(*exec.Cmd) Runnable) func() {
|
||||
origPrepare := PrepareCmd
|
||||
PrepareCmd = fn
|
||||
PrepareCmd = func(cmd *exec.Cmd) Runnable {
|
||||
// normalize git executable name for consistency in tests
|
||||
if baseName := filepath.Base(cmd.Args[0]); baseName == "git" || baseName == "git.exe" {
|
||||
cmd.Args[0] = "git"
|
||||
}
|
||||
return fn(cmd)
|
||||
}
|
||||
return func() {
|
||||
PrepareCmd = origPrepare
|
||||
}
|
||||
|
|
@ -36,7 +44,7 @@ type cmdWithStderr struct {
|
|||
|
||||
func (c cmdWithStderr) Output() ([]byte, error) {
|
||||
if os.Getenv("DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", c.Cmd.Args)
|
||||
_ = printArgs(os.Stderr, c.Cmd.Args)
|
||||
}
|
||||
if c.Cmd.Stderr != nil {
|
||||
return c.Cmd.Output()
|
||||
|
|
@ -52,7 +60,7 @@ func (c cmdWithStderr) Output() ([]byte, error) {
|
|||
|
||||
func (c cmdWithStderr) Run() error {
|
||||
if os.Getenv("DEBUG") != "" {
|
||||
fmt.Fprintf(os.Stderr, "%v\n", c.Cmd.Args)
|
||||
_ = printArgs(os.Stderr, c.Cmd.Args)
|
||||
}
|
||||
if c.Cmd.Stderr != nil {
|
||||
return c.Cmd.Run()
|
||||
|
|
@ -80,3 +88,12 @@ func (e CmdError) Error() string {
|
|||
}
|
||||
return fmt.Sprintf("%s%s: %s", msg, e.Args[0], e.Err)
|
||||
}
|
||||
|
||||
func printArgs(w io.Writer, args []string) error {
|
||||
if len(args) > 0 {
|
||||
// print commands, but omit the full path to an executable
|
||||
args = append([]string{filepath.Base(args[0])}, args[1:]...)
|
||||
}
|
||||
_, err := fmt.Fprintf(w, "%v\n", args)
|
||||
return err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ func Stub() (*CommandStubber, func(T)) {
|
|||
return
|
||||
}
|
||||
t.Helper()
|
||||
t.Errorf("umatched stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", "))
|
||||
t.Errorf("unmatched stubs (%d): %s", len(unmatched), strings.Join(unmatched, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package update
|
|||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
|
|
@ -11,6 +14,8 @@ import (
|
|||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
var gitDescribeSuffixRE = regexp.MustCompile(`\d+-\d+-g[a-f0-9]{8}$`)
|
||||
|
||||
// ReleaseInfo stores information about a release
|
||||
type ReleaseInfo struct {
|
||||
Version string `json:"tag_name"`
|
||||
|
|
@ -24,31 +29,31 @@ type StateEntry struct {
|
|||
|
||||
// CheckForUpdate checks whether this software has had a newer release on GitHub
|
||||
func CheckForUpdate(client *api.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) {
|
||||
latestRelease, err := getLatestReleaseInfo(client, stateFilePath, repo, currentVersion)
|
||||
stateEntry, _ := getStateEntry(stateFilePath)
|
||||
if stateEntry != nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
releaseInfo, err := getLatestReleaseInfo(client, repo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if versionGreaterThan(latestRelease.Version, currentVersion) {
|
||||
return latestRelease, nil
|
||||
err = setStateEntry(stateFilePath, time.Now(), *releaseInfo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if versionGreaterThan(releaseInfo.Version, currentVersion) {
|
||||
return releaseInfo, nil
|
||||
}
|
||||
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func getLatestReleaseInfo(client *api.Client, stateFilePath, repo, currentVersion string) (*ReleaseInfo, error) {
|
||||
stateEntry, err := getStateEntry(stateFilePath)
|
||||
if err == nil && time.Since(stateEntry.CheckedForUpdateAt).Hours() < 24 {
|
||||
return &stateEntry.LatestRelease, nil
|
||||
}
|
||||
|
||||
func getLatestReleaseInfo(client *api.Client, repo string) (*ReleaseInfo, error) {
|
||||
var latestRelease ReleaseInfo
|
||||
err = client.REST(ghinstance.Default(), "GET", fmt.Sprintf("repos/%s/releases/latest", repo), nil, &latestRelease)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
err = setStateEntry(stateFilePath, time.Now(), latestRelease)
|
||||
err := client.REST(ghinstance.Default(), "GET", fmt.Sprintf("repos/%s/releases/latest", repo), nil, &latestRelease)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
@ -83,6 +88,12 @@ func setStateEntry(stateFilePath string, t time.Time, r ReleaseInfo) error {
|
|||
}
|
||||
|
||||
func versionGreaterThan(v, w string) bool {
|
||||
w = gitDescribeSuffixRE.ReplaceAllStringFunc(w, func(m string) string {
|
||||
idx := strings.IndexRune(m, '-')
|
||||
n, _ := strconv.Atoi(m[0:idx])
|
||||
return fmt.Sprintf("%d-pre.0", n+1)
|
||||
})
|
||||
|
||||
vv, ve := version.NewVersion(v)
|
||||
vw, we := version.NewVersion(w)
|
||||
|
||||
|
|
@ -1,7 +1,6 @@
|
|||
package update
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
|
|
@ -34,6 +33,27 @@ func TestCheckForUpdate(t *testing.T) {
|
|||
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
||||
ExpectsResult: true,
|
||||
},
|
||||
{
|
||||
Name: "current is built from source",
|
||||
CurrentVersion: "v1.2.3-123-gdeadbeef",
|
||||
LatestVersion: "v1.2.3",
|
||||
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
||||
ExpectsResult: false,
|
||||
},
|
||||
{
|
||||
Name: "current is built from source after a prerelease",
|
||||
CurrentVersion: "v1.2.3-rc.1-123-gdeadbeef",
|
||||
LatestVersion: "v1.2.3",
|
||||
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
||||
ExpectsResult: true,
|
||||
},
|
||||
{
|
||||
Name: "latest is newer than version build from source",
|
||||
CurrentVersion: "v1.2.3-123-gdeadbeef",
|
||||
LatestVersion: "v1.2.4",
|
||||
LatestURL: "https://www.spacejam.com/archive/spacejam/movie/jam.htm",
|
||||
ExpectsResult: true,
|
||||
},
|
||||
{
|
||||
Name: "latest is current",
|
||||
CurrentVersion: "v1.0.0",
|
||||
|
|
@ -54,10 +74,14 @@ func TestCheckForUpdate(t *testing.T) {
|
|||
t.Run(s.Name, func(t *testing.T) {
|
||||
http := &httpmock.Registry{}
|
||||
client := api.NewClient(api.ReplaceTripper(http))
|
||||
http.StubResponse(200, bytes.NewBufferString(fmt.Sprintf(`{
|
||||
"tag_name": "%s",
|
||||
"html_url": "%s"
|
||||
}`, s.LatestVersion, s.LatestURL)))
|
||||
|
||||
http.Register(
|
||||
httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"),
|
||||
httpmock.StringResponse(fmt.Sprintf(`{
|
||||
"tag_name": "%s",
|
||||
"html_url": "%s"
|
||||
}`, s.LatestVersion, s.LatestURL)),
|
||||
)
|
||||
|
||||
rel, err := CheckForUpdate(client, tempFilePath(), "OWNER/REPO", s.CurrentVersion)
|
||||
if err != nil {
|
||||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/safeexec"
|
||||
"github.com/google/shlex"
|
||||
)
|
||||
|
||||
|
|
@ -31,7 +32,7 @@ func ForOS(goos, url string) *exec.Cmd {
|
|||
case "darwin":
|
||||
args = append(args, url)
|
||||
case "windows":
|
||||
exe = "cmd"
|
||||
exe, _ = lookPath("cmd")
|
||||
r := strings.NewReplacer("&", "^&")
|
||||
args = append(args, "/c", "start", r.Replace(url))
|
||||
default:
|
||||
|
|
@ -51,8 +52,13 @@ func FromLauncher(launcher, url string) (*exec.Cmd, error) {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
exe, err := lookPath(args[0])
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
args = append(args, url)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd := exec.Command(exe, args[1:]...)
|
||||
cmd.Stderr = os.Stderr
|
||||
return cmd, nil
|
||||
}
|
||||
|
|
@ -71,4 +77,4 @@ func linuxExe() string {
|
|||
return exe
|
||||
}
|
||||
|
||||
var lookPath = exec.LookPath
|
||||
var lookPath = safeexec.LookPath
|
||||
|
|
|
|||
|
|
@ -49,10 +49,12 @@ func TestForOS(t *testing.T) {
|
|||
goos: "windows",
|
||||
url: "https://example.com/path?a=1&b=2&c=3",
|
||||
},
|
||||
exe: "cmd",
|
||||
want: []string{"cmd", "/c", "start", "https://example.com/path?a=1^&b=2^&c=3"},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
origLookPath := lookPath
|
||||
lookPath = func(file string) (string, error) {
|
||||
if file == tt.exe {
|
||||
return file, nil
|
||||
|
|
@ -60,6 +62,9 @@ func TestForOS(t *testing.T) {
|
|||
return "", errors.New("not found")
|
||||
}
|
||||
}
|
||||
defer func() {
|
||||
lookPath = origLookPath
|
||||
}()
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if cmd := ForOS(tt.args.goos, tt.args.url); !reflect.DeepEqual(cmd.Args, tt.want) {
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ import (
|
|||
|
||||
func NewCmdAlias(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "alias",
|
||||
Use: "alias <command>",
|
||||
Short: "Create command shortcuts",
|
||||
Long: heredoc.Doc(`
|
||||
Aliases can be used to make shortcuts for gh commands or to compose multiple commands.
|
||||
|
|
|
|||
|
|
@ -6,7 +6,6 @@ import (
|
|||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -63,7 +62,7 @@ func deleteRun(opts *DeleteOptions) error {
|
|||
}
|
||||
|
||||
if opts.IO.IsStdoutTTY() {
|
||||
redCheck := utils.Red("✓")
|
||||
redCheck := opts.IO.ColorScheme().Red("✓")
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Deleted alias %s; was %s\n", redCheck, opts.Name, expansion)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -76,9 +76,7 @@ func TestAliasDelete(t *testing.T) {
|
|||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantErr != "" {
|
||||
if assert.Error(t, err) {
|
||||
assert.Equal(t, tt.wantErr, err.Error())
|
||||
}
|
||||
assert.EqualError(t, err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
|
|
|
|||
|
|
@ -4,13 +4,13 @@ import (
|
|||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"runtime"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/safeexec"
|
||||
"github.com/google/shlex"
|
||||
)
|
||||
|
||||
|
|
@ -80,7 +80,7 @@ func ExpandAlias(cfg config.Config, args []string, findShFunc func() (string, er
|
|||
}
|
||||
|
||||
func findSh() (string, error) {
|
||||
shPath, err := exec.LookPath("sh")
|
||||
shPath, err := safeexec.LookPath("sh")
|
||||
if err == nil {
|
||||
return shPath, nil
|
||||
}
|
||||
|
|
@ -88,7 +88,7 @@ func findSh() (string, error) {
|
|||
if runtime.GOOS == "windows" {
|
||||
winNotFoundErr := errors.New("unable to locate sh to execute the shell alias with. The sh.exe interpreter is typically distributed with Git for Windows.")
|
||||
// We can try and find a sh executable in a Git for Windows install
|
||||
gitPath, err := exec.LookPath("git")
|
||||
gitPath, err := safeexec.LookPath("git")
|
||||
if err != nil {
|
||||
return "", winNotFoundErr
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/google/shlex"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
|
@ -84,6 +83,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
|
|||
}
|
||||
|
||||
func setRun(opts *SetOptions) error {
|
||||
cs := opts.IO.ColorScheme()
|
||||
cfg, err := opts.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -96,7 +96,7 @@ func setRun(opts *SetOptions) error {
|
|||
|
||||
isTerminal := opts.IO.IsStdoutTTY()
|
||||
if isTerminal {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", utils.Bold(opts.Name), utils.Bold(opts.Expansion))
|
||||
fmt.Fprintf(opts.IO.ErrOut, "- Adding alias for %s: %s\n", cs.Bold(opts.Name), cs.Bold(opts.Expansion))
|
||||
}
|
||||
|
||||
expansion := opts.Expansion
|
||||
|
|
@ -114,13 +114,13 @@ func setRun(opts *SetOptions) error {
|
|||
return fmt.Errorf("could not create alias: %s does not correspond to a gh command", expansion)
|
||||
}
|
||||
|
||||
successMsg := fmt.Sprintf("%s Added alias.", utils.Green("✓"))
|
||||
successMsg := fmt.Sprintf("%s Added alias.", cs.SuccessIcon())
|
||||
if oldExpansion, ok := aliasCfg.Get(opts.Name); ok {
|
||||
successMsg = fmt.Sprintf("%s Changed alias %s from %s to %s",
|
||||
utils.Green("✓"),
|
||||
utils.Bold(opts.Name),
|
||||
utils.Bold(oldExpansion),
|
||||
utils.Bold(expansion),
|
||||
cs.SuccessIcon(),
|
||||
cs.Bold(opts.Name),
|
||||
cs.Bold(oldExpansion),
|
||||
cs.Bold(expansion),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -65,10 +65,7 @@ func TestAliasSet_gh_command(t *testing.T) {
|
|||
cfg := config.NewFromString(``)
|
||||
|
||||
_, err := runCommand(cfg, true, "pr 'pr status'")
|
||||
|
||||
if assert.Error(t, err) {
|
||||
assert.Equal(t, `could not create alias: "pr" is already a gh command`, err.Error())
|
||||
}
|
||||
assert.EqualError(t, err, `could not create alias: "pr" is already a gh command`)
|
||||
}
|
||||
|
||||
func TestAliasSet_empty_aliases(t *testing.T) {
|
||||
|
|
@ -210,9 +207,7 @@ func TestAliasSet_invalid_command(t *testing.T) {
|
|||
cfg := config.NewFromString(``)
|
||||
|
||||
_, err := runCommand(cfg, true, "co 'pe checkout'")
|
||||
if assert.Error(t, err) {
|
||||
assert.Equal(t, "could not create alias: pe checkout does not correspond to a gh command", err.Error())
|
||||
}
|
||||
assert.EqualError(t, err, "could not create alias: pe checkout does not correspond to a gh command")
|
||||
}
|
||||
|
||||
func TestShellAlias_flag(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -119,9 +119,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 github.com API requests.
|
||||
GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for github.com API requests.
|
||||
|
||||
GITHUB_ENTERPRISE_TOKEN: an authentication token for API requests to GitHub Enterprise.
|
||||
GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (in order of precedence): an authentication token for API requests to GitHub Enterprise.
|
||||
|
||||
GH_HOST: make the request to a GitHub host other than github.com.
|
||||
`),
|
||||
|
|
@ -403,7 +403,7 @@ func parseField(f string) (string, string, error) {
|
|||
|
||||
func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) {
|
||||
if strings.HasPrefix(v, "@") {
|
||||
return readUserFile(v[1:], opts.IO.In)
|
||||
return opts.IO.ReadUserFile(v[1:])
|
||||
}
|
||||
|
||||
if n, err := strconv.Atoi(v); err == nil {
|
||||
|
|
@ -422,21 +422,6 @@ func magicFieldValue(v string, opts *ApiOptions) (interface{}, error) {
|
|||
}
|
||||
}
|
||||
|
||||
func readUserFile(fn string, stdin io.ReadCloser) ([]byte, error) {
|
||||
var r io.ReadCloser
|
||||
if fn == "-" {
|
||||
r = stdin
|
||||
} else {
|
||||
var err error
|
||||
r, err = os.Open(fn)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
defer r.Close()
|
||||
return ioutil.ReadAll(r)
|
||||
}
|
||||
|
||||
func openUserFile(fn string, stdin io.ReadCloser) (io.ReadCloser, int64, error) {
|
||||
if fn == "-" {
|
||||
return stdin, -1, nil
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package auth
|
||||
|
||||
import (
|
||||
gitCredentialCmd "github.com/cli/cli/pkg/cmd/auth/gitcredential"
|
||||
authLoginCmd "github.com/cli/cli/pkg/cmd/auth/login"
|
||||
authLogoutCmd "github.com/cli/cli/pkg/cmd/auth/logout"
|
||||
authRefreshCmd "github.com/cli/cli/pkg/cmd/auth/refresh"
|
||||
|
|
@ -22,6 +23,7 @@ func NewCmdAuth(f *cmdutil.Factory) *cobra.Command {
|
|||
cmd.AddCommand(authLogoutCmd.NewCmdLogout(f, nil))
|
||||
cmd.AddCommand(authStatusCmd.NewCmdStatus(f, nil))
|
||||
cmd.AddCommand(authRefreshCmd.NewCmdRefresh(f, nil))
|
||||
cmd.AddCommand(gitCredentialCmd.NewCmdCredential(f, nil))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
117
pkg/cmd/auth/gitcredential/helper.go
Normal file
117
pkg/cmd/auth/gitcredential/helper.go
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
package login
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type config interface {
|
||||
Get(string, string) (string, error)
|
||||
}
|
||||
|
||||
type CredentialOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
Config func() (config, error)
|
||||
|
||||
Operation string
|
||||
}
|
||||
|
||||
func NewCmdCredential(f *cmdutil.Factory, runF func(*CredentialOptions) error) *cobra.Command {
|
||||
opts := &CredentialOptions{
|
||||
IO: f.IOStreams,
|
||||
Config: func() (config, error) {
|
||||
return f.Config()
|
||||
},
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "git-credential",
|
||||
Args: cobra.ExactArgs(1),
|
||||
Short: "Implements git credential helper protocol",
|
||||
Hidden: true,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Operation = args[0]
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
return helperRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func helperRun(opts *CredentialOptions) error {
|
||||
if opts.Operation == "store" {
|
||||
// We pretend to implement the "store" operation, but do nothing since we already have a cached token.
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
if opts.Operation != "get" {
|
||||
return fmt.Errorf("gh auth git-credential: %q operation not supported", opts.Operation)
|
||||
}
|
||||
|
||||
wants := map[string]string{}
|
||||
|
||||
s := bufio.NewScanner(opts.IO.In)
|
||||
for s.Scan() {
|
||||
line := s.Text()
|
||||
if line == "" {
|
||||
break
|
||||
}
|
||||
parts := strings.SplitN(line, "=", 2)
|
||||
if len(parts) < 2 {
|
||||
continue
|
||||
}
|
||||
key, value := parts[0], parts[1]
|
||||
if key == "url" {
|
||||
u, err := url.Parse(value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
wants["protocol"] = u.Scheme
|
||||
wants["host"] = u.Host
|
||||
wants["path"] = u.Path
|
||||
wants["username"] = u.User.Username()
|
||||
wants["password"], _ = u.User.Password()
|
||||
} else {
|
||||
wants[key] = value
|
||||
}
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if wants["protocol"] != "https" {
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
cfg, err := opts.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
gotUser, _ := cfg.Get(wants["host"], "user")
|
||||
gotToken, _ := cfg.Get(wants["host"], "oauth_token")
|
||||
if gotUser == "" || gotToken == "" {
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
if wants["username"] != "" && !strings.EqualFold(wants["username"], gotUser) {
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
fmt.Fprint(opts.IO.Out, "protocol=https\n")
|
||||
fmt.Fprintf(opts.IO.Out, "host=%s\n", wants["host"])
|
||||
fmt.Fprintf(opts.IO.Out, "username=%s\n", gotUser)
|
||||
fmt.Fprintf(opts.IO.Out, "password=%s\n", gotToken)
|
||||
|
||||
return nil
|
||||
}
|
||||
154
pkg/cmd/auth/gitcredential/helper_test.go
Normal file
154
pkg/cmd/auth/gitcredential/helper_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package login
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
)
|
||||
|
||||
type tinyConfig map[string]string
|
||||
|
||||
func (c tinyConfig) Get(host, key string) (string, error) {
|
||||
return c[fmt.Sprintf("%s:%s", host, key)], nil
|
||||
}
|
||||
|
||||
func Test_helperRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts CredentialOptions
|
||||
input string
|
||||
wantStdout string
|
||||
wantStderr string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "host only, credentials found",
|
||||
opts: CredentialOptions{
|
||||
Operation: "get",
|
||||
Config: func() (config, error) {
|
||||
return tinyConfig{
|
||||
"example.com:user": "monalisa",
|
||||
"example.com:oauth_token": "OTOKEN",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
input: heredoc.Doc(`
|
||||
protocol=https
|
||||
host=example.com
|
||||
`),
|
||||
wantErr: false,
|
||||
wantStdout: heredoc.Doc(`
|
||||
protocol=https
|
||||
host=example.com
|
||||
username=monalisa
|
||||
password=OTOKEN
|
||||
`),
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "host plus user",
|
||||
opts: CredentialOptions{
|
||||
Operation: "get",
|
||||
Config: func() (config, error) {
|
||||
return tinyConfig{
|
||||
"example.com:user": "monalisa",
|
||||
"example.com:oauth_token": "OTOKEN",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
input: heredoc.Doc(`
|
||||
protocol=https
|
||||
host=example.com
|
||||
username=monalisa
|
||||
`),
|
||||
wantErr: false,
|
||||
wantStdout: heredoc.Doc(`
|
||||
protocol=https
|
||||
host=example.com
|
||||
username=monalisa
|
||||
password=OTOKEN
|
||||
`),
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "url input",
|
||||
opts: CredentialOptions{
|
||||
Operation: "get",
|
||||
Config: func() (config, error) {
|
||||
return tinyConfig{
|
||||
"example.com:user": "monalisa",
|
||||
"example.com:oauth_token": "OTOKEN",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
input: heredoc.Doc(`
|
||||
url=https://monalisa@example.com
|
||||
`),
|
||||
wantErr: false,
|
||||
wantStdout: heredoc.Doc(`
|
||||
protocol=https
|
||||
host=example.com
|
||||
username=monalisa
|
||||
password=OTOKEN
|
||||
`),
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "host only, no credentials found",
|
||||
opts: CredentialOptions{
|
||||
Operation: "get",
|
||||
Config: func() (config, error) {
|
||||
return tinyConfig{
|
||||
"example.com:user": "monalisa",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
input: heredoc.Doc(`
|
||||
protocol=https
|
||||
host=example.com
|
||||
`),
|
||||
wantErr: true,
|
||||
wantStdout: "",
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "user mismatch",
|
||||
opts: CredentialOptions{
|
||||
Operation: "get",
|
||||
Config: func() (config, error) {
|
||||
return tinyConfig{
|
||||
"example.com:user": "monalisa",
|
||||
"example.com:oauth_token": "OTOKEN",
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
input: heredoc.Doc(`
|
||||
protocol=https
|
||||
host=example.com
|
||||
username=hubot
|
||||
`),
|
||||
wantErr: true,
|
||||
wantStdout: "",
|
||||
wantStderr: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
io, stdin, stdout, stderr := iostreams.Test()
|
||||
fmt.Fprint(stdin, tt.input)
|
||||
opts := &tt.opts
|
||||
opts.IO = io
|
||||
if err := helperRun(opts); (err != nil) != tt.wantErr {
|
||||
t.Fatalf("helperRun() error = %v, wantErr %v", err, tt.wantErr)
|
||||
}
|
||||
if tt.wantStdout != stdout.String() {
|
||||
t.Errorf("stdout: got %q, wants %q", stdout.String(), tt.wantStdout)
|
||||
}
|
||||
if tt.wantStderr != stderr.String() {
|
||||
t.Errorf("stderr: got %q, wants %q", stderr.String(), tt.wantStderr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -12,11 +12,10 @@ import (
|
|||
"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"
|
||||
"github.com/cli/cli/pkg/cmd/auth/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/pkg/prompt"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -121,70 +120,56 @@ func loginRun(opts *LoginOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
hostname := opts.Hostname
|
||||
if hostname == "" {
|
||||
if opts.Interactive {
|
||||
var err error
|
||||
hostname, err = promptForHostname()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
} else {
|
||||
return errors.New("must specify --hostname")
|
||||
}
|
||||
}
|
||||
|
||||
if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil {
|
||||
var roErr *config.ReadOnlyEnvError
|
||||
if errors.As(err, &roErr) {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", roErr.Variable)
|
||||
fmt.Fprint(opts.IO.ErrOut, "To have GitHub CLI store credentials instead, first clear the value from the environment.\n")
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
if opts.Token != "" {
|
||||
// I chose to not error on existing host here; my thinking is that for --with-token the user
|
||||
// probably doesn't care if a token is overwritten since they have a token in hand they
|
||||
// explicitly want to use.
|
||||
if opts.Hostname == "" {
|
||||
return errors.New("empty hostname would leak oauth_token")
|
||||
}
|
||||
|
||||
err := cfg.Set(opts.Hostname, "oauth_token", opts.Token)
|
||||
err := cfg.Set(hostname, "oauth_token", opts.Token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.ValidateHostCfg(opts.Hostname, cfg)
|
||||
apiClient, err := shared.ClientFromCfg(hostname, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := apiClient.HasMinimumScopes(hostname); err != nil {
|
||||
return fmt.Errorf("error validating token: %w", err)
|
||||
}
|
||||
|
||||
return cfg.Write()
|
||||
}
|
||||
|
||||
// TODO consider explicitly telling survey what io to use since it's implicit right now
|
||||
|
||||
hostname := opts.Hostname
|
||||
|
||||
if hostname == "" {
|
||||
var hostType int
|
||||
err := prompt.SurveyAskOne(&survey.Select{
|
||||
Message: "What account do you want to log into?",
|
||||
Options: []string{
|
||||
"GitHub.com",
|
||||
"GitHub Enterprise Server",
|
||||
},
|
||||
}, &hostType)
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not prompt: %w", err)
|
||||
}
|
||||
|
||||
isEnterprise := hostType == 1
|
||||
|
||||
hostname = ghinstance.Default()
|
||||
if isEnterprise {
|
||||
err := prompt.SurveyAskOne(&survey.Input{
|
||||
Message: "GHE hostname:",
|
||||
}, &hostname, survey.WithValidator(ghinstance.HostnameValidator))
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not prompt: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.IO.ErrOut, "- Logging into %s\n", hostname)
|
||||
|
||||
existingToken, _ := cfg.Get(hostname, "oauth_token")
|
||||
|
||||
if existingToken != "" && opts.Interactive {
|
||||
err := client.ValidateHostCfg(hostname, cfg)
|
||||
if err == nil {
|
||||
apiClient, err := client.ClientFromCfg(hostname, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
apiClient, err := shared.ClientFromCfg(hostname, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := apiClient.HasMinimumScopes(hostname); err == nil {
|
||||
username, err := api.CurrentLoginName(apiClient, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error using api: %w", err)
|
||||
|
|
@ -207,10 +192,6 @@ func loginRun(opts *LoginOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
var authMode int
|
||||
if opts.Web {
|
||||
authMode = 0
|
||||
|
|
@ -227,11 +208,13 @@ func loginRun(opts *LoginOptions) error {
|
|||
}
|
||||
}
|
||||
|
||||
userValidated := false
|
||||
if authMode == 0 {
|
||||
_, err := authflow.AuthFlowWithConfig(cfg, hostname, "", opts.Scopes)
|
||||
_, err := authflow.AuthFlowWithConfig(cfg, opts.IO, hostname, "", opts.Scopes)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to authenticate via web browser: %w", err)
|
||||
}
|
||||
userValidated = true
|
||||
} else {
|
||||
fmt.Fprintln(opts.IO.ErrOut)
|
||||
fmt.Fprintln(opts.IO.ErrOut, heredoc.Doc(getAccessTokenTip(hostname)))
|
||||
|
|
@ -243,22 +226,24 @@ func loginRun(opts *LoginOptions) error {
|
|||
return fmt.Errorf("could not prompt: %w", err)
|
||||
}
|
||||
|
||||
if hostname == "" {
|
||||
return errors.New("empty hostname would leak oauth_token")
|
||||
}
|
||||
|
||||
err = cfg.Set(hostname, "oauth_token", token)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = client.ValidateHostCfg(hostname, cfg)
|
||||
apiClient, err := shared.ClientFromCfg(hostname, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := apiClient.HasMinimumScopes(hostname); err != nil {
|
||||
return fmt.Errorf("error validating token: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
gitProtocol := "https"
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
||||
var gitProtocol string
|
||||
if opts.Interactive {
|
||||
err = prompt.SurveyAskOne(&survey.Select{
|
||||
Message: "Choose default git protocol",
|
||||
|
|
@ -279,22 +264,27 @@ func loginRun(opts *LoginOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", utils.GreenCheck())
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Configured git protocol\n", cs.SuccessIcon())
|
||||
}
|
||||
|
||||
apiClient, err := client.ClientFromCfg(hostname, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
var username string
|
||||
if userValidated {
|
||||
username, _ = cfg.Get(hostname, "user")
|
||||
} else {
|
||||
apiClient, err := shared.ClientFromCfg(hostname, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
username, err := api.CurrentLoginName(apiClient, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error using api: %w", err)
|
||||
}
|
||||
username, err = api.CurrentLoginName(apiClient, hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error using api: %w", err)
|
||||
}
|
||||
|
||||
err = cfg.Set(hostname, "user", username)
|
||||
if err != nil {
|
||||
return err
|
||||
err = cfg.Set(hostname, "user", username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
err = cfg.Write()
|
||||
|
|
@ -302,11 +292,47 @@ func loginRun(opts *LoginOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", utils.GreenCheck(), utils.Bold(username))
|
||||
if opts.Interactive && gitProtocol == "https" {
|
||||
err := shared.GitCredentialSetup(cfg, hostname, username)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Logged in as %s\n", cs.SuccessIcon(), cs.Bold(username))
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func promptForHostname() (string, error) {
|
||||
var hostType int
|
||||
err := prompt.SurveyAskOne(&survey.Select{
|
||||
Message: "What account do you want to log into?",
|
||||
Options: []string{
|
||||
"GitHub.com",
|
||||
"GitHub Enterprise Server",
|
||||
},
|
||||
}, &hostType)
|
||||
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not prompt: %w", err)
|
||||
}
|
||||
|
||||
isEnterprise := hostType == 1
|
||||
|
||||
hostname := ghinstance.Default()
|
||||
if isEnterprise {
|
||||
err := prompt.SurveyAskOne(&survey.Input{
|
||||
Message: "GHE hostname:",
|
||||
}, &hostname, survey.WithValidator(ghinstance.HostnameValidator))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not prompt: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return hostname, nil
|
||||
}
|
||||
|
||||
func getAccessTokenTip(hostname string) string {
|
||||
ghHostname := hostname
|
||||
if ghHostname == "" {
|
||||
|
|
|
|||
|
|
@ -3,12 +3,14 @@ package login
|
|||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmd/auth/client"
|
||||
"github.com/cli/cli/pkg/cmd/auth/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
|
|
@ -189,11 +191,13 @@ func Test_NewCmdLogin(t *testing.T) {
|
|||
|
||||
func Test_loginRun_nontty(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *LoginOptions
|
||||
httpStubs func(*httpmock.Registry)
|
||||
wantHosts string
|
||||
wantErr *regexp.Regexp
|
||||
name string
|
||||
opts *LoginOptions
|
||||
httpStubs func(*httpmock.Registry)
|
||||
env map[string]string
|
||||
wantHosts string
|
||||
wantErr string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "with token",
|
||||
|
|
@ -201,6 +205,9 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
Hostname: "github.com",
|
||||
Token: "abc123",
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
},
|
||||
wantHosts: "github.com:\n oauth_token: abc123\n",
|
||||
},
|
||||
{
|
||||
|
|
@ -223,7 +230,7 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("read:org"))
|
||||
},
|
||||
wantErr: regexp.MustCompile(`missing required scope 'repo'`),
|
||||
wantErr: `error validating token: missing required scope 'repo'`,
|
||||
},
|
||||
{
|
||||
name: "missing read scope",
|
||||
|
|
@ -234,7 +241,7 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo"))
|
||||
},
|
||||
wantErr: regexp.MustCompile(`missing required scope 'read:org'`),
|
||||
wantErr: `error validating token: missing required scope 'read:org'`,
|
||||
},
|
||||
{
|
||||
name: "has admin scope",
|
||||
|
|
@ -247,6 +254,36 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
},
|
||||
wantHosts: "github.com:\n oauth_token: abc456\n",
|
||||
},
|
||||
{
|
||||
name: "github.com token from environment",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "github.com",
|
||||
Token: "abc456",
|
||||
},
|
||||
env: map[string]string{
|
||||
"GH_TOKEN": "value_from_env",
|
||||
},
|
||||
wantErr: "SilentError",
|
||||
wantStderr: heredoc.Doc(`
|
||||
The value of the GH_TOKEN environment variable is being used for authentication.
|
||||
To have GitHub CLI store credentials instead, first clear the value from the environment.
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "GHE token from environment",
|
||||
opts: &LoginOptions{
|
||||
Hostname: "ghe.io",
|
||||
Token: "abc456",
|
||||
},
|
||||
env: map[string]string{
|
||||
"GH_ENTERPRISE_TOKEN": "value_from_env",
|
||||
},
|
||||
wantErr: "SilentError",
|
||||
wantStderr: heredoc.Doc(`
|
||||
The value of the GH_ENTERPRISE_TOKEN environment variable is being used for authentication.
|
||||
To have GitHub CLI store credentials instead, first clear the value from the environment.
|
||||
`),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -256,25 +293,39 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
io.SetStdoutTTY(false)
|
||||
|
||||
tt.opts.Config = func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
cfg := config.NewBlankConfig()
|
||||
return config.InheritEnv(cfg), nil
|
||||
}
|
||||
|
||||
tt.opts.IO = io
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
origClientFromCfg := client.ClientFromCfg
|
||||
origClientFromCfg := shared.ClientFromCfg
|
||||
defer func() {
|
||||
client.ClientFromCfg = origClientFromCfg
|
||||
shared.ClientFromCfg = origClientFromCfg
|
||||
}()
|
||||
client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
|
||||
shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
return api.NewClientFromHTTP(httpClient), nil
|
||||
}
|
||||
|
||||
old_GH_TOKEN := os.Getenv("GH_TOKEN")
|
||||
os.Setenv("GH_TOKEN", tt.env["GH_TOKEN"])
|
||||
old_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN")
|
||||
os.Setenv("GITHUB_TOKEN", tt.env["GITHUB_TOKEN"])
|
||||
old_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN")
|
||||
os.Setenv("GH_ENTERPRISE_TOKEN", tt.env["GH_ENTERPRISE_TOKEN"])
|
||||
old_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN")
|
||||
os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.env["GITHUB_ENTERPRISE_TOKEN"])
|
||||
defer func() {
|
||||
os.Setenv("GH_TOKEN", old_GH_TOKEN)
|
||||
os.Setenv("GITHUB_TOKEN", old_GITHUB_TOKEN)
|
||||
os.Setenv("GH_ENTERPRISE_TOKEN", old_GH_ENTERPRISE_TOKEN)
|
||||
os.Setenv("GITHUB_ENTERPRISE_TOKEN", old_GITHUB_ENTERPRISE_TOKEN)
|
||||
}()
|
||||
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
} else {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
}
|
||||
|
||||
mainBuf := bytes.Buffer{}
|
||||
|
|
@ -282,18 +333,14 @@ func Test_loginRun_nontty(t *testing.T) {
|
|||
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
||||
|
||||
err := loginRun(tt.opts)
|
||||
assert.Equal(t, tt.wantErr == nil, err == nil)
|
||||
if err != nil {
|
||||
if tt.wantErr != nil {
|
||||
assert.True(t, tt.wantErr.MatchString(err.Error()))
|
||||
return
|
||||
} else {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if tt.wantErr != "" {
|
||||
assert.EqualError(t, err, tt.wantErr)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "", stdout.String())
|
||||
assert.Equal(t, "", stderr.String())
|
||||
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||
assert.Equal(t, tt.wantHosts, hostsBuf.String())
|
||||
reg.Verify(t)
|
||||
})
|
||||
|
|
@ -319,7 +366,7 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
_ = cfg.Set("github.com", "oauth_token", "ghi789")
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
|
||||
|
|
@ -329,7 +376,7 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
as.StubOne(false) // do not continue
|
||||
},
|
||||
wantHosts: "", // nothing should have been written to hosts
|
||||
wantErrOut: regexp.MustCompile("Logging into github.com"),
|
||||
wantErrOut: nil,
|
||||
},
|
||||
{
|
||||
name: "hostname set",
|
||||
|
|
@ -342,9 +389,10 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
as.StubOne(1) // auth mode: token
|
||||
as.StubOne("def456") // auth token
|
||||
as.StubOne("HTTPS") // git_protocol
|
||||
as.StubOne(false) // cache credentials
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
|
||||
|
|
@ -363,9 +411,10 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
as.StubOne(1) // auth mode: token
|
||||
as.StubOne("def456") // auth token
|
||||
as.StubOne("HTTPS") // git_protocol
|
||||
as.StubOne(false) // cache credentials
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,read:public_key"))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
|
||||
|
|
@ -383,6 +432,7 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
as.StubOne(1) // auth mode: token
|
||||
as.StubOne("def456") // auth token
|
||||
as.StubOne("HTTPS") // git_protocol
|
||||
as.StubOne(false) // cache credentials
|
||||
},
|
||||
wantErrOut: regexp.MustCompile("Tip: you can generate a Personal Access Token here https://github.com/settings/tokens"),
|
||||
},
|
||||
|
|
@ -426,18 +476,18 @@ func Test_loginRun_Survey(t *testing.T) {
|
|||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
origClientFromCfg := client.ClientFromCfg
|
||||
origClientFromCfg := shared.ClientFromCfg
|
||||
defer func() {
|
||||
client.ClientFromCfg = origClientFromCfg
|
||||
shared.ClientFromCfg = origClientFromCfg
|
||||
}()
|
||||
client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
|
||||
shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
return api.NewClientFromHTTP(httpClient), nil
|
||||
}
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
} else {
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,read:public_key"))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"jillv"}}}`))
|
||||
|
|
|
|||
|
|
@ -12,7 +12,6 @@ import (
|
|||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/pkg/prompt"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -106,6 +105,12 @@ func logoutRun(opts *LogoutOptions) error {
|
|||
}
|
||||
|
||||
if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil {
|
||||
var roErr *config.ReadOnlyEnvError
|
||||
if errors.As(err, &roErr) {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", roErr.Variable)
|
||||
fmt.Fprint(opts.IO.ErrOut, "To erase credentials stored in GitHub CLI, first clear the value from the environment.\n")
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
@ -151,8 +156,9 @@ func logoutRun(opts *LogoutOptions) error {
|
|||
isTTY := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
|
||||
|
||||
if isTTY {
|
||||
cs := opts.IO.ColorScheme()
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Logged out of %s%s\n",
|
||||
utils.GreenCheck(), utils.Bold(hostname), usernameStr)
|
||||
cs.SuccessIcon(), cs.Bold(hostname), usernameStr)
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -98,7 +98,7 @@ func Test_logoutRun_tty(t *testing.T) {
|
|||
cfgHosts []string
|
||||
wantHosts string
|
||||
wantErrOut *regexp.Regexp
|
||||
wantErr *regexp.Regexp
|
||||
wantErr string
|
||||
}{
|
||||
{
|
||||
name: "no arguments, multiple hosts",
|
||||
|
|
@ -123,7 +123,7 @@ func Test_logoutRun_tty(t *testing.T) {
|
|||
{
|
||||
name: "no arguments, no hosts",
|
||||
opts: &LogoutOptions{},
|
||||
wantErr: regexp.MustCompile(`not logged in to any hosts`),
|
||||
wantErr: `not logged in to any hosts`,
|
||||
},
|
||||
{
|
||||
name: "hostname",
|
||||
|
|
@ -176,14 +176,11 @@ func Test_logoutRun_tty(t *testing.T) {
|
|||
}
|
||||
|
||||
err := logoutRun(tt.opts)
|
||||
assert.Equal(t, tt.wantErr == nil, err == nil)
|
||||
if err != nil {
|
||||
if tt.wantErr != nil {
|
||||
assert.True(t, tt.wantErr.MatchString(err.Error()))
|
||||
return
|
||||
} else {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if tt.wantErr != "" {
|
||||
assert.EqualError(t, err, tt.wantErr)
|
||||
return
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
if tt.wantErrOut == nil {
|
||||
|
|
@ -204,7 +201,7 @@ func Test_logoutRun_nontty(t *testing.T) {
|
|||
opts *LogoutOptions
|
||||
cfgHosts []string
|
||||
wantHosts string
|
||||
wantErr *regexp.Regexp
|
||||
wantErr string
|
||||
ghtoken string
|
||||
}{
|
||||
{
|
||||
|
|
@ -227,7 +224,7 @@ func Test_logoutRun_nontty(t *testing.T) {
|
|||
opts: &LogoutOptions{
|
||||
Hostname: "harry.mason",
|
||||
},
|
||||
wantErr: regexp.MustCompile(`not logged in to any hosts`),
|
||||
wantErr: `not logged in to any hosts`,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -258,16 +255,10 @@ func Test_logoutRun_nontty(t *testing.T) {
|
|||
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
||||
|
||||
err := logoutRun(tt.opts)
|
||||
assert.Equal(t, tt.wantErr == nil, err == nil)
|
||||
if err != nil {
|
||||
if tt.wantErr != nil {
|
||||
if !tt.wantErr.MatchString(err.Error()) {
|
||||
t.Errorf("got error: %v", err)
|
||||
}
|
||||
return
|
||||
} else {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if tt.wantErr != "" {
|
||||
assert.EqualError(t, err, tt.wantErr)
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "", stderr.String())
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/internal/authflow"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmd/auth/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/pkg/prompt"
|
||||
|
|
@ -20,15 +21,17 @@ type RefreshOptions struct {
|
|||
|
||||
Hostname string
|
||||
Scopes []string
|
||||
AuthFlow func(config.Config, string, []string) error
|
||||
AuthFlow func(config.Config, *iostreams.IOStreams, string, []string) error
|
||||
|
||||
Interactive bool
|
||||
}
|
||||
|
||||
func NewCmdRefresh(f *cmdutil.Factory, runF func(*RefreshOptions) error) *cobra.Command {
|
||||
opts := &RefreshOptions{
|
||||
IO: f.IOStreams,
|
||||
Config: f.Config,
|
||||
AuthFlow: func(cfg config.Config, hostname string, scopes []string) error {
|
||||
_, err := authflow.AuthFlowWithConfig(cfg, hostname, "", scopes)
|
||||
AuthFlow: func(cfg config.Config, io *iostreams.IOStreams, hostname string, scopes []string) error {
|
||||
_, err := authflow.AuthFlowWithConfig(cfg, io, hostname, "", scopes)
|
||||
return err
|
||||
},
|
||||
}
|
||||
|
|
@ -50,21 +53,15 @@ 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()
|
||||
opts.Interactive = opts.IO.CanPrompt()
|
||||
|
||||
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
|
||||
if !opts.Interactive && opts.Hostname == "" {
|
||||
return &cmdutil.FlagError{Err: errors.New("--hostname required when not running interactively")}
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return refreshRun(opts)
|
||||
},
|
||||
}
|
||||
|
|
@ -115,8 +112,26 @@ func refreshRun(opts *RefreshOptions) error {
|
|||
}
|
||||
|
||||
if err := cfg.CheckWriteable(hostname, "oauth_token"); err != nil {
|
||||
var roErr *config.ReadOnlyEnvError
|
||||
if errors.As(err, &roErr) {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "The value of the %s environment variable is being used for authentication.\n", roErr.Variable)
|
||||
fmt.Fprint(opts.IO.ErrOut, "To refresh credentials stored in GitHub CLI, first clear the value from the environment.\n")
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
return opts.AuthFlow(cfg, hostname, opts.Scopes)
|
||||
if err := opts.AuthFlow(cfg, opts.IO, hostname, opts.Scopes); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
protocol, _ := cfg.Get(hostname, "git_protocol")
|
||||
if opts.Interactive && protocol == "https" {
|
||||
username, _ := cfg.Get(hostname, "user")
|
||||
if err := shared.GitCredentialSetup(cfg, hostname, username); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package refresh
|
|||
|
||||
import (
|
||||
"bytes"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/internal/config"
|
||||
|
|
@ -37,9 +36,11 @@ func Test_NewCmdRefresh(t *testing.T) {
|
|||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "nontty hostname",
|
||||
cli: "-h aline.cedrac",
|
||||
wantsErr: true,
|
||||
name: "nontty hostname",
|
||||
cli: "-h aline.cedrac",
|
||||
wants: RefreshOptions{
|
||||
Hostname: "aline.cedrac",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "tty hostname",
|
||||
|
|
@ -132,14 +133,14 @@ func Test_refreshRun(t *testing.T) {
|
|||
opts *RefreshOptions
|
||||
askStubs func(*prompt.AskStubber)
|
||||
cfgHosts []string
|
||||
wantErr *regexp.Regexp
|
||||
wantErr string
|
||||
nontty bool
|
||||
wantAuthArgs authArgs
|
||||
}{
|
||||
{
|
||||
name: "no hosts configured",
|
||||
opts: &RefreshOptions{},
|
||||
wantErr: regexp.MustCompile(`not logged in to any hosts`),
|
||||
wantErr: `not logged in to any hosts`,
|
||||
},
|
||||
{
|
||||
name: "hostname given but dne",
|
||||
|
|
@ -150,7 +151,7 @@ func Test_refreshRun(t *testing.T) {
|
|||
opts: &RefreshOptions{
|
||||
Hostname: "obed.morton",
|
||||
},
|
||||
wantErr: regexp.MustCompile(`not logged in to obed.morton`),
|
||||
wantErr: `not logged in to obed.morton`,
|
||||
},
|
||||
{
|
||||
name: "hostname provided and is configured",
|
||||
|
|
@ -213,7 +214,7 @@ func Test_refreshRun(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
aa := authArgs{}
|
||||
tt.opts.AuthFlow = func(_ config.Config, hostname string, scopes []string) error {
|
||||
tt.opts.AuthFlow = func(_ config.Config, _ *iostreams.IOStreams, hostname string, scopes []string) error {
|
||||
aa.hostname = hostname
|
||||
aa.scopes = scopes
|
||||
return nil
|
||||
|
|
@ -248,14 +249,12 @@ func Test_refreshRun(t *testing.T) {
|
|||
}
|
||||
|
||||
err := refreshRun(tt.opts)
|
||||
assert.Equal(t, tt.wantErr == nil, err == nil)
|
||||
if err != nil {
|
||||
if tt.wantErr != nil {
|
||||
assert.True(t, tt.wantErr.MatchString(err.Error()))
|
||||
return
|
||||
} else {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
if tt.wantErr != "" {
|
||||
if assert.Error(t, err) {
|
||||
assert.Contains(t, err.Error(), tt.wantErr)
|
||||
}
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
assert.Equal(t, aa.hostname, tt.wantAuthArgs.hostname)
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package client
|
||||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -8,20 +8,6 @@ import (
|
|||
"github.com/cli/cli/internal/config"
|
||||
)
|
||||
|
||||
func ValidateHostCfg(hostname string, cfg config.Config) error {
|
||||
apiClient, err := ClientFromCfg(hostname, cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = apiClient.HasMinimumScopes(hostname)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not validate token: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
var ClientFromCfg = func(hostname string, cfg config.Config) (*api.Client, error) {
|
||||
var opts []api.ClientOption
|
||||
|
||||
110
pkg/cmd/auth/shared/git_credential.go
Normal file
110
pkg/cmd/auth/shared/git_credential.go
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/git"
|
||||
"github.com/cli/cli/internal/run"
|
||||
"github.com/cli/cli/pkg/prompt"
|
||||
"github.com/google/shlex"
|
||||
)
|
||||
|
||||
type configReader interface {
|
||||
Get(string, string) (string, error)
|
||||
}
|
||||
|
||||
func GitCredentialSetup(cfg configReader, hostname, username string) error {
|
||||
helper, _ := gitCredentialHelper(hostname)
|
||||
if isOurCredentialHelper(helper) {
|
||||
return nil
|
||||
}
|
||||
|
||||
var primeCredentials bool
|
||||
err := prompt.SurveyAskOne(&survey.Confirm{
|
||||
Message: "Authenticate Git with your GitHub credentials?",
|
||||
Default: true,
|
||||
}, &primeCredentials)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not prompt: %w", err)
|
||||
}
|
||||
|
||||
if !primeCredentials {
|
||||
return nil
|
||||
}
|
||||
|
||||
if helper == "" {
|
||||
// use GitHub CLI as a credential helper (for this host only)
|
||||
configureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "!gh auth git-credential")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return run.PrepareCmd(configureCmd).Run()
|
||||
}
|
||||
|
||||
// clear previous cached credentials
|
||||
rejectCmd, err := git.GitCommand("credential", "reject")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
rejectCmd.Stdin = bytes.NewBufferString(heredoc.Docf(`
|
||||
protocol=https
|
||||
host=%s
|
||||
`, hostname))
|
||||
|
||||
err = run.PrepareCmd(rejectCmd).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
approveCmd, err := git.GitCommand("credential", "approve")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
password, _ := cfg.Get(hostname, "oauth_token")
|
||||
approveCmd.Stdin = bytes.NewBufferString(heredoc.Docf(`
|
||||
protocol=https
|
||||
host=%s
|
||||
username=%s
|
||||
password=%s
|
||||
`, hostname, username, password))
|
||||
|
||||
err = run.PrepareCmd(approveCmd).Run()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func gitCredentialHelperKey(hostname string) string {
|
||||
return fmt.Sprintf("credential.https://%s.helper", hostname)
|
||||
}
|
||||
|
||||
func gitCredentialHelper(hostname string) (helper string, err error) {
|
||||
helper, err = git.Config(gitCredentialHelperKey(hostname))
|
||||
if helper != "" {
|
||||
return
|
||||
}
|
||||
helper, err = git.Config("credential.helper")
|
||||
return
|
||||
}
|
||||
|
||||
func isOurCredentialHelper(cmd string) bool {
|
||||
if !strings.HasPrefix(cmd, "!") {
|
||||
return false
|
||||
}
|
||||
|
||||
args, err := shlex.Split(cmd[1:])
|
||||
if err != nil || len(args) == 0 {
|
||||
return false
|
||||
}
|
||||
|
||||
return strings.TrimSuffix(filepath.Base(args[0]), ".exe") == "gh"
|
||||
}
|
||||
88
pkg/cmd/auth/shared/git_credential_test.go
Normal file
88
pkg/cmd/auth/shared/git_credential_test.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/internal/run"
|
||||
"github.com/cli/cli/pkg/prompt"
|
||||
)
|
||||
|
||||
type tinyConfig map[string]string
|
||||
|
||||
func (c tinyConfig) Get(host, key string) (string, error) {
|
||||
return c[fmt.Sprintf("%s:%s", host, key)], nil
|
||||
}
|
||||
|
||||
func TestGitCredentialSetup_configureExisting(t *testing.T) {
|
||||
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
|
||||
|
||||
cs, restoreRun := run.Stub()
|
||||
defer restoreRun(t)
|
||||
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
|
||||
cs.Register(`git config credential\.helper`, 0, "osxkeychain\n")
|
||||
cs.Register(`git credential reject`, 0, "")
|
||||
cs.Register(`git credential approve`, 0, "")
|
||||
|
||||
as, restoreAsk := prompt.InitAskStubber()
|
||||
defer restoreAsk()
|
||||
as.StubOne(true)
|
||||
|
||||
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
|
||||
t.Errorf("GitCredentialSetup() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCredentialSetup_setOurs(t *testing.T) {
|
||||
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
|
||||
|
||||
cs, restoreRun := run.Stub()
|
||||
defer restoreRun(t)
|
||||
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
|
||||
cs.Register(`git config credential\.helper`, 1, "")
|
||||
cs.Register(`git config --global credential\.https://example\.com\.helper`, 0, "", func(args []string) {
|
||||
if val := args[len(args)-1]; val != "!gh auth git-credential" {
|
||||
t.Errorf("global credential helper configured to %q", val)
|
||||
}
|
||||
})
|
||||
|
||||
as, restoreAsk := prompt.InitAskStubber()
|
||||
defer restoreAsk()
|
||||
as.StubOne(true)
|
||||
|
||||
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
|
||||
t.Errorf("GitCredentialSetup() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCredentialSetup_promptDeny(t *testing.T) {
|
||||
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
|
||||
|
||||
cs, restoreRun := run.Stub()
|
||||
defer restoreRun(t)
|
||||
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
|
||||
cs.Register(`git config credential\.helper`, 1, "")
|
||||
|
||||
as, restoreAsk := prompt.InitAskStubber()
|
||||
defer restoreAsk()
|
||||
as.StubOne(false)
|
||||
|
||||
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
|
||||
t.Errorf("GitCredentialSetup() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGitCredentialSetup_isOurs(t *testing.T) {
|
||||
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
|
||||
|
||||
cs, restoreRun := run.Stub()
|
||||
defer restoreRun(t)
|
||||
cs.Register(`git config credential\.https://example\.com\.helper`, 0, "!/path/to/gh auth\n")
|
||||
|
||||
_, restoreAsk := prompt.InitAskStubber()
|
||||
defer restoreAsk()
|
||||
|
||||
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
|
||||
t.Errorf("GitCredentialSetup() error = %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -64,12 +63,14 @@ func statusRun(opts *StatusOptions) error {
|
|||
|
||||
stderr := opts.IO.ErrOut
|
||||
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
||||
statusInfo := map[string][]string{}
|
||||
|
||||
hostnames, err := cfg.Hosts()
|
||||
if len(hostnames) == 0 || err != nil {
|
||||
fmt.Fprintf(stderr,
|
||||
"You are not logged into any GitHub hosts. Run %s to authenticate.\n", utils.Bold("gh auth login"))
|
||||
"You are not logged into any GitHub hosts. Run %s to authenticate.\n", cs.Bold("gh auth login"))
|
||||
return cmdutil.SilentError
|
||||
}
|
||||
|
||||
|
|
@ -98,39 +99,39 @@ func statusRun(opts *StatusOptions) error {
|
|||
if err != nil {
|
||||
var missingScopes *api.MissingScopesError
|
||||
if errors.As(err, &missingScopes) {
|
||||
addMsg("%s %s: %s", utils.Red("X"), hostname, err)
|
||||
addMsg("%s %s: the token in %s is %s", cs.Red("X"), hostname, tokenSource, err)
|
||||
if tokenIsWriteable {
|
||||
addMsg("- To request missing scopes, run: %s %s\n",
|
||||
utils.Bold("gh auth refresh -h"),
|
||||
utils.Bold(hostname))
|
||||
cs.Bold("gh auth refresh -h"),
|
||||
cs.Bold(hostname))
|
||||
}
|
||||
} else {
|
||||
addMsg("%s %s: authentication failed", utils.Red("X"), hostname)
|
||||
addMsg("- The %s token in %s is no longer valid.", utils.Bold(hostname), tokenSource)
|
||||
addMsg("%s %s: authentication failed", cs.Red("X"), hostname)
|
||||
addMsg("- The %s token in %s is no longer valid.", cs.Bold(hostname), tokenSource)
|
||||
if tokenIsWriteable {
|
||||
addMsg("- To re-authenticate, run: %s %s",
|
||||
utils.Bold("gh auth login -h"), utils.Bold(hostname))
|
||||
cs.Bold("gh auth login -h"), cs.Bold(hostname))
|
||||
addMsg("- To forget about this host, run: %s %s",
|
||||
utils.Bold("gh auth logout -h"), utils.Bold(hostname))
|
||||
cs.Bold("gh auth logout -h"), cs.Bold(hostname))
|
||||
}
|
||||
}
|
||||
failed = true
|
||||
} else {
|
||||
username, err := api.CurrentLoginName(apiClient, hostname)
|
||||
if err != nil {
|
||||
addMsg("%s %s: api call failed: %s", utils.Red("X"), hostname, err)
|
||||
addMsg("%s %s: api call failed: %s", cs.Red("X"), hostname, err)
|
||||
}
|
||||
addMsg("%s Logged in to %s as %s (%s)", utils.GreenCheck(), hostname, utils.Bold(username), tokenSource)
|
||||
addMsg("%s Logged in to %s as %s (%s)", cs.SuccessIcon(), hostname, cs.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))
|
||||
cs.SuccessIcon(), hostname, cs.Bold(proto))
|
||||
}
|
||||
tokenDisplay := "*******************"
|
||||
if opts.ShowToken {
|
||||
tokenDisplay = token
|
||||
}
|
||||
addMsg("%s Token: %s", utils.GreenCheck(), tokenDisplay)
|
||||
addMsg("%s Token: %s", cs.SuccessIcon(), tokenDisplay)
|
||||
}
|
||||
addMsg("")
|
||||
|
||||
|
|
@ -143,7 +144,7 @@ func statusRun(opts *StatusOptions) error {
|
|||
if !ok {
|
||||
continue
|
||||
}
|
||||
fmt.Fprintf(stderr, "%s\n", utils.Bold(hostname))
|
||||
fmt.Fprintf(stderr, "%s\n", cs.Bold(hostname))
|
||||
for _, line := range lines {
|
||||
fmt.Fprintf(stderr, " %s\n", line)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmd/auth/client"
|
||||
"github.com/cli/cli/pkg/cmd/auth/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
|
|
@ -78,7 +78,7 @@ func Test_statusRun(t *testing.T) {
|
|||
opts *StatusOptions
|
||||
httpStubs func(*httpmock.Registry)
|
||||
cfg func(config.Config)
|
||||
wantErr *regexp.Regexp
|
||||
wantErr string
|
||||
wantErrOut *regexp.Regexp
|
||||
}{
|
||||
{
|
||||
|
|
@ -91,7 +91,7 @@ func Test_statusRun(t *testing.T) {
|
|||
_ = c.Set("github.com", "oauth_token", "abc123")
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||
|
|
@ -106,14 +106,14 @@ func Test_statusRun(t *testing.T) {
|
|||
_ = c.Set("github.com", "oauth_token", "abc123")
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,"))
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo"))
|
||||
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(`joel.miller: missing required.*Logged in to github.com as.*tess`),
|
||||
wantErr: regexp.MustCompile(``),
|
||||
wantErr: "SilentError",
|
||||
},
|
||||
{
|
||||
name: "bad token",
|
||||
|
|
@ -124,13 +124,13 @@ func Test_statusRun(t *testing.T) {
|
|||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.StatusStringResponse(400, "no bueno"))
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||
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(`joel.miller: authentication failed.*Logged in to github.com as.*tess`),
|
||||
wantErr: regexp.MustCompile(``),
|
||||
wantErr: "SilentError",
|
||||
},
|
||||
{
|
||||
name: "all good",
|
||||
|
|
@ -140,8 +140,8 @@ func Test_statusRun(t *testing.T) {
|
|||
_ = c.Set("github.com", "oauth_token", "abc123")
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||
|
|
@ -159,8 +159,8 @@ func Test_statusRun(t *testing.T) {
|
|||
_ = c.Set("github.com", "oauth_token", "xyz456")
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||
|
|
@ -180,8 +180,8 @@ func Test_statusRun(t *testing.T) {
|
|||
_ = c.Set("github.com", "oauth_token", "xyz456")
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org,"))
|
||||
reg.Register(httpmock.REST("GET", "api/v3/"), httpmock.ScopesResponder("repo,read:org"))
|
||||
reg.Register(httpmock.REST("GET", ""), httpmock.ScopesResponder("repo,read:org"))
|
||||
reg.Register(
|
||||
httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"tess"}}}`))
|
||||
|
|
@ -217,11 +217,11 @@ func Test_statusRun(t *testing.T) {
|
|||
}
|
||||
|
||||
reg := &httpmock.Registry{}
|
||||
origClientFromCfg := client.ClientFromCfg
|
||||
origClientFromCfg := shared.ClientFromCfg
|
||||
defer func() {
|
||||
client.ClientFromCfg = origClientFromCfg
|
||||
shared.ClientFromCfg = origClientFromCfg
|
||||
}()
|
||||
client.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
|
||||
shared.ClientFromCfg = func(_ string, _ config.Config) (*api.Client, error) {
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
return api.NewClientFromHTTP(httpClient), nil
|
||||
}
|
||||
|
|
@ -236,14 +236,11 @@ func Test_statusRun(t *testing.T) {
|
|||
defer config.StubWriteConfig(&mainBuf, &hostsBuf)()
|
||||
|
||||
err := statusRun(tt.opts)
|
||||
assert.Equal(t, tt.wantErr == nil, err == nil)
|
||||
if err != nil {
|
||||
if tt.wantErr != nil {
|
||||
assert.True(t, tt.wantErr.MatchString(err.Error()))
|
||||
return
|
||||
} else {
|
||||
t.Fatalf("unexpected error: %s", err)
|
||||
}
|
||||
if tt.wantErr != "" {
|
||||
assert.EqualError(t, err, tt.wantErr)
|
||||
return
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
if tt.wantErrOut == nil {
|
||||
|
|
|
|||
|
|
@ -14,22 +14,44 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
|
|||
var shellType string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "completion",
|
||||
Use: "completion -s <shell>",
|
||||
Short: "Generate shell completion scripts",
|
||||
Long: heredoc.Doc(`
|
||||
Long: heredoc.Docf(`
|
||||
Generate shell completion scripts for GitHub CLI commands.
|
||||
|
||||
The output of this command will be computer code and is meant to be saved to a
|
||||
file or immediately evaluated by an interactive shell.
|
||||
|
||||
For example, for bash you could add this to your '~/.bash_profile':
|
||||
|
||||
eval "$(gh completion -s bash)"
|
||||
|
||||
When installing GitHub CLI through a package manager, however, it's possible that
|
||||
When installing GitHub CLI through a package manager, it's possible that
|
||||
no additional shell configuration is necessary to gain completion support. For
|
||||
Homebrew, see https://docs.brew.sh/Shell-Completion
|
||||
`),
|
||||
|
||||
If you need to set up completions manually, follow the instructions below. The exact
|
||||
config file locations might vary based on your system. Make sure to restart your
|
||||
shell before testing whether completions are working.
|
||||
|
||||
### bash
|
||||
|
||||
Add this to your %[1]s~/.bash_profile%[1]s:
|
||||
|
||||
eval "$(gh completion -s bash)"
|
||||
|
||||
### zsh
|
||||
|
||||
Generate a %[1]s_gh%[1]s completion script and put it somewhere in your %[1]s$fpath%[1]s:
|
||||
|
||||
gh completion -s zsh > /usr/local/share/zsh/site-functions/_gh
|
||||
|
||||
Ensure that the following is present in your %[1]s~/.zshrc%[1]s:
|
||||
|
||||
autoload -U compinit
|
||||
compinit -i
|
||||
|
||||
Zsh version 5.7 or later is recommended.
|
||||
|
||||
### fish
|
||||
|
||||
Generate a %[1]sgh.fish%[1]s completion script:
|
||||
|
||||
gh completion -s fish > ~/.config/fish/completions/gh.fish
|
||||
`, "`"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if shellType == "" {
|
||||
if io.IsStdoutTTY() {
|
||||
|
|
@ -54,6 +76,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
|
|||
return fmt.Errorf("unsupported shell type %q", shellType)
|
||||
}
|
||||
},
|
||||
DisableFlagsInUseLine: true,
|
||||
}
|
||||
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
|
|
|||
|
|
@ -1,115 +1,38 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/internal/config"
|
||||
cmdGet "github.com/cli/cli/pkg/cmd/config/get"
|
||||
cmdSet "github.com/cli/cli/pkg/cmd/config/set"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
|
||||
longDoc := strings.Builder{}
|
||||
longDoc.WriteString("Display or change configuration settings for gh.\n\n")
|
||||
longDoc.WriteString("Current respected settings:\n")
|
||||
for _, co := range config.ConfigOptions() {
|
||||
longDoc.WriteString(fmt.Sprintf("- %s: %s", co.Key, co.Description))
|
||||
if co.DefaultValue != "" {
|
||||
longDoc.WriteString(fmt.Sprintf(" (default: %q)", co.DefaultValue))
|
||||
}
|
||||
longDoc.WriteRune('\n')
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "config",
|
||||
Use: "config <command>",
|
||||
Short: "Manage configuration for gh",
|
||||
Long: heredoc.Doc(`
|
||||
Display or change configuration settings for gh.
|
||||
|
||||
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.
|
||||
- pager: terminal pager program to send standard output to.
|
||||
`),
|
||||
Long: longDoc.String(),
|
||||
}
|
||||
|
||||
cmdutil.DisableAuthCheck(cmd)
|
||||
|
||||
cmd.AddCommand(NewCmdConfigGet(f))
|
||||
cmd.AddCommand(NewCmdConfigSet(f))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewCmdConfigGet(f *cmdutil.Factory) *cobra.Command {
|
||||
var hostname string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Short: "Print the value of a given configuration key",
|
||||
Example: heredoc.Doc(`
|
||||
$ gh config get git_protocol
|
||||
https
|
||||
`),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
val, err := cfg.Get(hostname, args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if val != "" {
|
||||
fmt.Fprintf(f.IOStreams.Out, "%s\n", val)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&hostname, "host", "h", "", "Get per-host setting")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func NewCmdConfigSet(f *cmdutil.Factory) *cobra.Command {
|
||||
var hostname string
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "set <key> <value>",
|
||||
Short: "Update configuration with a value for the given key",
|
||||
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 {
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
key, value := args[0], args[1]
|
||||
err = cfg.Set(hostname, key, value)
|
||||
if err != nil {
|
||||
var invalidValue *config.InvalidValueError
|
||||
if errors.As(err, &invalidValue) {
|
||||
var values []string
|
||||
for _, v := range invalidValue.ValidValues {
|
||||
values = append(values, fmt.Sprintf("'%s'", v))
|
||||
}
|
||||
return fmt.Errorf("failed to set %q to %q: valid values are %v", key, value, strings.Join(values, ", "))
|
||||
}
|
||||
return fmt.Errorf("failed to set %q to %q: %w", key, value, err)
|
||||
}
|
||||
|
||||
err = cfg.Write()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write config to disk: %w", err)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&hostname, "host", "h", "", "Set per-host setting")
|
||||
cmd.AddCommand(cmdGet.NewCmdConfigGet(f, nil))
|
||||
cmd.AddCommand(cmdSet.NewCmdConfigSet(f, nil))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,165 +0,0 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
type configStub map[string]string
|
||||
|
||||
func genKey(host, key string) string {
|
||||
if host != "" {
|
||||
return host + ":" + key
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
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, "(memory)", nil
|
||||
}
|
||||
return "", "", errors.New("not found")
|
||||
}
|
||||
|
||||
func (c configStub) Set(host, key, value string) error {
|
||||
c[genKey(host, key)] = value
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c configStub) Aliases() (*config.AliasConfig, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (c configStub) Hosts() ([]string, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
func TestConfigGet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config configStub
|
||||
args []string
|
||||
stdout string
|
||||
stderr string
|
||||
}{
|
||||
{
|
||||
name: "get key",
|
||||
config: configStub{
|
||||
"editor": "ed",
|
||||
},
|
||||
args: []string{"editor"},
|
||||
stdout: "ed\n",
|
||||
stderr: "",
|
||||
},
|
||||
{
|
||||
name: "get key scoped by host",
|
||||
config: configStub{
|
||||
"editor": "ed",
|
||||
"github.com:editor": "vim",
|
||||
},
|
||||
args: []string{"editor", "-h", "github.com"},
|
||||
stdout: "vim\n",
|
||||
stderr: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
io, _, stdout, stderr := iostreams.Test()
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
Config: func() (config.Config, error) {
|
||||
return tt.config, nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd := NewCmdConfigGet(f)
|
||||
cmd.Flags().BoolP("help", "x", false, "")
|
||||
cmd.SetArgs(tt.args)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
|
||||
_, err := cmd.ExecuteC()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.stdout, stdout.String())
|
||||
assert.Equal(t, tt.stderr, stderr.String())
|
||||
assert.Equal(t, "", tt.config["_written"])
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigSet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config configStub
|
||||
args []string
|
||||
expectKey string
|
||||
stdout string
|
||||
stderr string
|
||||
}{
|
||||
{
|
||||
name: "set key",
|
||||
config: configStub{},
|
||||
args: []string{"editor", "vim"},
|
||||
expectKey: "editor",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
},
|
||||
{
|
||||
name: "set key scoped by host",
|
||||
config: configStub{},
|
||||
args: []string{"editor", "vim", "-h", "github.com"},
|
||||
expectKey: "github.com:editor",
|
||||
stdout: "",
|
||||
stderr: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
io, _, stdout, stderr := iostreams.Test()
|
||||
f := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
Config: func() (config.Config, error) {
|
||||
return tt.config, nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd := NewCmdConfigSet(f)
|
||||
cmd.Flags().BoolP("help", "x", false, "")
|
||||
cmd.SetArgs(tt.args)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
|
||||
_, err := cmd.ExecuteC()
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.stdout, stdout.String())
|
||||
assert.Equal(t, tt.stderr, stderr.String())
|
||||
assert.Equal(t, "vim", tt.config[tt.expectKey])
|
||||
assert.Equal(t, "true", tt.config["_written"])
|
||||
})
|
||||
}
|
||||
}
|
||||
65
pkg/cmd/config/get/get.go
Normal file
65
pkg/cmd/config/get/get.go
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
package get
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type GetOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
Config config.Config
|
||||
|
||||
Hostname string
|
||||
Key string
|
||||
}
|
||||
|
||||
func NewCmdConfigGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command {
|
||||
opts := &GetOptions{
|
||||
IO: f.IOStreams,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "get <key>",
|
||||
Short: "Print the value of a given configuration key",
|
||||
Example: heredoc.Doc(`
|
||||
$ gh config get git_protocol
|
||||
https
|
||||
`),
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Config = config
|
||||
opts.Key = args[0]
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return getRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.Hostname, "host", "h", "", "Get per-host setting")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func getRun(opts *GetOptions) error {
|
||||
val, err := opts.Config.Get(opts.Hostname, opts.Key)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if val != "" {
|
||||
fmt.Fprintf(opts.IO.Out, "%s\n", val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
122
pkg/cmd/config/get/get_test.go
Normal file
122
pkg/cmd/config/get/get_test.go
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
package get
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdConfigGet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
output GetOptions
|
||||
wantsErr bool
|
||||
}{
|
||||
{
|
||||
name: "no arguments",
|
||||
input: "",
|
||||
output: GetOptions{},
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "get key",
|
||||
input: "key",
|
||||
output: GetOptions{Key: "key"},
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "get key with host",
|
||||
input: "key --host test.com",
|
||||
output: GetOptions{Hostname: "test.com", Key: "key"},
|
||||
wantsErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
return config.ConfigStub{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
argv, err := shlex.Split(tt.input)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *GetOptions
|
||||
cmd := NewCmdConfigGet(f, func(opts *GetOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.Flags().BoolP("help", "x", false, "")
|
||||
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.output.Hostname, gotOpts.Hostname)
|
||||
assert.Equal(t, tt.output.Key, gotOpts.Key)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *GetOptions
|
||||
stdout string
|
||||
stderr string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "get key",
|
||||
input: &GetOptions{
|
||||
Key: "editor",
|
||||
Config: config.ConfigStub{
|
||||
"editor": "ed",
|
||||
},
|
||||
},
|
||||
stdout: "ed\n",
|
||||
},
|
||||
{
|
||||
name: "get key scoped by host",
|
||||
input: &GetOptions{
|
||||
Hostname: "github.com",
|
||||
Key: "editor",
|
||||
Config: config.ConfigStub{
|
||||
"editor": "ed",
|
||||
"github.com:editor": "vim",
|
||||
},
|
||||
},
|
||||
stdout: "vim\n",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
io, _, stdout, stderr := iostreams.Test()
|
||||
tt.input.IO = io
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := getRun(tt.input)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.stdout, stdout.String())
|
||||
assert.Equal(t, tt.stderr, stderr.String())
|
||||
_, err = tt.input.Config.Get("", "_written")
|
||||
assert.Error(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
90
pkg/cmd/config/set/set.go
Normal file
90
pkg/cmd/config/set/set.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package set
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
type SetOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
Config config.Config
|
||||
|
||||
Key string
|
||||
Value string
|
||||
Hostname string
|
||||
}
|
||||
|
||||
func NewCmdConfigSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
|
||||
opts := &SetOptions{
|
||||
IO: f.IOStreams,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "set <key> <value>",
|
||||
Short: "Update configuration with a value for the given key",
|
||||
Example: heredoc.Doc(`
|
||||
$ gh config set editor vim
|
||||
$ gh config set editor "code --wait"
|
||||
$ gh config set git_protocol ssh --host github.com
|
||||
$ gh config set prompt disabled
|
||||
`),
|
||||
Args: cobra.ExactArgs(2),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
config, err := f.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts.Config = config
|
||||
opts.Key = args[0]
|
||||
opts.Value = args[1]
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return setRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.Hostname, "host", "h", "", "Set per-host setting")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func setRun(opts *SetOptions) error {
|
||||
err := config.ValidateKey(opts.Key)
|
||||
if err != nil {
|
||||
warningIcon := opts.IO.ColorScheme().WarningIcon()
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s warning: '%s' is not a known configuration key\n", warningIcon, opts.Key)
|
||||
}
|
||||
|
||||
err = config.ValidateValue(opts.Key, opts.Value)
|
||||
if err != nil {
|
||||
var invalidValue *config.InvalidValueError
|
||||
if errors.As(err, &invalidValue) {
|
||||
var values []string
|
||||
for _, v := range invalidValue.ValidValues {
|
||||
values = append(values, fmt.Sprintf("'%s'", v))
|
||||
}
|
||||
return fmt.Errorf("failed to set %q to %q: valid values are %v", opts.Key, opts.Value, strings.Join(values, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
err = opts.Config.Set(opts.Hostname, opts.Key, opts.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to set %q to %q: %w", opts.Key, opts.Value, err)
|
||||
}
|
||||
|
||||
err = opts.Config.Write()
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to write config to disk: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
157
pkg/cmd/config/set/set_test.go
Normal file
157
pkg/cmd/config/set/set_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package set
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdConfigSet(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
output SetOptions
|
||||
wantsErr bool
|
||||
}{
|
||||
{
|
||||
name: "no arguments",
|
||||
input: "",
|
||||
output: SetOptions{},
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "no value argument",
|
||||
input: "key",
|
||||
output: SetOptions{},
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "set key value",
|
||||
input: "key value",
|
||||
output: SetOptions{Key: "key", Value: "value"},
|
||||
wantsErr: false,
|
||||
},
|
||||
{
|
||||
name: "set key value with host",
|
||||
input: "key value --host test.com",
|
||||
output: SetOptions{Hostname: "test.com", Key: "key", Value: "value"},
|
||||
wantsErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := &cmdutil.Factory{
|
||||
Config: func() (config.Config, error) {
|
||||
return config.ConfigStub{}, nil
|
||||
},
|
||||
}
|
||||
|
||||
argv, err := shlex.Split(tt.input)
|
||||
assert.NoError(t, err)
|
||||
|
||||
var gotOpts *SetOptions
|
||||
cmd := NewCmdConfigSet(f, func(opts *SetOptions) error {
|
||||
gotOpts = opts
|
||||
return nil
|
||||
})
|
||||
cmd.Flags().BoolP("help", "x", false, "")
|
||||
|
||||
cmd.SetArgs(argv)
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(&bytes.Buffer{})
|
||||
cmd.SetErr(&bytes.Buffer{})
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.output.Hostname, gotOpts.Hostname)
|
||||
assert.Equal(t, tt.output.Key, gotOpts.Key)
|
||||
assert.Equal(t, tt.output.Value, gotOpts.Value)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_setRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *SetOptions
|
||||
expectedValue string
|
||||
stdout string
|
||||
stderr string
|
||||
wantsErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "set key value",
|
||||
input: &SetOptions{
|
||||
Config: config.ConfigStub{},
|
||||
Key: "editor",
|
||||
Value: "vim",
|
||||
},
|
||||
expectedValue: "vim",
|
||||
},
|
||||
{
|
||||
name: "set key value scoped by host",
|
||||
input: &SetOptions{
|
||||
Config: config.ConfigStub{},
|
||||
Hostname: "github.com",
|
||||
Key: "editor",
|
||||
Value: "vim",
|
||||
},
|
||||
expectedValue: "vim",
|
||||
},
|
||||
{
|
||||
name: "set unknown key",
|
||||
input: &SetOptions{
|
||||
Config: config.ConfigStub{},
|
||||
Key: "unknownKey",
|
||||
Value: "someValue",
|
||||
},
|
||||
expectedValue: "someValue",
|
||||
stderr: "! warning: 'unknownKey' is not a known configuration key\n",
|
||||
},
|
||||
{
|
||||
name: "set invalid value",
|
||||
input: &SetOptions{
|
||||
Config: config.ConfigStub{},
|
||||
Key: "git_protocol",
|
||||
Value: "invalid",
|
||||
},
|
||||
wantsErr: true,
|
||||
errMsg: "failed to set \"git_protocol\" to \"invalid\": valid values are 'https', 'ssh'",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
io, _, stdout, stderr := iostreams.Test()
|
||||
tt.input.IO = io
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := setRun(tt.input)
|
||||
if tt.wantsErr {
|
||||
assert.EqualError(t, err, tt.errMsg)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.stdout, stdout.String())
|
||||
assert.Equal(t, tt.stderr, stderr.String())
|
||||
|
||||
val, err := tt.input.Config.Get(tt.input.Hostname, tt.input.Key)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.expectedValue, val)
|
||||
|
||||
val, err = tt.input.Config.Get("", "_written")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "true", val)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -5,9 +5,11 @@ import (
|
|||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/git"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
|
|
@ -31,11 +33,16 @@ func New(appVersion string) *cmdutil.Factory {
|
|||
return cachedConfig, configError
|
||||
}
|
||||
|
||||
hostOverride := ""
|
||||
if !strings.EqualFold(ghinstance.Default(), ghinstance.OverridableDefault()) {
|
||||
hostOverride = ghinstance.OverridableDefault()
|
||||
}
|
||||
|
||||
rr := &remoteResolver{
|
||||
readRemotes: git.Remotes,
|
||||
getConfig: configFunc,
|
||||
}
|
||||
remotesFunc := rr.Resolver()
|
||||
remotesFunc := rr.Resolver(hostOverride)
|
||||
|
||||
return &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/config"
|
||||
|
|
@ -12,6 +13,46 @@ import (
|
|||
"github.com/cli/cli/pkg/iostreams"
|
||||
)
|
||||
|
||||
var timezoneNames = map[int]string{
|
||||
-39600: "Pacific/Niue",
|
||||
-36000: "Pacific/Honolulu",
|
||||
-34200: "Pacific/Marquesas",
|
||||
-32400: "America/Anchorage",
|
||||
-28800: "America/Los_Angeles",
|
||||
-25200: "America/Chihuahua",
|
||||
-21600: "America/Chicago",
|
||||
-18000: "America/Bogota",
|
||||
-14400: "America/Caracas",
|
||||
-12600: "America/St_Johns",
|
||||
-10800: "America/Argentina/Buenos_Aires",
|
||||
-7200: "Atlantic/South_Georgia",
|
||||
-3600: "Atlantic/Cape_Verde",
|
||||
0: "Europe/London",
|
||||
3600: "Europe/Amsterdam",
|
||||
7200: "Europe/Athens",
|
||||
10800: "Europe/Istanbul",
|
||||
12600: "Asia/Tehran",
|
||||
14400: "Asia/Dubai",
|
||||
16200: "Asia/Kabul",
|
||||
18000: "Asia/Tashkent",
|
||||
19800: "Asia/Kolkata",
|
||||
20700: "Asia/Kathmandu",
|
||||
21600: "Asia/Dhaka",
|
||||
23400: "Asia/Rangoon",
|
||||
25200: "Asia/Bangkok",
|
||||
28800: "Asia/Manila",
|
||||
31500: "Australia/Eucla",
|
||||
32400: "Asia/Tokyo",
|
||||
34200: "Australia/Darwin",
|
||||
36000: "Australia/Brisbane",
|
||||
37800: "Australia/Adelaide",
|
||||
39600: "Pacific/Guadalcanal",
|
||||
43200: "Pacific/Nauru",
|
||||
46800: "Pacific/Auckland",
|
||||
49500: "Pacific/Chatham",
|
||||
50400: "Pacific/Kiritimati",
|
||||
}
|
||||
|
||||
// generic authenticated HTTP client for commands
|
||||
func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, setAccept bool) *http.Client {
|
||||
var opts []api.ClientOption
|
||||
|
|
@ -29,6 +70,16 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string
|
|||
}
|
||||
return "", nil
|
||||
}),
|
||||
api.AddHeaderFunc("Time-Zone", func(req *http.Request) (string, error) {
|
||||
if req.Method != "GET" && req.Method != "HEAD" {
|
||||
if time.Local.String() != "Local" {
|
||||
return time.Local.String(), nil
|
||||
}
|
||||
_, offset := time.Now().Zone()
|
||||
return timezoneNames[offset], nil
|
||||
}
|
||||
return "", nil
|
||||
}),
|
||||
)
|
||||
|
||||
if setAccept {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"errors"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/context"
|
||||
"github.com/cli/cli/git"
|
||||
|
|
@ -17,7 +18,7 @@ type remoteResolver struct {
|
|||
urlTranslator func(*url.URL) *url.URL
|
||||
}
|
||||
|
||||
func (rr *remoteResolver) Resolver() func() (context.Remotes, error) {
|
||||
func (rr *remoteResolver) Resolver(hostOverride string) func() (context.Remotes, error) {
|
||||
var cachedRemotes context.Remotes
|
||||
var remotesError error
|
||||
|
||||
|
|
@ -59,6 +60,22 @@ func (rr *remoteResolver) Resolver() func() (context.Remotes, error) {
|
|||
var hostname string
|
||||
cachedRemotes = context.Remotes{}
|
||||
sort.Sort(resolvedRemotes)
|
||||
|
||||
if hostOverride != "" {
|
||||
for _, r := range resolvedRemotes {
|
||||
if strings.EqualFold(r.RepoHost(), hostOverride) {
|
||||
cachedRemotes = append(cachedRemotes, r)
|
||||
}
|
||||
}
|
||||
|
||||
if len(cachedRemotes) == 0 {
|
||||
remotesError = errors.New("none of the git remotes configured for this repository correspond to the GH_HOST environment variable. Try adding a matching remote or unsetting the variable.")
|
||||
return nil, remotesError
|
||||
}
|
||||
|
||||
return cachedRemotes, nil
|
||||
}
|
||||
|
||||
for _, r := range resolvedRemotes {
|
||||
if hostname == "" {
|
||||
if !knownHosts[r.RepoHost()] {
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ func Test_remoteResolver(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
resolver := rr.Resolver()
|
||||
resolver := rr.Resolver("")
|
||||
remotes, err := resolver()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 2, len(remotes))
|
||||
|
|
@ -40,3 +40,32 @@ func Test_remoteResolver(t *testing.T) {
|
|||
assert.Equal(t, "upstream", remotes[0].Name)
|
||||
assert.Equal(t, "fork", remotes[1].Name)
|
||||
}
|
||||
|
||||
func Test_remoteResolverOverride(t *testing.T) {
|
||||
rr := &remoteResolver{
|
||||
readRemotes: func() (git.RemoteSet, error) {
|
||||
return git.RemoteSet{
|
||||
git.NewRemote("fork", "https://example.org/ghe-owner/ghe-fork.git"),
|
||||
git.NewRemote("origin", "https://github.com/owner/repo.git"),
|
||||
git.NewRemote("upstream", "https://example.org/ghe-owner/ghe-repo.git"),
|
||||
}, nil
|
||||
},
|
||||
getConfig: func() (config.Config, error) {
|
||||
return config.NewFromString(heredoc.Doc(`
|
||||
hosts:
|
||||
example.org:
|
||||
oauth_token: GHETOKEN
|
||||
`)), nil
|
||||
},
|
||||
urlTranslator: func(u *url.URL) *url.URL {
|
||||
return u
|
||||
},
|
||||
}
|
||||
|
||||
resolver := rr.Resolver("github.com")
|
||||
remotes, err := resolver()
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, 1, len(remotes))
|
||||
|
||||
assert.Equal(t, "origin", remotes[0].Name)
|
||||
}
|
||||
|
|
|
|||
101
pkg/cmd/gist/clone/clone.go
Normal file
101
pkg/cmd/gist/clone/clone.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package clone
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/git"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
type CloneOptions struct {
|
||||
HttpClient func() (*http.Client, error)
|
||||
Config func() (config.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
|
||||
GitArgs []string
|
||||
Directory string
|
||||
Gist string
|
||||
}
|
||||
|
||||
func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Command {
|
||||
opts := &CloneOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
Config: f.Config,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
DisableFlagsInUseLine: true,
|
||||
|
||||
Use: "clone <gist> [<directory>] [-- <gitflags>...]",
|
||||
Args: cmdutil.MinimumArgs(1, "cannot clone: gist argument required"),
|
||||
Short: "Clone a gist locally",
|
||||
Long: heredoc.Doc(`
|
||||
Clone a GitHub gist locally.
|
||||
|
||||
A gist can be supplied as argument in either of the following formats:
|
||||
- by ID, e.g. 5b0e0062eb8e9654adad7bb1d81cc75f
|
||||
- by URL, e.g. "https://gist.github.com/OWNER/5b0e0062eb8e9654adad7bb1d81cc75f"
|
||||
|
||||
Pass additional 'git clone' flags by listing them after '--'.
|
||||
`),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
opts.Gist = args[0]
|
||||
opts.GitArgs = args[1:]
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
||||
return cloneRun(opts)
|
||||
},
|
||||
}
|
||||
|
||||
cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
|
||||
if err == pflag.ErrHelp {
|
||||
return err
|
||||
}
|
||||
return &cmdutil.FlagError{Err: fmt.Errorf("%w\nSeparate git clone flags with '--'.", err)}
|
||||
})
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func cloneRun(opts *CloneOptions) error {
|
||||
gistURL := opts.Gist
|
||||
|
||||
if !git.IsURL(gistURL) {
|
||||
cfg, err := opts.Config()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
hostname := ghinstance.OverridableDefault()
|
||||
protocol, err := cfg.Get(hostname, "git_protocol")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gistURL = formatRemoteURL(hostname, gistURL, protocol)
|
||||
}
|
||||
|
||||
_, err := git.RunClone(gistURL, opts.GitArgs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatRemoteURL(hostname string, gistID string, protocol string) string {
|
||||
if protocol == "ssh" {
|
||||
return fmt.Sprintf("git@gist.%s:%s.git", hostname, gistID)
|
||||
}
|
||||
|
||||
return fmt.Sprintf("https://gist.%s/%s.git", hostname, gistID)
|
||||
}
|
||||
118
pkg/cmd/gist/clone/clone_test.go
Normal file
118
pkg/cmd/gist/clone/clone_test.go
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
package clone
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/test"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) {
|
||||
io, stdin, stdout, stderr := iostreams.Test()
|
||||
fac := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
return httpClient, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd := NewCmdClone(fac, nil)
|
||||
|
||||
argv, err := shlex.Split(cli)
|
||||
cmd.SetArgs(argv)
|
||||
|
||||
cmd.SetIn(stdin)
|
||||
cmd.SetOut(stdout)
|
||||
cmd.SetErr(stderr)
|
||||
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &test.CmdOut{OutBuf: stdout, ErrBuf: stderr}, nil
|
||||
}
|
||||
|
||||
func Test_GistClone(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args string
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "shorthand",
|
||||
args: "GIST",
|
||||
want: "git clone https://gist.github.com/GIST.git",
|
||||
},
|
||||
{
|
||||
name: "shorthand with directory",
|
||||
args: "GIST target_directory",
|
||||
want: "git clone https://gist.github.com/GIST.git target_directory",
|
||||
},
|
||||
{
|
||||
name: "clone arguments",
|
||||
args: "GIST -- -o upstream --depth 1",
|
||||
want: "git clone -o upstream --depth 1 https://gist.github.com/GIST.git",
|
||||
},
|
||||
{
|
||||
name: "clone arguments with directory",
|
||||
args: "GIST target_directory -- -o upstream --depth 1",
|
||||
want: "git clone -o upstream --depth 1 https://gist.github.com/GIST.git target_directory",
|
||||
},
|
||||
{
|
||||
name: "HTTPS URL",
|
||||
args: "https://gist.github.com/OWNER/GIST",
|
||||
want: "git clone https://gist.github.com/OWNER/GIST",
|
||||
},
|
||||
{
|
||||
name: "SSH URL",
|
||||
args: "git@gist.github.com:GIST.git",
|
||||
want: "git clone git@gist.github.com:GIST.git",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
|
||||
httpClient := &http.Client{Transport: reg}
|
||||
|
||||
cs, restore := test.InitCmdStubber()
|
||||
defer restore()
|
||||
|
||||
cs.Stub("") // git clone
|
||||
|
||||
output, err := runCloneCommand(httpClient, tt.args)
|
||||
if err != nil {
|
||||
t.Fatalf("error running command `gist clone`: %v", err)
|
||||
}
|
||||
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
assert.Equal(t, 1, cs.Count)
|
||||
assert.Equal(t, tt.want, strings.Join(cs.Calls[0].Args, " "))
|
||||
reg.Verify(t)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_GistClone_flagError(t *testing.T) {
|
||||
_, err := runCloneCommand(nil, "--depth 1 GIST")
|
||||
if err == nil || err.Error() != "unknown flag: --depth\nSeparate git clone flags with '--'." {
|
||||
t.Errorf("unexpected error %v", err)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,8 @@ type CreateOptions struct {
|
|||
Filenames []string
|
||||
FilenameOverride string
|
||||
|
||||
WebMode bool
|
||||
|
||||
HttpClient func() (*http.Client, error)
|
||||
}
|
||||
|
||||
|
|
@ -87,6 +89,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
|
|||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.Description, "desc", "d", "", "A description for this gist")
|
||||
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser with created gist")
|
||||
cmd.Flags().BoolVarP(&opts.Public, "public", "p", false, "List the gist publicly (default: private)")
|
||||
cmd.Flags().StringVarP(&opts.FilenameOverride, "filename", "f", "", "Provide a filename to be used when reading from STDIN")
|
||||
return cmd
|
||||
|
|
@ -116,8 +119,10 @@ func createRun(opts *CreateOptions) error {
|
|||
completionMessage = fmt.Sprintf("Created gist %s", gistName)
|
||||
}
|
||||
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
||||
errOut := opts.IO.ErrOut
|
||||
fmt.Fprintf(errOut, "%s %s\n", utils.Gray("-"), processMessage)
|
||||
fmt.Fprintf(errOut, "%s %s\n", cs.Gray("-"), processMessage)
|
||||
|
||||
httpClient, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
|
|
@ -132,10 +137,16 @@ func createRun(opts *CreateOptions) error {
|
|||
return fmt.Errorf("This command requires the 'gist' OAuth scope.\nPlease re-authenticate by doing `gh config set -h github.com oauth_token ''` and running the command again.")
|
||||
}
|
||||
}
|
||||
return fmt.Errorf("%s Failed to create gist: %w", utils.Red("X"), err)
|
||||
return fmt.Errorf("%s Failed to create gist: %w", cs.Red("X"), err)
|
||||
}
|
||||
|
||||
fmt.Fprintf(errOut, "%s %s\n", utils.Green("✓"), completionMessage)
|
||||
fmt.Fprintf(errOut, "%s %s\n", cs.SuccessIcon(), completionMessage)
|
||||
|
||||
if opts.WebMode {
|
||||
fmt.Fprintf(opts.IO.Out, "Opening %s in your browser.\n", utils.DisplayURL(gist.HTMLURL))
|
||||
|
||||
return utils.OpenInBrowser(gist.HTMLURL)
|
||||
}
|
||||
|
||||
fmt.Fprintln(opts.IO.Out, gist.HTMLURL)
|
||||
|
||||
|
|
@ -216,8 +227,8 @@ func createGist(client *http.Client, hostname, description string, public bool,
|
|||
}
|
||||
requestBody := bytes.NewReader(requestByte)
|
||||
|
||||
apliClient := api.NewClientFromHTTP(client)
|
||||
err = apliClient.REST(hostname, "POST", path, requestBody, &result)
|
||||
apiClient := api.NewClientFromHTTP(client)
|
||||
err = apiClient.REST(hostname, "POST", path, requestBody, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package create
|
|||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"github.com/cli/cli/test"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
|
@ -254,6 +255,26 @@ func Test_createRun(t *testing.T) {
|
|||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "web arg",
|
||||
opts: &CreateOptions{
|
||||
WebMode: true,
|
||||
Filenames: []string{fixtureFile},
|
||||
},
|
||||
wantOut: "Opening gist.github.com/aa5a315d61ae9438b18d in your browser.\n",
|
||||
wantStderr: "- Creating gist fixture.txt\n✓ Created gist fixture.txt\n",
|
||||
wantErr: false,
|
||||
wantParams: map[string]interface{}{
|
||||
"description": "",
|
||||
"updated_at": "0001-01-01T00:00:00Z",
|
||||
"public": false,
|
||||
"files": map[string]interface{}{
|
||||
"fixture.txt": map[string]interface{}{
|
||||
"content": "{}",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
reg := &httpmock.Registry{}
|
||||
|
|
@ -270,6 +291,13 @@ func Test_createRun(t *testing.T) {
|
|||
io, stdin, stdout, stderr := iostreams.Test()
|
||||
tt.opts.IO = io
|
||||
|
||||
cs, cmdTeardown := test.InitCmdStubber()
|
||||
defer cmdTeardown()
|
||||
|
||||
if tt.opts.WebMode {
|
||||
cs.Stub("")
|
||||
}
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stdin.WriteString(tt.stdin)
|
||||
|
||||
|
|
@ -285,6 +313,12 @@ func Test_createRun(t *testing.T) {
|
|||
assert.Equal(t, tt.wantOut, stdout.String())
|
||||
assert.Equal(t, tt.wantStderr, stderr.String())
|
||||
assert.Equal(t, tt.wantParams, reqBody)
|
||||
|
||||
if tt.opts.WebMode {
|
||||
browserCall := cs.Calls[0].Args
|
||||
assert.Equal(t, browserCall[len(browserCall)-1], "https://gist.github.com/aa5a315d61ae9438b18d")
|
||||
}
|
||||
|
||||
reg.Verify(t)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
88
pkg/cmd/gist/delete/delete.go
Normal file
88
pkg/cmd/gist/delete/delete.go
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
package delete
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"github.com/cli/cli/pkg/cmd/gist/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type DeleteOptions struct {
|
||||
IO *iostreams.IOStreams
|
||||
HttpClient func() (*http.Client, error)
|
||||
|
||||
Selector string
|
||||
}
|
||||
|
||||
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
|
||||
opts := DeleteOptions{
|
||||
IO: f.IOStreams,
|
||||
HttpClient: f.HttpClient,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Use: "delete {<gist ID> | <gist URL>}",
|
||||
Short: "Delete a gist",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(c *cobra.Command, args []string) error {
|
||||
opts.Selector = args[0]
|
||||
if runF != nil {
|
||||
return runF(&opts)
|
||||
}
|
||||
return deleteRun(&opts)
|
||||
},
|
||||
}
|
||||
return cmd
|
||||
}
|
||||
|
||||
func deleteRun(opts *DeleteOptions) error {
|
||||
gistID := opts.Selector
|
||||
|
||||
if strings.Contains(gistID, "/") {
|
||||
id, err := shared.GistIDFromURL(gistID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
gistID = id
|
||||
}
|
||||
client, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
apiClient := api.NewClientFromHTTP(client)
|
||||
|
||||
gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
username, err := api.CurrentLoginName(apiClient, ghinstance.OverridableDefault())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if username != gist.Owner.Login {
|
||||
return fmt.Errorf("You do not own this gist.")
|
||||
}
|
||||
|
||||
err = deleteGist(apiClient, ghinstance.OverridableDefault(), gistID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func deleteGist(apiClient *api.Client, hostname string, gistID string) error {
|
||||
path := "gists/" + gistID
|
||||
err := apiClient.REST(hostname, "DELETE", path, nil, nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
157
pkg/cmd/gist/delete/delete_test.go
Normal file
157
pkg/cmd/gist/delete/delete_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
package delete
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/pkg/cmd/gist/shared"
|
||||
"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/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewCmdDelete(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cli string
|
||||
wants DeleteOptions
|
||||
}{
|
||||
{
|
||||
name: "valid selector",
|
||||
cli: "123",
|
||||
wants: DeleteOptions{
|
||||
Selector: "123",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := &cmdutil.Factory{}
|
||||
|
||||
argv, err := shlex.Split(tt.cli)
|
||||
assert.NoError(t, err)
|
||||
var gotOpts *DeleteOptions
|
||||
cmd := NewCmdDelete(f, func(opts *DeleteOptions) 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.Selector, gotOpts.Selector)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_deleteRun(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
opts *DeleteOptions
|
||||
gist *shared.Gist
|
||||
httpStubs func(*httpmock.Registry)
|
||||
askStubs func(*prompt.AskStubber)
|
||||
nontty bool
|
||||
wantErr bool
|
||||
wantStderr string
|
||||
wantParams map[string]interface{}
|
||||
}{
|
||||
{
|
||||
name: "no such gist",
|
||||
wantErr: true,
|
||||
}, {
|
||||
name: "another user's gist",
|
||||
gist: &shared.Gist{
|
||||
ID: "1234",
|
||||
Files: map[string]*shared.GistFile{
|
||||
"cicada.txt": {
|
||||
Filename: "cicada.txt",
|
||||
Content: "bwhiizzzbwhuiiizzzz",
|
||||
Type: "text/plain",
|
||||
},
|
||||
},
|
||||
Owner: &shared.GistOwner{Login: "octocat2"},
|
||||
},
|
||||
wantErr: true,
|
||||
wantStderr: "You do not own this gist.",
|
||||
}, {
|
||||
name: "successfully delete",
|
||||
gist: &shared.Gist{
|
||||
ID: "1234",
|
||||
Files: map[string]*shared.GistFile{
|
||||
"cicada.txt": {
|
||||
Filename: "cicada.txt",
|
||||
Content: "bwhiizzzbwhuiiizzzz",
|
||||
Type: "text/plain",
|
||||
},
|
||||
},
|
||||
Owner: &shared.GistOwner{Login: "octocat"},
|
||||
},
|
||||
httpStubs: func(reg *httpmock.Registry) {
|
||||
reg.Register(httpmock.REST("DELETE", "gists/1234"),
|
||||
httpmock.StringResponse("{}"))
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
reg := &httpmock.Registry{}
|
||||
if tt.gist == nil {
|
||||
reg.Register(httpmock.REST("GET", "gists/1234"),
|
||||
httpmock.StatusStringResponse(404, "Not Found"))
|
||||
} else {
|
||||
reg.Register(httpmock.REST("GET", "gists/1234"),
|
||||
httpmock.JSONResponse(tt.gist))
|
||||
reg.Register(httpmock.GraphQL(`query UserCurrent\b`),
|
||||
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
|
||||
}
|
||||
|
||||
if tt.httpStubs != nil {
|
||||
tt.httpStubs(reg)
|
||||
}
|
||||
|
||||
as, teardown := prompt.InitAskStubber()
|
||||
defer teardown()
|
||||
if tt.askStubs != nil {
|
||||
tt.askStubs(as)
|
||||
}
|
||||
|
||||
if tt.opts == nil {
|
||||
tt.opts = &DeleteOptions{}
|
||||
}
|
||||
|
||||
tt.opts.HttpClient = func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
}
|
||||
io, _, _, _ := iostreams.Test()
|
||||
io.SetStdoutTTY(!tt.nontty)
|
||||
io.SetStdinTTY(!tt.nontty)
|
||||
tt.opts.IO = io
|
||||
tt.opts.Selector = "1234"
|
||||
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := deleteRun(tt.opts)
|
||||
reg.Verify(t)
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
if tt.wantStderr != "" {
|
||||
assert.EqualError(t, err, tt.wantStderr)
|
||||
}
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -2,7 +2,9 @@ package gist
|
|||
|
||||
import (
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
gistCloneCmd "github.com/cli/cli/pkg/cmd/gist/clone"
|
||||
gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create"
|
||||
gistDeleteCmd "github.com/cli/cli/pkg/cmd/gist/delete"
|
||||
gistEditCmd "github.com/cli/cli/pkg/cmd/gist/edit"
|
||||
gistListCmd "github.com/cli/cli/pkg/cmd/gist/list"
|
||||
gistViewCmd "github.com/cli/cli/pkg/cmd/gist/view"
|
||||
|
|
@ -12,7 +14,7 @@ import (
|
|||
|
||||
func NewCmdGist(f *cmdutil.Factory) *cobra.Command {
|
||||
cmd := &cobra.Command{
|
||||
Use: "gist",
|
||||
Use: "gist <command>",
|
||||
Short: "Manage gists",
|
||||
Long: `Work with GitHub gists.`,
|
||||
Annotations: map[string]string{
|
||||
|
|
@ -25,10 +27,12 @@ func NewCmdGist(f *cmdutil.Factory) *cobra.Command {
|
|||
},
|
||||
}
|
||||
|
||||
cmd.AddCommand(gistCloneCmd.NewCmdClone(f, nil))
|
||||
cmd.AddCommand(gistCreateCmd.NewCmdCreate(f, nil))
|
||||
cmd.AddCommand(gistListCmd.NewCmdList(f, nil))
|
||||
cmd.AddCommand(gistViewCmd.NewCmdView(f, nil))
|
||||
cmd.AddCommand(gistEditCmd.NewCmdEdit(f, nil))
|
||||
cmd.AddCommand(gistDeleteCmd.NewCmdDelete(f, nil))
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ func viewRun(opts *ViewOptions) error {
|
|||
content := gistFile.Content
|
||||
if strings.Contains(gistFile.Type, "markdown") && !opts.Raw {
|
||||
style := markdown.GetStyle(opts.IO.DetectTerminalTheme())
|
||||
rendered, err := markdown.Render(gistFile.Content, style)
|
||||
rendered, err := markdown.Render(gistFile.Content, style, "")
|
||||
if err == nil {
|
||||
content = rendered
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,7 +10,6 @@ import (
|
|||
"github.com/cli/cli/pkg/cmd/issue/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/utils"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
|
|
@ -53,6 +52,8 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
|
|||
}
|
||||
|
||||
func closeRun(opts *CloseOptions) error {
|
||||
cs := opts.IO.ColorScheme()
|
||||
|
||||
httpClient, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return err
|
||||
|
|
@ -65,7 +66,7 @@ func closeRun(opts *CloseOptions) error {
|
|||
}
|
||||
|
||||
if issue.Closed {
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already closed\n", utils.Yellow("!"), issue.Number, issue.Title)
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already closed\n", cs.Yellow("!"), issue.Number, issue.Title)
|
||||
return nil
|
||||
}
|
||||
|
||||
|
|
@ -74,7 +75,7 @@ func closeRun(opts *CloseOptions) error {
|
|||
return err
|
||||
}
|
||||
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Closed issue #%d (%s)\n", utils.Red("✔"), issue.Number, issue.Title)
|
||||
fmt.Fprintf(opts.IO.ErrOut, "%s Closed issue #%d (%s)\n", cs.Red("✔"), issue.Number, issue.Title)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/test"
|
||||
"github.com/google/shlex"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
|
||||
|
|
@ -58,14 +59,21 @@ func TestIssueClose(t *testing.T) {
|
|||
http := &httpmock.Registry{}
|
||||
defer http.Verify(t)
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issue": { "number": 13, "title": "The title of the issue"}
|
||||
} } }
|
||||
`))
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"}
|
||||
} } }`),
|
||||
)
|
||||
http.Register(
|
||||
httpmock.GraphQL(`mutation IssueClose\b`),
|
||||
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
|
||||
func(inputs map[string]interface{}) {
|
||||
assert.Equal(t, inputs["issueId"], "THE-ID")
|
||||
}),
|
||||
)
|
||||
|
||||
output, err := runCommand(http, true, "13")
|
||||
if err != nil {
|
||||
|
|
@ -83,12 +91,14 @@ func TestIssueClose_alreadyClosed(t *testing.T) {
|
|||
http := &httpmock.Registry{}
|
||||
defer http.Verify(t)
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issue": { "number": 13, "title": "The title of the issue", "closed": true}
|
||||
} } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issue": { "number": 13, "title": "The title of the issue", "closed": true}
|
||||
} } }`),
|
||||
)
|
||||
|
||||
output, err := runCommand(http, true, "13")
|
||||
if err != nil {
|
||||
|
|
@ -106,11 +116,13 @@ func TestIssueClose_issuesDisabled(t *testing.T) {
|
|||
http := &httpmock.Registry{}
|
||||
defer http.Verify(t)
|
||||
|
||||
http.StubResponse(200, bytes.NewBufferString(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": false
|
||||
} } }
|
||||
`))
|
||||
http.Register(
|
||||
httpmock.GraphQL(`query IssueByNumber\b`),
|
||||
httpmock.StringResponse(`
|
||||
{ "data": { "repository": {
|
||||
"hasIssuesEnabled": false
|
||||
} } }`),
|
||||
)
|
||||
|
||||
_, err := runCommand(http, true, "13")
|
||||
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
|
||||
|
|
|
|||
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