Merge remote-tracking branch 'origin/trunk' into repo-fork-gitflags

This commit is contained in:
vilmibm 2021-01-21 11:58:20 -08:00
commit a27a94f8b5
220 changed files with 13685 additions and 4420 deletions

View file

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

View file

@ -2,6 +2,7 @@ name: Code Scanning
on:
push:
pull_request:
schedule:
- cron: "0 0 * * 0"

View file

@ -39,4 +39,6 @@ jobs:
uses: actions/checkout@v2
- name: Build
env:
CGO_ENABLED: '0'
run: go build -v ./cmd/gh

View file

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

View file

@ -17,7 +17,7 @@ jobs:
go-version: 1.15
- name: Generate changelog
run: |
echo ::set-env name=GORELEASER_CURRENT_TAG::${GITHUB_REF#refs/tags/}
echo "GORELEASER_CURRENT_TAG=${GITHUB_REF#refs/tags/}" >> $GITHUB_ENV
git fetch --unshallow
script/changelog | tee CHANGELOG.md
- name: Run GoReleaser
@ -50,10 +50,11 @@ jobs:
run: |
api() { gh api -H 'accept: application/vnd.github.inertia-preview+json' "$@"; }
api-write() { [[ $GITHUB_REF == *-* ]] && echo "skipping: api $*" || api "$@"; }
cards=$(api projects/columns/$PENDING_COLUMN/cards | jq ".[].id")
cards=$(api --paginate projects/columns/$PENDING_COLUMN/cards | jq ".[].id")
for card in $cards; do
api-write projects/columns/cards/$card/moves -f position=top -F column_id=$DONE_COLUMN
api-write --silent projects/columns/cards/$card/moves -f position=top -F column_id=$DONE_COLUMN
done
echo "moved ${#cards[@]} cards to the Done column"
- name: Install packaging dependencies
run: sudo apt-get install -y createrepo rpm reprepro
@ -126,8 +127,8 @@ jobs:
- name: Prepare PATH
shell: bash
run: |
echo "::add-path::$WIX\\bin"
echo "::add-path::C:\\Program Files\\go-msi"
echo "$WIX\\bin" >> $GITHUB_PATH
echo "C:\\Program Files\\go-msi" >> $GITHUB_PATH
- name: Build MSI
id: buildmsi
shell: bash

4
.gitignore vendored
View file

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

View file

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

View file

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

View file

@ -42,22 +42,22 @@ For more information and distro-specific instructions, see the [Linux installati
### Windows
`gh` is available via [scoop][], [Chocolatey][], and as downloadable MSI.
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], and as downloadable MSI.
#### WinGet
| Install: | Upgrade: |
| ------------------- | --------------------|
| `winget install gh` | `winget install gh` |
<i>WinGet does not have a specialized `upgrade` command yet, but the `install` command should work for upgrading to a newer version of GitHub CLI.</i>
#### scoop
Install:
```powershell
scoop bucket add github-gh https://github.com/cli/scoop-gh.git
scoop install gh
```
Upgrade:
```powershell
scoop update gh
```
| Install: | Upgrade: |
| ------------------ | ------------------ |
| `scoop install gh` | `scoop update gh` |
#### Chocolatey
@ -88,6 +88,7 @@ tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
[manual]: https://cli.github.com/manual/
[Homebrew]: https://brew.sh
[MacPorts]: https://www.macports.org
[winget]: https://github.com/microsoft/winget-cli
[scoop]: https://scoop.sh
[Chocolatey]: https://chocolatey.org
[releases page]: https://github.com/cli/cli/releases/latest

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,8 @@ package api
import (
"encoding/json"
"testing"
"github.com/stretchr/testify/assert"
)
func TestPullRequest_ChecksStatus(t *testing.T) {
@ -31,11 +33,11 @@ func TestPullRequest_ChecksStatus(t *testing.T) {
} }] } }
`
err := json.Unmarshal([]byte(payload), &pr)
eq(t, err, nil)
assert.NoError(t, err)
checks := pr.ChecksStatus()
eq(t, checks.Total, 8)
eq(t, checks.Pending, 3)
eq(t, checks.Failing, 3)
eq(t, checks.Passing, 2)
assert.Equal(t, 8, checks.Total)
assert.Equal(t, 3, checks.Pending)
assert.Equal(t, 3, checks.Failing)
assert.Equal(t, 2, checks.Passing)
}

182
api/queries_comments.go Normal file
View file

@ -0,0 +1,182 @@
package api
import (
"context"
"time"
"github.com/cli/cli/internal/ghrepo"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
type Comments struct {
Nodes []Comment
TotalCount int
PageInfo PageInfo
}
type Comment struct {
Author Author
AuthorAssociation string
Body string
CreatedAt time.Time
IncludesCreatedEdit bool
ReactionGroups ReactionGroups
}
type PageInfo struct {
HasNextPage bool
EndCursor string
}
func CommentsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) (*Comments, error) {
type response struct {
Repository struct {
Issue struct {
Comments Comments `graphql:"comments(first: 100, after: $endCursor)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"repo": githubv4.String(repo.RepoName()),
"number": githubv4.Int(issue.Number),
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var comments []Comment
for {
var query response
err := gql.QueryNamed(context.Background(), "CommentsForIssue", &query, variables)
if err != nil {
return nil, err
}
comments = append(comments, query.Repository.Issue.Comments.Nodes...)
if !query.Repository.Issue.Comments.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.Issue.Comments.PageInfo.EndCursor)
}
return &Comments{Nodes: comments, TotalCount: len(comments)}, nil
}
func CommentsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) (*Comments, error) {
type response struct {
Repository struct {
PullRequest struct {
Comments Comments `graphql:"comments(first: 100, after: $endCursor)"`
} `graphql:"pullRequest(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"repo": githubv4.String(repo.RepoName()),
"number": githubv4.Int(pr.Number),
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var comments []Comment
for {
var query response
err := gql.QueryNamed(context.Background(), "CommentsForPullRequest", &query, variables)
if err != nil {
return nil, err
}
comments = append(comments, query.Repository.PullRequest.Comments.Nodes...)
if !query.Repository.PullRequest.Comments.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.PullRequest.Comments.PageInfo.EndCursor)
}
return &Comments{Nodes: comments, TotalCount: len(comments)}, nil
}
type CommentCreateInput struct {
Body string
SubjectId string
}
func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (string, error) {
var mutation struct {
AddComment struct {
CommentEdge struct {
Node struct {
URL string
}
}
} `graphql:"addComment(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.AddCommentInput{
Body: githubv4.String(params.Body),
SubjectID: graphql.ID(params.SubjectId),
},
}
gql := graphQLClient(client.http, repoHost)
err := gql.MutateNamed(context.Background(), "CommentCreate", &mutation, variables)
if err != nil {
return "", err
}
return mutation.AddComment.CommentEdge.Node.URL, nil
}
func commentsFragment() string {
return `comments(last: 1) {
nodes {
author {
login
}
authorAssociation
body
createdAt
includesCreatedEdit
` + reactionGroupsFragment() + `
}
totalCount
}`
}
func (c Comment) AuthorLogin() string {
return c.Author.Login
}
func (c Comment) Association() string {
return c.AuthorAssociation
}
func (c Comment) Content() string {
return c.Body
}
func (c Comment) Created() time.Time {
return c.CreatedAt
}
func (c Comment) IsEdited() bool {
return c.IncludesCreatedEdit
}
func (c Comment) Reactions() ReactionGroups {
return c.ReactionGroups
}
func (c Comment) Status() string {
return ""
}
func (c Comment) Link() string {
return ""
}

View file

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

View file

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

View file

