diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index c15d1e8b0..56f1248d6 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -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
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index 28d17464b..1bf4d7a72 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -2,6 +2,7 @@ name: Code Scanning
on:
push:
+ pull_request:
schedule:
- cron: "0 0 * * 0"
diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml
index 83fa87b8e..1d9613f04 100644
--- a/.github/workflows/go.yml
+++ b/.github/workflows/go.yml
@@ -39,4 +39,6 @@ jobs:
uses: actions/checkout@v2
- name: Build
+ env:
+ CGO_ENABLED: '0'
run: go build -v ./cmd/gh
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 4dc95a4f1..3ba3b3f25 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -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
diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
index bd1a7643b..3af2d090e 100644
--- a/.github/workflows/releases.yml
+++ b/.github/workflows/releases.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 00a5bb5a6..905701534 100644
--- a/.gitignore
+++ b/.gitignore
@@ -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/
diff --git a/.goreleaser.yml b/.goreleaser.yml
index c34aff15a..2f6d16f32 100644
--- a/.goreleaser.yml
+++ b/.goreleaser.yml
@@ -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
diff --git a/Makefile b/Makefile
index ad16069ee..3dfc08a0e 100644
--- a/Makefile
+++ b/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
diff --git a/README.md b/README.md
index e327f5490..0c2ea4f48 100644
--- a/README.md
+++ b/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` |
+
+WinGet does not have a specialized `upgrade` command yet, but the `install` command should work for upgrading to a newer version of GitHub CLI.
#### 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
diff --git a/api/cache.go b/api/cache.go
index 620660c15..1f6d8896b 100644
--- a/api/cache.go
+++ b/api/cache.go
@@ -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,
diff --git a/api/cache_test.go b/api/cache_test.go
index d1039d71b..f4a6a756e 100644
--- a/api/cache_test.go
+++ b/api/cache_test.go
@@ -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)
diff --git a/api/client.go b/api/client.go
index d4a32d87d..09195181b 100644
--- a/api/client.go
+++ b/api/client.go
@@ -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
}
diff --git a/api/client_test.go b/api/client_test.go
index d2925bd20..8edf279ea 100644
--- a/api/client_test.go
+++ b/api/client_test.go
@@ -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)
}
})
}
+
}
diff --git a/api/pull_request_test.go b/api/pull_request_test.go
index 609a27e7c..9fb1d9e72 100644
--- a/api/pull_request_test.go
+++ b/api/pull_request_test.go
@@ -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)
}
diff --git a/api/queries_comments.go b/api/queries_comments.go
new file mode 100644
index 000000000..16eba6152
--- /dev/null
+++ b/api/queries_comments.go
@@ -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 ""
+}
diff --git a/api/queries_issue.go b/api/queries_issue.go
index 690180feb..dca859831 100644
--- a/api/queries_issue.go
+++ b/api/queries_issue.go
@@ -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
+}
diff --git a/api/queries_issue_test.go b/api/queries_issue_test.go
index 83dee55b7..e6aca1907 100644
--- a/api/queries_issue_test.go
+++ b/api/queries_issue_test.go
@@ -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, "", "", "")
diff --git a/api/queries_pr.go b/api/queries_pr.go
index 34011d5be..309091000 100644
--- a/api/queries_pr.go
+++ b/api/queries_pr.go
@@ -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 {
diff --git a/api/queries_pr_review.go b/api/queries_pr_review.go
new file mode 100644
index 000000000..7378db111
--- /dev/null
+++ b/api/queries_pr_review.go
@@ -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
+}
diff --git a/api/queries_pr_test.go b/api/queries_pr_test.go
index e624b61b3..4e0c1581e 100644
--- a/api/queries_pr_test.go
+++ b/api/queries_pr_test.go
@@ -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")
+ }
+}
diff --git a/api/queries_repo.go b/api/queries_repo.go
index 54f88a7e1..1ca7a92f8 100644
--- a/api/queries_repo.go
+++ b/api/queries_repo.go
@@ -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
diff --git a/api/reaction_groups.go b/api/reaction_groups.go
new file mode 100644
index 000000000..849fe4b36
--- /dev/null
+++ b/api/reaction_groups.go
@@ -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
+ }
+ }`
+}
diff --git a/api/reaction_groups_test.go b/api/reaction_groups_test.go
new file mode 100644
index 000000000..e30a9e1f8
--- /dev/null
+++ b/api/reaction_groups_test.go
@@ -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())
+ })
+ }
+}
diff --git a/auth/oauth.go b/auth/oauth.go
deleted file mode 100644
index 2c8a78cd4..000000000
--- a/auth/oauth.go
+++ /dev/null
@@ -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, "
You have successfully authenticated. You may now close this page.
")
- }
- }))
-
- 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...)
-}
diff --git a/auth/oauth_test.go b/auth/oauth_test.go
deleted file mode 100644
index a9070a1b1..000000000
--- a/auth/oauth_test.go
+++ /dev/null
@@ -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)
- }
- })
- }
-}
diff --git a/cmd/gen-docs/main.go b/cmd/gen-docs/main.go
index 631185393..5698794e0 100644
--- a/cmd/gen-docs/main.go
+++ b/cmd/gen-docs/main.go
@@ -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)
}
diff --git a/cmd/gh/main.go b/cmd/gh/main.go
index c393a627e..f1e72f1b5 100644
--- a/cmd/gh/main.go
+++ b/cmd/gh/main.go
@@ -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)
}
diff --git a/context/remote_test.go b/context/remote_test.go
index ab3f7e2e2..de9f21901 100644
--- a/context/remote_test.go
+++ b/context/remote_test.go
@@ -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) {
diff --git a/docs/install_linux.md b/docs/install_linux.md
index c7a49d967..d51f35d09 100644
--- a/docs/install_linux.md
+++ b/docs/install_linux.md
@@ -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
diff --git a/docs/releasing.md b/docs/releasing.md
index 442e68c3a..3c583a80b 100644
--- a/docs/releasing.md
+++ b/docs/releasing.md
@@ -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:
-3. Check
+3. Verify release is displayed and has correct assets:
4. Scan generated release notes and optionally add a human touch by grouping items under topic sections
5. Verify the marketing site was updated:
-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:
*
-*
*
*
diff --git a/docs/source.md b/docs/source.md
index fc90ef94f..28c9a2312 100644
--- a/docs/source.md
+++ b/docs/source.md
@@ -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.
diff --git a/docs/triage.md b/docs/triage.md
index a37f55a30..caad1e52c 100644
--- a/docs/triage.md
+++ b/docs/triage.md
@@ -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
diff --git a/git/git.go b/git/git.go
index aa08c4d6c..3cda262f0 100644
--- a/git/git.go
+++ b/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
diff --git a/git/remote.go b/git/remote.go
index 77e550c37..a4291b40f 100644
--- a/git/remote.go
+++ b/git/remote.go
@@ -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()
}
diff --git a/git/remote_test.go b/git/remote_test.go
index 2e7d30cb6..382896590 100644
--- a/git/remote_test.go
+++ b/git/remote_test.go
@@ -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)
}
diff --git a/git/ssh_config.go b/git/ssh_config.go
index 287298cd9..317ff6059 100644
--- a/git/ssh_config.go
+++ b/git/ssh_config.go
@@ -13,15 +13,10 @@ import (
)
var (
- sshHostRE,
- sshTokenRE *regexp.Regexp
+ sshConfigLineRE = regexp.MustCompile(`\A\s*(?P[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P.+)`)
+ 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 {
diff --git a/git/ssh_config_test.go b/git/ssh_config_test.go
index 28f339aa6..f05ca303b 100644
--- a/git/ssh_config_test.go
+++ b/git/ssh_config_test.go
@@ -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) {
diff --git a/go.mod b/go.mod
index fe0d39b7b..6de526901 100644
--- a/go.mod
+++ b/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
)
diff --git a/go.sum b/go.sum
index b3659cb76..d2f73e243 100644
--- a/go.sum
+++ b/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=
diff --git a/internal/authflow/flow.go b/internal/authflow/flow.go
index 1c804931f..fac9d31a6 100644
--- a/internal/authflow/flow.go
+++ b/internal/authflow/flow.go
@@ -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) {
diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go
index f40cb9097..35ecabda4 100644
--- a/internal/config/config_file_test.go
+++ b/internal/config/config_file_test.go
@@ -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
diff --git a/internal/config/config_type.go b/internal/config/config_type.go
index a28521f0e..4c04d9f6b 100644
--- a/internal/config/config_type.go
+++ b/internal/config/config_type.go
@@ -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 ""
}
diff --git a/internal/config/config_type_test.go b/internal/config/config_type_test.go
index 46b9016f4..fca819f46 100644
--- a/internal/config/config_type_test.go
+++ b/internal/config/config_type_test.go
@@ -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)
}
diff --git a/internal/config/from_env.go b/internal/config/from_env.go
index fa930da06..7b2853bd7 100644
--- a/internal/config/from_env.go
+++ b/internal/config/from_env.go
@@ -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) != ""
+}
diff --git a/internal/config/from_env_test.go b/internal/config/from_env_test.go
index 412d37248..989cb3e3c 100644
--- a/internal/config/from_env_test.go
+++ b/internal/config/from_env_test.go
@@ -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())
})
}
}
diff --git a/internal/config/stub.go b/internal/config/stub.go
new file mode 100644
index 000000000..57761dac5
--- /dev/null
+++ b/internal/config/stub.go
@@ -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
+}
diff --git a/internal/docs/docs_test.go b/internal/docs/docs_test.go
new file mode 100644
index 000000000..e6b15062e
--- /dev/null
+++ b/internal/docs/docs_test.go
@@ -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)
+ }
+}
diff --git a/internal/docs/man.go b/internal/docs/man.go
new file mode 100644
index 000000000..570e9c780
--- /dev/null
+++ b/internal/docs/man.go
@@ -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() }
diff --git a/internal/docs/man_test.go b/internal/docs/man_test.go
new file mode 100644
index 000000000..58591e19e
--- /dev/null
+++ b/internal/docs/man_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/docs/markdown.go b/internal/docs/markdown.go
new file mode 100644
index 000000000..3432e9784
--- /dev/null
+++ b/internal/docs/markdown.go
@@ -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
+}
diff --git a/internal/docs/markdown_test.go b/internal/docs/markdown_test.go
new file mode 100644
index 000000000..27be95efa
--- /dev/null
+++ b/internal/docs/markdown_test.go
@@ -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)
+ }
+ }
+}
diff --git a/internal/ghinstance/host_test.go b/internal/ghinstance/host_test.go
index 787569c68..a392e4ad2 100644
--- a/internal/ghinstance/host_test.go
+++ b/internal/ghinstance/host_test.go
@@ -139,7 +139,7 @@ func TestHostnameValidator(t *testing.T) {
assert.Error(t, err)
return
}
- assert.Equal(t, nil, err)
+ assert.NoError(t, err)
})
}
}
diff --git a/internal/run/run.go b/internal/run/run.go
index 67de76fa2..bc2c92191 100644
--- a/internal/run/run.go
+++ b/internal/run/run.go
@@ -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
+}
diff --git a/internal/run/stub.go b/internal/run/stub.go
index 9bd6e279b..f11834c19 100644
--- a/internal/run/stub.go
+++ b/internal/run/stub.go
@@ -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, ", "))
}
}
diff --git a/update/update.go b/internal/update/update.go
similarity index 65%
rename from update/update.go
rename to internal/update/update.go
index bf89a12e8..b647d5a5c 100644
--- a/update/update.go
+++ b/internal/update/update.go
@@ -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)
diff --git a/update/update_test.go b/internal/update/update_test.go
similarity index 69%
rename from update/update_test.go
rename to internal/update/update_test.go
index 2fcb2d6ab..15759c500 100644
--- a/update/update_test.go
+++ b/internal/update/update_test.go
@@ -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 {
diff --git a/pkg/browser/browser.go b/pkg/browser/browser.go
index 133b3dbb1..ec4c59e6c 100644
--- a/pkg/browser/browser.go
+++ b/pkg/browser/browser.go
@@ -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
diff --git a/pkg/browser/browser_test.go b/pkg/browser/browser_test.go
index 48b91f7c1..6a24327da 100644
--- a/pkg/browser/browser_test.go
+++ b/pkg/browser/browser_test.go
@@ -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) {
diff --git a/pkg/cmd/alias/alias.go b/pkg/cmd/alias/alias.go
index 0a2971f04..8c50d6ff9 100644
--- a/pkg/cmd/alias/alias.go
+++ b/pkg/cmd/alias/alias.go
@@ -11,7 +11,7 @@ import (
func NewCmdAlias(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
- Use: "alias",
+ Use: "alias ",
Short: "Create command shortcuts",
Long: heredoc.Doc(`
Aliases can be used to make shortcuts for gh commands or to compose multiple commands.
diff --git a/pkg/cmd/alias/delete/delete.go b/pkg/cmd/alias/delete/delete.go
index ccf98ca68..a0ff1973b 100644
--- a/pkg/cmd/alias/delete/delete.go
+++ b/pkg/cmd/alias/delete/delete.go
@@ -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)
}
diff --git a/pkg/cmd/alias/delete/delete_test.go b/pkg/cmd/alias/delete/delete_test.go
index 3c6acea28..6348b361c 100644
--- a/pkg/cmd/alias/delete/delete_test.go
+++ b/pkg/cmd/alias/delete/delete_test.go
@@ -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)
diff --git a/pkg/cmd/alias/expand/expand.go b/pkg/cmd/alias/expand/expand.go
index b2117f198..ec791df52 100644
--- a/pkg/cmd/alias/expand/expand.go
+++ b/pkg/cmd/alias/expand/expand.go
@@ -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
}
diff --git a/pkg/cmd/alias/set/set.go b/pkg/cmd/alias/set/set.go
index a9f628471..294a4bd0e 100644
--- a/pkg/cmd/alias/set/set.go
+++ b/pkg/cmd/alias/set/set.go
@@ -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),
)
}
diff --git a/pkg/cmd/alias/set/set_test.go b/pkg/cmd/alias/set/set_test.go
index 95f3a5031..c440d80c1 100644
--- a/pkg/cmd/alias/set/set_test.go
+++ b/pkg/cmd/alias/set/set_test.go
@@ -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) {
diff --git a/pkg/cmd/api/api.go b/pkg/cmd/api/api.go
index 4d64a260b..63437937c 100644
--- a/pkg/cmd/api/api.go
+++ b/pkg/cmd/api/api.go
@@ -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
diff --git a/pkg/cmd/auth/auth.go b/pkg/cmd/auth/auth.go
index ddeb07aa6..6909f99f8 100644
--- a/pkg/cmd/auth/auth.go
+++ b/pkg/cmd/auth/auth.go
@@ -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
}
diff --git a/pkg/cmd/auth/gitcredential/helper.go b/pkg/cmd/auth/gitcredential/helper.go
new file mode 100644
index 000000000..c62cef3a8
--- /dev/null
+++ b/pkg/cmd/auth/gitcredential/helper.go
@@ -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
+}
diff --git a/pkg/cmd/auth/gitcredential/helper_test.go b/pkg/cmd/auth/gitcredential/helper_test.go
new file mode 100644
index 000000000..336d4ef34
--- /dev/null
+++ b/pkg/cmd/auth/gitcredential/helper_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/pkg/cmd/auth/login/login.go b/pkg/cmd/auth/login/login.go
index 929d6b61d..eb3b6b645 100644
--- a/pkg/cmd/auth/login/login.go
+++ b/pkg/cmd/auth/login/login.go
@@ -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 == "" {
diff --git a/pkg/cmd/auth/login/login_test.go b/pkg/cmd/auth/login/login_test.go
index 9a6b5aa8b..e14bc2e3f 100644
--- a/pkg/cmd/auth/login/login_test.go
+++ b/pkg/cmd/auth/login/login_test.go
@@ -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"}}}`))
diff --git a/pkg/cmd/auth/logout/logout.go b/pkg/cmd/auth/logout/logout.go
index 78a3bb98f..1454a3656 100644
--- a/pkg/cmd/auth/logout/logout.go
+++ b/pkg/cmd/auth/logout/logout.go
@@ -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
diff --git a/pkg/cmd/auth/logout/logout_test.go b/pkg/cmd/auth/logout/logout_test.go
index a9d14cc91..03b439ac0 100644
--- a/pkg/cmd/auth/logout/logout_test.go
+++ b/pkg/cmd/auth/logout/logout_test.go
@@ -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())
diff --git a/pkg/cmd/auth/refresh/refresh.go b/pkg/cmd/auth/refresh/refresh.go
index e2a7829b4..11673c58c 100644
--- a/pkg/cmd/auth/refresh/refresh.go
+++ b/pkg/cmd/auth/refresh/refresh.go
@@ -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
}
diff --git a/pkg/cmd/auth/refresh/refresh_test.go b/pkg/cmd/auth/refresh/refresh_test.go
index 46db2af52..e11592e23 100644
--- a/pkg/cmd/auth/refresh/refresh_test.go
+++ b/pkg/cmd/auth/refresh/refresh_test.go
@@ -2,7 +2,6 @@ package refresh
import (
"bytes"
- "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)
diff --git a/pkg/cmd/auth/client/client.go b/pkg/cmd/auth/shared/client.go
similarity index 71%
rename from pkg/cmd/auth/client/client.go
rename to pkg/cmd/auth/shared/client.go
index bacde0b82..87acb0a71 100644
--- a/pkg/cmd/auth/client/client.go
+++ b/pkg/cmd/auth/shared/client.go
@@ -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
diff --git a/pkg/cmd/auth/shared/git_credential.go b/pkg/cmd/auth/shared/git_credential.go
new file mode 100644
index 000000000..bc0b3de1e
--- /dev/null
+++ b/pkg/cmd/auth/shared/git_credential.go
@@ -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"
+}
diff --git a/pkg/cmd/auth/shared/git_credential_test.go b/pkg/cmd/auth/shared/git_credential_test.go
new file mode 100644
index 000000000..a8d2fe408
--- /dev/null
+++ b/pkg/cmd/auth/shared/git_credential_test.go
@@ -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)
+ }
+}
diff --git a/pkg/cmd/auth/status/status.go b/pkg/cmd/auth/status/status.go
index 442b84afb..59603981d 100644
--- a/pkg/cmd/auth/status/status.go
+++ b/pkg/cmd/auth/status/status.go
@@ -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)
}
diff --git a/pkg/cmd/auth/status/status_test.go b/pkg/cmd/auth/status/status_test.go
index 0de14d388..4eb37bd36 100644
--- a/pkg/cmd/auth/status/status_test.go
+++ b/pkg/cmd/auth/status/status_test.go
@@ -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 {
diff --git a/pkg/cmd/completion/completion.go b/pkg/cmd/completion/completion.go
index 3d3699b5d..24414a5fc 100644
--- a/pkg/cmd/completion/completion.go
+++ b/pkg/cmd/completion/completion.go
@@ -14,22 +14,44 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
var shellType string
cmd := &cobra.Command{
- Use: "completion",
+ Use: "completion -s ",
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)
diff --git a/pkg/cmd/config/config.go b/pkg/cmd/config/config.go
index 0fb02eb9d..d5621605f 100644
--- a/pkg/cmd/config/config.go
+++ b/pkg/cmd/config/config.go
@@ -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 ",
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 ",
- 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 ",
- 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
}
diff --git a/pkg/cmd/config/config_test.go b/pkg/cmd/config/config_test.go
deleted file mode 100644
index 20c7dd543..000000000
--- a/pkg/cmd/config/config_test.go
+++ /dev/null
@@ -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"])
- })
- }
-}
diff --git a/pkg/cmd/config/get/get.go b/pkg/cmd/config/get/get.go
new file mode 100644
index 000000000..a3ce55176
--- /dev/null
+++ b/pkg/cmd/config/get/get.go
@@ -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 ",
+ 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
+}
diff --git a/pkg/cmd/config/get/get_test.go b/pkg/cmd/config/get/get_test.go
new file mode 100644
index 000000000..1e2104dbd
--- /dev/null
+++ b/pkg/cmd/config/get/get_test.go
@@ -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)
+ })
+ }
+}
diff --git a/pkg/cmd/config/set/set.go b/pkg/cmd/config/set/set.go
new file mode 100644
index 000000000..d1a2d5c59
--- /dev/null
+++ b/pkg/cmd/config/set/set.go
@@ -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 ",
+ 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
+}
diff --git a/pkg/cmd/config/set/set_test.go b/pkg/cmd/config/set/set_test.go
new file mode 100644
index 000000000..56efc2ebd
--- /dev/null
+++ b/pkg/cmd/config/set/set_test.go
@@ -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)
+ })
+ }
+}
diff --git a/pkg/cmd/factory/default.go b/pkg/cmd/factory/default.go
index ae8622b2a..2c70ab5cd 100644
--- a/pkg/cmd/factory/default.go
+++ b/pkg/cmd/factory/default.go
@@ -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,
diff --git a/pkg/cmd/factory/http.go b/pkg/cmd/factory/http.go
index 47fbbefe8..8cd1bf12a 100644
--- a/pkg/cmd/factory/http.go
+++ b/pkg/cmd/factory/http.go
@@ -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 {
diff --git a/pkg/cmd/factory/remote_resolver.go b/pkg/cmd/factory/remote_resolver.go
index 589b19959..ee603e49d 100644
--- a/pkg/cmd/factory/remote_resolver.go
+++ b/pkg/cmd/factory/remote_resolver.go
@@ -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()] {
diff --git a/pkg/cmd/factory/remote_resolver_test.go b/pkg/cmd/factory/remote_resolver_test.go
index d0337499c..27c937e36 100644
--- a/pkg/cmd/factory/remote_resolver_test.go
+++ b/pkg/cmd/factory/remote_resolver_test.go
@@ -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)
+}
diff --git a/pkg/cmd/gist/clone/clone.go b/pkg/cmd/gist/clone/clone.go
new file mode 100644
index 000000000..8bdc932ae
--- /dev/null
+++ b/pkg/cmd/gist/clone/clone.go
@@ -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 [] [-- ...]",
+ 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)
+}
diff --git a/pkg/cmd/gist/clone/clone_test.go b/pkg/cmd/gist/clone/clone_test.go
new file mode 100644
index 000000000..8faf3d40c
--- /dev/null
+++ b/pkg/cmd/gist/clone/clone_test.go
@@ -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)
+ }
+}
diff --git a/pkg/cmd/gist/create/create.go b/pkg/cmd/gist/create/create.go
index 85fcb158f..dd46b6edd 100644
--- a/pkg/cmd/gist/create/create.go
+++ b/pkg/cmd/gist/create/create.go
@@ -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
}
diff --git a/pkg/cmd/gist/create/create_test.go b/pkg/cmd/gist/create/create_test.go
index dd1912909..548abd743 100644
--- a/pkg/cmd/gist/create/create_test.go
+++ b/pkg/cmd/gist/create/create_test.go
@@ -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)
})
}
diff --git a/pkg/cmd/gist/delete/delete.go b/pkg/cmd/gist/delete/delete.go
new file mode 100644
index 000000000..03ddc6e46
--- /dev/null
+++ b/pkg/cmd/gist/delete/delete.go
@@ -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 { | }",
+ 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
+}
diff --git a/pkg/cmd/gist/delete/delete_test.go b/pkg/cmd/gist/delete/delete_test.go
new file mode 100644
index 000000000..21480803c
--- /dev/null
+++ b/pkg/cmd/gist/delete/delete_test.go
@@ -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)
+
+ })
+ }
+}
diff --git a/pkg/cmd/gist/gist.go b/pkg/cmd/gist/gist.go
index 41808f4b4..df7e0f575 100644
--- a/pkg/cmd/gist/gist.go
+++ b/pkg/cmd/gist/gist.go
@@ -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 ",
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
}
diff --git a/pkg/cmd/gist/view/view.go b/pkg/cmd/gist/view/view.go
index be24cba54..701975c84 100644
--- a/pkg/cmd/gist/view/view.go
+++ b/pkg/cmd/gist/view/view.go
@@ -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
}
diff --git a/pkg/cmd/issue/close/close.go b/pkg/cmd/issue/close/close.go
index 66314a86d..a6aa04bc6 100644
--- a/pkg/cmd/issue/close/close.go
+++ b/pkg/cmd/issue/close/close.go
@@ -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
}
diff --git a/pkg/cmd/issue/close/close_test.go b/pkg/cmd/issue/close/close_test.go
index eadf7f440..5a2054309 100644
--- a/pkg/cmd/issue/close/close_test.go
+++ b/pkg/cmd/issue/close/close_test.go
@@ -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" {
diff --git a/pkg/cmd/issue/comment/comment.go b/pkg/cmd/issue/comment/comment.go
new file mode 100644
index 000000000..1e4264574
--- /dev/null
+++ b/pkg/cmd/issue/comment/comment.go
@@ -0,0 +1,69 @@
+package comment
+
+import (
+ "net/http"
+
+ "github.com/MakeNowJust/heredoc"
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/internal/ghrepo"
+ issueShared "github.com/cli/cli/pkg/cmd/issue/shared"
+ prShared "github.com/cli/cli/pkg/cmd/pr/shared"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/utils"
+ "github.com/spf13/cobra"
+)
+
+func NewCmdComment(f *cmdutil.Factory, runF func(*prShared.CommentableOptions) error) *cobra.Command {
+ opts := &prShared.CommentableOptions{
+ IO: f.IOStreams,
+ HttpClient: f.HttpClient,
+ EditSurvey: prShared.CommentableEditSurvey(f.Config, f.IOStreams),
+ InteractiveEditSurvey: prShared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
+ ConfirmSubmitSurvey: prShared.CommentableConfirmSubmitSurvey,
+ OpenInBrowser: utils.OpenInBrowser,
+ }
+
+ cmd := &cobra.Command{
+ Use: "comment { | }",
+ Short: "Create a new issue comment",
+ Example: heredoc.Doc(`
+ $ gh issue comment 22 --body "I was able to reproduce this issue, lets fix it."
+ `),
+ Args: cobra.ExactArgs(1),
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ opts.RetrieveCommentable = retrieveIssue(f.HttpClient, f.BaseRepo, args[0])
+ return prShared.CommentablePreRun(cmd, opts)
+ },
+ RunE: func(_ *cobra.Command, args []string) error {
+ if runF != nil {
+ return runF(opts)
+ }
+ return prShared.CommentableRun(opts)
+ },
+ }
+
+ cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
+ cmd.Flags().BoolP("editor", "e", false, "Add body using editor")
+ cmd.Flags().BoolP("web", "w", false, "Add body in browser")
+
+ return cmd
+}
+
+func retrieveIssue(httpClient func() (*http.Client, error),
+ baseRepo func() (ghrepo.Interface, error),
+ selector string) func() (prShared.Commentable, ghrepo.Interface, error) {
+ return func() (prShared.Commentable, ghrepo.Interface, error) {
+ httpClient, err := httpClient()
+ if err != nil {
+ return nil, nil, err
+ }
+ apiClient := api.NewClientFromHTTP(httpClient)
+
+ issue, repo, err := issueShared.IssueFromArg(apiClient, baseRepo, selector)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return issue, repo, nil
+ }
+}
diff --git a/pkg/cmd/issue/comment/comment_test.go b/pkg/cmd/issue/comment/comment_test.go
new file mode 100644
index 000000000..bfbdd6690
--- /dev/null
+++ b/pkg/cmd/issue/comment/comment_test.go
@@ -0,0 +1,261 @@
+package comment
+
+import (
+ "bytes"
+ "net/http"
+ "testing"
+
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmd/pr/shared"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/pkg/httpmock"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/google/shlex"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewCmdComment(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ output shared.CommentableOptions
+ wantsErr bool
+ }{
+ {
+ name: "no arguments",
+ input: "",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
+ {
+ name: "issue number",
+ input: "1",
+ output: shared.CommentableOptions{
+ Interactive: true,
+ InputType: 0,
+ Body: "",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "issue url",
+ input: "https://github.com/OWNER/REPO/issues/12",
+ output: shared.CommentableOptions{
+ Interactive: true,
+ InputType: 0,
+ Body: "",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "body flag",
+ input: "1 --body test",
+ output: shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeInline,
+ Body: "test",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "editor flag",
+ input: "1 --editor",
+ output: shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeEditor,
+ Body: "",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "web flag",
+ input: "1 --web",
+ output: shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeWeb,
+ Body: "",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "editor and web flags",
+ input: "1 --editor --web",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
+ {
+ name: "editor and body flags",
+ input: "1 --editor --body test",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
+ {
+ name: "web and body flags",
+ input: "1 --web --body test",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
+ {
+ name: "editor, web, and body flags",
+ input: "1 --editor --web --body test",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ io, _, _, _ := iostreams.Test()
+ io.SetStdoutTTY(true)
+ io.SetStdinTTY(true)
+ io.SetStderrTTY(true)
+
+ f := &cmdutil.Factory{
+ IOStreams: io,
+ }
+
+ argv, err := shlex.Split(tt.input)
+ assert.NoError(t, err)
+
+ var gotOpts *shared.CommentableOptions
+ cmd := NewCmdComment(f, func(opts *shared.CommentableOptions) 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.Interactive, gotOpts.Interactive)
+ assert.Equal(t, tt.output.InputType, gotOpts.InputType)
+ assert.Equal(t, tt.output.Body, gotOpts.Body)
+ })
+ }
+}
+
+func Test_commentRun(t *testing.T) {
+ tests := []struct {
+ name string
+ input *shared.CommentableOptions
+ httpStubs func(*testing.T, *httpmock.Registry)
+ stdout string
+ stderr string
+ }{
+ {
+ name: "interactive editor",
+ input: &shared.CommentableOptions{
+ Interactive: true,
+ InputType: 0,
+ Body: "",
+
+ InteractiveEditSurvey: func() (string, error) { return "comment body", nil },
+ ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockIssueFromNumber(t, reg)
+ mockCommentCreate(t, reg)
+ },
+ stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
+ },
+ {
+ name: "non-interactive web",
+ input: &shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeWeb,
+ Body: "",
+
+ OpenInBrowser: func(string) error { return nil },
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockIssueFromNumber(t, reg)
+ },
+ stderr: "Opening github.com/OWNER/REPO/issues/123 in your browser.\n",
+ },
+ {
+ name: "non-interactive editor",
+ input: &shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeEditor,
+ Body: "",
+
+ EditSurvey: func() (string, error) { return "comment body", nil },
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockIssueFromNumber(t, reg)
+ mockCommentCreate(t, reg)
+ },
+ stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
+ },
+ {
+ name: "non-interactive inline",
+ input: &shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeInline,
+ Body: "comment body",
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockIssueFromNumber(t, reg)
+ mockCommentCreate(t, reg)
+ },
+ stdout: "https://github.com/OWNER/REPO/issues/123#issuecomment-456\n",
+ },
+ }
+ for _, tt := range tests {
+ io, _, stdout, stderr := iostreams.Test()
+ io.SetStdoutTTY(true)
+ io.SetStdinTTY(true)
+ io.SetStderrTTY(true)
+
+ reg := &httpmock.Registry{}
+ defer reg.Verify(t)
+ tt.httpStubs(t, reg)
+
+ httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
+ baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
+
+ tt.input.IO = io
+ tt.input.HttpClient = httpClient
+ tt.input.RetrieveCommentable = retrieveIssue(tt.input.HttpClient, baseRepo, "123")
+
+ t.Run(tt.name, func(t *testing.T) {
+ err := shared.CommentableRun(tt.input)
+ assert.NoError(t, err)
+ assert.Equal(t, tt.stdout, stdout.String())
+ assert.Equal(t, tt.stderr, stderr.String())
+ })
+ }
+}
+
+func mockIssueFromNumber(_ *testing.T, reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.GraphQL(`query IssueByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "hasIssuesEnabled": true, "issue": {
+ "number": 123,
+ "url": "https://github.com/OWNER/REPO/issues/123"
+ } } } }`),
+ )
+}
+
+func mockCommentCreate(t *testing.T, reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.GraphQL(`mutation CommentCreate\b`),
+ httpmock.GraphQLMutation(`
+ { "data": { "addComment": { "commentEdge": { "node": {
+ "url": "https://github.com/OWNER/REPO/issues/123#issuecomment-456"
+ } } } } }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, "comment body", inputs["body"])
+ }),
+ )
+}
diff --git a/pkg/cmd/issue/create/create.go b/pkg/cmd/issue/create/create.go
index 2a8ce27c6..f8ee73abb 100644
--- a/pkg/cmd/issue/create/create.go
+++ b/pkg/cmd/issue/create/create.go
@@ -7,12 +7,10 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
- "github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
- "github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
@@ -28,6 +26,7 @@ type CreateOptions struct {
RepoOverride string
WebMode bool
+ RecoverFile string
Title string
Body string
@@ -65,6 +64,10 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
bodyProvided := cmd.Flags().Changed("body")
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
+ if !opts.IO.CanPrompt() && opts.RecoverFile != "" {
+ return &cmdutil.FlagError{Err: errors.New("--recover only supported when running interactively")}
+ }
+
opts.Interactive = !(titleProvided && bodyProvided)
if opts.Interactive && !opts.IO.CanPrompt() {
@@ -85,30 +88,24 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
cmd.Flags().StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
cmd.Flags().StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the issue to projects by `name`")
cmd.Flags().StringVarP(&opts.Milestone, "milestone", "m", "", "Add the issue to a milestone by `name`")
+ cmd.Flags().StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create")
return cmd
}
-func createRun(opts *CreateOptions) error {
+func createRun(opts *CreateOptions) (err error) {
httpClient, err := opts.HttpClient()
if err != nil {
- return err
+ return
}
apiClient := api.NewClientFromHTTP(httpClient)
baseRepo, err := opts.BaseRepo()
if err != nil {
- return err
+ return
}
- var nonLegacyTemplateFiles []string
- if opts.RootDirOverride != "" {
- nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(opts.RootDirOverride, "ISSUE_TEMPLATE")
- } else if opts.RepoOverride == "" {
- if rootDir, err := git.ToplevelDir(); err == nil {
- nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "ISSUE_TEMPLATE")
- }
- }
+ templateFiles, legacyTemplate := prShared.FindTemplates(opts.RootDirOverride, "ISSUE_TEMPLATE")
isTerminal := opts.IO.IsStdoutTTY()
@@ -117,14 +114,32 @@ func createRun(opts *CreateOptions) error {
milestones = []string{opts.Milestone}
}
+ tb := prShared.IssueMetadataState{
+ Type: prShared.IssueMetadata,
+ Assignees: opts.Assignees,
+ Labels: opts.Labels,
+ Projects: opts.Projects,
+ Milestones: milestones,
+ Title: opts.Title,
+ Body: opts.Body,
+ }
+
+ if opts.RecoverFile != "" {
+ err = prShared.FillFromJSON(opts.IO, opts.RecoverFile, &tb)
+ if err != nil {
+ err = fmt.Errorf("failed to recover input: %w", err)
+ return
+ }
+ }
+
if opts.WebMode {
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
if opts.Title != "" || opts.Body != "" {
- openURL, err = prShared.WithPrAndIssueQueryParams(openURL, opts.Title, opts.Body, opts.Assignees, opts.Labels, opts.Projects, milestones)
+ openURL, err = prShared.WithPrAndIssueQueryParams(openURL, tb)
if err != nil {
- return err
+ return
}
- } else if len(nonLegacyTemplateFiles) > 1 {
+ } else if len(templateFiles) > 1 {
openURL += "/choose"
}
if isTerminal {
@@ -139,68 +154,91 @@ func createRun(opts *CreateOptions) error {
repo, err := api.GitHubRepo(apiClient, baseRepo)
if err != nil {
- return err
+ return
}
if !repo.HasIssuesEnabled {
- return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo))
+ err = fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo))
+ return
}
action := prShared.SubmitAction
- tb := prShared.IssueMetadataState{
- Type: prShared.IssueMetadata,
- Assignees: opts.Assignees,
- Labels: opts.Labels,
- Projects: opts.Projects,
- Milestones: milestones,
- }
-
- title := opts.Title
- body := opts.Body
if opts.Interactive {
- var legacyTemplateFile *string
- if opts.RepoOverride == "" {
- if rootDir, err := git.ToplevelDir(); err == nil {
- // TODO: figure out how to stub this in tests
- legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE")
+ var editorCommand string
+ editorCommand, err = cmdutil.DetermineEditor(opts.Config)
+ if err != nil {
+ return
+ }
+
+ defer prShared.PreserveInput(opts.IO, &tb, &err)()
+
+ if opts.Title == "" {
+ err = prShared.TitleSurvey(&tb)
+ if err != nil {
+ return
}
}
- editorCommand, err := cmdutil.DetermineEditor(opts.Config)
- if err != nil {
- return err
+ if opts.Body == "" {
+ templateContent := ""
+
+ if opts.RecoverFile == "" {
+ templateContent, err = prShared.TemplateSurvey(templateFiles, legacyTemplate, tb)
+ if err != nil {
+ return
+ }
+ }
+
+ err = prShared.BodySurvey(&tb, templateContent, editorCommand)
+ if err != nil {
+ return
+ }
+
+ if tb.Body == "" {
+ tb.Body = templateContent
+ }
}
- err = prShared.TitleBodySurvey(opts.IO, editorCommand, &tb, apiClient, baseRepo, title, body, prShared.Defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage())
+ action, err = prShared.ConfirmSubmission(!tb.HasMetadata(), repo.ViewerCanTriage())
if err != nil {
- return fmt.Errorf("could not collect title and/or body: %w", err)
+ err = fmt.Errorf("unable to confirm: %w", err)
+ return
}
- action = tb.Action
+ if action == prShared.MetadataAction {
+ fetcher := &prShared.MetadataFetcher{
+ IO: opts.IO,
+ APIClient: apiClient,
+ Repo: baseRepo,
+ State: &tb,
+ }
+ err = prShared.MetadataSurvey(opts.IO, baseRepo, fetcher, &tb)
+ if err != nil {
+ return
+ }
- if tb.Action == prShared.CancelAction {
+ action, err = prShared.ConfirmSubmission(!tb.HasMetadata(), false)
+ if err != nil {
+ return
+ }
+ }
+
+ if action == prShared.CancelAction {
fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
-
- return nil
- }
-
- if title == "" {
- title = tb.Title
- }
- if body == "" {
- body = tb.Body
+ return
}
} else {
- if title == "" {
- return fmt.Errorf("title can't be blank")
+ if tb.Title == "" {
+ err = fmt.Errorf("title can't be blank")
+ return
}
}
if action == prShared.PreviewAction {
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
- openURL, err = prShared.WithPrAndIssueQueryParams(openURL, title, body, tb.Assignees, tb.Labels, tb.Projects, tb.Milestones)
+ openURL, err = prShared.WithPrAndIssueQueryParams(openURL, tb)
if err != nil {
- return err
+ return
}
if isTerminal {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
@@ -208,18 +246,19 @@ func createRun(opts *CreateOptions) error {
return utils.OpenInBrowser(openURL)
} else if action == prShared.SubmitAction {
params := map[string]interface{}{
- "title": title,
- "body": body,
+ "title": tb.Title,
+ "body": tb.Body,
}
err = prShared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb)
if err != nil {
- return err
+ return
}
- newIssue, err := api.IssueCreate(apiClient, repo, params)
+ var newIssue *api.Issue
+ newIssue, err = api.IssueCreate(apiClient, repo, params)
if err != nil {
- return err
+ return
}
fmt.Fprintln(opts.IO.Out, newIssue.URL)
@@ -227,5 +266,5 @@ func createRun(opts *CreateOptions) error {
panic("Unreachable state")
}
- return nil
+ return
}
diff --git a/pkg/cmd/issue/create/create_test.go b/pkg/cmd/issue/create/create_test.go
index 2216f5b1d..73a28dc46 100644
--- a/pkg/cmd/issue/create/create_test.go
+++ b/pkg/cmd/issue/create/create_test.go
@@ -3,16 +3,19 @@ package create
import (
"bytes"
"encoding/json"
+ "fmt"
"io/ioutil"
"net/http"
+ "os"
"os/exec"
- "reflect"
"strings"
"testing"
+ "github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
+ prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@@ -22,13 +25,6 @@ import (
"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 runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
return runCommandWithRootDirOverridden(rt, isTTY, cli, "")
}
@@ -79,83 +75,166 @@ func TestIssueCreate_nontty_error(t *testing.T) {
defer http.Verify(t)
_, err := runCommand(http, false, `-t hello`)
- if err == nil {
- t.Fatal("expected error running command `issue create`")
- }
-
- assert.Equal(t, "must provide --title and --body when not running interactively", err.Error())
+ assert.EqualError(t, err, "must provide --title and --body when not running interactively")
}
func TestIssueCreate(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "id": "REPOID",
- "hasIssuesEnabled": true
- } } }
- `))
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "createIssue": { "issue": {
- "URL": "https://github.com/OWNER/REPO/issues/12"
- } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query RepositoryInfo\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "id": "REPOID",
+ "hasIssuesEnabled": true
+ } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation IssueCreate\b`),
+ httpmock.GraphQLMutation(`
+ { "data": { "createIssue": { "issue": {
+ "URL": "https://github.com/OWNER/REPO/issues/12"
+ } } } }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["repositoryId"], "REPOID")
+ assert.Equal(t, inputs["title"], "hello")
+ assert.Equal(t, inputs["body"], "cash rules everything around me")
+ }),
+ )
output, err := runCommand(http, true, `-t hello -b "cash rules everything around me"`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
- bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
- reqBody := struct {
- Variables struct {
- Input struct {
- RepositoryID string
- Title string
- Body string
+ assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
+}
+
+func TestIssueCreate_recover(t *testing.T) {
+ http := &httpmock.Registry{}
+ defer http.Verify(t)
+
+ http.Register(
+ httpmock.GraphQL(`query RepositoryInfo\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "id": "REPOID",
+ "hasIssuesEnabled": true
+ } } }`))
+ http.Register(
+ httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`),
+ httpmock.StringResponse(`
+ { "data": {
+ "u000": { "login": "MonaLisa", "id": "MONAID" },
+ "repository": {
+ "l000": { "name": "bug", "id": "BUGID" },
+ "l001": { "name": "TODO", "id": "TODOID" }
}
- }
- }{}
- _ = json.Unmarshal(bodyBytes, &reqBody)
+ } }
+ `))
+ http.Register(
+ httpmock.GraphQL(`mutation IssueCreate\b`),
+ httpmock.GraphQLMutation(`
+ { "data": { "createIssue": { "issue": {
+ "URL": "https://github.com/OWNER/REPO/issues/12"
+ } } } }
+ `, func(inputs map[string]interface{}) {
+ assert.Equal(t, "recovered title", inputs["title"])
+ assert.Equal(t, "recovered body", inputs["body"])
+ assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
+ }))
- eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
- eq(t, reqBody.Variables.Input.Title, "hello")
- eq(t, reqBody.Variables.Input.Body, "cash rules everything around me")
+ as, teardown := prompt.InitAskStubber()
+ defer teardown()
- eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "Title",
+ Default: true,
+ },
+ })
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "Body",
+ Default: true,
+ },
+ })
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "confirmation",
+ Value: 0,
+ },
+ })
+
+ tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*")
+ assert.NoError(t, err)
+
+ state := prShared.IssueMetadataState{
+ Title: "recovered title",
+ Body: "recovered body",
+ Labels: []string{"bug", "TODO"},
+ }
+
+ data, err := json.Marshal(state)
+ assert.NoError(t, err)
+
+ _, err = tmpfile.Write(data)
+ assert.NoError(t, err)
+
+ args := fmt.Sprintf("--recover '%s'", tmpfile.Name())
+
+ output, err := runCommandWithRootDirOverridden(http, true, args, "")
+ if err != nil {
+ t.Errorf("error running command `issue create`: %v", err)
+ }
+
+ assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
}
func TestIssueCreate_nonLegacyTemplate(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "id": "REPOID",
- "hasIssuesEnabled": true
- } } }
- `))
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "createIssue": { "issue": {
- "URL": "https://github.com/OWNER/REPO/issues/12"
- } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query RepositoryInfo\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "id": "REPOID",
+ "hasIssuesEnabled": true
+ } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation IssueCreate\b`),
+ httpmock.GraphQLMutation(`
+ { "data": { "createIssue": { "issue": {
+ "URL": "https://github.com/OWNER/REPO/issues/12"
+ } } } }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["repositoryId"], "REPOID")
+ assert.Equal(t, inputs["title"], "hello")
+ assert.Equal(t, inputs["body"], "I have a suggestion for an enhancement")
+ }),
+ )
as, teardown := prompt.InitAskStubber()
defer teardown()
+
+ // template
as.Stub([]*prompt.QuestionStub{
{
Name: "index",
Value: 1,
},
})
+ // body
as.Stub([]*prompt.QuestionStub{
{
- Name: "body",
+ Name: "Body",
Default: true,
},
- })
+ }) // body
+ // confirm
as.Stub([]*prompt.QuestionStub{
{
Name: "confirmation",
@@ -168,23 +247,65 @@ func TestIssueCreate_nonLegacyTemplate(t *testing.T) {
t.Errorf("error running command `issue create`: %v", err)
}
- bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
- reqBody := struct {
- Variables struct {
- Input struct {
- RepositoryID string
- Title string
- Body string
- }
- }
- }{}
- _ = json.Unmarshal(bodyBytes, &reqBody)
+ assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
+}
- eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
- eq(t, reqBody.Variables.Input.Title, "hello")
- eq(t, reqBody.Variables.Input.Body, "I have a suggestion for an enhancement")
+func TestIssueCreate_continueInBrowser(t *testing.T) {
+ http := &httpmock.Registry{}
+ defer http.Verify(t)
- eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
+ http.Register(
+ httpmock.GraphQL(`query RepositoryInfo\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "id": "REPOID",
+ "hasIssuesEnabled": true
+ } } }`),
+ )
+
+ as, teardown := prompt.InitAskStubber()
+ defer teardown()
+
+ // title
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "Title",
+ Value: "hello",
+ },
+ })
+ // confirm
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "confirmation",
+ Value: 1,
+ },
+ })
+
+ var seenCmd *exec.Cmd
+ restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
+ seenCmd = cmd
+ return &test.OutputStub{}
+ })
+ defer restoreCmd()
+
+ output, err := runCommand(http, true, `-b body`)
+ if err != nil {
+ t.Errorf("error running command `issue create`: %v", err)
+ }
+
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, heredoc.Doc(`
+
+ Creating issue in OWNER/REPO
+
+ Opening github.com/OWNER/REPO/issues/new in your browser.
+ `), output.Stderr())
+
+ if seenCmd == nil {
+ t.Fatal("expected a command to run")
+ }
+ url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "")
+ assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?body=body&title=hello", url)
}
func TestIssueCreate_metadata(t *testing.T) {
@@ -243,12 +364,12 @@ func TestIssueCreate_metadata(t *testing.T) {
"URL": "https://github.com/OWNER/REPO/issues/12"
} } } }
`, func(inputs map[string]interface{}) {
- eq(t, inputs["title"], "TITLE")
- eq(t, inputs["body"], "BODY")
- eq(t, inputs["assigneeIds"], []interface{}{"MONAID"})
- eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"})
- eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"})
- eq(t, inputs["milestoneId"], "BIGONEID")
+ assert.Equal(t, "TITLE", inputs["title"])
+ assert.Equal(t, "BODY", inputs["body"])
+ assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"])
+ assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
+ assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"])
+ assert.Equal(t, "BIGONEID", inputs["milestoneId"])
if v, ok := inputs["userIds"]; ok {
t.Errorf("did not expect userIds: %v", v)
}
@@ -262,19 +383,21 @@ func TestIssueCreate_metadata(t *testing.T) {
t.Errorf("error running command `issue create`: %v", err)
}
- eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
+ assert.Equal(t, "https://github.com/OWNER/REPO/issues/12\n", output.String())
}
func TestIssueCreate_disabledIssues(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "id": "REPOID",
- "hasIssuesEnabled": false
- } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query RepositoryInfo\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "id": "REPOID",
+ "hasIssuesEnabled": false
+ } } }`),
+ )
_, err := runCommand(http, true, `-t heres -b johnny`)
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
@@ -302,9 +425,9 @@ func TestIssueCreate_web(t *testing.T) {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, "https://github.com/OWNER/REPO/issues/new")
- eq(t, output.String(), "")
- eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n")
+ assert.Equal(t, "https://github.com/OWNER/REPO/issues/new", url)
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "Opening github.com/OWNER/REPO/issues/new in your browser.\n", output.Stderr())
}
func TestIssueCreate_webTitleBody(t *testing.T) {
@@ -327,7 +450,7 @@ func TestIssueCreate_webTitleBody(t *testing.T) {
t.Fatal("expected a command to run")
}
url := strings.ReplaceAll(seenCmd.Args[len(seenCmd.Args)-1], "^", "")
- eq(t, url, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle")
- eq(t, output.String(), "")
- eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n")
+ assert.Equal(t, "https://github.com/OWNER/REPO/issues/new?body=mybody&title=mytitle", url)
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "Opening github.com/OWNER/REPO/issues/new in your browser.\n", output.Stderr())
}
diff --git a/pkg/cmd/issue/issue.go b/pkg/cmd/issue/issue.go
index 6f2cea6d9..12463b480 100644
--- a/pkg/cmd/issue/issue.go
+++ b/pkg/cmd/issue/issue.go
@@ -3,6 +3,7 @@ package issue
import (
"github.com/MakeNowJust/heredoc"
cmdClose "github.com/cli/cli/pkg/cmd/issue/close"
+ cmdComment "github.com/cli/cli/pkg/cmd/issue/comment"
cmdCreate "github.com/cli/cli/pkg/cmd/issue/create"
cmdList "github.com/cli/cli/pkg/cmd/issue/list"
cmdReopen "github.com/cli/cli/pkg/cmd/issue/reopen"
@@ -40,6 +41,7 @@ func NewCmdIssue(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdReopen.NewCmdReopen(f, nil))
cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
+ cmd.AddCommand(cmdComment.NewCmdComment(f, nil))
return cmd
}
diff --git a/pkg/cmd/issue/list/list_test.go b/pkg/cmd/issue/list/list_test.go
index ff202b6f3..a8fb93264 100644
--- a/pkg/cmd/issue/list/list_test.go
+++ b/pkg/cmd/issue/list/list_test.go
@@ -6,7 +6,6 @@ import (
"io/ioutil"
"net/http"
"os/exec"
- "reflect"
"regexp"
"testing"
@@ -22,13 +21,6 @@ import (
"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 runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
@@ -79,7 +71,7 @@ func TestIssueList_nontty(t *testing.T) {
t.Errorf("error running command `issue list`: %v", err)
}
- eq(t, output.Stderr(), "")
+ assert.Equal(t, "", output.Stderr())
test.ExpectLines(t, output.String(),
`1[\t]+number won[\t]+label[\t]+\d+`,
`2[\t]+number too[\t]+label[\t]+\d+`,
@@ -147,11 +139,11 @@ func TestIssueList_tty_withFlags(t *testing.T) {
t.Errorf("error running command `issue list`: %v", err)
}
- eq(t, output.Stderr(), "")
- eq(t, output.String(), `
+ assert.Equal(t, "", output.Stderr())
+ assert.Equal(t, `
No issues match your search in OWNER/REPO
-`)
+`, output.String())
}
func TestIssueList_withInvalidLimitFlag(t *testing.T) {
@@ -169,12 +161,14 @@ func TestIssueList_nullAssigneeLabels(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "hasIssuesEnabled": true,
- "issues": { "nodes": [] }
- } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query IssueList\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "hasIssuesEnabled": true,
+ "issues": { "nodes": [] }
+ } } }`),
+ )
_, err := runCommand(http, true, "")
if err != nil {
@@ -189,19 +183,21 @@ func TestIssueList_nullAssigneeLabels(t *testing.T) {
_, assigneeDeclared := reqBody.Variables["assignee"]
_, labelsDeclared := reqBody.Variables["labels"]
- eq(t, assigneeDeclared, false)
- eq(t, labelsDeclared, false)
+ assert.Equal(t, false, assigneeDeclared)
+ assert.Equal(t, false, labelsDeclared)
}
func TestIssueList_disabledIssues(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "hasIssuesEnabled": false
- } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query IssueList\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "hasIssuesEnabled": false
+ } } }`),
+ )
_, err := runCommand(http, true, "")
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
@@ -227,14 +223,14 @@ func TestIssueList_web(t *testing.T) {
expectedURL := "https://github.com/OWNER/REPO/issues?q=is%3Aissue+assignee%3Apeter+label%3Abug+label%3Adocs+author%3Ajohn+mentions%3Afrank+milestone%3Av1.1"
- eq(t, output.String(), "")
- eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues in your browser.\n")
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "Opening github.com/OWNER/REPO/issues in your browser.\n", output.Stderr())
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, expectedURL)
+ assert.Equal(t, expectedURL, url)
}
func TestIssueList_milestoneNotFound(t *testing.T) {
diff --git a/pkg/cmd/issue/reopen/reopen.go b/pkg/cmd/issue/reopen/reopen.go
index 72a052503..e65e2eceb 100644
--- a/pkg/cmd/issue/reopen/reopen.go
+++ b/pkg/cmd/issue/reopen/reopen.go
@@ -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 NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Co
}
func reopenRun(opts *ReopenOptions) error {
+ cs := opts.IO.ColorScheme()
+
httpClient, err := opts.HttpClient()
if err != nil {
return err
@@ -65,7 +66,7 @@ func reopenRun(opts *ReopenOptions) error {
}
if !issue.Closed {
- fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already open\n", utils.Yellow("!"), issue.Number, issue.Title)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Issue #%d (%s) is already open\n", cs.Yellow("!"), issue.Number, issue.Title)
return nil
}
@@ -74,7 +75,7 @@ func reopenRun(opts *ReopenOptions) error {
return err
}
- fmt.Fprintf(opts.IO.ErrOut, "%s Reopened issue #%d (%s)\n", utils.Green("✔"), issue.Number, issue.Title)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Reopened issue #%d (%s)\n", cs.SuccessIcon(), issue.Number, issue.Title)
return nil
}
diff --git a/pkg/cmd/issue/reopen/reopen_test.go b/pkg/cmd/issue/reopen/reopen_test.go
index df8e8ecd7..bce4bc882 100644
--- a/pkg/cmd/issue/reopen/reopen_test.go
+++ b/pkg/cmd/issue/reopen/reopen_test.go
@@ -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 TestIssueReopen(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "hasIssuesEnabled": true,
- "issue": { "number": 2, "closed": true, "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": 2, "closed": true, "title": "The title of the issue"}
+ } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation IssueReopen\b`),
+ httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["issueId"], "THE-ID")
+ }),
+ )
output, err := runCommand(http, true, "2")
if err != nil {
@@ -83,12 +91,14 @@ func TestIssueReopen_alreadyOpen(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "hasIssuesEnabled": true,
- "issue": { "number": 2, "closed": false, "title": "The title of the issue"}
- } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query IssueByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "hasIssuesEnabled": true,
+ "issue": { "number": 2, "closed": false, "title": "The title of the issue"}
+ } } }`),
+ )
output, err := runCommand(http, true, "2")
if err != nil {
@@ -106,11 +116,13 @@ func TestIssueReopen_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, "2")
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
diff --git a/pkg/cmd/issue/shared/display.go b/pkg/cmd/issue/shared/display.go
index 983052f19..ff9b83e1e 100644
--- a/pkg/cmd/issue/shared/display.go
+++ b/pkg/cmd/issue/shared/display.go
@@ -14,6 +14,7 @@ import (
)
func PrintIssues(io *iostreams.IOStreams, prefix string, totalCount int, issues []api.Issue) {
+ cs := io.ColorScheme()
table := utils.NewTablePrinter(io)
for _, issue := range issues {
issueNum := strconv.Itoa(issue.Number)
@@ -27,14 +28,14 @@ func PrintIssues(io *iostreams.IOStreams, prefix string, totalCount int, issues
}
now := time.Now()
ago := now.Sub(issue.UpdatedAt)
- table.AddField(issueNum, nil, prShared.ColorFuncForState(issue.State))
+ table.AddField(issueNum, nil, cs.ColorFromString(prShared.ColorForState(issue.State)))
if !table.IsTTY() {
table.AddField(issue.State, nil, nil)
}
table.AddField(text.ReplaceExcessiveWhitespace(issue.Title), nil, nil)
- table.AddField(labels, nil, utils.Gray)
+ table.AddField(labels, truncateLabels, cs.Gray)
if table.IsTTY() {
- table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray)
+ table.AddField(utils.FuzzyAgo(ago), nil, cs.Gray)
} else {
table.AddField(issue.UpdatedAt.String(), nil, nil)
}
@@ -43,10 +44,18 @@ func PrintIssues(io *iostreams.IOStreams, prefix string, totalCount int, issues
_ = table.Render()
remaining := totalCount - len(issues)
if remaining > 0 {
- fmt.Fprintf(io.Out, utils.Gray("%sAnd %d more\n"), prefix, remaining)
+ fmt.Fprintf(io.Out, cs.Gray("%sAnd %d more\n"), prefix, remaining)
}
}
+func truncateLabels(w int, t string) string {
+ if len(t) < 2 {
+ return t
+ }
+ truncated := text.Truncate(w-2, t[1:len(t)-1])
+ return fmt.Sprintf("(%s)", truncated)
+}
+
func IssueLabelList(issue api.Issue) string {
if len(issue.Labels.Nodes) == 0 {
return ""
@@ -57,9 +66,5 @@ func IssueLabelList(issue api.Issue) string {
labelNames = append(labelNames, label.Name)
}
- list := strings.Join(labelNames, ", ")
- if issue.Labels.TotalCount > len(issue.Labels.Nodes) {
- list += ", …"
- }
- return list
+ return strings.Join(labelNames, ", ")
}
diff --git a/pkg/cmd/issue/shared/display_test.go b/pkg/cmd/issue/shared/display_test.go
new file mode 100644
index 000000000..d6e97f7e8
--- /dev/null
+++ b/pkg/cmd/issue/shared/display_test.go
@@ -0,0 +1,15 @@
+package shared
+
+import "testing"
+
+func Test_truncateLabels(t *testing.T) {
+ got := truncateLabels(12, "(one, two, three)")
+ expected := "(one, tw...)"
+ if got != expected {
+ t.Errorf("expected %q, got %q", expected, got)
+ }
+
+ if truncateLabels(10, "") != "" {
+ t.Error("blank value error")
+ }
+}
diff --git a/pkg/cmd/issue/shared/lookup.go b/pkg/cmd/issue/shared/lookup.go
index 90a729599..6e716f6da 100644
--- a/pkg/cmd/issue/shared/lookup.go
+++ b/pkg/cmd/issue/shared/lookup.go
@@ -12,49 +12,48 @@ import (
)
func IssueFromArg(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), arg string) (*api.Issue, ghrepo.Interface, error) {
- issue, baseRepo, err := issueFromURL(apiClient, arg)
- if err != nil {
- return nil, nil, err
- }
- if issue != nil {
- return issue, baseRepo, nil
+ issueNumber, baseRepo := issueMetadataFromURL(arg)
+
+ if issueNumber == 0 {
+ var err error
+ issueNumber, err = strconv.Atoi(strings.TrimPrefix(arg, "#"))
+ if err != nil {
+ return nil, nil, fmt.Errorf("invalid issue format: %q", arg)
+ }
}
- baseRepo, err = baseRepoFn()
- if err != nil {
- return nil, nil, fmt.Errorf("could not determine base repo: %w", err)
+ if baseRepo == nil {
+ var err error
+ baseRepo, err = baseRepoFn()
+ if err != nil {
+ return nil, nil, fmt.Errorf("could not determine base repo: %w", err)
+ }
}
- issueNumber, err := strconv.Atoi(strings.TrimPrefix(arg, "#"))
- if err != nil {
- return nil, nil, fmt.Errorf("invalid issue format: %q", arg)
- }
-
- issue, err = issueFromNumber(apiClient, baseRepo, issueNumber)
+ issue, err := issueFromNumber(apiClient, baseRepo, issueNumber)
return issue, baseRepo, err
}
var issueURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/issues/(\d+)`)
-func issueFromURL(apiClient *api.Client, s string) (*api.Issue, ghrepo.Interface, error) {
+func issueMetadataFromURL(s string) (int, ghrepo.Interface) {
u, err := url.Parse(s)
if err != nil {
- return nil, nil, nil
+ return 0, nil
}
if u.Scheme != "https" && u.Scheme != "http" {
- return nil, nil, nil
+ return 0, nil
}
m := issueURLRE.FindStringSubmatch(u.Path)
if m == nil {
- return nil, nil, nil
+ return 0, nil
}
repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname())
issueNumber, _ := strconv.Atoi(m[3])
- issue, err := issueFromNumber(apiClient, repo, issueNumber)
- return issue, repo, err
+ return issueNumber, repo
}
func issueFromNumber(apiClient *api.Client, repo ghrepo.Interface, issueNumber int) (*api.Issue, error) {
diff --git a/pkg/cmd/issue/status/status.go b/pkg/cmd/issue/status/status.go
index 42ba91823..218d5d38a 100644
--- a/pkg/cmd/issue/status/status.go
+++ b/pkg/cmd/issue/status/status.go
@@ -80,28 +80,28 @@ func statusRun(opts *StatusOptions) error {
fmt.Fprintf(out, "Relevant issues in %s\n", ghrepo.FullName(baseRepo))
fmt.Fprintln(out, "")
- prShared.PrintHeader(out, "Issues assigned to you")
+ prShared.PrintHeader(opts.IO, "Issues assigned to you")
if issuePayload.Assigned.TotalCount > 0 {
issueShared.PrintIssues(opts.IO, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues)
} else {
message := " There are no issues assigned to you"
- prShared.PrintMessage(out, message)
+ prShared.PrintMessage(opts.IO, message)
}
fmt.Fprintln(out)
- prShared.PrintHeader(out, "Issues mentioning you")
+ prShared.PrintHeader(opts.IO, "Issues mentioning you")
if issuePayload.Mentioned.TotalCount > 0 {
issueShared.PrintIssues(opts.IO, " ", issuePayload.Mentioned.TotalCount, issuePayload.Mentioned.Issues)
} else {
- prShared.PrintMessage(out, " There are no issues mentioning you")
+ prShared.PrintMessage(opts.IO, " There are no issues mentioning you")
}
fmt.Fprintln(out)
- prShared.PrintHeader(out, "Issues opened by you")
+ prShared.PrintHeader(opts.IO, "Issues opened by you")
if issuePayload.Authored.TotalCount > 0 {
issueShared.PrintIssues(opts.IO, " ", issuePayload.Authored.TotalCount, issuePayload.Authored.Issues)
} else {
- prShared.PrintMessage(out, " There are no issues opened by you")
+ prShared.PrintMessage(opts.IO, " There are no issues opened by you")
}
fmt.Fprintln(out)
diff --git a/pkg/cmd/issue/view/fixtures/issueView_preview.json b/pkg/cmd/issue/view/fixtures/issueView_preview.json
index e25090a61..65fc5ef51 100644
--- a/pkg/cmd/issue/view/fixtures/issueView_preview.json
+++ b/pkg/cmd/issue/view/fixtures/issueView_preview.json
@@ -7,21 +7,21 @@
"body": "**bold story**",
"title": "ix of coins",
"state": "OPEN",
- "created_at": "2011-01-26T19:01:12Z",
+ "createdAt": "2011-01-26T19:01:12Z",
"author": {
"login": "marseilles"
},
"assignees": {
"nodes": [],
- "totalcount": 0
+ "totalCount": 0
},
"labels": {
"nodes": [],
- "totalcount": 0
+ "totalCount": 0
},
"projectcards": {
"nodes": [],
- "totalcount": 0
+ "totalCount": 0
},
"milestone": {
"title": ""
diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json b/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json
index 978927125..4665c47e1 100644
--- a/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json
+++ b/pkg/cmd/issue/view/fixtures/issueView_previewClosedState.json
@@ -7,7 +7,7 @@
"body": "**bold story**",
"title": "ix of coins",
"state": "CLOSED",
- "created_at": "2011-01-26T19:01:12Z",
+ "createdAt": "2011-01-26T19:01:12Z",
"author": {
"login": "marseilles"
},
diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json
new file mode 100644
index 000000000..d2cd27f30
--- /dev/null
+++ b/pkg/cmd/issue/view/fixtures/issueView_previewFullComments.json
@@ -0,0 +1,308 @@
+{
+ "data": {
+ "repository": {
+ "issue": {
+ "comments": {
+ "nodes": [
+ {
+ "author": {
+ "login": "monalisa"
+ },
+ "authorAssociation": "NONE",
+ "body": "Comment 1",
+ "createdAt": "2020-01-01T12:00:00Z",
+ "includesCreatedEdit": true,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 1
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 2
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 3
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 4
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 5
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 6
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 7
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 8
+ }
+ }
+ ]
+ },
+ {
+ "author": {
+ "login": "johnnytest"
+ },
+ "authorAssociation": "CONTRIBUTOR",
+ "body": "Comment 2",
+ "createdAt": "2020-01-01T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ]
+ },
+ {
+ "author": {
+ "login": "elvisp"
+ },
+ "authorAssociation": "MEMBER",
+ "body": "Comment 3",
+ "createdAt": "2020-01-01T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ]
+ },
+ {
+ "author": {
+ "login": "loislane"
+ },
+ "authorAssociation": "OWNER",
+ "body": "Comment 4",
+ "createdAt": "2020-01-01T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ]
+ },
+ {
+ "author": {
+ "login": "marseilles"
+ },
+ "authorAssociation": "COLLABORATOR",
+ "body": "Comment 5",
+ "createdAt": "2020-01-01T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ]
+ }
+ ],
+ "totalCount": 5
+ }
+ }
+ }
+ }
+}
diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json b/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json
new file mode 100644
index 000000000..87fc7bffc
--- /dev/null
+++ b/pkg/cmd/issue/view/fixtures/issueView_previewSingleComment.json
@@ -0,0 +1,147 @@
+{
+ "data": {
+ "repository": {
+ "hasIssuesEnabled": true,
+ "issue": {
+ "number": 123,
+ "body": "some body",
+ "title": "some title",
+ "state": "OPEN",
+ "createdAt": "2020-01-01T12:00:00Z",
+ "author": {
+ "login": "marseilles"
+ },
+ "assignees": {
+ "nodes": [],
+ "totalCount": 0
+ },
+ "labels": {
+ "nodes": [],
+ "totalCount": 0
+ },
+ "projectcards": {
+ "nodes": [],
+ "totalCount": 0
+ },
+ "milestone": {
+ "title": ""
+ },
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ],
+ "comments": {
+ "nodes": [
+ {
+ "author": {
+ "login": "marseilles"
+ },
+ "authorAssociation": "COLLABORATOR",
+ "body": "Comment 5",
+ "createdAt": "2020-01-01T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ]
+ }
+ ],
+ "totalCount": 5
+ },
+ "url": "https://github.com/OWNER/REPO/issues/123"
+ }
+ }
+ }
+}
diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json
index 6e87a42b6..104f134be 100644
--- a/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json
+++ b/pkg/cmd/issue/view/fixtures/issueView_previewWithEmptyBody.json
@@ -7,7 +7,7 @@
"body": "",
"title": "ix of coins",
"state": "OPEN",
- "created_at": "2011-01-26T19:01:12Z",
+ "createdAt": "2011-01-26T19:01:12Z",
"author": {
"login": "marseilles"
},
diff --git a/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json b/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json
index 246bbd77b..9bce5f745 100644
--- a/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json
+++ b/pkg/cmd/issue/view/fixtures/issueView_previewWithMetadata.json
@@ -7,7 +7,7 @@
"body": "**bold story**",
"title": "ix of coins",
"state": "OPEN",
- "created_at": "2011-01-26T19:01:12Z",
+ "createdAt": "2011-01-26T19:01:12Z",
"author": {
"login": "marseilles"
},
@@ -20,7 +20,7 @@
"login": "monaco"
}
],
- "totalcount": 2
+ "totalCount": 2
},
"labels": {
"nodes": [
@@ -40,7 +40,7 @@
"name": "five"
}
],
- "totalcount": 5
+ "totalCount": 5
},
"projectcards": {
"nodes": [
@@ -77,13 +77,63 @@
}
}
],
- "totalcount": 3
+ "totalCount": 3
},
"milestone": {
"title": "uluru"
},
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 8
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 7
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 6
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 5
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 4
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 3
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 2
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 1
+ }
+ }
+ ],
"comments": {
- "totalcount": 9
+ "totalCount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
}
diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go
index 66156898e..24867b32b 100644
--- a/pkg/cmd/issue/view/view.go
+++ b/pkg/cmd/issue/view/view.go
@@ -29,6 +29,7 @@ type ViewOptions struct {
SelectorArg string
WebMode bool
+ Comments bool
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
@@ -45,9 +46,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
Display the title, body, and other information about an issue.
With '--web', open the issue in a web browser instead.
- `),
- Example: heredoc.Doc(`
- `),
+ `),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
@@ -65,6 +64,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
}
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open an issue in the browser")
+ cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View issue comments")
return cmd
}
@@ -76,20 +76,29 @@ func viewRun(opts *ViewOptions) error {
}
apiClient := api.NewClientFromHTTP(httpClient)
- issue, _, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
+ issue, repo, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
if err != nil {
return err
}
- openURL := issue.URL
-
if opts.WebMode {
+ openURL := issue.URL
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
}
return utils.OpenInBrowser(openURL)
}
+ if opts.Comments {
+ opts.IO.StartProgressIndicator()
+ comments, err := api.CommentsForIssue(apiClient, repo, issue)
+ opts.IO.StopProgressIndicator()
+ if err != nil {
+ return err
+ }
+ issue.Comments = *comments
+ }
+
opts.IO.DetectTerminalTheme()
err = opts.IO.StartPager()
@@ -99,8 +108,14 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if opts.IO.IsStdoutTTY() {
- return printHumanIssuePreview(opts.IO, issue)
+ return printHumanIssuePreview(opts, issue)
}
+
+ if opts.Comments {
+ fmt.Fprint(opts.IO.Out, prShared.RawCommentList(issue.Comments, api.PullRequestReviews{}))
+ return nil
+ }
+
return printRawIssuePreview(opts.IO.Out, issue)
}
@@ -119,65 +134,83 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
fmt.Fprintf(out, "assignees:\t%s\n", assignees)
fmt.Fprintf(out, "projects:\t%s\n", projects)
fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title)
-
fmt.Fprintln(out, "--")
fmt.Fprintln(out, issue.Body)
return nil
}
-func printHumanIssuePreview(io *iostreams.IOStreams, issue *api.Issue) error {
- out := io.Out
+func printHumanIssuePreview(opts *ViewOptions, issue *api.Issue) error {
+ out := opts.IO.Out
now := time.Now()
ago := now.Sub(issue.CreatedAt)
+ cs := opts.IO.ColorScheme()
// Header (Title and State)
- fmt.Fprintln(out, utils.Bold(issue.Title))
- fmt.Fprint(out, issueStateTitleWithColor(issue.State))
- fmt.Fprintln(out, utils.Gray(fmt.Sprintf(
- " • %s opened %s • %s",
+ fmt.Fprintln(out, cs.Bold(issue.Title))
+ fmt.Fprintf(out,
+ "%s • %s opened %s • %s\n",
+ issueStateTitleWithColor(cs, issue.State),
issue.Author.Login,
utils.FuzzyAgo(ago),
utils.Pluralize(issue.Comments.TotalCount, "comment"),
- )))
+ )
+
+ // Reactions
+ if reactions := prShared.ReactionGroupList(issue.ReactionGroups); reactions != "" {
+ fmt.Fprint(out, reactions)
+ fmt.Fprintln(out)
+ }
// Metadata
- fmt.Fprintln(out)
if assignees := issueAssigneeList(*issue); assignees != "" {
- fmt.Fprint(out, utils.Bold("Assignees: "))
+ fmt.Fprint(out, cs.Bold("Assignees: "))
fmt.Fprintln(out, assignees)
}
if labels := shared.IssueLabelList(*issue); labels != "" {
- fmt.Fprint(out, utils.Bold("Labels: "))
+ fmt.Fprint(out, cs.Bold("Labels: "))
fmt.Fprintln(out, labels)
}
if projects := issueProjectList(*issue); projects != "" {
- fmt.Fprint(out, utils.Bold("Projects: "))
+ fmt.Fprint(out, cs.Bold("Projects: "))
fmt.Fprintln(out, projects)
}
if issue.Milestone.Title != "" {
- fmt.Fprint(out, utils.Bold("Milestone: "))
+ fmt.Fprint(out, cs.Bold("Milestone: "))
fmt.Fprintln(out, issue.Milestone.Title)
}
// Body
- if issue.Body != "" {
- fmt.Fprintln(out)
- style := markdown.GetStyle(io.TerminalTheme())
- md, err := markdown.Render(issue.Body, style)
+ var md string
+ var err error
+ if issue.Body == "" {
+ md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided"))
+ } else {
+ style := markdown.GetStyle(opts.IO.TerminalTheme())
+ md, err = markdown.Render(issue.Body, style, "")
if err != nil {
return err
}
- fmt.Fprintln(out, md)
}
- fmt.Fprintln(out)
+ fmt.Fprintf(out, "\n%s\n", md)
+
+ // Comments
+ if issue.Comments.TotalCount > 0 {
+ preview := !opts.Comments
+ comments, err := prShared.CommentList(opts.IO, issue.Comments, api.PullRequestReviews{}, preview)
+ if err != nil {
+ return err
+ }
+ fmt.Fprint(out, comments)
+ }
// Footer
- fmt.Fprintf(out, utils.Gray("View this issue on GitHub: %s\n"), issue.URL)
+ fmt.Fprintf(out, cs.Gray("View this issue on GitHub: %s\n"), issue.URL)
+
return nil
}
-func issueStateTitleWithColor(state string) string {
- colorFunc := prShared.ColorFuncForState(state)
+func issueStateTitleWithColor(cs *iostreams.ColorScheme, state string) string {
+ colorFunc := cs.ColorFromString(prShared.ColorForState(state))
return colorFunc(strings.Title(strings.ToLower(state)))
}
diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go
index 0567c87ff..a3a42bc93 100644
--- a/pkg/cmd/issue/view/view_test.go
+++ b/pkg/cmd/issue/view/view_test.go
@@ -2,10 +2,10 @@ package view
import (
"bytes"
+ "fmt"
"io/ioutil"
"net/http"
"os/exec"
- "reflect"
"testing"
"github.com/cli/cli/internal/config"
@@ -16,15 +16,9 @@ import (
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
+ "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 runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
@@ -67,12 +61,15 @@ func TestIssueView_web(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "hasIssuesEnabled": true, "issue": {
- "number": 123,
- "url": "https://github.com/OWNER/REPO/issues/123"
- } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query IssueByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "hasIssuesEnabled": true, "issue": {
+ "number": 123,
+ "url": "https://github.com/OWNER/REPO/issues/123"
+ } } } }
+ `),
+ )
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@@ -86,26 +83,29 @@ func TestIssueView_web(t *testing.T) {
t.Errorf("error running command `issue view`: %v", err)
}
- eq(t, output.String(), "")
- eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/123 in your browser.\n")
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", output.Stderr())
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, "https://github.com/OWNER/REPO/issues/123")
+ assert.Equal(t, "https://github.com/OWNER/REPO/issues/123", url)
}
func TestIssueView_web_numberArgWithHash(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "hasIssuesEnabled": true, "issue": {
- "number": 123,
- "url": "https://github.com/OWNER/REPO/issues/123"
- } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query IssueByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "hasIssuesEnabled": true, "issue": {
+ "number": 123,
+ "url": "https://github.com/OWNER/REPO/issues/123"
+ } } } }
+ `),
+ )
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@@ -119,14 +119,14 @@ func TestIssueView_web_numberArgWithHash(t *testing.T) {
t.Errorf("error running command `issue view`: %v", err)
}
- eq(t, output.String(), "")
- eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/issues/123 in your browser.\n")
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "Opening github.com/OWNER/REPO/issues/123 in your browser.\n", output.Stderr())
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, "https://github.com/OWNER/REPO/issues/123")
+ assert.Equal(t, "https://github.com/OWNER/REPO/issues/123", url)
}
func TestIssueView_nontty_Preview(t *testing.T) {
@@ -192,7 +192,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
t.Errorf("error running `issue view`: %v", err)
}
- eq(t, output.Stderr(), "")
+ assert.Equal(t, "", output.Stderr())
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
@@ -208,7 +208,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
fixture: "./fixtures/issueView_preview.json",
expectedOutputs: []string{
`ix of coins`,
- `Open.*marseilles opened about 292 years ago.*9 comments`,
+ `Open.*marseilles opened about 9 years ago.*9 comments`,
`bold story`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
},
@@ -217,7 +217,8 @@ func TestIssueView_tty_Preview(t *testing.T) {
fixture: "./fixtures/issueView_previewWithMetadata.json",
expectedOutputs: []string{
`ix of coins`,
- `Open.*marseilles opened about 292 years ago.*9 comments`,
+ `Open.*marseilles opened about 9 years ago.*9 comments`,
+ `8 \x{1f615} • 7 \x{1f440} • 6 \x{2764}\x{fe0f} • 5 \x{1f389} • 4 \x{1f604} • 3 \x{1f680} • 2 \x{1f44e} • 1 \x{1f44d}`,
`Assignees:.*marseilles, monaco\n`,
`Labels:.*one, two, three, four, five\n`,
`Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
@@ -230,7 +231,8 @@ func TestIssueView_tty_Preview(t *testing.T) {
fixture: "./fixtures/issueView_previewWithEmptyBody.json",
expectedOutputs: []string{
`ix of coins`,
- `Open.*marseilles opened about 292 years ago.*9 comments`,
+ `Open.*marseilles opened about 9 years ago.*9 comments`,
+ `No description provided`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
},
},
@@ -238,7 +240,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
fixture: "./fixtures/issueView_previewClosedState.json",
expectedOutputs: []string{
`ix of coins`,
- `Closed.*marseilles opened about 292 years ago.*9 comments`,
+ `Closed.*marseilles opened about 9 years ago.*9 comments`,
`bold story`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
},
@@ -256,7 +258,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
t.Errorf("error running `issue view`: %v", err)
}
- eq(t, output.Stderr(), "")
+ assert.Equal(t, "", output.Stderr())
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
@@ -267,11 +269,14 @@ func TestIssueView_web_notFound(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "errors": [
- { "message": "Could not resolve to an Issue with the number of 9999." }
- ] }
- `))
+ http.Register(
+ httpmock.GraphQL(`query IssueByNumber\b`),
+ httpmock.StringResponse(`
+ { "errors": [
+ { "message": "Could not resolve to an Issue with the number of 9999." }
+ ] }
+ `),
+ )
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@@ -294,12 +299,15 @@ func TestIssueView_disabledIssues(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "id": "REPOID",
- "hasIssuesEnabled": false
- } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query IssueByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "id": "REPOID",
+ "hasIssuesEnabled": false
+ } } }
+ `),
+ )
_, err := runCommand(http, true, `6666`)
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
@@ -311,12 +319,15 @@ func TestIssueView_web_urlArg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "hasIssuesEnabled": true, "issue": {
- "number": 123,
- "url": "https://github.com/OWNER/REPO/issues/123"
- } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query IssueByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "hasIssuesEnabled": true, "issue": {
+ "number": 123,
+ "url": "https://github.com/OWNER/REPO/issues/123"
+ } } } }
+ `),
+ )
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@@ -330,11 +341,155 @@ func TestIssueView_web_urlArg(t *testing.T) {
t.Errorf("error running command `issue view`: %v", err)
}
- eq(t, output.String(), "")
+ assert.Equal(t, "", output.String())
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, "https://github.com/OWNER/REPO/issues/123")
+ assert.Equal(t, "https://github.com/OWNER/REPO/issues/123", url)
+}
+
+func TestIssueView_tty_Comments(t *testing.T) {
+ tests := map[string]struct {
+ cli string
+ fixtures map[string]string
+ expectedOutputs []string
+ wantsErr bool
+ }{
+ "without comments flag": {
+ cli: "123",
+ fixtures: map[string]string{
+ "IssueByNumber": "./fixtures/issueView_previewSingleComment.json",
+ },
+ expectedOutputs: []string{
+ `some title`,
+ `some body`,
+ `———————— Not showing 4 comments ————————`,
+ `marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`,
+ `Comment 5`,
+ `Use --comments to view the full conversation`,
+ `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
+ },
+ },
+ "with comments flag": {
+ cli: "123 --comments",
+ fixtures: map[string]string{
+ "IssueByNumber": "./fixtures/issueView_previewSingleComment.json",
+ "CommentsForIssue": "./fixtures/issueView_previewFullComments.json",
+ },
+ expectedOutputs: []string{
+ `some title`,
+ `some body`,
+ `monalisa • Jan 1, 2020 • Edited`,
+ `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`,
+ `Comment 1`,
+ `johnnytest \(Contributor\) • Jan 1, 2020`,
+ `Comment 2`,
+ `elvisp \(Member\) • Jan 1, 2020`,
+ `Comment 3`,
+ `loislane \(Owner\) • Jan 1, 2020`,
+ `Comment 4`,
+ `marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`,
+ `Comment 5`,
+ `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
+ },
+ },
+ "with invalid comments flag": {
+ cli: "123 --comments 3",
+ wantsErr: true,
+ },
+ }
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ http := &httpmock.Registry{}
+ defer http.Verify(t)
+ for name, file := range tc.fixtures {
+ name := fmt.Sprintf(`query %s\b`, name)
+ http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
+ }
+ output, err := runCommand(http, true, tc.cli)
+ if tc.wantsErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.Stderr())
+ test.ExpectLines(t, output.String(), tc.expectedOutputs...)
+ })
+ }
+}
+
+func TestIssueView_nontty_Comments(t *testing.T) {
+ tests := map[string]struct {
+ cli string
+ fixtures map[string]string
+ expectedOutputs []string
+ wantsErr bool
+ }{
+ "without comments flag": {
+ cli: "123",
+ fixtures: map[string]string{
+ "IssueByNumber": "./fixtures/issueView_previewSingleComment.json",
+ },
+ expectedOutputs: []string{
+ `title:\tsome title`,
+ `state:\tOPEN`,
+ `author:\tmarseilles`,
+ `comments:\t5`,
+ `some body`,
+ },
+ },
+ "with comments flag": {
+ cli: "123 --comments",
+ fixtures: map[string]string{
+ "IssueByNumber": "./fixtures/issueView_previewSingleComment.json",
+ "CommentsForIssue": "./fixtures/issueView_previewFullComments.json",
+ },
+ expectedOutputs: []string{
+ `author:\tmonalisa`,
+ `association:\t`,
+ `edited:\ttrue`,
+ `Comment 1`,
+ `author:\tjohnnytest`,
+ `association:\tcontributor`,
+ `edited:\tfalse`,
+ `Comment 2`,
+ `author:\telvisp`,
+ `association:\tmember`,
+ `edited:\tfalse`,
+ `Comment 3`,
+ `author:\tloislane`,
+ `association:\towner`,
+ `edited:\tfalse`,
+ `Comment 4`,
+ `author:\tmarseilles`,
+ `association:\tcollaborator`,
+ `edited:\tfalse`,
+ `Comment 5`,
+ },
+ },
+ "with invalid comments flag": {
+ cli: "123 --comments 3",
+ wantsErr: true,
+ },
+ }
+ for name, tc := range tests {
+ t.Run(name, func(t *testing.T) {
+ http := &httpmock.Registry{}
+ defer http.Verify(t)
+ for name, file := range tc.fixtures {
+ name := fmt.Sprintf(`query %s\b`, name)
+ http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
+ }
+ output, err := runCommand(http, false, tc.cli)
+ if tc.wantsErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.Stderr())
+ test.ExpectLines(t, output.String(), tc.expectedOutputs...)
+ })
+ }
}
diff --git a/pkg/cmd/pr/checkout/checkout.go b/pkg/cmd/pr/checkout/checkout.go
index 30a59a7ca..34db96f87 100644
--- a/pkg/cmd/pr/checkout/checkout.go
+++ b/pkg/cmd/pr/checkout/checkout.go
@@ -1,7 +1,6 @@
package checkout
import (
- "errors"
"fmt"
"net/http"
"os"
@@ -17,6 +16,7 @@ import (
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
+ "github.com/cli/safeexec"
"github.com/spf13/cobra"
)
@@ -44,12 +44,7 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr
cmd := &cobra.Command{
Use: "checkout { | | }",
Short: "Check out a pull request in git",
- Args: func(cmd *cobra.Command, args []string) error {
- if len(args) == 0 {
- return &cmdutil.FlagError{Err: errors.New("argument required")}
- }
- return nil
- },
+ Args: cmdutil.MinimumArgs(1, "argument required"),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
@@ -171,7 +166,12 @@ func checkoutRun(opts *CheckoutOptions) error {
}
for _, args := range cmdQueue {
- cmd := exec.Command(args[0], args[1:]...)
+ // TODO: reuse the result of this lookup across loop iteration
+ exe, err := safeexec.LookPath(args[0])
+ if err != nil {
+ return err
+ }
+ cmd := exec.Command(exe, args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := run.PrepareCmd(cmd).Run(); err != nil {
diff --git a/pkg/cmd/pr/checkout/checkout_test.go b/pkg/cmd/pr/checkout/checkout_test.go
index 2ca060587..7513e69fe 100644
--- a/pkg/cmd/pr/checkout/checkout_test.go
+++ b/pkg/cmd/pr/checkout/checkout_test.go
@@ -7,7 +7,6 @@ import (
"io/ioutil"
"net/http"
"os/exec"
- "reflect"
"strings"
"testing"
@@ -25,13 +24,6 @@ import (
"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)
- }
-}
-
type errorStub struct {
message string
}
@@ -137,10 +129,10 @@ func TestPRCheckout_sameRepo(t *testing.T) {
if !assert.Equal(t, 4, len(ranCommands)) {
return
}
- eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature")
- eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature")
- eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin")
- eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature")
+ assert.Equal(t, "git fetch origin +refs/heads/feature:refs/remotes/origin/feature", strings.Join(ranCommands[0], " "))
+ assert.Equal(t, "git checkout -b feature --no-track origin/feature", strings.Join(ranCommands[1], " "))
+ assert.Equal(t, "git config branch.feature.remote origin", strings.Join(ranCommands[2], " "))
+ assert.Equal(t, "git config branch.feature.merge refs/heads/feature", strings.Join(ranCommands[3], " "))
}
func TestPRCheckout_urlArg(t *testing.T) {
@@ -174,11 +166,11 @@ func TestPRCheckout_urlArg(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "master", `https://github.com/OWNER/REPO/pull/123/files`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
- eq(t, len(ranCommands), 4)
- eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature")
+ assert.Equal(t, 4, len(ranCommands))
+ assert.Equal(t, "git checkout -b feature --no-track origin/feature", strings.Join(ranCommands[1], " "))
}
func TestPRCheckout_urlArg_differentBase(t *testing.T) {
@@ -213,8 +205,8 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "master", `https://github.com/OTHER/POE/pull/123/files`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
reqBody := struct {
@@ -225,12 +217,12 @@ func TestPRCheckout_urlArg_differentBase(t *testing.T) {
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
- eq(t, reqBody.Variables.Owner, "OTHER")
- eq(t, reqBody.Variables.Repo, "POE")
+ assert.Equal(t, "OTHER", reqBody.Variables.Owner)
+ assert.Equal(t, "POE", reqBody.Variables.Repo)
- eq(t, len(ranCommands), 5)
- eq(t, strings.Join(ranCommands[1], " "), "git fetch https://github.com/OTHER/POE.git refs/pull/123/head:feature")
- eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.remote https://github.com/OTHER/POE.git")
+ assert.Equal(t, 5, len(ranCommands))
+ assert.Equal(t, "git fetch https://github.com/OTHER/POE.git refs/pull/123/head:feature", strings.Join(ranCommands[1], " "))
+ assert.Equal(t, "git config branch.feature.remote https://github.com/OTHER/POE.git", strings.Join(ranCommands[3], " "))
}
func TestPRCheckout_branchArg(t *testing.T) {
@@ -265,11 +257,11 @@ func TestPRCheckout_branchArg(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "master", `hubot:feature`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
- eq(t, len(ranCommands), 5)
- eq(t, strings.Join(ranCommands[1], " "), "git fetch origin refs/pull/123/head:feature")
+ assert.Equal(t, 5, len(ranCommands))
+ assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[1], " "))
}
func TestPRCheckout_existingBranch(t *testing.T) {
@@ -304,13 +296,13 @@ func TestPRCheckout_existingBranch(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "master", `123`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
- eq(t, len(ranCommands), 3)
- eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature")
- eq(t, strings.Join(ranCommands[1], " "), "git checkout feature")
- eq(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature")
+ assert.Equal(t, 3, len(ranCommands))
+ assert.Equal(t, "git fetch origin +refs/heads/feature:refs/remotes/origin/feature", strings.Join(ranCommands[0], " "))
+ assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " "))
+ assert.Equal(t, "git merge --ff-only refs/remotes/origin/feature", strings.Join(ranCommands[2], " "))
}
func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
@@ -356,14 +348,14 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, remotes, "master", `123`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
- eq(t, len(ranCommands), 4)
- eq(t, strings.Join(ranCommands[0], " "), "git fetch robot-fork +refs/heads/feature:refs/remotes/robot-fork/feature")
- eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track robot-fork/feature")
- eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote robot-fork")
- eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature")
+ assert.Equal(t, 4, len(ranCommands))
+ assert.Equal(t, "git fetch robot-fork +refs/heads/feature:refs/remotes/robot-fork/feature", strings.Join(ranCommands[0], " "))
+ assert.Equal(t, "git checkout -b feature --no-track robot-fork/feature", strings.Join(ranCommands[1], " "))
+ assert.Equal(t, "git config branch.feature.remote robot-fork", strings.Join(ranCommands[2], " "))
+ assert.Equal(t, "git config branch.feature.merge refs/heads/feature", strings.Join(ranCommands[3], " "))
}
func TestPRCheckout_differentRepo(t *testing.T) {
@@ -398,14 +390,14 @@ func TestPRCheckout_differentRepo(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "master", `123`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
- eq(t, len(ranCommands), 4)
- eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature")
- eq(t, strings.Join(ranCommands[1], " "), "git checkout feature")
- eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin")
- eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/pull/123/head")
+ assert.Equal(t, 4, len(ranCommands))
+ assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " "))
+ assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " "))
+ assert.Equal(t, "git config branch.feature.remote origin", strings.Join(ranCommands[2], " "))
+ assert.Equal(t, "git config branch.feature.merge refs/pull/123/head", strings.Join(ranCommands[3], " "))
}
func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
@@ -440,12 +432,12 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "master", `123`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
- eq(t, len(ranCommands), 2)
- eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature")
- eq(t, strings.Join(ranCommands[1], " "), "git checkout feature")
+ assert.Equal(t, 2, len(ranCommands))
+ assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " "))
+ assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " "))
}
func TestPRCheckout_detachedHead(t *testing.T) {
@@ -480,12 +472,12 @@ func TestPRCheckout_detachedHead(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "", `123`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
- eq(t, len(ranCommands), 2)
- eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature")
- eq(t, strings.Join(ranCommands[1], " "), "git checkout feature")
+ assert.Equal(t, 2, len(ranCommands))
+ assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " "))
+ assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " "))
}
func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
@@ -520,12 +512,12 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "feature", `123`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
- eq(t, len(ranCommands), 2)
- eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head")
- eq(t, strings.Join(ranCommands[1], " "), "git merge --ff-only FETCH_HEAD")
+ assert.Equal(t, 2, len(ranCommands))
+ assert.Equal(t, "git fetch origin refs/pull/123/head", strings.Join(ranCommands[0], " "))
+ assert.Equal(t, "git merge --ff-only FETCH_HEAD", strings.Join(ranCommands[1], " "))
}
func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) {
@@ -554,9 +546,7 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "master", `123`)
- if assert.Errorf(t, err, "expected command to fail") {
- assert.Equal(t, `invalid branch name: "-foo"`, err.Error())
- }
+ assert.EqualError(t, err, `invalid branch name: "-foo"`)
assert.Equal(t, "", output.Stderr())
}
@@ -592,14 +582,14 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "master", `123`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
- eq(t, len(ranCommands), 4)
- eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature")
- eq(t, strings.Join(ranCommands[1], " "), "git checkout feature")
- eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote https://github.com/hubot/REPO.git")
- eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature")
+ assert.Equal(t, 4, len(ranCommands))
+ assert.Equal(t, "git fetch origin refs/pull/123/head:feature", strings.Join(ranCommands[0], " "))
+ assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " "))
+ assert.Equal(t, "git config branch.feature.remote https://github.com/hubot/REPO.git", strings.Join(ranCommands[2], " "))
+ assert.Equal(t, "git config branch.feature.merge refs/heads/feature", strings.Join(ranCommands[3], " "))
}
func TestPRCheckout_recurseSubmodules(t *testing.T) {
@@ -633,13 +623,13 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) {
defer restoreCmd()
output, err := runCommand(http, nil, "master", `123 --recurse-submodules`)
- eq(t, err, nil)
- eq(t, output.String(), "")
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.String())
- eq(t, len(ranCommands), 5)
- eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature")
- eq(t, strings.Join(ranCommands[1], " "), "git checkout feature")
- eq(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature")
- eq(t, strings.Join(ranCommands[3], " "), "git submodule sync --recursive")
- eq(t, strings.Join(ranCommands[4], " "), "git submodule update --init --recursive")
+ assert.Equal(t, 5, len(ranCommands))
+ assert.Equal(t, "git fetch origin +refs/heads/feature:refs/remotes/origin/feature", strings.Join(ranCommands[0], " "))
+ assert.Equal(t, "git checkout feature", strings.Join(ranCommands[1], " "))
+ assert.Equal(t, "git merge --ff-only refs/remotes/origin/feature", strings.Join(ranCommands[2], " "))
+ assert.Equal(t, "git submodule sync --recursive", strings.Join(ranCommands[3], " "))
+ assert.Equal(t, "git submodule update --init --recursive", strings.Join(ranCommands[4], " "))
}
diff --git a/pkg/cmd/pr/checks/checks.go b/pkg/cmd/pr/checks/checks.go
index f267c9854..89eb8e5d3 100644
--- a/pkg/cmd/pr/checks/checks.go
+++ b/pkg/cmd/pr/checks/checks.go
@@ -24,6 +24,8 @@ type ChecksOptions struct {
Branch func() (string, error)
Remotes func() (context.Remotes, error)
+ WebMode bool
+
SelectorArg string
}
@@ -60,6 +62,8 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co
},
}
+ cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to show details about checks")
+
return cmd
}
@@ -70,18 +74,28 @@ func checksRun(opts *ChecksOptions) error {
}
apiClient := api.NewClientFromHTTP(httpClient)
- pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
+ pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
if err != nil {
return err
}
if len(pr.Commits.Nodes) == 0 {
- return nil
+ return fmt.Errorf("no commit found on the pull request")
}
rollup := pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes
if len(rollup) == 0 {
- return nil
+ return fmt.Errorf("no checks reported on the '%s' branch", pr.BaseRefName)
+ }
+
+ isTerminal := opts.IO.IsStdoutTTY()
+
+ if opts.WebMode {
+ openURL := ghrepo.GenerateRepoURL(baseRepo, "pull/%d/checks", pr.Number)
+ if isTerminal {
+ fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
+ }
+ return utils.OpenInBrowser(openURL)
}
passing := 0
@@ -97,13 +111,15 @@ func checksRun(opts *ChecksOptions) error {
markColor func(string) string
}
+ cs := opts.IO.ColorScheme()
+
outputs := []output{}
for _, c := range pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes {
mark := "✓"
bucket := "pass"
state := c.State
- markColor := utils.Green
+ markColor := cs.Green
if state == "" {
if c.Status == "COMPLETED" {
state = c.Conclusion
@@ -116,12 +132,12 @@ func checksRun(opts *ChecksOptions) error {
passing++
case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED":
mark = "X"
- markColor = utils.Red
+ markColor = cs.Red
failing++
bucket = "fail"
case "EXPECTED", "REQUESTED", "QUEUED", "PENDING", "IN_PROGRESS", "STALE":
mark = "-"
- markColor = utils.Yellow
+ markColor = cs.Yellow
pending++
bucket = "pending"
default:
@@ -162,9 +178,8 @@ func checksRun(opts *ChecksOptions) error {
if b0 == b1 {
if n0 == n1 {
return l0 < l1
- } else {
- return n0 < n1
}
+ return n0 < n1
}
return (b0 == "fail") || (b0 == "pending" && b1 == "success")
@@ -173,7 +188,7 @@ func checksRun(opts *ChecksOptions) error {
tp := utils.NewTablePrinter(opts.IO)
for _, o := range outputs {
- if opts.IO.IsStdoutTTY() {
+ if isTerminal {
tp.AddField(o.mark, nil, o.markColor)
tp.AddField(o.name, nil, nil)
tp.AddField(o.elapsed, nil, nil)
@@ -206,10 +221,10 @@ func checksRun(opts *ChecksOptions) error {
"%d failing, %d successful, and %d pending checks",
failing, passing, pending)
- summary = fmt.Sprintf("%s\n%s", utils.Bold(summary), tallies)
+ summary = fmt.Sprintf("%s\n%s", cs.Bold(summary), tallies)
}
- if opts.IO.IsStdoutTTY() {
+ if isTerminal {
fmt.Fprintln(opts.IO.Out, summary)
fmt.Fprintln(opts.IO.Out)
}
diff --git a/pkg/cmd/pr/checks/checks_test.go b/pkg/cmd/pr/checks/checks_test.go
index 43dbf8cda..4e9167353 100644
--- a/pkg/cmd/pr/checks/checks_test.go
+++ b/pkg/cmd/pr/checks/checks_test.go
@@ -9,6 +9,7 @@ import (
"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"
)
@@ -66,107 +67,104 @@ func Test_checksRun(t *testing.T) {
name string
fixture string
stubs func(*httpmock.Registry)
- wantOut string
nontty bool
- wantErr bool
+ wantOut string
+ wantErr string
}{
{
name: "no commits",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
- httpmock.JSONResponse(
- bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 123 }
- } } }
- `)))
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "number": 123 }
+ } } }
+ `))
},
+ wantOut: "",
+ wantErr: "no commit found on the pull request",
},
{
name: "no checks",
stubs: func(reg *httpmock.Registry) {
- reg.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]} }
- } } }
- `))
+ reg.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }
+ } } }
+ `))
},
+ wantOut: "",
+ wantErr: "no checks reported on the 'master' branch",
},
{
name: "some failing",
fixture: "./fixtures/someFailing.json",
wantOut: "Some checks were not successful\n1 failing, 1 successful, and 1 pending checks\n\nX sad tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n- slow tests 1m26s sweet link\n",
- wantErr: true,
+ wantErr: "SilentError",
},
{
name: "some pending",
fixture: "./fixtures/somePending.json",
wantOut: "Some checks are still pending\n0 failing, 2 successful, and 1 pending checks\n\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n- slow tests 1m26s sweet link\n",
- wantErr: true,
+ wantErr: "SilentError",
},
{
name: "all passing",
fixture: "./fixtures/allPassing.json",
wantOut: "All checks were successful\n0 failing, 3 successful, and 0 pending checks\n\n✓ awesome tests 1m26s sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n",
+ wantErr: "",
},
{
name: "with statuses",
fixture: "./fixtures/withStatuses.json",
wantOut: "Some checks were not successful\n1 failing, 2 successful, and 0 pending checks\n\nX a status sweet link\n✓ cool tests 1m26s sweet link\n✓ rad tests 1m26s sweet link\n",
- wantErr: true,
- },
- {
- name: "no commits",
- nontty: true,
- stubs: func(reg *httpmock.Registry) {
- reg.Register(
- httpmock.GraphQL(`query PullRequestByNumber\b`),
- httpmock.JSONResponse(
- bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 123 }
- } } }
- `)))
- },
+ wantErr: "SilentError",
},
{
name: "no checks",
nontty: true,
stubs: func(reg *httpmock.Registry) {
- reg.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]} }
- } } }
+ reg.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }
+ } } }
`))
},
+ wantOut: "",
+ wantErr: "no checks reported on the 'master' branch",
},
{
name: "some failing",
nontty: true,
fixture: "./fixtures/someFailing.json",
wantOut: "sad tests\tfail\t1m26s\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nslow tests\tpending\t1m26s\tsweet link\n",
- wantErr: true,
+ wantErr: "SilentError",
},
{
name: "some pending",
nontty: true,
fixture: "./fixtures/somePending.json",
wantOut: "cool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\nslow tests\tpending\t1m26s\tsweet link\n",
- wantErr: true,
+ wantErr: "SilentError",
},
{
name: "all passing",
nontty: true,
fixture: "./fixtures/allPassing.json",
wantOut: "awesome tests\tpass\t1m26s\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\n",
+ wantErr: "",
},
{
name: "with statuses",
nontty: true,
fixture: "./fixtures/withStatuses.json",
wantOut: "a status\tfail\t0\tsweet link\ncool tests\tpass\t1m26s\tsweet link\nrad tests\tpass\t1m26s\tsweet link\n",
- wantErr: true,
+ wantErr: "SilentError",
},
}
@@ -184,13 +182,12 @@ func Test_checksRun(t *testing.T) {
}
reg := &httpmock.Registry{}
+ defer reg.Verify(t)
+
if tt.stubs != nil {
tt.stubs(reg)
} else if tt.fixture != "" {
- reg.Register(
- httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse(tt.fixture))
- } else {
- panic("need either stubs or fixture key")
+ reg.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse(tt.fixture))
}
opts.HttpClient = func() (*http.Client, error) {
@@ -198,13 +195,72 @@ func Test_checksRun(t *testing.T) {
}
err := checksRun(opts)
- if tt.wantErr {
- assert.Equal(t, "SilentError", err.Error())
+ if tt.wantErr != "" {
+ assert.EqualError(t, err, tt.wantErr)
} else {
assert.NoError(t, err)
}
assert.Equal(t, tt.wantOut, stdout.String())
+ })
+ }
+}
+
+func TestChecksRun_web(t *testing.T) {
+ tests := []struct {
+ name string
+ isTTY bool
+ wantStderr string
+ wantStdout string
+ }{
+ {
+ name: "tty",
+ isTTY: true,
+ wantStderr: "Opening github.com/OWNER/REPO/pull/123/checks in your browser.\n",
+ wantStdout: "",
+ },
+ {
+ name: "nontty",
+ isTTY: false,
+ wantStderr: "",
+ wantStdout: "",
+ },
+ }
+
+ reg := &httpmock.Registry{}
+
+ opts := &ChecksOptions{
+ WebMode: true,
+ HttpClient: func() (*http.Client, error) {
+ return &http.Client{Transport: reg}, nil
+ },
+ BaseRepo: func() (ghrepo.Interface, error) {
+ return ghrepo.New("OWNER", "REPO"), nil
+ },
+ SelectorArg: "123",
+ }
+
+ for _, tc := range tests {
+ t.Run(tc.name, func(t *testing.T) {
+ reg.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse("./fixtures/allPassing.json"))
+
+ io, _, stdout, stderr := iostreams.Test()
+ io.SetStdoutTTY(tc.isTTY)
+ io.SetStdinTTY(tc.isTTY)
+ io.SetStderrTTY(tc.isTTY)
+
+ opts.IO = io
+
+ cs, teardown := test.InitCmdStubber()
+ defer teardown()
+
+ cs.Stub("") // browser open
+
+ err := checksRun(opts)
+ assert.NoError(t, err)
+ assert.Equal(t, tc.wantStdout, stdout.String())
+ assert.Equal(t, tc.wantStderr, stderr.String())
reg.Verify(t)
})
}
diff --git a/pkg/cmd/pr/close/close.go b/pkg/cmd/pr/close/close.go
index 7ff257855..6b6cc8c9a 100644
--- a/pkg/cmd/pr/close/close.go
+++ b/pkg/cmd/pr/close/close.go
@@ -11,7 +11,6 @@ import (
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
- "github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
@@ -61,6 +60,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
@@ -73,10 +74,10 @@ func closeRun(opts *CloseOptions) error {
}
if pr.State == "MERGED" {
- fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be closed because it was already merged", utils.Red("!"), pr.Number, pr.Title)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be closed because it was already merged", cs.Red("!"), pr.Number, pr.Title)
return cmdutil.SilentError
} else if pr.Closed {
- fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already closed\n", utils.Yellow("!"), pr.Number, pr.Title)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already closed\n", cs.Yellow("!"), pr.Number, pr.Title)
return nil
}
@@ -85,7 +86,7 @@ func closeRun(opts *CloseOptions) error {
return fmt.Errorf("API call failed: %w", err)
}
- fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", utils.Red("✔"), pr.Number, pr.Title)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", cs.Red("✔"), pr.Number, pr.Title)
crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner()
@@ -114,24 +115,24 @@ func closeRun(opts *CloseOptions) error {
if localBranchExists {
err = git.DeleteLocalBranch(pr.HeadRefName)
if err != nil {
- err = fmt.Errorf("failed to delete local branch %s: %w", utils.Cyan(pr.HeadRefName), err)
+ err = fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err)
return err
}
}
if branchToSwitchTo != "" {
- branchSwitchString = fmt.Sprintf(" and switched to branch %s", utils.Cyan(branchToSwitchTo))
+ branchSwitchString = fmt.Sprintf(" and switched to branch %s", cs.Cyan(branchToSwitchTo))
}
}
if !crossRepoPR {
err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName)
if err != nil {
- err = fmt.Errorf("failed to delete remote branch %s: %w", utils.Cyan(pr.HeadRefName), err)
+ err = fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err)
return err
}
}
- fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", utils.Red("✔"), utils.Cyan(pr.HeadRefName), branchSwitchString)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", cs.Red("✔"), cs.Cyan(pr.HeadRefName), branchSwitchString)
}
return nil
diff --git a/pkg/cmd/pr/close/close_test.go b/pkg/cmd/pr/close/close_test.go
index 136554b4f..3154b4e4b 100644
--- a/pkg/cmd/pr/close/close_test.go
+++ b/pkg/cmd/pr/close/close_test.go
@@ -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) {
@@ -61,13 +62,20 @@ func TestPrClose(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 96, "title": "The title of the PR" }
- } } }
- `))
-
- http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "id": "THE-ID", "number": 96, "title": "The title of the PR" }
+ } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestClose\b`),
+ httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["pullRequestId"], "THE-ID")
+ }),
+ )
output, err := runCommand(http, true, "96")
if err != nil {
@@ -85,11 +93,13 @@ func TestPrClose_alreadyClosed(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 101, "title": "The title of the PR", "closed": true }
- } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "number": 101, "title": "The title of the PR", "closed": true }
+ } } }`),
+ )
output, err := runCommand(http, true, "101")
if err != nil {
@@ -106,12 +116,21 @@ func TestPrClose_alreadyClosed(t *testing.T) {
func TestPrClose_deleteBranch(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 96, "title": "The title of the PR", "headRefName":"blueberries", "headRepositoryOwner": {"login": "OWNER"}}
- } } }
- `))
- http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "id": "THE-ID", "number": 96, "title": "The title of the PR", "headRefName":"blueberries", "headRepositoryOwner": {"login": "OWNER"}}
+ } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestClose\b`),
+ httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["pullRequestId"], "THE-ID")
+ }),
+ )
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
httpmock.StringResponse(`{}`))
diff --git a/pkg/cmd/pr/comment/comment.go b/pkg/cmd/pr/comment/comment.go
new file mode 100644
index 000000000..27847b4fa
--- /dev/null
+++ b/pkg/cmd/pr/comment/comment.go
@@ -0,0 +1,78 @@
+package comment
+
+import (
+ "errors"
+ "net/http"
+
+ "github.com/MakeNowJust/heredoc"
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/context"
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmd/pr/shared"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/utils"
+ "github.com/spf13/cobra"
+)
+
+func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) error) *cobra.Command {
+ opts := &shared.CommentableOptions{
+ IO: f.IOStreams,
+ HttpClient: f.HttpClient,
+ EditSurvey: shared.CommentableEditSurvey(f.Config, f.IOStreams),
+ InteractiveEditSurvey: shared.CommentableInteractiveEditSurvey(f.Config, f.IOStreams),
+ ConfirmSubmitSurvey: shared.CommentableConfirmSubmitSurvey,
+ OpenInBrowser: utils.OpenInBrowser,
+ }
+
+ cmd := &cobra.Command{
+ Use: "comment [ | | ]",
+ Short: "Create a new pr comment",
+ Example: heredoc.Doc(`
+ $ gh pr comment 22 --body "This looks great, lets get it deployed."
+ `),
+ PreRunE: func(cmd *cobra.Command, args []string) error {
+ if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
+ return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
+ }
+ var selector string
+ if len(args) > 0 {
+ selector = args[0]
+ }
+ opts.RetrieveCommentable = retrievePR(f.HttpClient, f.BaseRepo, f.Branch, f.Remotes, selector)
+ return shared.CommentablePreRun(cmd, opts)
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if runF != nil {
+ return runF(opts)
+ }
+ return shared.CommentableRun(opts)
+ },
+ }
+
+ cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "Supply a body. Will prompt for one otherwise.")
+ cmd.Flags().BoolP("editor", "e", false, "Add body using editor")
+ cmd.Flags().BoolP("web", "w", false, "Add body in browser")
+
+ return cmd
+}
+
+func retrievePR(httpClient func() (*http.Client, error),
+ baseRepo func() (ghrepo.Interface, error),
+ branch func() (string, error),
+ remotes func() (context.Remotes, error),
+ selector string) func() (shared.Commentable, ghrepo.Interface, error) {
+ return func() (shared.Commentable, ghrepo.Interface, error) {
+ httpClient, err := httpClient()
+ if err != nil {
+ return nil, nil, err
+ }
+ apiClient := api.NewClientFromHTTP(httpClient)
+
+ pr, repo, err := shared.PRFromArgs(apiClient, baseRepo, branch, remotes, selector)
+ if err != nil {
+ return nil, nil, err
+ }
+
+ return pr, repo, nil
+ }
+}
diff --git a/pkg/cmd/pr/comment/comment_test.go b/pkg/cmd/pr/comment/comment_test.go
new file mode 100644
index 000000000..52256d431
--- /dev/null
+++ b/pkg/cmd/pr/comment/comment_test.go
@@ -0,0 +1,278 @@
+package comment
+
+import (
+ "bytes"
+ "net/http"
+ "testing"
+
+ "github.com/cli/cli/context"
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmd/pr/shared"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/pkg/httpmock"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/google/shlex"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewCmdComment(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ output shared.CommentableOptions
+ wantsErr bool
+ }{
+ {
+ name: "no arguments",
+ input: "",
+ output: shared.CommentableOptions{
+ Interactive: true,
+ InputType: 0,
+ Body: "",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "pr number",
+ input: "1",
+ output: shared.CommentableOptions{
+ Interactive: true,
+ InputType: 0,
+ Body: "",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "pr url",
+ input: "https://github.com/OWNER/REPO/pull/12",
+ output: shared.CommentableOptions{
+ Interactive: true,
+ InputType: 0,
+ Body: "",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "pr branch",
+ input: "branch-name",
+ output: shared.CommentableOptions{
+ Interactive: true,
+ InputType: 0,
+ Body: "",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "body flag",
+ input: "1 --body test",
+ output: shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeInline,
+ Body: "test",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "editor flag",
+ input: "1 --editor",
+ output: shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeEditor,
+ Body: "",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "web flag",
+ input: "1 --web",
+ output: shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeWeb,
+ Body: "",
+ },
+ wantsErr: false,
+ },
+ {
+ name: "editor and web flags",
+ input: "1 --editor --web",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
+ {
+ name: "editor and body flags",
+ input: "1 --editor --body test",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
+ {
+ name: "web and body flags",
+ input: "1 --web --body test",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
+ {
+ name: "editor, web, and body flags",
+ input: "1 --editor --web --body test",
+ output: shared.CommentableOptions{},
+ wantsErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ io, _, _, _ := iostreams.Test()
+ io.SetStdoutTTY(true)
+ io.SetStdinTTY(true)
+ io.SetStderrTTY(true)
+
+ f := &cmdutil.Factory{
+ IOStreams: io,
+ }
+
+ argv, err := shlex.Split(tt.input)
+ assert.NoError(t, err)
+
+ var gotOpts *shared.CommentableOptions
+ cmd := NewCmdComment(f, func(opts *shared.CommentableOptions) 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.Interactive, gotOpts.Interactive)
+ assert.Equal(t, tt.output.InputType, gotOpts.InputType)
+ assert.Equal(t, tt.output.Body, gotOpts.Body)
+ })
+ }
+}
+
+func Test_commentRun(t *testing.T) {
+ tests := []struct {
+ name string
+ input *shared.CommentableOptions
+ httpStubs func(*testing.T, *httpmock.Registry)
+ stdout string
+ stderr string
+ }{
+ {
+ name: "interactive editor",
+ input: &shared.CommentableOptions{
+ Interactive: true,
+ InputType: 0,
+ Body: "",
+
+ InteractiveEditSurvey: func() (string, error) { return "comment body", nil },
+ ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockPullRequestFromNumber(t, reg)
+ mockCommentCreate(t, reg)
+ },
+ stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
+ },
+ {
+ name: "non-interactive web",
+ input: &shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeWeb,
+ Body: "",
+
+ OpenInBrowser: func(string) error { return nil },
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockPullRequestFromNumber(t, reg)
+ },
+ stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n",
+ },
+ {
+ name: "non-interactive editor",
+ input: &shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeEditor,
+ Body: "",
+
+ EditSurvey: func() (string, error) { return "comment body", nil },
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockPullRequestFromNumber(t, reg)
+ mockCommentCreate(t, reg)
+ },
+ stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
+ },
+ {
+ name: "non-interactive inline",
+ input: &shared.CommentableOptions{
+ Interactive: false,
+ InputType: shared.InputTypeInline,
+ Body: "comment body",
+ },
+ httpStubs: func(t *testing.T, reg *httpmock.Registry) {
+ mockPullRequestFromNumber(t, reg)
+ mockCommentCreate(t, reg)
+ },
+ stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
+ },
+ }
+ for _, tt := range tests {
+ io, _, stdout, stderr := iostreams.Test()
+ io.SetStdoutTTY(true)
+ io.SetStdinTTY(true)
+ io.SetStderrTTY(true)
+
+ reg := &httpmock.Registry{}
+ defer reg.Verify(t)
+ tt.httpStubs(t, reg)
+
+ httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
+ baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
+ branch := func() (string, error) { return "", nil }
+ remotes := func() (context.Remotes, error) { return nil, nil }
+
+ tt.input.IO = io
+ tt.input.HttpClient = httpClient
+ tt.input.RetrieveCommentable = retrievePR(httpClient, baseRepo, branch, remotes, "123")
+
+ t.Run(tt.name, func(t *testing.T) {
+ err := shared.CommentableRun(tt.input)
+ assert.NoError(t, err)
+ assert.Equal(t, tt.stdout, stdout.String())
+ assert.Equal(t, tt.stderr, stderr.String())
+ })
+ }
+}
+
+func mockPullRequestFromNumber(_ *testing.T, reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequest": {
+ "number": 123,
+ "url": "https://github.com/OWNER/REPO/pull/123"
+ } } } }`),
+ )
+}
+
+func mockCommentCreate(t *testing.T, reg *httpmock.Registry) {
+ reg.Register(
+ httpmock.GraphQL(`mutation CommentCreate\b`),
+ httpmock.GraphQLMutation(`
+ { "data": { "addComment": { "commentEdge": { "node": {
+ "url": "https://github.com/OWNER/REPO/pull/123#issuecomment-456"
+ } } } } }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, "comment body", inputs["body"])
+ }),
+ )
+}
diff --git a/pkg/cmd/pr/create/create.go b/pkg/cmd/pr/create/create.go
index 670491ac7..009c567a7 100644
--- a/pkg/cmd/pr/create/create.go
+++ b/pkg/cmd/pr/create/create.go
@@ -5,6 +5,7 @@ import (
"fmt"
"net/http"
"net/url"
+ "regexp"
"strings"
"time"
@@ -17,7 +18,6 @@ import (
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
- "github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/utils"
@@ -25,22 +25,22 @@ import (
)
type CreateOptions struct {
+ // This struct stores user input and factory functions
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
Remotes func() (context.Remotes, error)
Branch func() (string, error)
- Interactive bool
-
TitleProvided bool
BodyProvided bool
RootDirOverride string
RepoOverride string
- Autofill bool
- WebMode bool
+ Autofill bool
+ WebMode bool
+ RecoverFile string
IsDraft bool
Title string
@@ -53,6 +53,23 @@ type CreateOptions struct {
Labels []string
Projects []string
Milestone string
+
+ MaintainerCanModify bool
+}
+
+type CreateContext struct {
+ // This struct stores contextual data about the creation process and is for building up enough
+ // data to create a pull request
+ RepoContext *context.ResolvedRemotes
+ BaseRepo ghrepo.Interface
+ HeadRepo ghrepo.Interface
+ BaseTrackingBranch string
+ BaseBranch string
+ HeadBranch string
+ HeadBranchLabel string
+ HeadRemote *context.Remote
+ IsPushEnabled bool
+ Client *api.Client
}
func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Command {
@@ -76,10 +93,14 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
A prompt will also ask for the title and the body of the pull request. Use '--title'
and '--body' to skip this, or use '--fill' to autofill these values from git commits.
+
+ By default users with write access to the base respository can add new commits to your branch.
+ If undesired, you may disable access of maintainers by using '--no-maintainer-edit'
+ You can always change this setting later via the web interface.
`),
Example: heredoc.Doc(`
$ gh pr create --title "The bug is fixed" --body "Everything works again"
- $ gh pr create --reviewer monalisa,hubot
+ $ gh pr create --reviewer monalisa,hubot --reviewer myorg/team-name
$ gh pr create --project "Roadmap"
$ gh pr create --base develop --head monalisa:feature
`),
@@ -88,8 +109,12 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
opts.TitleProvided = cmd.Flags().Changed("title")
opts.BodyProvided = cmd.Flags().Changed("body")
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
+ noMaintainerEdit, _ := cmd.Flags().GetBool("no-maintainer-edit")
+ opts.MaintainerCanModify = !noMaintainerEdit
- opts.Interactive = !(opts.TitleProvided && opts.BodyProvided)
+ if !opts.IO.CanPrompt() && opts.RecoverFile != "" {
+ return &cmdutil.FlagError{Err: errors.New("--recover only supported when running interactively")}
+ }
if !opts.IO.CanPrompt() && !opts.WebMode && !opts.TitleProvided && !opts.Autofill {
return &cmdutil.FlagError{Err: errors.New("--title or --fill required when not running interactively")}
@@ -101,6 +126,9 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
if len(opts.Reviewers) > 0 && opts.WebMode {
return errors.New("the --reviewer flag is not supported with --web")
}
+ if cmd.Flags().Changed("no-maintainer-edit") && opts.WebMode {
+ return errors.New("the --no-maintainer-edit flag is not supported with --web")
+ }
if runF != nil {
return runF(opts)
@@ -117,399 +145,198 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
fl.StringVarP(&opts.HeadBranch, "head", "H", "", "The `branch` that contains commits for your pull request (default: current branch)")
fl.BoolVarP(&opts.WebMode, "web", "w", false, "Open the web browser to create a pull request")
fl.BoolVarP(&opts.Autofill, "fill", "f", false, "Do not prompt for title/body and just use commit info")
- fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people by their `login`")
+ fl.StringSliceVarP(&opts.Reviewers, "reviewer", "r", nil, "Request reviews from people or teams by their `handle`")
fl.StringSliceVarP(&opts.Assignees, "assignee", "a", nil, "Assign people by their `login`")
fl.StringSliceVarP(&opts.Labels, "label", "l", nil, "Add labels by `name`")
fl.StringSliceVarP(&opts.Projects, "project", "p", nil, "Add the pull request to projects by `name`")
fl.StringVarP(&opts.Milestone, "milestone", "m", "", "Add the pull request to a milestone by `name`")
+ fl.Bool("no-maintainer-edit", false, "Disable maintainer's ability to modify pull request")
+ fl.StringVar(&opts.RecoverFile, "recover", "", "Recover input from a failed run of create")
return cmd
}
-func createRun(opts *CreateOptions) error {
- httpClient, err := opts.HttpClient()
+func createRun(opts *CreateOptions) (err error) {
+ ctx, err := NewCreateContext(opts)
if err != nil {
- return err
+ return
}
- client := api.NewClientFromHTTP(httpClient)
- remotes, err := opts.Remotes()
+ client := ctx.Client
+
+ state, err := NewIssueState(*ctx, *opts)
if err != nil {
- return err
+ return
}
- repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
- if err != nil {
- return err
- }
-
- var baseRepo *api.Repository
- if br, err := repoContext.BaseRepo(opts.IO); err == nil {
- if r, ok := br.(*api.Repository); ok {
- baseRepo = r
- } else {
- // TODO: if RepoNetwork is going to be requested anyway in `repoContext.HeadRepos()`,
- // consider piggybacking on that result instead of performing a separate lookup
- var err error
- baseRepo, err = api.GitHubRepo(client, br)
- if err != nil {
- return err
- }
- }
- } else {
- return fmt.Errorf("could not determine base repository: %w", err)
- }
-
- isPushEnabled := false
- headBranch := opts.HeadBranch
- headBranchLabel := opts.HeadBranch
- if headBranch == "" {
- headBranch, err = opts.Branch()
- if err != nil {
- return fmt.Errorf("could not determine the current branch: %w", err)
- }
- headBranchLabel = headBranch
- isPushEnabled = true
- } else if idx := strings.IndexRune(headBranch, ':'); idx >= 0 {
- headBranch = headBranch[idx+1:]
- }
-
- if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 {
- fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change"))
- }
-
- var headRepo ghrepo.Interface
- var headRemote *context.Remote
-
- if isPushEnabled {
- // determine whether the head branch is already pushed to a remote
- if pushedTo := determineTrackingBranch(remotes, headBranch); pushedTo != nil {
- isPushEnabled = false
- if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil {
- headRepo = r
- headRemote = r
- headBranchLabel = pushedTo.BranchName
- if !ghrepo.IsSame(baseRepo, headRepo) {
- headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), pushedTo.BranchName)
- }
- }
- }
- }
-
- // otherwise, ask the user for the head repository using info obtained from the API
- if headRepo == nil && isPushEnabled && opts.IO.CanPrompt() {
- pushableRepos, err := repoContext.HeadRepos()
- if err != nil {
- return err
- }
-
- if len(pushableRepos) == 0 {
- pushableRepos, err = api.RepoFindForks(client, baseRepo, 3)
- if err != nil {
- return err
- }
- }
-
- currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost())
- if err != nil {
- return err
- }
-
- hasOwnFork := false
- var pushOptions []string
- for _, r := range pushableRepos {
- pushOptions = append(pushOptions, ghrepo.FullName(r))
- if r.RepoOwner() == currentLogin {
- hasOwnFork = true
- }
- }
-
- if !hasOwnFork {
- pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(baseRepo))
- }
- pushOptions = append(pushOptions, "Skip pushing the branch")
- pushOptions = append(pushOptions, "Cancel")
-
- var selectedOption int
- err = prompt.SurveyAskOne(&survey.Select{
- Message: fmt.Sprintf("Where should we push the '%s' branch?", headBranch),
- Options: pushOptions,
- }, &selectedOption)
- if err != nil {
- return err
- }
-
- if selectedOption < len(pushableRepos) {
- headRepo = pushableRepos[selectedOption]
- if !ghrepo.IsSame(baseRepo, headRepo) {
- headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
- }
- } else if pushOptions[selectedOption] == "Skip pushing the branch" {
- isPushEnabled = false
- } else if pushOptions[selectedOption] == "Cancel" {
- return cmdutil.SilentError
- } else {
- // "Create a fork of ..."
- if baseRepo.IsPrivate {
- return fmt.Errorf("cannot fork private repository %s", ghrepo.FullName(baseRepo))
- }
- headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, headBranch)
- }
- }
-
- if headRepo == nil && isPushEnabled && !opts.IO.CanPrompt() {
- fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag")
- return cmdutil.SilentError
- }
-
- baseBranch := opts.BaseBranch
- if baseBranch == "" {
- baseBranch = baseRepo.DefaultBranchRef.Name
- }
- if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) {
- return fmt.Errorf("must be on a branch named differently than %q", baseBranch)
- }
-
- var milestoneTitles []string
- if opts.Milestone != "" {
- milestoneTitles = []string{opts.Milestone}
- }
-
- baseTrackingBranch := baseBranch
- if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil {
- baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch)
- }
- defs, defaultsErr := computeDefaults(baseTrackingBranch, headBranch)
-
- title := opts.Title
- body := opts.Body
-
- action := shared.SubmitAction
if opts.WebMode {
- action = shared.PreviewAction
- if (title == "" || body == "") && defaultsErr != nil {
- return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
+ if !opts.Autofill {
+ state.Title = opts.Title
+ state.Body = opts.Body
}
- } else if opts.Autofill {
- if defaultsErr != nil && !(opts.TitleProvided || opts.BodyProvided) {
- return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
+ err = handlePush(*opts, *ctx)
+ if err != nil {
+ return
}
- if !opts.TitleProvided {
- title = defs.Title
+ return previewPR(*opts, *ctx, *state)
+ }
+
+ if opts.TitleProvided {
+ state.Title = opts.Title
+ }
+
+ if opts.BodyProvided {
+ state.Body = opts.Body
+ }
+
+ existingPR, err := api.PullRequestForBranch(
+ client, ctx.BaseRepo, ctx.BaseBranch, ctx.HeadBranchLabel, []string{"OPEN"})
+ var notFound *api.NotFoundError
+ if err != nil && !errors.As(err, ¬Found) {
+ return fmt.Errorf("error checking for existing pull request: %w", err)
+ }
+ if err == nil {
+ return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s",
+ ctx.HeadBranchLabel, ctx.BaseBranch, existingPR.URL)
+ }
+
+ message := "\nCreating pull request for %s into %s in %s\n\n"
+ if state.Draft {
+ message = "\nCreating draft pull request for %s into %s in %s\n\n"
+ }
+
+ cs := opts.IO.ColorScheme()
+
+ if opts.IO.CanPrompt() {
+ fmt.Fprintf(opts.IO.ErrOut, message,
+ cs.Cyan(ctx.HeadBranchLabel),
+ cs.Cyan(ctx.BaseBranch),
+ ghrepo.FullName(ctx.BaseRepo))
+ }
+
+ if opts.Autofill || (opts.TitleProvided && opts.BodyProvided) {
+ err = handlePush(*opts, *ctx)
+ if err != nil {
+ return
}
- if !opts.BodyProvided {
- body = defs.Body
+ return submitPR(*opts, *ctx, *state)
+ }
+
+ if opts.RecoverFile != "" {
+ err = shared.FillFromJSON(opts.IO, opts.RecoverFile, state)
+ if err != nil {
+ return fmt.Errorf("failed to recover input: %w", err)
}
}
- if !opts.WebMode {
- existingPR, err := api.PullRequestForBranch(client, baseRepo, baseBranch, headBranchLabel)
- var notFound *api.NotFoundError
- if err != nil && !errors.As(err, ¬Found) {
- return fmt.Errorf("error checking for existing pull request: %w", err)
- }
- if err == nil {
- return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s", headBranchLabel, baseBranch, existingPR.URL)
+ if !opts.TitleProvided {
+ err = shared.TitleSurvey(state)
+ if err != nil {
+ return
}
}
- isTerminal := opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY()
+ editorCommand, err := cmdutil.DetermineEditor(opts.Config)
+ if err != nil {
+ return
+ }
- if !opts.WebMode && !opts.Autofill {
- message := "\nCreating pull request for %s into %s in %s\n\n"
- if opts.IsDraft {
- message = "\nCreating draft pull request for %s into %s in %s\n\n"
- }
+ defer shared.PreserveInput(opts.IO, state, &err)()
- if isTerminal {
- fmt.Fprintf(opts.IO.ErrOut, message,
- utils.Cyan(headBranchLabel),
- utils.Cyan(baseBranch),
- ghrepo.FullName(baseRepo))
- if (title == "" || body == "") && defaultsErr != nil {
- fmt.Fprintf(opts.IO.ErrOut, "%s warning: could not compute title or body defaults: %s\n", utils.Yellow("!"), defaultsErr)
+ templateContent := ""
+ if !opts.BodyProvided {
+ if opts.RecoverFile == "" {
+ templateFiles, legacyTemplate := shared.FindTemplates(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE")
+
+ templateContent, err = shared.TemplateSurvey(templateFiles, legacyTemplate, *state)
+ if err != nil {
+ return
}
}
- }
- tb := shared.IssueMetadataState{
- Type: shared.PRMetadata,
- Reviewers: opts.Reviewers,
- Assignees: opts.Assignees,
- Labels: opts.Labels,
- Projects: opts.Projects,
- Milestones: milestoneTitles,
- }
-
- if !opts.WebMode && !opts.Autofill && opts.Interactive {
- var nonLegacyTemplateFiles []string
- var legacyTemplateFile *string
-
- if opts.RootDirOverride != "" {
- nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE")
- legacyTemplateFile = githubtemplate.FindLegacy(opts.RootDirOverride, "PULL_REQUEST_TEMPLATE")
- } else if rootDir, err := git.ToplevelDir(); err == nil {
- nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "PULL_REQUEST_TEMPLATE")
- legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "PULL_REQUEST_TEMPLATE")
- }
-
- editorCommand, err := cmdutil.DetermineEditor(opts.Config)
+ err = shared.BodySurvey(state, templateContent, editorCommand)
if err != nil {
- return err
+ return
}
- err = shared.TitleBodySurvey(opts.IO, editorCommand, &tb, client, baseRepo, title, body, defs, nonLegacyTemplateFiles, legacyTemplateFile, true, baseRepo.ViewerCanTriage())
+ if state.Body == "" {
+ state.Body = templateContent
+ }
+ }
+
+ allowMetadata := ctx.BaseRepo.(*api.Repository).ViewerCanTriage()
+ action, err := shared.ConfirmSubmission(!state.HasMetadata(), allowMetadata)
+ if err != nil {
+ return fmt.Errorf("unable to confirm: %w", err)
+ }
+
+ if action == shared.MetadataAction {
+ fetcher := &shared.MetadataFetcher{
+ IO: opts.IO,
+ APIClient: client,
+ Repo: ctx.BaseRepo,
+ State: state,
+ }
+ err = shared.MetadataSurvey(opts.IO, ctx.BaseRepo, fetcher, state)
if err != nil {
- return fmt.Errorf("could not collect title and/or body: %w", err)
+ return
}
- action = tb.Action
-
- if action == shared.CancelAction {
- fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
- return nil
- }
-
- if title == "" {
- title = tb.Title
- }
- if body == "" {
- body = tb.Body
- }
- }
-
- if action == shared.SubmitAction && title == "" {
- return errors.New("pull request title must not be blank")
- }
-
- didForkRepo := false
- // if a head repository could not be determined so far, automatically create
- // one by forking the base repository
- if headRepo == nil && isPushEnabled {
- headRepo, err = api.ForkRepo(client, baseRepo)
+ action, err = shared.ConfirmSubmission(!state.HasMetadata(), false)
if err != nil {
- return fmt.Errorf("error forking repo: %w", err)
- }
- didForkRepo = true
- }
-
- if headRemote == nil && headRepo != nil {
- headRemote, _ = repoContext.RemoteForRepo(headRepo)
- }
-
- // There are two cases when an existing remote for the head repo will be
- // missing:
- // 1. the head repo was just created by auto-forking;
- // 2. an existing fork was discovered by querying the API.
- //
- // In either case, we want to add the head repo as a new git remote so we
- // can push to it.
- if headRemote == nil && isPushEnabled {
- cfg, err := opts.Config()
- if err != nil {
- return err
- }
- cloneProtocol, _ := cfg.Get(headRepo.RepoHost(), "git_protocol")
-
- headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol)
-
- // TODO: prevent clashes with another remote of a same name
- gitRemote, err := git.AddRemote("fork", headRepoURL)
- if err != nil {
- return fmt.Errorf("error adding remote: %w", err)
- }
- headRemote = &context.Remote{
- Remote: gitRemote,
- Repo: headRepo,
+ return
}
}
- // automatically push the branch if it hasn't been pushed anywhere yet
- if isPushEnabled {
- pushTries := 0
- maxPushTries := 3
- for {
- if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", headBranch)); err != nil {
- if didForkRepo && pushTries < maxPushTries {
- pushTries++
- // first wait 2 seconds after forking, then 4s, then 6s
- waitSeconds := 2 * pushTries
- fmt.Fprintf(opts.IO.ErrOut, "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second"))
- time.Sleep(time.Duration(waitSeconds) * time.Second)
- continue
- }
- return err
- }
- break
- }
+ if action == shared.CancelAction {
+ fmt.Fprintln(opts.IO.ErrOut, "Discarding.")
+ return nil
+ }
+
+ err = handlePush(*opts, *ctx)
+ if err != nil {
+ return
+ }
+
+ if action == shared.PreviewAction {
+ return previewPR(*opts, *ctx, *state)
}
if action == shared.SubmitAction {
- params := map[string]interface{}{
- "title": title,
- "body": body,
- "draft": opts.IsDraft,
- "baseRefName": baseBranch,
- "headRefName": headBranchLabel,
- }
-
- err = shared.AddMetadataToIssueParams(client, baseRepo, params, &tb)
- if err != nil {
- return err
- }
-
- pr, err := api.CreatePullRequest(client, baseRepo, params)
- if pr != nil {
- fmt.Fprintln(opts.IO.Out, pr.URL)
- }
- if err != nil {
- if pr != nil {
- return fmt.Errorf("pull request update failed: %w", err)
- }
- return fmt.Errorf("pull request create failed: %w", err)
- }
- } else if action == shared.PreviewAction {
- openURL, err := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body, tb.Assignees, tb.Labels, tb.Projects, tb.Milestones)
- if err != nil {
- return err
- }
- if isTerminal {
- fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
- }
- return utils.OpenInBrowser(openURL)
- } else {
- panic("Unreachable state")
+ return submitPR(*opts, *ctx, *state)
}
- return nil
+ err = errors.New("expected to cancel, preview, or submit")
+ return
}
-func computeDefaults(baseRef, headRef string) (shared.Defaults, error) {
- out := shared.Defaults{}
+func initDefaultTitleBody(ctx CreateContext, state *shared.IssueMetadataState) error {
+ baseRef := ctx.BaseTrackingBranch
+ headRef := ctx.HeadBranch
commits, err := git.Commits(baseRef, headRef)
if err != nil {
- return out, err
+ return err
}
if len(commits) == 1 {
- out.Title = commits[0].Title
+ state.Title = commits[0].Title
body, err := git.CommitBody(commits[0].Sha)
if err != nil {
- return out, err
+ return err
}
- out.Body = body
+ state.Body = body
} else {
- out.Title = utils.Humanize(headRef)
+ state.Title = utils.Humanize(headRef)
var body strings.Builder
for i := len(commits) - 1; i >= 0; i-- {
fmt.Fprintf(&body, "- %s\n", commits[i].Title)
}
- out.Body = body.String()
+ state.Body = body.String()
}
- return out, nil
+ return nil
}
func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef {
@@ -553,11 +380,335 @@ func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.Tr
return nil
}
-func generateCompareURL(r ghrepo.Interface, base, head, title, body string, assignees, labels, projects []string, milestones []string) (string, error) {
- u := ghrepo.GenerateRepoURL(r, "compare/%s...%s?expand=1", url.QueryEscape(base), url.QueryEscape(head))
- url, err := shared.WithPrAndIssueQueryParams(u, title, body, assignees, labels, projects, milestones)
+func NewIssueState(ctx CreateContext, opts CreateOptions) (*shared.IssueMetadataState, error) {
+ var milestoneTitles []string
+ if opts.Milestone != "" {
+ milestoneTitles = []string{opts.Milestone}
+ }
+
+ state := &shared.IssueMetadataState{
+ Type: shared.PRMetadata,
+ Reviewers: opts.Reviewers,
+ Assignees: opts.Assignees,
+ Labels: opts.Labels,
+ Projects: opts.Projects,
+ Milestones: milestoneTitles,
+ Draft: opts.IsDraft,
+ }
+
+ if opts.Autofill || !opts.TitleProvided || !opts.BodyProvided {
+ err := initDefaultTitleBody(ctx, state)
+ if err != nil && opts.Autofill {
+ return nil, fmt.Errorf("could not compute title or body defaults: %w", err)
+ }
+ }
+
+ return state, nil
+}
+
+func NewCreateContext(opts *CreateOptions) (*CreateContext, error) {
+ httpClient, err := opts.HttpClient()
+ if err != nil {
+ return nil, err
+ }
+ client := api.NewClientFromHTTP(httpClient)
+
+ remotes, err := opts.Remotes()
+ if err != nil {
+ return nil, err
+ }
+
+ repoContext, err := context.ResolveRemotesToRepos(remotes, client, opts.RepoOverride)
+ if err != nil {
+ return nil, err
+ }
+
+ var baseRepo *api.Repository
+ if br, err := repoContext.BaseRepo(opts.IO); err == nil {
+ if r, ok := br.(*api.Repository); ok {
+ baseRepo = r
+ } else {
+ // TODO: if RepoNetwork is going to be requested anyway in `repoContext.HeadRepos()`,
+ // consider piggybacking on that result instead of performing a separate lookup
+ baseRepo, err = api.GitHubRepo(client, br)
+ if err != nil {
+ return nil, err
+ }
+ }
+ } else {
+ return nil, fmt.Errorf("could not determine base repository: %w", err)
+ }
+
+ isPushEnabled := false
+ headBranch := opts.HeadBranch
+ headBranchLabel := opts.HeadBranch
+ if headBranch == "" {
+ headBranch, err = opts.Branch()
+ if err != nil {
+ return nil, fmt.Errorf("could not determine the current branch: %w", err)
+ }
+ headBranchLabel = headBranch
+ isPushEnabled = true
+ } else if idx := strings.IndexRune(headBranch, ':'); idx >= 0 {
+ headBranch = headBranch[idx+1:]
+ }
+
+ if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 {
+ fmt.Fprintf(opts.IO.ErrOut, "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change"))
+ }
+
+ var headRepo ghrepo.Interface
+ var headRemote *context.Remote
+
+ if isPushEnabled {
+ // determine whether the head branch is already pushed to a remote
+ if pushedTo := determineTrackingBranch(remotes, headBranch); pushedTo != nil {
+ isPushEnabled = false
+ if r, err := remotes.FindByName(pushedTo.RemoteName); err == nil {
+ headRepo = r
+ headRemote = r
+ headBranchLabel = pushedTo.BranchName
+ if !ghrepo.IsSame(baseRepo, headRepo) {
+ headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), pushedTo.BranchName)
+ }
+ }
+ }
+ }
+
+ // otherwise, ask the user for the head repository using info obtained from the API
+ if headRepo == nil && isPushEnabled && opts.IO.CanPrompt() {
+ pushableRepos, err := repoContext.HeadRepos()
+ if err != nil {
+ return nil, err
+ }
+
+ if len(pushableRepos) == 0 {
+ pushableRepos, err = api.RepoFindForks(client, baseRepo, 3)
+ if err != nil {
+ return nil, err
+ }
+ }
+
+ currentLogin, err := api.CurrentLoginName(client, baseRepo.RepoHost())
+ if err != nil {
+ return nil, err
+ }
+
+ hasOwnFork := false
+ var pushOptions []string
+ for _, r := range pushableRepos {
+ pushOptions = append(pushOptions, ghrepo.FullName(r))
+ if r.RepoOwner() == currentLogin {
+ hasOwnFork = true
+ }
+ }
+
+ if !hasOwnFork {
+ pushOptions = append(pushOptions, "Create a fork of "+ghrepo.FullName(baseRepo))
+ }
+ pushOptions = append(pushOptions, "Skip pushing the branch")
+ pushOptions = append(pushOptions, "Cancel")
+
+ var selectedOption int
+ err = prompt.SurveyAskOne(&survey.Select{
+ Message: fmt.Sprintf("Where should we push the '%s' branch?", headBranch),
+ Options: pushOptions,
+ }, &selectedOption)
+ if err != nil {
+ return nil, err
+ }
+
+ if selectedOption < len(pushableRepos) {
+ headRepo = pushableRepos[selectedOption]
+ if !ghrepo.IsSame(baseRepo, headRepo) {
+ headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
+ }
+ } else if pushOptions[selectedOption] == "Skip pushing the branch" {
+ isPushEnabled = false
+ } else if pushOptions[selectedOption] == "Cancel" {
+ return nil, cmdutil.SilentError
+ } else {
+ // "Create a fork of ..."
+ if baseRepo.IsPrivate {
+ return nil, fmt.Errorf("cannot fork private repository %s", ghrepo.FullName(baseRepo))
+ }
+ headBranchLabel = fmt.Sprintf("%s:%s", currentLogin, headBranch)
+ }
+ }
+
+ if headRepo == nil && isPushEnabled && !opts.IO.CanPrompt() {
+ fmt.Fprintf(opts.IO.ErrOut, "aborted: you must first push the current branch to a remote, or use the --head flag")
+ return nil, cmdutil.SilentError
+ }
+
+ baseBranch := opts.BaseBranch
+ if baseBranch == "" {
+ baseBranch = baseRepo.DefaultBranchRef.Name
+ }
+ if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) {
+ return nil, fmt.Errorf("must be on a branch named differently than %q", baseBranch)
+ }
+
+ baseTrackingBranch := baseBranch
+ if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil {
+ baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch)
+ }
+
+ return &CreateContext{
+ BaseRepo: baseRepo,
+ HeadRepo: headRepo,
+ BaseBranch: baseBranch,
+ BaseTrackingBranch: baseTrackingBranch,
+ HeadBranch: headBranch,
+ HeadBranchLabel: headBranchLabel,
+ HeadRemote: headRemote,
+ IsPushEnabled: isPushEnabled,
+ RepoContext: repoContext,
+ Client: client,
+ }, nil
+
+}
+
+func submitPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState) error {
+ client := ctx.Client
+
+ params := map[string]interface{}{
+ "title": state.Title,
+ "body": state.Body,
+ "draft": state.Draft,
+ "baseRefName": ctx.BaseBranch,
+ "headRefName": ctx.HeadBranchLabel,
+ "maintainerCanModify": opts.MaintainerCanModify,
+ }
+
+ if params["title"] == "" {
+ return errors.New("pull request title must not be blank")
+ }
+
+ err := shared.AddMetadataToIssueParams(client, ctx.BaseRepo, params, &state)
+ if err != nil {
+ return err
+ }
+
+ pr, err := api.CreatePullRequest(client, ctx.BaseRepo.(*api.Repository), params)
+ if pr != nil {
+ fmt.Fprintln(opts.IO.Out, pr.URL)
+ }
+ if err != nil {
+ if pr != nil {
+ return fmt.Errorf("pull request update failed: %w", err)
+ }
+ return fmt.Errorf("pull request create failed: %w", err)
+ }
+ return nil
+}
+
+func previewPR(opts CreateOptions, ctx CreateContext, state shared.IssueMetadataState) error {
+ openURL, err := generateCompareURL(ctx, state)
+ if err != nil {
+ return err
+ }
+
+ if opts.IO.IsStdinTTY() && opts.IO.IsStdoutTTY() {
+ fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
+ }
+ return utils.OpenInBrowser(openURL)
+
+}
+
+func handlePush(opts CreateOptions, ctx CreateContext) error {
+ didForkRepo := false
+ headRepo := ctx.HeadRepo
+ headRemote := ctx.HeadRemote
+ client := ctx.Client
+
+ var err error
+ // if a head repository could not be determined so far, automatically create
+ // one by forking the base repository
+ if headRepo == nil && ctx.IsPushEnabled {
+ headRepo, err = api.ForkRepo(client, ctx.BaseRepo)
+ if err != nil {
+ return fmt.Errorf("error forking repo: %w", err)
+ }
+ didForkRepo = true
+ }
+
+ if headRemote == nil && headRepo != nil {
+ headRemote, _ = ctx.RepoContext.RemoteForRepo(headRepo)
+ }
+
+ // There are two cases when an existing remote for the head repo will be
+ // missing:
+ // 1. the head repo was just created by auto-forking;
+ // 2. an existing fork was discovered by querying the API.
+ //
+ // In either case, we want to add the head repo as a new git remote so we
+ // can push to it.
+ if headRemote == nil && ctx.IsPushEnabled {
+ cfg, err := opts.Config()
+ if err != nil {
+ return err
+ }
+ cloneProtocol, _ := cfg.Get(headRepo.RepoHost(), "git_protocol")
+
+ headRepoURL := ghrepo.FormatRemoteURL(headRepo, cloneProtocol)
+
+ // TODO: prevent clashes with another remote of a same name
+ gitRemote, err := git.AddRemote("fork", headRepoURL)
+ if err != nil {
+ return fmt.Errorf("error adding remote: %w", err)
+ }
+ headRemote = &context.Remote{
+ Remote: gitRemote,
+ Repo: headRepo,
+ }
+ }
+
+ // automatically push the branch if it hasn't been pushed anywhere yet
+ if ctx.IsPushEnabled {
+ pushBranch := func() error {
+ pushTries := 0
+ maxPushTries := 3
+ for {
+ r := NewRegexpWriter(opts.IO.ErrOut, gitPushRegexp, "")
+ defer r.Flush()
+ cmdErr := r
+ cmdOut := opts.IO.Out
+ if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", ctx.HeadBranch), cmdOut, cmdErr); err != nil {
+ if didForkRepo && pushTries < maxPushTries {
+ pushTries++
+ // first wait 2 seconds after forking, then 4s, then 6s
+ waitSeconds := 2 * pushTries
+ fmt.Fprintf(opts.IO.ErrOut, "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second"))
+ time.Sleep(time.Duration(waitSeconds) * time.Second)
+ continue
+ }
+ return err
+ }
+ break
+ }
+ return nil
+ }
+
+ err := pushBranch()
+ if err != nil {
+ return err
+ }
+ }
+
+ return nil
+}
+
+func generateCompareURL(ctx CreateContext, state shared.IssueMetadataState) (string, error) {
+ u := ghrepo.GenerateRepoURL(
+ ctx.BaseRepo,
+ "compare/%s...%s?expand=1",
+ url.QueryEscape(ctx.BaseBranch), url.QueryEscape(ctx.HeadBranchLabel))
+ url, err := shared.WithPrAndIssueQueryParams(u, state)
if err != nil {
return "", err
}
return url, nil
}
+
+var gitPushRegexp = regexp.MustCompile("^remote: (Create a pull request.*by visiting|[[:space:]]*https://.*/pull/new/).*\n?$")
diff --git a/pkg/cmd/pr/create/create_test.go b/pkg/cmd/pr/create/create_test.go
index 456da84a9..6809a2040 100644
--- a/pkg/cmd/pr/create/create_test.go
+++ b/pkg/cmd/pr/create/create_test.go
@@ -3,9 +3,10 @@ package create
import (
"bytes"
"encoding/json"
+ "fmt"
"io/ioutil"
"net/http"
- "reflect"
+ "os"
"strings"
"testing"
@@ -15,6 +16,7 @@ import (
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
+ prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@@ -25,13 +27,6 @@ import (
"github.com/stretchr/testify/require"
)
-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 runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, isTTY bool, cli string) (*test.CmdOut, error) {
return runCommandWithRootDirOverridden(rt, remotes, branch, isTTY, cli, "")
}
@@ -112,12 +107,12 @@ func TestPRCreate_nontty_web(t *testing.T) {
output, err := runCommand(http, nil, "feature", false, `--web --head=feature`)
require.NoError(t, err)
- eq(t, output.String(), "")
- eq(t, output.Stderr(), "")
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "", output.Stderr())
- eq(t, len(cs.Calls), 3)
+ assert.Equal(t, 3, len(cs.Calls))
browserCall := cs.Calls[2].Args
- eq(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1")
+ assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1", browserCall[len(browserCall)-1])
}
@@ -126,29 +121,126 @@ func TestPRCreate_nontty_insufficient_flags(t *testing.T) {
defer http.Verify(t)
output, err := runCommand(http, nil, "feature", false, "")
- if err == nil {
- t.Fatal("expected error")
- }
-
- assert.Equal(t, "--title or --fill required when not running interactively", err.Error())
+ assert.EqualError(t, err, "--title or --fill required when not running interactively")
assert.Equal(t, "", output.String())
}
+func TestPRCreate_recover(t *testing.T) {
+ http := initFakeHTTP()
+ defer http.Verify(t)
+
+ http.StubRepoInfoResponse("OWNER", "REPO", "master")
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes" : [
+ ] } } } }
+ `))
+ http.Register(
+ httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`),
+ httpmock.StringResponse(`
+ { "data": {
+ "u000": { "login": "jillValentine", "id": "JILLID" },
+ "repository": {},
+ "organization": {}
+ } }
+ `))
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`),
+ httpmock.GraphQLMutation(`
+ { "data": { "requestReviews": {
+ "clientMutationId": ""
+ } } }
+ `, func(inputs map[string]interface{}) {
+ assert.Equal(t, []interface{}{"JILLID"}, inputs["userIds"])
+ }))
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestCreate\b`),
+ httpmock.GraphQLMutation(`
+ { "data": { "createPullRequest": { "pullRequest": {
+ "URL": "https://github.com/OWNER/REPO/pull/12"
+ } } } }
+ `, func(input map[string]interface{}) {
+ assert.Equal(t, "recovered title", input["title"].(string))
+ assert.Equal(t, "recovered body", input["body"].(string))
+ }))
+
+ cs, cmdTeardown := test.InitCmdStubber()
+ defer cmdTeardown()
+
+ cs.Stub("") // git status
+ cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
+
+ as, teardown := prompt.InitAskStubber()
+ defer teardown()
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "Title",
+ Default: true,
+ },
+ })
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "Body",
+ Default: true,
+ },
+ })
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "confirmation",
+ Value: 0,
+ },
+ })
+
+ tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*")
+ assert.NoError(t, err)
+
+ state := prShared.IssueMetadataState{
+ Title: "recovered title",
+ Body: "recovered body",
+ Reviewers: []string{"jillValentine"},
+ }
+
+ data, err := json.Marshal(state)
+ assert.NoError(t, err)
+
+ _, err = tmpfile.Write(data)
+ assert.NoError(t, err)
+
+ args := fmt.Sprintf("--recover '%s' -Hfeature", tmpfile.Name())
+
+ output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, args, "")
+ assert.NoError(t, err)
+
+ assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String())
+}
+
func TestPRCreate_nontty(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoInfoResponse("OWNER", "REPO", "master")
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes" : [
- ] } } } }
- `))
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "createPullRequest": { "pullRequest": {
- "URL": "https://github.com/OWNER/REPO/pull/12"
- } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes" : [
+ ] } } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestCreate\b`),
+ httpmock.GraphQLMutation(`
+ { "data": { "createPullRequest": { "pullRequest": {
+ "URL": "https://github.com/OWNER/REPO/pull/12"
+ } } } }`,
+ func(input map[string]interface{}) {
+ assert.Equal(t, "REPOID", input["repositoryId"])
+ assert.Equal(t, "my title", input["title"])
+ assert.Equal(t, "my body", input["body"])
+ assert.Equal(t, "master", input["baseRefName"])
+ assert.Equal(t, "feature", input["headRefName"])
+ }),
+ )
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
@@ -159,26 +251,6 @@ func TestPRCreate_nontty(t *testing.T) {
output, err := runCommand(http, nil, "feature", false, `-t "my title" -b "my body" -H feature`)
require.NoError(t, err)
- bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body)
- reqBody := struct {
- Variables struct {
- Input struct {
- RepositoryID string
- Title string
- Body string
- BaseRefName string
- HeadRefName string
- }
- }
- }{}
- _ = json.Unmarshal(bodyBytes, &reqBody)
-
- assert.Equal(t, "REPOID", reqBody.Variables.Input.RepositoryID)
- assert.Equal(t, "my title", reqBody.Variables.Input.Title)
- assert.Equal(t, "my body", reqBody.Variables.Input.Body)
- assert.Equal(t, "master", reqBody.Variables.Input.BaseRefName)
- assert.Equal(t, "feature", reqBody.Variables.Input.HeadRefName)
-
assert.Equal(t, "", output.Stderr())
assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String())
}
@@ -232,6 +304,57 @@ func TestPRCreate(t *testing.T) {
assert.Equal(t, "\nCreating pull request for feature into master in OWNER/REPO\n\n", output.Stderr())
}
+func TestPRCreate_NoMaintainerModify(t *testing.T) {
+ // TODO update this copypasta
+ http := initFakeHTTP()
+ defer http.Verify(t)
+
+ http.StubRepoInfoResponse("OWNER", "REPO", "master")
+ http.StubRepoResponse("OWNER", "REPO")
+ http.Register(
+ httpmock.GraphQL(`query UserCurrent\b`),
+ httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes" : [
+ ] } } } }
+ `))
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestCreate\b`),
+ httpmock.GraphQLMutation(`
+ { "data": { "createPullRequest": { "pullRequest": {
+ "URL": "https://github.com/OWNER/REPO/pull/12"
+ } } } }
+ `, func(input map[string]interface{}) {
+ assert.Equal(t, false, input["maintainerCanModify"].(bool))
+ assert.Equal(t, "REPOID", input["repositoryId"].(string))
+ assert.Equal(t, "my title", input["title"].(string))
+ assert.Equal(t, "my body", input["body"].(string))
+ assert.Equal(t, "master", input["baseRefName"].(string))
+ assert.Equal(t, "feature", input["headRefName"].(string))
+ }))
+
+ cs, cmdTeardown := test.InitCmdStubber()
+ defer cmdTeardown()
+
+ cs.Stub("") // git config --get-regexp (determineTrackingBranch)
+ cs.Stub("") // git show-ref --verify (determineTrackingBranch)
+ cs.Stub("") // git status
+ cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
+ cs.Stub("") // git push
+
+ ask, cleanupAsk := prompt.InitAskStubber()
+ defer cleanupAsk()
+ ask.StubOne(0)
+
+ output, err := runCommand(http, nil, "feature", true, `-t "my title" -b "my body" --no-maintainer-edit`)
+ require.NoError(t, err)
+
+ assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String())
+ assert.Equal(t, "\nCreating pull request for feature into master in OWNER/REPO\n\n", output.Stderr())
+}
+
func TestPRCreate_createFork(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
@@ -270,12 +393,11 @@ func TestPRCreate_createFork(t *testing.T) {
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
- cs.Stub("") // git config --get-regexp (determineTrackingBranch)
- cs.Stub("") // git show-ref --verify (determineTrackingBranch)
- cs.Stub("") // git status
- cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
- cs.Stub("") // git remote add
- cs.Stub("") // git push
+ cs.Stub("") // git config --get-regexp (determineTrackingBranch)
+ cs.Stub("") // git show-ref --verify (determineTrackingBranch)
+ cs.Stub("") // git status
+ cs.Stub("") // git remote add
+ cs.Stub("") // git push
ask, cleanupAsk := prompt.InitAskStubber()
defer cleanupAsk()
@@ -284,8 +406,8 @@ func TestPRCreate_createFork(t *testing.T) {
output, err := runCommand(http, nil, "feature", true, `-t title -b body`)
require.NoError(t, err)
- assert.Equal(t, []string{"git", "remote", "add", "-f", "fork", "https://github.com/monalisa/REPO.git"}, cs.Calls[4].Args)
- assert.Equal(t, []string{"git", "push", "--set-upstream", "fork", "HEAD:feature"}, cs.Calls[5].Args)
+ assert.Equal(t, []string{"git", "remote", "add", "-f", "fork", "https://github.com/monalisa/REPO.git"}, cs.Calls[3].Args)
+ assert.Equal(t, []string{"git", "push", "--set-upstream", "fork", "HEAD:feature"}, cs.Calls[4].Args)
assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String())
}
@@ -340,9 +462,6 @@ func TestPRCreate_pushedToNonBaseRepo(t *testing.T) {
deadb00f refs/remotes/upstream/feature
deadbeef refs/remotes/origin/feature
`)) // determineTrackingBranch
- cs.Register("git .+ log", 1, "", func(args []string) {
- assert.Equal(t, "upstream/master...feature", args[len(args)-1])
- })
_, cleanupAsk := prompt.InitAskStubber()
defer cleanupAsk()
@@ -389,9 +508,6 @@ func TestPRCreate_pushedToDifferentBranchName(t *testing.T) {
deadbeef HEAD
deadbeef refs/remotes/origin/my-feat2
`)) // determineTrackingBranch
- cs.Register("git .+ log", 1, "", func(args []string) {
- assert.Equal(t, "origin/master...feature", args[len(args)-1])
- })
_, cleanupAsk := prompt.InitAskStubber()
defer cleanupAsk()
@@ -438,24 +554,24 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) {
Name: "index",
Value: 0,
},
- })
+ }) // template
as.Stub([]*prompt.QuestionStub{
{
- Name: "body",
+ Name: "Body",
Default: true,
},
- })
+ }) // body
as.Stub([]*prompt.QuestionStub{
{
Name: "confirmation",
Value: 0,
},
- })
+ }) // confirm
output, err := runCommandWithRootDirOverridden(http, nil, "feature", true, `-t "my title" -H feature`, "./fixtures/repoWithNonLegacyPRTemplates")
require.NoError(t, err)
- eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
+ assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String())
}
func TestPRCreate_metadata(t *testing.T) {
@@ -523,8 +639,8 @@ func TestPRCreate_metadata(t *testing.T) {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`, func(inputs map[string]interface{}) {
- eq(t, inputs["title"], "TITLE")
- eq(t, inputs["body"], "BODY")
+ assert.Equal(t, "TITLE", inputs["title"])
+ assert.Equal(t, "BODY", inputs["body"])
if v, ok := inputs["assigneeIds"]; ok {
t.Errorf("did not expect assigneeIds: %v", v)
}
@@ -539,11 +655,11 @@ func TestPRCreate_metadata(t *testing.T) {
"clientMutationId": ""
} } }
`, func(inputs map[string]interface{}) {
- eq(t, inputs["pullRequestId"], "NEWPULLID")
- eq(t, inputs["assigneeIds"], []interface{}{"MONAID"})
- eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"})
- eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"})
- eq(t, inputs["milestoneId"], "BIGONEID")
+ assert.Equal(t, "NEWPULLID", inputs["pullRequestId"])
+ assert.Equal(t, []interface{}{"MONAID"}, inputs["assigneeIds"])
+ assert.Equal(t, []interface{}{"BUGID", "TODOID"}, inputs["labelIds"])
+ assert.Equal(t, []interface{}{"ROADMAPID"}, inputs["projectIds"])
+ assert.Equal(t, "BIGONEID", inputs["milestoneId"])
}))
http.Register(
httpmock.GraphQL(`mutation PullRequestCreateRequestReviews\b`),
@@ -552,10 +668,10 @@ func TestPRCreate_metadata(t *testing.T) {
"clientMutationId": ""
} } }
`, func(inputs map[string]interface{}) {
- eq(t, inputs["pullRequestId"], "NEWPULLID")
- eq(t, inputs["userIds"], []interface{}{"HUBOTID", "MONAID"})
- eq(t, inputs["teamIds"], []interface{}{"COREID", "ROBOTID"})
- eq(t, inputs["union"], true)
+ assert.Equal(t, "NEWPULLID", inputs["pullRequestId"])
+ assert.Equal(t, []interface{}{"HUBOTID", "MONAID"}, inputs["userIds"])
+ assert.Equal(t, []interface{}{"COREID", "ROBOTID"}, inputs["teamIds"])
+ assert.Equal(t, true, inputs["union"])
}))
cs, cmdTeardown := test.InitCmdStubber()
@@ -565,9 +681,9 @@ func TestPRCreate_metadata(t *testing.T) {
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
output, err := runCommand(http, nil, "feature", true, `-t TITLE -b BODY -H feature -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`)
- eq(t, err, nil)
+ assert.NoError(t, err)
- eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
+ assert.Equal(t, "https://github.com/OWNER/REPO/pull/12\n", output.String())
}
func TestPRCreate_alreadyExists(t *testing.T) {
@@ -575,23 +691,24 @@ func TestPRCreate_alreadyExists(t *testing.T) {
defer http.Verify(t)
http.StubRepoInfoResponse("OWNER", "REPO", "master")
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [
- { "url": "https://github.com/OWNER/REPO/pull/123",
- "headRefName": "feature",
- "baseRefName": "master" }
- ] } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [
+ { "url": "https://github.com/OWNER/REPO/pull/123",
+ "headRefName": "feature",
+ "baseRefName": "master" }
+ ] } } } }`),
+ )
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
- cs.Stub("") // git config --get-regexp (determineTrackingBranch)
- cs.Stub("") // git show-ref --verify (determineTrackingBranch)
- cs.Stub("") // git status
- cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
+ cs.Stub("") // git config --get-regexp (determineTrackingBranch)
+ cs.Stub("") // git show-ref --verify (determineTrackingBranch)
+ cs.Stub("") // git status
- _, err := runCommand(http, nil, "feature", true, `-H feature`)
+ _, err := runCommand(http, nil, "feature", true, `-ttitle -bbody -H feature`)
if err == nil {
t.Fatal("error expected, got nil")
}
@@ -627,13 +744,13 @@ func TestPRCreate_web(t *testing.T) {
output, err := runCommand(http, nil, "feature", true, `--web`)
require.NoError(t, err)
- eq(t, output.String(), "")
- eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n")
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n", output.Stderr())
- eq(t, len(cs.Calls), 6)
- eq(t, strings.Join(cs.Calls[4].Args, " "), "git push --set-upstream origin HEAD:feature")
+ assert.Equal(t, 6, len(cs.Calls))
+ assert.Equal(t, "git push --set-upstream origin HEAD:feature", strings.Join(cs.Calls[4].Args, " "))
browserCall := cs.Calls[5].Args
- eq(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1")
+ assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1", browserCall[len(browserCall)-1])
}
func Test_determineTrackingBranch_empty(t *testing.T) {
@@ -701,10 +818,10 @@ deadbeef refs/remotes/upstream/feature`) // git show-ref --verify (ShowRefs)
t.Fatal("expected result, got nil")
}
- eq(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/feature", "refs/remotes/upstream/feature"})
+ assert.Equal(t, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/feature", "refs/remotes/upstream/feature"}, cs.Calls[1].Args)
- eq(t, ref.RemoteName, "upstream")
- eq(t, ref.BranchName, "feature")
+ assert.Equal(t, "upstream", ref.RemoteName)
+ assert.Equal(t, "feature", ref.BranchName)
}
func Test_determineTrackingBranch_respectTrackingConfig(t *testing.T) {
@@ -728,54 +845,46 @@ deadb00f refs/remotes/origin/feature`) // git show-ref --verify (ShowRefs)
t.Errorf("expected nil result, got %v", ref)
}
- eq(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/great-feat", "refs/remotes/origin/feature"})
+ assert.Equal(t, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/great-feat", "refs/remotes/origin/feature"}, cs.Calls[1].Args)
}
func Test_generateCompareURL(t *testing.T) {
- type args struct {
- r ghrepo.Interface
- base string
- head string
- title string
- body string
- assignees []string
- labels []string
- projects []string
- milestones []string
- }
tests := []struct {
name string
- args args
+ ctx CreateContext
+ state prShared.IssueMetadataState
want string
wantErr bool
}{
{
name: "basic",
- args: args{
- r: ghrepo.New("OWNER", "REPO"),
- base: "main",
- head: "feature",
+ ctx: CreateContext{
+ BaseRepo: ghrepo.New("OWNER", "REPO"),
+ BaseBranch: "main",
+ HeadBranchLabel: "feature",
},
want: "https://github.com/OWNER/REPO/compare/main...feature?expand=1",
wantErr: false,
},
{
name: "with labels",
- args: args{
- r: ghrepo.New("OWNER", "REPO"),
- base: "a",
- head: "b",
- labels: []string{"one", "two three"},
+ ctx: CreateContext{
+ BaseRepo: ghrepo.New("OWNER", "REPO"),
+ BaseBranch: "a",
+ HeadBranchLabel: "b",
+ },
+ state: prShared.IssueMetadataState{
+ Labels: []string{"one", "two three"},
},
want: "https://github.com/OWNER/REPO/compare/a...b?expand=1&labels=one%2Ctwo+three",
wantErr: false,
},
{
name: "complex branch names",
- args: args{
- r: ghrepo.New("OWNER", "REPO"),
- base: "main/trunk",
- head: "owner:feature",
+ ctx: CreateContext{
+ BaseRepo: ghrepo.New("OWNER", "REPO"),
+ BaseBranch: "main/trunk",
+ HeadBranchLabel: "owner:feature",
},
want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner%3Afeature?expand=1",
wantErr: false,
@@ -783,7 +892,7 @@ func Test_generateCompareURL(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- got, err := generateCompareURL(tt.args.r, tt.args.base, tt.args.head, tt.args.title, tt.args.body, tt.args.assignees, tt.args.labels, tt.args.projects, tt.args.milestones)
+ got, err := generateCompareURL(tt.ctx, tt.state)
if (err != nil) != tt.wantErr {
t.Errorf("generateCompareURL() error = %v, wantErr %v", err, tt.wantErr)
return
diff --git a/pkg/cmd/pr/create/regexp_writer.go b/pkg/cmd/pr/create/regexp_writer.go
new file mode 100644
index 000000000..500637d7c
--- /dev/null
+++ b/pkg/cmd/pr/create/regexp_writer.go
@@ -0,0 +1,64 @@
+package create
+
+import (
+ "bytes"
+ "io"
+ "regexp"
+)
+
+func NewRegexpWriter(out io.Writer, re *regexp.Regexp, repl string) *RegexpWriter {
+ return &RegexpWriter{out: out, re: *re, repl: repl}
+}
+
+type RegexpWriter struct {
+ out io.Writer
+ re regexp.Regexp
+ repl string
+ buf []byte
+}
+
+func (s *RegexpWriter) Write(data []byte) (int, error) {
+ if len(data) == 0 {
+ return 0, nil
+ }
+
+ filtered := []byte{}
+ repl := []byte(s.repl)
+ lines := bytes.SplitAfter(data, []byte("\n"))
+
+ if len(s.buf) > 0 {
+ lines[0] = append(s.buf, lines[0]...)
+ }
+
+ for i, line := range lines {
+ if i == len(lines) {
+ s.buf = line
+ } else {
+ f := s.re.ReplaceAll(line, repl)
+ if len(f) > 0 {
+ filtered = append(filtered, f...)
+ }
+ }
+ }
+
+ if len(filtered) != 0 {
+ _, err := s.out.Write(filtered)
+ if err != nil {
+ return 0, err
+ }
+ }
+
+ return len(data), nil
+}
+
+func (s *RegexpWriter) Flush() (int, error) {
+ if len(s.buf) > 0 {
+ repl := []byte(s.repl)
+ filtered := s.re.ReplaceAll(s.buf, repl)
+ if len(filtered) > 0 {
+ return s.out.Write(filtered)
+ }
+ }
+
+ return 0, nil
+}
diff --git a/pkg/cmd/pr/create/regexp_writer_test.go b/pkg/cmd/pr/create/regexp_writer_test.go
new file mode 100644
index 000000000..f9ec0b863
--- /dev/null
+++ b/pkg/cmd/pr/create/regexp_writer_test.go
@@ -0,0 +1,160 @@
+package create
+
+import (
+ "bytes"
+ "regexp"
+ "testing"
+
+ "github.com/MakeNowJust/heredoc"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_Write(t *testing.T) {
+ type input struct {
+ in []string
+ re *regexp.Regexp
+ repl string
+ }
+ type output struct {
+ wantsErr bool
+ out string
+ length int
+ }
+ tests := []struct {
+ name string
+ input input
+ output output
+ }{
+ {
+ name: "single line input",
+ input: input{
+ in: []string{"some input line that has wrong information"},
+ re: regexp.MustCompile("wrong"),
+ repl: "right",
+ },
+ output: output{
+ wantsErr: false,
+ out: "some input line that has right information",
+ length: 42,
+ },
+ },
+ {
+ name: "multiple line input",
+ input: input{
+ in: []string{"multiple lines\nin this\ninput lines"},
+ re: regexp.MustCompile("lines"),
+ repl: "tests",
+ },
+ output: output{
+ wantsErr: false,
+ out: "multiple tests\nin this\ninput tests",
+ length: 34,
+ },
+ },
+ {
+ name: "no matches",
+ input: input{
+ in: []string{"this line has no matches"},
+ re: regexp.MustCompile("wrong"),
+ repl: "right",
+ },
+ output: output{
+ wantsErr: false,
+ out: "this line has no matches",
+ length: 24,
+ },
+ },
+ {
+ name: "no output",
+ input: input{
+ in: []string{"remove this whole line"},
+ re: regexp.MustCompile("^remove.*$"),
+ repl: "",
+ },
+ output: output{
+ wantsErr: false,
+ out: "",
+ length: 22,
+ },
+ },
+ {
+ name: "no input",
+ input: input{
+ in: []string{""},
+ re: regexp.MustCompile("remove"),
+ repl: "",
+ },
+ output: output{
+ wantsErr: false,
+ out: "",
+ length: 0,
+ },
+ },
+ {
+ name: "multiple lines removed",
+ input: input{
+ in: []string{"beginning line\nremove this whole line\nremove this one also\nnot this one"},
+ re: regexp.MustCompile("(?s)^remove.*$"),
+ repl: "",
+ },
+ output: output{
+ wantsErr: false,
+ out: "beginning line\nnot this one",
+ length: 71,
+ },
+ },
+ {
+ name: "removes remote from git push output",
+ input: input{
+ in: []string{heredoc.Doc(`
+ output: some information
+ remote:
+ remote: Create a pull request for 'regex' on GitHub by visiting:
+ remote: https://github.com/owner/repo/pull/new/regex
+ remote:
+ output: more information
+ `)},
+ re: regexp.MustCompile("^remote: (Create a pull request.*by visiting|[[:space:]]*https://.*/pull/new/).*\n?$"),
+ repl: "",
+ },
+ output: output{
+ wantsErr: false,
+ out: "output: some information\nremote:\nremote:\noutput: more information\n",
+ length: 189,
+ },
+ },
+ {
+ name: "multiple writes",
+ input: input{
+ in: []string{"first write\n", "second write ", "third write"},
+ re: regexp.MustCompile("write"),
+ repl: "read",
+ },
+ output: output{
+ wantsErr: false,
+ out: "first read\nsecond read third read",
+ length: 36,
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ out := &bytes.Buffer{}
+ writer := NewRegexpWriter(out, tt.input.re, tt.input.repl)
+ t.Run(tt.name, func(t *testing.T) {
+ length := 0
+ for _, in := range tt.input.in {
+ l, err := writer.Write([]byte(in))
+ length = length + l
+ if tt.output.wantsErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ }
+ writer.Flush()
+ assert.Equal(t, tt.output.out, out.String())
+ assert.Equal(t, tt.output.length, length)
+ })
+ }
+}
diff --git a/pkg/cmd/pr/diff/diff_test.go b/pkg/cmd/pr/diff/diff_test.go
index 5e362ac4d..2e81116a4 100644
--- a/pkg/cmd/pr/diff/diff_test.go
+++ b/pkg/cmd/pr/diff/diff_test.go
@@ -164,44 +164,59 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s
func TestPRDiff_no_current_pr(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [] } } } }
- `))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequests": { "nodes": [] }
+ } } }`),
+ )
+
_, err := runCommand(http, nil, false, "")
- if err == nil {
- t.Fatal("expected error")
- }
- assert.Equal(t, `no open pull requests found for branch "feature"`, err.Error())
+ assert.EqualError(t, err, `no pull requests found for branch "feature"`)
}
func TestPRDiff_argument_not_found(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 123 }
- } } }
-`))
- http.StubResponse(404, bytes.NewBufferString(""))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "number": 123 }
+ } } }`),
+ )
+ http.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"),
+ httpmock.StatusStringResponse(404, ""),
+ )
+
_, err := runCommand(http, nil, false, "123")
- if err == nil {
- t.Fatal("expected error", err)
- }
- assert.Equal(t, `could not find pull request diff: pull request not found`, err.Error())
+ assert.EqualError(t, err, `could not find pull request diff: pull request not found`)
}
func TestPRDiff_notty(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [
- { "url": "https://github.com/OWNER/REPO/pull/123",
- "number": 123,
- "id": "foobar123",
- "headRefName": "feature",
- "baseRefName": "master" }
- ] } } } }`))
- http.StubResponse(200, bytes.NewBufferString(testDiff))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [
+ { "url": "https://github.com/OWNER/REPO/pull/123",
+ "number": 123,
+ "id": "foobar123",
+ "headRefName": "feature",
+ "baseRefName": "master" }
+ ] } } } }`),
+ )
+ http.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"),
+ httpmock.StringResponse(testDiff),
+ )
+
output, err := runCommand(http, nil, false, "")
if err != nil {
t.Fatalf("unexpected error: %s", err)
@@ -214,15 +229,23 @@ func TestPRDiff_notty(t *testing.T) {
func TestPRDiff_tty(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [
- { "url": "https://github.com/OWNER/REPO/pull/123",
- "number": 123,
- "id": "foobar123",
- "headRefName": "feature",
- "baseRefName": "master" }
- ] } } } }`))
- http.StubResponse(200, bytes.NewBufferString(testDiff))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [
+ { "url": "https://github.com/OWNER/REPO/pull/123",
+ "number": 123,
+ "id": "foobar123",
+ "headRefName": "feature",
+ "baseRefName": "master" }
+ ] } } } }`),
+ )
+ http.Register(
+ httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"),
+ httpmock.StringResponse(testDiff),
+ )
+
output, err := runCommand(http, nil, true, "")
if err != nil {
t.Fatalf("unexpected error: %s", err)
diff --git a/pkg/cmd/pr/list/list.go b/pkg/cmd/pr/list/list.go
index e4fe318c8..3ed4b1ea1 100644
--- a/pkg/cmd/pr/list/list.go
+++ b/pkg/cmd/pr/list/list.go
@@ -145,15 +145,16 @@ func listRun(opts *ListOptions) error {
fmt.Fprintf(opts.IO.Out, "\n%s\n\n", title)
}
+ cs := opts.IO.ColorScheme()
table := utils.NewTablePrinter(opts.IO)
for _, pr := range listResult.PullRequests {
prNum := strconv.Itoa(pr.Number)
if table.IsTTY() {
prNum = "#" + prNum
}
- table.AddField(prNum, nil, shared.ColorFuncForPR(pr))
+ table.AddField(prNum, nil, cs.ColorFromString(shared.ColorForPR(pr)))
table.AddField(text.ReplaceExcessiveWhitespace(pr.Title), nil, nil)
- table.AddField(pr.HeadLabel(), nil, utils.Cyan)
+ table.AddField(pr.HeadLabel(), nil, cs.Cyan)
if !table.IsTTY() {
table.AddField(prStateWithDraft(&pr), nil, nil)
}
diff --git a/pkg/cmd/pr/list/list_test.go b/pkg/cmd/pr/list/list_test.go
index ab99e4aa9..d1398a0d6 100644
--- a/pkg/cmd/pr/list/list_test.go
+++ b/pkg/cmd/pr/list/list_test.go
@@ -5,7 +5,6 @@ import (
"io/ioutil"
"net/http"
"os/exec"
- "reflect"
"strings"
"testing"
@@ -20,12 +19,6 @@ import (
"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 runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
@@ -122,11 +115,11 @@ func TestPRList_filtering(t *testing.T) {
t.Fatal(err)
}
- eq(t, output.Stderr(), "")
- eq(t, output.String(), `
+ assert.Equal(t, "", output.Stderr())
+ assert.Equal(t, `
No pull requests match your search in OWNER/REPO
-`)
+`, output.String())
}
func TestPRList_filteringRemoveDuplicate(t *testing.T) {
@@ -220,12 +213,12 @@ func TestPRList_web(t *testing.T) {
expectedURL := "https://github.com/OWNER/REPO/pulls?q=is%3Apr+is%3Amerged+assignee%3Apeter+label%3Abug+label%3Adocs+base%3Atrunk"
- eq(t, output.String(), "")
- eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/pulls in your browser.\n")
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "Opening github.com/OWNER/REPO/pulls in your browser.\n", output.Stderr())
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, expectedURL)
+ assert.Equal(t, url, expectedURL)
}
diff --git a/pkg/cmd/pr/merge/merge.go b/pkg/cmd/pr/merge/merge.go
index e22b6c13d..19292c7e2 100644
--- a/pkg/cmd/pr/merge/merge.go
+++ b/pkg/cmd/pr/merge/merge.go
@@ -16,7 +16,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"
)
@@ -28,12 +27,13 @@ type MergeOptions struct {
Remotes func() (context.Remotes, error)
Branch func() (string, error)
- SelectorArg string
- DeleteBranch bool
- DeleteLocalBranch bool
- MergeMethod api.PullRequestMergeMethod
- InteractiveMode bool
- ConfirmSubmit bool
+ SelectorArg string
+ DeleteBranch bool
+ MergeMethod api.PullRequestMergeMethod
+
+ IsDeleteBranchIndicated bool
+ CanDeleteLocalBranch bool
+ InteractiveMode bool
}
func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Command {
@@ -56,9 +56,6 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
Short: "Merge a pull request",
Long: heredoc.Doc(`
Merge a pull request on GitHub.
-
- By default, the head branch of the pull request will get deleted on both remote and local repositories.
- To retain the branch, use '--delete-branch=false'.
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -95,7 +92,8 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
return &cmdutil.FlagError{Err: errors.New("only one of --merge, --rebase, or --squash can be enabled")}
}
- opts.DeleteLocalBranch = !cmd.Flags().Changed("repo")
+ opts.IsDeleteBranchIndicated = cmd.Flags().Changed("delete-branch")
+ opts.CanDeleteLocalBranch = !cmd.Flags().Changed("repo")
if runF != nil {
return runF(opts)
@@ -104,15 +102,16 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
},
}
- cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", true, "Delete the local and remote branch after merge")
+ cmd.Flags().BoolVarP(&opts.DeleteBranch, "delete-branch", "d", false, "Delete the local and remote branch after merge")
cmd.Flags().BoolVarP(&flagMerge, "merge", "m", false, "Merge the commits with the base branch")
cmd.Flags().BoolVarP(&flagRebase, "rebase", "r", false, "Rebase the commits onto the base branch")
cmd.Flags().BoolVarP(&flagSquash, "squash", "s", false, "Squash the commits into one commit and merge it into the base branch")
- cmd.Flags().BoolVarP(&opts.ConfirmSubmit, "confirm", "y", false, "Confirm Merging the PR (default: false)")
return cmd
}
func mergeRun(opts *MergeOptions) error {
+ cs := opts.IO.ColorScheme()
+
httpClient, err := opts.HttpClient()
if err != nil {
return err
@@ -125,119 +124,114 @@ func mergeRun(opts *MergeOptions) error {
}
if pr.Mergeable == "CONFLICTING" {
- err := fmt.Errorf("%s Pull request #%d (%s) has conflicts and isn't mergeable ", utils.Red("!"), pr.Number, pr.Title)
- return err
- } else if pr.Mergeable == "UNKNOWN" {
- err := fmt.Errorf("%s Pull request #%d (%s) can't be merged right now; try again in a few seconds", utils.Red("!"), pr.Number, pr.Title)
- return err
- } else if pr.State == "MERGED" {
- err := fmt.Errorf("%s Pull request #%d (%s) was already merged", utils.Red("!"), pr.Number, pr.Title)
- return err
+ fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) has conflicts and isn't mergeable\n", cs.Red("!"), pr.Number, pr.Title)
+ return cmdutil.SilentError
}
- mergeMethod := opts.MergeMethod
deleteBranch := opts.DeleteBranch
crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner()
+ isTerminal := opts.IO.IsStdoutTTY()
- if opts.InteractiveMode {
- mergeMethod, deleteBranch, err = prInteractiveMerge(opts.DeleteLocalBranch, crossRepoPR)
- if err != nil {
- return nil
+ isPRAlreadyMerged := pr.State == "MERGED"
+ if !isPRAlreadyMerged {
+ mergeMethod := opts.MergeMethod
+
+ if opts.InteractiveMode {
+ mergeMethod, deleteBranch, err = prInteractiveMerge(opts, crossRepoPR)
+ if err != nil {
+ if errors.Is(err, cancelError) {
+ fmt.Fprintln(opts.IO.ErrOut, "Cancelled.")
+ return cmdutil.SilentError
+ }
+ return err
+ }
}
- }
- shouldSubmitFromFlag := opts.ConfirmSubmit
- if !shouldSubmitFromFlag {
- fmt.Println(opts.ConfirmSubmit)
- err := prompt.Confirm("Submit? ", &shouldSubmitFromFlag)
+ err = api.PullRequestMerge(apiClient, baseRepo, pr, mergeMethod)
if err != nil {
return err
}
- }
-
- if shouldSubmitFromFlag {
- var action string
- if mergeMethod == api.PullRequestMergeMethodRebase {
- action = "Rebased and merged"
- err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase)
- } else if mergeMethod == api.PullRequestMergeMethodSquash {
- action = "Squashed and merged"
- err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash)
- } else if mergeMethod == api.PullRequestMergeMethodMerge {
- action = "Merged"
- err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge)
- } else {
- err = fmt.Errorf("unknown merge method (%d) used", mergeMethod)
- return err
- }
-
- if err != nil {
- return fmt.Errorf("API call failed: %w", err)
- }
-
- isTerminal := opts.IO.IsStdoutTTY()
if isTerminal {
- fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", utils.Magenta("✔"), action, pr.Number, pr.Title)
+ action := "Merged"
+ switch mergeMethod {
+ case api.PullRequestMergeMethodRebase:
+ action = "Rebased and merged"
+ case api.PullRequestMergeMethodSquash:
+ action = "Squashed and merged"
+ }
+ fmt.Fprintf(opts.IO.ErrOut, "%s %s pull request #%d (%s)\n", cs.Magenta("✔"), action, pr.Number, pr.Title)
+ }
+ } else if !opts.IsDeleteBranchIndicated && opts.InteractiveMode && !crossRepoPR {
+ err := prompt.SurveyAskOne(&survey.Confirm{
+ Message: fmt.Sprintf("Pull request #%d was already merged. Delete the branch locally?", pr.Number),
+ Default: false,
+ }, &deleteBranch)
+ if err != nil {
+ return fmt.Errorf("could not prompt: %w", err)
+ }
+ } else if crossRepoPR {
+ fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d was already merged\n", cs.WarningIcon(), pr.Number)
+ }
+
+ if !deleteBranch || crossRepoPR {
+ return nil
+ }
+
+ branchSwitchString := ""
+
+ if opts.CanDeleteLocalBranch {
+ currentBranch, err := opts.Branch()
+ if err != nil {
+ return err
}
- if deleteBranch {
- branchSwitchString := ""
-
- if opts.DeleteLocalBranch && !crossRepoPR {
- currentBranch, err := opts.Branch()
- if err != nil {
- return err
- }
-
- var branchToSwitchTo string
- if currentBranch == pr.HeadRefName {
- branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo)
- if err != nil {
- return err
- }
- err = git.CheckoutBranch(branchToSwitchTo)
- if err != nil {
- return err
- }
- }
-
- localBranchExists := git.HasLocalBranch(pr.HeadRefName)
- if localBranchExists {
- err = git.DeleteLocalBranch(pr.HeadRefName)
- if err != nil {
- err = fmt.Errorf("failed to delete local branch %s: %w", utils.Cyan(pr.HeadRefName), err)
- return err
- }
- }
-
- if branchToSwitchTo != "" {
- branchSwitchString = fmt.Sprintf(" and switched to branch %s", utils.Cyan(branchToSwitchTo))
- }
+ var branchToSwitchTo string
+ if currentBranch == pr.HeadRefName {
+ branchToSwitchTo, err = api.RepoDefaultBranch(apiClient, baseRepo)
+ if err != nil {
+ return err
}
-
- if !crossRepoPR {
- err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName)
- var httpErr api.HTTPError
- // The ref might have already been deleted by GitHub
- if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) {
- err = fmt.Errorf("failed to delete remote branch %s: %w", utils.Cyan(pr.HeadRefName), err)
- return err
- }
- }
-
- if isTerminal {
- fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", utils.Red("✔"), utils.Cyan(pr.HeadRefName), branchSwitchString)
+ err = git.CheckoutBranch(branchToSwitchTo)
+ if err != nil {
+ return err
}
}
- } else {
- fmt.Println("Discarding")
+
+ localBranchExists := git.HasLocalBranch(pr.HeadRefName)
+ if localBranchExists {
+ err = git.DeleteLocalBranch(pr.HeadRefName)
+ if err != nil {
+ err = fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err)
+ return err
+ }
+ }
+
+ if branchToSwitchTo != "" {
+ branchSwitchString = fmt.Sprintf(" and switched to branch %s", cs.Cyan(branchToSwitchTo))
+ }
+ }
+
+ if !isPRAlreadyMerged {
+ err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName)
+ var httpErr api.HTTPError
+ // The ref might have already been deleted by GitHub
+ if err != nil && (!errors.As(err, &httpErr) || httpErr.StatusCode != 422) {
+ err = fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err)
+ return err
+ }
+ }
+
+ if isTerminal {
+ fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", cs.Red("✔"), cs.Cyan(pr.HeadRefName), branchSwitchString)
}
return nil
}
-func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) {
+var cancelError = errors.New("cancelError")
+
+func prInteractiveMerge(opts *MergeOptions, crossRepoPR bool) (api.PullRequestMergeMethod, bool, error) {
mergeMethodQuestion := &survey.Question{
Name: "mergeMethod",
Prompt: &survey.Select{
@@ -249,9 +243,9 @@ func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullReque
qs := []*survey.Question{mergeMethodQuestion}
- if !crossRepoPR {
+ if !crossRepoPR && !opts.IsDeleteBranchIndicated {
var message string
- if deleteLocalBranch {
+ if opts.CanDeleteLocalBranch {
message = "Delete the branch locally and on GitHub?"
} else {
message = "Delete the branch on GitHub?"
@@ -267,15 +261,29 @@ func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullReque
qs = append(qs, deleteBranchQuestion)
}
+ qs = append(qs, &survey.Question{
+ Name: "isConfirmed",
+ Prompt: &survey.Confirm{
+ Message: "Submit?",
+ Default: false,
+ },
+ })
+
answers := struct {
MergeMethod int
DeleteBranch bool
- }{}
+ IsConfirmed bool
+ }{
+ DeleteBranch: opts.DeleteBranch,
+ }
err := prompt.SurveyAsk(qs, &answers)
if err != nil {
return 0, false, fmt.Errorf("could not prompt: %w", err)
}
+ if !answers.IsConfirmed {
+ return 0, false, cancelError
+ }
var mergeMethod api.PullRequestMergeMethod
switch answers.MergeMethod {
@@ -287,6 +295,5 @@ func prInteractiveMerge(deleteLocalBranch bool, crossRepoPR bool) (api.PullReque
mergeMethod = api.PullRequestMergeMethodSquash
}
- deleteBranch := answers.DeleteBranch
- return mergeMethod, deleteBranch, nil
+ return mergeMethod, answers.DeleteBranch, nil
}
diff --git a/pkg/cmd/pr/merge/merge_test.go b/pkg/cmd/pr/merge/merge_test.go
index 5b272bff1..6545afd9c 100644
--- a/pkg/cmd/pr/merge/merge_test.go
+++ b/pkg/cmd/pr/merge/merge_test.go
@@ -2,6 +2,7 @@ package merge
import (
"bytes"
+ "errors"
"io/ioutil"
"net/http"
"regexp"
@@ -13,6 +14,7 @@ import (
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@@ -36,11 +38,25 @@ func Test_NewCmdMerge(t *testing.T) {
args: "123",
isTTY: true,
want: MergeOptions{
- SelectorArg: "123",
- DeleteBranch: true,
- DeleteLocalBranch: true,
- MergeMethod: api.PullRequestMergeMethodMerge,
- InteractiveMode: true,
+ SelectorArg: "123",
+ DeleteBranch: false,
+ IsDeleteBranchIndicated: false,
+ CanDeleteLocalBranch: true,
+ MergeMethod: api.PullRequestMergeMethodMerge,
+ InteractiveMode: true,
+ },
+ },
+ {
+ name: "delete-branch specified",
+ args: "--delete-branch=false",
+ isTTY: true,
+ want: MergeOptions{
+ SelectorArg: "",
+ DeleteBranch: false,
+ IsDeleteBranchIndicated: true,
+ CanDeleteLocalBranch: true,
+ MergeMethod: api.PullRequestMergeMethodMerge,
+ InteractiveMode: true,
},
},
{
@@ -104,7 +120,7 @@ func Test_NewCmdMerge(t *testing.T) {
assert.Equal(t, tt.want.SelectorArg, opts.SelectorArg)
assert.Equal(t, tt.want.DeleteBranch, opts.DeleteBranch)
- assert.Equal(t, tt.want.DeleteLocalBranch, opts.DeleteLocalBranch)
+ assert.Equal(t, tt.want.CanDeleteLocalBranch, opts.CanDeleteLocalBranch)
assert.Equal(t, tt.want.MergeMethod, opts.MergeMethod)
assert.Equal(t, tt.want.InteractiveMode, opts.InteractiveMode)
})
@@ -191,15 +207,10 @@ func TestPrMerge(t *testing.T) {
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
- http.Register(
- httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
- httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
- prompt.StubConfirm(true)
-
cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge)
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
@@ -239,15 +250,10 @@ func TestPrMerge_nontty(t *testing.T) {
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
- http.Register(
- httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
- httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
- prompt.StubConfirm(true)
-
cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge)
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
@@ -284,15 +290,10 @@ func TestPrMerge_withRepoFlag(t *testing.T) {
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
- http.Register(
- httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
- httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
- prompt.StubConfirm(true)
-
output, err := runCommand(http, "master", true, "pr merge 1 --merge -R OWNER/REPO")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
@@ -328,8 +329,6 @@ func TestPrMerge_deleteBranch(t *testing.T) {
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
- prompt.StubConfirm(true)
-
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git checkout master
cs.Stub("") // git rev-parse --verify blueberries`
@@ -365,8 +364,6 @@ func TestPrMerge_deleteNonCurrentBranch(t *testing.T) {
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
- prompt.StubConfirm(true)
-
// We don't expect the default branch to be checked out, just that blueberries is deleted
cs.Stub("") // git rev-parse --verify blueberries
cs.Stub("") // git branch -d blueberries
@@ -394,15 +391,10 @@ func TestPrMerge_noPrNumberGiven(t *testing.T) {
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
- http.Register(
- httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
- httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
- prompt.StubConfirm(true)
-
cs.Stub("branch.blueberries.remote origin\nbranch.blueberries.merge refs/heads/blueberries") // git config --get-regexp ^branch\.master\.(remote|merge)
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
@@ -442,15 +434,10 @@ func TestPrMerge_rebase(t *testing.T) {
assert.Equal(t, "REBASE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
- http.Register(
- httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
- httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
- prompt.StubConfirm(true)
-
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
@@ -489,15 +476,10 @@ func TestPrMerge_squash(t *testing.T) {
assert.Equal(t, "SQUASH", input["mergeMethod"].(string))
assert.Equal(t, "The title of the PR (#3)", input["commitHeadline"].(string))
}))
- http.Register(
- httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
- httpmock.StringResponse(`{}`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
- prompt.StubConfirm(true)
-
cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
cs.Stub("") // git symbolic-ref --quiet --short HEAD
cs.Stub("") // git checkout master
@@ -508,10 +490,49 @@ func TestPrMerge_squash(t *testing.T) {
t.Fatalf("error running command `pr merge`: %v", err)
}
- test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3", `Deleted branch.*blueberries`)
+ test.ExpectLines(t, output.Stderr(), "Squashed and merged pull request #3")
}
func TestPrMerge_alreadyMerged(t *testing.T) {
+ http := initFakeHTTP()
+ defer http.Verify(t)
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": {
+ "number": 4,
+ "title": "The title of the PR",
+ "state": "MERGED",
+ "baseRefName": "master",
+ "headRefName": "blueberries",
+ "headRepositoryOwner": {
+ "login": "OWNER"
+ },
+ "isCrossRepository": false
+ }
+ } } }`))
+
+ cs, cmdTeardown := run.Stub()
+ defer cmdTeardown(t)
+
+ cs.Register(`git checkout master`, 0, "")
+ cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "")
+ cs.Register(`git branch -D blueberries`, 0, "")
+
+ as, surveyTeardown := prompt.InitAskStubber()
+ defer surveyTeardown()
+ as.StubOne(true)
+
+ output, err := runCommand(http, "blueberries", true, "pr merge 4")
+ if err != nil {
+ t.Fatalf("Got unexpected error running `pr merge` %s", err)
+ }
+
+ test.ExpectLines(t, output.Stderr(), "✔ Deleted branch blueberries and switched to branch master")
+}
+
+func TestPrMerge_alreadyMerged_nonInteractive(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
@@ -521,26 +542,16 @@ func TestPrMerge_alreadyMerged(t *testing.T) {
"pullRequest": { "number": 4, "title": "The title of the PR", "state": "MERGED"}
} } }`))
- cs, cmdTeardown := test.InitCmdStubber()
- defer cmdTeardown()
+ _, cmdTeardown := run.Stub()
+ defer cmdTeardown(t)
- prompt.StubConfirm(true)
-
- cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
- cs.Stub("") // git symbolic-ref --quiet --short HEAD
- cs.Stub("") // git checkout master
- cs.Stub("") // git branch -d
-
- output, err := runCommand(http, "master", true, "pr merge 4")
- if err == nil {
- t.Fatalf("expected an error running command `pr merge`: %v", err)
+ output, err := runCommand(http, "blueberries", true, "pr merge 4 --merge")
+ if err != nil {
+ t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
- r := regexp.MustCompile(`Pull request #4 \(The title of the PR\) was already merged`)
-
- if !r.MatchString(err.Error()) {
- t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
- }
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "! Pull request #4 was already merged\n", output.Stderr())
}
func TestPRMerge_interactive(t *testing.T) {
@@ -587,14 +598,118 @@ func TestPRMerge_interactive(t *testing.T) {
Name: "deleteBranch",
Value: true,
},
+ {
+ Name: "isConfirmed",
+ Value: true,
+ },
})
- prompt.StubConfirm(true)
-
output, err := runCommand(http, "blueberries", true, "")
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
- test.ExpectLines(t, output.Stderr(), "Merged pull request #3", `Deleted branch.*blueberries`)
+ test.ExpectLines(t, output.Stderr(), "Merged pull request #3")
+}
+
+func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) {
+ http := initFakeHTTP()
+ defer http.Verify(t)
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [{
+ "headRefName": "blueberries",
+ "headRepositoryOwner": {"login": "OWNER"},
+ "id": "THE-ID",
+ "number": 3
+ }] } } } }`))
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestMerge\b`),
+ httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
+ assert.Equal(t, "THE-ID", input["pullRequestId"].(string))
+ assert.Equal(t, "MERGE", input["mergeMethod"].(string))
+ assert.NotContains(t, input, "commitHeadline")
+ }))
+ http.Register(
+ httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/blueberries"),
+ httpmock.StringResponse(`{}`))
+
+ cs, cmdTeardown := test.InitCmdStubber()
+ defer cmdTeardown()
+
+ cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
+ cs.Stub("") // git symbolic-ref --quiet --short HEAD
+ cs.Stub("") // git checkout master
+ cs.Stub("") // git push origin --delete blueberries
+ cs.Stub("") // git branch -d
+
+ as, surveyTeardown := prompt.InitAskStubber()
+ defer surveyTeardown()
+
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "mergeMethod",
+ Value: 0,
+ },
+ {
+ Name: "isConfirmed",
+ Value: true,
+ },
+ })
+
+ output, err := runCommand(http, "blueberries", true, "-d")
+ if err != nil {
+ t.Fatalf("Got unexpected error running `pr merge` %s", err)
+ }
+
+ test.ExpectLines(t, output.Stderr(), "Merged pull request #3", "Deleted branch blueberries and switched to branch master")
+}
+
+func TestPRMerge_interactiveCancelled(t *testing.T) {
+ http := initFakeHTTP()
+ defer http.Verify(t)
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [{
+ "headRefName": "blueberries",
+ "headRepositoryOwner": {"login": "OWNER"},
+ "id": "THE-ID",
+ "number": 3
+ }] } } } }`))
+
+ cs, cmdTeardown := test.InitCmdStubber()
+ defer cmdTeardown()
+
+ cs.Stub("") // git config --get-regexp ^branch\.blueberries\.(remote|merge)$
+ cs.Stub("") // git symbolic-ref --quiet --short HEAD
+ cs.Stub("") // git checkout master
+ cs.Stub("") // git push origin --delete blueberries
+ cs.Stub("") // git branch -d
+
+ as, surveyTeardown := prompt.InitAskStubber()
+ defer surveyTeardown()
+
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "mergeMethod",
+ Value: 0,
+ },
+ {
+ Name: "deleteBranch",
+ Value: true,
+ },
+ {
+ Name: "isConfirmed",
+ Value: false,
+ },
+ })
+
+ output, err := runCommand(http, "blueberries", true, "")
+ if !errors.Is(err, cmdutil.SilentError) {
+ t.Fatalf("got error %v", err)
+ }
+
+ assert.Equal(t, "Cancelled.\n", output.Stderr())
}
diff --git a/pkg/cmd/pr/pr.go b/pkg/cmd/pr/pr.go
index e06eb8059..f1981fabe 100644
--- a/pkg/cmd/pr/pr.go
+++ b/pkg/cmd/pr/pr.go
@@ -5,6 +5,7 @@ import (
cmdCheckout "github.com/cli/cli/pkg/cmd/pr/checkout"
cmdChecks "github.com/cli/cli/pkg/cmd/pr/checks"
cmdClose "github.com/cli/cli/pkg/cmd/pr/close"
+ cmdComment "github.com/cli/cli/pkg/cmd/pr/comment"
cmdCreate "github.com/cli/cli/pkg/cmd/pr/create"
cmdDiff "github.com/cli/cli/pkg/cmd/pr/diff"
cmdList "github.com/cli/cli/pkg/cmd/pr/list"
@@ -53,6 +54,7 @@ func NewCmdPR(f *cmdutil.Factory) *cobra.Command {
cmd.AddCommand(cmdStatus.NewCmdStatus(f, nil))
cmd.AddCommand(cmdView.NewCmdView(f, nil))
cmd.AddCommand(cmdChecks.NewCmdChecks(f, nil))
+ cmd.AddCommand(cmdComment.NewCmdComment(f, nil))
return cmd
}
diff --git a/pkg/cmd/pr/ready/ready.go b/pkg/cmd/pr/ready/ready.go
index 933dec082..d160eccf4 100644
--- a/pkg/cmd/pr/ready/ready.go
+++ b/pkg/cmd/pr/ready/ready.go
@@ -12,7 +12,6 @@ import (
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
- "github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
@@ -63,6 +62,8 @@ func NewCmdReady(f *cmdutil.Factory, runF func(*ReadyOptions) error) *cobra.Comm
}
func readyRun(opts *ReadyOptions) error {
+ cs := opts.IO.ColorScheme()
+
httpClient, err := opts.HttpClient()
if err != nil {
return err
@@ -75,10 +76,10 @@ func readyRun(opts *ReadyOptions) error {
}
if pr.Closed {
- fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"", utils.Red("!"), pr.Number)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"", cs.Red("!"), pr.Number)
return cmdutil.SilentError
} else if !pr.IsDraft {
- fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is already \"ready for review\"\n", utils.Yellow("!"), pr.Number)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is already \"ready for review\"\n", cs.Yellow("!"), pr.Number)
return nil
}
@@ -87,7 +88,7 @@ func readyRun(opts *ReadyOptions) error {
return fmt.Errorf("API call failed: %w", err)
}
- fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is marked as \"ready for review\"\n", utils.Green("✔"), pr.Number)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is marked as \"ready for review\"\n", cs.SuccessIcon(), pr.Number)
return nil
}
diff --git a/pkg/cmd/pr/ready/ready_test.go b/pkg/cmd/pr/ready/ready_test.go
index 4dc5d9eee..caf2c7e5f 100644
--- a/pkg/cmd/pr/ready/ready_test.go
+++ b/pkg/cmd/pr/ready/ready_test.go
@@ -143,12 +143,20 @@ func TestPRReady(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 444, "closed": false, "isDraft": true}
- } } }
- `))
- http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "id": "THE-ID", "number": 444, "closed": false, "isDraft": true}
+ } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestReadyForReview\b`),
+ httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["pullRequestId"], "THE-ID")
+ }),
+ )
output, err := runCommand(http, true, "444")
if err != nil {
@@ -166,11 +174,13 @@ func TestPRReady_alreadyReady(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 445, "closed": false, "isDraft": false}
- } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "number": 445, "closed": false, "isDraft": false}
+ } } }`),
+ )
output, err := runCommand(http, true, "445")
if err != nil {
@@ -188,11 +198,13 @@ func TestPRReady_closed(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 446, "closed": true, "isDraft": true}
- } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "number": 446, "closed": true, "isDraft": true}
+ } } }`),
+ )
output, err := runCommand(http, true, "446")
if err == nil {
diff --git a/pkg/cmd/pr/reopen/reopen.go b/pkg/cmd/pr/reopen/reopen.go
index 78f4a36ec..afbc1c37f 100644
--- a/pkg/cmd/pr/reopen/reopen.go
+++ b/pkg/cmd/pr/reopen/reopen.go
@@ -10,7 +10,6 @@ import (
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
- "github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
@@ -53,6 +52,8 @@ func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Co
}
func reopenRun(opts *ReopenOptions) error {
+ cs := opts.IO.ColorScheme()
+
httpClient, err := opts.HttpClient()
if err != nil {
return err
@@ -65,12 +66,12 @@ func reopenRun(opts *ReopenOptions) error {
}
if pr.State == "MERGED" {
- fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be reopened because it was already merged", utils.Red("!"), pr.Number, pr.Title)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be reopened because it was already merged", cs.Red("!"), pr.Number, pr.Title)
return cmdutil.SilentError
}
if !pr.Closed {
- fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already open\n", utils.Yellow("!"), pr.Number, pr.Title)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already open\n", cs.Yellow("!"), pr.Number, pr.Title)
return nil
}
@@ -79,7 +80,7 @@ func reopenRun(opts *ReopenOptions) error {
return fmt.Errorf("API call failed: %w", err)
}
- fmt.Fprintf(opts.IO.ErrOut, "%s Reopened pull request #%d (%s)\n", utils.Green("✔"), pr.Number, pr.Title)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Reopened pull request #%d (%s)\n", cs.SuccessIcon(), pr.Number, pr.Title)
return nil
}
diff --git a/pkg/cmd/pr/reopen/reopen_test.go b/pkg/cmd/pr/reopen/reopen_test.go
index 24dfa488f..19c4a1e6a 100644
--- a/pkg/cmd/pr/reopen/reopen_test.go
+++ b/pkg/cmd/pr/reopen/reopen_test.go
@@ -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,13 +59,20 @@ func TestPRReopen(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true}
- } } }
- `))
-
- http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "id": "THE-ID", "number": 666, "title": "The title of the PR", "closed": true}
+ } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestReopen\b`),
+ httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["pullRequestId"], "THE-ID")
+ }),
+ )
output, err := runCommand(http, true, "666")
if err != nil {
@@ -82,11 +90,13 @@ func TestPRReopen_alreadyOpen(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 666, "title": "The title of the PR", "closed": false}
- } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "number": 666, "title": "The title of the PR", "closed": false}
+ } } }`),
+ )
output, err := runCommand(http, true, "666")
if err != nil {
@@ -104,11 +114,13 @@ func TestPRReopen_alreadyMerged(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": {
- "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true, "state": "MERGED"}
- } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": {
+ "pullRequest": { "number": 666, "title": "The title of the PR", "closed": true, "state": "MERGED"}
+ } } }`),
+ )
output, err := runCommand(http, true, "666")
if err == nil {
diff --git a/pkg/cmd/pr/review/review.go b/pkg/cmd/pr/review/review.go
index 1f0916bf7..4fb23ec97 100644
--- a/pkg/cmd/pr/review/review.go
+++ b/pkg/cmd/pr/review/review.go
@@ -17,7 +17,6 @@ import (
"github.com/cli/cli/pkg/markdown"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/pkg/surveyext"
- "github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
@@ -61,13 +60,13 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co
Example: heredoc.Doc(`
# approve the pull request of the current branch
$ gh pr review --approve
-
+
# leave a review comment for the current branch
$ gh pr review --comment -b "interesting"
-
+
# add a review for a specific pull request
$ gh pr review 123
-
+
# request changes on a specific pull request
$ gh pr review 123 -r -b "needs more ASCII art"
`),
@@ -172,13 +171,15 @@ func reviewRun(opts *ReviewOptions) error {
return nil
}
+ cs := opts.IO.ColorScheme()
+
switch reviewData.State {
case api.ReviewComment:
- fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request #%d\n", utils.Gray("-"), pr.Number)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Reviewed pull request #%d\n", cs.Gray("-"), pr.Number)
case api.ReviewApprove:
- fmt.Fprintf(opts.IO.ErrOut, "%s Approved pull request #%d\n", utils.Green("✓"), pr.Number)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Approved pull request #%d\n", cs.SuccessIcon(), pr.Number)
case api.ReviewRequestChanges:
- fmt.Fprintf(opts.IO.ErrOut, "%s Requested changes to pull request #%d\n", utils.Red("+"), pr.Number)
+ fmt.Fprintf(opts.IO.ErrOut, "%s Requested changes to pull request #%d\n", cs.Red("+"), pr.Number)
}
return nil
@@ -254,7 +255,7 @@ func reviewSurvey(io *iostreams.IOStreams, editorCommand string) (*api.PullReque
if len(bodyAnswers.Body) > 0 {
style := markdown.GetStyle(io.DetectTerminalTheme())
- renderedBody, err := markdown.Render(bodyAnswers.Body, style)
+ renderedBody, err := markdown.Render(bodyAnswers.Body, style, "")
if err != nil {
return nil, err
}
diff --git a/pkg/cmd/pr/review/review_test.go b/pkg/cmd/pr/review/review_test.go
index b96ab8089..21e513412 100644
--- a/pkg/cmd/pr/review/review_test.go
+++ b/pkg/cmd/pr/review/review_test.go
@@ -2,7 +2,6 @@ package review
import (
"bytes"
- "encoding/json"
"io/ioutil"
"net/http"
"regexp"
@@ -183,24 +182,36 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s
func TestPRReview_url_arg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequest": {
- "id": "foobar123",
- "number": 123,
- "headRefName": "feature",
- "headRepositoryOwner": {
- "login": "hubot"
- },
- "headRepository": {
- "name": "REPO",
- "defaultBranchRef": {
- "name": "master"
- }
- },
- "isCrossRepository": false,
- "maintainerCanModify": false
- } } } } `))
- http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequest": {
+ "id": "foobar123",
+ "number": 123,
+ "headRefName": "feature",
+ "headRepositoryOwner": {
+ "login": "hubot"
+ },
+ "headRepository": {
+ "name": "REPO",
+ "defaultBranchRef": {
+ "name": "master"
+ }
+ },
+ "isCrossRepository": false,
+ "maintainerCanModify": false
+ } } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
+ httpmock.GraphQLMutation(`{"data": {} }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["pullRequestId"], "foobar123")
+ assert.Equal(t, inputs["event"], "APPROVE")
+ assert.Equal(t, inputs["body"], "")
+ }),
+ )
output, err := runCommand(http, nil, true, "--approve https://github.com/OWNER/REPO/pull/123")
if err != nil {
@@ -208,45 +219,41 @@ func TestPRReview_url_arg(t *testing.T) {
}
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
-
- bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
- reqBody := struct {
- Variables struct {
- Input struct {
- PullRequestID string
- Event string
- Body string
- }
- }
- }{}
- _ = json.Unmarshal(bodyBytes, &reqBody)
-
- assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID)
- assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event)
- assert.Equal(t, "", reqBody.Variables.Input.Body)
}
func TestPRReview_number_arg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequest": {
- "id": "foobar123",
- "number": 123,
- "headRefName": "feature",
- "headRepositoryOwner": {
- "login": "hubot"
- },
- "headRepository": {
- "name": "REPO",
- "defaultBranchRef": {
- "name": "master"
- }
- },
- "isCrossRepository": false,
- "maintainerCanModify": false
- } } } } `))
- http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequest": {
+ "id": "foobar123",
+ "number": 123,
+ "headRefName": "feature",
+ "headRepositoryOwner": {
+ "login": "hubot"
+ },
+ "headRepository": {
+ "name": "REPO",
+ "defaultBranchRef": {
+ "name": "master"
+ }
+ },
+ "isCrossRepository": false,
+ "maintainerCanModify": false
+ } } } } `),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestReviewAdd`),
+ httpmock.GraphQLMutation(`{"data": {} }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["pullRequestId"], "foobar123")
+ assert.Equal(t, inputs["event"], "APPROVE")
+ assert.Equal(t, inputs["body"], "")
+ }),
+ )
output, err := runCommand(http, nil, true, "--approve 123")
if err != nil {
@@ -254,36 +261,32 @@ func TestPRReview_number_arg(t *testing.T) {
}
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
-
- bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
- reqBody := struct {
- Variables struct {
- Input struct {
- PullRequestID string
- Event string
- Body string
- }
- }
- }{}
- _ = json.Unmarshal(bodyBytes, &reqBody)
-
- assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID)
- assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event)
- assert.Equal(t, "", reqBody.Variables.Input.Body)
}
func TestPRReview_no_arg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [
- { "url": "https://github.com/OWNER/REPO/pull/123",
- "number": 123,
- "id": "foobar123",
- "headRefName": "feature",
- "baseRefName": "master" }
- ] } } } }`))
- http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [
+ { "url": "https://github.com/OWNER/REPO/pull/123",
+ "number": 123,
+ "id": "foobar123",
+ "headRefName": "feature",
+ "baseRefName": "master" }
+ ] } } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
+ httpmock.GraphQLMutation(`{"data": {} }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["pullRequestId"], "foobar123")
+ assert.Equal(t, inputs["event"], "COMMENT")
+ assert.Equal(t, inputs["body"], "cool story")
+ }),
+ )
output, err := runCommand(http, nil, true, `--comment -b "cool story"`)
if err != nil {
@@ -291,22 +294,6 @@ func TestPRReview_no_arg(t *testing.T) {
}
test.ExpectLines(t, output.Stderr(), "Reviewed pull request #123")
-
- bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
- reqBody := struct {
- Variables struct {
- Input struct {
- PullRequestID string
- Event string
- Body string
- }
- }
- }{}
- _ = json.Unmarshal(bodyBytes, &reqBody)
-
- assert.Equal(t, "foobar123", reqBody.Variables.Input.PullRequestID)
- assert.Equal(t, "COMMENT", reqBody.Variables.Input.Event)
- assert.Equal(t, "cool story", reqBody.Variables.Input.Body)
}
func TestPRReview(t *testing.T) {
@@ -319,41 +306,37 @@ func TestPRReview(t *testing.T) {
{`--request-changes -b"bad"`, "REQUEST_CHANGES", "bad"},
{`--approve`, "APPROVE", ""},
{`--approve -b"hot damn"`, "APPROVE", "hot damn"},
- {`--comment --body "i donno"`, "COMMENT", "i donno"},
+ {`--comment --body "i dunno"`, "COMMENT", "i dunno"},
}
for _, kase := range cases {
t.Run(kase.Cmd, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
- ] } } } }
- `))
- http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
+ ] } } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
+ httpmock.GraphQLMutation(`{"data": {} }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["event"], kase.ExpectedEvent)
+ assert.Equal(t, inputs["body"], kase.ExpectedBody)
+ }),
+ )
_, err := runCommand(http, nil, false, kase.Cmd)
if err != nil {
t.Fatalf("got unexpected error running %s: %s", kase.Cmd, err)
}
-
- bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
- reqBody := struct {
- Variables struct {
- Input struct {
- Event string
- Body string
- }
- }
- }{}
- _ = json.Unmarshal(bodyBytes, &reqBody)
-
- assert.Equal(t, kase.ExpectedEvent, reqBody.Variables.Input.Event)
- assert.Equal(t, kase.ExpectedBody, reqBody.Variables.Input.Body)
})
}
}
@@ -361,17 +344,27 @@ func TestPRReview(t *testing.T) {
func TestPRReview_nontty(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [
- { "url": "https://github.com/OWNER/REPO/pull/123",
- "number": 123,
- "id": "foobar123",
- "headRefName": "feature",
- "baseRefName": "master" }
- ] } } } }
- `))
- http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [
+ { "url": "https://github.com/OWNER/REPO/pull/123",
+ "number": 123,
+ "id": "foobar123",
+ "headRefName": "feature",
+ "baseRefName": "master" }
+ ] } } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
+ httpmock.GraphQLMutation(`{"data": {} }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["event"], "COMMENT")
+ assert.Equal(t, inputs["body"], "cool")
+ }),
+ )
+
output, err := runCommand(http, nil, false, "-c -bcool")
if err != nil {
t.Fatalf("unexpected error running command: %s", err)
@@ -379,35 +372,32 @@ func TestPRReview_nontty(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
-
- bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
- reqBody := struct {
- Variables struct {
- Input struct {
- Event string
- Body string
- }
- }
- }{}
- _ = json.Unmarshal(bodyBytes, &reqBody)
-
- assert.Equal(t, "COMMENT", reqBody.Variables.Input.Event)
- assert.Equal(t, "cool", reqBody.Variables.Input.Body)
}
func TestPRReview_interactive(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [
- { "url": "https://github.com/OWNER/REPO/pull/123",
- "number": 123,
- "id": "foobar123",
- "headRefName": "feature",
- "baseRefName": "master" }
- ] } } } }
- `))
- http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [
+ { "url": "https://github.com/OWNER/REPO/pull/123",
+ "number": 123,
+ "id": "foobar123",
+ "headRefName": "feature",
+ "baseRefName": "master" }
+ ] } } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
+ httpmock.GraphQLMutation(`{"data": {} }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["event"], "APPROVE")
+ assert.Equal(t, inputs["body"], "cool story")
+ }),
+ )
+
as, teardown := prompt.InitAskStubber()
defer teardown()
@@ -440,33 +430,22 @@ func TestPRReview_interactive(t *testing.T) {
test.ExpectLines(t, output.String(),
"Got:",
"cool.*story")
-
- bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
- reqBody := struct {
- Variables struct {
- Input struct {
- Event string
- Body string
- }
- }
- }{}
- _ = json.Unmarshal(bodyBytes, &reqBody)
-
- assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event)
- assert.Equal(t, "cool story", reqBody.Variables.Input.Body)
}
func TestPRReview_interactive_no_body(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [
- { "url": "https://github.com/OWNER/REPO/pull/123",
- "id": "foobar123",
- "headRefName": "feature",
- "baseRefName": "master" }
- ] } } } }
- `))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [
+ { "url": "https://github.com/OWNER/REPO/pull/123",
+ "id": "foobar123",
+ "headRefName": "feature",
+ "baseRefName": "master" }
+ ] } } } }`),
+ )
as, teardown := prompt.InitAskStubber()
defer teardown()
@@ -491,25 +470,33 @@ func TestPRReview_interactive_no_body(t *testing.T) {
})
_, err := runCommand(http, nil, true, "")
- if err == nil {
- t.Fatal("expected error")
- }
- assert.Equal(t, "this type of review cannot be blank", err.Error())
+ assert.EqualError(t, err, "this type of review cannot be blank")
}
func TestPRReview_interactive_blank_approve(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [
- { "url": "https://github.com/OWNER/REPO/pull/123",
- "number": 123,
- "id": "foobar123",
- "headRefName": "feature",
- "baseRefName": "master" }
- ] } } } }
- `))
- http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [
+ { "url": "https://github.com/OWNER/REPO/pull/123",
+ "number": 123,
+ "id": "foobar123",
+ "headRefName": "feature",
+ "baseRefName": "master" }
+ ] } } } }`),
+ )
+ http.Register(
+ httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
+ httpmock.GraphQLMutation(`{"data": {} }`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["event"], "APPROVE")
+ assert.Equal(t, inputs["body"], "")
+ }),
+ )
+
as, teardown := prompt.InitAskStubber()
defer teardown()
@@ -543,18 +530,4 @@ func TestPRReview_interactive_blank_approve(t *testing.T) {
}
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
-
- bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
- reqBody := struct {
- Variables struct {
- Input struct {
- Event string
- Body string
- }
- }
- }{}
- _ = json.Unmarshal(bodyBytes, &reqBody)
-
- assert.Equal(t, "APPROVE", reqBody.Variables.Input.Event)
- assert.Equal(t, "", reqBody.Variables.Input.Body)
}
diff --git a/pkg/cmd/pr/shared/commentable.go b/pkg/cmd/pr/shared/commentable.go
new file mode 100644
index 000000000..55edccbcb
--- /dev/null
+++ b/pkg/cmd/pr/shared/commentable.go
@@ -0,0 +1,168 @@
+package shared
+
+import (
+ "bufio"
+ "errors"
+ "fmt"
+ "io"
+ "net/http"
+
+ "github.com/AlecAivazis/survey/v2"
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/internal/config"
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/cli/cli/pkg/surveyext"
+ "github.com/cli/cli/utils"
+ "github.com/spf13/cobra"
+)
+
+type InputType int
+
+const (
+ InputTypeEditor InputType = iota
+ InputTypeInline
+ InputTypeWeb
+)
+
+type Commentable interface {
+ Link() string
+ Identifier() string
+}
+
+type CommentableOptions struct {
+ IO *iostreams.IOStreams
+ HttpClient func() (*http.Client, error)
+ RetrieveCommentable func() (Commentable, ghrepo.Interface, error)
+ EditSurvey func() (string, error)
+ InteractiveEditSurvey func() (string, error)
+ ConfirmSubmitSurvey func() (bool, error)
+ OpenInBrowser func(string) error
+ Interactive bool
+ InputType InputType
+ Body string
+}
+
+func CommentablePreRun(cmd *cobra.Command, opts *CommentableOptions) error {
+ inputFlags := 0
+ if cmd.Flags().Changed("body") {
+ opts.InputType = InputTypeInline
+ inputFlags++
+ }
+ if web, _ := cmd.Flags().GetBool("web"); web {
+ opts.InputType = InputTypeWeb
+ inputFlags++
+ }
+ if editor, _ := cmd.Flags().GetBool("editor"); editor {
+ opts.InputType = InputTypeEditor
+ inputFlags++
+ }
+
+ if inputFlags == 0 {
+ if !opts.IO.CanPrompt() {
+ return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")}
+ }
+ opts.Interactive = true
+ } else if inputFlags == 1 {
+ if !opts.IO.CanPrompt() && opts.InputType == InputTypeEditor {
+ return &cmdutil.FlagError{Err: errors.New("--body or --web required when not running interactively")}
+ }
+ } else if inputFlags > 1 {
+ return &cmdutil.FlagError{Err: fmt.Errorf("specify only one of --body, --editor, or --web")}
+ }
+
+ return nil
+}
+
+func CommentableRun(opts *CommentableOptions) error {
+ commentable, repo, err := opts.RetrieveCommentable()
+ if err != nil {
+ return err
+ }
+
+ switch opts.InputType {
+ case InputTypeWeb:
+ openURL := commentable.Link() + "#issuecomment-new"
+ if opts.IO.IsStdoutTTY() {
+ fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
+ }
+ return opts.OpenInBrowser(openURL)
+ case InputTypeEditor:
+ var body string
+ if opts.Interactive {
+ body, err = opts.InteractiveEditSurvey()
+ } else {
+ body, err = opts.EditSurvey()
+ }
+ if err != nil {
+ return err
+ }
+ opts.Body = body
+ }
+
+ if opts.Interactive {
+ cont, err := opts.ConfirmSubmitSurvey()
+ if err != nil {
+ return err
+ }
+ if !cont {
+ return errors.New("Discarding...")
+ }
+ }
+
+ httpClient, err := opts.HttpClient()
+ if err != nil {
+ return err
+ }
+ apiClient := api.NewClientFromHTTP(httpClient)
+ params := api.CommentCreateInput{Body: opts.Body, SubjectId: commentable.Identifier()}
+ url, err := api.CommentCreate(apiClient, repo.RepoHost(), params)
+ if err != nil {
+ return err
+ }
+ fmt.Fprintln(opts.IO.Out, url)
+ return nil
+}
+
+func CommentableConfirmSubmitSurvey() (bool, error) {
+ var confirm bool
+ submit := &survey.Confirm{
+ Message: "Submit?",
+ Default: true,
+ }
+ err := survey.AskOne(submit, &confirm)
+ return confirm, err
+}
+
+func CommentableInteractiveEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) {
+ return func() (string, error) {
+ editorCommand, err := cmdutil.DetermineEditor(cf)
+ if err != nil {
+ return "", err
+ }
+ if editorCommand == "" {
+ editorCommand = surveyext.DefaultEditorName()
+ }
+ cs := io.ColorScheme()
+ fmt.Fprintf(io.Out, "- %s to draft your comment in %s... ", cs.Bold("Press Enter"), cs.Bold(editorCommand))
+ _ = waitForEnter(io.In)
+ return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut, nil)
+ }
+}
+
+func CommentableEditSurvey(cf func() (config.Config, error), io *iostreams.IOStreams) func() (string, error) {
+ return func() (string, error) {
+ editorCommand, err := cmdutil.DetermineEditor(cf)
+ if err != nil {
+ return "", err
+ }
+ return surveyext.Edit(editorCommand, "*.md", "", io.In, io.Out, io.ErrOut, nil)
+ }
+}
+
+func waitForEnter(r io.Reader) error {
+ scanner := bufio.NewScanner(r)
+ scanner.Scan()
+ return scanner.Err()
+}
diff --git a/pkg/cmd/pr/shared/comments.go b/pkg/cmd/pr/shared/comments.go
new file mode 100644
index 000000000..9f27416a2
--- /dev/null
+++ b/pkg/cmd/pr/shared/comments.go
@@ -0,0 +1,184 @@
+package shared
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/cli/cli/pkg/markdown"
+ "github.com/cli/cli/utils"
+)
+
+type Comment interface {
+ AuthorLogin() string
+ Association() string
+ Content() string
+ Created() time.Time
+ IsEdited() bool
+ Link() string
+ Reactions() api.ReactionGroups
+ Status() string
+}
+
+func RawCommentList(comments api.Comments, reviews api.PullRequestReviews) string {
+ sortedComments := sortComments(comments, reviews)
+ var b strings.Builder
+ for _, comment := range sortedComments {
+ fmt.Fprint(&b, formatRawComment(comment))
+ }
+ return b.String()
+}
+
+func formatRawComment(comment Comment) string {
+ var b strings.Builder
+ fmt.Fprintf(&b, "author:\t%s\n", comment.AuthorLogin())
+ fmt.Fprintf(&b, "association:\t%s\n", strings.ToLower(comment.Association()))
+ fmt.Fprintf(&b, "edited:\t%t\n", comment.IsEdited())
+ fmt.Fprintf(&b, "status:\t%s\n", formatRawCommentStatus(comment.Status()))
+ fmt.Fprintln(&b, "--")
+ fmt.Fprintln(&b, comment.Content())
+ fmt.Fprintln(&b, "--")
+ return b.String()
+}
+
+func CommentList(io *iostreams.IOStreams, comments api.Comments, reviews api.PullRequestReviews, preview bool) (string, error) {
+ sortedComments := sortComments(comments, reviews)
+ if preview && len(sortedComments) > 0 {
+ sortedComments = sortedComments[len(sortedComments)-1:]
+ }
+ var b strings.Builder
+ cs := io.ColorScheme()
+ totalCount := comments.TotalCount + reviews.TotalCount
+ retrievedCount := len(sortedComments)
+ hiddenCount := totalCount - retrievedCount
+
+ if hiddenCount > 0 {
+ fmt.Fprint(&b, cs.Gray(fmt.Sprintf("———————— Not showing %s ————————", utils.Pluralize(hiddenCount, "comment"))))
+ fmt.Fprintf(&b, "\n\n\n")
+ }
+
+ for i, comment := range sortedComments {
+ last := i+1 == retrievedCount
+ cmt, err := formatComment(io, comment, last)
+ if err != nil {
+ return "", err
+ }
+ fmt.Fprint(&b, cmt)
+ if last {
+ fmt.Fprintln(&b)
+ }
+ }
+
+ if hiddenCount > 0 {
+ fmt.Fprint(&b, cs.Gray("Use --comments to view the full conversation"))
+ fmt.Fprintln(&b)
+ }
+
+ return b.String(), nil
+}
+
+func formatComment(io *iostreams.IOStreams, comment Comment, newest bool) (string, error) {
+ var b strings.Builder
+ cs := io.ColorScheme()
+
+ // Header
+ fmt.Fprint(&b, cs.Bold(comment.AuthorLogin()))
+ if comment.Status() != "" {
+ fmt.Fprint(&b, formatCommentStatus(cs, comment.Status()))
+ }
+ if comment.Association() != "NONE" {
+ fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" (%s)", strings.Title(strings.ToLower(comment.Association())))))
+ }
+ fmt.Fprint(&b, cs.Bold(fmt.Sprintf(" • %s", utils.FuzzyAgoAbbr(time.Now(), comment.Created()))))
+ if comment.IsEdited() {
+ fmt.Fprint(&b, cs.Bold(" • Edited"))
+ }
+ if newest {
+ fmt.Fprint(&b, cs.Bold(" • "))
+ fmt.Fprint(&b, cs.CyanBold("Newest comment"))
+ }
+ fmt.Fprintln(&b)
+
+ // Reactions
+ if reactions := ReactionGroupList(comment.Reactions()); reactions != "" {
+ fmt.Fprint(&b, reactions)
+ fmt.Fprintln(&b)
+ }
+
+ // Body
+ var md string
+ var err error
+ if comment.Content() == "" {
+ md = fmt.Sprintf("\n %s\n\n", cs.Gray("No body provided"))
+ } else {
+ style := markdown.GetStyle(io.TerminalTheme())
+ md, err = markdown.Render(comment.Content(), style, "")
+ if err != nil {
+ return "", err
+ }
+ }
+ fmt.Fprint(&b, md)
+
+ // Footer
+ if comment.Link() != "" {
+ fmt.Fprintf(&b, cs.Gray("View the full review: %s\n\n"), comment.Link())
+ }
+
+ return b.String(), nil
+}
+
+func sortComments(cs api.Comments, rs api.PullRequestReviews) []Comment {
+ comments := cs.Nodes
+ reviews := rs.Nodes
+ var sorted []Comment = make([]Comment, len(comments)+len(reviews))
+
+ var i int
+ for _, c := range comments {
+ sorted[i] = c
+ i++
+ }
+ for _, r := range reviews {
+ sorted[i] = r
+ i++
+ }
+
+ sort.Slice(sorted, func(i, j int) bool {
+ return sorted[i].Created().Before(sorted[j].Created())
+ })
+
+ return sorted
+}
+
+const (
+ approvedStatus = "APPROVED"
+ changesRequestedStatus = "CHANGES_REQUESTED"
+ commentedStatus = "COMMENTED"
+ dismissedStatus = "DISMISSED"
+)
+
+func formatCommentStatus(cs *iostreams.ColorScheme, status string) string {
+ switch status {
+ case approvedStatus:
+ return fmt.Sprintf(" %s", cs.Green("approved"))
+ case changesRequestedStatus:
+ return fmt.Sprintf(" %s", cs.Red("requested changes"))
+ case commentedStatus, dismissedStatus:
+ return fmt.Sprintf(" %s", strings.ToLower(status))
+ }
+
+ return ""
+}
+
+func formatRawCommentStatus(status string) string {
+ if status == approvedStatus ||
+ status == changesRequestedStatus ||
+ status == commentedStatus ||
+ status == dismissedStatus {
+ return strings.ReplaceAll(strings.ToLower(status), "_", " ")
+ }
+
+ return "none"
+}
diff --git a/pkg/cmd/pr/shared/display.go b/pkg/cmd/pr/shared/display.go
index 31e7228b6..061fb9664 100644
--- a/pkg/cmd/pr/shared/display.go
+++ b/pkg/cmd/pr/shared/display.go
@@ -2,48 +2,49 @@ package shared
import (
"fmt"
- "io"
"strings"
"github.com/cli/cli/api"
+ "github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
)
-func StateTitleWithColor(pr api.PullRequest) string {
- prStateColorFunc := ColorFuncForPR(pr)
+func StateTitleWithColor(cs *iostreams.ColorScheme, pr api.PullRequest) string {
+ prStateColorFunc := cs.ColorFromString(ColorForPR(pr))
+
if pr.State == "OPEN" && pr.IsDraft {
return prStateColorFunc(strings.Title(strings.ToLower("Draft")))
}
return prStateColorFunc(strings.Title(strings.ToLower(pr.State)))
}
-func ColorFuncForPR(pr api.PullRequest) func(string) string {
+func ColorForPR(pr api.PullRequest) string {
if pr.State == "OPEN" && pr.IsDraft {
- return utils.Gray
+ return "gray"
}
- return ColorFuncForState(pr.State)
+ return ColorForState(pr.State)
}
-// ColorFuncForState returns a color function for a PR/Issue state
-func ColorFuncForState(state string) func(string) string {
+// ColorForState returns a color constant for a PR/Issue state
+func ColorForState(state string) string {
switch state {
case "OPEN":
- return utils.Green
+ return "green"
case "CLOSED":
- return utils.Red
+ return "red"
case "MERGED":
- return utils.Magenta
+ return "magenta"
default:
- return nil
+ return ""
}
}
-func PrintHeader(w io.Writer, s string) {
- fmt.Fprintln(w, utils.Bold(s))
+func PrintHeader(io *iostreams.IOStreams, s string) {
+ fmt.Fprintln(io.Out, io.ColorScheme().Bold(s))
}
-func PrintMessage(w io.Writer, s string) {
- fmt.Fprintln(w, utils.Gray(s))
+func PrintMessage(io *iostreams.IOStreams, s string) {
+ fmt.Fprintln(io.Out, io.ColorScheme().Gray(s))
}
func ListHeader(repoName string, itemName string, matchCount int, totalMatchCount int, hasFilters bool) string {
diff --git a/pkg/cmd/pr/shared/lookup.go b/pkg/cmd/pr/shared/lookup.go
index 9583b854f..06e9221c0 100644
--- a/pkg/cmd/pr/shared/lookup.go
+++ b/pkg/cmd/pr/shared/lookup.go
@@ -43,7 +43,7 @@ func PRFromArgs(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, erro
}
// Last see if it is a branch name
- pr, err = api.PullRequestForBranch(apiClient, repo, "", arg)
+ pr, err = api.PullRequestForBranch(apiClient, repo, "", arg, nil)
return pr, repo, err
}
}
@@ -117,5 +117,5 @@ func prForCurrentBranch(apiClient *api.Client, repo ghrepo.Interface, branchFn f
}
}
- return api.PullRequestForBranch(apiClient, repo, "", prHeadRef)
+ return api.PullRequestForBranch(apiClient, repo, "", prHeadRef, nil)
}
diff --git a/pkg/cmd/pr/shared/params.go b/pkg/cmd/pr/shared/params.go
index 616b4b358..6efdf9494 100644
--- a/pkg/cmd/pr/shared/params.go
+++ b/pkg/cmd/pr/shared/params.go
@@ -9,53 +9,81 @@ import (
"github.com/cli/cli/internal/ghrepo"
)
-func WithPrAndIssueQueryParams(baseURL, title, body string, assignees, labels, projects []string, milestones []string) (string, error) {
+func WithPrAndIssueQueryParams(baseURL string, state IssueMetadataState) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", err
}
q := u.Query()
- if title != "" {
- q.Set("title", title)
+ if state.Title != "" {
+ q.Set("title", state.Title)
}
- if body != "" {
- q.Set("body", body)
+ if state.Body != "" {
+ q.Set("body", state.Body)
}
- if len(assignees) > 0 {
- q.Set("assignees", strings.Join(assignees, ","))
+ if len(state.Assignees) > 0 {
+ q.Set("assignees", strings.Join(state.Assignees, ","))
}
- if len(labels) > 0 {
- q.Set("labels", strings.Join(labels, ","))
+ if len(state.Labels) > 0 {
+ q.Set("labels", strings.Join(state.Labels, ","))
}
- if len(projects) > 0 {
- q.Set("projects", strings.Join(projects, ","))
+ if len(state.Projects) > 0 {
+ q.Set("projects", strings.Join(state.Projects, ","))
}
- if len(milestones) > 0 {
- q.Set("milestone", milestones[0])
+ if len(state.Milestones) > 0 {
+ q.Set("milestone", state.Milestones[0])
}
u.RawQuery = q.Encode()
return u.String(), nil
}
+// Ensure that tb.MetadataResult object exists and contains enough pre-fetched API data to be able
+// to resolve all object listed in tb to GraphQL IDs.
+func fillMetadata(client *api.Client, baseRepo ghrepo.Interface, tb *IssueMetadataState) error {
+ resolveInput := api.RepoResolveInput{}
+
+ if len(tb.Assignees) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) {
+ resolveInput.Assignees = tb.Assignees
+ }
+
+ if len(tb.Reviewers) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.AssignableUsers) == 0) {
+ resolveInput.Reviewers = tb.Reviewers
+ }
+
+ if len(tb.Labels) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Labels) == 0) {
+ resolveInput.Labels = tb.Labels
+ }
+
+ if len(tb.Projects) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Projects) == 0) {
+ resolveInput.Projects = tb.Projects
+ }
+
+ if len(tb.Milestones) > 0 && (tb.MetadataResult == nil || len(tb.MetadataResult.Milestones) == 0) {
+ resolveInput.Milestones = tb.Milestones
+ }
+
+ metadataResult, err := api.RepoResolveMetadataIDs(client, baseRepo, resolveInput)
+ if err != nil {
+ return err
+ }
+
+ if tb.MetadataResult == nil {
+ tb.MetadataResult = metadataResult
+ } else {
+ tb.MetadataResult.Merge(metadataResult)
+ }
+
+ return nil
+}
+
func AddMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *IssueMetadataState) error {
if !tb.HasMetadata() {
return nil
}
- if tb.MetadataResult == nil {
- resolveInput := api.RepoResolveInput{
- Reviewers: tb.Reviewers,
- Assignees: tb.Assignees,
- Labels: tb.Labels,
- Projects: tb.Projects,
- Milestones: tb.Milestones,
- }
-
- var err error
- tb.MetadataResult, err = api.RepoResolveMetadataIDs(client, baseRepo, resolveInput)
- if err != nil {
- return err
- }
+ err := fillMetadata(client, baseRepo, tb)
+ if err != nil {
+ return err
}
assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees)
diff --git a/pkg/cmd/pr/shared/preserve.go b/pkg/cmd/pr/shared/preserve.go
new file mode 100644
index 000000000..4105823cd
--- /dev/null
+++ b/pkg/cmd/pr/shared/preserve.go
@@ -0,0 +1,62 @@
+package shared
+
+import (
+ "encoding/json"
+ "fmt"
+ "os"
+
+ "github.com/cli/cli/pkg/iostreams"
+)
+
+func PreserveInput(io *iostreams.IOStreams, state *IssueMetadataState, createErr *error) func() {
+ return func() {
+ if !state.IsDirty() {
+ return
+ }
+
+ if *createErr == nil {
+ return
+ }
+
+ out := io.ErrOut
+
+ // this extra newline guards against appending to the end of a survey line
+ fmt.Fprintln(out)
+
+ data, err := json.Marshal(state)
+ if err != nil {
+ fmt.Fprintf(out, "failed to save input to file: %s\n", err)
+ fmt.Fprintln(out, "would have saved:")
+ fmt.Fprintf(out, "%v\n", state)
+ return
+ }
+
+ tmpfile, err := io.TempFile(os.TempDir(), "gh*.json")
+ if err != nil {
+ fmt.Fprintf(out, "failed to save input to file: %s\n", err)
+ fmt.Fprintln(out, "would have saved:")
+ fmt.Fprintf(out, "%v\n", state)
+ return
+ }
+
+ _, err = tmpfile.Write(data)
+ if err != nil {
+ fmt.Fprintf(out, "failed to save input to file: %s\n", err)
+ fmt.Fprintln(out, "would have saved:")
+ fmt.Fprintln(out, string(data))
+ return
+ }
+
+ cs := io.ColorScheme()
+
+ issueType := "pr"
+ if state.Type == IssueMetadata {
+ issueType = "issue"
+ }
+
+ fmt.Fprintf(out, "%s operation failed. To restore: gh %s create --recover %s\n", cs.FailureIcon(), issueType, tmpfile.Name())
+
+ // some whitespace before the actual error
+ fmt.Fprintln(out)
+ }
+}
diff --git a/pkg/cmd/pr/shared/preserve_test.go b/pkg/cmd/pr/shared/preserve_test.go
new file mode 100644
index 000000000..28949d957
--- /dev/null
+++ b/pkg/cmd/pr/shared/preserve_test.go
@@ -0,0 +1,122 @@
+package shared
+
+import (
+ "encoding/json"
+ "errors"
+ "io/ioutil"
+ "os"
+ "testing"
+
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/cli/cli/test"
+ "github.com/stretchr/testify/assert"
+)
+
+func Test_PreserveInput(t *testing.T) {
+ tests := []struct {
+ name string
+ state *IssueMetadataState
+ err bool
+ wantErrLine string
+ wantPreservation bool
+ }{
+ {
+ name: "err, no changes to state",
+ err: true,
+ },
+ {
+ name: "no err, no changes to state",
+ err: false,
+ },
+ {
+ name: "no err, changes to state",
+ state: &IssueMetadataState{
+ dirty: true,
+ },
+ },
+ {
+ name: "err, title/body input received",
+ state: &IssueMetadataState{
+ dirty: true,
+ Title: "almost a",
+ Body: "jill sandwich",
+ Reviewers: []string{"barry", "chris"},
+ Labels: []string{"sandwich"},
+ },
+ wantErrLine: `X operation failed. To restore: gh issue create --recover .*testfile.*`,
+ err: true,
+ wantPreservation: true,
+ },
+ {
+ name: "err, metadata received",
+ state: &IssueMetadataState{
+ Reviewers: []string{"barry", "chris"},
+ Labels: []string{"sandwich"},
+ },
+ wantErrLine: `X operation failed. To restore: gh issue create --recover .*testfile.*`,
+ err: true,
+ wantPreservation: true,
+ },
+ {
+ name: "err, dirty, pull request",
+ state: &IssueMetadataState{
+ dirty: true,
+ Title: "a pull request",
+ Type: PRMetadata,
+ },
+ wantErrLine: `X operation failed. To restore: gh pr create --recover .*testfile.*`,
+ err: true,
+ wantPreservation: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ if tt.state == nil {
+ tt.state = &IssueMetadataState{}
+ }
+
+ io, _, _, errOut := iostreams.Test()
+
+ tf, tferr := tmpfile()
+ assert.NoError(t, tferr)
+ defer os.Remove(tf.Name())
+
+ io.TempFileOverride = tf
+
+ var err error
+ if tt.err {
+ err = errors.New("error during creation")
+ }
+
+ PreserveInput(io, tt.state, &err)()
+
+ _, err = tf.Seek(0, 0)
+ assert.NoError(t, err)
+
+ data, err := ioutil.ReadAll(tf)
+ assert.NoError(t, err)
+
+ if tt.wantPreservation {
+ test.ExpectLines(t, errOut.String(), tt.wantErrLine)
+ preserved := &IssueMetadataState{}
+ assert.NoError(t, json.Unmarshal(data, preserved))
+ preserved.dirty = tt.state.dirty
+ assert.Equal(t, preserved, tt.state)
+ } else {
+ assert.Equal(t, errOut.String(), "")
+ assert.Equal(t, string(data), "")
+ }
+ })
+ }
+}
+
+func tmpfile() (*os.File, error) {
+ dir := os.TempDir()
+ tmpfile, err := ioutil.TempFile(dir, "testfile*")
+ if err != nil {
+ return nil, err
+ }
+
+ return tmpfile, nil
+}
diff --git a/pkg/cmd/pr/shared/reaction_groups.go b/pkg/cmd/pr/shared/reaction_groups.go
new file mode 100644
index 000000000..caf972672
--- /dev/null
+++ b/pkg/cmd/pr/shared/reaction_groups.go
@@ -0,0 +1,32 @@
+package shared
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/cli/cli/api"
+)
+
+func ReactionGroupList(rgs api.ReactionGroups) string {
+ var rs []string
+
+ for _, rg := range rgs {
+ if r := formatReactionGroup(rg); r != "" {
+ rs = append(rs, r)
+ }
+ }
+
+ return strings.Join(rs, " • ")
+}
+
+func formatReactionGroup(rg api.ReactionGroup) string {
+ c := rg.Count()
+ if c == 0 {
+ return ""
+ }
+ e := rg.Emoji()
+ if e == "" {
+ return ""
+ }
+ return fmt.Sprintf("%v %s", c, e)
+}
diff --git a/pkg/cmd/pr/shared/state.go b/pkg/cmd/pr/shared/state.go
new file mode 100644
index 000000000..930a69fcc
--- /dev/null
+++ b/pkg/cmd/pr/shared/state.go
@@ -0,0 +1,68 @@
+package shared
+
+import (
+ "encoding/json"
+ "fmt"
+
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/pkg/iostreams"
+)
+
+type metadataStateType int
+
+const (
+ IssueMetadata metadataStateType = iota
+ PRMetadata
+)
+
+type IssueMetadataState struct {
+ Type metadataStateType
+
+ Draft bool
+
+ Body string
+ Title string
+
+ Metadata []string
+ Reviewers []string
+ Assignees []string
+ Labels []string
+ Projects []string
+ Milestones []string
+
+ MetadataResult *api.RepoMetadataResult
+
+ dirty bool // whether user i/o has modified this
+}
+
+func (tb *IssueMetadataState) MarkDirty() {
+ tb.dirty = true
+}
+
+func (tb *IssueMetadataState) IsDirty() bool {
+ return tb.dirty || tb.HasMetadata()
+}
+
+func (tb *IssueMetadataState) HasMetadata() bool {
+ return len(tb.Reviewers) > 0 ||
+ len(tb.Assignees) > 0 ||
+ len(tb.Labels) > 0 ||
+ len(tb.Projects) > 0 ||
+ len(tb.Milestones) > 0
+}
+
+func FillFromJSON(io *iostreams.IOStreams, recoverFile string, state *IssueMetadataState) error {
+ var data []byte
+ var err error
+ data, err = io.ReadUserFile(recoverFile)
+ if err != nil {
+ return fmt.Errorf("failed to read file %s: %w", recoverFile, err)
+ }
+
+ err = json.Unmarshal(data, state)
+ if err != nil {
+ return fmt.Errorf("JSON parsing failure: %w", err)
+ }
+
+ return nil
+}
diff --git a/pkg/cmd/pr/shared/survey.go b/pkg/cmd/pr/shared/survey.go
new file mode 100644
index 000000000..8ef40d047
--- /dev/null
+++ b/pkg/cmd/pr/shared/survey.go
@@ -0,0 +1,406 @@
+package shared
+
+import (
+ "fmt"
+ "strings"
+
+ "github.com/AlecAivazis/survey/v2"
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/git"
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/githubtemplate"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/cli/cli/pkg/prompt"
+ "github.com/cli/cli/pkg/surveyext"
+)
+
+type Action int
+
+const (
+ SubmitAction Action = iota
+ PreviewAction
+ CancelAction
+ MetadataAction
+
+ noMilestone = "(none)"
+)
+
+func ConfirmSubmission(allowPreview bool, allowMetadata bool) (Action, error) {
+ const (
+ submitLabel = "Submit"
+ previewLabel = "Continue in browser"
+ metadataLabel = "Add metadata"
+ cancelLabel = "Cancel"
+ )
+
+ options := []string{submitLabel}
+ if allowPreview {
+ options = append(options, previewLabel)
+ }
+ if allowMetadata {
+ options = append(options, metadataLabel)
+ }
+ options = append(options, cancelLabel)
+
+ confirmAnswers := struct {
+ Confirmation int
+ }{}
+ confirmQs := []*survey.Question{
+ {
+ Name: "confirmation",
+ Prompt: &survey.Select{
+ Message: "What's next?",
+ Options: options,
+ },
+ },
+ }
+
+ err := prompt.SurveyAsk(confirmQs, &confirmAnswers)
+ if err != nil {
+ return -1, fmt.Errorf("could not prompt: %w", err)
+ }
+
+ switch options[confirmAnswers.Confirmation] {
+ case submitLabel:
+ return SubmitAction, nil
+ case previewLabel:
+ return PreviewAction, nil
+ case metadataLabel:
+ return MetadataAction, nil
+ case cancelLabel:
+ return CancelAction, nil
+ default:
+ return -1, fmt.Errorf("invalid index: %d", confirmAnswers.Confirmation)
+ }
+}
+
+func TemplateSurvey(templateFiles []string, legacyTemplate string, state IssueMetadataState) (templateContent string, err error) {
+ if len(templateFiles) == 0 && legacyTemplate == "" {
+ return
+ }
+
+ if len(templateFiles) > 0 {
+ templateContent, err = selectTemplate(templateFiles, legacyTemplate, state.Type)
+ } else {
+ templateContent = string(githubtemplate.ExtractContents(legacyTemplate))
+ }
+
+ return
+}
+
+func selectTemplate(nonLegacyTemplatePaths []string, legacyTemplatePath string, metadataType metadataStateType) (string, error) {
+ templateResponse := struct {
+ Index int
+ }{}
+ templateNames := make([]string, 0, len(nonLegacyTemplatePaths))
+ for _, p := range nonLegacyTemplatePaths {
+ templateNames = append(templateNames, githubtemplate.ExtractName(p))
+ }
+ if metadataType == IssueMetadata {
+ templateNames = append(templateNames, "Open a blank issue")
+ } else if metadataType == PRMetadata {
+ templateNames = append(templateNames, "Open a blank pull request")
+ }
+
+ selectQs := []*survey.Question{
+ {
+ Name: "index",
+ Prompt: &survey.Select{
+ Message: "Choose a template",
+ Options: templateNames,
+ },
+ },
+ }
+ if err := prompt.SurveyAsk(selectQs, &templateResponse); err != nil {
+ return "", fmt.Errorf("could not prompt: %w", err)
+ }
+
+ if templateResponse.Index == len(nonLegacyTemplatePaths) { // the user has selected the blank template
+ if legacyTemplatePath != "" {
+ templateContents := githubtemplate.ExtractContents(legacyTemplatePath)
+ return string(templateContents), nil
+ } else {
+ return "", nil
+ }
+ }
+ templateContents := githubtemplate.ExtractContents(nonLegacyTemplatePaths[templateResponse.Index])
+ return string(templateContents), nil
+}
+
+func BodySurvey(state *IssueMetadataState, templateContent, editorCommand string) error {
+ if templateContent != "" {
+ if state.Body != "" {
+ // prevent excessive newlines between default body and template
+ state.Body = strings.TrimRight(state.Body, "\n")
+ state.Body += "\n\n"
+ }
+ state.Body += templateContent
+ }
+
+ preBody := state.Body
+
+ // TODO should just be an AskOne but ran into problems with the stubber
+ qs := []*survey.Question{
+ {
+ Name: "Body",
+ Prompt: &surveyext.GhEditor{
+ BlankAllowed: true,
+ EditorCommand: editorCommand,
+ Editor: &survey.Editor{
+ Message: "Body",
+ FileName: "*.md",
+ Default: state.Body,
+ HideDefault: true,
+ AppendDefault: true,
+ },
+ },
+ },
+ }
+
+ err := prompt.SurveyAsk(qs, state)
+ if err != nil {
+ return err
+ }
+
+ if state.Body != "" && preBody != state.Body {
+ state.MarkDirty()
+ }
+
+ return nil
+}
+
+func TitleSurvey(state *IssueMetadataState) error {
+ preTitle := state.Title
+
+ // TODO should just be an AskOne but ran into problems with the stubber
+ qs := []*survey.Question{
+ {
+ Name: "Title",
+ Prompt: &survey.Input{
+ Message: "Title",
+ Default: state.Title,
+ },
+ },
+ }
+
+ err := prompt.SurveyAsk(qs, state)
+ if err != nil {
+ return err
+ }
+
+ if preTitle != state.Title {
+ state.MarkDirty()
+ }
+
+ return nil
+}
+
+type MetadataFetcher struct {
+ IO *iostreams.IOStreams
+ APIClient *api.Client
+ Repo ghrepo.Interface
+ State *IssueMetadataState
+}
+
+func (mf *MetadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) {
+ mf.IO.StartProgressIndicator()
+ metadataResult, err := api.RepoMetadata(mf.APIClient, mf.Repo, input)
+ mf.IO.StopProgressIndicator()
+ mf.State.MetadataResult = metadataResult
+ return metadataResult, err
+}
+
+type RepoMetadataFetcher interface {
+ RepoMetadataFetch(api.RepoMetadataInput) (*api.RepoMetadataResult, error)
+}
+
+func MetadataSurvey(io *iostreams.IOStreams, baseRepo ghrepo.Interface, fetcher RepoMetadataFetcher, state *IssueMetadataState) error {
+ isChosen := func(m string) bool {
+ for _, c := range state.Metadata {
+ if m == c {
+ return true
+ }
+ }
+ return false
+ }
+
+ allowReviewers := state.Type == PRMetadata
+
+ extraFieldsOptions := []string{}
+ if allowReviewers {
+ extraFieldsOptions = append(extraFieldsOptions, "Reviewers")
+ }
+ extraFieldsOptions = append(extraFieldsOptions, "Assignees", "Labels", "Projects", "Milestone")
+
+ err := prompt.SurveyAsk([]*survey.Question{
+ {
+ Name: "metadata",
+ Prompt: &survey.MultiSelect{
+ Message: "What would you like to add?",
+ Options: extraFieldsOptions,
+ },
+ },
+ }, state)
+ if err != nil {
+ return fmt.Errorf("could not prompt: %w", err)
+ }
+
+ metadataInput := api.RepoMetadataInput{
+ Reviewers: isChosen("Reviewers"),
+ Assignees: isChosen("Assignees"),
+ Labels: isChosen("Labels"),
+ Projects: isChosen("Projects"),
+ Milestones: isChosen("Milestone"),
+ }
+ metadataResult, err := fetcher.RepoMetadataFetch(metadataInput)
+ if err != nil {
+ return fmt.Errorf("error fetching metadata options: %w", err)
+ }
+
+ var users []string
+ for _, u := range metadataResult.AssignableUsers {
+ users = append(users, u.Login)
+ }
+ var teams []string
+ for _, t := range metadataResult.Teams {
+ teams = append(teams, fmt.Sprintf("%s/%s", baseRepo.RepoOwner(), t.Slug))
+ }
+ var labels []string
+ for _, l := range metadataResult.Labels {
+ labels = append(labels, l.Name)
+ }
+ var projects []string
+ for _, l := range metadataResult.Projects {
+ projects = append(projects, l.Name)
+ }
+ milestones := []string{noMilestone}
+ for _, m := range metadataResult.Milestones {
+ milestones = append(milestones, m.Title)
+ }
+
+ var mqs []*survey.Question
+ if isChosen("Reviewers") {
+ if len(users) > 0 || len(teams) > 0 {
+ mqs = append(mqs, &survey.Question{
+ Name: "reviewers",
+ Prompt: &survey.MultiSelect{
+ Message: "Reviewers",
+ Options: append(users, teams...),
+ Default: state.Reviewers,
+ },
+ })
+ } else {
+ fmt.Fprintln(io.ErrOut, "warning: no available reviewers")
+ }
+ }
+ if isChosen("Assignees") {
+ if len(users) > 0 {
+ mqs = append(mqs, &survey.Question{
+ Name: "assignees",
+ Prompt: &survey.MultiSelect{
+ Message: "Assignees",
+ Options: users,
+ Default: state.Assignees,
+ },
+ })
+ } else {
+ fmt.Fprintln(io.ErrOut, "warning: no assignable users")
+ }
+ }
+ if isChosen("Labels") {
+ if len(labels) > 0 {
+ mqs = append(mqs, &survey.Question{
+ Name: "labels",
+ Prompt: &survey.MultiSelect{
+ Message: "Labels",
+ Options: labels,
+ Default: state.Labels,
+ },
+ })
+ } else {
+ fmt.Fprintln(io.ErrOut, "warning: no labels in the repository")
+ }
+ }
+ if isChosen("Projects") {
+ if len(projects) > 0 {
+ mqs = append(mqs, &survey.Question{
+ Name: "projects",
+ Prompt: &survey.MultiSelect{
+ Message: "Projects",
+ Options: projects,
+ Default: state.Projects,
+ },
+ })
+ } else {
+ fmt.Fprintln(io.ErrOut, "warning: no projects to choose from")
+ }
+ }
+ if isChosen("Milestone") {
+ if len(milestones) > 1 {
+ var milestoneDefault string
+ if len(state.Milestones) > 0 {
+ milestoneDefault = state.Milestones[0]
+ }
+ mqs = append(mqs, &survey.Question{
+ Name: "milestone",
+ Prompt: &survey.Select{
+ Message: "Milestone",
+ Options: milestones,
+ Default: milestoneDefault,
+ },
+ })
+ } else {
+ fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository")
+ }
+ }
+
+ values := struct {
+ Reviewers []string
+ Assignees []string
+ Labels []string
+ Projects []string
+ Milestone string
+ }{}
+
+ err = prompt.SurveyAsk(mqs, &values, survey.WithKeepFilter(true))
+ if err != nil {
+ return fmt.Errorf("could not prompt: %w", err)
+ }
+
+ if isChosen("Reviewers") {
+ state.Reviewers = values.Reviewers
+ }
+ if isChosen("Assignees") {
+ state.Assignees = values.Assignees
+ }
+ if isChosen("Labels") {
+ state.Labels = values.Labels
+ }
+ if isChosen("Projects") {
+ state.Projects = values.Projects
+ }
+ if isChosen("Milestone") {
+ if values.Milestone != "" && values.Milestone != noMilestone {
+ state.Milestones = []string{values.Milestone}
+ } else {
+ state.Milestones = []string{}
+ }
+ }
+
+ return nil
+}
+
+func FindTemplates(dir, path string) ([]string, string) {
+ if dir == "" {
+ rootDir, err := git.ToplevelDir()
+ if err != nil {
+ return []string{}, ""
+ }
+ dir = rootDir
+ }
+
+ templateFiles := githubtemplate.FindNonLegacy(dir, path)
+ legacyTemplate := githubtemplate.FindLegacy(dir, path)
+
+ return templateFiles, legacyTemplate
+}
diff --git a/pkg/cmd/pr/shared/survey_test.go b/pkg/cmd/pr/shared/survey_test.go
new file mode 100644
index 000000000..a500040d3
--- /dev/null
+++ b/pkg/cmd/pr/shared/survey_test.go
@@ -0,0 +1,144 @@
+package shared
+
+import (
+ "testing"
+
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/cli/cli/pkg/prompt"
+ "github.com/stretchr/testify/assert"
+)
+
+type metadataFetcher struct {
+ metadataResult *api.RepoMetadataResult
+}
+
+func (mf *metadataFetcher) RepoMetadataFetch(input api.RepoMetadataInput) (*api.RepoMetadataResult, error) {
+ return mf.metadataResult, nil
+}
+
+func TestMetadataSurvey_selectAll(t *testing.T) {
+ io, _, stdout, stderr := iostreams.Test()
+
+ repo := ghrepo.New("OWNER", "REPO")
+
+ fetcher := &metadataFetcher{
+ metadataResult: &api.RepoMetadataResult{
+ AssignableUsers: []api.RepoAssignee{
+ {Login: "hubot"},
+ {Login: "monalisa"},
+ },
+ Labels: []api.RepoLabel{
+ {Name: "help wanted"},
+ {Name: "good first issue"},
+ },
+ Projects: []api.RepoProject{
+ {Name: "Huge Refactoring"},
+ {Name: "The road to 1.0"},
+ },
+ Milestones: []api.RepoMilestone{
+ {Title: "1.2 patch release"},
+ },
+ },
+ }
+
+ as, restoreAsk := prompt.InitAskStubber()
+ defer restoreAsk()
+
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "metadata",
+ Value: []string{"Labels", "Projects", "Assignees", "Reviewers", "Milestone"},
+ },
+ })
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "reviewers",
+ Value: []string{"monalisa"},
+ },
+ {
+ Name: "assignees",
+ Value: []string{"hubot"},
+ },
+ {
+ Name: "labels",
+ Value: []string{"good first issue"},
+ },
+ {
+ Name: "projects",
+ Value: []string{"The road to 1.0"},
+ },
+ {
+ Name: "milestone",
+ Value: []string{"(none)"},
+ },
+ })
+
+ state := &IssueMetadataState{
+ Assignees: []string{"hubot"},
+ }
+ err := MetadataSurvey(io, repo, fetcher, state)
+ assert.NoError(t, err)
+
+ assert.Equal(t, "", stdout.String())
+ assert.Equal(t, "", stderr.String())
+
+ assert.Equal(t, []string{"hubot"}, state.Assignees)
+ assert.Equal(t, []string{"monalisa"}, state.Reviewers)
+ assert.Equal(t, []string{"good first issue"}, state.Labels)
+ assert.Equal(t, []string{"The road to 1.0"}, state.Projects)
+ assert.Equal(t, []string{}, state.Milestones)
+}
+
+func TestMetadataSurvey_keepExisting(t *testing.T) {
+ io, _, stdout, stderr := iostreams.Test()
+
+ repo := ghrepo.New("OWNER", "REPO")
+
+ fetcher := &metadataFetcher{
+ metadataResult: &api.RepoMetadataResult{
+ Labels: []api.RepoLabel{
+ {Name: "help wanted"},
+ {Name: "good first issue"},
+ },
+ Projects: []api.RepoProject{
+ {Name: "Huge Refactoring"},
+ {Name: "The road to 1.0"},
+ },
+ },
+ }
+
+ as, restoreAsk := prompt.InitAskStubber()
+ defer restoreAsk()
+
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "metadata",
+ Value: []string{"Labels", "Projects"},
+ },
+ })
+ as.Stub([]*prompt.QuestionStub{
+ {
+ Name: "labels",
+ Value: []string{"good first issue"},
+ },
+ {
+ Name: "projects",
+ Value: []string{"The road to 1.0"},
+ },
+ })
+
+ state := &IssueMetadataState{
+ Assignees: []string{"hubot"},
+ }
+ err := MetadataSurvey(io, repo, fetcher, state)
+ assert.NoError(t, err)
+
+ assert.Equal(t, "", stdout.String())
+ assert.Equal(t, "", stderr.String())
+
+ assert.Equal(t, []string{"hubot"}, state.Assignees)
+ assert.Equal(t, []string{"good first issue"}, state.Labels)
+ assert.Equal(t, []string{"The road to 1.0"}, state.Projects)
+}
diff --git a/pkg/cmd/pr/shared/title_body_survey.go b/pkg/cmd/pr/shared/title_body_survey.go
deleted file mode 100644
index 83a50e0ff..000000000
--- a/pkg/cmd/pr/shared/title_body_survey.go
+++ /dev/null
@@ -1,396 +0,0 @@
-package shared
-
-import (
- "fmt"
- "strings"
-
- "github.com/AlecAivazis/survey/v2"
- "github.com/cli/cli/api"
- "github.com/cli/cli/internal/ghrepo"
- "github.com/cli/cli/pkg/githubtemplate"
- "github.com/cli/cli/pkg/iostreams"
- "github.com/cli/cli/pkg/prompt"
- "github.com/cli/cli/pkg/surveyext"
- "github.com/cli/cli/utils"
-)
-
-type Defaults struct {
- Title string
- Body string
-}
-
-type Action int
-type metadataStateType int
-
-const (
- IssueMetadata metadataStateType = iota
- PRMetadata
-)
-
-type IssueMetadataState struct {
- Type metadataStateType
-
- Body string
- Title string
- Action Action
-
- Metadata []string
- Reviewers []string
- Assignees []string
- Labels []string
- Projects []string
- Milestones []string
-
- MetadataResult *api.RepoMetadataResult
-}
-
-func (tb *IssueMetadataState) HasMetadata() bool {
- return len(tb.Reviewers) > 0 ||
- len(tb.Assignees) > 0 ||
- len(tb.Labels) > 0 ||
- len(tb.Projects) > 0 ||
- len(tb.Milestones) > 0
-}
-
-const (
- SubmitAction Action = iota
- PreviewAction
- CancelAction
- MetadataAction
-
- noMilestone = "(none)"
-)
-
-func confirmSubmission(allowPreview bool, allowMetadata bool) (Action, error) {
- const (
- submitLabel = "Submit"
- previewLabel = "Continue in browser"
- metadataLabel = "Add metadata"
- cancelLabel = "Cancel"
- )
-
- options := []string{submitLabel}
- if allowPreview {
- options = append(options, previewLabel)
- }
- if allowMetadata {
- options = append(options, metadataLabel)
- }
- options = append(options, cancelLabel)
-
- confirmAnswers := struct {
- Confirmation int
- }{}
- confirmQs := []*survey.Question{
- {
- Name: "confirmation",
- Prompt: &survey.Select{
- Message: "What's next?",
- Options: options,
- },
- },
- }
-
- err := prompt.SurveyAsk(confirmQs, &confirmAnswers)
- if err != nil {
- return -1, fmt.Errorf("could not prompt: %w", err)
- }
-
- switch options[confirmAnswers.Confirmation] {
- case submitLabel:
- return SubmitAction, nil
- case previewLabel:
- return PreviewAction, nil
- case metadataLabel:
- return MetadataAction, nil
- case cancelLabel:
- return CancelAction, nil
- default:
- return -1, fmt.Errorf("invalid index: %d", confirmAnswers.Confirmation)
- }
-}
-
-func selectTemplate(nonLegacyTemplatePaths []string, legacyTemplatePath *string, metadataType metadataStateType) (string, error) {
- templateResponse := struct {
- Index int
- }{}
- templateNames := make([]string, 0, len(nonLegacyTemplatePaths))
- for _, p := range nonLegacyTemplatePaths {
- templateNames = append(templateNames, githubtemplate.ExtractName(p))
- }
- if metadataType == IssueMetadata {
- templateNames = append(templateNames, "Open a blank issue")
- } else if metadataType == PRMetadata {
- templateNames = append(templateNames, "Open a blank pull request")
- }
-
- selectQs := []*survey.Question{
- {
- Name: "index",
- Prompt: &survey.Select{
- Message: "Choose a template",
- Options: templateNames,
- },
- },
- }
- if err := prompt.SurveyAsk(selectQs, &templateResponse); err != nil {
- return "", fmt.Errorf("could not prompt: %w", err)
- }
-
- if templateResponse.Index == len(nonLegacyTemplatePaths) { // the user has selected the blank template
- if legacyTemplatePath != nil {
- templateContents := githubtemplate.ExtractContents(*legacyTemplatePath)
- return string(templateContents), nil
- } else {
- return "", nil
- }
- }
- templateContents := githubtemplate.ExtractContents(nonLegacyTemplatePaths[templateResponse.Index])
- return string(templateContents), nil
-}
-
-// FIXME: this command has too many parameters and responsibilities
-func TitleBodySurvey(io *iostreams.IOStreams, editorCommand string, issueState *IssueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs Defaults, nonLegacyTemplatePaths []string, legacyTemplatePath *string, allowReviewers, allowMetadata bool) error {
- issueState.Title = defs.Title
- templateContents := ""
-
- if providedBody == "" {
- issueState.Body = defs.Body
-
- if len(nonLegacyTemplatePaths) > 0 {
- var err error
- templateContents, err = selectTemplate(nonLegacyTemplatePaths, legacyTemplatePath, issueState.Type)
- if err != nil {
- return err
- }
-
- } else if legacyTemplatePath != nil {
- templateContents = string(githubtemplate.ExtractContents(*legacyTemplatePath))
- }
-
- if templateContents != "" {
- if issueState.Body != "" {
- // prevent excessive newlines between default body and template
- issueState.Body = strings.TrimRight(issueState.Body, "\n")
- issueState.Body += "\n\n"
- }
- issueState.Body += templateContents
- }
- }
-
- titleQuestion := &survey.Question{
- Name: "title",
- Prompt: &survey.Input{
- Message: "Title",
- Default: issueState.Title,
- },
- }
- bodyQuestion := &survey.Question{
- Name: "body",
- Prompt: &surveyext.GhEditor{
- BlankAllowed: true,
- EditorCommand: editorCommand,
- Editor: &survey.Editor{
- Message: "Body",
- FileName: "*.md",
- Default: issueState.Body,
- HideDefault: true,
- AppendDefault: true,
- },
- },
- }
-
- var qs []*survey.Question
- if providedTitle == "" {
- qs = append(qs, titleQuestion)
- }
- if providedBody == "" {
- qs = append(qs, bodyQuestion)
- }
-
- err := prompt.SurveyAsk(qs, issueState)
- if err != nil {
- return fmt.Errorf("could not prompt: %w", err)
- }
-
- if issueState.Body == "" {
- issueState.Body = templateContents
- }
-
- allowPreview := !issueState.HasMetadata()
- confirmA, err := confirmSubmission(allowPreview, allowMetadata)
- if err != nil {
- return fmt.Errorf("unable to confirm: %w", err)
- }
-
- if confirmA == MetadataAction {
- isChosen := func(m string) bool {
- for _, c := range issueState.Metadata {
- if m == c {
- return true
- }
- }
- return false
- }
-
- extraFieldsOptions := []string{}
- if allowReviewers {
- extraFieldsOptions = append(extraFieldsOptions, "Reviewers")
- }
- extraFieldsOptions = append(extraFieldsOptions, "Assignees", "Labels", "Projects", "Milestone")
-
- err = prompt.SurveyAsk([]*survey.Question{
- {
- Name: "metadata",
- Prompt: &survey.MultiSelect{
- Message: "What would you like to add?",
- Options: extraFieldsOptions,
- },
- },
- }, issueState)
- if err != nil {
- return fmt.Errorf("could not prompt: %w", err)
- }
-
- metadataInput := api.RepoMetadataInput{
- Reviewers: isChosen("Reviewers"),
- Assignees: isChosen("Assignees"),
- Labels: isChosen("Labels"),
- Projects: isChosen("Projects"),
- Milestones: isChosen("Milestone"),
- }
- s := utils.Spinner(io.ErrOut)
- utils.StartSpinner(s)
- issueState.MetadataResult, err = api.RepoMetadata(apiClient, repo, metadataInput)
- utils.StopSpinner(s)
- if err != nil {
- return fmt.Errorf("error fetching metadata options: %w", err)
- }
-
- var users []string
- for _, u := range issueState.MetadataResult.AssignableUsers {
- users = append(users, u.Login)
- }
- var teams []string
- for _, t := range issueState.MetadataResult.Teams {
- teams = append(teams, fmt.Sprintf("%s/%s", repo.RepoOwner(), t.Slug))
- }
- var labels []string
- for _, l := range issueState.MetadataResult.Labels {
- labels = append(labels, l.Name)
- }
- var projects []string
- for _, l := range issueState.MetadataResult.Projects {
- projects = append(projects, l.Name)
- }
- milestones := []string{noMilestone}
- for _, m := range issueState.MetadataResult.Milestones {
- milestones = append(milestones, m.Title)
- }
-
- type metadataValues struct {
- Reviewers []string
- Assignees []string
- Labels []string
- Projects []string
- Milestone string
- }
- var mqs []*survey.Question
- if isChosen("Reviewers") {
- if len(users) > 0 || len(teams) > 0 {
- mqs = append(mqs, &survey.Question{
- Name: "reviewers",
- Prompt: &survey.MultiSelect{
- Message: "Reviewers",
- Options: append(users, teams...),
- Default: issueState.Reviewers,
- },
- })
- } else {
- fmt.Fprintln(io.ErrOut, "warning: no available reviewers")
- }
- }
- if isChosen("Assignees") {
- if len(users) > 0 {
- mqs = append(mqs, &survey.Question{
- Name: "assignees",
- Prompt: &survey.MultiSelect{
- Message: "Assignees",
- Options: users,
- Default: issueState.Assignees,
- },
- })
- } else {
- fmt.Fprintln(io.ErrOut, "warning: no assignable users")
- }
- }
- if isChosen("Labels") {
- if len(labels) > 0 {
- mqs = append(mqs, &survey.Question{
- Name: "labels",
- Prompt: &survey.MultiSelect{
- Message: "Labels",
- Options: labels,
- Default: issueState.Labels,
- },
- })
- } else {
- fmt.Fprintln(io.ErrOut, "warning: no labels in the repository")
- }
- }
- if isChosen("Projects") {
- if len(projects) > 0 {
- mqs = append(mqs, &survey.Question{
- Name: "projects",
- Prompt: &survey.MultiSelect{
- Message: "Projects",
- Options: projects,
- Default: issueState.Projects,
- },
- })
- } else {
- fmt.Fprintln(io.ErrOut, "warning: no projects to choose from")
- }
- }
- if isChosen("Milestone") {
- if len(milestones) > 1 {
- var milestoneDefault string
- if len(issueState.Milestones) > 0 {
- milestoneDefault = issueState.Milestones[0]
- }
- mqs = append(mqs, &survey.Question{
- Name: "milestone",
- Prompt: &survey.Select{
- Message: "Milestone",
- Options: milestones,
- Default: milestoneDefault,
- },
- })
- } else {
- fmt.Fprintln(io.ErrOut, "warning: no milestones in the repository")
- }
- }
- values := metadataValues{}
- err = prompt.SurveyAsk(mqs, &values, survey.WithKeepFilter(true))
- if err != nil {
- return fmt.Errorf("could not prompt: %w", err)
- }
- issueState.Reviewers = values.Reviewers
- issueState.Assignees = values.Assignees
- issueState.Labels = values.Labels
- issueState.Projects = values.Projects
- if values.Milestone != "" && values.Milestone != noMilestone {
- issueState.Milestones = []string{values.Milestone}
- }
-
- allowPreview = !issueState.HasMetadata()
- allowMetadata = false
- confirmA, err = confirmSubmission(allowPreview, allowMetadata)
- if err != nil {
- return fmt.Errorf("unable to confirm: %w", err)
- }
- }
-
- issueState.Action = confirmA
- return nil
-}
diff --git a/pkg/cmd/pr/status/status.go b/pkg/cmd/pr/status/status.go
index 2c012d712..bec49f259 100644
--- a/pkg/cmd/pr/status/status.go
+++ b/pkg/cmd/pr/status/status.go
@@ -3,7 +3,6 @@ package status
import (
"errors"
"fmt"
- "io"
"net/http"
"regexp"
"strconv"
@@ -18,7 +17,6 @@ import (
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/text"
- "github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
@@ -104,38 +102,39 @@ func statusRun(opts *StatusOptions) error {
defer opts.IO.StopPager()
out := opts.IO.Out
+ cs := opts.IO.ColorScheme()
fmt.Fprintln(out, "")
fmt.Fprintf(out, "Relevant pull requests in %s\n", ghrepo.FullName(baseRepo))
fmt.Fprintln(out, "")
- shared.PrintHeader(out, "Current branch")
+ shared.PrintHeader(opts.IO, "Current branch")
currentPR := prPayload.CurrentPR
if currentPR != nil && currentPR.State != "OPEN" && prPayload.DefaultBranch == currentBranch {
currentPR = nil
}
if currentPR != nil {
- printPrs(out, 1, *currentPR)
+ printPrs(opts.IO, 1, *currentPR)
} else if currentPRHeadRef == "" {
- shared.PrintMessage(out, " There is no current branch")
+ shared.PrintMessage(opts.IO, " There is no current branch")
} else {
- shared.PrintMessage(out, fmt.Sprintf(" There is no pull request associated with %s", utils.Cyan("["+currentPRHeadRef+"]")))
+ shared.PrintMessage(opts.IO, fmt.Sprintf(" There is no pull request associated with %s", cs.Cyan("["+currentPRHeadRef+"]")))
}
fmt.Fprintln(out)
- shared.PrintHeader(out, "Created by you")
+ shared.PrintHeader(opts.IO, "Created by you")
if prPayload.ViewerCreated.TotalCount > 0 {
- printPrs(out, prPayload.ViewerCreated.TotalCount, prPayload.ViewerCreated.PullRequests...)
+ printPrs(opts.IO, prPayload.ViewerCreated.TotalCount, prPayload.ViewerCreated.PullRequests...)
} else {
- shared.PrintMessage(out, " You have no open pull requests")
+ shared.PrintMessage(opts.IO, " You have no open pull requests")
}
fmt.Fprintln(out)
- shared.PrintHeader(out, "Requesting a code review from you")
+ shared.PrintHeader(opts.IO, "Requesting a code review from you")
if prPayload.ReviewRequested.TotalCount > 0 {
- printPrs(out, prPayload.ReviewRequested.TotalCount, prPayload.ReviewRequested.PullRequests...)
+ printPrs(opts.IO, prPayload.ReviewRequested.TotalCount, prPayload.ReviewRequested.PullRequests...)
} else {
- shared.PrintMessage(out, " You have no pull requests to review")
+ shared.PrintMessage(opts.IO, " You have no pull requests to review")
}
fmt.Fprintln(out)
@@ -179,20 +178,16 @@ func prSelectorForCurrentBranch(baseRepo ghrepo.Interface, prHeadRef string, rem
return
}
-func printPrs(w io.Writer, totalCount int, prs ...api.PullRequest) {
+func printPrs(io *iostreams.IOStreams, totalCount int, prs ...api.PullRequest) {
+ w := io.Out
+ cs := io.ColorScheme()
+
for _, pr := range prs {
prNumber := fmt.Sprintf("#%d", pr.Number)
- prStateColorFunc := utils.Green
- if pr.IsDraft {
- prStateColorFunc = utils.Gray
- } else if pr.State == "MERGED" {
- prStateColorFunc = utils.Magenta
- } else if pr.State == "CLOSED" {
- prStateColorFunc = utils.Red
- }
+ prStateColorFunc := cs.ColorFromString(shared.ColorForPR(pr))
- fmt.Fprintf(w, " %s %s %s", prStateColorFunc(prNumber), text.Truncate(50, text.ReplaceExcessiveWhitespace(pr.Title)), utils.Cyan("["+pr.HeadLabel()+"]"))
+ fmt.Fprintf(w, " %s %s %s", prStateColorFunc(prNumber), text.Truncate(50, text.ReplaceExcessiveWhitespace(pr.Title)), cs.Cyan("["+pr.HeadLabel()+"]"))
checks := pr.ChecksStatus()
reviews := pr.ReviewStatus()
@@ -208,14 +203,14 @@ func printPrs(w io.Writer, totalCount int, prs ...api.PullRequest) {
var summary string
if checks.Failing > 0 {
if checks.Failing == checks.Total {
- summary = utils.Red("× All checks failing")
+ summary = cs.Red("× All checks failing")
} else {
- summary = utils.Red(fmt.Sprintf("× %d/%d checks failing", checks.Failing, checks.Total))
+ summary = cs.Red(fmt.Sprintf("× %d/%d checks failing", checks.Failing, checks.Total))
}
} else if checks.Pending > 0 {
- summary = utils.Yellow("- Checks pending")
+ summary = cs.Yellow("- Checks pending")
} else if checks.Passing == checks.Total {
- summary = utils.Green("✓ Checks passing")
+ summary = cs.Green("✓ Checks passing")
}
fmt.Fprint(w, summary)
}
@@ -226,20 +221,20 @@ func printPrs(w io.Writer, totalCount int, prs ...api.PullRequest) {
}
if reviews.ChangesRequested {
- fmt.Fprint(w, utils.Red("+ Changes requested"))
+ fmt.Fprint(w, cs.Red("+ Changes requested"))
} else if reviews.ReviewRequired {
- fmt.Fprint(w, utils.Yellow("- Review required"))
+ fmt.Fprint(w, cs.Yellow("- Review required"))
} else if reviews.Approved {
- fmt.Fprint(w, utils.Green("✓ Approved"))
+ fmt.Fprint(w, cs.Green("✓ Approved"))
}
} else {
- fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(pr))
+ fmt.Fprintf(w, " - %s", shared.StateTitleWithColor(cs, pr))
}
fmt.Fprint(w, "\n")
}
remaining := totalCount - len(prs)
if remaining > 0 {
- fmt.Fprintf(w, utils.Gray(" And %d more\n"), remaining)
+ fmt.Fprintf(w, cs.Gray(" And %d more\n"), remaining)
}
}
diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json b/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json
new file mode 100644
index 000000000..1ee02364a
--- /dev/null
+++ b/pkg/cmd/pr/view/fixtures/prViewPreviewFullComments.json
@@ -0,0 +1,308 @@
+{
+ "data": {
+ "repository": {
+ "pullRequest": {
+ "comments": {
+ "nodes": [
+ {
+ "author": {
+ "login": "monalisa"
+ },
+ "authorAssociation": "NONE",
+ "body": "Comment 1",
+ "createdAt": "2020-01-01T12:00:00Z",
+ "includesCreatedEdit": true,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 1
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 2
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 3
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 4
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 5
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 6
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 7
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 8
+ }
+ }
+ ]
+ },
+ {
+ "author": {
+ "login": "johnnytest"
+ },
+ "authorAssociation": "CONTRIBUTOR",
+ "body": "Comment 2",
+ "createdAt": "2020-01-03T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ]
+ },
+ {
+ "author": {
+ "login": "elvisp"
+ },
+ "authorAssociation": "MEMBER",
+ "body": "Comment 3",
+ "createdAt": "2020-01-05T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ]
+ },
+ {
+ "author": {
+ "login": "loislane"
+ },
+ "authorAssociation": "OWNER",
+ "body": "Comment 4",
+ "createdAt": "2020-01-07T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ]
+ },
+ {
+ "author": {
+ "login": "marseilles"
+ },
+ "authorAssociation": "COLLABORATOR",
+ "body": "Comment 5",
+ "createdAt": "2020-01-09T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ]
+ }
+ ],
+ "totalCount": 5
+ }
+ }
+ }
+ }
+}
diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json b/pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json
new file mode 100644
index 000000000..5645f7df4
--- /dev/null
+++ b/pkg/cmd/pr/view/fixtures/prViewPreviewManyReviews.json
@@ -0,0 +1,67 @@
+{
+ "data": {
+ "repository": {
+ "pullRequest": {
+ "reviews": {
+ "nodes": [
+ {
+ "author": {
+ "login": "123"
+ },
+ "state": "COMMENTED"
+ },
+ {
+ "author": {
+ "login": "def"
+ },
+ "state": "CHANGES_REQUESTED"
+ },
+ {
+ "author": {
+ "login": "abc"
+ },
+ "state": "APPROVED"
+ },
+ {
+ "author": {
+ "login": "DEF"
+ },
+ "state": "COMMENTED"
+ },
+ {
+ "author": {
+ "login": "xyz"
+ },
+ "state": "APPROVED"
+ },
+ {
+ "author": {
+ "login": ""
+ },
+ "state": "APPROVED"
+ },
+ {
+ "author": {
+ "login": "hubot"
+ },
+ "state": "CHANGES_REQUESTED"
+ },
+ {
+ "author": {
+ "login": "hubot"
+ },
+ "state": "DISMISSED"
+ },
+ {
+ "author": {
+ "login": "monalisa"
+ },
+ "state": "PENDING"
+ }
+ ],
+ "totalCount": 9
+ }
+ }
+ }
+ }
+}
diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json b/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json
new file mode 100644
index 000000000..92e1a5a75
--- /dev/null
+++ b/pkg/cmd/pr/view/fixtures/prViewPreviewNoReviews.json
@@ -0,0 +1 @@
+{ "data": { "repository": { "pullRequest": { "reviews": { } } } } }
diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewReviews.json b/pkg/cmd/pr/view/fixtures/prViewPreviewReviews.json
new file mode 100644
index 000000000..393003fd9
--- /dev/null
+++ b/pkg/cmd/pr/view/fixtures/prViewPreviewReviews.json
@@ -0,0 +1,318 @@
+{
+ "data": {
+ "repository": {
+ "pullRequest": {
+ "reviews": {
+ "nodes": [
+ {
+ "author": {
+ "login": "sam"
+ },
+ "authorAssociation": "NONE",
+ "body": "Review 1",
+ "createdAt": "2020-01-02T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 1
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 1
+ }
+ }
+ ],
+ "state": "COMMENTED",
+ "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-1"
+ },
+ {
+ "author": {
+ "login": "matt"
+ },
+ "authorAssociation": "OWNER",
+ "body": "Review 2",
+ "createdAt": "2020-01-04T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 1
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 1
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ],
+ "state": "CHANGES_REQUESTED",
+ "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-2"
+ },
+ {
+ "author": {
+ "login": "leah"
+ },
+ "authorAssociation": "MEMBER",
+ "body": "Review 3",
+ "createdAt": "2020-01-06T12:00:00Z",
+ "includesCreatedEdit": true,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ],
+ "state": "APPROVED",
+ "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-3"
+ },
+ {
+ "author": {
+ "login": "louise"
+ },
+ "authorAssociation": "NONE",
+ "body": "Review 4",
+ "createdAt": "2020-01-08T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ],
+ "state": "DISMISSED",
+ "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-4"
+ },
+ {
+ "author": {
+ "login": "david"
+ },
+ "authorAssociation": "NONE",
+ "body": "Review 5",
+ "createdAt": "2020-01-10T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ],
+ "state": "PENDING",
+ "url": "https://github.com/OWNER/REPO/pull/12#pullrequestreview-5"
+ }
+ ],
+ "totalCount": 5
+ }
+ }
+ }
+ }
+}
diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json b/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json
new file mode 100644
index 000000000..71d58fe83
--- /dev/null
+++ b/pkg/cmd/pr/view/fixtures/prViewPreviewSingleComment.json
@@ -0,0 +1,155 @@
+{
+ "data": {
+ "repository": {
+ "pullRequest": {
+ "number": 12,
+ "title": "some title",
+ "state": "OPEN",
+ "body": "some body",
+ "url": "https://github.com/OWNER/REPO/pull/12",
+ "author": {
+ "login": "nobody"
+ },
+ "assignees": {
+ "nodes": [],
+ "totalcount": 0
+ },
+ "labels": {
+ "nodes": [],
+ "totalcount": 0
+ },
+ "projectcards": {
+ "nodes": [],
+ "totalcount": 0
+ },
+ "milestone": {
+ "title": ""
+ },
+ "commits": {
+ "totalCount": 12
+ },
+ "baseRefName": "master",
+ "headRefName": "blueberries",
+ "headRepositoryOwner": {
+ "login": "hubot"
+ },
+ "isCrossRepository": true,
+ "isDraft": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 1
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 2
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 3
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ],
+ "comments": {
+ "nodes": [
+ {
+ "author": {
+ "login": "marseilles"
+ },
+ "authorAssociation": "COLLABORATOR",
+ "body": "Comment 5",
+ "createdAt": "2020-01-09T12:00:00Z",
+ "includesCreatedEdit": false,
+ "reactionGroups": [
+ {
+ "content": "CONFUSED",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "EYES",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HEART",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "HOORAY",
+ "users": {
+ "totalCount": 4
+ }
+ },
+ {
+ "content": "LAUGH",
+ "users": {
+ "totalCount": 5
+ }
+ },
+ {
+ "content": "ROCKET",
+ "users": {
+ "totalCount": 6
+ }
+ },
+ {
+ "content": "THUMBS_DOWN",
+ "users": {
+ "totalCount": 0
+ }
+ },
+ {
+ "content": "THUMBS_UP",
+ "users": {
+ "totalCount": 0
+ }
+ }
+ ]
+ }
+ ],
+ "totalCount": 5
+ }
+ }
+ }
+ }
+}
diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json
index 0ca6124e6..c6f801477 100644
--- a/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json
+++ b/pkg/cmd/pr/view/fixtures/prViewPreviewWithMetadataByNumber.json
@@ -21,28 +21,6 @@
],
"totalcount": 1
},
- "reviews": {
- "nodes": [
- {
- "author": {
- "login": "3"
- },
- "state": "COMMENTED"
- },
- {
- "author": {
- "login": "2"
- },
- "state": "APPROVED"
- },
- {
- "author": {
- "login": "1"
- },
- "state": "CHANGES_REQUESTED"
- }
- ]
- },
"assignees": {
"nodes": [
{
diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json
index 4d2bd57af..6ff594fec 100644
--- a/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json
+++ b/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json
@@ -33,64 +33,6 @@
],
"totalcount": 1
},
- "reviews": {
- "nodes": [
- {
- "author": {
- "login": "123"
- },
- "state": "COMMENTED"
- },
- {
- "author": {
- "login": "def"
- },
- "state": "CHANGES_REQUESTED"
- },
- {
- "author": {
- "login": "abc"
- },
- "state": "APPROVED"
- },
- {
- "author": {
- "login": "DEF"
- },
- "state": "COMMENTED"
- },
- {
- "author": {
- "login": "xyz"
- },
- "state": "APPROVED"
- },
- {
- "author": {
- "login": ""
- },
- "state": "APPROVED"
- },
- {
- "author": {
- "login": "hubot"
- },
- "state": "CHANGES_REQUESTED"
- },
- {
- "author": {
- "login": "hubot"
- },
- "state": "DISMISSED"
- },
- {
- "author": {
- "login": "monalisa"
- },
- "state": "PENDING"
- }
- ]
- },
"assignees": {
"nodes": [],
"totalcount": 0
diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go
index 4476a84c0..b2f84ff78 100644
--- a/pkg/cmd/pr/view/view.go
+++ b/pkg/cmd/pr/view/view.go
@@ -3,10 +3,10 @@ package view
import (
"errors"
"fmt"
- "io"
"net/http"
"sort"
"strings"
+ "sync"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
@@ -31,6 +31,7 @@ type ViewOptions struct {
SelectorArg string
BrowserMode bool
+ Comments bool
}
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
@@ -74,26 +75,23 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
}
cmd.Flags().BoolVarP(&opts.BrowserMode, "web", "w", false, "Open a pull request in the browser")
+ cmd.Flags().BoolVarP(&opts.Comments, "comments", "c", false, "View pull request comments")
return cmd
}
func viewRun(opts *ViewOptions) error {
- httpClient, err := opts.HttpClient()
- if err != nil {
- return err
- }
- apiClient := api.NewClientFromHTTP(httpClient)
-
- pr, _, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
+ opts.IO.StartProgressIndicator()
+ pr, err := retrievePullRequest(opts)
+ opts.IO.StopProgressIndicator()
if err != nil {
return err
}
- openURL := pr.URL
connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY()
if opts.BrowserMode {
+ openURL := pr.URL
if connectedToTerminal {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
}
@@ -109,13 +107,22 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if connectedToTerminal {
- return printHumanPrPreview(opts.IO, pr)
+ return printHumanPrPreview(opts, pr)
}
- return printRawPrPreview(opts.IO.Out, pr)
+
+ if opts.Comments {
+ fmt.Fprint(opts.IO.Out, shared.RawCommentList(pr.Comments, pr.Reviews))
+ return nil
+ }
+
+ return printRawPrPreview(opts.IO, pr)
}
-func printRawPrPreview(out io.Writer, pr *api.PullRequest) error {
- reviewers := prReviewerList(*pr)
+func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
+ out := io.Out
+ cs := io.ColorScheme()
+
+ reviewers := prReviewerList(*pr, cs)
assignees := prAssigneeList(*pr)
labels := prLabelList(*pr)
projects := prProjectList(*pr)
@@ -137,57 +144,76 @@ func printRawPrPreview(out io.Writer, pr *api.PullRequest) error {
return nil
}
-func printHumanPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
- out := io.Out
+func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error {
+ out := opts.IO.Out
+ cs := opts.IO.ColorScheme()
// Header (Title and State)
- fmt.Fprintln(out, utils.Bold(pr.Title))
- fmt.Fprintf(out, "%s", shared.StateTitleWithColor(*pr))
- fmt.Fprintln(out, utils.Gray(fmt.Sprintf(
- " • %s wants to merge %s into %s from %s",
+ fmt.Fprintln(out, cs.Bold(pr.Title))
+ fmt.Fprintf(out,
+ "%s • %s wants to merge %s into %s from %s\n",
+ shared.StateTitleWithColor(cs, *pr),
pr.Author.Login,
utils.Pluralize(pr.Commits.TotalCount, "commit"),
pr.BaseRefName,
pr.HeadRefName,
- )))
- fmt.Fprintln(out)
+ )
+
+ // Reactions
+ if reactions := shared.ReactionGroupList(pr.ReactionGroups); reactions != "" {
+ fmt.Fprint(out, reactions)
+ fmt.Fprintln(out)
+ }
// Metadata
- if reviewers := prReviewerList(*pr); reviewers != "" {
- fmt.Fprint(out, utils.Bold("Reviewers: "))
+ if reviewers := prReviewerList(*pr, cs); reviewers != "" {
+ fmt.Fprint(out, cs.Bold("Reviewers: "))
fmt.Fprintln(out, reviewers)
}
if assignees := prAssigneeList(*pr); assignees != "" {
- fmt.Fprint(out, utils.Bold("Assignees: "))
+ fmt.Fprint(out, cs.Bold("Assignees: "))
fmt.Fprintln(out, assignees)
}
if labels := prLabelList(*pr); labels != "" {
- fmt.Fprint(out, utils.Bold("Labels: "))
+ fmt.Fprint(out, cs.Bold("Labels: "))
fmt.Fprintln(out, labels)
}
if projects := prProjectList(*pr); projects != "" {
- fmt.Fprint(out, utils.Bold("Projects: "))
+ fmt.Fprint(out, cs.Bold("Projects: "))
fmt.Fprintln(out, projects)
}
if pr.Milestone.Title != "" {
- fmt.Fprint(out, utils.Bold("Milestone: "))
+ fmt.Fprint(out, cs.Bold("Milestone: "))
fmt.Fprintln(out, pr.Milestone.Title)
}
// Body
- if pr.Body != "" {
- fmt.Fprintln(out)
- style := markdown.GetStyle(io.TerminalTheme())
- md, err := markdown.Render(pr.Body, style)
+ var md string
+ var err error
+ if pr.Body == "" {
+ md = fmt.Sprintf("\n %s\n\n", cs.Gray("No description provided"))
+ } else {
+ style := markdown.GetStyle(opts.IO.TerminalTheme())
+ md, err = markdown.Render(pr.Body, style, "")
if err != nil {
return err
}
- fmt.Fprintln(out, md)
}
- fmt.Fprintln(out)
+ fmt.Fprintf(out, "\n%s\n", md)
+
+ // Reviews and Comments
+ if pr.Comments.TotalCount > 0 || pr.Reviews.TotalCount > 0 {
+ preview := !opts.Comments
+ comments, err := shared.CommentList(opts.IO, pr.Comments, pr.DisplayableReviews(), preview)
+ if err != nil {
+ return err
+ }
+ fmt.Fprint(out, comments)
+ }
// Footer
- fmt.Fprintf(out, utils.Gray("View this pull request on GitHub: %s\n"), pr.URL)
+ fmt.Fprintf(out, cs.Gray("View this pull request on GitHub: %s\n"), pr.URL)
+
return nil
}
@@ -205,43 +231,39 @@ type reviewerState struct {
State string
}
-// colorFuncForReviewerState returns a color function for a reviewer state
-func colorFuncForReviewerState(state string) func(string) string {
- switch state {
- case requestedReviewState:
- return utils.Yellow
- case approvedReviewState:
- return utils.Green
- case changesRequestedReviewState:
- return utils.Red
- case commentedReviewState:
- return func(str string) string { return str } // Do nothing
- default:
- return nil
- }
-}
-
// formattedReviewerState formats a reviewerState with state color
-func formattedReviewerState(reviewer *reviewerState) string {
+func formattedReviewerState(cs *iostreams.ColorScheme, reviewer *reviewerState) string {
state := reviewer.State
if state == dismissedReviewState {
- // Show "DISMISSED" review as "COMMENTED", since "dimissed" only makes
+ // Show "DISMISSED" review as "COMMENTED", since "dismissed" only makes
// sense when displayed in an events timeline but not in the final tally.
state = commentedReviewState
}
- stateColorFunc := colorFuncForReviewerState(state)
- return fmt.Sprintf("%s (%s)", reviewer.Name, stateColorFunc(strings.ReplaceAll(strings.Title(strings.ToLower(state)), "_", " ")))
+
+ var colorFunc func(string) string
+ switch state {
+ case requestedReviewState:
+ colorFunc = cs.Yellow
+ case approvedReviewState:
+ colorFunc = cs.Green
+ case changesRequestedReviewState:
+ colorFunc = cs.Red
+ default:
+ colorFunc = func(str string) string { return str } // Do nothing
+ }
+
+ return fmt.Sprintf("%s (%s)", reviewer.Name, colorFunc(strings.ReplaceAll(strings.Title(strings.ToLower(state)), "_", " ")))
}
// prReviewerList generates a reviewer list with their last state
-func prReviewerList(pr api.PullRequest) string {
+func prReviewerList(pr api.PullRequest, cs *iostreams.ColorScheme) string {
reviewerStates := parseReviewers(pr)
reviewers := make([]string, 0, len(reviewerStates))
sortReviewerStates(reviewerStates)
for _, reviewer := range reviewerStates {
- reviewers = append(reviewers, formattedReviewerState(reviewer))
+ reviewers = append(reviewers, formattedReviewerState(cs, reviewer))
}
reviewerList := strings.Join(reviewers, ", ")
@@ -372,3 +394,51 @@ func prStateWithDraft(pr *api.PullRequest) string {
return pr.State
}
+
+func retrievePullRequest(opts *ViewOptions) (*api.PullRequest, error) {
+ httpClient, err := opts.HttpClient()
+ if err != nil {
+ return nil, err
+ }
+
+ apiClient := api.NewClientFromHTTP(httpClient)
+
+ pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
+ if err != nil {
+ return nil, err
+ }
+
+ if opts.BrowserMode {
+ return pr, nil
+ }
+
+ var errp, errc error
+ var wg sync.WaitGroup
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ var reviews *api.PullRequestReviews
+ reviews, errp = api.ReviewsForPullRequest(apiClient, repo, pr)
+ pr.Reviews = *reviews
+ }()
+
+ if opts.Comments {
+ wg.Add(1)
+ go func() {
+ defer wg.Done()
+ var comments *api.Comments
+ comments, errc = api.CommentsForPullRequest(apiClient, repo, pr)
+ pr.Comments = *comments
+ }()
+ }
+
+ wg.Wait()
+
+ if errp != nil {
+ err = errp
+ }
+ if errc != nil {
+ err = errc
+ }
+ return pr, err
+}
diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go
index 6dc3561f7..ef983212c 100644
--- a/pkg/cmd/pr/view/view_test.go
+++ b/pkg/cmd/pr/view/view_test.go
@@ -2,10 +2,10 @@ package view
import (
"bytes"
+ "fmt"
"io/ioutil"
"net/http"
"os/exec"
- "reflect"
"strings"
"testing"
@@ -64,6 +64,15 @@ func Test_NewCmdView(t *testing.T) {
isTTY: true,
wantErr: "argument required when using the --repo flag",
},
+ {
+ name: "comments",
+ args: "123 -c",
+ isTTY: true,
+ want: ViewOptions{
+ SelectorArg: "123",
+ Comments: true,
+ },
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -104,13 +113,6 @@ func Test_NewCmdView(t *testing.T) {
}
}
-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 runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
@@ -164,13 +166,16 @@ func TestPRView_Preview_nontty(t *testing.T) {
tests := map[string]struct {
branch string
args string
- fixture string
+ fixtures map[string]string
expectedOutputs []string
}{
"Open PR without metadata": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreview.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreview.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
`state:\tOPEN\n`,
@@ -186,12 +191,15 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Open PR with metadata by number": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
- `reviewers:\t2 \(Approved\), 3 \(Commented\), 1 \(Requested\)\n`,
+ `reviewers:\t1 \(Requested\)\n`,
`assignees:\tmarseilles, monaco\n`,
`labels:\tone, two, three, four, five\n`,
`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
@@ -200,9 +208,12 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Open PR with reviewers by number": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewWithReviewersByNumber.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewManyReviews.json",
+ },
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
`state:\tOPEN\n`,
@@ -216,14 +227,18 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Open PR with metadata by branch": {
- branch: "master",
- args: "blueberries",
- fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json",
+ branch: "master",
+ args: "blueberries",
+ fixtures: map[string]string{
+ "PullRequestForBranch": "./fixtures/prViewPreviewWithMetadataByBranch.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`title:\tBlueberries are a good fruit`,
`state:\tOPEN`,
`author:\tnobody`,
`assignees:\tmarseilles, monaco\n`,
+ `reviewers:\t\n`,
`labels:\tone, two, three, four, five\n`,
`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`,
`milestone:\tuluru\n`,
@@ -231,14 +246,18 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Open PR for the current branch": {
- branch: "blueberries",
- args: "",
- fixture: "./fixtures/prView.json",
+ branch: "blueberries",
+ args: "",
+ fixtures: map[string]string{
+ "PullRequestForBranch": "./fixtures/prView.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`title:\tBlueberries are a good fruit`,
`state:\tOPEN`,
`author:\tnobody`,
`assignees:\t\n`,
+ `reviewers:\t\n`,
`labels:\t\n`,
`projects:\t\n`,
`milestone:\t\n`,
@@ -246,23 +265,30 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Open PR wth empty body for the current branch": {
- branch: "blueberries",
- args: "",
- fixture: "./fixtures/prView_EmptyBody.json",
+ branch: "blueberries",
+ args: "",
+ fixtures: map[string]string{
+ "PullRequestForBranch": "./fixtures/prView_EmptyBody.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`title:\tBlueberries are a good fruit`,
`state:\tOPEN`,
`author:\tnobody`,
`assignees:\t\n`,
+ `reviewers:\t\n`,
`labels:\t\n`,
`projects:\t\n`,
`milestone:\t\n`,
},
},
"Closed PR": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreviewClosedState.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`state:\tCLOSED\n`,
`author:\tnobody\n`,
@@ -275,9 +301,12 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Merged PR": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreviewMergedState.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`state:\tMERGED\n`,
`author:\tnobody\n`,
@@ -290,30 +319,38 @@ func TestPRView_Preview_nontty(t *testing.T) {
},
},
"Draft PR": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreviewDraftState.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
`state:\tDRAFT\n`,
`author:\tnobody\n`,
`labels:`,
`assignees:`,
+ `reviewers:`,
`projects:`,
`milestone:`,
`\*\*blueberries taste good\*\*`,
},
},
"Draft PR by branch": {
- branch: "master",
- args: "blueberries",
- fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json",
+ branch: "master",
+ args: "blueberries",
+ fixtures: map[string]string{
+ "PullRequestForBranch": "./fixtures/prViewPreviewDraftStatebyBranch.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`title:\tBlueberries are a good fruit\n`,
`state:\tDRAFT\n`,
`author:\tnobody\n`,
`labels:`,
`assignees:`,
+ `reviewers:`,
`projects:`,
`milestone:`,
`\*\*blueberries taste good\*\*`,
@@ -325,14 +362,17 @@ func TestPRView_Preview_nontty(t *testing.T) {
t.Run(name, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture))
+ for name, file := range tc.fixtures {
+ name := fmt.Sprintf(`query %s\b`, name)
+ http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
+ }
output, err := runCommand(http, tc.branch, false, tc.args)
if err != nil {
t.Errorf("error running command `%v`: %v", tc.args, err)
}
- eq(t, output.Stderr(), "")
+ assert.Equal(t, "", output.Stderr())
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
@@ -343,13 +383,16 @@ func TestPRView_Preview(t *testing.T) {
tests := map[string]struct {
branch string
args string
- fixture string
+ fixtures map[string]string
expectedOutputs []string
}{
"Open PR without metadata": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreview.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreview.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`Blueberries are from a fork`,
`Open.*nobody wants to merge 12 commits into master from blueberries`,
@@ -358,36 +401,45 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Open PR with metadata by number": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreviewWithMetadataByNumber.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`Blueberries are from a fork`,
`Open.*nobody wants to merge 12 commits into master from blueberries`,
- `Reviewers:.*2 \(.*Approved.*\), 3 \(Commented\), 1 \(.*Requested.*\)\n`,
+ `Reviewers:.*1 \(.*Requested.*\)\n`,
`Assignees:.*marseilles, monaco\n`,
`Labels:.*one, two, three, four, five\n`,
`Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
`Milestone:.*uluru\n`,
`blueberries taste good`,
- `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`,
+ `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
},
},
"Open PR with reviewers by number": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreviewWithReviewersByNumber.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewWithReviewersByNumber.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewManyReviews.json",
+ },
expectedOutputs: []string{
`Blueberries are from a fork`,
`Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`,
`blueberries taste good`,
- `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12\n`,
+ `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
},
},
"Open PR with metadata by branch": {
- branch: "master",
- args: "blueberries",
- fixture: "./fixtures/prViewPreviewWithMetadataByBranch.json",
+ branch: "master",
+ args: "blueberries",
+ fixtures: map[string]string{
+ "PullRequestForBranch": "./fixtures/prViewPreviewWithMetadataByBranch.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Open.*nobody wants to merge 8 commits into master from blueberries`,
@@ -396,13 +448,16 @@ func TestPRView_Preview(t *testing.T) {
`Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`,
`Milestone:.*uluru\n`,
`blueberries taste good`,
- `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10\n`,
+ `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`,
},
},
"Open PR for the current branch": {
- branch: "blueberries",
- args: "",
- fixture: "./fixtures/prView.json",
+ branch: "blueberries",
+ args: "",
+ fixtures: map[string]string{
+ "PullRequestForBranch": "./fixtures/prView.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Open.*nobody wants to merge 8 commits into master from blueberries`,
@@ -411,9 +466,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Open PR wth empty body for the current branch": {
- branch: "blueberries",
- args: "",
- fixture: "./fixtures/prView_EmptyBody.json",
+ branch: "blueberries",
+ args: "",
+ fixtures: map[string]string{
+ "PullRequestForBranch": "./fixtures/prView_EmptyBody.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Open.*nobody wants to merge 8 commits into master from blueberries`,
@@ -421,9 +479,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Closed PR": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreviewClosedState.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`Blueberries are from a fork`,
`Closed.*nobody wants to merge 12 commits into master from blueberries`,
@@ -432,9 +493,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Merged PR": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreviewMergedState.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`Blueberries are from a fork`,
`Merged.*nobody wants to merge 12 commits into master from blueberries`,
@@ -443,9 +507,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Draft PR": {
- branch: "master",
- args: "12",
- fixture: "./fixtures/prViewPreviewDraftState.json",
+ branch: "master",
+ args: "12",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`Blueberries are from a fork`,
`Draft.*nobody wants to merge 12 commits into master from blueberries`,
@@ -454,9 +521,12 @@ func TestPRView_Preview(t *testing.T) {
},
},
"Draft PR by branch": {
- branch: "master",
- args: "blueberries",
- fixture: "./fixtures/prViewPreviewDraftStatebyBranch.json",
+ branch: "master",
+ args: "blueberries",
+ fixtures: map[string]string{
+ "PullRequestForBranch": "./fixtures/prViewPreviewDraftStatebyBranch.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
+ },
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Draft.*nobody wants to merge 8 commits into master from blueberries`,
@@ -470,14 +540,17 @@ func TestPRView_Preview(t *testing.T) {
t.Run(name, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.Register(httpmock.GraphQL(`query PullRequest(ByNumber|ForBranch)\b`), httpmock.FileResponse(tc.fixture))
+ for name, file := range tc.fixtures {
+ name := fmt.Sprintf(`query %s\b`, name)
+ http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
+ }
output, err := runCommand(http, tc.branch, true, tc.args)
if err != nil {
t.Errorf("error running command `%v`: %v", tc.args, err)
}
- eq(t, output.Stderr(), "")
+ assert.Equal(t, "", output.Stderr())
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
@@ -506,8 +579,8 @@ func TestPRView_web_currentBranch(t *testing.T) {
t.Errorf("error running command `pr view`: %v", err)
}
- eq(t, output.String(), "")
- eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/pull/10 in your browser.\n")
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "Opening github.com/OWNER/REPO/pull/10 in your browser.\n", output.Stderr())
if seenCmd == nil {
t.Fatal("expected a command to run")
@@ -536,7 +609,7 @@ func TestPRView_web_noResultsForBranch(t *testing.T) {
defer restoreCmd()
_, err := runCommand(http, "blueberries", true, "-w")
- if err == nil || err.Error() != `no open pull requests found for branch "blueberries"` {
+ if err == nil || err.Error() != `no pull requests found for branch "blueberries"` {
t.Errorf("error running command `pr view`: %v", err)
}
@@ -549,11 +622,13 @@ func TestPRView_web_numberArg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequest": {
- "url": "https://github.com/OWNER/REPO/pull/23"
- } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequest": {
+ "url": "https://github.com/OWNER/REPO/pull/23"
+ } } } }`),
+ )
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@@ -567,24 +642,26 @@ func TestPRView_web_numberArg(t *testing.T) {
t.Errorf("error running command `pr view`: %v", err)
}
- eq(t, output.String(), "")
+ assert.Equal(t, "", output.String())
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, "https://github.com/OWNER/REPO/pull/23")
+ assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url)
}
func TestPRView_web_numberArgWithHash(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequest": {
- "url": "https://github.com/OWNER/REPO/pull/23"
- } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequest": {
+ "url": "https://github.com/OWNER/REPO/pull/23"
+ } } } }`),
+ )
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@@ -598,23 +675,26 @@ func TestPRView_web_numberArgWithHash(t *testing.T) {
t.Errorf("error running command `pr view`: %v", err)
}
- eq(t, output.String(), "")
+ assert.Equal(t, "", output.String())
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, "https://github.com/OWNER/REPO/pull/23")
+ assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url)
}
func TestPRView_web_urlArg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequest": {
- "url": "https://github.com/OWNER/REPO/pull/23"
- } } } }
- `))
+
+ http.Register(
+ httpmock.GraphQL(`query PullRequestByNumber\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequest": {
+ "url": "https://github.com/OWNER/REPO/pull/23"
+ } } } }`),
+ )
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@@ -628,26 +708,28 @@ func TestPRView_web_urlArg(t *testing.T) {
t.Errorf("error running command `pr view`: %v", err)
}
- eq(t, output.String(), "")
+ assert.Equal(t, "", output.String())
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, "https://github.com/OWNER/REPO/pull/23")
+ assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url)
}
func TestPRView_web_branchArg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [
- { "headRefName": "blueberries",
- "isCrossRepository": false,
- "url": "https://github.com/OWNER/REPO/pull/23" }
- ] } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [
+ { "headRefName": "blueberries",
+ "isCrossRepository": false,
+ "url": "https://github.com/OWNER/REPO/pull/23" }
+ ] } } } }`),
+ )
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@@ -661,27 +743,29 @@ func TestPRView_web_branchArg(t *testing.T) {
t.Errorf("error running command `pr view`: %v", err)
}
- eq(t, output.String(), "")
+ assert.Equal(t, "", output.String())
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, "https://github.com/OWNER/REPO/pull/23")
+ assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", url)
}
func TestPRView_web_branchWithOwnerArg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
- http.StubResponse(200, bytes.NewBufferString(`
- { "data": { "repository": { "pullRequests": { "nodes": [
- { "headRefName": "blueberries",
- "isCrossRepository": true,
- "headRepositoryOwner": { "login": "hubot" },
- "url": "https://github.com/hubot/REPO/pull/23" }
- ] } } } }
- `))
+ http.Register(
+ httpmock.GraphQL(`query PullRequestForBranch\b`),
+ httpmock.StringResponse(`
+ { "data": { "repository": { "pullRequests": { "nodes": [
+ { "headRefName": "blueberries",
+ "isCrossRepository": true,
+ "headRepositoryOwner": { "login": "hubot" },
+ "url": "https://github.com/hubot/REPO/pull/23" }
+ ] } } } }`),
+ )
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
@@ -695,11 +779,208 @@ func TestPRView_web_branchWithOwnerArg(t *testing.T) {
t.Errorf("error running command `pr view`: %v", err)
}
- eq(t, output.String(), "")
+ assert.Equal(t, "", output.String())
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
- eq(t, url, "https://github.com/hubot/REPO/pull/23")
+ assert.Equal(t, "https://github.com/hubot/REPO/pull/23", url)
+}
+
+func TestPRView_tty_Comments(t *testing.T) {
+ tests := map[string]struct {
+ branch string
+ cli string
+ fixtures map[string]string
+ expectedOutputs []string
+ wantsErr bool
+ }{
+ "without comments flag": {
+ branch: "master",
+ cli: "123",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json",
+ },
+ expectedOutputs: []string{
+ `some title`,
+ `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f}`,
+ `some body`,
+ `———————— Not showing 8 comments ————————`,
+ `marseilles \(Collaborator\) • Jan 9, 2020 • Newest comment`,
+ `4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680}`,
+ `Comment 5`,
+ `Use --comments to view the full conversation`,
+ `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
+ },
+ },
+ "with comments flag": {
+ branch: "master",
+ cli: "123 --comments",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json",
+ "CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json",
+ },
+ expectedOutputs: []string{
+ `some title`,
+ `some body`,
+ `monalisa • Jan 1, 2020 • Edited`,
+ `1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`,
+ `Comment 1`,
+ `sam commented • Jan 2, 2020`,
+ `1 \x{1f44e} • 1 \x{1f44d}`,
+ `Review 1`,
+ `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-1`,
+ `johnnytest \(Contributor\) • Jan 3, 2020`,
+ `Comment 2`,
+ `matt requested changes \(Owner\) • Jan 4, 2020`,
+ `1 \x{1f615} • 1 \x{1f440}`,
+ `Review 2`,
+ `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-2`,
+ `elvisp \(Member\) • Jan 5, 2020`,
+ `Comment 3`,
+ `leah approved \(Member\) • Jan 6, 2020 • Edited`,
+ `Review 3`,
+ `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-3`,
+ `loislane \(Owner\) • Jan 7, 2020`,
+ `Comment 4`,
+ `louise dismissed • Jan 8, 2020`,
+ `Review 4`,
+ `View the full review: https://github.com/OWNER/REPO/pull/12#pullrequestreview-4`,
+ `marseilles \(Collaborator\) • Jan 9, 2020 • Newest comment`,
+ `Comment 5`,
+ `View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
+ },
+ },
+ "with invalid comments flag": {
+ branch: "master",
+ cli: "123 --comments 3",
+ wantsErr: true,
+ },
+ }
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ http := &httpmock.Registry{}
+ defer http.Verify(t)
+ for name, file := range tt.fixtures {
+ name := fmt.Sprintf(`query %s\b`, name)
+ http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
+ }
+ output, err := runCommand(http, tt.branch, true, tt.cli)
+ if tt.wantsErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.Stderr())
+ test.ExpectLines(t, output.String(), tt.expectedOutputs...)
+ })
+ }
+}
+
+func TestPRView_nontty_Comments(t *testing.T) {
+ tests := map[string]struct {
+ branch string
+ cli string
+ fixtures map[string]string
+ expectedOutputs []string
+ wantsErr bool
+ }{
+ "without comments flag": {
+ branch: "master",
+ cli: "123",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json",
+ },
+ expectedOutputs: []string{
+ `title:\tsome title`,
+ `state:\tOPEN`,
+ `author:\tnobody`,
+ `url:\thttps://github.com/OWNER/REPO/pull/12`,
+ `some body`,
+ },
+ },
+ "with comments flag": {
+ branch: "master",
+ cli: "123 --comments",
+ fixtures: map[string]string{
+ "PullRequestByNumber": "./fixtures/prViewPreviewSingleComment.json",
+ "ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json",
+ "CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json",
+ },
+ expectedOutputs: []string{
+ `author:\tmonalisa`,
+ `association:\tnone`,
+ `edited:\ttrue`,
+ `status:\tnone`,
+ `Comment 1`,
+ `author:\tsam`,
+ `association:\tnone`,
+ `edited:\tfalse`,
+ `status:\tcommented`,
+ `Review 1`,
+ `author:\tjohnnytest`,
+ `association:\tcontributor`,
+ `edited:\tfalse`,
+ `status:\tnone`,
+ `Comment 2`,
+ `author:\tmatt`,
+ `association:\towner`,
+ `edited:\tfalse`,
+ `status:\tchanges requested`,
+ `Review 2`,
+ `author:\telvisp`,
+ `association:\tmember`,
+ `edited:\tfalse`,
+ `status:\tnone`,
+ `Comment 3`,
+ `author:\tleah`,
+ `association:\tmember`,
+ `edited:\ttrue`,
+ `status:\tapproved`,
+ `Review 3`,
+ `author:\tloislane`,
+ `association:\towner`,
+ `edited:\tfalse`,
+ `status:\tnone`,
+ `Comment 4`,
+ `author:\tlouise`,
+ `association:\tnone`,
+ `edited:\tfalse`,
+ `status:\tdismissed`,
+ `Review 4`,
+ `author:\tmarseilles`,
+ `association:\tcollaborator`,
+ `edited:\tfalse`,
+ `status:\tnone`,
+ `Comment 5`,
+ },
+ },
+ "with invalid comments flag": {
+ branch: "master",
+ cli: "123 --comments 3",
+ wantsErr: true,
+ },
+ }
+ for name, tt := range tests {
+ t.Run(name, func(t *testing.T) {
+ http := &httpmock.Registry{}
+ defer http.Verify(t)
+ for name, file := range tt.fixtures {
+ name := fmt.Sprintf(`query %s\b`, name)
+ http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
+ }
+ output, err := runCommand(http, tt.branch, false, tt.cli)
+ if tt.wantsErr {
+ assert.Error(t, err)
+ return
+ }
+ assert.NoError(t, err)
+ assert.Equal(t, "", output.Stderr())
+ test.ExpectLines(t, output.String(), tt.expectedOutputs...)
+ })
+ }
}
diff --git a/pkg/cmd/release/create/create.go b/pkg/cmd/release/create/create.go
index 5534821c1..9b3eb0fb7 100644
--- a/pkg/cmd/release/create/create.go
+++ b/pkg/cmd/release/create/create.go
@@ -6,11 +6,11 @@ import (
"io/ioutil"
"net/http"
"os"
- "os/exec"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
+ "github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
@@ -76,7 +76,7 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
# upload a release asset with a display label
$ gh release create v1.2.3 '/path/to/asset.zip#My display label'
`),
- Args: cobra.MinimumNArgs(1),
+ Args: cmdutil.MinimumArgs(1, "could not create: no tag name provided"),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
@@ -307,13 +307,19 @@ func createRun(opts *CreateOptions) error {
}
func gitTagInfo(tagName string) (string, error) {
- cmd := exec.Command("git", "tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)")
+ cmd, err := git.GitCommand("tag", "--list", tagName, "--format=%(contents:subject)%0a%0a%(contents:body)")
+ if err != nil {
+ return "", err
+ }
b, err := run.PrepareCmd(cmd).Output()
return string(b), err
}
func detectPreviousTag(headRef string) (string, error) {
- cmd := exec.Command("git", "describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef))
+ cmd, err := git.GitCommand("describe", "--tags", "--abbrev=0", fmt.Sprintf("%s^", headRef))
+ if err != nil {
+ return "", err
+ }
b, err := run.PrepareCmd(cmd).Output()
return strings.TrimSpace(string(b)), err
}
@@ -324,7 +330,10 @@ type logEntry struct {
}
func changelogForRange(refRange string) ([]logEntry, error) {
- cmd := exec.Command("git", "-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange)
+ cmd, err := git.GitCommand("-c", "log.ShowSignature=false", "log", "--first-parent", "--reverse", "--pretty=format:%B%x00", refRange)
+ if err != nil {
+ return nil, err
+ }
b, err := run.PrepareCmd(cmd).Output()
if err != nil {
return nil, err
diff --git a/pkg/cmd/release/create/create_test.go b/pkg/cmd/release/create/create_test.go
index 806801ec2..8bfb2d325 100644
--- a/pkg/cmd/release/create/create_test.go
+++ b/pkg/cmd/release/create/create_test.go
@@ -158,7 +158,7 @@ func Test_NewCmdCreate(t *testing.T) {
name: "no arguments",
args: "",
isTTY: true,
- wantErr: "requires at least 1 arg(s), only received 0",
+ wantErr: "could not create: no tag name provided",
},
}
for _, tt := range tests {
diff --git a/pkg/cmd/release/view/view.go b/pkg/cmd/release/view/view.go
index 4dcfdeee2..2218a6d1c 100644
--- a/pkg/cmd/release/view/view.go
+++ b/pkg/cmd/release/view/view.go
@@ -124,7 +124,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
}
style := markdown.GetStyle(io.DetectTerminalTheme())
- renderedDescription, err := markdown.Render(release.Body, style)
+ renderedDescription, err := markdown.Render(release.Body, style, "")
if err != nil {
return err
}
diff --git a/pkg/cmd/repo/clone/clone.go b/pkg/cmd/repo/clone/clone.go
index 6dc225538..ee2a2ecdf 100644
--- a/pkg/cmd/repo/clone/clone.go
+++ b/pkg/cmd/repo/clone/clone.go
@@ -23,7 +23,6 @@ type CloneOptions struct {
IO *iostreams.IOStreams
GitArgs []string
- Directory string
Repository string
}
@@ -38,14 +37,14 @@ func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Comm
DisableFlagsInUseLine: true,
Use: "clone [] [-- ...]",
- Args: cobra.MinimumNArgs(1),
+ Args: cmdutil.MinimumArgs(1, "cannot clone: repository argument required"),
Short: "Clone a repository locally",
Long: heredoc.Doc(`
Clone a GitHub repository locally.
If the "OWNER/" portion of the "OWNER/REPO" repository argument is omitted, it
defaults to the name of the authenticating user.
-
+
Pass additional 'git clone' flags by listing them after '--'.
`),
RunE: func(cmd *cobra.Command, args []string) error {
@@ -83,12 +82,13 @@ func cloneRun(opts *CloneOptions) error {
apiClient := api.NewClientFromHTTP(httpClient)
- respositoryIsURL := strings.Contains(opts.Repository, ":")
- repositoryIsFullName := !respositoryIsURL && strings.Contains(opts.Repository, "/")
+ repositoryIsURL := strings.Contains(opts.Repository, ":")
+ repositoryIsFullName := !repositoryIsURL && strings.Contains(opts.Repository, "/")
var repo ghrepo.Interface
var protocol string
- if respositoryIsURL {
+
+ if repositoryIsURL {
repoURL, err := git.ParseURL(opts.Repository)
if err != nil {
return err
@@ -124,6 +124,12 @@ func cloneRun(opts *CloneOptions) error {
}
}
+ wantsWiki := strings.HasSuffix(repo.RepoName(), ".wiki")
+ if wantsWiki {
+ repoName := strings.TrimSuffix(repo.RepoName(), ".wiki")
+ repo = ghrepo.NewWithHost(repo.RepoOwner(), repoName, repo.RepoHost())
+ }
+
// Load the repo from the API to get the username/repo name in its
// canonical capitalization
canonicalRepo, err := api.GitHubRepo(apiClient, repo)
@@ -132,6 +138,14 @@ func cloneRun(opts *CloneOptions) error {
}
canonicalCloneURL := ghrepo.FormatRemoteURL(canonicalRepo, protocol)
+ // If repo HasWikiEnabled and wantsWiki is true then create a new clone URL
+ if wantsWiki {
+ if !canonicalRepo.HasWikiEnabled {
+ return fmt.Errorf("The '%s' repository does not have a wiki", ghrepo.FullName(canonicalRepo))
+ }
+ canonicalCloneURL = strings.TrimSuffix(canonicalCloneURL, ".git") + ".wiki.git"
+ }
+
cloneDir, err := git.RunClone(canonicalCloneURL, opts.GitArgs)
if err != nil {
return err
diff --git a/pkg/cmd/repo/clone/clone_test.go b/pkg/cmd/repo/clone/clone_test.go
index f696cd354..db40f4f2d 100644
--- a/pkg/cmd/repo/clone/clone_test.go
+++ b/pkg/cmd/repo/clone/clone_test.go
@@ -12,8 +12,87 @@ import (
"github.com/cli/cli/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
+ "github.com/stretchr/testify/require"
)
+func TestNewCmdClone(t *testing.T) {
+ testCases := []struct {
+ name string
+ args string
+ wantOpts CloneOptions
+ wantErr string
+ }{
+ {
+ name: "no arguments",
+ args: "",
+ wantErr: "cannot clone: repository argument required",
+ },
+ {
+ name: "repo argument",
+ args: "OWNER/REPO",
+ wantOpts: CloneOptions{
+ Repository: "OWNER/REPO",
+ GitArgs: []string{},
+ },
+ },
+ {
+ name: "directory argument",
+ args: "OWNER/REPO mydir",
+ wantOpts: CloneOptions{
+ Repository: "OWNER/REPO",
+ GitArgs: []string{"mydir"},
+ },
+ },
+ {
+ name: "git clone arguments",
+ args: "OWNER/REPO -- --depth 1 --recurse-submodules",
+ wantOpts: CloneOptions{
+ Repository: "OWNER/REPO",
+ GitArgs: []string{"--depth", "1", "--recurse-submodules"},
+ },
+ },
+ {
+ name: "unknown argument",
+ args: "OWNER/REPO --depth 1",
+ wantErr: "unknown flag: --depth\nSeparate git clone flags with '--'.",
+ },
+ }
+ for _, tt := range testCases {
+ t.Run(tt.name, func(t *testing.T) {
+ io, stdin, stdout, stderr := iostreams.Test()
+ fac := &cmdutil.Factory{IOStreams: io}
+
+ var opts *CloneOptions
+ cmd := NewCmdClone(fac, func(co *CloneOptions) error {
+ opts = co
+ return nil
+ })
+
+ argv, err := shlex.Split(tt.args)
+ require.NoError(t, err)
+ cmd.SetArgs(argv)
+
+ cmd.SetIn(stdin)
+ cmd.SetOut(stdout)
+ cmd.SetErr(stderr)
+
+ _, err = cmd.ExecuteC()
+ if tt.wantErr != "" {
+ assert.EqualError(t, err, tt.wantErr)
+ return
+ } else {
+ assert.NoError(t, err)
+ }
+
+ assert.Equal(t, "", stdout.String())
+ assert.Equal(t, "", stderr.String())
+
+ assert.Equal(t, tt.wantOpts.Repository, opts.Repository)
+ assert.Equal(t, tt.wantOpts.GitArgs, opts.GitArgs)
+ })
+ }
+}
+
func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) {
io, stdin, stdout, stderr := iostreams.Test()
fac := &cmdutil.Factory{
@@ -89,6 +168,16 @@ func Test_RepoClone(t *testing.T) {
args: "Owner/Repo",
want: "git clone https://github.com/OWNER/REPO.git",
},
+ {
+ name: "clone wiki",
+ args: "Owner/Repo.wiki",
+ want: "git clone https://github.com/OWNER/REPO.wiki.git",
+ },
+ {
+ name: "wiki URL",
+ args: "https://github.com/owner/repo.wiki",
+ want: "git clone https://github.com/OWNER/REPO.wiki.git",
+ },
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@@ -100,7 +189,8 @@ func Test_RepoClone(t *testing.T) {
"name": "REPO",
"owner": {
"login": "OWNER"
- }
+ },
+ "hasWikiEnabled": true
} } }
`))
@@ -203,10 +293,3 @@ func Test_RepoClone_withoutUsername(t *testing.T) {
assert.Equal(t, 1, cs.Count)
assert.Equal(t, "git clone https://github.com/OWNER/REPO.git", strings.Join(cs.Calls[0].Args, " "))
}
-
-func Test_RepoClone_flagError(t *testing.T) {
- _, err := runCloneCommand(nil, "--depth 1 OWNER/REPO")
- if err == nil || err.Error() != "unknown flag: --depth\nSeparate git clone flags with '--'." {
- t.Errorf("unexpected error %v", err)
- }
-}
diff --git a/pkg/cmd/repo/create/create.go b/pkg/cmd/repo/create/create.go
index 77ddcaf33..6d8426cf1 100644
--- a/pkg/cmd/repo/create/create.go
+++ b/pkg/cmd/repo/create/create.go
@@ -18,7 +18,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"
)
@@ -232,7 +231,7 @@ func createRun(opts *CreateOptions) error {
createLocalDirectory := opts.ConfirmSubmit
if !opts.ConfirmSubmit {
- opts.ConfirmSubmit, err = confirmSubmission(input.Name, input.OwnerID, &opts.ConfirmSubmit)
+ opts.ConfirmSubmit, err = confirmSubmission(input.Name, input.OwnerID)
if err != nil {
return err
}
@@ -246,11 +245,11 @@ func createRun(opts *CreateOptions) error {
stderr := opts.IO.ErrOut
stdout := opts.IO.Out
- greenCheck := utils.Green("✓")
+ cs := opts.IO.ColorScheme()
isTTY := opts.IO.IsStdoutTTY()
if isTTY {
- fmt.Fprintf(stderr, "%s Created repository %s on GitHub\n", greenCheck, ghrepo.FullName(repo))
+ fmt.Fprintf(stderr, "%s Created repository %s on GitHub\n", cs.SuccessIcon(), ghrepo.FullName(repo))
} else {
fmt.Fprintln(stdout, repo.URL)
}
@@ -272,35 +271,46 @@ func createRun(opts *CreateOptions) error {
return err
}
if isTTY {
- fmt.Fprintf(stderr, "%s Added remote %s\n", greenCheck, remoteURL)
+ fmt.Fprintf(stderr, "%s Added remote %s\n", cs.SuccessIcon(), remoteURL)
}
- } else if opts.IO.CanPrompt() {
- doSetup := createLocalDirectory
- if !doSetup {
- err := prompt.Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &doSetup)
+ } else {
+ if opts.IO.CanPrompt() {
+ if !createLocalDirectory {
+ err := prompt.Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &createLocalDirectory)
+ if err != nil {
+ return err
+ }
+ }
+ }
+ if createLocalDirectory {
+ path := repo.Name
+
+ gitInit, err := git.GitCommand("init", path)
if err != nil {
return err
}
- }
- if doSetup {
- path := repo.Name
-
- gitInit := git.GitCommand("init", path)
- gitInit.Stdout = stdout
+ isTTY := opts.IO.IsStdoutTTY()
+ if isTTY {
+ gitInit.Stdout = stdout
+ }
gitInit.Stderr = stderr
err = run.PrepareCmd(gitInit).Run()
if err != nil {
return err
}
- gitRemoteAdd := git.GitCommand("-C", path, "remote", "add", "origin", remoteURL)
+ gitRemoteAdd, err := git.GitCommand("-C", path, "remote", "add", "origin", remoteURL)
+ if err != nil {
+ return err
+ }
gitRemoteAdd.Stdout = stdout
gitRemoteAdd.Stderr = stderr
err = run.PrepareCmd(gitRemoteAdd).Run()
if err != nil {
return err
}
-
- fmt.Fprintf(stderr, "%s Initialized repository in './%s/'\n", utils.GreenCheck(), path)
+ if isTTY {
+ fmt.Fprintf(stderr, "%s Initialized repository in './%s/'\n", cs.SuccessIcon(), path)
+ }
}
}
@@ -359,7 +369,7 @@ func interactiveRepoCreate(isDescEmpty bool, isVisibilityPassed bool, repoName s
return answers.RepoName, answers.RepoDescription, strings.ToUpper(answers.RepoVisibility), nil
}
-func confirmSubmission(repoName string, repoOwner string, isConfirmFlagPassed *bool) (bool, error) {
+func confirmSubmission(repoName string, repoOwner string) (bool, error) {
qs := []*survey.Question{}
promptString := ""
diff --git a/pkg/cmd/repo/create/create_test.go b/pkg/cmd/repo/create/create_test.go
index 7282b350d..f9f3ea12d 100644
--- a/pkg/cmd/repo/create/create_test.go
+++ b/pkg/cmd/repo/create/create_test.go
@@ -3,6 +3,7 @@ package create
import (
"bytes"
"encoding/json"
+ "errors"
"io/ioutil"
"net/http"
"os/exec"
@@ -20,10 +21,10 @@ import (
"github.com/stretchr/testify/assert"
)
-func runCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) {
+func runCommand(httpClient *http.Client, cli string, isTTY bool) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
- io.SetStdoutTTY(true)
- io.SetStdinTTY(true)
+ io.SetStdoutTTY(isTTY)
+ io.SetStdinTTY(isTTY)
fac := &cmdutil.Factory{
IOStreams: io,
HttpClient: func() (*http.Client, error) {
@@ -106,7 +107,7 @@ func TestRepoCreate(t *testing.T) {
},
})
- output, err := runCommand(httpClient, "REPO")
+ output, err := runCommand(httpClient, "REPO", true)
if err != nil {
t.Errorf("error running command `repo create`: %v", err)
}
@@ -143,6 +144,83 @@ func TestRepoCreate(t *testing.T) {
}
}
+func TestRepoCreate_outsideGitWorkDir(t *testing.T) {
+ reg := &httpmock.Registry{}
+ reg.Register(
+ httpmock.GraphQL(`mutation RepositoryCreate\b`),
+ httpmock.StringResponse(`
+ { "data": { "createRepository": {
+ "repository": {
+ "id": "REPOID",
+ "url": "https://github.com/OWNER/REPO",
+ "name": "REPO",
+ "owner": {
+ "login": "OWNER"
+ }
+ }
+ } } }`))
+
+ httpClient := &http.Client{Transport: reg}
+
+ var seenCmds []*exec.Cmd
+ cmdOutputs := []test.OutputStub{
+ {
+ Error: errors.New("Not a git repository"),
+ },
+ {},
+ {},
+ }
+ restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
+ if len(cmdOutputs) == 0 {
+ t.Fatal("Too many calls to git command")
+ }
+ out := cmdOutputs[0]
+ cmdOutputs = cmdOutputs[1:]
+ seenCmds = append(seenCmds, cmd)
+ return &out
+ })
+ defer restoreCmd()
+
+ output, err := runCommand(httpClient, "REPO --private --confirm", false)
+ if err != nil {
+ t.Errorf("error running command `repo create`: %v", err)
+ }
+
+ assert.Equal(t, "https://github.com/OWNER/REPO\n", output.String())
+ assert.Equal(t, "", output.Stderr())
+
+ if len(seenCmds) != 3 {
+ t.Fatal("expected three commands to run")
+ }
+
+ assert.Equal(t, "git rev-parse --show-toplevel", strings.Join(seenCmds[0].Args, " "))
+ assert.Equal(t, "git init REPO", strings.Join(seenCmds[1].Args, " "))
+ assert.Equal(t, "git -C REPO remote add origin https://github.com/OWNER/REPO.git", strings.Join(seenCmds[2].Args, " "))
+
+ var reqBody struct {
+ Query string
+ Variables struct {
+ Input map[string]interface{}
+ }
+ }
+
+ if len(reg.Requests) != 1 {
+ t.Fatalf("expected 1 HTTP request, got %d", len(reg.Requests))
+ }
+
+ bodyBytes, _ := ioutil.ReadAll(reg.Requests[0].Body)
+ _ = json.Unmarshal(bodyBytes, &reqBody)
+ if repoName := reqBody.Variables.Input["name"].(string); repoName != "REPO" {
+ t.Errorf("expected %q, got %q", "REPO", repoName)
+ }
+ if repoVisibility := reqBody.Variables.Input["visibility"].(string); repoVisibility != "PRIVATE" {
+ t.Errorf("expected %q, got %q", "PRIVATE", repoVisibility)
+ }
+ if _, ownerSet := reqBody.Variables.Input["ownerId"]; ownerSet {
+ t.Error("expected ownerId not to be set")
+ }
+}
+
func TestRepoCreate_org(t *testing.T) {
reg := &httpmock.Registry{}
reg.Register(
@@ -188,7 +266,7 @@ func TestRepoCreate_org(t *testing.T) {
},
})
- output, err := runCommand(httpClient, "ORG/REPO")
+ output, err := runCommand(httpClient, "ORG/REPO", true)
if err != nil {
t.Errorf("error running command `repo create`: %v", err)
}
@@ -270,7 +348,7 @@ func TestRepoCreate_orgWithTeam(t *testing.T) {
},
})
- output, err := runCommand(httpClient, "ORG/REPO --team monkeys")
+ output, err := runCommand(httpClient, "ORG/REPO --team monkeys", true)
if err != nil {
t.Errorf("error running command `repo create`: %v", err)
}
@@ -353,7 +431,7 @@ func TestRepoCreate_template(t *testing.T) {
},
})
- output, err := runCommand(httpClient, "REPO --template='OWNER/REPO'")
+ output, err := runCommand(httpClient, "REPO --template='OWNER/REPO'", true)
if err != nil {
t.Errorf("error running command `repo create`: %v", err)
}
@@ -441,7 +519,7 @@ func TestRepoCreate_withoutNameArg(t *testing.T) {
},
})
- output, err := runCommand(httpClient, "")
+ output, err := runCommand(httpClient, "", true)
if err != nil {
t.Errorf("error running command `repo create`: %v", err)
}
diff --git a/pkg/cmd/repo/create/http_test.go b/pkg/cmd/repo/create/http_test.go
index c8e2e7a2a..240a1b625 100644
--- a/pkg/cmd/repo/create/http_test.go
+++ b/pkg/cmd/repo/create/http_test.go
@@ -1,23 +1,28 @@
package create
import (
- "bytes"
- "encoding/json"
- "io/ioutil"
"testing"
"github.com/cli/cli/api"
"github.com/cli/cli/pkg/httpmock"
+ "github.com/stretchr/testify/assert"
)
func Test_RepoCreate(t *testing.T) {
reg := &httpmock.Registry{}
httpClient := api.NewHTTPClient(api.ReplaceTripper(reg))
- reg.StubResponse(200, bytes.NewBufferString(`{}`))
+ reg.Register(
+ httpmock.GraphQL(`mutation RepositoryCreate\b`),
+ httpmock.GraphQLMutation(`{}`,
+ func(inputs map[string]interface{}) {
+ assert.Equal(t, inputs["description"], "roasted chestnuts")
+ assert.Equal(t, inputs["homepageUrl"], "http://example.com")
+ }),
+ )
input := repoCreateInput{
- Description: "roasted chesnuts",
+ Description: "roasted chestnuts",
HomepageURL: "http://example.com",
}
@@ -29,20 +34,4 @@ func Test_RepoCreate(t *testing.T) {
if len(reg.Requests) != 1 {
t.Fatalf("expected 1 HTTP request, seen %d", len(reg.Requests))
}
-
- var reqBody struct {
- Query string
- Variables struct {
- Input map[string]interface{}
- }
- }
-
- bodyBytes, _ := ioutil.ReadAll(reg.Requests[0].Body)
- _ = json.Unmarshal(bodyBytes, &reqBody)
- if description := reqBody.Variables.Input["description"].(string); description != "roasted chesnuts" {
- t.Errorf("expected description to be %q, got %q", "roasted chesnuts", description)
- }
- if homepage := reqBody.Variables.Input["homepageUrl"].(string); homepage != "http://example.com" {
- t.Errorf("expected homepageUrl to be %q, got %q", "http://example.com", homepage)
- }
}
diff --git a/pkg/cmd/repo/credits/credits.go b/pkg/cmd/repo/credits/credits.go
index c69ce1f3e..e792b54d6 100644
--- a/pkg/cmd/repo/credits/credits.go
+++ b/pkg/cmd/repo/credits/credits.go
@@ -45,10 +45,10 @@ func NewCmdCredits(f *cmdutil.Factory, runF func(*CreditsOptions) error) *cobra.
Example: heredoc.Doc(`
# see a credits animation for this project
$ gh credits
-
+
# display a non-animated thank you
$ gh credits -s
-
+
# just print the contributors, one per line
$ gh credits | cat
`),
@@ -154,6 +154,7 @@ func creditsRun(opts *CreditsOptions) error {
static := opts.Static || isWindows
out := opts.IO.Out
+ cs := opts.IO.ColorScheme()
if isTTY && static {
fmt.Fprintln(out, "THANK YOU CONTRIBUTORS!!! <3")
@@ -167,7 +168,7 @@ func creditsRun(opts *CreditsOptions) error {
}
if isTTY && !static {
- logins = append(logins, getColor(x)(c.Login))
+ logins = append(logins, cs.ColorFromString(getColor(x))(c.Login))
} else {
fmt.Fprintf(out, "%s\n", c.Login)
}
@@ -183,14 +184,13 @@ func creditsRun(opts *CreditsOptions) error {
thankLines := strings.Split(thankYou, "\n")
for x, tl := range thankLines {
- lines = append(lines, getColor(x)(tl))
+ lines = append(lines, cs.ColorFromString(getColor(x))(tl))
}
lines = append(lines, "")
lines = append(lines, logins...)
lines = append(lines, "( <3 press ctrl-c to quit <3 )")
termWidth, termHeight, err := utils.TerminalSize(out)
- //termWidth, termHeight, err := terminal.GetSize(int(outFile.Fd()))
if err != nil {
return err
}
@@ -277,14 +277,14 @@ func twinkle(starLine string) string {
return starLine
}
-func getColor(x int) func(string) string {
- rainbow := []func(string) string{
- utils.Magenta,
- utils.Red,
- utils.Yellow,
- utils.Green,
- utils.Cyan,
- utils.Blue,
+func getColor(x int) string {
+ rainbow := []string{
+ "magenta",
+ "red",
+ "yellow",
+ "green",
+ "cyan",
+ "blue",
}
ix := x % len(rainbow)
diff --git a/pkg/cmd/repo/fork/fork.go b/pkg/cmd/repo/fork/fork.go
index d5f9c359b..0b0daff04 100644
--- a/pkg/cmd/repo/fork/fork.go
+++ b/pkg/cmd/repo/fork/fork.go
@@ -12,7 +12,6 @@ import (
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
- "github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
@@ -34,6 +33,7 @@ type ForkOptions struct {
Remote bool
PromptClone bool
PromptRemote bool
+ RemoteName string
}
var Since = func(t time.Time) time.Duration {
@@ -93,6 +93,7 @@ Additional 'git clone' flags can be passed in by listing them after '--'.`,
cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork {true|false}")
cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add remote for fork {true|false}")
+ cmd.Flags().StringVar(&opts.RemoteName, "remote-name", "origin", "Specify a name for a fork's new remote.")
return cmd
}
@@ -141,19 +142,8 @@ func forkRun(opts *ForkOptions) error {
connectedToTerminal := opts.IO.IsStdoutTTY() && opts.IO.IsStderrTTY() && opts.IO.IsStdinTTY()
+ cs := opts.IO.ColorScheme()
stderr := opts.IO.ErrOut
- s := utils.Spinner(stderr)
- stopSpinner := func() {}
-
- if connectedToTerminal {
- loading := utils.Gray("Forking ") + utils.Bold(utils.Gray(ghrepo.FullName(repoToFork))) + utils.Gray("...")
- s.Suffix = " " + loading
- s.FinalMSG = utils.Gray(fmt.Sprintf("- %s\n", loading))
- utils.StartSpinner(s)
- stopSpinner = func() {
- utils.StopSpinner(s)
- }
- }
httpClient, err := opts.HttpClient()
if err != nil {
@@ -162,14 +152,13 @@ func forkRun(opts *ForkOptions) error {
apiClient := api.NewClientFromHTTP(httpClient)
+ opts.IO.StartProgressIndicator()
forkedRepo, err := api.ForkRepo(apiClient, repoToFork)
+ opts.IO.StopProgressIndicator()
if err != nil {
- stopSpinner()
return fmt.Errorf("failed to fork: %w", err)
}
- stopSpinner()
-
// This is weird. There is not an efficient way to determine via the GitHub API whether or not a
// given user has forked a given repo. We noticed, also, that the create fork API endpoint just
// returns the fork repo data even if it already exists -- with no change in status code or
@@ -179,8 +168,8 @@ func forkRun(opts *ForkOptions) error {
if createdAgo > time.Minute {
if connectedToTerminal {
fmt.Fprintf(stderr, "%s %s %s\n",
- utils.Yellow("!"),
- utils.Bold(ghrepo.FullName(forkedRepo)),
+ cs.Yellow("!"),
+ cs.Bold(ghrepo.FullName(forkedRepo)),
"already exists")
} else {
fmt.Fprintf(stderr, "%s already exists", ghrepo.FullName(forkedRepo))
@@ -188,7 +177,7 @@ func forkRun(opts *ForkOptions) error {
}
} else {
if connectedToTerminal {
- fmt.Fprintf(stderr, "%s Created fork %s\n", utils.GreenCheck(), utils.Bold(ghrepo.FullName(forkedRepo)))
+ fmt.Fprintf(stderr, "%s Created fork %s\n", cs.SuccessIcon(), cs.Bold(ghrepo.FullName(forkedRepo)))
}
}
@@ -210,9 +199,24 @@ func forkRun(opts *ForkOptions) error {
if err != nil {
return err
}
+
+ if remote, err := remotes.FindByRepo(repoToFork.RepoOwner(), repoToFork.RepoName()); err == nil {
+
+ scheme := ""
+ if remote.FetchURL != nil {
+ scheme = remote.FetchURL.Scheme
+ }
+ if remote.PushURL != nil {
+ scheme = remote.PushURL.Scheme
+ }
+ if scheme != "" {
+ protocol = scheme
+ }
+ }
+
if remote, err := remotes.FindByRepo(forkedRepo.RepoOwner(), forkedRepo.RepoName()); err == nil {
if connectedToTerminal {
- fmt.Fprintf(stderr, "%s Using existing remote %s\n", utils.GreenCheck(), utils.Bold(remote.Name))
+ fmt.Fprintf(stderr, "%s Using existing remote %s\n", cs.SuccessIcon(), cs.Bold(remote.Name))
}
return nil
}
@@ -225,22 +229,13 @@ func forkRun(opts *ForkOptions) error {
}
}
if remoteDesired {
- remoteName := "origin"
-
+ remoteName := opts.RemoteName
remotes, err := opts.Remotes()
if err != nil {
return err
}
if _, err := remotes.FindByName(remoteName); err == nil {
- renameTarget := "upstream"
- renameCmd := git.GitCommand("remote", "rename", remoteName, renameTarget)
- err = run.PrepareCmd(renameCmd).Run()
- if err != nil {
- return err
- }
- if connectedToTerminal {
- fmt.Fprintf(stderr, "%s Renamed %s remote to %s\n", utils.GreenCheck(), utils.Bold(remoteName), utils.Bold(renameTarget))
- }
+ return fmt.Errorf("a remote called '%s' already exists. You can rerun this command with --remote-name to specify a different remote name.", remoteName)
}
forkedRepoCloneURL := ghrepo.FormatRemoteURL(forkedRepo, protocol)
@@ -251,7 +246,7 @@ func forkRun(opts *ForkOptions) error {
}
if connectedToTerminal {
- fmt.Fprintf(stderr, "%s Added remote %s\n", utils.GreenCheck(), utils.Bold(remoteName))
+ fmt.Fprintf(stderr, "%s Added remote %s\n", cs.SuccessIcon(), cs.Bold(remoteName))
}
}
} else {
@@ -276,7 +271,7 @@ func forkRun(opts *ForkOptions) error {
}
if connectedToTerminal {
- fmt.Fprintf(stderr, "%s Cloned fork\n", utils.GreenCheck())
+ fmt.Fprintf(stderr, "%s Cloned fork\n", cs.SuccessIcon())
}
}
}
diff --git a/pkg/cmd/repo/fork/fork_test.go b/pkg/cmd/repo/fork/fork_test.go
index baf6cb995..bec0b782d 100644
--- a/pkg/cmd/repo/fork/fork_test.go
+++ b/pkg/cmd/repo/fork/fork_test.go
@@ -2,13 +2,13 @@ package fork
import (
"net/http"
+ "net/url"
"os/exec"
"regexp"
"strings"
"testing"
"time"
- "github.com/briandowns/spinner"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
@@ -19,7 +19,6 @@ import (
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/test"
- "github.com/cli/cli/utils"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
@@ -44,8 +43,11 @@ func runCommand(httpClient *http.Client, remotes []*context.Remote, isTTY bool,
if remotes == nil {
return []*context.Remote{
{
- Remote: &git.Remote{Name: "origin"},
- Repo: ghrepo.New("OWNER", "REPO"),
+ Remote: &git.Remote{
+ Name: "origin",
+ FetchURL: &url.URL{},
+ },
+ Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
}
@@ -98,6 +100,56 @@ func TestRepoFork_nontty(t *testing.T) {
reg.Verify(t)
}
+func TestRepoFork_existing_remote_error(t *testing.T) {
+ defer stubSince(2 * time.Second)()
+ reg := &httpmock.Registry{}
+ defer reg.StubWithFixturePath(200, "./forkResult.json")()
+ httpClient := &http.Client{Transport: reg}
+
+ _, err := runCommand(httpClient, nil, false, "--remote")
+ if err == nil {
+ t.Fatal("expected error running command `repo fork`")
+ }
+
+ assert.Equal(t, "a remote called 'origin' already exists. You can rerun this command with --remote-name to specify a different remote name.", err.Error())
+
+ reg.Verify(t)
+}
+
+func TestRepoFork_no_existing_remote(t *testing.T) {
+ remotes := []*context.Remote{
+ {
+ Remote: &git.Remote{
+ Name: "upstream",
+ FetchURL: &url.URL{},
+ },
+ Repo: ghrepo.New("OWNER", "REPO"),
+ },
+ }
+ defer stubSince(2 * time.Second)()
+ reg := &httpmock.Registry{}
+ defer reg.StubWithFixturePath(200, "./forkResult.json")()
+ httpClient := &http.Client{Transport: reg}
+
+ cs, restore := test.InitCmdStubber()
+ defer restore()
+
+ cs.Stub("") // git remote rename
+ cs.Stub("") // git remote add
+
+ output, err := runCommand(httpClient, remotes, false, "--remote")
+ if err != nil {
+ t.Fatalf("error running command `repo fork`: %v", err)
+ }
+
+ assert.Equal(t, 1, len(cs.Calls))
+ assert.Equal(t, "git remote add -f origin https://github.com/someone/REPO.git", strings.Join(cs.Calls[0].Args, " "))
+
+ assert.Equal(t, "", output.String())
+ assert.Equal(t, "", output.Stderr())
+ reg.Verify(t)
+}
+
func TestRepoFork_in_parent_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
@@ -110,14 +162,13 @@ func TestRepoFork_in_parent_nontty(t *testing.T) {
cs.Stub("") // git remote rename
cs.Stub("") // git remote add
- output, err := runCommand(httpClient, nil, false, "--remote")
+ output, err := runCommand(httpClient, nil, false, "--remote --remote-name=fork")
if err != nil {
t.Fatalf("error running command `repo fork`: %v", err)
}
- assert.Equal(t, 2, len(cs.Calls))
- assert.Equal(t, "git remote rename origin upstream", strings.Join(cs.Calls[0].Args, " "))
- assert.Equal(t, "git remote add -f origin https://github.com/someone/REPO.git", strings.Join(cs.Calls[1].Args, " "))
+ assert.Equal(t, 1, len(cs.Calls))
+ assert.Equal(t, "git remote add -f fork https://github.com/someone/REPO.git", strings.Join(cs.Calls[0].Args, " "))
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
@@ -151,7 +202,6 @@ func TestRepoFork_outside_parent_nontty(t *testing.T) {
}
func TestRepoFork_already_forked(t *testing.T) {
- stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
@@ -176,14 +226,13 @@ func TestRepoFork_already_forked(t *testing.T) {
}
func TestRepoFork_reuseRemote(t *testing.T) {
- stubSpinner()
remotes := []*context.Remote{
{
- Remote: &git.Remote{Name: "origin"},
+ Remote: &git.Remote{Name: "origin", FetchURL: &url.URL{}},
Repo: ghrepo.New("someone", "REPO"),
},
{
- Remote: &git.Remote{Name: "upstream"},
+ Remote: &git.Remote{Name: "upstream", FetchURL: &url.URL{}},
Repo: ghrepo.New("OWNER", "REPO"),
},
}
@@ -204,7 +253,6 @@ func TestRepoFork_reuseRemote(t *testing.T) {
}
func TestRepoFork_in_parent(t *testing.T) {
- stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
@@ -230,7 +278,6 @@ func TestRepoFork_in_parent(t *testing.T) {
}
func TestRepoFork_outside(t *testing.T) {
- stubSpinner()
tests := []struct {
name string
args string
@@ -269,7 +316,6 @@ func TestRepoFork_outside(t *testing.T) {
}
func TestRepoFork_in_parent_yes(t *testing.T) {
- stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
@@ -281,30 +327,24 @@ func TestRepoFork_in_parent_yes(t *testing.T) {
return &test.OutputStub{}
})()
- output, err := runCommand(httpClient, nil, true, "--remote")
+ output, err := runCommand(httpClient, nil, true, "--remote --remote-name=fork")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
- expectedCmds := []string{
- "git remote rename origin upstream",
- "git remote add -f origin https://github.com/someone/REPO.git",
- }
-
- for x, cmd := range seenCmds {
- assert.Equal(t, expectedCmds[x], strings.Join(cmd.Args, " "))
- }
+ assert.Equal(t, 1, len(seenCmds))
+ expectedCmd := "git remote add -f fork https://github.com/someone/REPO.git"
+ assert.Equal(t, expectedCmd, strings.Join(seenCmds[0].Args, " "))
assert.Equal(t, "", output.String())
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
- "Added remote.*origin")
+ "Added remote.*fork")
reg.Verify(t)
}
func TestRepoFork_outside_yes(t *testing.T) {
- stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
@@ -333,7 +373,6 @@ func TestRepoFork_outside_yes(t *testing.T) {
}
func TestRepoFork_outside_survey_yes(t *testing.T) {
- stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
@@ -364,7 +403,6 @@ func TestRepoFork_outside_survey_yes(t *testing.T) {
}
func TestRepoFork_outside_survey_no(t *testing.T) {
- stubSpinner()
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
@@ -396,7 +434,6 @@ func TestRepoFork_outside_survey_no(t *testing.T) {
}
func TestRepoFork_in_parent_survey_yes(t *testing.T) {
- stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
@@ -410,31 +447,24 @@ func TestRepoFork_in_parent_survey_yes(t *testing.T) {
defer prompt.StubConfirm(true)()
- output, err := runCommand(httpClient, nil, true, "")
+ output, err := runCommand(httpClient, nil, true, "--remote-name=fork")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
- expectedCmds := []string{
- "git remote rename origin upstream",
- "git remote add -f origin https://github.com/someone/REPO.git",
- }
-
- for x, cmd := range seenCmds {
- assert.Equal(t, expectedCmds[x], strings.Join(cmd.Args, " "))
- }
+ assert.Equal(t, 1, len(seenCmds))
+ expectedCmd := "git remote add -f fork https://github.com/someone/REPO.git"
+ assert.Equal(t, expectedCmd, strings.Join(seenCmds[0].Args, " "))
assert.Equal(t, "", output.String())
test.ExpectLines(t, output.Stderr(),
"Created fork.*someone/REPO",
- "Renamed.*origin.*remote to.*upstream",
- "Added remote.*origin")
+ "Added remote.*fork")
reg.Verify(t)
}
func TestRepoFork_in_parent_survey_no(t *testing.T) {
- stubSpinner()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
httpClient := &http.Client{Transport: reg}
@@ -472,12 +502,42 @@ func Test_RepoFork_flagError(t *testing.T) {
}
}
-func stubSpinner() {
- // not bothering with teardown since we never want spinners when doing tests
- utils.StartSpinner = func(_ *spinner.Spinner) {
+func TestRepoFork_in_parent_match_protocol(t *testing.T) {
+ defer stubSince(2 * time.Second)()
+ reg := &httpmock.Registry{}
+ defer reg.StubWithFixturePath(200, "./forkResult.json")()
+ httpClient := &http.Client{Transport: reg}
+
+ var seenCmds []*exec.Cmd
+ defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
+ seenCmds = append(seenCmds, cmd)
+ return &test.OutputStub{}
+ })()
+
+ remotes := []*context.Remote{
+ {
+ Remote: &git.Remote{Name: "origin", PushURL: &url.URL{
+ Scheme: "ssh",
+ }},
+ Repo: ghrepo.New("OWNER", "REPO"),
+ },
}
- utils.StopSpinner = func(_ *spinner.Spinner) {
+
+ output, err := runCommand(httpClient, remotes, true, "--remote --remote-name=fork")
+ if err != nil {
+ t.Errorf("error running command `repo fork`: %v", err)
}
+
+ assert.Equal(t, 1, len(seenCmds))
+ expectedCmd := "git remote add -f fork git@github.com:someone/REPO.git"
+ assert.Equal(t, expectedCmd, strings.Join(seenCmds[0].Args, " "))
+
+ assert.Equal(t, "", output.String())
+
+ test.ExpectLines(t, output.Stderr(),
+ "Created fork.*someone/REPO",
+ "Added remote.*fork")
+ reg.Verify(t)
}
func stubSince(d time.Duration) func() {
diff --git a/pkg/cmd/repo/garden/garden.go b/pkg/cmd/repo/garden/garden.go
index 2402ccbc1..c7ee048a7 100644
--- a/pkg/cmd/repo/garden/garden.go
+++ b/pkg/cmd/repo/garden/garden.go
@@ -4,13 +4,15 @@ import (
"bytes"
"errors"
"fmt"
- "io"
"math/rand"
"net/http"
+ "os"
"os/exec"
+ "os/signal"
"runtime"
"strconv"
"strings"
+ "syscall"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
@@ -119,6 +121,7 @@ func NewCmdGarden(f *cmdutil.Factory, runF func(*GardenOptions) error) *cobra.Co
}
func gardenRun(opts *GardenOptions) error {
+ cs := opts.IO.ColorScheme()
out := opts.IO.Out
if runtime.GOOS == "windows" {
@@ -187,7 +190,7 @@ func gardenRun(opts *GardenOptions) error {
oldTTYCommand := exec.Command("stty", sttyFileArg, "/dev/tty", "-g")
oldTTYSettings, err := oldTTYCommand.CombinedOutput()
if err != nil {
- fmt.Fprintln(out, "getting TTY setings failed:", string(oldTTYSettings))
+ fmt.Fprintln(out, "getting TTY settings failed:", string(oldTTYSettings))
return err
}
@@ -198,7 +201,7 @@ func gardenRun(opts *GardenOptions) error {
if err != nil {
return err
}
- player := &Player{0, 0, utils.Bold("@"), geo, 0}
+ player := &Player{0, 0, cs.Bold("@"), geo, 0}
garden := plantGarden(commits, geo)
if len(garden) < geo.Height {
@@ -210,12 +213,28 @@ func gardenRun(opts *GardenOptions) error {
geo.Width = 0
}
clear(opts.IO)
- drawGarden(out, garden, player)
+ drawGarden(opts.IO, garden, player)
// thanks stackoverflow https://stackoverflow.com/a/17278776
_ = exec.Command("stty", sttyFileArg, "/dev/tty", "cbreak", "min", "1").Run()
_ = exec.Command("stty", sttyFileArg, "/dev/tty", "-echo").Run()
+ walkAway := func() {
+ clear(opts.IO)
+ fmt.Fprint(out, "\033[?25h")
+ _ = exec.Command("stty", sttyFileArg, "/dev/tty", strings.TrimSpace(string(oldTTYSettings))).Run()
+ fmt.Fprintln(out)
+ fmt.Fprintln(out, cs.Bold("You turn and walk away from the wildflower garden..."))
+ }
+
+ c := make(chan os.Signal)
+ signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+ go func() {
+ <-c
+ walkAway()
+ os.Exit(0)
+ }()
+
var b []byte = make([]byte, 3)
for {
_, _ = opts.IO.In.Read(b)
@@ -284,7 +303,7 @@ func gardenRun(opts *GardenOptions) error {
}
// status line stuff
- sl := statusLine(garden, player)
+ sl := statusLine(garden, player, opts.IO)
fmt.Fprint(out, "\033[;H") // move to top left
for y := 0; y < player.Geo.Height-1; y++ {
@@ -293,15 +312,10 @@ func gardenRun(opts *GardenOptions) error {
fmt.Fprintln(out)
fmt.Fprintln(out)
- fmt.Fprint(out, utils.Bold(sl))
+ fmt.Fprint(out, cs.Bold(sl))
}
- clear(opts.IO)
- fmt.Fprint(out, "\033[?25h")
- _ = exec.Command("stty", sttyFileArg, "/dev/tty", strings.TrimSpace(string(oldTTYSettings))).Run()
- fmt.Fprintln(out)
- fmt.Fprintln(out, utils.Bold("You turn and walk away from the wildflower garden..."))
-
+ walkAway()
return nil
}
@@ -409,7 +423,10 @@ func plantGarden(commits []*Commit, geo *Geometry) [][]*Cell {
return garden
}
-func drawGarden(out io.Writer, garden [][]*Cell, player *Player) {
+func drawGarden(io *iostreams.IOStreams, garden [][]*Cell, player *Player) {
+ out := io.Out
+ cs := io.ColorScheme()
+
fmt.Fprint(out, "\033[?25l") // hide cursor. it needs to be restored at command exit.
sl := ""
for y, gardenRow := range garden {
@@ -418,7 +435,7 @@ func drawGarden(out io.Writer, garden [][]*Cell, player *Player) {
underPlayer := (player.X == x && player.Y == y)
if underPlayer {
sl = gardenCell.StatusLine
- char = utils.Bold(player.Char)
+ char = cs.Bold(player.Char)
if strings.Contains(gardenCell.StatusLine, "stream") {
player.ShoeMoistureContent = 5
@@ -433,20 +450,29 @@ func drawGarden(out io.Writer, garden [][]*Cell, player *Player) {
}
fmt.Println()
- fmt.Fprintln(out, utils.Bold(sl))
+ fmt.Fprintln(out, cs.Bold(sl))
}
-func statusLine(garden [][]*Cell, player *Player) string {
- statusLine := garden[player.Y][player.X].StatusLine + " "
+func statusLine(garden [][]*Cell, player *Player, io *iostreams.IOStreams) string {
+ width := io.TerminalWidth()
+ statusLines := []string{garden[player.Y][player.X].StatusLine}
+
if player.ShoeMoistureContent > 1 {
- statusLine += "\nYour shoes squish with water from the stream."
+ statusLines = append(statusLines, "Your shoes squish with water from the stream.")
} else if player.ShoeMoistureContent == 1 {
- statusLine += "\nYour shoes seem to have dried out."
+ statusLines = append(statusLines, "Your shoes seem to have dried out.")
} else {
- statusLine += "\n "
+ statusLines = append(statusLines, "")
}
- return statusLine
+ for i, line := range statusLines {
+ if len(line) < width {
+ paddingSize := width - len(line)
+ statusLines[i] = line + strings.Repeat(" ", paddingSize)
+ }
+ }
+
+ return strings.Join(statusLines, "\n")
}
func shaToColorFunc(sha string) func(string) string {
diff --git a/pkg/cmd/repo/view/http.go b/pkg/cmd/repo/view/http.go
index b7e3096ce..24eaaa676 100644
--- a/pkg/cmd/repo/view/http.go
+++ b/pkg/cmd/repo/view/http.go
@@ -15,6 +15,7 @@ var NotFoundError = errors.New("not found")
type RepoReadme struct {
Filename string
Content string
+ BaseURL string
}
func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string) (*RepoReadme, error) {
@@ -22,6 +23,7 @@ func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string)
var response struct {
Name string
Content string
+ HTMLURL string `json:"html_url"`
}
err := apiClient.REST(repo.RepoHost(), "GET", getReadmePath(repo, branch), nil, &response)
@@ -41,6 +43,7 @@ func RepositoryReadme(client *http.Client, repo ghrepo.Interface, branch string)
return &RepoReadme{
Filename: response.Name,
Content: string(decoded),
+ BaseURL: response.HTMLURL,
}, nil
}
diff --git a/pkg/cmd/repo/view/view.go b/pkg/cmd/repo/view/view.go
index 6cb978e5e..77a343f38 100644
--- a/pkg/cmd/repo/view/view.go
+++ b/pkg/cmd/repo/view/view.go
@@ -4,6 +4,7 @@ import (
"errors"
"fmt"
"net/http"
+ "net/url"
"strings"
"syscall"
"text/template"
@@ -100,7 +101,7 @@ func viewRun(opts *ViewOptions) error {
return err
}
- openURL := ghrepo.GenerateRepoURL(toView, "")
+ openURL := generateBranchURL(toView, opts.Branch)
if opts.Web {
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(openURL))
@@ -151,13 +152,15 @@ func viewRun(opts *ViewOptions) error {
return err
}
+ cs := opts.IO.ColorScheme()
+
var readmeContent string
if readme == nil {
- readmeContent = utils.Gray("This repository does not have a README")
+ readmeContent = cs.Gray("This repository does not have a README")
} else if isMarkdownFile(readme.Filename) {
var err error
style := markdown.GetStyle(opts.IO.TerminalTheme())
- readmeContent, err = markdown.Render(readme.Content, style)
+ readmeContent, err = markdown.Render(readme.Content, style, readme.BaseURL)
if err != nil {
return fmt.Errorf("error rendering markdown: %w", err)
}
@@ -168,7 +171,7 @@ func viewRun(opts *ViewOptions) error {
description := repo.Description
if description == "" {
- description = utils.Gray("No description provided")
+ description = cs.Gray("No description provided")
}
repoData := struct {
@@ -177,10 +180,10 @@ func viewRun(opts *ViewOptions) error {
Readme string
View string
}{
- FullName: utils.Bold(fullName),
+ FullName: cs.Bold(fullName),
Description: description,
Readme: readmeContent,
- View: utils.Gray(fmt.Sprintf("View this repository on GitHub: %s", openURL)),
+ View: cs.Gray(fmt.Sprintf("View this repository on GitHub: %s", openURL)),
}
err = tmpl.Execute(stdout, repoData)
@@ -199,3 +202,11 @@ func isMarkdownFile(filename string) bool {
strings.HasSuffix(filename, ".mdown") ||
strings.HasSuffix(filename, ".mkdown")
}
+
+func generateBranchURL(r ghrepo.Interface, branch string) string {
+ if branch == "" {
+ return ghrepo.GenerateRepoURL(r, "")
+ }
+
+ return ghrepo.GenerateRepoURL(r, "tree/%s", url.QueryEscape(branch))
+}
diff --git a/pkg/cmd/repo/view/view_test.go b/pkg/cmd/repo/view/view_test.go
index aeb8ae9b7..945fab965 100644
--- a/pkg/cmd/repo/view/view_test.go
+++ b/pkg/cmd/repo/view/view_test.go
@@ -222,7 +222,7 @@ func Test_ViewRun(t *testing.T) {
- View this repository on GitHub: https://github.com/OWNER/REPO
+ View this repository on GitHub: https://github.com/OWNER/REPO/tree/feat%2Fawesome
`),
},
{
diff --git a/pkg/cmd/root/help.go b/pkg/cmd/root/help.go
index ae57c1328..80b61341e 100644
--- a/pkg/cmd/root/help.go
+++ b/pkg/cmd/root/help.go
@@ -6,8 +6,8 @@ import (
"strings"
"github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/text"
- "github.com/cli/cli/utils"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
@@ -35,7 +35,7 @@ func rootUsageFunc(command *cobra.Command) error {
return nil
}
-func rootFlagErrrorFunc(cmd *cobra.Command, err error) error {
+func rootFlagErrorFunc(cmd *cobra.Command, err error) error {
if err == pflag.ErrHelp {
return err
}
@@ -80,7 +80,7 @@ func isRootCmd(command *cobra.Command) bool {
return command != nil && !command.HasParent()
}
-func rootHelpFunc(command *cobra.Command, args []string) {
+func rootHelpFunc(cs *iostreams.ColorScheme, command *cobra.Command, args []string) {
if isRootCmd(command.Parent()) && len(args) >= 2 && args[1] != "--help" && args[1] != "-h" {
nestedSuggestFunc(command, args[1])
hasFailed = true
@@ -158,7 +158,7 @@ Read the manual at https://cli.github.com/manual`})
for _, e := range helpEntries {
if e.Title != "" {
// If there is a title, add indentation to each line in the body
- fmt.Fprintln(out, utils.Bold(e.Title))
+ fmt.Fprintln(out, cs.Bold(e.Title))
fmt.Fprintln(out, text.Indent(strings.Trim(e.Body, "\r\n"), " "))
} else {
// If there is no title print the body as is
diff --git a/pkg/cmd/root/help_reference.go b/pkg/cmd/root/help_reference.go
new file mode 100644
index 000000000..ecc7ec856
--- /dev/null
+++ b/pkg/cmd/root/help_reference.go
@@ -0,0 +1,69 @@
+package root
+
+import (
+ "bytes"
+ "fmt"
+ "io"
+ "strings"
+
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/cli/cli/pkg/markdown"
+ "github.com/spf13/cobra"
+)
+
+func referenceHelpFn(io *iostreams.IOStreams) func(*cobra.Command, []string) {
+ return func(cmd *cobra.Command, args []string) {
+ wrapWidth := 0
+ style := "notty"
+ if io.IsStdoutTTY() {
+ wrapWidth = io.TerminalWidth()
+ style = markdown.GetStyle(io.DetectTerminalTheme())
+ }
+
+ md, err := markdown.RenderWrap(cmd.Long, style, wrapWidth)
+ if err != nil {
+ fmt.Fprintln(io.ErrOut, err)
+ return
+ }
+
+ if !io.IsStdoutTTY() {
+ fmt.Fprint(io.Out, dedent(md))
+ return
+ }
+
+ _ = io.StartPager()
+ defer io.StopPager()
+ fmt.Fprint(io.Out, md)
+ }
+}
+
+func referenceLong(cmd *cobra.Command) string {
+ buf := bytes.NewBufferString("# gh reference\n\n")
+ for _, c := range cmd.Commands() {
+ if c.Hidden {
+ continue
+ }
+ cmdRef(buf, c, 2)
+ }
+ return buf.String()
+}
+
+func cmdRef(w io.Writer, cmd *cobra.Command, depth int) {
+ // Name + Description
+ fmt.Fprintf(w, "%s `%s`\n\n", strings.Repeat("#", depth), cmd.UseLine())
+ fmt.Fprintf(w, "%s\n\n", cmd.Short)
+
+ // Flags
+ // TODO: fold in InheritedFlags/PersistentFlags, but omit `--help` due to repetitiveness
+ if flagUsages := cmd.Flags().FlagUsages(); flagUsages != "" {
+ fmt.Fprintf(w, "```\n%s````\n\n", dedent(flagUsages))
+ }
+
+ // Subcommands
+ for _, c := range cmd.Commands() {
+ if c.Hidden {
+ continue
+ }
+ cmdRef(w, c, depth+1)
+ }
+}
diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go
index d88109ad3..99055034b 100644
--- a/pkg/cmd/root/help_topic.go
+++ b/pkg/cmd/root/help_topic.go
@@ -5,52 +5,63 @@ import (
"github.com/spf13/cobra"
)
+var HelpTopics = map[string]map[string]string{
+ "environment": {
+ "short": "Environment variables that can be used with gh",
+ "long": heredoc.Doc(`
+ GH_TOKEN, GITHUB_TOKEN (in order of precedence): an authentication token for github.com
+ API requests. Setting this avoids being prompted to authenticate and takes precedence over
+ previously stored credentials.
+
+ GH_ENTERPRISE_TOKEN, GITHUB_ENTERPRISE_TOKEN (in order of precedence): an authentication
+ token for API requests to GitHub Enterprise.
+
+ GH_REPO: specify the GitHub repository in the "[HOST/]OWNER/REPO" format for commands
+ that otherwise operate on a local repository.
+
+ GH_HOST: specify the GitHub hostname for commands that would otherwise assume
+ the "github.com" host when not in a context of an existing repository.
+
+ GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use
+ for authoring text.
+
+ BROWSER: the web browser to use for opening links.
+
+ DEBUG: set to any value to enable verbose output to standard error. Include values "api"
+ or "oauth" to print detailed information about HTTP requests or authentication flow.
+
+ GH_PAGER, PAGER (in order of precedence): a terminal paging program to send standard output to, e.g. "less".
+
+ GLAMOUR_STYLE: the style to use for rendering Markdown. See
+ https://github.com/charmbracelet/glamour#styles
+
+ NO_COLOR: set to any value to avoid printing ANSI escape sequences for color output.
+
+ CLICOLOR: set to "0" to disable printing ANSI colors in output.
+
+ CLICOLOR_FORCE: set to a value other than "0" to keep ANSI colors in output
+ even when the output is piped.
+
+ GH_NO_UPDATE_NOTIFIER: set to any value to disable update notifications. By default, gh
+ checks for new releases once every 24 hours and displays an upgrade notice on standard
+ error if a newer version was found.
+ `),
+ },
+ "reference": {
+ "short": "A comprehensive reference of all gh commands",
+ },
+}
+
func NewHelpTopic(topic string) *cobra.Command {
- topicContent := make(map[string]string)
-
- topicContent["environment"] = heredoc.Doc(`
- GITHUB_TOKEN: an authentication token for github.com API requests. Setting this avoids
- being prompted to authenticate and takes precedence over previously stored credentials.
-
- GITHUB_ENTERPRISE_TOKEN: an authentication token for API requests to GitHub Enterprise.
-
- GH_REPO: specify the GitHub repository in the "[HOST/]OWNER/REPO" format for commands
- that otherwise operate on a local repository.
-
- GH_HOST: specify the GitHub hostname for commands that would otherwise assume
- the "github.com" host when not in a context of an existing repository.
-
- GH_EDITOR, GIT_EDITOR, VISUAL, EDITOR (in order of precedence): the editor tool to use
- for authoring text.
-
- BROWSER: the web browser to use for opening links.
-
- DEBUG: set to any value to enable verbose output to standard error. Include values "api"
- or "oauth" to print detailed information about HTTP requests or authentication flow.
-
- GH_PAGER, PAGER (in order of precedence): a terminal paging program to send standard output to, e.g. "less".
-
- GLAMOUR_STYLE: the style to use for rendering Markdown. See
- https://github.com/charmbracelet/glamour#styles
-
- NO_COLOR: set to any value to avoid printing ANSI escape sequences for color output.
-
- CLICOLOR: set to "0" to disable printing ANSI colors in output.
-
- CLICOLOR_FORCE: set to a value other than "0" to keep ANSI colors in output
- even when the output is piped.
-
- GH_NO_UPDATE_NOTIFIER: set to any value to disable update notifications. By default, gh
- checks for new releases once every 24 hours and displays an upgrade notice on standard
- error if a newer version was found.
- `)
-
cmd := &cobra.Command{
Use: topic,
- Long: topicContent[topic],
+ Short: HelpTopics[topic]["short"],
+ Long: HelpTopics[topic]["long"],
Hidden: true,
- Args: cobra.NoArgs,
- Run: helpTopicHelpFunc,
+ Annotations: map[string]string{
+ "markdown:generate": "true",
+ "markdown:basename": "gh_help_" + topic,
+ },
}
cmd.SetHelpFunc(helpTopicHelpFunc)
diff --git a/pkg/cmd/root/help_topic_test.go b/pkg/cmd/root/help_topic_test.go
index f194541ac..3aba4bdf3 100644
--- a/pkg/cmd/root/help_topic_test.go
+++ b/pkg/cmd/root/help_topic_test.go
@@ -34,7 +34,7 @@ func TestNewHelpTopic(t *testing.T) {
topic: "environment",
args: []string{"invalid"},
flags: []string{},
- wantsErr: true,
+ wantsErr: false,
},
{
name: "more than zero flags",
@@ -48,7 +48,7 @@ func TestNewHelpTopic(t *testing.T) {
topic: "environment",
args: []string{"help"},
flags: []string{},
- wantsErr: true,
+ wantsErr: false,
},
{
name: "help flag",
diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go
index d1b5d9a9d..ba05f902c 100644
--- a/pkg/cmd/root/root.go
+++ b/pkg/cmd/root/root.go
@@ -19,6 +19,8 @@ import (
releaseCmd "github.com/cli/cli/pkg/cmd/release"
repoCmd "github.com/cli/cli/pkg/cmd/repo"
creditsCmd "github.com/cli/cli/pkg/cmd/repo/credits"
+ secretCmd "github.com/cli/cli/pkg/cmd/secret"
+ sshKeyCmd "github.com/cli/cli/pkg/cmd/ssh-key"
versionCmd "github.com/cli/cli/pkg/cmd/version"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
@@ -50,10 +52,16 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
cmd.SetOut(f.IOStreams.Out)
cmd.SetErr(f.IOStreams.ErrOut)
+ cs := f.IOStreams.ColorScheme()
+
+ helpHelper := func(command *cobra.Command, args []string) {
+ rootHelpFunc(cs, command, args)
+ }
+
cmd.PersistentFlags().Bool("help", false, "Show help for command")
- cmd.SetHelpFunc(rootHelpFunc)
+ cmd.SetHelpFunc(helpHelper)
cmd.SetUsageFunc(rootUsageFunc)
- cmd.SetFlagErrorFunc(rootFlagErrrorFunc)
+ cmd.SetFlagErrorFunc(rootFlagErrorFunc)
formattedVersion := versionCmd.Format(version, buildDate)
cmd.SetVersionTemplate(formattedVersion)
@@ -68,6 +76,8 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil))
cmd.AddCommand(gistCmd.NewCmdGist(f))
cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams))
+ cmd.AddCommand(secretCmd.NewCmdSecret(f))
+ cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
// the `api` command should not inherit any extra HTTP headers
bareHTTPCmdFactory := *f
@@ -86,9 +96,14 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
// Help topics
cmd.AddCommand(NewHelpTopic("environment"))
+ referenceCmd := NewHelpTopic("reference")
+ referenceCmd.SetHelpFunc(referenceHelpFn(f.IOStreams))
+ cmd.AddCommand(referenceCmd)
cmdutil.DisableAuthCheck(cmd)
+ // this needs to appear last:
+ referenceCmd.Long = referenceLong(cmd)
return cmd
}
diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go
new file mode 100644
index 000000000..792e5fc82
--- /dev/null
+++ b/pkg/cmd/secret/list/list.go
@@ -0,0 +1,183 @@
+package list
+
+import (
+ "fmt"
+ "net/http"
+ "net/url"
+ "strings"
+ "time"
+
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/internal/ghinstance"
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmd/secret/shared"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/cli/cli/utils"
+ "github.com/spf13/cobra"
+)
+
+type ListOptions struct {
+ HttpClient func() (*http.Client, error)
+ IO *iostreams.IOStreams
+ BaseRepo func() (ghrepo.Interface, error)
+
+ OrgName string
+}
+
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+ opts := &ListOptions{
+ IO: f.IOStreams,
+ HttpClient: f.HttpClient,
+ }
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "List secrets",
+ Long: "List secrets for a repository or organization",
+ Args: cobra.NoArgs,
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // support `-R, --repo` override
+ opts.BaseRepo = f.BaseRepo
+
+ if runF != nil {
+ return runF(opts)
+ }
+
+ return listRun(opts)
+ },
+ }
+
+ cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization")
+
+ return cmd
+}
+
+func listRun(opts *ListOptions) error {
+ c, err := opts.HttpClient()
+ if err != nil {
+ return fmt.Errorf("could not create http client: %w", err)
+ }
+ client := api.NewClientFromHTTP(c)
+
+ orgName := opts.OrgName
+
+ var baseRepo ghrepo.Interface
+ if orgName == "" {
+ baseRepo, err = opts.BaseRepo()
+ if err != nil {
+ return fmt.Errorf("could not determine base repo: %w", err)
+ }
+ }
+
+ var secrets []*Secret
+ if orgName == "" {
+ secrets, err = getRepoSecrets(client, baseRepo)
+ } else {
+ secrets, err = getOrgSecrets(client, orgName)
+ }
+
+ if err != nil {
+ return fmt.Errorf("failed to get secrets: %w", err)
+ }
+
+ tp := utils.NewTablePrinter(opts.IO)
+ for _, secret := range secrets {
+ tp.AddField(secret.Name, nil, nil)
+ updatedAt := secret.UpdatedAt.Format("2006-01-02")
+ if opts.IO.IsStdoutTTY() {
+ updatedAt = fmt.Sprintf("Updated %s", updatedAt)
+ }
+ tp.AddField(updatedAt, nil, nil)
+ if secret.Visibility != "" {
+ if opts.IO.IsStdoutTTY() {
+ tp.AddField(fmtVisibility(*secret), nil, nil)
+ } else {
+ tp.AddField(strings.ToUpper(string(secret.Visibility)), nil, nil)
+ }
+ }
+ tp.EndRow()
+ }
+
+ err = tp.Render()
+ if err != nil {
+ return err
+ }
+
+ return nil
+}
+
+type Secret struct {
+ Name string
+ UpdatedAt time.Time `json:"updated_at"`
+ Visibility shared.Visibility
+ SelectedReposURL string `json:"selected_repositories_url"`
+ NumSelectedRepos int
+}
+
+func fmtVisibility(s Secret) string {
+ switch s.Visibility {
+ case shared.All:
+ return "Visible to all repositories"
+ case shared.Private:
+ return "Visible to private repositories"
+ case shared.Selected:
+ if s.NumSelectedRepos == 1 {
+ return "Visible to 1 selected repository"
+ } else {
+ return fmt.Sprintf("Visible to %d selected repositories", s.NumSelectedRepos)
+ }
+ }
+ return ""
+}
+
+func getOrgSecrets(client *api.Client, orgName string) ([]*Secret, error) {
+ host := ghinstance.OverridableDefault()
+ secrets, err := getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName))
+ if err != nil {
+ return nil, err
+ }
+
+ type responseData struct {
+ TotalCount int `json:"total_count"`
+ }
+
+ for _, secret := range secrets {
+ if secret.SelectedReposURL == "" {
+ continue
+ }
+ u, err := url.Parse(secret.SelectedReposURL)
+ if err != nil {
+ return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err)
+ }
+
+ var result responseData
+ err = client.REST(u.Host, "GET", u.Path[1:], nil, &result)
+ if err != nil {
+ return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err)
+ }
+ secret.NumSelectedRepos = result.TotalCount
+ }
+
+ return secrets, nil
+}
+
+func getRepoSecrets(client *api.Client, repo ghrepo.Interface) ([]*Secret, error) {
+ return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets",
+ ghrepo.FullName(repo)))
+}
+
+type secretsPayload struct {
+ Secrets []*Secret
+}
+
+func getSecrets(client *api.Client, host, path string) ([]*Secret, error) {
+ result := secretsPayload{}
+
+ err := client.REST(host, "GET", path, nil, &result)
+ if err != nil {
+ return nil, err
+ }
+
+ return result.Secrets, nil
+}
diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go
new file mode 100644
index 000000000..601c13c27
--- /dev/null
+++ b/pkg/cmd/secret/list/list_test.go
@@ -0,0 +1,197 @@
+package list
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmd/secret/shared"
+ "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 Test_NewCmdList(t *testing.T) {
+ tests := []struct {
+ name string
+ cli string
+ wants ListOptions
+ }{
+ {
+ name: "repo",
+ cli: "",
+ wants: ListOptions{
+ OrgName: "",
+ },
+ },
+ {
+ name: "org",
+ cli: "-oUmbrellaCorporation",
+ wants: ListOptions{
+ OrgName: "UmbrellaCorporation",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ io, _, _, _ := iostreams.Test()
+ f := &cmdutil.Factory{
+ IOStreams: io,
+ }
+
+ argv, err := shlex.Split(tt.cli)
+ assert.NoError(t, err)
+
+ var gotOpts *ListOptions
+ cmd := NewCmdList(f, func(opts *ListOptions) 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.OrgName, gotOpts.OrgName)
+
+ })
+ }
+}
+
+func Test_listRun(t *testing.T) {
+ tests := []struct {
+ name string
+ tty bool
+ opts *ListOptions
+ wantOut []string
+ }{
+ {
+ name: "repo tty",
+ tty: true,
+ opts: &ListOptions{},
+ wantOut: []string{
+ "SECRET_ONE.*Updated 1988-10-11",
+ "SECRET_TWO.*Updated 2020-12-04",
+ "SECRET_THREE.*Updated 1975-11-30",
+ },
+ },
+ {
+ name: "repo not tty",
+ tty: false,
+ opts: &ListOptions{},
+ wantOut: []string{
+ "SECRET_ONE\t1988-10-11",
+ "SECRET_TWO\t2020-12-04",
+ "SECRET_THREE\t1975-11-30",
+ },
+ },
+ {
+ name: "org tty",
+ tty: true,
+ opts: &ListOptions{
+ OrgName: "UmbrellaCorporation",
+ },
+ wantOut: []string{
+ "SECRET_ONE.*Updated 1988-10-11.*Visible to all repositories",
+ "SECRET_TWO.*Updated 2020-12-04.*Visible to private repositories",
+ "SECRET_THREE.*Updated 1975-11-30.*Visible to 2 selected repositories",
+ },
+ },
+ {
+ name: "org not tty",
+ tty: false,
+ opts: &ListOptions{
+ OrgName: "UmbrellaCorporation",
+ },
+ wantOut: []string{
+ "SECRET_ONE\t1988-10-11\tALL",
+ "SECRET_TWO\t2020-12-04\tPRIVATE",
+ "SECRET_THREE\t1975-11-30\tSELECTED",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ reg := &httpmock.Registry{}
+
+ t0, _ := time.Parse("2006-01-02", "1988-10-11")
+ t1, _ := time.Parse("2006-01-02", "2020-12-04")
+ t2, _ := time.Parse("2006-01-02", "1975-11-30")
+ path := "repos/owner/repo/actions/secrets"
+ payload := secretsPayload{}
+ payload.Secrets = []*Secret{
+ {
+ Name: "SECRET_ONE",
+ UpdatedAt: t0,
+ },
+ {
+ Name: "SECRET_TWO",
+ UpdatedAt: t1,
+ },
+ {
+ Name: "SECRET_THREE",
+ UpdatedAt: t2,
+ },
+ }
+ if tt.opts.OrgName != "" {
+ payload.Secrets = []*Secret{
+ {
+ Name: "SECRET_ONE",
+ UpdatedAt: t0,
+ Visibility: shared.All,
+ },
+ {
+ Name: "SECRET_TWO",
+ UpdatedAt: t1,
+ Visibility: shared.Private,
+ },
+ {
+ Name: "SECRET_THREE",
+ UpdatedAt: t2,
+ Visibility: shared.Selected,
+ SelectedReposURL: fmt.Sprintf("https://api.github.com/orgs/%s/actions/secrets/SECRET_THREE/repositories", tt.opts.OrgName),
+ },
+ }
+ path = fmt.Sprintf("orgs/%s/actions/secrets", tt.opts.OrgName)
+
+ reg.Register(
+ httpmock.REST("GET", fmt.Sprintf("orgs/%s/actions/secrets/SECRET_THREE/repositories", tt.opts.OrgName)),
+ httpmock.JSONResponse(struct {
+ TotalCount int `json:"total_count"`
+ }{2}))
+ }
+
+ reg.Register(httpmock.REST("GET", path), httpmock.JSONResponse(payload))
+
+ io, _, stdout, _ := iostreams.Test()
+
+ io.SetStdoutTTY(tt.tty)
+
+ tt.opts.IO = io
+ tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
+ return ghrepo.FromFullName("owner/repo")
+ }
+ tt.opts.HttpClient = func() (*http.Client, error) {
+ return &http.Client{Transport: reg}, nil
+ }
+
+ err := listRun(tt.opts)
+ assert.NoError(t, err)
+
+ reg.Verify(t)
+
+ test.ExpectLines(t, stdout.String(), tt.wantOut...)
+ })
+ }
+}
diff --git a/pkg/cmd/secret/remove/remove.go b/pkg/cmd/secret/remove/remove.go
new file mode 100644
index 000000000..000b71ae4
--- /dev/null
+++ b/pkg/cmd/secret/remove/remove.go
@@ -0,0 +1,94 @@
+package remove
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/internal/ghinstance"
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/spf13/cobra"
+)
+
+type RemoveOptions struct {
+ HttpClient func() (*http.Client, error)
+ IO *iostreams.IOStreams
+ BaseRepo func() (ghrepo.Interface, error)
+
+ SecretName string
+ OrgName string
+}
+
+func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Command {
+ opts := &RemoveOptions{
+ IO: f.IOStreams,
+ HttpClient: f.HttpClient,
+ }
+
+ cmd := &cobra.Command{
+ Use: "remove ",
+ Short: "Remove an organization or repository secret",
+ Args: cobra.ExactArgs(1),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // support `-R, --repo` override
+ opts.BaseRepo = f.BaseRepo
+
+ opts.SecretName = args[0]
+
+ if runF != nil {
+ return runF(opts)
+ }
+
+ return removeRun(opts)
+ },
+ }
+ cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization")
+
+ return cmd
+}
+
+func removeRun(opts *RemoveOptions) error {
+ c, err := opts.HttpClient()
+ if err != nil {
+ return fmt.Errorf("could not create http client: %w", err)
+ }
+ client := api.NewClientFromHTTP(c)
+
+ orgName := opts.OrgName
+
+ var baseRepo ghrepo.Interface
+ if orgName == "" {
+ baseRepo, err = opts.BaseRepo()
+ if err != nil {
+ return fmt.Errorf("could not determine base repo: %w", err)
+ }
+ }
+
+ var path string
+ if orgName == "" {
+ path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName)
+ } else {
+ path = fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, opts.SecretName)
+ }
+
+ host := ghinstance.OverridableDefault()
+ err = client.REST(host, "DELETE", path, nil, nil)
+ if err != nil {
+ return fmt.Errorf("failed to delete secret %s: %w", opts.SecretName, err)
+ }
+
+ if opts.IO.IsStdoutTTY() {
+ cs := opts.IO.ColorScheme()
+ if orgName == "" {
+ fmt.Fprintf(opts.IO.Out,
+ "%s Removed secret %s from %s\n", cs.SuccessIcon(), opts.SecretName, ghrepo.FullName(baseRepo))
+ } else {
+ fmt.Fprintf(opts.IO.Out,
+ "%s Removed secret %s from %s\n", cs.SuccessIcon(), opts.SecretName, orgName)
+ }
+ }
+
+ return nil
+}
diff --git a/pkg/cmd/secret/remove/remove_test.go b/pkg/cmd/secret/remove/remove_test.go
new file mode 100644
index 000000000..efd1f660d
--- /dev/null
+++ b/pkg/cmd/secret/remove/remove_test.go
@@ -0,0 +1,157 @@
+package remove
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "testing"
+
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/pkg/httpmock"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/google/shlex"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewCmdRemove(t *testing.T) {
+ tests := []struct {
+ name string
+ cli string
+ wants RemoveOptions
+ wantsErr bool
+ }{
+ {
+ name: "no args",
+ wantsErr: true,
+ },
+ {
+ name: "repo",
+ cli: "cool",
+ wants: RemoveOptions{
+ SecretName: "cool",
+ },
+ },
+ {
+ name: "org",
+ cli: "cool --org anOrg",
+ wants: RemoveOptions{
+ SecretName: "cool",
+ OrgName: "anOrg",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ io, _, _, _ := iostreams.Test()
+ f := &cmdutil.Factory{
+ IOStreams: io,
+ }
+
+ argv, err := shlex.Split(tt.cli)
+ assert.NoError(t, err)
+
+ var gotOpts *RemoveOptions
+ cmd := NewCmdRemove(f, func(opts *RemoveOptions) error {
+ gotOpts = opts
+ return nil
+ })
+ 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.wants.SecretName, gotOpts.SecretName)
+ assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
+ })
+ }
+
+}
+
+func Test_removeRun_repo(t *testing.T) {
+ reg := &httpmock.Registry{}
+
+ reg.Register(
+ httpmock.REST("DELETE", "repos/owner/repo/actions/secrets/cool_secret"),
+ httpmock.StatusStringResponse(204, "No Content"))
+
+ io, _, _, _ := iostreams.Test()
+
+ opts := &RemoveOptions{
+ IO: io,
+ HttpClient: func() (*http.Client, error) {
+ return &http.Client{Transport: reg}, nil
+ },
+ BaseRepo: func() (ghrepo.Interface, error) {
+ return ghrepo.FromFullName("owner/repo")
+ },
+ SecretName: "cool_secret",
+ }
+
+ err := removeRun(opts)
+ assert.NoError(t, err)
+
+ reg.Verify(t)
+}
+
+func Test_removeRun_org(t *testing.T) {
+ tests := []struct {
+ name string
+ opts *RemoveOptions
+ }{
+ {
+ name: "repo",
+ opts: &RemoveOptions{},
+ },
+ {
+ name: "org",
+ opts: &RemoveOptions{
+ OrgName: "UmbrellaCorporation",
+ },
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ reg := &httpmock.Registry{}
+
+ orgName := tt.opts.OrgName
+
+ if orgName == "" {
+ reg.Register(
+ httpmock.REST("DELETE", "repos/owner/repo/actions/secrets/tVirus"),
+ httpmock.StatusStringResponse(204, "No Content"))
+ } else {
+ reg.Register(
+ httpmock.REST("DELETE", fmt.Sprintf("orgs/%s/actions/secrets/tVirus", orgName)),
+ httpmock.StatusStringResponse(204, "No Content"))
+ }
+
+ io, _, _, _ := iostreams.Test()
+
+ tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
+ return ghrepo.FromFullName("owner/repo")
+ }
+ tt.opts.HttpClient = func() (*http.Client, error) {
+ return &http.Client{Transport: reg}, nil
+ }
+ tt.opts.IO = io
+ tt.opts.SecretName = "tVirus"
+
+ err := removeRun(tt.opts)
+ assert.NoError(t, err)
+
+ reg.Verify(t)
+
+ })
+ }
+
+}
diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go
new file mode 100644
index 000000000..e8b82a41d
--- /dev/null
+++ b/pkg/cmd/secret/secret.go
@@ -0,0 +1,30 @@
+package secret
+
+import (
+ "github.com/MakeNowJust/heredoc"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/spf13/cobra"
+
+ cmdList "github.com/cli/cli/pkg/cmd/secret/list"
+ cmdRemove "github.com/cli/cli/pkg/cmd/secret/remove"
+ cmdSet "github.com/cli/cli/pkg/cmd/secret/set"
+)
+
+func NewCmdSecret(f *cmdutil.Factory) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "secret ",
+ Short: "Manage GitHub secrets",
+ Long: heredoc.Doc(`
+ Secrets can be set at the repository or organization level for use in GitHub Actions.
+ Run "gh help secret set" to learn how to get started.
+`),
+ }
+
+ cmdutil.EnableRepoOverride(cmd, f)
+
+ cmd.AddCommand(cmdList.NewCmdList(f, nil))
+ cmd.AddCommand(cmdSet.NewCmdSet(f, nil))
+ cmd.AddCommand(cmdRemove.NewCmdRemove(f, nil))
+
+ return cmd
+}
diff --git a/pkg/cmd/secret/set/http.go b/pkg/cmd/secret/set/http.go
new file mode 100644
index 000000000..b8d80974e
--- /dev/null
+++ b/pkg/cmd/secret/set/http.go
@@ -0,0 +1,141 @@
+package set
+
+import (
+ "bytes"
+ "encoding/base64"
+ "encoding/json"
+ "fmt"
+ "strings"
+
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/internal/ghinstance"
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmd/secret/shared"
+)
+
+type SecretPayload struct {
+ EncryptedValue string `json:"encrypted_value"`
+ Visibility string `json:"visibility,omitempty"`
+ Repositories []int `json:"selected_repository_ids,omitempty"`
+ KeyID string `json:"key_id"`
+}
+
+type PubKey struct {
+ Raw [32]byte
+ ID string `json:"key_id"`
+ Key string
+}
+
+func getPubKey(client *api.Client, host, path string) (*PubKey, error) {
+ pk := PubKey{}
+ err := client.REST(host, "GET", path, nil, &pk)
+ if err != nil {
+ return nil, err
+ }
+
+ if pk.Key == "" {
+ return nil, fmt.Errorf("failed to find public key at %s/%s", host, path)
+ }
+
+ decoded, err := base64.StdEncoding.DecodeString(pk.Key)
+ if err != nil {
+ return nil, fmt.Errorf("failed to decode public key: %w", err)
+ }
+
+ copy(pk.Raw[:], decoded[0:32])
+ return &pk, nil
+}
+
+func getOrgPublicKey(client *api.Client, orgName string) (*PubKey, error) {
+ host := ghinstance.OverridableDefault()
+ return getPubKey(client, host, fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName))
+}
+
+func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) {
+ return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets/public-key",
+ ghrepo.FullName(repo)))
+}
+
+func putSecret(client *api.Client, host, path string, payload SecretPayload) error {
+ payloadBytes, err := json.Marshal(payload)
+ if err != nil {
+ return fmt.Errorf("failed to serialize: %w", err)
+ }
+ requestBody := bytes.NewReader(payloadBytes)
+
+ return client.REST(host, "PUT", path, requestBody, nil)
+}
+
+func putOrgSecret(client *api.Client, pk *PubKey, opts SetOptions, eValue string) error {
+ secretName := opts.SecretName
+ orgName := opts.OrgName
+ visibility := opts.Visibility
+ host := ghinstance.OverridableDefault()
+
+ var repositoryIDs []int
+ var err error
+ if orgName != "" && visibility == shared.Selected {
+ repositoryIDs, err = mapRepoNameToID(client, host, orgName, opts.RepositoryNames)
+ if err != nil {
+ return fmt.Errorf("failed to look up IDs for repositories %v: %w", opts.RepositoryNames, err)
+ }
+ }
+
+ payload := SecretPayload{
+ EncryptedValue: eValue,
+ KeyID: pk.ID,
+ Repositories: repositoryIDs,
+ Visibility: visibility,
+ }
+ path := fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, secretName)
+
+ return putSecret(client, host, path, payload)
+}
+
+func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string) error {
+ payload := SecretPayload{
+ EncryptedValue: eValue,
+ KeyID: pk.ID,
+ }
+ path := fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(repo), secretName)
+ return putSecret(client, repo.RepoHost(), path, payload)
+}
+
+func mapRepoNameToID(client *api.Client, host, orgName string, repositoryNames []string) ([]int, error) {
+ queries := make([]string, 0, len(repositoryNames))
+ for _, repoName := range repositoryNames {
+ queries = append(queries, fmt.Sprintf(`
+ %s: repository(owner: %q, name :%q) {
+ databaseId
+ }
+ `, repoName, orgName, repoName))
+ }
+
+ query := fmt.Sprintf(`query MapRepositoryNames { %s }`, strings.Join(queries, ""))
+
+ graphqlResult := make(map[string]*struct {
+ DatabaseID int `json:"databaseId"`
+ })
+
+ err := client.GraphQL(host, query, nil, &graphqlResult)
+
+ gqlErr, isGqlErr := err.(*api.GraphQLErrorResponse)
+ if isGqlErr {
+ for _, ge := range gqlErr.Errors {
+ if ge.Type == "NOT_FOUND" {
+ return nil, fmt.Errorf("could not find %s/%s", orgName, ge.Path[0])
+ }
+ }
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to look up repositories: %w", err)
+ }
+
+ result := make([]int, 0, len(repositoryNames))
+
+ for _, repoName := range repositoryNames {
+ result = append(result, graphqlResult[repoName].DatabaseID)
+ }
+
+ return result, nil
+}
diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go
new file mode 100644
index 000000000..1a05d93cc
--- /dev/null
+++ b/pkg/cmd/secret/set/set.go
@@ -0,0 +1,209 @@
+package set
+
+import (
+ "encoding/base64"
+ "errors"
+ "fmt"
+ "io"
+ "io/ioutil"
+ "net/http"
+ "regexp"
+ "strings"
+
+ "github.com/MakeNowJust/heredoc"
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmd/secret/shared"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/spf13/cobra"
+ "golang.org/x/crypto/nacl/box"
+)
+
+type SetOptions struct {
+ HttpClient func() (*http.Client, error)
+ IO *iostreams.IOStreams
+ BaseRepo func() (ghrepo.Interface, error)
+
+ RandomOverride io.Reader
+
+ SecretName string
+ OrgName string
+ Body string
+ Visibility string
+ RepositoryNames []string
+}
+
+func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
+ opts := &SetOptions{
+ IO: f.IOStreams,
+ HttpClient: f.HttpClient,
+ }
+
+ cmd := &cobra.Command{
+ Use: "set ",
+ Short: "Create or update secrets",
+ Long: "Locally encrypt a new or updated secret at either the repository or organization level and send it to GitHub for storage.",
+ Example: heredoc.Doc(`
+ $ gh secret set FROM_FLAG -b"some literal value"
+ $ gh secret set FROM_ENV -b"${ENV_VALUE}"
+ $ gh secret set FROM_FILE < file.json
+ $ gh secret set ORG_SECRET -bval --org=anOrg --visibility=all
+ $ gh secret set ORG_SECRET -bval --org=anOrg --repos="repo1,repo2,repo3"
+`),
+ Args: func(cmd *cobra.Command, args []string) error {
+ if len(args) != 1 {
+ return &cmdutil.FlagError{Err: errors.New("must pass single secret name")}
+ }
+ if !cmd.Flags().Changed("body") && opts.IO.IsStdinTTY() {
+ return &cmdutil.FlagError{Err: errors.New("no --body specified but nothing on STIDN")}
+ }
+ return nil
+ },
+ RunE: func(cmd *cobra.Command, args []string) error {
+ // support `-R, --repo` override
+ opts.BaseRepo = f.BaseRepo
+
+ opts.SecretName = args[0]
+
+ err := validSecretName(opts.SecretName)
+ if err != nil {
+ return err
+ }
+
+ if cmd.Flags().Changed("visibility") {
+ if opts.OrgName == "" {
+ return &cmdutil.FlagError{Err: errors.New(
+ "--visibility not supported for repository secrets; did you mean to pass --org?")}
+ }
+
+ if opts.Visibility != shared.All && opts.Visibility != shared.Private && opts.Visibility != shared.Selected {
+ return &cmdutil.FlagError{Err: errors.New(
+ "--visibility must be one of `all`, `private`, or `selected`")}
+ }
+
+ if opts.Visibility != shared.Selected && cmd.Flags().Changed("repos") {
+ return &cmdutil.FlagError{Err: errors.New(
+ "--repos only supported when --visibility='selected'")}
+ }
+
+ if opts.Visibility == shared.Selected && !cmd.Flags().Changed("repos") {
+ return &cmdutil.FlagError{Err: errors.New(
+ "--repos flag required when --visibility='selected'")}
+ }
+ } else {
+ if cmd.Flags().Changed("repos") {
+ opts.Visibility = shared.Selected
+ }
+ }
+
+ if runF != nil {
+ return runF(opts)
+ }
+
+ return setRun(opts)
+ },
+ }
+ cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization")
+ cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`")
+ cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility")
+ cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "A value for the secret. Reads from STDIN if not specified.")
+
+ return cmd
+}
+
+func setRun(opts *SetOptions) error {
+ body, err := getBody(opts)
+ if err != nil {
+ return fmt.Errorf("did not understand secret body: %w", err)
+ }
+
+ c, err := opts.HttpClient()
+ if err != nil {
+ return fmt.Errorf("could not create http client: %w", err)
+ }
+ client := api.NewClientFromHTTP(c)
+
+ orgName := opts.OrgName
+
+ var baseRepo ghrepo.Interface
+ if orgName == "" {
+ baseRepo, err = opts.BaseRepo()
+ if err != nil {
+ return fmt.Errorf("could not determine base repo: %w", err)
+ }
+ }
+
+ var pk *PubKey
+ if orgName != "" {
+ pk, err = getOrgPublicKey(client, orgName)
+ } else {
+ pk, err = getRepoPubKey(client, baseRepo)
+ }
+ if err != nil {
+ return fmt.Errorf("failed to fetch public key: %w", err)
+ }
+
+ eBody, err := box.SealAnonymous(nil, body, &pk.Raw, opts.RandomOverride)
+ if err != nil {
+ return fmt.Errorf("failed to encrypt body: %w", err)
+ }
+
+ encoded := base64.StdEncoding.EncodeToString(eBody)
+
+ if orgName != "" {
+ err = putOrgSecret(client, pk, *opts, encoded)
+ } else {
+ err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded)
+ }
+ if err != nil {
+ return fmt.Errorf("failed to set secret: %w", err)
+ }
+
+ if opts.IO.IsStdoutTTY() {
+ cs := opts.IO.ColorScheme()
+
+ if orgName == "" {
+ fmt.Fprintf(opts.IO.Out, "%s Set secret %s for %s\n", cs.SuccessIcon(), opts.SecretName, ghrepo.FullName(baseRepo))
+ } else {
+ fmt.Fprintf(opts.IO.Out, "%s Set secret %s for %s\n", cs.SuccessIcon(), opts.SecretName, orgName)
+ }
+ }
+
+ return nil
+}
+
+func validSecretName(name string) error {
+ if name == "" {
+ return errors.New("secret name cannot be blank")
+ }
+
+ if strings.HasPrefix(name, "GITHUB_") {
+ return errors.New("secret name cannot begin with GITHUB_")
+ }
+
+ leadingNumber := regexp.MustCompile(`^[0-9]`)
+ if leadingNumber.MatchString(name) {
+ return errors.New("secret name cannot start with a number")
+ }
+
+ validChars := regexp.MustCompile(`^([0-9]|[a-z]|[A-Z]|_)+$`)
+ if !validChars.MatchString(name) {
+ return errors.New("secret name can only contain letters, numbers, and _")
+ }
+
+ return nil
+}
+
+func getBody(opts *SetOptions) ([]byte, error) {
+ if opts.Body == "" {
+ body, err := ioutil.ReadAll(opts.IO.In)
+ if err != nil {
+ return nil, fmt.Errorf("failed to read from STDIN: %w", err)
+ }
+
+ return body, nil
+ }
+
+ return []byte(opts.Body), nil
+}
diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go
new file mode 100644
index 000000000..4536205a1
--- /dev/null
+++ b/pkg/cmd/secret/set/set_test.go
@@ -0,0 +1,323 @@
+package set
+
+import (
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "testing"
+
+ "github.com/cli/cli/internal/ghrepo"
+ "github.com/cli/cli/pkg/cmd/secret/shared"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/pkg/httpmock"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/google/shlex"
+ "github.com/stretchr/testify/assert"
+)
+
+func TestNewCmdSet(t *testing.T) {
+ tests := []struct {
+ name string
+ cli string
+ wants SetOptions
+ stdinTTY bool
+ wantsErr bool
+ }{
+ {
+ name: "invalid visibility",
+ cli: "cool_secret --org coolOrg -v'mistyVeil'",
+ wantsErr: true,
+ },
+ {
+ name: "invalid visibility",
+ cli: "cool_secret --org coolOrg -v'selected'",
+ wantsErr: true,
+ },
+ {
+ name: "repos with wrong vis",
+ cli: "cool_secret --org coolOrg -v'private' -rcoolRepo",
+ wantsErr: true,
+ },
+ {
+ name: "no name",
+ cli: "",
+ wantsErr: true,
+ },
+ {
+ name: "multiple names",
+ cli: "cool_secret good_secret",
+ wantsErr: true,
+ },
+ {
+ name: "no body, stdin is terminal",
+ cli: "cool_secret",
+ stdinTTY: true,
+ wantsErr: true,
+ },
+ {
+ name: "visibility without org",
+ cli: "cool_secret -vall",
+ wantsErr: true,
+ },
+ {
+ name: "repos without vis",
+ cli: "cool_secret -bs --org coolOrg -rcoolRepo",
+ wants: SetOptions{
+ SecretName: "cool_secret",
+ Visibility: shared.Selected,
+ RepositoryNames: []string{"coolRepo"},
+ Body: "s",
+ OrgName: "coolOrg",
+ },
+ },
+ {
+ name: "org with selected repo",
+ cli: "-ocoolOrg -bs -vselected -rcoolRepo cool_secret",
+ wants: SetOptions{
+ SecretName: "cool_secret",
+ Visibility: shared.Selected,
+ RepositoryNames: []string{"coolRepo"},
+ Body: "s",
+ OrgName: "coolOrg",
+ },
+ },
+ {
+ name: "org with selected repos",
+ cli: `--org=coolOrg -bs -vselected -r="coolRepo,radRepo,goodRepo" cool_secret`,
+ wants: SetOptions{
+ SecretName: "cool_secret",
+ Visibility: shared.Selected,
+ RepositoryNames: []string{"coolRepo", "goodRepo", "radRepo"},
+ Body: "s",
+ OrgName: "coolOrg",
+ },
+ },
+ {
+ name: "repo",
+ cli: `cool_secret -b"a secret"`,
+ wants: SetOptions{
+ SecretName: "cool_secret",
+ Visibility: shared.Private,
+ Body: "a secret",
+ OrgName: "",
+ },
+ },
+ {
+ name: "vis all",
+ cli: `cool_secret --org coolOrg -b"cool" -vall`,
+ wants: SetOptions{
+ SecretName: "cool_secret",
+ Visibility: shared.All,
+ Body: "cool",
+ OrgName: "coolOrg",
+ },
+ },
+ {
+ name: "bad name prefix",
+ cli: `GITHUB_SECRET -b"cool"`,
+ wantsErr: true,
+ },
+ {
+ name: "leading numbers in name",
+ cli: `123_SECRET -b"cool"`,
+ wantsErr: true,
+ },
+ {
+ name: "invalid characters in name",
+ cli: `BAD-SECRET -b"cool"`,
+ wantsErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ io, _, _, _ := iostreams.Test()
+ f := &cmdutil.Factory{
+ IOStreams: io,
+ }
+
+ io.SetStdinTTY(tt.stdinTTY)
+
+ argv, err := shlex.Split(tt.cli)
+ assert.NoError(t, err)
+
+ var gotOpts *SetOptions
+ cmd := NewCmdSet(f, func(opts *SetOptions) error {
+ gotOpts = opts
+ return nil
+ })
+ 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.wants.SecretName, gotOpts.SecretName)
+ assert.Equal(t, tt.wants.Body, gotOpts.Body)
+ assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility)
+ assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
+ assert.ElementsMatch(t, tt.wants.RepositoryNames, gotOpts.RepositoryNames)
+ })
+ }
+}
+
+func Test_setRun_repo(t *testing.T) {
+ reg := &httpmock.Registry{}
+
+ reg.Register(httpmock.REST("GET", "repos/owner/repo/actions/secrets/public-key"),
+ httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="}))
+
+ reg.Register(httpmock.REST("PUT", "repos/owner/repo/actions/secrets/cool_secret"), httpmock.StatusStringResponse(201, `{}`))
+
+ io, _, _, _ := iostreams.Test()
+
+ opts := &SetOptions{
+ HttpClient: func() (*http.Client, error) {
+ return &http.Client{Transport: reg}, nil
+ },
+ BaseRepo: func() (ghrepo.Interface, error) {
+ return ghrepo.FromFullName("owner/repo")
+ },
+ IO: io,
+ SecretName: "cool_secret",
+ Body: "a secret",
+ // Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7
+ RandomOverride: bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}),
+ }
+
+ err := setRun(opts)
+ assert.NoError(t, err)
+
+ reg.Verify(t)
+
+ data, err := ioutil.ReadAll(reg.Requests[1].Body)
+ assert.NoError(t, err)
+ var payload SecretPayload
+ err = json.Unmarshal(data, &payload)
+ assert.NoError(t, err)
+ assert.Equal(t, payload.KeyID, "123")
+ assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=")
+}
+
+func Test_setRun_org(t *testing.T) {
+ tests := []struct {
+ name string
+ opts *SetOptions
+ wantVisibility shared.Visibility
+ wantRepositories []int
+ }{
+ {
+ name: "all vis",
+ opts: &SetOptions{
+ OrgName: "UmbrellaCorporation",
+ Visibility: shared.All,
+ },
+ },
+ {
+ name: "selected visibility",
+ opts: &SetOptions{
+ OrgName: "UmbrellaCorporation",
+ Visibility: shared.Selected,
+ RepositoryNames: []string{"birkin", "wesker"},
+ },
+ wantRepositories: []int{1, 2},
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ reg := &httpmock.Registry{}
+
+ orgName := tt.opts.OrgName
+
+ reg.Register(httpmock.REST("GET",
+ fmt.Sprintf("orgs/%s/actions/secrets/public-key", orgName)),
+ httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="}))
+
+ reg.Register(httpmock.REST("PUT",
+ fmt.Sprintf("orgs/%s/actions/secrets/cool_secret", orgName)),
+ httpmock.StatusStringResponse(201, `{}`))
+
+ if len(tt.opts.RepositoryNames) > 0 {
+ reg.Register(httpmock.GraphQL(`query MapRepositoryNames\b`),
+ httpmock.StringResponse(`{"data":{"birkin":{"databaseId":1},"wesker":{"databaseId":2}}}`))
+ }
+
+ io, _, _, _ := iostreams.Test()
+
+ tt.opts.BaseRepo = func() (ghrepo.Interface, error) {
+ return ghrepo.FromFullName("owner/repo")
+ }
+ tt.opts.HttpClient = func() (*http.Client, error) {
+ return &http.Client{Transport: reg}, nil
+ }
+ tt.opts.IO = io
+ tt.opts.SecretName = "cool_secret"
+ tt.opts.Body = "a secret"
+ // Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7
+ tt.opts.RandomOverride = bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5})
+
+ err := setRun(tt.opts)
+ assert.NoError(t, err)
+
+ reg.Verify(t)
+
+ data, err := ioutil.ReadAll(reg.Requests[len(reg.Requests)-1].Body)
+ assert.NoError(t, err)
+ var payload SecretPayload
+ err = json.Unmarshal(data, &payload)
+ assert.NoError(t, err)
+ assert.Equal(t, payload.KeyID, "123")
+ assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=")
+ assert.Equal(t, payload.Visibility, tt.opts.Visibility)
+ assert.ElementsMatch(t, payload.Repositories, tt.wantRepositories)
+ })
+ }
+}
+
+func Test_getBody(t *testing.T) {
+ tests := []struct {
+ name string
+ bodyArg string
+ want string
+ stdin string
+ }{
+ {
+ name: "literal value",
+ bodyArg: "a secret",
+ want: "a secret",
+ },
+ {
+ name: "from stdin",
+ want: "a secret",
+ stdin: "a secret",
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ io, stdin, _, _ := iostreams.Test()
+
+ io.SetStdinTTY(false)
+
+ _, err := stdin.WriteString(tt.stdin)
+ assert.NoError(t, err)
+
+ body, err := getBody(&SetOptions{
+ Body: tt.bodyArg,
+ IO: io,
+ })
+ assert.NoError(t, err)
+
+ assert.Equal(t, string(body), tt.want)
+ })
+ }
+}
diff --git a/pkg/cmd/secret/shared/shared.go b/pkg/cmd/secret/shared/shared.go
new file mode 100644
index 000000000..4f58dd971
--- /dev/null
+++ b/pkg/cmd/secret/shared/shared.go
@@ -0,0 +1,9 @@
+package shared
+
+type Visibility string
+
+const (
+ All = "all"
+ Private = "private"
+ Selected = "selected"
+)
diff --git a/pkg/cmd/ssh-key/list/http.go b/pkg/cmd/ssh-key/list/http.go
new file mode 100644
index 000000000..70a8d0578
--- /dev/null
+++ b/pkg/cmd/ssh-key/list/http.go
@@ -0,0 +1,58 @@
+package list
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io/ioutil"
+ "net/http"
+ "time"
+
+ "github.com/cli/cli/api"
+ "github.com/cli/cli/internal/ghinstance"
+)
+
+var scopesError = errors.New("insufficient OAuth scopes")
+
+type sshKey struct {
+ Key string
+ Title string
+ CreatedAt time.Time `json:"created_at"`
+}
+
+func userKeys(httpClient *http.Client, userHandle string) ([]sshKey, error) {
+ resource := "user/keys"
+ if userHandle != "" {
+ resource = fmt.Sprintf("users/%s/keys", userHandle)
+ }
+ url := fmt.Sprintf("%s%s?per_page=%d", ghinstance.RESTPrefix(ghinstance.OverridableDefault()), resource, 100)
+ req, err := http.NewRequest("GET", url, nil)
+ if err != nil {
+ return nil, err
+ }
+
+ resp, err := httpClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode == 404 {
+ return nil, scopesError
+ } else if resp.StatusCode > 299 {
+ return nil, api.HandleHTTPError(resp)
+ }
+
+ b, err := ioutil.ReadAll(resp.Body)
+ if err != nil {
+ return nil, err
+ }
+
+ var keys []sshKey
+ err = json.Unmarshal(b, &keys)
+ if err != nil {
+ return nil, err
+ }
+
+ return keys, nil
+}
diff --git a/pkg/cmd/ssh-key/list/list.go b/pkg/cmd/ssh-key/list/list.go
new file mode 100644
index 000000000..93a9f5470
--- /dev/null
+++ b/pkg/cmd/ssh-key/list/list.go
@@ -0,0 +1,96 @@
+package list
+
+import (
+ "errors"
+ "fmt"
+ "net/http"
+ "time"
+
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/cli/cli/pkg/iostreams"
+ "github.com/cli/cli/utils"
+ "github.com/spf13/cobra"
+)
+
+// ListOptions struct for list command
+type ListOptions struct {
+ IO *iostreams.IOStreams
+ HTTPClient func() (*http.Client, error)
+}
+
+// NewCmdList creates a command for list all SSH Keys
+func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
+ opts := &ListOptions{
+ HTTPClient: f.HttpClient,
+ IO: f.IOStreams,
+ }
+
+ cmd := &cobra.Command{
+ Use: "list",
+ Short: "Lists SSH keys in a GitHub account",
+ Args: cobra.ExactArgs(0),
+ RunE: func(cmd *cobra.Command, args []string) error {
+ if runF != nil {
+ return runF(opts)
+ }
+ return listRun(opts)
+ },
+ }
+
+ return cmd
+}
+
+func listRun(opts *ListOptions) error {
+ apiClient, err := opts.HTTPClient()
+ if err != nil {
+ return err
+ }
+
+ sshKeys, err := userKeys(apiClient, "")
+ if err != nil {
+ if errors.Is(err, scopesError) {
+ cs := opts.IO.ColorScheme()
+ fmt.Fprint(opts.IO.ErrOut, "Error: insufficient OAuth scopes to list SSH keys\n")
+ fmt.Fprintf(opts.IO.ErrOut, "Run the following to grant scopes: %s\n", cs.Bold("gh auth refresh -s read:public_key"))
+ return cmdutil.SilentError
+ }
+ return err
+ }
+
+ if len(sshKeys) == 0 {
+ fmt.Fprintln(opts.IO.ErrOut, "No SSH keys present in GitHub account.")
+ return cmdutil.SilentError
+ }
+
+ t := utils.NewTablePrinter(opts.IO)
+ cs := opts.IO.ColorScheme()
+ now := time.Now()
+
+ for _, sshKey := range sshKeys {
+ t.AddField(sshKey.Title, nil, nil)
+ t.AddField(sshKey.Key, truncateMiddle, nil)
+
+ createdAt := sshKey.CreatedAt.Format(time.RFC3339)
+ if t.IsTTY() {
+ createdAt = utils.FuzzyAgoAbbr(now, sshKey.CreatedAt)
+ }
+ t.AddField(createdAt, nil, cs.Gray)
+ t.EndRow()
+ }
+
+ return t.Render()
+}
+
+func truncateMiddle(maxWidth int, t string) string {
+ if len(t) <= maxWidth {
+ return t
+ }
+
+ ellipsis := "..."
+ if maxWidth < len(ellipsis)+2 {
+ return t[0:maxWidth]
+ }
+
+ halfWidth := (maxWidth - len(ellipsis)) / 2
+ return t[0:halfWidth] + ellipsis + t[len(t)-halfWidth:]
+}
diff --git a/pkg/cmd/ssh-key/list/list_test.go b/pkg/cmd/ssh-key/list/list_test.go
new file mode 100644
index 000000000..9dd261d9d
--- /dev/null
+++ b/pkg/cmd/ssh-key/list/list_test.go
@@ -0,0 +1,131 @@
+package list
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+ "time"
+
+ "github.com/MakeNowJust/heredoc"
+ "github.com/cli/cli/pkg/httpmock"
+ "github.com/cli/cli/pkg/iostreams"
+)
+
+func TestListRun(t *testing.T) {
+ tests := []struct {
+ name string
+ opts ListOptions
+ isTTY bool
+ wantStdout string
+ wantStderr string
+ wantErr bool
+ }{
+ {
+ name: "list tty",
+ opts: ListOptions{
+ HTTPClient: func() (*http.Client, error) {
+ createdAt := time.Now().Add(time.Duration(-24) * time.Hour)
+ reg := &httpmock.Registry{}
+ reg.Register(
+ httpmock.REST("GET", "user/keys"),
+ httpmock.StringResponse(fmt.Sprintf(`[
+ {
+ "id": 1234,
+ "key": "ssh-rsa AAAABbBB123",
+ "title": "Mac",
+ "created_at": "%[1]s"
+ },
+ {
+ "id": 5678,
+ "key": "ssh-rsa EEEEEEEK247",
+ "title": "hubot@Windows",
+ "created_at": "%[1]s"
+ }
+ ]`, createdAt.Format(time.RFC3339))),
+ )
+ return &http.Client{Transport: reg}, nil
+ },
+ },
+ isTTY: true,
+ wantStdout: heredoc.Doc(`
+ Mac ssh-rsa AAAABbBB123 1d
+ hubot@Windows ssh-rsa EEEEEEEK247 1d
+ `),
+ wantStderr: "",
+ },
+ {
+ name: "list non-tty",
+ opts: ListOptions{
+ HTTPClient: func() (*http.Client, error) {
+ createdAt, _ := time.Parse(time.RFC3339, "2020-08-31T15:44:24+02:00")
+ reg := &httpmock.Registry{}
+ reg.Register(
+ httpmock.REST("GET", "user/keys"),
+ httpmock.StringResponse(fmt.Sprintf(`[
+ {
+ "id": 1234,
+ "key": "ssh-rsa AAAABbBB123",
+ "title": "Mac",
+ "created_at": "%[1]s"
+ },
+ {
+ "id": 5678,
+ "key": "ssh-rsa EEEEEEEK247",
+ "title": "hubot@Windows",
+ "created_at": "%[1]s"
+ }
+ ]`, createdAt.Format(time.RFC3339))),
+ )
+ return &http.Client{Transport: reg}, nil
+ },
+ },
+ isTTY: false,
+ wantStdout: heredoc.Doc(`
+ Mac ssh-rsa AAAABbBB123 2020-08-31T15:44:24+02:00
+ hubot@Windows ssh-rsa EEEEEEEK247 2020-08-31T15:44:24+02:00
+ `),
+ wantStderr: "",
+ },
+ {
+ name: "no keys",
+ opts: ListOptions{
+ HTTPClient: func() (*http.Client, error) {
+ reg := &httpmock.Registry{}
+ reg.Register(
+ httpmock.REST("GET", "user/keys"),
+ httpmock.StringResponse(`[]`),
+ )
+ return &http.Client{Transport: reg}, nil
+ },
+ },
+ wantStdout: "",
+ wantStderr: "No SSH keys present in GitHub account.\n",
+ wantErr: true,
+ },
+ }
+
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ io, _, stdout, stderr := iostreams.Test()
+ io.SetStdoutTTY(tt.isTTY)
+ io.SetStdinTTY(tt.isTTY)
+ io.SetStderrTTY(tt.isTTY)
+
+ opts := tt.opts
+ opts.IO = io
+
+ err := listRun(&opts)
+ if (err != nil) != tt.wantErr {
+ t.Errorf("linRun() return error: %v", err)
+ return
+ }
+
+ if stdout.String() != tt.wantStdout {
+ t.Errorf("wants stdout %q, got %q", tt.wantStdout, stdout.String())
+ }
+ if stderr.String() != tt.wantStderr {
+ t.Errorf("wants stderr %q, got %q", tt.wantStderr, stderr.String())
+ }
+ })
+ }
+}
diff --git a/pkg/cmd/ssh-key/ssh-key.go b/pkg/cmd/ssh-key/ssh-key.go
new file mode 100644
index 000000000..00ffbf689
--- /dev/null
+++ b/pkg/cmd/ssh-key/ssh-key.go
@@ -0,0 +1,22 @@
+package key
+
+import (
+ cmdList "github.com/cli/cli/pkg/cmd/ssh-key/list"
+ "github.com/cli/cli/pkg/cmdutil"
+ "github.com/spf13/cobra"
+)
+
+// NewCmdSSHKey creates a command for manage SSH Keys
+func NewCmdSSHKey(f *cmdutil.Factory) *cobra.Command {
+ cmd := &cobra.Command{
+ Use: "ssh-key ",
+ Short: "Manage SSH keys",
+ Long: "Work with GitHub SSH keys",
+
+ Hidden: true,
+ }
+
+ cmd.AddCommand(cmdList.NewCmdList(f, nil))
+
+ return cmd
+}
diff --git a/pkg/cmd/version/version.go b/pkg/cmd/version/version.go
index 040a3132e..d473ab0b2 100644
--- a/pkg/cmd/version/version.go
+++ b/pkg/cmd/version/version.go
@@ -26,11 +26,12 @@ func NewCmdVersion(f *cmdutil.Factory, version, buildDate string) *cobra.Command
func Format(version, buildDate string) string {
version = strings.TrimPrefix(version, "v")
+ var dateStr string
if buildDate != "" {
- version = fmt.Sprintf("%s (%s)", version, buildDate)
+ dateStr = fmt.Sprintf(" (%s)", buildDate)
}
- return fmt.Sprintf("gh version %s\n%s\n", version, changelogURL(version))
+ return fmt.Sprintf("gh version %s%s\n%s\n", version, dateStr, changelogURL(version))
}
func changelogURL(version string) string {
diff --git a/pkg/cmd/version/version_test.go b/pkg/cmd/version/version_test.go
index 9a1d49db3..be1065dfd 100644
--- a/pkg/cmd/version/version_test.go
+++ b/pkg/cmd/version/version_test.go
@@ -4,6 +4,13 @@ import (
"testing"
)
+func TestFormat(t *testing.T) {
+ expects := "gh version 1.4.0 (2020-12-15)\nhttps://github.com/cli/cli/releases/tag/v1.4.0\n"
+ if got := Format("1.4.0", "2020-12-15"); got != expects {
+ t.Errorf("Format() = %q, wants %q", got, expects)
+ }
+}
+
func TestChangelogURL(t *testing.T) {
tag := "0.3.2"
url := "https://github.com/cli/cli/releases/tag/v0.3.2"
diff --git a/pkg/cmdutil/args.go b/pkg/cmdutil/args.go
index c203fb691..65f3ade51 100644
--- a/pkg/cmdutil/args.go
+++ b/pkg/cmdutil/args.go
@@ -8,6 +8,19 @@ import (
"github.com/spf13/pflag"
)
+func MinimumArgs(n int, msg string) cobra.PositionalArgs {
+ if msg == "" {
+ return cobra.MinimumNArgs(1)
+ }
+
+ return func(cmd *cobra.Command, args []string) error {
+ if len(args) < n {
+ return &FlagError{Err: errors.New(msg)}
+ }
+ return nil
+ }
+}
+
func NoArgsQuoteReminder(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return nil
diff --git a/pkg/cmdutil/args_test.go b/pkg/cmdutil/args_test.go
new file mode 100644
index 000000000..db0b96510
--- /dev/null
+++ b/pkg/cmdutil/args_test.go
@@ -0,0 +1,50 @@
+package cmdutil
+
+import "testing"
+
+func TestMinimumArgs(t *testing.T) {
+ tests := []struct {
+ N int
+ Args []string
+ }{
+ {
+ N: 1,
+ Args: []string{"v1.2.3"},
+ },
+ {
+ N: 2,
+ Args: []string{"v1.2.3", "cli/cli"},
+ },
+ }
+
+ for _, test := range tests {
+ if got := MinimumArgs(test.N, "")(nil, test.Args); got != nil {
+ t.Errorf("Got: %v, Want: (nil)", got)
+ }
+ }
+}
+
+func TestMinimumNs_with_error(t *testing.T) {
+ tests := []struct {
+ N int
+ CustomMessage string
+ WantMessage string
+ }{
+ {
+ N: 1,
+ CustomMessage: "A custom msg",
+ WantMessage: "A custom msg",
+ },
+ {
+ N: 1,
+ CustomMessage: "",
+ WantMessage: "requires at least 1 arg(s), only received 0",
+ },
+ }
+
+ for _, test := range tests {
+ if got := MinimumArgs(test.N, test.CustomMessage)(nil, nil); got.Error() != test.WantMessage {
+ t.Errorf("Got: %v, Want: %v", got, test.WantMessage)
+ }
+ }
+}
diff --git a/pkg/cmdutil/auth_check.go b/pkg/cmdutil/auth_check.go
index 5d40d0143..10df9fade 100644
--- a/pkg/cmdutil/auth_check.go
+++ b/pkg/cmdutil/auth_check.go
@@ -17,6 +17,10 @@ func DisableAuthCheck(cmd *cobra.Command) {
}
func CheckAuth(cfg config.Config) bool {
+ if config.AuthTokenProvidedFromEnv() {
+ return true
+ }
+
hosts, err := cfg.Hosts()
if err != nil {
return false
diff --git a/pkg/cmdutil/auth_check_test.go b/pkg/cmdutil/auth_check_test.go
index 22b8ff5d4..2798750f0 100644
--- a/pkg/cmdutil/auth_check_test.go
+++ b/pkg/cmdutil/auth_check_test.go
@@ -1,6 +1,7 @@
package cmdutil
import (
+ "os"
"testing"
"github.com/cli/cli/internal/config"
@@ -8,21 +9,34 @@ import (
)
func Test_CheckAuth(t *testing.T) {
+ orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN")
+ t.Cleanup(func() {
+ os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN)
+ })
+
tests := []struct {
name string
cfg func(config.Config)
+ envToken bool
expected bool
}{
{
name: "no hosts",
cfg: func(c config.Config) {},
+ envToken: false,
expected: false,
},
+ {name: "no hosts, env auth token",
+ cfg: func(c config.Config) {},
+ envToken: true,
+ expected: true,
+ },
{
name: "host, no token",
cfg: func(c config.Config) {
_ = c.Set("github.com", "oauth_token", "")
},
+ envToken: false,
expected: false,
},
{
@@ -30,12 +44,19 @@ func Test_CheckAuth(t *testing.T) {
cfg: func(c config.Config) {
_ = c.Set("github.com", "oauth_token", "a token")
},
+ envToken: false,
expected: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
+ if tt.envToken {
+ os.Setenv("GITHUB_TOKEN", "TOKEN")
+ } else {
+ os.Setenv("GITHUB_TOKEN", "")
+ }
+
cfg := config.NewBlankConfig()
tt.cfg(cfg)
result := CheckAuth(cfg)
diff --git a/pkg/githubtemplate/github_template.go b/pkg/githubtemplate/github_template.go
index cd54ae210..4ae0ff4b1 100644
--- a/pkg/githubtemplate/github_template.go
+++ b/pkg/githubtemplate/github_template.go
@@ -53,7 +53,7 @@ mainLoop:
}
// FindLegacy returns the file path of the default(legacy) template
-func FindLegacy(rootDir string, name string) *string {
+func FindLegacy(rootDir string, name string) string {
namePattern := regexp.MustCompile(fmt.Sprintf(`(?i)^%s(\.|$)`, strings.ReplaceAll(name, "_", "[_-]")))
// https://help.github.com/en/github/building-a-strong-community/creating-a-pull-request-template-for-your-repository
@@ -71,12 +71,11 @@ func FindLegacy(rootDir string, name string) *string {
// detect a single template file
for _, file := range files {
if namePattern.MatchString(file.Name()) && !file.IsDir() {
- result := path.Join(dir, file.Name())
- return &result
+ return path.Join(dir, file.Name())
}
}
}
- return nil
+ return ""
}
// ExtractName returns the name of the template from YAML front-matter
diff --git a/pkg/githubtemplate/github_template_test.go b/pkg/githubtemplate/github_template_test.go
index d1c73fec7..c9f42f552 100644
--- a/pkg/githubtemplate/github_template_test.go
+++ b/pkg/githubtemplate/github_template_test.go
@@ -250,10 +250,10 @@ func TestFindLegacy(t *testing.T) {
}
got := FindLegacy(tt.args.rootDir, tt.args.name)
- if got == nil {
+ if got == "" {
t.Errorf("FindLegacy() = nil, want %v", tt.want)
- } else if *got != tt.want {
- t.Errorf("FindLegacy() = %v, want %v", *got, tt.want)
+ } else if got != tt.want {
+ t.Errorf("FindLegacy() = %v, want %v", got, tt.want)
}
})
os.RemoveAll(tmpdir)
diff --git a/pkg/httpmock/legacy.go b/pkg/httpmock/legacy.go
index 9b5d5afef..071876cc1 100644
--- a/pkg/httpmock/legacy.go
+++ b/pkg/httpmock/legacy.go
@@ -2,37 +2,12 @@ package httpmock
import (
"fmt"
- "io"
"net/http"
"os"
- "path"
- "strings"
)
// TODO: clean up methods in this file when there are no more callers
-func (r *Registry) StubResponse(status int, body io.Reader) {
- r.Register(MatchAny, func(req *http.Request) (*http.Response, error) {
- return httpResponse(status, req, body), nil
- })
-}
-
-func (r *Registry) StubWithFixture(status int, fixtureFileName string) func() {
- fixturePath := path.Join("../test/fixtures/", fixtureFileName)
- fixtureFile, err := os.Open(fixturePath)
- r.Register(MatchAny, func(req *http.Request) (*http.Response, error) {
- if err != nil {
- return nil, err
- }
- return httpResponse(200, req, fixtureFile), nil
- })
- return func() {
- if err == nil {
- fixtureFile.Close()
- }
- }
-}
-
func (r *Registry) StubWithFixturePath(status int, fixturePath string) func() {
fixtureFile, err := os.Open(fixturePath)
r.Register(MatchAny, func(req *http.Request) (*http.Response, error) {
@@ -72,14 +47,6 @@ func (r *Registry) StubRepoResponseWithPermission(owner, repo, permission string
r.Register(GraphQL(`query RepositoryNetwork\b`), StringResponse(RepoNetworkStubResponse(owner, repo, "master", permission)))
}
-func (r *Registry) StubRepoResponseWithDefaultBranch(owner, repo, defaultBranch string) {
- r.Register(GraphQL(`query RepositoryNetwork\b`), StringResponse(RepoNetworkStubResponse(owner, repo, defaultBranch, "WRITE")))
-}
-
-func (r *Registry) StubForkedRepoResponse(ownRepo, parentRepo string) {
- r.Register(GraphQL(`query RepositoryNetwork\b`), StringResponse(RepoNetworkStubForkResponse(ownRepo, parentRepo)))
-}
-
func RepoNetworkStubResponse(owner, repo, defaultBranch, permission string) string {
return fmt.Sprintf(`
{ "data": { "repo_000": {
@@ -93,28 +60,3 @@ func RepoNetworkStubResponse(owner, repo, defaultBranch, permission string) stri
} } }
`, repo, owner, defaultBranch, permission)
}
-
-func RepoNetworkStubForkResponse(forkFullName, parentFullName string) string {
- forkRepo := strings.SplitN(forkFullName, "/", 2)
- parentRepo := strings.SplitN(parentFullName, "/", 2)
- return fmt.Sprintf(`
- { "data": { "repo_000": {
- "id": "REPOID2",
- "name": "%s",
- "owner": {"login": "%s"},
- "defaultBranchRef": {
- "name": "master"
- },
- "viewerPermission": "ADMIN",
- "parent": {
- "id": "REPOID1",
- "name": "%s",
- "owner": {"login": "%s"},
- "defaultBranchRef": {
- "name": "master"
- },
- "viewerPermission": "READ"
- }
- } } }
- `, forkRepo[1], forkRepo[0], parentRepo[1], parentRepo[0])
-}
diff --git a/pkg/iostreams/color.go b/pkg/iostreams/color.go
index 2efe93c8b..972caab3d 100644
--- a/pkg/iostreams/color.go
+++ b/pkg/iostreams/color.go
@@ -9,14 +9,15 @@ import (
)
var (
- magenta = ansi.ColorFunc("magenta")
- cyan = ansi.ColorFunc("cyan")
- red = ansi.ColorFunc("red")
- yellow = ansi.ColorFunc("yellow")
- blue = ansi.ColorFunc("blue")
- green = ansi.ColorFunc("green")
- gray = ansi.ColorFunc("black+h")
- bold = ansi.ColorFunc("default+b")
+ magenta = ansi.ColorFunc("magenta")
+ cyan = ansi.ColorFunc("cyan")
+ red = ansi.ColorFunc("red")
+ yellow = ansi.ColorFunc("yellow")
+ blue = ansi.ColorFunc("blue")
+ green = ansi.ColorFunc("green")
+ gray = ansi.ColorFunc("black+h")
+ bold = ansi.ColorFunc("default+b")
+ cyanBold = ansi.ColorFunc("cyan+b")
gray256 = func(t string) string {
return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, t)
@@ -107,6 +108,13 @@ func (c *ColorScheme) Cyan(t string) string {
return cyan(t)
}
+func (c *ColorScheme) CyanBold(t string) string {
+ if !c.enabled {
+ return t
+ }
+ return cyanBold(t)
+}
+
func (c *ColorScheme) Blue(t string) string {
if !c.enabled {
return t
@@ -121,3 +129,36 @@ func (c *ColorScheme) SuccessIcon() string {
func (c *ColorScheme) WarningIcon() string {
return c.Yellow("!")
}
+
+func (c *ColorScheme) FailureIcon() string {
+ return c.Red("X")
+}
+
+func (c *ColorScheme) ColorFromString(s string) func(string) string {
+ s = strings.ToLower(s)
+ var fn func(string) string
+ switch s {
+ case "bold":
+ fn = c.Bold
+ case "red":
+ fn = c.Red
+ case "yellow":
+ fn = c.Yellow
+ case "green":
+ fn = c.Green
+ case "gray":
+ fn = c.Gray
+ case "magenta":
+ fn = c.Magenta
+ case "cyan":
+ fn = c.Cyan
+ case "blue":
+ fn = c.Blue
+ default:
+ fn = func(s string) string {
+ return s
+ }
+ }
+
+ return fn
+}
diff --git a/pkg/iostreams/iostreams.go b/pkg/iostreams/iostreams.go
index 19bdd546a..5df44098d 100644
--- a/pkg/iostreams/iostreams.go
+++ b/pkg/iostreams/iostreams.go
@@ -12,6 +12,7 @@ import (
"time"
"github.com/briandowns/spinner"
+ "github.com/cli/safeexec"
"github.com/google/shlex"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
@@ -44,6 +45,8 @@ type IOStreams struct {
pagerProcess *os.Process
neverPrompt bool
+
+ TempFileOverride *os.File
}
func (s *IOStreams) ColorEnabled() bool {
@@ -160,7 +163,11 @@ func (s *IOStreams) StartPager() error {
pagerEnv = append(pagerEnv, "LV=-c")
}
- pagerCmd := exec.Command(pagerArgs[0], pagerArgs[1:]...)
+ pagerExe, err := safeexec.LookPath(pagerArgs[0])
+ if err != nil {
+ return err
+ }
+ pagerCmd := exec.Command(pagerExe, pagerArgs[1:]...)
pagerCmd.Env = pagerEnv
pagerCmd.Stdout = s.Out
pagerCmd.Stderr = s.ErrOut
@@ -228,7 +235,11 @@ func (s *IOStreams) TerminalWidth() int {
}
if isCygwinTerminal(out) {
- tputCmd := exec.Command("tput", "cols")
+ tputExe, err := safeexec.LookPath("tput")
+ if err != nil {
+ return defaultWidth
+ }
+ tputCmd := exec.Command(tputExe, "cols")
tputCmd.Stdin = os.Stdin
if out, err := tputCmd.Output(); err == nil {
if w, err := strconv.Atoi(strings.TrimSpace(string(out))); err == nil {
@@ -244,6 +255,28 @@ func (s *IOStreams) ColorScheme() *ColorScheme {
return NewColorScheme(s.ColorEnabled(), s.ColorSupport256())
}
+func (s *IOStreams) ReadUserFile(fn string) ([]byte, error) {
+ var r io.ReadCloser
+ if fn == "-" {
+ r = s.In
+ } else {
+ var err error
+ r, err = os.Open(fn)
+ if err != nil {
+ return nil, err
+ }
+ }
+ defer r.Close()
+ return ioutil.ReadAll(r)
+}
+
+func (s *IOStreams) TempFile(dir, pattern string) (*os.File, error) {
+ if s.TempFileOverride != nil {
+ return s.TempFileOverride, nil
+ }
+ return ioutil.TempFile(dir, pattern)
+}
+
func System() *IOStreams {
stdoutIsTTY := isTerminal(os.Stdout)
stderrIsTTY := isTerminal(os.Stderr)
diff --git a/pkg/markdown/markdown.go b/pkg/markdown/markdown.go
index c5fa46d6b..505c7d401 100644
--- a/pkg/markdown/markdown.go
+++ b/pkg/markdown/markdown.go
@@ -7,7 +7,24 @@ import (
"github.com/charmbracelet/glamour"
)
-func Render(text, style string) (string, error) {
+func Render(text, style string, baseURL string) (string, error) {
+ // Glamour rendering preserves carriage return characters in code blocks, but
+ // we need to ensure that no such characters are present in the output.
+ text = strings.ReplaceAll(text, "\r\n", "\n")
+
+ tr, err := glamour.NewTermRenderer(
+ glamour.WithStylePath(style),
+ glamour.WithBaseURL(baseURL),
+ // glamour.WithWordWrap(80), // TODO: make configurable
+ )
+ if err != nil {
+ return "", err
+ }
+
+ return tr.Render(text)
+}
+
+func RenderWrap(text, style string, wrap int) (string, error) {
// Glamour rendering preserves carriage return characters in code blocks, but
// we need to ensure that no such characters are present in the output.
text = strings.ReplaceAll(text, "\r\n", "\n")
@@ -15,7 +32,7 @@ func Render(text, style string) (string, error) {
tr, err := glamour.NewTermRenderer(
glamour.WithStylePath(style),
// glamour.WithBaseURL(""), // TODO: make configurable
- // glamour.WithWordWrap(80), // TODO: make configurable
+ glamour.WithWordWrap(wrap),
)
if err != nil {
return "", err
diff --git a/pkg/markdown/markdown_test.go b/pkg/markdown/markdown_test.go
index 079b5ff7a..7a2b3063e 100644
--- a/pkg/markdown/markdown_test.go
+++ b/pkg/markdown/markdown_test.go
@@ -43,7 +43,7 @@ func Test_Render(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
- _, err := Render(tt.input.text, tt.input.style)
+ _, err := Render(tt.input.text, tt.input.style, "")
if tt.output.wantsErr {
assert.Error(t, err)
return
diff --git a/pkg/prompt/stubber.go b/pkg/prompt/stubber.go
index ab08cd1ab..be920cd25 100644
--- a/pkg/prompt/stubber.go
+++ b/pkg/prompt/stubber.go
@@ -31,6 +31,7 @@ func InitAskStubber() (*AskStubber, func()) {
}
stubbedPrompt := as.StubOnes[count]
if stubbedPrompt.Default {
+ // TODO this is failing for basic AskOne invocations with a string result.
defaultValue := reflect.ValueOf(p).Elem().FieldByName("Default")
_ = core.WriteAnswer(response, "", defaultValue)
} else {
@@ -45,11 +46,14 @@ func InitAskStubber() (*AskStubber, func()) {
count := as.Count
as.Count += 1
if count >= len(as.Stubs) {
- panic(fmt.Sprintf("more asks than stubs. most recent call: %v", qs))
+ panic(fmt.Sprintf("more asks than stubs. most recent call: %#v", qs))
}
// actually set response
stubbedQuestions := as.Stubs[count]
+ if len(stubbedQuestions) != len(qs) {
+ panic(fmt.Sprintf("asked questions: %d; stubbed questions: %d", len(qs), len(stubbedQuestions)))
+ }
for i, sq := range stubbedQuestions {
q := qs[i]
if q.Name != sq.Name {
diff --git a/pkg/surveyext/editor.go b/pkg/surveyext/editor.go
index 038cc9036..21a358aa0 100644
--- a/pkg/surveyext/editor.go
+++ b/pkg/surveyext/editor.go
@@ -157,3 +157,7 @@ func (e *GhEditor) Prompt(config *survey.PromptConfig) (interface{}, error) {
}
return e.prompt(initialValue, config)
}
+
+func DefaultEditorName() string {
+ return filepath.Base(defaultEditor)
+}
diff --git a/pkg/surveyext/editor_manual.go b/pkg/surveyext/editor_manual.go
index faa2345ad..d5faeb063 100644
--- a/pkg/surveyext/editor_manual.go
+++ b/pkg/surveyext/editor_manual.go
@@ -7,6 +7,7 @@ import (
"os"
"os/exec"
+ "github.com/cli/safeexec"
shellquote "github.com/kballard/go-shellquote"
)
@@ -55,7 +56,12 @@ func Edit(editorCommand, fn, initialValue string, stdin io.Reader, stdout io.Wri
}
args = append(args, f.Name())
- cmd := exec.Command(args[0], args[1:]...)
+ editorExe, err := safeexec.LookPath(args[0])
+ if err != nil {
+ return "", err
+ }
+
+ cmd := exec.Command(editorExe, args[1:]...)
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = stderr
diff --git a/script/build.go b/script/build.go
new file mode 100644
index 000000000..3b1a08ea5
--- /dev/null
+++ b/script/build.go
@@ -0,0 +1,196 @@
+// Build tasks for the GitHub CLI project.
+//
+// Usage: go run script/build.go []
+//
+// Known tasks are:
+//
+// bin/gh:
+// Builds the main executable.
+// Supported environment variables:
+// - GH_VERSION: determined from source by default
+// - GH_OAUTH_CLIENT_ID
+// - GH_OAUTH_CLIENT_SECRET
+// - SOURCE_DATE_EPOCH: enables reproducible builds
+// - GO_LDFLAGS
+//
+// manpages:
+// Builds the man pages under `share/man/man1/`.
+//
+// clean:
+// Deletes all built files.
+//
+
+package main
+
+import (
+ "fmt"
+ "io/ioutil"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "runtime"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/cli/safeexec"
+)
+
+var tasks = map[string]func(string) error{
+ "bin/gh": func(exe string) error {
+ info, err := os.Stat(exe)
+ if err == nil && !sourceFilesLaterThan(info.ModTime()) {
+ fmt.Printf("%s: `%s` is up to date.\n", self, exe)
+ return nil
+ }
+
+ ldflags := os.Getenv("GO_LDFLAGS")
+ ldflags = fmt.Sprintf("-X github.com/cli/cli/internal/build.Version=%s %s", version(), ldflags)
+ ldflags = fmt.Sprintf("-X github.com/cli/cli/internal/build.Date=%s %s", date(), ldflags)
+ if oauthSecret := os.Getenv("GH_OAUTH_CLIENT_SECRET"); oauthSecret != "" {
+ ldflags = fmt.Sprintf("-X github.com/cli/cli/internal/authflow.oauthClientSecret=%s %s", oauthSecret, ldflags)
+ ldflags = fmt.Sprintf("-X github.com/cli/cli/internal/authflow.oauthClientID=%s %s", os.Getenv("GH_OAUTH_CLIENT_ID"), ldflags)
+ }
+
+ return run("go", "build", "-trimpath", "-ldflags", ldflags, "-o", exe, "./cmd/gh")
+ },
+ "manpages": func(_ string) error {
+ return run("go", "run", "./cmd/gen-docs", "--man-page", "--doc-path", "./share/man/man1/")
+ },
+ "clean": func(_ string) error {
+ return rmrf("bin", "share")
+ },
+}
+
+var self string
+
+func main() {
+ task := "bin/gh"
+ if runtime.GOOS == "windows" {
+ task = "bin\\gh.exe"
+ }
+
+ if len(os.Args) > 1 {
+ task = os.Args[1]
+ }
+
+ self = filepath.Base(os.Args[0])
+ if self == "build" {
+ self = "build.go"
+ }
+
+ t := tasks[normalizeTask(task)]
+ if t == nil {
+ fmt.Fprintf(os.Stderr, "Don't know how to build task `%s`.\n", task)
+ os.Exit(1)
+ }
+
+ err := t(task)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ fmt.Fprintf(os.Stderr, "%s: building task `%s` failed.\n", self, task)
+ os.Exit(1)
+ }
+}
+
+func version() string {
+ if versionEnv := os.Getenv("GH_VERSION"); versionEnv != "" {
+ return versionEnv
+ }
+ if desc, err := cmdOutput("git", "describe", "--tags"); err == nil {
+ return desc
+ }
+ rev, _ := cmdOutput("git", "rev-parse", "--short", "HEAD")
+ return rev
+}
+
+func date() string {
+ t := time.Now()
+ if sourceDate := os.Getenv("SOURCE_DATE_EPOCH"); sourceDate != "" {
+ if sec, err := strconv.ParseInt(sourceDate, 10, 64); err == nil {
+ t = time.Unix(sec, 0)
+ }
+ }
+ return t.Format("2006-01-02")
+}
+
+func sourceFilesLaterThan(t time.Time) bool {
+ foundLater := false
+ _ = filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
+ if err != nil {
+ return err
+ }
+ if foundLater {
+ return filepath.SkipDir
+ }
+ if len(path) > 1 && (path[0] == '.' || path[0] == '_') {
+ if info.IsDir() {
+ return filepath.SkipDir
+ } else {
+ return nil
+ }
+ }
+ if info.IsDir() {
+ return nil
+ }
+ if path == "go.mod" || path == "go.sum" || (strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go")) {
+ if info.ModTime().After(t) {
+ foundLater = true
+ }
+ }
+ return nil
+ })
+ return foundLater
+}
+
+func rmrf(targets ...string) error {
+ args := append([]string{"rm", "-rf"}, targets...)
+ announce(args...)
+ for _, target := range targets {
+ if err := os.RemoveAll(target); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func announce(args ...string) {
+ fmt.Println(shellInspect(args))
+}
+
+func run(args ...string) error {
+ exe, err := safeexec.LookPath(args[0])
+ if err != nil {
+ return err
+ }
+ announce(args...)
+ cmd := exec.Command(exe, args[1:]...)
+ return cmd.Run()
+}
+
+func cmdOutput(args ...string) (string, error) {
+ exe, err := safeexec.LookPath(args[0])
+ if err != nil {
+ return "", err
+ }
+ cmd := exec.Command(exe, args[1:]...)
+ cmd.Stderr = ioutil.Discard
+ out, err := cmd.Output()
+ return strings.TrimSuffix(string(out), "\n"), err
+}
+
+func shellInspect(args []string) string {
+ fmtArgs := make([]string, len(args))
+ for i, arg := range args {
+ if strings.ContainsAny(arg, " \t'\"") {
+ fmtArgs[i] = fmt.Sprintf("%q", arg)
+ } else {
+ fmtArgs[i] = arg
+ }
+ }
+ return strings.Join(fmtArgs, " ")
+}
+
+func normalizeTask(t string) string {
+ return filepath.ToSlash(strings.TrimSuffix(t, ".exe"))
+}
diff --git a/script/distributions b/script/distributions
index 5af84b6b3..20308bbf6 100644
--- a/script/distributions
+++ b/script/distributions
@@ -1,7 +1,7 @@
Origin: gh
Label: gh
Codename: stable
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - debian stable repo
SignWith: C99B11DEB97541F0
@@ -9,7 +9,7 @@ SignWith: C99B11DEB97541F0
Origin: gh
Label: gh
Codename: oldstable
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - debian oldstable repo
SignWith: C99B11DEB97541F0
@@ -17,7 +17,7 @@ SignWith: C99B11DEB97541F0
Origin: gh
Label: gh
Codename: testing
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - debian testing repo
SignWith: C99B11DEB97541F0
@@ -25,7 +25,7 @@ SignWith: C99B11DEB97541F0
Origin: gh
Label: gh
Codename: unstable
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - debian unstable repo
SignWith: C99B11DEB97541F0
@@ -33,7 +33,7 @@ SignWith: C99B11DEB97541F0
Origin: gh
Label: gh
Codename: buster
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - debian buster repo
SignWith: C99B11DEB97541F0
@@ -41,7 +41,7 @@ SignWith: C99B11DEB97541F0
Origin: gh
Label: gh
Codename: bullseye
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - debian bullseye repo
SignWith: C99B11DEB97541F0
@@ -49,7 +49,7 @@ SignWith: C99B11DEB97541F0
Origin: gh
Label: gh
Codename: stretch
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - debian stretch repo
SignWith: C99B11DEB97541F0
@@ -57,7 +57,7 @@ SignWith: C99B11DEB97541F0
Origin: gh
Label: gh
Codename: jessie
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - debian jessie repo
SignWith: C99B11DEB97541F0
@@ -65,7 +65,7 @@ SignWith: C99B11DEB97541F0
Origin: gh
Label: gh
Codename: focal
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu focal repo
SignWith: C99B11DEB97541F0
@@ -74,7 +74,7 @@ DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: precise
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu precise repo
SignWith: C99B11DEB97541F0
@@ -83,7 +83,7 @@ DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: bionic
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu bionic repo
SignWith: C99B11DEB97541F0
@@ -92,7 +92,7 @@ DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: trusty
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu trusty repo
SignWith: C99B11DEB97541F0
@@ -101,7 +101,7 @@ DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: xenial
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu xenial repo
SignWith: C99B11DEB97541F0
@@ -110,7 +110,7 @@ DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: groovy
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu groovy repo
SignWith: C99B11DEB97541F0
@@ -119,7 +119,7 @@ DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: eoan
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu eoan repo
SignWith: C99B11DEB97541F0
@@ -128,7 +128,7 @@ DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: disco
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu disco repo
SignWith: C99B11DEB97541F0
@@ -137,7 +137,7 @@ DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: cosmic
-Architectures: i386 amd64 arm64
+Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu cosmic repo
SignWith: C99B11DEB97541F0
diff --git a/utils/color.go b/utils/color.go
deleted file mode 100644
index 3e875acc4..000000000
--- a/utils/color.go
+++ /dev/null
@@ -1,57 +0,0 @@
-package utils
-
-import (
- "fmt"
- "io"
- "os"
-
- "github.com/cli/cli/pkg/iostreams"
- "github.com/mattn/go-colorable"
- "github.com/mgutz/ansi"
-)
-
-var (
- // Outputs ANSI color if stdout is a tty
- Magenta = makeColorFunc("magenta")
- Cyan = makeColorFunc("cyan")
- Red = makeColorFunc("red")
- Yellow = makeColorFunc("yellow")
- Blue = makeColorFunc("blue")
- Green = makeColorFunc("green")
- Gray = makeColorFunc("black+h")
- Bold = makeColorFunc("default+b")
-)
-
-// NewColorable returns an output stream that handles ANSI color sequences on Windows
-func NewColorable(w io.Writer) io.Writer {
- if f, isFile := w.(*os.File); isFile {
- return colorable.NewColorable(f)
- }
- return w
-}
-
-func makeColorFunc(color string) func(string) string {
- cf := ansi.ColorFunc(color)
- return func(arg string) string {
- if isColorEnabled() {
- if color == "black+h" && iostreams.Is256ColorSupported() {
- return fmt.Sprintf("\x1b[%d;5;%dm%s\x1b[m", 38, 242, arg)
- }
- return cf(arg)
- }
- return arg
- }
-}
-
-func isColorEnabled() bool {
- if iostreams.EnvColorForced() {
- return true
- }
-
- if iostreams.EnvColorDisabled() {
- return false
- }
-
- // TODO ignores cmd.OutOrStdout
- return IsTerminal(os.Stdout)
-}
diff --git a/utils/utils.go b/utils/utils.go
index 69e48c150..57b39b5ba 100644
--- a/utils/utils.go
+++ b/utils/utils.go
@@ -2,12 +2,10 @@ package utils
import (
"fmt"
- "io"
"net/url"
"strings"
"time"
- "github.com/briandowns/spinner"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/browser"
)
@@ -59,6 +57,22 @@ func FuzzyAgo(ago time.Duration) string {
return fmtDuration(int(ago.Hours()/24/365), "year")
}
+func FuzzyAgoAbbr(now time.Time, createdAt time.Time) string {
+ ago := now.Sub(createdAt)
+
+ if ago < time.Hour {
+ return fmt.Sprintf("%d%s", int(ago.Minutes()), "m")
+ }
+ if ago < 24*time.Hour {
+ return fmt.Sprintf("%d%s", int(ago.Hours()), "h")
+ }
+ if ago < 30*24*time.Hour {
+ return fmt.Sprintf("%d%s", int(ago.Hours())/24, "d")
+ }
+
+ return createdAt.Format("Jan _2, 2006")
+}
+
func Humanize(s string) string {
// Replaces - and _ with spaces.
replace := "_-"
@@ -72,20 +86,6 @@ func Humanize(s string) string {
return strings.Map(h, s)
}
-// We do this so we can stub out the spinner in tests -- it made things really flakey. This is not
-// an elegant solution.
-var StartSpinner = func(s *spinner.Spinner) {
- s.Start()
-}
-
-var StopSpinner = func(s *spinner.Spinner) {
- s.Stop()
-}
-
-func Spinner(w io.Writer) *spinner.Spinner {
- return spinner.New(spinner.CharSets[11], 400*time.Millisecond, spinner.WithWriter(w))
-}
-
func IsURL(s string) bool {
return strings.HasPrefix(s, "http:/") || strings.HasPrefix(s, "https:/")
}
@@ -97,15 +97,3 @@ func DisplayURL(urlStr string) string {
}
return u.Hostname() + u.Path
}
-
-func GreenCheck() string {
- return Green("✓")
-}
-
-func YellowDash() string {
- return Yellow("-")
-}
-
-func RedX() string {
- return Red("X")
-}
diff --git a/utils/utils_test.go b/utils/utils_test.go
index 0891c2a39..5dc7b2478 100644
--- a/utils/utils_test.go
+++ b/utils/utils_test.go
@@ -6,7 +6,6 @@ import (
)
func TestFuzzyAgo(t *testing.T) {
-
cases := map[string]string{
"1s": "less than a minute ago",
"30s": "less than a minute ago",
@@ -36,3 +35,29 @@ func TestFuzzyAgo(t *testing.T) {
}
}
}
+
+func TestFuzzyAgoAbbr(t *testing.T) {
+ const form = "2006-Jan-02 15:04:05"
+ now, _ := time.Parse(form, "2020-Nov-22 14:00:00")
+
+ cases := map[string]string{
+ "2020-Nov-22 14:00:00": "0m",
+ "2020-Nov-22 13:59:00": "1m",
+ "2020-Nov-22 13:30:00": "30m",
+ "2020-Nov-22 13:00:00": "1h",
+ "2020-Nov-22 02:00:00": "12h",
+ "2020-Nov-21 14:00:00": "1d",
+ "2020-Nov-07 14:00:00": "15d",
+ "2020-Oct-24 14:00:00": "29d",
+ "2020-Oct-23 14:00:00": "Oct 23, 2020",
+ "2019-Nov-22 14:00:00": "Nov 22, 2019",
+ }
+
+ for createdAt, expected := range cases {
+ d, _ := time.Parse(form, createdAt)
+ fuzzy := FuzzyAgoAbbr(now, d)
+ if fuzzy != expected {
+ t.Errorf("unexpected fuzzy duration abbr value: %s for %s", fuzzy, createdAt)
+ }
+ }
+}