@ -6,6 +6,7 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
@ -14,19 +15,6 @@ import (
"github.com/shurcooL/githubv4"
)
type PullRequestReviewState int
const (
ReviewApprove PullRequestReviewState = iota
ReviewRequestChanges
ReviewComment
)
type PullRequestReviewInput struct {
Body string
State PullRequestReviewState
}
type PullRequestsPayload struct {
ViewerCreated PullRequestAndTotalCount
ReviewRequested PullRequestAndTotalCount
@ -102,14 +90,6 @@ type PullRequest struct {
}
TotalCount int
}
Reviews struct {
Nodes []struct {
Author struct {
Login string
}
State string
}
}
Assignees struct {
Nodes []struct {
Login string
@ -136,6 +116,9 @@ type PullRequest struct {
Milestone struct {
Title string
}
Comments Comments
ReactionGroups ReactionGroups
Reviews PullRequestReviews
}
type NotFoundError struct {
@ -153,6 +136,14 @@ func (pr PullRequest) HeadLabel() string {
return pr.HeadRefName
}
func (pr PullRequest) Link() string {
return pr.URL
}
func (pr PullRequest) Identifier() string {
return pr.ID
}
type PullRequestReviewStatus struct {
ChangesRequested bool
Approved bool
@ -217,6 +208,18 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
return
}
func (pr *PullRequest) DisplayableReviews() PullRequestReviews {
published := []PullRequestReview{}
for _, prr := range pr.Reviews.Nodes {
//Dont display pending reviews
//Dont display commenting reviews without top level comment body
if prr.State != "PENDING" && !(prr.State == "COMMENTED" && prr.Body == "") {
published = append(published, prr)
}
}
return PullRequestReviews{Nodes: published, TotalCount: len(published)}
}
func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.ReadCloser, error) {
url := fmt.Sprintf("%srepos/%s/pulls/%d",
ghinstance.RESTPrefix(baseRepo.RepoHost()), ghrepo.FullName(baseRepo), prNumber)
@ -567,15 +570,6 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
}
totalCount
}
reviews(last: 100) {
nodes {
author {
login
}
state
}
totalCount
}
assignees(first: 100) {
nodes {
login
@ -602,6 +596,8 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
milestone{
title
}
` + commentsFragment() + `
` + reactionGroupsFragment() + `
}
}
}`
@ -621,11 +617,10 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
return &resp.Repository.PullRequest, nil
}
func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, headBranch string) (*PullRequest, error) {
func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, headBranch string, stateFilters []string) (*PullRequest, error) {
type response struct {
Repository struct {
PullRequests struct {
ID githubv4.ID
Nodes []PullRequest
}
}
@ -637,9 +632,9 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
}
query := `
query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!) {
query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!, $states: [PullRequestState!]) {
repository(owner: $owner, name: $repo) {
pullRequests(headRefName: $headRefName, states: OPEN, first: 30) {
pullRequests(headRefName: $headRefName, states: $states, first: 30, orderBy: { field: CREATED_AT, direction: DESC }) {
nodes {
id
number
@ -677,15 +672,6 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
}
totalCount
}
reviews(last: 100) {
nodes {
author {
login
}
state
}
totalCount
}
assignees(first: 100) {
nodes {
login
@ -712,6 +698,8 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
milestone{
title
}
` + commentsFragment() + `
` + reactionGroupsFragment() + `
}
}
}
@ -726,6 +714,7 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"headRefName": branchWithoutOwner,
"states": stateFilters,
}
var resp response
@ -734,18 +723,23 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, hea
return nil, err
}
for _, pr := range resp.Repository.PullRequests.Nodes {
if pr.HeadLabel() == headBranch {
if baseBranch != "" {
if pr.BaseRefName != baseBranch {
continue
}
}
prs := resp.Repository.PullRequests.Nodes
sortPullRequestsByState(prs)
for _, pr := range prs {
if pr.HeadLabel() == headBranch && (baseBranch == "" || pr.BaseRefName == baseBranch) {
return &pr, nil
}
}
return nil, &NotFoundError{fmt.Errorf("no open pull requests found for branch %q", headBranch)}
return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranch)}
}
// sortPullRequestsByState sorts a PullRequest slice by open-first
func sortPullRequestsByState(prs []PullRequest) {
sort.SliceStable(prs, func(a, b int) bool {
return prs[a].State == "OPEN"
})
}
// CreatePullRequest creates a pull request in a GitHub repository
@ -765,7 +759,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
}
for key, val := range params {
switch key {
case "title", "body", "draft", "baseRefName", "headRefName":
case "title", "body", "draft", "baseRefName", "headRefName", "maintainerCanModify":
inputParams[key] = val
}
}
@ -850,34 +844,6 @@ func isBlank(v interface{}) bool {
}
}
func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
var mutation struct {
AddPullRequestReview struct {
ClientMutationID string
} `graphql:"addPullRequestReview(input:$input)"`
}
state := githubv4.PullRequestReviewEventComment
switch input.State {
case ReviewApprove:
state = githubv4.PullRequestReviewEventApprove
case ReviewRequestChanges:
state = githubv4.PullRequestReviewEventRequestChanges
}
body := githubv4.String(input.Body)
variables := map[string]interface{}{
"input": githubv4.AddPullRequestReviewInput{
PullRequestID: pr.ID,
Event: &state,
Body: &body,
},
}
gql := graphQLClient(client.http, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables)
}
func PullRequestList(client *Client, repo ghrepo.Interface, vars map[string]interface{}, limit int) (*PullRequestAndTotalCount, error) {
type prBlock struct {
Edges []struct {

135
api/queries_pr_review.go Normal file
View file

@ -0,0 +1,135 @@
package api
import (
"context"
"time"
"github.com/cli/cli/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
type PullRequestReviewState int
const (
ReviewApprove PullRequestReviewState = iota
ReviewRequestChanges
ReviewComment
)
type PullRequestReviewInput struct {
Body string
State PullRequestReviewState
}
type PullRequestReviews struct {
Nodes []PullRequestReview
PageInfo PageInfo
TotalCount int
}
type PullRequestReview struct {
Author Author
AuthorAssociation string
Body string
CreatedAt time.Time
IncludesCreatedEdit bool
ReactionGroups ReactionGroups
State string
URL string
}
func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
var mutation struct {
AddPullRequestReview struct {
ClientMutationID string
} `graphql:"addPullRequestReview(input:$input)"`
}
state := githubv4.PullRequestReviewEventComment
switch input.State {
case ReviewApprove:
state = githubv4.PullRequestReviewEventApprove
case ReviewRequestChanges:
state = githubv4.PullRequestReviewEventRequestChanges
}
body := githubv4.String(input.Body)
variables := map[string]interface{}{
"input": githubv4.AddPullRequestReviewInput{
PullRequestID: pr.ID,
Event: &state,
Body: &body,
},
}
gql := graphQLClient(client.http, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables)
}
func ReviewsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) (*PullRequestReviews, error) {
type response struct {
Repository struct {
PullRequest struct {
Reviews PullRequestReviews `graphql:"reviews(first: 100, after: $endCursor)"`
} `graphql:"pullRequest(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"repo": githubv4.String(repo.RepoName()),
"number": githubv4.Int(pr.Number),
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var reviews []PullRequestReview
for {
var query response
err := gql.QueryNamed(context.Background(), "ReviewsForPullRequest", &query, variables)
if err != nil {
return nil, err
}
reviews = append(reviews, query.Repository.PullRequest.Reviews.Nodes...)
if !query.Repository.PullRequest.Reviews.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.PullRequest.Reviews.PageInfo.EndCursor)
}
return &PullRequestReviews{Nodes: reviews, TotalCount: len(reviews)}, nil
}
func (prr PullRequestReview) AuthorLogin() string {
return prr.Author.Login
}
func (prr PullRequestReview) Association() string {
return prr.AuthorAssociation
}
func (prr PullRequestReview) Content() string {
return prr.Body
}
func (prr PullRequestReview) Created() time.Time {
return prr.CreatedAt
}
func (prr PullRequestReview) IsEdited() bool {
return prr.IncludesCreatedEdit
}
func (prr PullRequestReview) Reactions() ReactionGroups {
return prr.ReactionGroups
}
func (prr PullRequestReview) Status() string {
return prr.State
}
func (prr PullRequestReview) Link() string {
return prr.URL
}

View file

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

View file

@ -27,6 +27,7 @@ type Repository struct {
IsPrivate bool
HasIssuesEnabled bool
HasWikiEnabled bool
ViewerPermission string
DefaultBranchRef BranchRef
@ -94,6 +95,7 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
owner { login }
hasIssuesEnabled
description
hasWikiEnabled
viewerPermission
defaultBranchRef {
name
@ -464,6 +466,28 @@ func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) {
return "", errors.New("not found")
}
func (m *RepoMetadataResult) Merge(m2 *RepoMetadataResult) {
if len(m2.AssignableUsers) > 0 || len(m.AssignableUsers) == 0 {
m.AssignableUsers = m2.AssignableUsers
}
if len(m2.Teams) > 0 || len(m.Teams) == 0 {
m.Teams = m2.Teams
}
if len(m2.Labels) > 0 || len(m.Labels) == 0 {
m.Labels = m2.Labels
}
if len(m2.Projects) > 0 || len(m.Projects) == 0 {
m.Projects = m2.Projects
}
if len(m2.Milestones) > 0 || len(m.Milestones) == 0 {
m.Milestones = m2.Milestones
}
}
type RepoMetadataInput struct {
Assignees bool
Reviewers bool

40
api/reaction_groups.go Normal file
View file

@ -0,0 +1,40 @@
package api
type ReactionGroups []ReactionGroup
type ReactionGroup struct {
Content string
Users ReactionGroupUsers
}
type ReactionGroupUsers struct {
TotalCount int
}
func (rg ReactionGroup) Count() int {
return rg.Users.TotalCount
}
func (rg ReactionGroup) Emoji() string {
return reactionEmoji[rg.Content]
}
var reactionEmoji = map[string]string{
"THUMBS_UP": "\U0001f44d",
"THUMBS_DOWN": "\U0001f44e",
"LAUGH": "\U0001f604",
"HOORAY": "\U0001f389",
"CONFUSED": "\U0001f615",
"HEART": "\u2764\ufe0f",
"ROCKET": "\U0001f680",
"EYES": "\U0001f440",
}
func reactionGroupsFragment() string {
return `reactionGroups {
content
users {
totalCount
}
}`
}

100
api/reaction_groups_test.go Normal file
View file

@ -0,0 +1,100 @@
package api
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_String(t *testing.T) {
tests := map[string]struct {
rg ReactionGroup
emoji string
count int
}{
"empty reaction group": {
rg: ReactionGroup{},
emoji: "",
count: 0,
},
"unknown reaction group": {
rg: ReactionGroup{
Content: "UNKNOWN",
Users: ReactionGroupUsers{TotalCount: 1},
},
emoji: "",
count: 1,
},
"thumbs up reaction group": {
rg: ReactionGroup{
Content: "THUMBS_UP",
Users: ReactionGroupUsers{TotalCount: 2},
},
emoji: "\U0001f44d",
count: 2,
},
"thumbs down reaction group": {
rg: ReactionGroup{
Content: "THUMBS_DOWN",
Users: ReactionGroupUsers{TotalCount: 3},
},
emoji: "\U0001f44e",
count: 3,
},
"laugh reaction group": {
rg: ReactionGroup{
Content: "LAUGH",
Users: ReactionGroupUsers{TotalCount: 4},
},
emoji: "\U0001f604",
count: 4,
},
"hooray reaction group": {
rg: ReactionGroup{
Content: "HOORAY",
Users: ReactionGroupUsers{TotalCount: 5},
},
emoji: "\U0001f389",
count: 5,
},
"confused reaction group": {
rg: ReactionGroup{
Content: "CONFUSED",
Users: ReactionGroupUsers{TotalCount: 6},
},
emoji: "\U0001f615",
count: 6,
},
"heart reaction group": {
rg: ReactionGroup{
Content: "HEART",
Users: ReactionGroupUsers{TotalCount: 7},
},
emoji: "\u2764\ufe0f",
count: 7,
},
"rocket reaction group": {
rg: ReactionGroup{
Content: "ROCKET",
Users: ReactionGroupUsers{TotalCount: 8},
},
emoji: "\U0001f680",
count: 8,
},
"eyes reaction group": {
rg: ReactionGroup{
Content: "EYES",
Users: ReactionGroupUsers{TotalCount: 9},
},
emoji: "\U0001f440",
count: 9,
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
assert.Equal(t, tt.emoji, tt.rg.Emoji())
assert.Equal(t, tt.count, tt.rg.Count())
})
}
}

View file

@ -1,275 +0,0 @@
package auth
import (
"crypto/rand"
"encoding/hex"
"errors"
"fmt"
"io"
"io/ioutil"
"net"
"net/http"
"net/url"
"strconv"
"strings"
"time"
"github.com/cli/cli/internal/ghinstance"
)
func randomString(length int) (string, error) {
b := make([]byte, length/2)
_, err := rand.Read(b)
if err != nil {
return "", err
}
return hex.EncodeToString(b), nil
}
// OAuthFlow represents the setup for authenticating with GitHub
type OAuthFlow struct {
Hostname string
ClientID string
ClientSecret string
Scopes []string
OpenInBrowser func(string, string) error
WriteSuccessHTML func(io.Writer)
VerboseStream io.Writer
HTTPClient *http.Client
TimeNow func() time.Time
TimeSleep func(time.Duration)
}
func detectDeviceFlow(statusCode int, values url.Values) (bool, error) {
if statusCode == http.StatusUnauthorized || statusCode == http.StatusForbidden ||
statusCode == http.StatusNotFound || statusCode == http.StatusUnprocessableEntity ||
(statusCode == http.StatusOK && values == nil) ||
(statusCode == http.StatusBadRequest && values != nil && values.Get("error") == "unauthorized_client") {
return true, nil
} else if statusCode != http.StatusOK {
if values != nil && values.Get("error_description") != "" {
return false, fmt.Errorf("HTTP %d: %s", statusCode, values.Get("error_description"))
}
return false, fmt.Errorf("error: HTTP %d", statusCode)
}
return false, nil
}
// ObtainAccessToken guides the user through the browser OAuth flow on GitHub
// and returns the OAuth access token upon completion.
func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
// first, check if OAuth Device Flow is supported
initURL := fmt.Sprintf("https://%s/login/device/code", oa.Hostname)
tokenURL := fmt.Sprintf("https://%s/login/oauth/access_token", oa.Hostname)
oa.logf("POST %s\n", initURL)
resp, err := oa.HTTPClient.PostForm(initURL, url.Values{
"client_id": {oa.ClientID},
"scope": {strings.Join(oa.Scopes, " ")},
})
if err != nil {
return
}
defer resp.Body.Close()
var values url.Values
if strings.Contains(resp.Header.Get("Content-Type"), "application/x-www-form-urlencoded") {
var bb []byte
bb, err = ioutil.ReadAll(resp.Body)
if err != nil {
return
}
values, err = url.ParseQuery(string(bb))
if err != nil {
return
}
}
if doFallback, err := detectDeviceFlow(resp.StatusCode, values); doFallback {
// OAuth Device Flow is not available; continue with OAuth browser flow with a
// local server endpoint as callback target
return oa.localServerFlow()
} else if err != nil {
return "", fmt.Errorf("%v (%s)", err, initURL)
}
timeNow := oa.TimeNow
if timeNow == nil {
timeNow = time.Now
}
timeSleep := oa.TimeSleep
if timeSleep == nil {
timeSleep = time.Sleep
}
intervalSeconds, err := strconv.Atoi(values.Get("interval"))
if err != nil {
return "", fmt.Errorf("could not parse interval=%q as integer: %w", values.Get("interval"), err)
}
checkInterval := time.Duration(intervalSeconds) * time.Second
expiresIn, err := strconv.Atoi(values.Get("expires_in"))
if err != nil {
return "", fmt.Errorf("could not parse expires_in=%q as integer: %w", values.Get("expires_in"), err)
}
expiresAt := timeNow().Add(time.Duration(expiresIn) * time.Second)
err = oa.OpenInBrowser(values.Get("verification_uri"), values.Get("user_code"))
if err != nil {
return
}
for {
timeSleep(checkInterval)
accessToken, err = oa.deviceFlowPing(tokenURL, values.Get("device_code"))
if accessToken == "" && err == nil {
if timeNow().After(expiresAt) {
err = errors.New("authentication timed out")
} else {
continue
}
}
break
}
return
}
func (oa *OAuthFlow) deviceFlowPing(tokenURL, deviceCode string) (accessToken string, err error) {
oa.logf("POST %s\n", tokenURL)
resp, err := oa.HTTPClient.PostForm(tokenURL, url.Values{
"client_id": {oa.ClientID},
"device_code": {deviceCode},
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
})
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return "", fmt.Errorf("error: HTTP %d (%s)", resp.StatusCode, tokenURL)
}
bb, err := ioutil.ReadAll(resp.Body)
if err != nil {
return "", err
}
values, err := url.ParseQuery(string(bb))
if err != nil {
return "", err
}
if accessToken := values.Get("access_token"); accessToken != "" {
return accessToken, nil
}
errorType := values.Get("error")
if errorType == "authorization_pending" {
return "", nil
}
if errorDescription := values.Get("error_description"); errorDescription != "" {
return "", errors.New(errorDescription)
}
return "", errors.New("OAuth device flow error")
}
func (oa *OAuthFlow) localServerFlow() (accessToken string, err error) {
state, _ := randomString(20)
code := ""
listener, err := net.Listen("tcp4", "127.0.0.1:0")
if err != nil {
return
}
port := listener.Addr().(*net.TCPAddr).Port
scopes := "repo"
if oa.Scopes != nil {
scopes = strings.Join(oa.Scopes, " ")
}
localhost := "127.0.0.1"
callbackPath := "/callback"
if ghinstance.IsEnterprise(oa.Hostname) {
// the OAuth app on Enterprise hosts is still registered with a legacy callback URL
// see https://github.com/cli/cli/pull/222, https://github.com/cli/cli/pull/650
localhost = "localhost"
callbackPath = "/"
}
q := url.Values{}
q.Set("client_id", oa.ClientID)
q.Set("redirect_uri", fmt.Sprintf("http://%s:%d%s", localhost, port, callbackPath))
q.Set("scope", scopes)
q.Set("state", state)
startURL := fmt.Sprintf("https://%s/login/oauth/authorize?%s", oa.Hostname, q.Encode())
oa.logf("open %s\n", startURL)
err = oa.OpenInBrowser(startURL, "")
if err != nil {
return
}
_ = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
oa.logf("server handler: %s\n", r.URL.Path)
if r.URL.Path != callbackPath {
w.WriteHeader(404)
return
}
defer listener.Close()
rq := r.URL.Query()
if state != rq.Get("state") {
fmt.Fprintf(w, "Error: state mismatch")
return
}
code = rq.Get("code")
oa.logf("server received code %q\n", code)
w.Header().Add("content-type", "text/html")
if oa.WriteSuccessHTML != nil {
oa.WriteSuccessHTML(w)
} else {
fmt.Fprintf(w, "<p>You have successfully authenticated. You may now close this page.</p>")
}
}))
tokenURL := fmt.Sprintf("https://%s/login/oauth/access_token", oa.Hostname)
oa.logf("POST %s\n", tokenURL)
resp, err := oa.HTTPClient.PostForm(tokenURL,
url.Values{
"client_id": {oa.ClientID},
"client_secret": {oa.ClientSecret},
"code": {code},
"state": {state},
})
if err != nil {
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
err = fmt.Errorf("HTTP %d error while obtaining OAuth access token", resp.StatusCode)
return
}
body, err := ioutil.ReadAll(resp.Body)
if err != nil {
return
}
tokenValues, err := url.ParseQuery(string(body))
if err != nil {
return
}
accessToken = tokenValues.Get("access_token")
if accessToken == "" {
err = errors.New("the access token could not be read from HTTP response")
}
return
}
func (oa *OAuthFlow) logf(format string, args ...interface{}) {
if oa.VerboseStream == nil {
return
}
fmt.Fprintf(oa.VerboseStream, format, args...)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -15,14 +15,13 @@ To test out the build system, publish a prerelease tag with a name such as `vX.Y
1. `git tag v1.2.3 && git push origin v1.2.3`
2. Wait several minutes for builds to run: <https://github.com/cli/cli/actions>
3. Check <https://github.com/cli/cli/releases>
3. Verify release is displayed and has correct assets: <https://github.com/cli/cli/releases>
4. Scan generated release notes and optionally add a human touch by grouping items under topic sections
5. Verify the marketing site was updated: <https://cli.github.com>
6. (Optional) Delete any pre-releases related to this release.
6. (Optional) Delete any pre-releases related to this release
A successful build will result in changes across several repositories:
* <https://github.com/github/cli.github.com>
* <https://github.com/github/homebrew-gh>
* <https://github.com/Homebrew/homebrew-core/pulls>
* <https://github.com/cli/scoop-gh>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -13,15 +13,10 @@ import (
)
var (
sshHostRE,
sshTokenRE *regexp.Regexp
sshConfigLineRE = regexp.MustCompile(`\A\s*(?P<keyword>[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P<argument>.+)`)
sshTokenRE = regexp.MustCompile(`%[%h]`)
)
func init() {
sshHostRE = regexp.MustCompile("(?i)^[ \t]*(host|hostname)[ \t]+(.+)$")
sshTokenRE = regexp.MustCompile(`%[%h]`)
}
// SSHAliasMap encapsulates the translation of SSH hostname aliases
type SSHAliasMap map[string]string
@ -45,6 +40,103 @@ func (m SSHAliasMap) Translator() func(*url.URL) *url.URL {
}
}
type sshParser struct {
homeDir string
aliasMap SSHAliasMap
hosts []string
open func(string) (io.Reader, error)
glob func(string) ([]string, error)
}
func (p *sshParser) read(fileName string) error {
var file io.Reader
if p.open == nil {
f, err := os.Open(fileName)
if err != nil {
return err
}
defer f.Close()
file = f
} else {
var err error
file, err = p.open(fileName)
if err != nil {
return err
}
}
if len(p.hosts) == 0 {
p.hosts = []string{"*"}
}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
m := sshConfigLineRE.FindStringSubmatch(scanner.Text())
if len(m) < 3 {
continue
}
keyword, arguments := strings.ToLower(m[1]), m[2]
switch keyword {
case "host":
p.hosts = strings.Fields(arguments)
case "hostname":
for _, host := range p.hosts {
for _, name := range strings.Fields(arguments) {
if p.aliasMap == nil {
p.aliasMap = make(SSHAliasMap)
}
p.aliasMap[host] = sshExpandTokens(name, host)
}
}
case "include":
for _, arg := range strings.Fields(arguments) {
path := p.absolutePath(fileName, arg)
var fileNames []string
if p.glob == nil {
paths, _ := filepath.Glob(path)
for _, p := range paths {
if s, err := os.Stat(p); err == nil && !s.IsDir() {
fileNames = append(fileNames, p)
}
}
} else {
var err error
fileNames, err = p.glob(path)
if err != nil {
continue
}
}
for _, fileName := range fileNames {
_ = p.read(fileName)
}
}
}
}
return scanner.Err()
}
func (p *sshParser) absolutePath(parentFile, path string) string {
if filepath.IsAbs(path) || strings.HasPrefix(filepath.ToSlash(path), "/") {
return path
}
if strings.HasPrefix(path, "~") {
return filepath.Join(p.homeDir, strings.TrimPrefix(path, "~"))
}
if strings.HasPrefix(filepath.ToSlash(parentFile), "/etc/ssh") {
return filepath.Join("/etc/ssh", path)
}
return filepath.Join(p.homeDir, ".ssh", path)
}
// ParseSSHConfig constructs a map of SSH hostname aliases based on user and
// system configuration files
func ParseSSHConfig() SSHAliasMap {
@ -52,54 +144,19 @@ func ParseSSHConfig() SSHAliasMap {
"/etc/ssh_config",
"/etc/ssh/ssh_config",
}
p := sshParser{}
if homedir, err := homedir.Dir(); err == nil {
userConfig := filepath.Join(homedir, ".ssh", "config")
configFiles = append([]string{userConfig}, configFiles...)
p.homeDir = homedir
}
openFiles := make([]io.Reader, 0, len(configFiles))
for _, file := range configFiles {
f, err := os.Open(file)
if err != nil {
continue
}
defer f.Close()
openFiles = append(openFiles, f)
_ = p.read(file)
}
return sshParse(openFiles...)
}
func sshParse(r ...io.Reader) SSHAliasMap {
config := make(SSHAliasMap)
for _, file := range r {
_ = sshParseConfig(config, file)
}
return config
}
func sshParseConfig(c SSHAliasMap, file io.Reader) error {
hosts := []string{"*"}
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
match := sshHostRE.FindStringSubmatch(line)
if match == nil {
continue
}
names := strings.Fields(match[2])
if strings.EqualFold(match[1], "host") {
hosts = names
} else {
for _, host := range hosts {
for _, name := range names {
c[host] = sshExpandTokens(name, host)
}
}
}
}
return scanner.Err()
return p.aliasMap
}
func sshExpandTokens(text, host string) string {

View file

@ -1,31 +1,127 @@
package git
import (
"bytes"
"fmt"
"io"
"net/url"
"reflect"
"strings"
"path/filepath"
"testing"
"github.com/MakeNowJust/heredoc"
)
// TODO: extract assertion helpers into a shared package
func eq(t *testing.T, got interface{}, expected interface{}) {
t.Helper()
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected: %v, got: %v", expected, got)
func Test_sshParser_read(t *testing.T) {
testFiles := map[string]string{
"/etc/ssh/config": heredoc.Doc(`
Include sites/*
`),
"/etc/ssh/sites/cfg1": heredoc.Doc(`
Host s1
Hostname=site1.net
`),
"/etc/ssh/sites/cfg2": heredoc.Doc(`
Host s2
Hostname = site2.net
`),
"HOME/.ssh/config": heredoc.Doc(`
Host *
Host gh gittyhubby
Hostname github.com
#Hostname example.com
Host ex
Include ex_config/*
`),
"HOME/.ssh/ex_config/ex_cfg": heredoc.Doc(`
Hostname example.com
`),
}
globResults := map[string][]string{
"/etc/ssh/sites/*": {"/etc/ssh/sites/cfg1", "/etc/ssh/sites/cfg2"},
"HOME/.ssh/ex_config/*": {"HOME/.ssh/ex_config/ex_cfg"},
}
p := &sshParser{
homeDir: "HOME",
open: func(s string) (io.Reader, error) {
if contents, ok := testFiles[filepath.ToSlash(s)]; ok {
return bytes.NewBufferString(contents), nil
} else {
return nil, fmt.Errorf("no test file stub found: %q", s)
}
},
glob: func(p string) ([]string, error) {
if results, ok := globResults[filepath.ToSlash(p)]; ok {
return results, nil
} else {
return nil, fmt.Errorf("no glob stubs found: %q", p)
}
},
}
if err := p.read("/etc/ssh/config"); err != nil {
t.Fatalf("read(global config) = %v", err)
}
if err := p.read("HOME/.ssh/config"); err != nil {
t.Fatalf("read(user config) = %v", err)
}
if got := p.aliasMap["gh"]; got != "github.com" {
t.Errorf("expected alias %q to expand to %q, got %q", "gh", "github.com", got)
}
if got := p.aliasMap["gittyhubby"]; got != "github.com" {
t.Errorf("expected alias %q to expand to %q, got %q", "gittyhubby", "github.com", got)
}
if got := p.aliasMap["example.com"]; got != "" {
t.Errorf("expected alias %q to expand to %q, got %q", "example.com", "", got)
}
if got := p.aliasMap["ex"]; got != "example.com" {
t.Errorf("expected alias %q to expand to %q, got %q", "ex", "example.com", got)
}
if got := p.aliasMap["s1"]; got != "site1.net" {
t.Errorf("expected alias %q to expand to %q, got %q", "s1", "site1.net", got)
}
}
func Test_sshParse(t *testing.T) {
m := sshParse(strings.NewReader(`
Host foo bar
HostName example.com
`), strings.NewReader(`
Host bar baz
hostname %%%h.net%%
`))
eq(t, m["foo"], "example.com")
eq(t, m["bar"], "%bar.net%")
eq(t, m["nonexist"], "")
func Test_sshParser_absolutePath(t *testing.T) {
dir := "HOME"
p := &sshParser{homeDir: dir}
tests := map[string]struct {
parentFile string
arg string
want string
wantErr bool
}{
"absolute path": {
parentFile: "/etc/ssh/ssh_config",
arg: "/etc/ssh/config",
want: "/etc/ssh/config",
},
"system relative path": {
parentFile: "/etc/ssh/config",
arg: "configs/*.conf",
want: filepath.Join("/etc", "ssh", "configs", "*.conf"),
},
"user relative path": {
parentFile: filepath.Join(dir, ".ssh", "ssh_config"),
arg: "configs/*.conf",
want: filepath.Join(dir, ".ssh", "configs/*.conf"),
},
"shell-like ~ rerefence": {
parentFile: filepath.Join(dir, ".ssh", "ssh_config"),
arg: "~/.ssh/*.conf",
want: filepath.Join(dir, ".ssh", "*.conf"),
},
}
for name, tt := range tests {
t.Run(name, func(t *testing.T) {
if got := p.absolutePath(tt.parentFile, tt.arg); got != tt.want {
t.Errorf("absolutePath(): %q, wants %q", got, tt.want)
}
})
}
}
func Test_Translator(t *testing.T) {

15
go.mod
View file

@ -3,30 +3,33 @@ module github.com/cli/cli
go 1.13
require (
github.com/AlecAivazis/survey/v2 v2.1.1
github.com/AlecAivazis/survey/v2 v2.2.7
github.com/MakeNowJust/heredoc v1.0.0
github.com/briandowns/spinner v1.11.1
github.com/charmbracelet/glamour v0.2.1-0.20200724174618-1246d13c1684
github.com/cli/oauth v0.8.0
github.com/cli/safeexec v1.0.0
github.com/cpuguy83/go-md2man/v2 v2.0.0
github.com/enescakir/emoji v1.0.0
github.com/google/go-cmp v0.5.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/hashicorp/go-version v1.2.1
github.com/henvic/httpretty v0.0.6
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.7
github.com/mattn/go-colorable v0.1.8
github.com/mattn/go-isatty v0.0.12
github.com/mattn/go-runewidth v0.0.9
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/mitchellh/go-homedir v1.1.0
github.com/muesli/termenv v0.7.2
github.com/muesli/termenv v0.7.4
github.com/rivo/uniseg v0.1.0
github.com/shurcooL/githubv4 v0.0.0-20200802174311-f27d2ca7f6d5
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/spf13/cobra v1.1.1
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.6.1
golang.org/x/crypto v0.0.0-20200820211705-5c72a883971a
golang.org/x/text v0.3.3 // indirect
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897
golang.org/x/text v0.3.4 // indirect
gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776
)

30
go.sum
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,9 +11,13 @@ import (
func TestInheritEnv(t *testing.T) {
orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN")
orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN")
orig_GH_TOKEN := os.Getenv("GH_TOKEN")
orig_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN")
t.Cleanup(func() {
os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN)
os.Setenv("GITHUB_ENTERPRISE_TOKEN", orig_GITHUB_ENTERPRISE_TOKEN)
os.Setenv("GH_TOKEN", orig_GH_TOKEN)
os.Setenv("GH_ENTERPRISE_TOKEN", orig_GH_ENTERPRISE_TOKEN)
})
type wants struct {
@ -28,15 +32,15 @@ func TestInheritEnv(t *testing.T) {
baseConfig string
GITHUB_TOKEN string
GITHUB_ENTERPRISE_TOKEN string
GH_TOKEN string
GH_ENTERPRISE_TOKEN string
hostname string
wants wants
}{
{
name: "blank",
baseConfig: ``,
GITHUB_TOKEN: "",
GITHUB_ENTERPRISE_TOKEN: "",
hostname: "github.com",
name: "blank",
baseConfig: ``,
hostname: "github.com",
wants: wants{
hosts: []string(nil),
token: "",
@ -45,11 +49,10 @@ func TestInheritEnv(t *testing.T) {
},
},
{
name: "GITHUB_TOKEN over blank config",
baseConfig: ``,
GITHUB_TOKEN: "OTOKEN",
GITHUB_ENTERPRISE_TOKEN: "",
hostname: "github.com",
name: "GITHUB_TOKEN over blank config",
baseConfig: ``,
GITHUB_TOKEN: "OTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
@ -58,11 +61,34 @@ func TestInheritEnv(t *testing.T) {
},
},
{
name: "GITHUB_TOKEN not applicable to GHE",
baseConfig: ``,
GITHUB_TOKEN: "OTOKEN",
GITHUB_ENTERPRISE_TOKEN: "",
hostname: "example.org",
name: "GH_TOKEN over blank config",
baseConfig: ``,
GH_TOKEN: "OTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
source: "GH_TOKEN",
writeable: false,
},
},
{
name: "GITHUB_TOKEN not applicable to GHE",
baseConfig: ``,
GITHUB_TOKEN: "OTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{"github.com"},
token: "",
source: "~/.config/gh/config.yml",
writeable: true,
},
},
{
name: "GH_TOKEN not applicable to GHE",
baseConfig: ``,
GH_TOKEN: "OTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{"github.com"},
token: "",
@ -73,7 +99,6 @@ func TestInheritEnv(t *testing.T) {
{
name: "GITHUB_ENTERPRISE_TOKEN over blank config",
baseConfig: ``,
GITHUB_TOKEN: "",
GITHUB_ENTERPRISE_TOKEN: "ENTOKEN",
hostname: "example.org",
wants: wants{
@ -83,6 +108,18 @@ func TestInheritEnv(t *testing.T) {
writeable: false,
},
},
{
name: "GH_ENTERPRISE_TOKEN over blank config",
baseConfig: ``,
GH_ENTERPRISE_TOKEN: "ENTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string(nil),
token: "ENTOKEN",
source: "GH_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "token from file",
baseConfig: heredoc.Doc(`
@ -90,9 +127,7 @@ func TestInheritEnv(t *testing.T) {
github.com:
oauth_token: OTOKEN
`),
GITHUB_TOKEN: "",
GITHUB_ENTERPRISE_TOKEN: "",
hostname: "github.com",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
@ -107,9 +142,8 @@ func TestInheritEnv(t *testing.T) {
github.com:
oauth_token: OTOKEN
`),
GITHUB_TOKEN: "ENVTOKEN",
GITHUB_ENTERPRISE_TOKEN: "",
hostname: "github.com",
GITHUB_TOKEN: "ENVTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "ENVTOKEN",
@ -117,6 +151,80 @@ func TestInheritEnv(t *testing.T) {
writeable: false,
},
},
{
name: "GH_TOKEN shadows token from file",
baseConfig: heredoc.Doc(`
hosts:
github.com:
oauth_token: OTOKEN
`),
GH_TOKEN: "ENVTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "ENVTOKEN",
source: "GH_TOKEN",
writeable: false,
},
},
{
name: "GITHUB_ENTERPRISE_TOKEN shadows token from file",
baseConfig: heredoc.Doc(`
hosts:
example.org:
oauth_token: OTOKEN
`),
GITHUB_ENTERPRISE_TOKEN: "ENVTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{"example.org"},
token: "ENVTOKEN",
source: "GITHUB_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "GH_ENTERPRISE_TOKEN shadows token from file",
baseConfig: heredoc.Doc(`
hosts:
example.org:
oauth_token: OTOKEN
`),
GH_ENTERPRISE_TOKEN: "ENVTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{"example.org"},
token: "ENVTOKEN",
source: "GH_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "GH_TOKEN shadows token from GITHUB_TOKEN",
baseConfig: ``,
GH_TOKEN: "GHTOKEN",
GITHUB_TOKEN: "GITHUBTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "GHTOKEN",
source: "GH_TOKEN",
writeable: false,
},
},
{
name: "GH_ENTERPRISE_TOKEN shadows token from GITHUB_ENTERPRISE_TOKEN",
baseConfig: ``,
GH_ENTERPRISE_TOKEN: "GHTOKEN",
GITHUB_ENTERPRISE_TOKEN: "GITHUBTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string(nil),
token: "GHTOKEN",
source: "GH_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "GITHUB_TOKEN adds host entry",
baseConfig: heredoc.Doc(`
@ -124,9 +232,8 @@ func TestInheritEnv(t *testing.T) {
example.org:
oauth_token: OTOKEN
`),
GITHUB_TOKEN: "ENVTOKEN",
GITHUB_ENTERPRISE_TOKEN: "",
hostname: "github.com",
GITHUB_TOKEN: "ENVTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com", "example.org"},
token: "ENVTOKEN",
@ -134,11 +241,29 @@ func TestInheritEnv(t *testing.T) {
writeable: false,
},
},
{
name: "GH_TOKEN adds host entry",
baseConfig: heredoc.Doc(`
hosts:
example.org:
oauth_token: OTOKEN
`),
GH_TOKEN: "ENVTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com", "example.org"},
token: "ENVTOKEN",
source: "GH_TOKEN",
writeable: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Setenv("GITHUB_TOKEN", tt.GITHUB_TOKEN)
os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN)
os.Setenv("GH_TOKEN", tt.GH_TOKEN)
os.Setenv("GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN)
baseCfg := NewFromString(tt.baseConfig)
cfg := InheritEnv(baseCfg)
@ -154,7 +279,66 @@ func TestInheritEnv(t *testing.T) {
assert.Equal(t, tt.wants.token, val)
err := cfg.CheckWriteable(tt.hostname, "oauth_token")
assert.Equal(t, tt.wants.writeable, err == nil)
if tt.wants.writeable != (err == nil) {
t.Errorf("CheckWriteable() = %v, wants %v", err, tt.wants.writeable)
}
})
}
}
func TestAuthTokenProvidedFromEnv(t *testing.T) {
orig_GITHUB_TOKEN := os.Getenv("GITHUB_TOKEN")
orig_GITHUB_ENTERPRISE_TOKEN := os.Getenv("GITHUB_ENTERPRISE_TOKEN")
orig_GH_TOKEN := os.Getenv("GH_TOKEN")
orig_GH_ENTERPRISE_TOKEN := os.Getenv("GH_ENTERPRISE_TOKEN")
t.Cleanup(func() {
os.Setenv("GITHUB_TOKEN", orig_GITHUB_TOKEN)
os.Setenv("GITHUB_ENTERPRISE_TOKEN", orig_GITHUB_ENTERPRISE_TOKEN)
os.Setenv("GH_TOKEN", orig_GH_TOKEN)
os.Setenv("GH_ENTERPRISE_TOKEN", orig_GH_ENTERPRISE_TOKEN)
})
tests := []struct {
name string
GITHUB_TOKEN string
GITHUB_ENTERPRISE_TOKEN string
GH_TOKEN string
GH_ENTERPRISE_TOKEN string
provided bool
}{
{
name: "no env tokens",
provided: false,
},
{
name: "GH_TOKEN",
GH_TOKEN: "TOKEN",
provided: true,
},
{
name: "GITHUB_TOKEN",
GITHUB_TOKEN: "TOKEN",
provided: true,
},
{
name: "GH_ENTERPRISE_TOKEN",
GH_ENTERPRISE_TOKEN: "TOKEN",
provided: true,
},
{
name: "GITHUB_ENTERPRISE_TOKEN",
GITHUB_ENTERPRISE_TOKEN: "TOKEN",
provided: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
os.Setenv("GITHUB_TOKEN", tt.GITHUB_TOKEN)
os.Setenv("GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN)
os.Setenv("GH_TOKEN", tt.GH_TOKEN)
os.Setenv("GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN)
assert.Equal(t, tt.provided, AuthTokenProvidedFromEnv())
})
}
}

51
internal/config/stub.go Normal file
View file

@ -0,0 +1,51 @@
package config
import (
"errors"
)
type ConfigStub map[string]string
func genKey(host, key string) string {
if host != "" {
return host + ":" + key
}
return key
}
func (c ConfigStub) Get(host, key string) (string, error) {
val, _, err := c.GetWithSource(host, key)
return val, err
}
func (c ConfigStub) GetWithSource(host, key string) (string, string, error) {
if v, found := c[genKey(host, key)]; found {
return v, "(memory)", nil
}
return "", "", errors.New("not found")
}
func (c ConfigStub) Set(host, key, value string) error {
c[genKey(host, key)] = value
return nil
}
func (c ConfigStub) Aliases() (*AliasConfig, error) {
return nil, nil
}
func (c ConfigStub) Hosts() ([]string, error) {
return nil, nil
}
func (c ConfigStub) UnsetHost(hostname string) {
}
func (c ConfigStub) CheckWriteable(host, key string) error {
return nil
}
func (c ConfigStub) Write() error {
c["_written"] = "true"
return nil
}

View file

@ -0,0 +1,91 @@
package docs
import (
"strings"
"testing"
"github.com/spf13/cobra"
)
func emptyRun(*cobra.Command, []string) {}
func init() {
rootCmd.PersistentFlags().StringP("rootflag", "r", "two", "")
rootCmd.PersistentFlags().StringP("strtwo", "t", "two", "help message for parent flag strtwo")
echoCmd.PersistentFlags().StringP("strone", "s", "one", "help message for flag strone")
echoCmd.PersistentFlags().BoolP("persistentbool", "p", false, "help message for flag persistentbool")
echoCmd.Flags().IntP("intone", "i", 123, "help message for flag intone")
echoCmd.Flags().BoolP("boolone", "b", true, "help message for flag boolone")
timesCmd.PersistentFlags().StringP("strtwo", "t", "2", "help message for child flag strtwo")
timesCmd.Flags().IntP("inttwo", "j", 234, "help message for flag inttwo")
timesCmd.Flags().BoolP("booltwo", "c", false, "help message for flag booltwo")
printCmd.PersistentFlags().StringP("strthree", "s", "three", "help message for flag strthree")
printCmd.Flags().IntP("intthree", "i", 345, "help message for flag intthree")
printCmd.Flags().BoolP("boolthree", "b", true, "help message for flag boolthree")
echoCmd.AddCommand(timesCmd, echoSubCmd, deprecatedCmd)
rootCmd.AddCommand(printCmd, echoCmd, dummyCmd)
}
var rootCmd = &cobra.Command{
Use: "root",
Short: "Root short description",
Long: "Root long description",
Run: emptyRun,
}
var echoCmd = &cobra.Command{
Use: "echo [string to echo]",
Aliases: []string{"say"},
Short: "Echo anything to the screen",
Long: "an utterly useless command for testing",
Example: "Just run cobra-test echo",
}
var echoSubCmd = &cobra.Command{
Use: "echosub [string to print]",
Short: "second sub command for echo",
Long: "an absolutely utterly useless command for testing gendocs!.",
Run: emptyRun,
}
var timesCmd = &cobra.Command{
Use: "times [# times] [string to echo]",
SuggestFor: []string{"counts"},
Short: "Echo anything to the screen more times",
Long: `a slightly useless command for testing.`,
Run: emptyRun,
}
var deprecatedCmd = &cobra.Command{
Use: "deprecated [can't do anything here]",
Short: "A command which is deprecated",
Long: `an absolutely utterly useless command for testing deprecation!.`,
Deprecated: "Please use echo instead",
}
var printCmd = &cobra.Command{
Use: "print [string to print]",
Short: "Print anything to the screen",
Long: `an absolutely utterly useless command for testing.`,
}
var dummyCmd = &cobra.Command{
Use: "dummy [action]",
Short: "Performs a dummy action",
}
func checkStringContains(t *testing.T, got, expected string) {
if !strings.Contains(got, expected) {
t.Errorf("Expected to contain: \n %v\nGot:\n %v\n", expected, got)
}
}
func checkStringOmits(t *testing.T, got, expected string) {
if strings.Contains(got, expected) {
t.Errorf("Expected to not contain: \n %v\nGot: %v", expected, got)
}
}

242
internal/docs/man.go Normal file
View file

@ -0,0 +1,242 @@
package docs
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"github.com/cpuguy83/go-md2man/v2/md2man"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// GenManTree will generate a man page for this command and all descendants
// in the directory given. The header may be nil. This function may not work
// correctly if your command names have `-` in them. If you have `cmd` with two
// subcmds, `sub` and `sub-third`, and `sub` has a subcommand called `third`
// it is undefined which help output will be in the file `cmd-sub-third.1`.
func GenManTree(cmd *cobra.Command, header *GenManHeader, dir string) error {
return GenManTreeFromOpts(cmd, GenManTreeOptions{
Header: header,
Path: dir,
CommandSeparator: "-",
})
}
// GenManTreeFromOpts generates a man page for the command and all descendants.
// The pages are written to the opts.Path directory.
func GenManTreeFromOpts(cmd *cobra.Command, opts GenManTreeOptions) error {
header := opts.Header
if header == nil {
header = &GenManHeader{}
}
for _, c := range cmd.Commands() {
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
continue
}
if err := GenManTreeFromOpts(c, opts); err != nil {
return err
}
}
section := "1"
if header.Section != "" {
section = header.Section
}
separator := "_"
if opts.CommandSeparator != "" {
separator = opts.CommandSeparator
}
basename := strings.Replace(cmd.CommandPath(), " ", separator, -1)
filename := filepath.Join(opts.Path, basename+"."+section)
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
headerCopy := *header
return GenMan(cmd, &headerCopy, f)
}
// GenManTreeOptions is the options for generating the man pages.
// Used only in GenManTreeFromOpts.
type GenManTreeOptions struct {
Header *GenManHeader
Path string
CommandSeparator string
}
// GenManHeader is a lot like the .TH header at the start of man pages. These
// include the title, section, date, source, and manual. We will use the
// current time if Date is unset.
type GenManHeader struct {
Title string
Section string
Date *time.Time
date string
Source string
Manual string
}
// GenMan will generate a man page for the given command and write it to
// w. The header argument may be nil, however obviously w may not.
func GenMan(cmd *cobra.Command, header *GenManHeader, w io.Writer) error {
if header == nil {
header = &GenManHeader{}
}
if err := fillHeader(header, cmd.CommandPath()); err != nil {
return err
}
b := genMan(cmd, header)
_, err := w.Write(md2man.Render(b))
return err
}
func fillHeader(header *GenManHeader, name string) error {
if header.Title == "" {
header.Title = strings.ToUpper(strings.Replace(name, " ", "\\-", -1))
}
if header.Section == "" {
header.Section = "1"
}
if header.Date == nil {
now := time.Now()
if epoch := os.Getenv("SOURCE_DATE_EPOCH"); epoch != "" {
unixEpoch, err := strconv.ParseInt(epoch, 10, 64)
if err != nil {
return fmt.Errorf("invalid SOURCE_DATE_EPOCH: %v", err)
}
now = time.Unix(unixEpoch, 0)
}
header.Date = &now
}
header.date = (*header.Date).Format("Jan 2006")
return nil
}
func manPreamble(buf *bytes.Buffer, header *GenManHeader, cmd *cobra.Command, dashedName string) {
description := cmd.Long
if len(description) == 0 {
description = cmd.Short
}
buf.WriteString(fmt.Sprintf(`%% "%s" "%s" "%s" "%s" "%s"
# NAME
`, header.Title, header.Section, header.date, header.Source, header.Manual))
buf.WriteString(fmt.Sprintf("%s \\- %s\n\n", dashedName, cmd.Short))
buf.WriteString("# SYNOPSIS\n")
buf.WriteString(fmt.Sprintf("**%s**\n\n", cmd.UseLine()))
buf.WriteString("# DESCRIPTION\n")
buf.WriteString(description + "\n\n")
}
func manPrintFlags(buf *bytes.Buffer, flags *pflag.FlagSet) {
flags.VisitAll(func(flag *pflag.Flag) {
if len(flag.Deprecated) > 0 || flag.Hidden {
return
}
format := ""
if len(flag.Shorthand) > 0 && len(flag.ShorthandDeprecated) == 0 {
format = fmt.Sprintf("**-%s**, **--%s**", flag.Shorthand, flag.Name)
} else {
format = fmt.Sprintf("**--%s**", flag.Name)
}
if len(flag.NoOptDefVal) > 0 {
format += "["
}
if flag.Value.Type() == "string" {
// put quotes on the value
format += "=%q"
} else {
format += "=%s"
}
if len(flag.NoOptDefVal) > 0 {
format += "]"
}
format += "\n\t%s\n\n"
buf.WriteString(fmt.Sprintf(format, flag.DefValue, flag.Usage))
})
}
func manPrintOptions(buf *bytes.Buffer, command *cobra.Command) {
flags := command.NonInheritedFlags()
if flags.HasAvailableFlags() {
buf.WriteString("# OPTIONS\n")
manPrintFlags(buf, flags)
buf.WriteString("\n")
}
flags = command.InheritedFlags()
if flags.HasAvailableFlags() {
buf.WriteString("# OPTIONS INHERITED FROM PARENT COMMANDS\n")
manPrintFlags(buf, flags)
buf.WriteString("\n")
}
}
func genMan(cmd *cobra.Command, header *GenManHeader) []byte {
cmd.InitDefaultHelpCmd()
cmd.InitDefaultHelpFlag()
// something like `rootcmd-subcmd1-subcmd2`
dashCommandName := strings.Replace(cmd.CommandPath(), " ", "-", -1)
buf := new(bytes.Buffer)
manPreamble(buf, header, cmd, dashCommandName)
manPrintOptions(buf, cmd)
if len(cmd.Example) > 0 {
buf.WriteString("# EXAMPLE\n")
buf.WriteString(fmt.Sprintf("```\n%s\n```\n", cmd.Example))
}
if hasSeeAlso(cmd) {
buf.WriteString("# SEE ALSO\n")
seealsos := make([]string, 0)
if cmd.HasParent() {
parentPath := cmd.Parent().CommandPath()
dashParentPath := strings.Replace(parentPath, " ", "-", -1)
seealso := fmt.Sprintf("**%s(%s)**", dashParentPath, header.Section)
seealsos = append(seealsos, seealso)
}
children := cmd.Commands()
sort.Sort(byName(children))
for _, c := range children {
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
continue
}
seealso := fmt.Sprintf("**%s-%s(%s)**", dashCommandName, c.Name(), header.Section)
seealsos = append(seealsos, seealso)
}
buf.WriteString(strings.Join(seealsos, ", ") + "\n")
}
return buf.Bytes()
}
// Test to see if we have a reason to print See Also information in docs
// Basically this is a test for a parent command or a subcommand which is
// both not deprecated and not the autogenerated help command.
func hasSeeAlso(cmd *cobra.Command) bool {
if cmd.HasParent() {
return true
}
for _, c := range cmd.Commands() {
if !c.IsAvailableCommand() || c.IsAdditionalHelpTopicCommand() {
continue
}
return true
}
return false
}
type byName []*cobra.Command
func (s byName) Len() int { return len(s) }
func (s byName) Swap(i, j int) { s[i], s[j] = s[j], s[i] }
func (s byName) Less(i, j int) bool { return s[i].Name() < s[j].Name() }

191
internal/docs/man_test.go Normal file
View file

@ -0,0 +1,191 @@
package docs
import (
"bufio"
"bytes"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"strings"
"testing"
"github.com/spf13/cobra"
)
func translate(in string) string {
return strings.Replace(in, "-", "\\-", -1)
}
func TestGenManDoc(t *testing.T) {
header := &GenManHeader{
Title: "Project",
Section: "2",
}
// We generate on a subcommand so we have both subcommands and parents
buf := new(bytes.Buffer)
if err := GenMan(echoCmd, header, buf); err != nil {
t.Fatal(err)
}
output := buf.String()
// Make sure parent has - in CommandPath() in SEE ALSO:
parentPath := echoCmd.Parent().CommandPath()
dashParentPath := strings.Replace(parentPath, " ", "-", -1)
expected := translate(dashParentPath)
expected = expected + "(" + header.Section + ")"
checkStringContains(t, output, expected)
checkStringContains(t, output, translate(echoCmd.Name()))
checkStringContains(t, output, translate(echoCmd.Name()))
checkStringContains(t, output, "boolone")
checkStringContains(t, output, "rootflag")
checkStringContains(t, output, translate(rootCmd.Name()))
checkStringContains(t, output, translate(echoSubCmd.Name()))
checkStringOmits(t, output, translate(deprecatedCmd.Name()))
}
func TestGenManNoHiddenParents(t *testing.T) {
header := &GenManHeader{
Title: "Project",
Section: "2",
}
// We generate on a subcommand so we have both subcommands and parents
for _, name := range []string{"rootflag", "strtwo"} {
f := rootCmd.PersistentFlags().Lookup(name)
f.Hidden = true
defer func() { f.Hidden = false }()
}
buf := new(bytes.Buffer)
if err := GenMan(echoCmd, header, buf); err != nil {
t.Fatal(err)
}
output := buf.String()
// Make sure parent has - in CommandPath() in SEE ALSO:
parentPath := echoCmd.Parent().CommandPath()
dashParentPath := strings.Replace(parentPath, " ", "-", -1)
expected := translate(dashParentPath)
expected = expected + "(" + header.Section + ")"
checkStringContains(t, output, expected)
checkStringContains(t, output, translate(echoCmd.Name()))
checkStringContains(t, output, translate(echoCmd.Name()))
checkStringContains(t, output, "boolone")
checkStringOmits(t, output, "rootflag")
checkStringContains(t, output, translate(rootCmd.Name()))
checkStringContains(t, output, translate(echoSubCmd.Name()))
checkStringOmits(t, output, translate(deprecatedCmd.Name()))
checkStringOmits(t, output, "OPTIONS INHERITED FROM PARENT COMMANDS")
}
func TestGenManSeeAlso(t *testing.T) {
rootCmd := &cobra.Command{Use: "root", Run: emptyRun}
aCmd := &cobra.Command{Use: "aaa", Run: emptyRun, Hidden: true} // #229
bCmd := &cobra.Command{Use: "bbb", Run: emptyRun}
cCmd := &cobra.Command{Use: "ccc", Run: emptyRun}
rootCmd.AddCommand(aCmd, bCmd, cCmd)
buf := new(bytes.Buffer)
header := &GenManHeader{}
if err := GenMan(rootCmd, header, buf); err != nil {
t.Fatal(err)
}
scanner := bufio.NewScanner(buf)
if err := assertLineFound(scanner, ".SH SEE ALSO"); err != nil {
t.Fatalf("Couldn't find SEE ALSO section header: %v", err)
}
if err := assertNextLineEquals(scanner, ".PP"); err != nil {
t.Fatalf("First line after SEE ALSO wasn't break-indent: %v", err)
}
if err := assertNextLineEquals(scanner, `\fBroot\-bbb(1)\fP, \fBroot\-ccc(1)\fP`); err != nil {
t.Fatalf("Second line after SEE ALSO wasn't correct: %v", err)
}
}
func TestManPrintFlagsHidesShortDeprecated(t *testing.T) {
c := &cobra.Command{}
c.Flags().StringP("foo", "f", "default", "Foo flag")
_ = c.Flags().MarkShorthandDeprecated("foo", "don't use it no more")
buf := new(bytes.Buffer)
manPrintFlags(buf, c.Flags())
got := buf.String()
expected := "**--foo**=\"default\"\n\tFoo flag\n\n"
if got != expected {
t.Errorf("Expected %v, got %v", expected, got)
}
}
func TestGenManTree(t *testing.T) {
c := &cobra.Command{Use: "do [OPTIONS] arg1 arg2"}
header := &GenManHeader{Section: "2"}
tmpdir, err := ioutil.TempDir("", "test-gen-man-tree")
if err != nil {
t.Fatalf("Failed to create tmpdir: %s", err.Error())
}
defer os.RemoveAll(tmpdir)
if err := GenManTree(c, header, tmpdir); err != nil {
t.Fatalf("GenManTree failed: %s", err.Error())
}
if _, err := os.Stat(filepath.Join(tmpdir, "do.2")); err != nil {
t.Fatalf("Expected file 'do.2' to exist")
}
if header.Title != "" {
t.Fatalf("Expected header.Title to be unmodified")
}
}
func assertLineFound(scanner *bufio.Scanner, expectedLine string) error {
for scanner.Scan() {
line := scanner.Text()
if line == expectedLine {
return nil
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan failed: %s", err)
}
return fmt.Errorf("hit EOF before finding %v", expectedLine)
}
func assertNextLineEquals(scanner *bufio.Scanner, expectedLine string) error {
if scanner.Scan() {
line := scanner.Text()
if line == expectedLine {
return nil
}
return fmt.Errorf("got %v, not %v", line, expectedLine)
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("scan failed: %v", err)
}
return fmt.Errorf("hit EOF before finding %v", expectedLine)
}
func BenchmarkGenManToFile(b *testing.B) {
file, err := ioutil.TempFile("", "")
if err != nil {
b.Fatal(err)
}
defer os.Remove(file.Name())
defer file.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := GenMan(rootCmd, nil, file); err != nil {
b.Fatal(err)
}
}
}

114
internal/docs/markdown.go Normal file
View file

@ -0,0 +1,114 @@
package docs
import (
"bytes"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/spf13/cobra"
)
func printOptions(buf *bytes.Buffer, cmd *cobra.Command, name string) error {
flags := cmd.NonInheritedFlags()
flags.SetOutput(buf)
if flags.HasAvailableFlags() {
buf.WriteString("### Options\n\n```\n")
flags.PrintDefaults()
buf.WriteString("```\n\n")
}
parentFlags := cmd.InheritedFlags()
parentFlags.SetOutput(buf)
if parentFlags.HasAvailableFlags() {
buf.WriteString("### Options inherited from parent commands\n\n```\n")
parentFlags.PrintDefaults()
buf.WriteString("```\n\n")
}
return nil
}
// GenMarkdown creates markdown output.
func GenMarkdown(cmd *cobra.Command, w io.Writer) error {
return GenMarkdownCustom(cmd, w, func(s string) string { return s })
}
// GenMarkdownCustom creates custom markdown output.
func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) string) error {
cmd.InitDefaultHelpCmd()
cmd.InitDefaultHelpFlag()
buf := new(bytes.Buffer)
name := cmd.CommandPath()
buf.WriteString("## " + name + "\n\n")
buf.WriteString(cmd.Short + "\n\n")
if len(cmd.Long) > 0 {
buf.WriteString("### Synopsis\n\n")
buf.WriteString(cmd.Long + "\n\n")
}
if cmd.Runnable() {
buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.UseLine()))
}
if len(cmd.Example) > 0 {
buf.WriteString("### Examples\n\n")
buf.WriteString(fmt.Sprintf("```\n%s\n```\n\n", cmd.Example))
}
if err := printOptions(buf, cmd, name); err != nil {
return err
}
_, err := buf.WriteTo(w)
return err
}
// GenMarkdownTree will generate a markdown page for this command and all
// descendants in the directory given. The header may be nil.
// This function may not work correctly if your command names have `-` in them.
// If you have `cmd` with two subcmds, `sub` and `sub-third`,
// and `sub` has a subcommand called `third`, it is undefined which
// help output will be in the file `cmd-sub-third.1`.
func GenMarkdownTree(cmd *cobra.Command, dir string) error {
identity := func(s string) string { return s }
emptyStr := func(s string) string { return "" }
return GenMarkdownTreeCustom(cmd, dir, emptyStr, identity)
}
// GenMarkdownTreeCustom is the the same as GenMarkdownTree, but
// with custom filePrepender and linkHandler.
func GenMarkdownTreeCustom(cmd *cobra.Command, dir string, filePrepender, linkHandler func(string) string) error {
for _, c := range cmd.Commands() {
_, forceGeneration := c.Annotations["markdown:generate"]
if c.Hidden && !forceGeneration {
continue
}
if err := GenMarkdownTreeCustom(c, dir, filePrepender, linkHandler); err != nil {
return err
}
}
basename := strings.Replace(cmd.CommandPath(), " ", "_", -1) + ".md"
if basenameOverride, found := cmd.Annotations["markdown:basename"]; found {
basename = basenameOverride + ".md"
}
filename := filepath.Join(dir, basename)
f, err := os.Create(filename)
if err != nil {
return err
}
defer f.Close()
if _, err := io.WriteString(f, filePrepender(filename)); err != nil {
return err
}
if err := GenMarkdownCustom(cmd, f, linkHandler); err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,99 @@
package docs
import (
"bytes"
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/spf13/cobra"
)
func TestGenMdDoc(t *testing.T) {
// We generate on subcommand so we have both subcommands and parents.
buf := new(bytes.Buffer)
if err := GenMarkdown(echoCmd, buf); err != nil {
t.Fatal(err)
}
output := buf.String()
checkStringContains(t, output, echoCmd.Long)
checkStringContains(t, output, echoCmd.Example)
checkStringContains(t, output, "boolone")
checkStringContains(t, output, "rootflag")
checkStringOmits(t, output, rootCmd.Short)
checkStringOmits(t, output, echoSubCmd.Short)
checkStringOmits(t, output, deprecatedCmd.Short)
checkStringContains(t, output, "Options inherited from parent commands")
}
func TestGenMdDocWithNoLongOrSynopsis(t *testing.T) {
// We generate on subcommand so we have both subcommands and parents.
buf := new(bytes.Buffer)
if err := GenMarkdown(dummyCmd, buf); err != nil {
t.Fatal(err)
}
output := buf.String()
checkStringContains(t, output, dummyCmd.Example)
checkStringContains(t, output, dummyCmd.Short)
checkStringContains(t, output, "Options inherited from parent commands")
checkStringOmits(t, output, "### Synopsis")
}
func TestGenMdNoHiddenParents(t *testing.T) {
// We generate on subcommand so we have both subcommands and parents.
for _, name := range []string{"rootflag", "strtwo"} {
f := rootCmd.PersistentFlags().Lookup(name)
f.Hidden = true
defer func() { f.Hidden = false }()
}
buf := new(bytes.Buffer)
if err := GenMarkdown(echoCmd, buf); err != nil {
t.Fatal(err)
}
output := buf.String()
checkStringContains(t, output, echoCmd.Long)
checkStringContains(t, output, echoCmd.Example)
checkStringContains(t, output, "boolone")
checkStringOmits(t, output, "rootflag")
checkStringOmits(t, output, rootCmd.Short)
checkStringOmits(t, output, echoSubCmd.Short)
checkStringOmits(t, output, deprecatedCmd.Short)
checkStringOmits(t, output, "Options inherited from parent commands")
}
func TestGenMdTree(t *testing.T) {
c := &cobra.Command{Use: "do [OPTIONS] arg1 arg2"}
tmpdir, err := ioutil.TempDir("", "test-gen-md-tree")
if err != nil {
t.Fatalf("Failed to create tmpdir: %v", err)
}
defer os.RemoveAll(tmpdir)
if err := GenMarkdownTree(c, tmpdir); err != nil {
t.Fatalf("GenMarkdownTree failed: %v", err)
}
if _, err := os.Stat(filepath.Join(tmpdir, "do.md")); err != nil {
t.Fatalf("Expected file 'do.md' to exist")
}
}
func BenchmarkGenMarkdownToFile(b *testing.B) {
file, err := ioutil.TempFile("", "")
if err != nil {
b.Fatal(err)
}
defer os.Remove(file.Name())
defer file.Close()
b.ResetTimer()
for i := 0; i < b.N; i++ {
if err := GenMarkdown(rootCmd, file); err != nil {
b.Fatal(err)
}
}
}

View file

@ -139,7 +139,7 @@ func TestHostnameValidator(t *testing.T) {
assert.Error(t, err)
return
}
assert.Equal(t, nil, err)
assert.NoError(t, err)
})
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -11,7 +11,7 @@ import (
func NewCmdAlias(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "alias",
Use: "alias <command>",
Short: "Create command shortcuts",
Long: heredoc.Doc(`
Aliases can be used to make shortcuts for gh commands or to compose multiple commands.

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,117 @@
package login
import (
"bufio"
"fmt"
"net/url"
"strings"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
type config interface {
Get(string, string) (string, error)
}
type CredentialOptions struct {
IO *iostreams.IOStreams
Config func() (config, error)
Operation string
}
func NewCmdCredential(f *cmdutil.Factory, runF func(*CredentialOptions) error) *cobra.Command {
opts := &CredentialOptions{
IO: f.IOStreams,
Config: func() (config, error) {
return f.Config()
},
}
cmd := &cobra.Command{
Use: "git-credential",
Args: cobra.ExactArgs(1),
Short: "Implements git credential helper protocol",
Hidden: true,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Operation = args[0]
if runF != nil {
return runF(opts)
}
return helperRun(opts)
},
}
return cmd
}
func helperRun(opts *CredentialOptions) error {
if opts.Operation == "store" {
// We pretend to implement the "store" operation, but do nothing since we already have a cached token.
return cmdutil.SilentError
}
if opts.Operation != "get" {
return fmt.Errorf("gh auth git-credential: %q operation not supported", opts.Operation)
}
wants := map[string]string{}
s := bufio.NewScanner(opts.IO.In)
for s.Scan() {
line := s.Text()
if line == "" {
break
}
parts := strings.SplitN(line, "=", 2)
if len(parts) < 2 {
continue
}
key, value := parts[0], parts[1]
if key == "url" {
u, err := url.Parse(value)
if err != nil {
return err
}
wants["protocol"] = u.Scheme
wants["host"] = u.Host
wants["path"] = u.Path
wants["username"] = u.User.Username()
wants["password"], _ = u.User.Password()
} else {
wants[key] = value
}
}
if err := s.Err(); err != nil {
return err
}
if wants["protocol"] != "https" {
return cmdutil.SilentError
}
cfg, err := opts.Config()
if err != nil {
return err
}
gotUser, _ := cfg.Get(wants["host"], "user")
gotToken, _ := cfg.Get(wants["host"], "oauth_token")
if gotUser == "" || gotToken == "" {
return cmdutil.SilentError
}
if wants["username"] != "" && !strings.EqualFold(wants["username"], gotUser) {
return cmdutil.SilentError
}
fmt.Fprint(opts.IO.Out, "protocol=https\n")
fmt.Fprintf(opts.IO.Out, "host=%s\n", wants["host"])
fmt.Fprintf(opts.IO.Out, "username=%s\n", gotUser)
fmt.Fprintf(opts.IO.Out, "password=%s\n", gotToken)
return nil
}

View file

@ -0,0 +1,154 @@
package login
import (
"fmt"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/pkg/iostreams"
)
type tinyConfig map[string]string
func (c tinyConfig) Get(host, key string) (string, error) {
return c[fmt.Sprintf("%s:%s", host, key)], nil
}
func Test_helperRun(t *testing.T) {
tests := []struct {
name string
opts CredentialOptions
input string
wantStdout string
wantStderr string
wantErr bool
}{
{
name: "host only, credentials found",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"example.com:user": "monalisa",
"example.com:oauth_token": "OTOKEN",
}, nil
},
},
input: heredoc.Doc(`
protocol=https
host=example.com
`),
wantErr: false,
wantStdout: heredoc.Doc(`
protocol=https
host=example.com
username=monalisa
password=OTOKEN
`),
wantStderr: "",
},
{
name: "host plus user",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"example.com:user": "monalisa",
"example.com:oauth_token": "OTOKEN",
}, nil
},
},
input: heredoc.Doc(`
protocol=https
host=example.com
username=monalisa
`),
wantErr: false,
wantStdout: heredoc.Doc(`
protocol=https
host=example.com
username=monalisa
password=OTOKEN
`),
wantStderr: "",
},
{
name: "url input",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"example.com:user": "monalisa",
"example.com:oauth_token": "OTOKEN",
}, nil
},
},
input: heredoc.Doc(`
url=https://monalisa@example.com
`),
wantErr: false,
wantStdout: heredoc.Doc(`
protocol=https
host=example.com
username=monalisa
password=OTOKEN
`),
wantStderr: "",
},
{
name: "host only, no credentials found",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"example.com:user": "monalisa",
}, nil
},
},
input: heredoc.Doc(`
protocol=https
host=example.com
`),
wantErr: true,
wantStdout: "",
wantStderr: "",
},
{
name: "user mismatch",
opts: CredentialOptions{
Operation: "get",
Config: func() (config, error) {
return tinyConfig{
"example.com:user": "monalisa",
"example.com:oauth_token": "OTOKEN",
}, nil
},
},
input: heredoc.Doc(`
protocol=https
host=example.com
username=hubot
`),
wantErr: true,
wantStdout: "",
wantStderr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, stdin, stdout, stderr := iostreams.Test()
fmt.Fprint(stdin, tt.input)
opts := &tt.opts
opts.IO = io
if err := helperRun(opts); (err != nil) != tt.wantErr {
t.Fatalf("helperRun() error = %v, wantErr %v", err, tt.wantErr)
}
if tt.wantStdout != stdout.String() {
t.Errorf("stdout: got %q, wants %q", stdout.String(), tt.wantStdout)
}
if tt.wantStderr != stderr.String() {
t.Errorf("stderr: got %q, wants %q", stderr.String(), tt.wantStderr)
}
})
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,110 @@
package shared
import (
"bytes"
"fmt"
"path/filepath"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/prompt"
"github.com/google/shlex"
)
type configReader interface {
Get(string, string) (string, error)
}
func GitCredentialSetup(cfg configReader, hostname, username string) error {
helper, _ := gitCredentialHelper(hostname)
if isOurCredentialHelper(helper) {
return nil
}
var primeCredentials bool
err := prompt.SurveyAskOne(&survey.Confirm{
Message: "Authenticate Git with your GitHub credentials?",
Default: true,
}, &primeCredentials)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
if !primeCredentials {
return nil
}
if helper == "" {
// use GitHub CLI as a credential helper (for this host only)
configureCmd, err := git.GitCommand("config", "--global", gitCredentialHelperKey(hostname), "!gh auth git-credential")
if err != nil {
return err
}
return run.PrepareCmd(configureCmd).Run()
}
// clear previous cached credentials
rejectCmd, err := git.GitCommand("credential", "reject")
if err != nil {
return err
}
rejectCmd.Stdin = bytes.NewBufferString(heredoc.Docf(`
protocol=https
host=%s
`, hostname))
err = run.PrepareCmd(rejectCmd).Run()
if err != nil {
return err
}
approveCmd, err := git.GitCommand("credential", "approve")
if err != nil {
return err
}
password, _ := cfg.Get(hostname, "oauth_token")
approveCmd.Stdin = bytes.NewBufferString(heredoc.Docf(`
protocol=https
host=%s
username=%s
password=%s
`, hostname, username, password))
err = run.PrepareCmd(approveCmd).Run()
if err != nil {
return err
}
return nil
}
func gitCredentialHelperKey(hostname string) string {
return fmt.Sprintf("credential.https://%s.helper", hostname)
}
func gitCredentialHelper(hostname string) (helper string, err error) {
helper, err = git.Config(gitCredentialHelperKey(hostname))
if helper != "" {
return
}
helper, err = git.Config("credential.helper")
return
}
func isOurCredentialHelper(cmd string) bool {
if !strings.HasPrefix(cmd, "!") {
return false
}
args, err := shlex.Split(cmd[1:])
if err != nil || len(args) == 0 {
return false
}
return strings.TrimSuffix(filepath.Base(args[0]), ".exe") == "gh"
}

View file

@ -0,0 +1,88 @@
package shared
import (
"fmt"
"testing"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/prompt"
)
type tinyConfig map[string]string
func (c tinyConfig) Get(host, key string) (string, error) {
return c[fmt.Sprintf("%s:%s", host, key)], nil
}
func TestGitCredentialSetup_configureExisting(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
cs.Register(`git config credential\.helper`, 0, "osxkeychain\n")
cs.Register(`git credential reject`, 0, "")
cs.Register(`git credential approve`, 0, "")
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
as.StubOne(true)
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}
func TestGitCredentialSetup_setOurs(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
cs.Register(`git config credential\.helper`, 1, "")
cs.Register(`git config --global credential\.https://example\.com\.helper`, 0, "", func(args []string) {
if val := args[len(args)-1]; val != "!gh auth git-credential" {
t.Errorf("global credential helper configured to %q", val)
}
})
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
as.StubOne(true)
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}
func TestGitCredentialSetup_promptDeny(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 1, "")
cs.Register(`git config credential\.helper`, 1, "")
as, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
as.StubOne(false)
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}
func TestGitCredentialSetup_isOurs(t *testing.T) {
cfg := tinyConfig{"example.com:oauth_token": "OTOKEN"}
cs, restoreRun := run.Stub()
defer restoreRun(t)
cs.Register(`git config credential\.https://example\.com\.helper`, 0, "!/path/to/gh auth\n")
_, restoreAsk := prompt.InitAskStubber()
defer restoreAsk()
if err := GitCredentialSetup(cfg, "example.com", "monalisa"); err != nil {
t.Errorf("GitCredentialSetup() error = %v", err)
}
}

View file

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

View file

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

View file

@ -14,22 +14,44 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
var shellType string
cmd := &cobra.Command{
Use: "completion",
Use: "completion -s <shell>",
Short: "Generate shell completion scripts",
Long: heredoc.Doc(`
Long: heredoc.Docf(`
Generate shell completion scripts for GitHub CLI commands.
The output of this command will be computer code and is meant to be saved to a
file or immediately evaluated by an interactive shell.
For example, for bash you could add this to your '~/.bash_profile':
eval "$(gh completion -s bash)"
When installing GitHub CLI through a package manager, however, it's possible that
When installing GitHub CLI through a package manager, it's possible that
no additional shell configuration is necessary to gain completion support. For
Homebrew, see https://docs.brew.sh/Shell-Completion
`),
If you need to set up completions manually, follow the instructions below. The exact
config file locations might vary based on your system. Make sure to restart your
shell before testing whether completions are working.
### bash
Add this to your %[1]s~/.bash_profile%[1]s:
eval "$(gh completion -s bash)"
### zsh
Generate a %[1]s_gh%[1]s completion script and put it somewhere in your %[1]s$fpath%[1]s:
gh completion -s zsh > /usr/local/share/zsh/site-functions/_gh
Ensure that the following is present in your %[1]s~/.zshrc%[1]s:
autoload -U compinit
compinit -i
Zsh version 5.7 or later is recommended.
### fish
Generate a %[1]sgh.fish%[1]s completion script:
gh completion -s fish > ~/.config/fish/completions/gh.fish
`, "`"),
RunE: func(cmd *cobra.Command, args []string) error {
if shellType == "" {
if io.IsStdoutTTY() {
@ -54,6 +76,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
return fmt.Errorf("unsupported shell type %q", shellType)
}
},
DisableFlagsInUseLine: true,
}
cmdutil.DisableAuthCheck(cmd)

View file

@ -1,115 +1,38 @@
package config
import (
"errors"
"fmt"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/config"
cmdGet "github.com/cli/cli/pkg/cmd/config/get"
cmdSet "github.com/cli/cli/pkg/cmd/config/set"
"github.com/cli/cli/pkg/cmdutil"
"github.com/spf13/cobra"
)
func NewCmdConfig(f *cmdutil.Factory) *cobra.Command {
longDoc := strings.Builder{}
longDoc.WriteString("Display or change configuration settings for gh.\n\n")
longDoc.WriteString("Current respected settings:\n")
for _, co := range config.ConfigOptions() {
longDoc.WriteString(fmt.Sprintf("- %s: %s", co.Key, co.Description))
if co.DefaultValue != "" {
longDoc.WriteString(fmt.Sprintf(" (default: %q)", co.DefaultValue))
}
longDoc.WriteRune('\n')
}
cmd := &cobra.Command{
Use: "config",
Use: "config <command>",
Short: "Manage configuration for gh",
Long: heredoc.Doc(`
Display or change configuration settings for gh.
Current respected settings:
- git_protocol: "https" or "ssh". Default is "https".
- editor: if unset, defaults to environment variables.
- prompt: "enabled" or "disabled". Toggles interactive prompting.
- pager: terminal pager program to send standard output to.
`),
Long: longDoc.String(),
}
cmdutil.DisableAuthCheck(cmd)
cmd.AddCommand(NewCmdConfigGet(f))
cmd.AddCommand(NewCmdConfigSet(f))
return cmd
}
func NewCmdConfigGet(f *cmdutil.Factory) *cobra.Command {
var hostname string
cmd := &cobra.Command{
Use: "get <key>",
Short: "Print the value of a given configuration key",
Example: heredoc.Doc(`
$ gh config get git_protocol
https
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
val, err := cfg.Get(hostname, args[0])
if err != nil {
return err
}
if val != "" {
fmt.Fprintf(f.IOStreams.Out, "%s\n", val)
}
return nil
},
}
cmd.Flags().StringVarP(&hostname, "host", "h", "", "Get per-host setting")
return cmd
}
func NewCmdConfigSet(f *cmdutil.Factory) *cobra.Command {
var hostname string
cmd := &cobra.Command{
Use: "set <key> <value>",
Short: "Update configuration with a value for the given key",
Example: heredoc.Doc(`
$ gh config set editor vim
$ gh config set editor "code --wait"
$ gh config set git_protocol ssh
$ gh config set prompt disabled
`),
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
cfg, err := f.Config()
if err != nil {
return err
}
key, value := args[0], args[1]
err = cfg.Set(hostname, key, value)
if err != nil {
var invalidValue *config.InvalidValueError
if errors.As(err, &invalidValue) {
var values []string
for _, v := range invalidValue.ValidValues {
values = append(values, fmt.Sprintf("'%s'", v))
}
return fmt.Errorf("failed to set %q to %q: valid values are %v", key, value, strings.Join(values, ", "))
}
return fmt.Errorf("failed to set %q to %q: %w", key, value, err)
}
err = cfg.Write()
if err != nil {
return fmt.Errorf("failed to write config to disk: %w", err)
}
return nil
},
}
cmd.Flags().StringVarP(&hostname, "host", "h", "", "Set per-host setting")
cmd.AddCommand(cmdGet.NewCmdConfigGet(f, nil))
cmd.AddCommand(cmdSet.NewCmdConfigSet(f, nil))
return cmd
}

View file

@ -1,165 +0,0 @@
package config
import (
"errors"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type configStub map[string]string
func genKey(host, key string) string {
if host != "" {
return host + ":" + key
}
return key
}
func (c configStub) Get(host, key string) (string, error) {
val, _, err := c.GetWithSource(host, key)
return val, err
}
func (c configStub) GetWithSource(host, key string) (string, string, error) {
if v, found := c[genKey(host, key)]; found {
return v, "(memory)", nil
}
return "", "", errors.New("not found")
}
func (c configStub) Set(host, key, value string) error {
c[genKey(host, key)] = value
return nil
}
func (c configStub) Aliases() (*config.AliasConfig, error) {
return nil, nil
}
func (c configStub) Hosts() ([]string, error) {
return nil, nil
}
func (c configStub) UnsetHost(hostname string) {
}
func (c configStub) CheckWriteable(host, key string) error {
return nil
}
func (c configStub) Write() error {
c["_written"] = "true"
return nil
}
func TestConfigGet(t *testing.T) {
tests := []struct {
name string
config configStub
args []string
stdout string
stderr string
}{
{
name: "get key",
config: configStub{
"editor": "ed",
},
args: []string{"editor"},
stdout: "ed\n",
stderr: "",
},
{
name: "get key scoped by host",
config: configStub{
"editor": "ed",
"github.com:editor": "vim",
},
args: []string{"editor", "-h", "github.com"},
stdout: "vim\n",
stderr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: io,
Config: func() (config.Config, error) {
return tt.config, nil
},
}
cmd := NewCmdConfigGet(f)
cmd.Flags().BoolP("help", "x", false, "")
cmd.SetArgs(tt.args)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
_, err := cmd.ExecuteC()
require.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())
assert.Equal(t, "", tt.config["_written"])
})
}
}
func TestConfigSet(t *testing.T) {
tests := []struct {
name string
config configStub
args []string
expectKey string
stdout string
stderr string
}{
{
name: "set key",
config: configStub{},
args: []string{"editor", "vim"},
expectKey: "editor",
stdout: "",
stderr: "",
},
{
name: "set key scoped by host",
config: configStub{},
args: []string{"editor", "vim", "-h", "github.com"},
expectKey: "github.com:editor",
stdout: "",
stderr: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
f := &cmdutil.Factory{
IOStreams: io,
Config: func() (config.Config, error) {
return tt.config, nil
},
}
cmd := NewCmdConfigSet(f)
cmd.Flags().BoolP("help", "x", false, "")
cmd.SetArgs(tt.args)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
_, err := cmd.ExecuteC()
require.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())
assert.Equal(t, "vim", tt.config[tt.expectKey])
assert.Equal(t, "true", tt.config["_written"])
})
}
}

65
pkg/cmd/config/get/get.go Normal file
View file

@ -0,0 +1,65 @@
package get
import (
"fmt"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
type GetOptions struct {
IO *iostreams.IOStreams
Config config.Config
Hostname string
Key string
}
func NewCmdConfigGet(f *cmdutil.Factory, runF func(*GetOptions) error) *cobra.Command {
opts := &GetOptions{
IO: f.IOStreams,
}
cmd := &cobra.Command{
Use: "get <key>",
Short: "Print the value of a given configuration key",
Example: heredoc.Doc(`
$ gh config get git_protocol
https
`),
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := f.Config()
if err != nil {
return err
}
opts.Config = config
opts.Key = args[0]
if runF != nil {
return runF(opts)
}
return getRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Hostname, "host", "h", "", "Get per-host setting")
return cmd
}
func getRun(opts *GetOptions) error {
val, err := opts.Config.Get(opts.Hostname, opts.Key)
if err != nil {
return err
}
if val != "" {
fmt.Fprintf(opts.IO.Out, "%s\n", val)
}
return nil
}

View file

@ -0,0 +1,122 @@
package get
import (
"bytes"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdConfigGet(t *testing.T) {
tests := []struct {
name string
input string
output GetOptions
wantsErr bool
}{
{
name: "no arguments",
input: "",
output: GetOptions{},
wantsErr: true,
},
{
name: "get key",
input: "key",
output: GetOptions{Key: "key"},
wantsErr: false,
},
{
name: "get key with host",
input: "key --host test.com",
output: GetOptions{Hostname: "test.com", Key: "key"},
wantsErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &cmdutil.Factory{
Config: func() (config.Config, error) {
return config.ConfigStub{}, nil
},
}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *GetOptions
cmd := NewCmdConfigGet(f, func(opts *GetOptions) error {
gotOpts = opts
return nil
})
cmd.Flags().BoolP("help", "x", false, "")
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output.Hostname, gotOpts.Hostname)
assert.Equal(t, tt.output.Key, gotOpts.Key)
})
}
}
func Test_getRun(t *testing.T) {
tests := []struct {
name string
input *GetOptions
stdout string
stderr string
wantErr bool
}{
{
name: "get key",
input: &GetOptions{
Key: "editor",
Config: config.ConfigStub{
"editor": "ed",
},
},
stdout: "ed\n",
},
{
name: "get key scoped by host",
input: &GetOptions{
Hostname: "github.com",
Key: "editor",
Config: config.ConfigStub{
"editor": "ed",
"github.com:editor": "vim",
},
},
stdout: "vim\n",
},
}
for _, tt := range tests {
io, _, stdout, stderr := iostreams.Test()
tt.input.IO = io
t.Run(tt.name, func(t *testing.T) {
err := getRun(tt.input)
assert.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())
_, err = tt.input.Config.Get("", "_written")
assert.Error(t, err)
})
}
}

90
pkg/cmd/config/set/set.go Normal file
View file

@ -0,0 +1,90 @@
package set
import (
"errors"
"fmt"
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
type SetOptions struct {
IO *iostreams.IOStreams
Config config.Config
Key string
Value string
Hostname string
}
func NewCmdConfigSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command {
opts := &SetOptions{
IO: f.IOStreams,
}
cmd := &cobra.Command{
Use: "set <key> <value>",
Short: "Update configuration with a value for the given key",
Example: heredoc.Doc(`
$ gh config set editor vim
$ gh config set editor "code --wait"
$ gh config set git_protocol ssh --host github.com
$ gh config set prompt disabled
`),
Args: cobra.ExactArgs(2),
RunE: func(cmd *cobra.Command, args []string) error {
config, err := f.Config()
if err != nil {
return err
}
opts.Config = config
opts.Key = args[0]
opts.Value = args[1]
if runF != nil {
return runF(opts)
}
return setRun(opts)
},
}
cmd.Flags().StringVarP(&opts.Hostname, "host", "h", "", "Set per-host setting")
return cmd
}
func setRun(opts *SetOptions) error {
err := config.ValidateKey(opts.Key)
if err != nil {
warningIcon := opts.IO.ColorScheme().WarningIcon()
fmt.Fprintf(opts.IO.ErrOut, "%s warning: '%s' is not a known configuration key\n", warningIcon, opts.Key)
}
err = config.ValidateValue(opts.Key, opts.Value)
if err != nil {
var invalidValue *config.InvalidValueError
if errors.As(err, &invalidValue) {
var values []string
for _, v := range invalidValue.ValidValues {
values = append(values, fmt.Sprintf("'%s'", v))
}
return fmt.Errorf("failed to set %q to %q: valid values are %v", opts.Key, opts.Value, strings.Join(values, ", "))
}
}
err = opts.Config.Set(opts.Hostname, opts.Key, opts.Value)
if err != nil {
return fmt.Errorf("failed to set %q to %q: %w", opts.Key, opts.Value, err)
}
err = opts.Config.Write()
if err != nil {
return fmt.Errorf("failed to write config to disk: %w", err)
}
return nil
}

View file

@ -0,0 +1,157 @@
package set
import (
"bytes"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdConfigSet(t *testing.T) {
tests := []struct {
name string
input string
output SetOptions
wantsErr bool
}{
{
name: "no arguments",
input: "",
output: SetOptions{},
wantsErr: true,
},
{
name: "no value argument",
input: "key",
output: SetOptions{},
wantsErr: true,
},
{
name: "set key value",
input: "key value",
output: SetOptions{Key: "key", Value: "value"},
wantsErr: false,
},
{
name: "set key value with host",
input: "key value --host test.com",
output: SetOptions{Hostname: "test.com", Key: "key", Value: "value"},
wantsErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &cmdutil.Factory{
Config: func() (config.Config, error) {
return config.ConfigStub{}, nil
},
}
argv, err := shlex.Split(tt.input)
assert.NoError(t, err)
var gotOpts *SetOptions
cmd := NewCmdConfigSet(f, func(opts *SetOptions) error {
gotOpts = opts
return nil
})
cmd.Flags().BoolP("help", "x", false, "")
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output.Hostname, gotOpts.Hostname)
assert.Equal(t, tt.output.Key, gotOpts.Key)
assert.Equal(t, tt.output.Value, gotOpts.Value)
})
}
}
func Test_setRun(t *testing.T) {
tests := []struct {
name string
input *SetOptions
expectedValue string
stdout string
stderr string
wantsErr bool
errMsg string
}{
{
name: "set key value",
input: &SetOptions{
Config: config.ConfigStub{},
Key: "editor",
Value: "vim",
},
expectedValue: "vim",
},
{
name: "set key value scoped by host",
input: &SetOptions{
Config: config.ConfigStub{},
Hostname: "github.com",
Key: "editor",
Value: "vim",
},
expectedValue: "vim",
},
{
name: "set unknown key",
input: &SetOptions{
Config: config.ConfigStub{},
Key: "unknownKey",
Value: "someValue",
},
expectedValue: "someValue",
stderr: "! warning: 'unknownKey' is not a known configuration key\n",
},
{
name: "set invalid value",
input: &SetOptions{
Config: config.ConfigStub{},
Key: "git_protocol",
Value: "invalid",
},
wantsErr: true,
errMsg: "failed to set \"git_protocol\" to \"invalid\": valid values are 'https', 'ssh'",
},
}
for _, tt := range tests {
io, _, stdout, stderr := iostreams.Test()
tt.input.IO = io
t.Run(tt.name, func(t *testing.T) {
err := setRun(tt.input)
if tt.wantsErr {
assert.EqualError(t, err, tt.errMsg)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.stdout, stdout.String())
assert.Equal(t, tt.stderr, stderr.String())
val, err := tt.input.Config.Get(tt.input.Hostname, tt.input.Key)
assert.NoError(t, err)
assert.Equal(t, tt.expectedValue, val)
val, err = tt.input.Config.Get("", "_written")
assert.NoError(t, err)
assert.Equal(t, "true", val)
})
}
}

View file

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

View file

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

View file

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

View file

@ -32,7 +32,7 @@ func Test_remoteResolver(t *testing.T) {
},
}
resolver := rr.Resolver()
resolver := rr.Resolver("")
remotes, err := resolver()
require.NoError(t, err)
require.Equal(t, 2, len(remotes))
@ -40,3 +40,32 @@ func Test_remoteResolver(t *testing.T) {
assert.Equal(t, "upstream", remotes[0].Name)
assert.Equal(t, "fork", remotes[1].Name)
}
func Test_remoteResolverOverride(t *testing.T) {
rr := &remoteResolver{
readRemotes: func() (git.RemoteSet, error) {
return git.RemoteSet{
git.NewRemote("fork", "https://example.org/ghe-owner/ghe-fork.git"),
git.NewRemote("origin", "https://github.com/owner/repo.git"),
git.NewRemote("upstream", "https://example.org/ghe-owner/ghe-repo.git"),
}, nil
},
getConfig: func() (config.Config, error) {
return config.NewFromString(heredoc.Doc(`
hosts:
example.org:
oauth_token: GHETOKEN
`)), nil
},
urlTranslator: func(u *url.URL) *url.URL {
return u
},
}
resolver := rr.Resolver("github.com")
remotes, err := resolver()
require.NoError(t, err)
require.Equal(t, 1, len(remotes))
assert.Equal(t, "origin", remotes[0].Name)
}

101
pkg/cmd/gist/clone/clone.go Normal file
View file

@ -0,0 +1,101 @@
package clone
import (
"fmt"
"net/http"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
type CloneOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
GitArgs []string
Directory string
Gist string
}
func NewCmdClone(f *cmdutil.Factory, runF func(*CloneOptions) error) *cobra.Command {
opts := &CloneOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
}
cmd := &cobra.Command{
DisableFlagsInUseLine: true,
Use: "clone <gist> [<directory>] [-- <gitflags>...]",
Args: cmdutil.MinimumArgs(1, "cannot clone: gist argument required"),
Short: "Clone a gist locally",
Long: heredoc.Doc(`
Clone a GitHub gist locally.
A gist can be supplied as argument in either of the following formats:
- by ID, e.g. 5b0e0062eb8e9654adad7bb1d81cc75f
- by URL, e.g. "https://gist.github.com/OWNER/5b0e0062eb8e9654adad7bb1d81cc75f"
Pass additional 'git clone' flags by listing them after '--'.
`),
RunE: func(cmd *cobra.Command, args []string) error {
opts.Gist = args[0]
opts.GitArgs = args[1:]
if runF != nil {
return runF(opts)
}
return cloneRun(opts)
},
}
cmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
if err == pflag.ErrHelp {
return err
}
return &cmdutil.FlagError{Err: fmt.Errorf("%w\nSeparate git clone flags with '--'.", err)}
})
return cmd
}
func cloneRun(opts *CloneOptions) error {
gistURL := opts.Gist
if !git.IsURL(gistURL) {
cfg, err := opts.Config()
if err != nil {
return err
}
hostname := ghinstance.OverridableDefault()
protocol, err := cfg.Get(hostname, "git_protocol")
if err != nil {
return err
}
gistURL = formatRemoteURL(hostname, gistURL, protocol)
}
_, err := git.RunClone(gistURL, opts.GitArgs)
if err != nil {
return err
}
return nil
}
func formatRemoteURL(hostname string, gistID string, protocol string) string {
if protocol == "ssh" {
return fmt.Sprintf("git@gist.%s:%s.git", hostname, gistID)
}
return fmt.Sprintf("https://gist.%s/%s.git", hostname, gistID)
}

View file

@ -0,0 +1,118 @@
package clone
import (
"net/http"
"strings"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func runCloneCommand(httpClient *http.Client, cli string) (*test.CmdOut, error) {
io, stdin, stdout, stderr := iostreams.Test()
fac := &cmdutil.Factory{
IOStreams: io,
HttpClient: func() (*http.Client, error) {
return httpClient, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
}
cmd := NewCmdClone(fac, nil)
argv, err := shlex.Split(cli)
cmd.SetArgs(argv)
cmd.SetIn(stdin)
cmd.SetOut(stdout)
cmd.SetErr(stderr)
if err != nil {
panic(err)
}
_, err = cmd.ExecuteC()
if err != nil {
return nil, err
}
return &test.CmdOut{OutBuf: stdout, ErrBuf: stderr}, nil
}
func Test_GistClone(t *testing.T) {
tests := []struct {
name string
args string
want string
}{
{
name: "shorthand",
args: "GIST",
want: "git clone https://gist.github.com/GIST.git",
},
{
name: "shorthand with directory",
args: "GIST target_directory",
want: "git clone https://gist.github.com/GIST.git target_directory",
},
{
name: "clone arguments",
args: "GIST -- -o upstream --depth 1",
want: "git clone -o upstream --depth 1 https://gist.github.com/GIST.git",
},
{
name: "clone arguments with directory",
args: "GIST target_directory -- -o upstream --depth 1",
want: "git clone -o upstream --depth 1 https://gist.github.com/GIST.git target_directory",
},
{
name: "HTTPS URL",
args: "https://gist.github.com/OWNER/GIST",
want: "git clone https://gist.github.com/OWNER/GIST",
},
{
name: "SSH URL",
args: "git@gist.github.com:GIST.git",
want: "git clone git@gist.github.com:GIST.git",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
httpClient := &http.Client{Transport: reg}
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
output, err := runCloneCommand(httpClient, tt.args)
if err != nil {
t.Fatalf("error running command `gist clone`: %v", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
assert.Equal(t, 1, cs.Count)
assert.Equal(t, tt.want, strings.Join(cs.Calls[0].Args, " "))
reg.Verify(t)
})
}
}
func Test_GistClone_flagError(t *testing.T) {
_, err := runCloneCommand(nil, "--depth 1 GIST")
if err == nil || err.Error() != "unknown flag: --depth\nSeparate git clone flags with '--'." {
t.Errorf("unexpected error %v", err)
}
}

View file

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

View file

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

View file

@ -0,0 +1,88 @@
package delete
import (
"fmt"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/cmd/gist/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
"net/http"
"strings"
)
type DeleteOptions struct {
IO *iostreams.IOStreams
HttpClient func() (*http.Client, error)
Selector string
}
func NewCmdDelete(f *cmdutil.Factory, runF func(*DeleteOptions) error) *cobra.Command {
opts := DeleteOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
}
cmd := &cobra.Command{
Use: "delete {<gist ID> | <gist URL>}",
Short: "Delete a gist",
Args: cobra.ExactArgs(1),
RunE: func(c *cobra.Command, args []string) error {
opts.Selector = args[0]
if runF != nil {
return runF(&opts)
}
return deleteRun(&opts)
},
}
return cmd
}
func deleteRun(opts *DeleteOptions) error {
gistID := opts.Selector
if strings.Contains(gistID, "/") {
id, err := shared.GistIDFromURL(gistID)
if err != nil {
return err
}
gistID = id
}
client, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(client)
gist, err := shared.GetGist(client, ghinstance.OverridableDefault(), gistID)
if err != nil {
return err
}
username, err := api.CurrentLoginName(apiClient, ghinstance.OverridableDefault())
if err != nil {
return err
}
if username != gist.Owner.Login {
return fmt.Errorf("You do not own this gist.")
}
err = deleteGist(apiClient, ghinstance.OverridableDefault(), gistID)
if err != nil {
return err
}
return nil
}
func deleteGist(apiClient *api.Client, hostname string, gistID string) error {
path := "gists/" + gistID
err := apiClient.REST(hostname, "DELETE", path, nil, nil)
if err != nil {
return err
}
return nil
}

View file

@ -0,0 +1,157 @@
package delete
import (
"bytes"
"net/http"
"testing"
"github.com/cli/cli/pkg/cmd/gist/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/prompt"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func TestNewCmdDelete(t *testing.T) {
tests := []struct {
name string
cli string
wants DeleteOptions
}{
{
name: "valid selector",
cli: "123",
wants: DeleteOptions{
Selector: "123",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := &cmdutil.Factory{}
argv, err := shlex.Split(tt.cli)
assert.NoError(t, err)
var gotOpts *DeleteOptions
cmd := NewCmdDelete(f, func(opts *DeleteOptions) error {
gotOpts = opts
return nil
})
cmd.SetArgs(argv)
cmd.SetIn(&bytes.Buffer{})
cmd.SetOut(&bytes.Buffer{})
cmd.SetErr(&bytes.Buffer{})
_, err = cmd.ExecuteC()
assert.NoError(t, err)
assert.Equal(t, tt.wants.Selector, gotOpts.Selector)
})
}
}
func Test_deleteRun(t *testing.T) {
tests := []struct {
name string
opts *DeleteOptions
gist *shared.Gist
httpStubs func(*httpmock.Registry)
askStubs func(*prompt.AskStubber)
nontty bool
wantErr bool
wantStderr string
wantParams map[string]interface{}
}{
{
name: "no such gist",
wantErr: true,
}, {
name: "another user's gist",
gist: &shared.Gist{
ID: "1234",
Files: map[string]*shared.GistFile{
"cicada.txt": {
Filename: "cicada.txt",
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
},
Owner: &shared.GistOwner{Login: "octocat2"},
},
wantErr: true,
wantStderr: "You do not own this gist.",
}, {
name: "successfully delete",
gist: &shared.Gist{
ID: "1234",
Files: map[string]*shared.GistFile{
"cicada.txt": {
Filename: "cicada.txt",
Content: "bwhiizzzbwhuiiizzzz",
Type: "text/plain",
},
},
Owner: &shared.GistOwner{Login: "octocat"},
},
httpStubs: func(reg *httpmock.Registry) {
reg.Register(httpmock.REST("DELETE", "gists/1234"),
httpmock.StringResponse("{}"))
},
wantErr: false,
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
if tt.gist == nil {
reg.Register(httpmock.REST("GET", "gists/1234"),
httpmock.StatusStringResponse(404, "Not Found"))
} else {
reg.Register(httpmock.REST("GET", "gists/1234"),
httpmock.JSONResponse(tt.gist))
reg.Register(httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data":{"viewer":{"login":"octocat"}}}`))
}
if tt.httpStubs != nil {
tt.httpStubs(reg)
}
as, teardown := prompt.InitAskStubber()
defer teardown()
if tt.askStubs != nil {
tt.askStubs(as)
}
if tt.opts == nil {
tt.opts = &DeleteOptions{}
}
tt.opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
io, _, _, _ := iostreams.Test()
io.SetStdoutTTY(!tt.nontty)
io.SetStdinTTY(!tt.nontty)
tt.opts.IO = io
tt.opts.Selector = "1234"
t.Run(tt.name, func(t *testing.T) {
err := deleteRun(tt.opts)
reg.Verify(t)
if tt.wantErr {
assert.Error(t, err)
if tt.wantStderr != "" {
assert.EqualError(t, err, tt.wantStderr)
}
return
}
assert.NoError(t, err)
})
}
}

View file

@ -2,7 +2,9 @@ package gist
import (
"github.com/MakeNowJust/heredoc"
gistCloneCmd "github.com/cli/cli/pkg/cmd/gist/clone"
gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create"
gistDeleteCmd "github.com/cli/cli/pkg/cmd/gist/delete"
gistEditCmd "github.com/cli/cli/pkg/cmd/gist/edit"
gistListCmd "github.com/cli/cli/pkg/cmd/gist/list"
gistViewCmd "github.com/cli/cli/pkg/cmd/gist/view"
@ -12,7 +14,7 @@ import (
func NewCmdGist(f *cmdutil.Factory) *cobra.Command {
cmd := &cobra.Command{
Use: "gist",
Use: "gist <command>",
Short: "Manage gists",
Long: `Work with GitHub gists.`,
Annotations: map[string]string{
@ -25,10 +27,12 @@ func NewCmdGist(f *cmdutil.Factory) *cobra.Command {
},
}
cmd.AddCommand(gistCloneCmd.NewCmdClone(f, nil))
cmd.AddCommand(gistCreateCmd.NewCmdCreate(f, nil))
cmd.AddCommand(gistListCmd.NewCmdList(f, nil))
cmd.AddCommand(gistViewCmd.NewCmdView(f, nil))
cmd.AddCommand(gistEditCmd.NewCmdEdit(f, nil))
cmd.AddCommand(gistDeleteCmd.NewCmdDelete(f, nil))
return cmd
}

View file

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

View file

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

View file

@ -14,6 +14,7 @@ import (
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/test"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
)
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
@ -58,14 +59,21 @@ func TestIssueClose(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 13, "title": "The title of the issue"}
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
http.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "id": "THE-ID", "number": 13, "title": "The title of the issue"}
} } }`),
)
http.Register(
httpmock.GraphQL(`mutation IssueClose\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["issueId"], "THE-ID")
}),
)
output, err := runCommand(http, true, "13")
if err != nil {
@ -83,12 +91,14 @@ func TestIssueClose_alreadyClosed(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 13, "title": "The title of the issue", "closed": true}
} } }
`))
http.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 13, "title": "The title of the issue", "closed": true}
} } }`),
)
output, err := runCommand(http, true, "13")
if err != nil {
@ -106,11 +116,13 @@ func TestIssueClose_issuesDisabled(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": false
} } }
`))
http.Register(
httpmock.GraphQL(`query IssueByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"hasIssuesEnabled": false
} } }`),
)
_, err := runCommand(http, true, "13")
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {

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