Merge remote-tracking branch 'origin/trunk' into base-cmd

This commit is contained in:
vilmibm 2022-12-13 10:39:40 -08:00
commit 7e3e2d96a8
450 changed files with 24075 additions and 13693 deletions

View file

@ -1,5 +0,0 @@
# See here for image contents: https://github.com/microsoft/vscode-dev-containers/tree/v0.234.0/containers/go/.devcontainer/base.Dockerfile
# VARIANT Defined in devcontainer.json
ARG VARIANT="1.18"
FROM mcr.microsoft.com/vscode/devcontainers/go:0-${VARIANT}

View file

@ -1,22 +1,24 @@
{
"extensions": [
"golang.go"
],
"build": {
"dockerfile": "Dockerfile",
"args": {
"VARIANT": "1.18"
}
"image": "mcr.microsoft.com/devcontainers/go:1.19",
"features": {
"ghcr.io/devcontainers/features/sshd:1": {}
},
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go"
"remoteUser": "vscode",
"customizations": {
"vscode": {
"extensions": [
"golang.go"
],
"settings": {
"go.toolsManagement.checkForUpdates": "local",
"go.useLanguageServer": true,
"go.gopath": "/go"
}
}
},
"runArgs": [
"--cap-add=SYS_PTRACE",
"--security-opt",
"seccomp=unconfined"
],
"remoteUser": "vscode"
]
}

13
.github/SECURITY.md vendored
View file

@ -1,3 +1,14 @@
If you discover a security issue in this repository, please submit it through the [GitHub Security Bug Bounty](https://hackerone.com/github).
GitHub takes the security of our software products and services seriously, including the open source code repositories managed through our GitHub organizations, such as [cli](https://github.com/cli).
If you believe you have found a security vulnerability in GitHub CLI, you can report it to us in one of two ways:
* Report it to this repository directly using [private vulnerability reporting][]. Such reports are not eligible for a bounty reward.
* Submit the report through [HackerOne][] to be eligible for a bounty reward.
**Please do not report security vulnerabilities through public GitHub issues, discussions, or pull requests.**
Thanks for helping make GitHub safe for everyone.
[private vulnerability reporting]: https://github.com/cli/cli/security/advisories
[HackerOne]: https://hackerone.com/github

View file

@ -13,10 +13,10 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Set up Go 1.18
- name: Set up Go 1.19
uses: actions/setup-go@v3
with:
go-version: 1.18
go-version: 1.19
- name: Check out code
uses: actions/checkout@v3

View file

@ -19,10 +19,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.18
- name: Set up Go 1.19
uses: actions/setup-go@v3
with:
go-version: 1.18
go-version: 1.19
- name: Check out code
uses: actions/checkout@v3
@ -40,7 +40,7 @@ jobs:
go mod verify
go mod download
LINT_VERSION=1.46.0
LINT_VERSION=1.50.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/

View file

@ -15,10 +15,12 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up Go 1.18
with:
fetch-depth: 0
- name: Set up Go 1.19
uses: actions/setup-go@v3
with:
go-version: 1.18
go-version: 1.19
- name: Generate changelog
id: changelog
run: |
@ -39,9 +41,9 @@ jobs:
env:
CERT_CONTENTS: ${{ secrets.WINDOWS_CERT_PFX }}
- name: Run GoReleaser
uses: goreleaser/goreleaser-action@v2
uses: goreleaser/goreleaser-action@v3
with:
version: v0.174.1
version: "~1.13.1"
args: release --release-notes=CHANGELOG.md
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
@ -79,11 +81,11 @@ jobs:
run: sudo apt-get install -y rpm reprepro
- name: Set up GPG
run: |
gpg --import --no-tty --batch --yes < script/pubkey.asc
echo "${{secrets.GPG_PUBKEY}}" | base64 -d | gpg --import --no-tty --batch --yes
echo "${{secrets.GPG_KEY}}" | base64 -d | gpg --import --no-tty --batch --yes
echo "allow-preset-passphrase" > ~/.gnupg/gpg-agent.conf
gpg-connect-agent RELOADAGENT /bye
echo "${{secrets.GPG_PASSPHRASE}}" | /usr/lib/gnupg2/gpg-preset-passphrase --preset 867DAD5051270B843EF54F6186FA10E3A1D22DC5
echo "${{secrets.GPG_PASSPHRASE}}" | /usr/lib/gnupg2/gpg-preset-passphrase --preset "${{secrets.GPG_KEYGRIP}}"
- name: Sign RPMs
run: |
cp script/rpmmacros ~/.rpmmacros
@ -147,7 +149,7 @@ jobs:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Prepare PATH
id: setupmsbuild
uses: microsoft/setup-msbuild@v1.0.3
uses: microsoft/setup-msbuild@v1.1.3
- name: Build MSI
id: buildmsi
shell: bash
@ -188,7 +190,7 @@ jobs:
DISCUSSION_CATEGORY: General
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@v1
uses: mislav/bump-homebrew-formula-action@v2
if: "!contains(github.ref, '-')" # skip prereleases
with:
formula-name: gh
@ -218,18 +220,3 @@ jobs:
GIT_AUTHOR_NAME: cli automation
GIT_COMMITTER_EMAIL: noreply@github.com
GIT_AUTHOR_EMAIL: noreply@github.com
- name: Bump Winget manifest
shell: pwsh
env:
WINGETCREATE_VERSION: v1.0.3.0
GITHUB_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }}
run: |
$tagname = $env:GITHUB_REF.Replace("refs/tags/", "")
$version = $tagname.Replace("v", "")
$url = "https://github.com/cli/cli/releases/download/${tagname}/gh_${version}_windows_amd64.msi"
iwr https://github.com/microsoft/winget-create/releases/download/${env:WINGETCREATE_VERSION}/wingetcreate.exe -OutFile wingetcreate.exe
.\wingetcreate.exe update GitHub.cli --url $url --version $version
if ($version -notmatch "-") {
.\wingetcreate.exe submit .\manifests\g\GitHub\cli\${version}\ --token $env:GITHUB_TOKEN
}

View file

@ -31,7 +31,7 @@ builds:
- <<: *build_defaults
id: windows
goos: [windows]
goarch: [386, amd64]
goarch: [386, amd64, arm64]
hooks:
post:
- ./script/sign-windows-executable.sh '{{ .Path }}'
@ -60,7 +60,7 @@ nfpms:
- license: MIT
maintainer: GitHub
homepage: https://github.com/cli/cli
bindir: /usr/bin
bindir: /usr
dependencies:
- git
description: GitHubs official command line tool.

View file

@ -1,5 +0,0 @@
{
"search.exclude": {
"vendor/**": true
}
}

View file

@ -8,7 +8,7 @@ GitHub CLI is available for repositories hosted on GitHub.com and GitHub Enterpr
## Documentation
[See the manual][manual] for setup and usage instructions.
For [installation options see below](#installation), for usage instructions [see the manual][manual].
## Contributing
@ -49,9 +49,13 @@ Additional Conda installation options available on the [gh-feedstock page](https
### Linux & BSD
`gh` is available via [Homebrew](#homebrew), [Conda](#conda), [Spack](#spack), and as downloadable binaries from the [releases page][].
`gh` is available via:
- [our Debian and RPM repositories](./docs/install_linux.md);
- community-maintained repositories in various Linux distros;
- OS-agnostic package managers such as [Homebrew](#homebrew), [Conda](#conda), and [Spack](#spack); and
- our [releases page][] as precompiled binaries.
For instructions on specific distributions and package managers, see [Linux & BSD installation](./docs/install_linux.md).
For more information, see [Linux & BSD installation](./docs/install_linux.md).
### Windows
@ -63,6 +67,9 @@ For instructions on specific distributions and package managers, see [Linux & BS
| ------------------- | --------------------|
| `winget install --id GitHub.cli` | `winget upgrade --id GitHub.cli` |
> **Note**
> The Windows installer modifes your PATH. When using Windows Terminal, you will need to **open a new window** for the changes to take affect. (Simply opening a new tab will _not_ be sufficient.)
#### scoop
| Install: | Upgrade: |
@ -79,6 +86,16 @@ For instructions on specific distributions and package managers, see [Linux & BS
MSI installers are available for download on the [releases page][].
### Codespaces
To add GitHub CLI to your codespace, add the following to your [devcontainer file](https://docs.github.com/en/codespaces/setting-up-your-project-for-codespaces/adding-features-to-a-devcontainer-file):
```json
"features": {
"github-cli": "latest"
}
```
### GitHub Actions
GitHub CLI comes pre-installed in all [GitHub-Hosted Runners](https://docs.github.com/en/actions/using-github-hosted-runners/about-github-hosted-runners).

View file

@ -1,178 +0,0 @@
package api
import (
"bufio"
"bytes"
"crypto/sha256"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
func NewCachedClient(httpClient *http.Client, cacheTTL time.Duration) *http.Client {
cacheDir := filepath.Join(os.TempDir(), "gh-cli-cache")
return &http.Client{
Transport: CacheResponse(cacheTTL, cacheDir)(httpClient.Transport),
}
}
func isCacheableRequest(req *http.Request) bool {
if strings.EqualFold(req.Method, "GET") || strings.EqualFold(req.Method, "HEAD") {
return true
}
if strings.EqualFold(req.Method, "POST") && (req.URL.Path == "/graphql" || req.URL.Path == "/api/graphql") {
return true
}
return false
}
func isCacheableResponse(res *http.Response) bool {
return res.StatusCode < 500 && res.StatusCode != 403
}
// 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,
mu: &sync.RWMutex{},
}
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
if !isCacheableRequest(req) {
return tr.RoundTrip(req)
}
key, keyErr := cacheKey(req)
if keyErr == nil {
if res, err := fs.read(key); err == nil {
res.Request = req
return res, nil
}
}
res, err := tr.RoundTrip(req)
if err == nil && keyErr == nil && isCacheableResponse(res) {
_ = fs.store(key, res)
}
return res, err
}}
}
}
func copyStream(r io.ReadCloser) (io.ReadCloser, io.ReadCloser) {
b := &bytes.Buffer{}
nr := io.TeeReader(r, b)
return io.NopCloser(b), &readCloser{
Reader: nr,
Closer: r,
}
}
type readCloser struct {
io.Reader
io.Closer
}
func cacheKey(req *http.Request) (string, error) {
h := sha256.New()
fmt.Fprintf(h, "%s:", req.Method)
fmt.Fprintf(h, "%s:", req.URL.String())
fmt.Fprintf(h, "%s:", req.Header.Get("Accept"))
fmt.Fprintf(h, "%s:", req.Header.Get("Authorization"))
if req.Body != nil {
var bodyCopy io.ReadCloser
req.Body, bodyCopy = copyStream(req.Body)
defer bodyCopy.Close()
if _, err := io.Copy(h, bodyCopy); err != nil {
return "", err
}
}
digest := h.Sum(nil)
return fmt.Sprintf("%x", digest), nil
}
type fileStorage struct {
dir string
ttl time.Duration
mu *sync.RWMutex
}
func (fs *fileStorage) filePath(key string) string {
if len(key) >= 6 {
return filepath.Join(fs.dir, key[0:2], key[2:4], key[4:])
}
return filepath.Join(fs.dir, key)
}
func (fs *fileStorage) read(key string) (*http.Response, error) {
cacheFile := fs.filePath(key)
fs.mu.RLock()
defer fs.mu.RUnlock()
f, err := os.Open(cacheFile)
if err != nil {
return nil, err
}
defer f.Close()
stat, err := f.Stat()
if err != nil {
return nil, err
}
age := time.Since(stat.ModTime())
if age > fs.ttl {
return nil, errors.New("cache expired")
}
body := &bytes.Buffer{}
_, err = io.Copy(body, f)
if err != nil {
return nil, err
}
res, err := http.ReadResponse(bufio.NewReader(body), nil)
return res, err
}
func (fs *fileStorage) store(key string, res *http.Response) error {
cacheFile := fs.filePath(key)
fs.mu.Lock()
defer fs.mu.Unlock()
err := os.MkdirAll(filepath.Dir(cacheFile), 0755)
if err != nil {
return err
}
f, err := os.OpenFile(cacheFile, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return err
}
defer f.Close()
var origBody io.ReadCloser
if res.Body != nil {
origBody, res.Body = copyStream(res.Body)
defer res.Body.Close()
}
err = res.Write(f)
if origBody != nil {
res.Body = origBody
}
return err
}

View file

@ -1,88 +0,0 @@
package api
import (
"bytes"
"fmt"
"io"
"net/http"
"path/filepath"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_CacheResponse(t *testing.T) {
counter := 0
fakeHTTP := funcTripper{
roundTrip: func(req *http.Request) (*http.Response, error) {
counter += 1
body := fmt.Sprintf("%d: %s %s", counter, req.Method, req.URL.String())
status := 200
if req.URL.Path == "/error" {
status = 500
}
return &http.Response{
StatusCode: status,
Body: io.NopCloser(bytes.NewBufferString(body)),
}, nil
},
}
cacheDir := filepath.Join(t.TempDir(), "gh-cli-cache")
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)
if err != nil {
return "", err
}
res, err := httpClient.Do(req)
if err != nil {
return "", err
}
defer res.Body.Close()
resBody, err := io.ReadAll(res.Body)
if err != nil {
err = fmt.Errorf("ReadAll: %w", err)
}
return string(resBody), err
}
var res string
var err error
res, err = do("GET", "http://example.com/path", nil)
require.NoError(t, err)
assert.Equal(t, "1: GET http://example.com/path", res)
res, err = do("GET", "http://example.com/path", nil)
require.NoError(t, err)
assert.Equal(t, "1: GET http://example.com/path", res)
res, err = do("GET", "http://example.com/path2", nil)
require.NoError(t, err)
assert.Equal(t, "2: GET http://example.com/path2", res)
res, err = do("POST", "http://example.com/path2", nil)
require.NoError(t, err)
assert.Equal(t, "3: POST http://example.com/path2", res)
res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`))
require.NoError(t, err)
assert.Equal(t, "4: POST http://example.com/graphql", res)
res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello`))
require.NoError(t, err)
assert.Equal(t, "4: POST http://example.com/graphql", res)
res, err = do("POST", "http://example.com/graphql", bytes.NewBufferString(`hello2`))
require.NoError(t, err)
assert.Equal(t, "5: POST http://example.com/graphql", res)
res, err = do("GET", "http://example.com/error", nil)
require.NoError(t, err)
assert.Equal(t, "6: GET http://example.com/error", res)
res, err = do("GET", "http://example.com/error", nil)
require.NoError(t, err)
assert.Equal(t, "7: GET http://example.com/error", res)
}

View file

@ -1,127 +1,35 @@
package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"strings"
"github.com/cli/cli/v2/internal/ghinstance"
graphql "github.com/cli/shurcooL-graphql"
"github.com/henvic/httpretty"
"github.com/cli/go-gh"
ghAPI "github.com/cli/go-gh/pkg/api"
)
// ClientOption represents an argument to NewClient
type ClientOption = func(http.RoundTripper) http.RoundTripper
const (
accept = "Accept"
authorization = "Authorization"
cacheTTL = "X-GH-CACHE-TTL"
graphqlFeatures = "GraphQL-Features"
features = "merge_queue"
userAgent = "User-Agent"
)
// NewHTTPClient initializes an http.Client
func NewHTTPClient(opts ...ClientOption) *http.Client {
tr := http.DefaultTransport
for _, opt := range opts {
tr = opt(tr)
}
return &http.Client{Transport: tr}
}
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
// NewClient initializes a Client
func NewClient(opts ...ClientOption) *Client {
client := &Client{http: NewHTTPClient(opts...)}
return client
}
// NewClientFromHTTP takes in an http.Client instance
func NewClientFromHTTP(httpClient *http.Client) *Client {
client := &Client{http: httpClient}
return client
}
// AddHeader turns a RoundTripper into one that adds a request header
func AddHeader(name, value string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
if req.Header.Get(name) == "" {
req.Header.Add(name, value)
}
return tr.RoundTrip(req)
}}
}
}
// AddHeaderFunc is an AddHeader that gets the string value from a function
func AddHeaderFunc(name string, getValue func(*http.Request) (string, error)) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
if req.Header.Get(name) != "" {
return tr.RoundTrip(req)
}
value, err := getValue(req)
if err != nil {
return nil, err
}
if value != "" {
req.Header.Add(name, value)
}
return tr.RoundTrip(req)
}}
}
}
// VerboseLog enables request/response logging within a RoundTripper
func VerboseLog(out io.Writer, logTraffic bool, colorize bool) ClientOption {
logger := &httpretty.Logger{
Time: true,
TLS: false,
Colors: colorize,
RequestHeader: logTraffic,
RequestBody: logTraffic,
ResponseHeader: logTraffic,
ResponseBody: logTraffic,
Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}},
MaxResponseBody: 10000,
}
logger.SetOutput(out)
logger.SetBodyFilter(func(h http.Header) (skip bool, err error) {
return !inspectableMIMEType(h.Get("Content-Type")), nil
})
return logger.RoundTripper
}
// ReplaceTripper substitutes the underlying RoundTripper with a custom one
func ReplaceTripper(tr http.RoundTripper) ClientOption {
return func(http.RoundTripper) http.RoundTripper {
return tr
}
}
// ExtractHeader extracts a named header from any response received by this client and, if non-blank, saves
// it to dest.
func ExtractHeader(name string, dest *string) ClientOption {
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 {
if value := res.Header.Get(name); value != "" {
*dest = value
}
}
return res, err
}}
}
}
type funcTripper struct {
roundTrip func(*http.Request) (*http.Response, error)
}
func (tr funcTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return tr.roundTrip(req)
}
// Client facilitates making HTTP requests to the GitHub API
type Client struct {
http *http.Client
}
@ -130,103 +38,165 @@ func (c *Client) HTTP() *http.Client {
return c.http
}
type graphQLResponse struct {
Data interface{}
Errors []GraphQLError
}
// GraphQLError is a single error returned in a GraphQL response
type GraphQLError struct {
Type string
Message string
Path []interface{} // mixed strings and numbers
ghAPI.GQLError
}
func (ge GraphQLError) PathString() string {
var res strings.Builder
for i, v := range ge.Path {
if i > 0 {
res.WriteRune('.')
}
fmt.Fprintf(&res, "%v", v)
}
return res.String()
}
// GraphQLErrorResponse contains errors returned in a GraphQL response
type GraphQLErrorResponse struct {
Errors []GraphQLError
}
func (gr GraphQLErrorResponse) Error() string {
errorMessages := make([]string, 0, len(gr.Errors))
for _, e := range gr.Errors {
msg := e.Message
if p := e.PathString(); p != "" {
msg = fmt.Sprintf("%s (%s)", msg, p)
}
errorMessages = append(errorMessages, msg)
}
return fmt.Sprintf("GraphQL: %s", strings.Join(errorMessages, ", "))
}
// Match checks if this error is only about a specific type on a specific path. If the path argument ends
// with a ".", it will match all its subpaths as well.
func (gr GraphQLErrorResponse) Match(expectType, expectPath string) bool {
for _, e := range gr.Errors {
if e.Type != expectType || !matchPath(e.PathString(), expectPath) {
return false
}
}
return true
}
func matchPath(p, expect string) bool {
if strings.HasSuffix(expect, ".") {
return strings.HasPrefix(p, expect) || p == strings.TrimSuffix(expect, ".")
}
return p == expect
}
// HTTPError is an error returned by a failed API call
type HTTPError struct {
StatusCode int
RequestURL *url.URL
Message string
Errors []HTTPErrorItem
ghAPI.HTTPError
scopesSuggestion string
}
type HTTPErrorItem struct {
Message string
Resource string
Field string
Code string
}
func (err HTTPError) Error() string {
if msgs := strings.SplitN(err.Message, "\n", 2); len(msgs) > 1 {
return fmt.Sprintf("HTTP %d: %s (%s)\n%s", err.StatusCode, msgs[0], err.RequestURL, msgs[1])
} else if err.Message != "" {
return fmt.Sprintf("HTTP %d: %s (%s)", err.StatusCode, err.Message, err.RequestURL)
}
return fmt.Sprintf("HTTP %d (%s)", err.StatusCode, err.RequestURL)
}
func (err HTTPError) ScopesSuggestion() string {
return err.scopesSuggestion
}
// GraphQL performs a GraphQL request and parses the response. If there are errors in the response,
// GraphQLError will be returned, but the data will also be parsed into the receiver.
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error {
opts := clientOptions(hostname, c.http.Transport)
opts.Headers[graphqlFeatures] = features
gqlClient, err := gh.GQLClient(&opts)
if err != nil {
return err
}
return handleResponse(gqlClient.Do(query, variables, data))
}
// GraphQL performs a GraphQL mutation and parses the response. If there are errors in the response,
// GraphQLError will be returned, but the data will also be parsed into the receiver.
func (c Client) Mutate(hostname, name string, mutation interface{}, variables map[string]interface{}) error {
opts := clientOptions(hostname, c.http.Transport)
opts.Headers[graphqlFeatures] = features
gqlClient, err := gh.GQLClient(&opts)
if err != nil {
return err
}
return handleResponse(gqlClient.Mutate(name, mutation, variables))
}
// GraphQL performs a GraphQL query and parses the response. If there are errors in the response,
// GraphQLError will be returned, but the data will also be parsed into the receiver.
func (c Client) Query(hostname, name string, query interface{}, variables map[string]interface{}) error {
opts := clientOptions(hostname, c.http.Transport)
opts.Headers[graphqlFeatures] = features
gqlClient, err := gh.GQLClient(&opts)
if err != nil {
return err
}
return handleResponse(gqlClient.Query(name, query, variables))
}
// REST performs a REST request and parses the response.
func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error {
opts := clientOptions(hostname, c.http.Transport)
restClient, err := gh.RESTClient(&opts)
if err != nil {
return err
}
return handleResponse(restClient.Do(method, p, body, data))
}
func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) {
opts := clientOptions(hostname, c.http.Transport)
restClient, err := gh.RESTClient(&opts)
if err != nil {
return "", err
}
resp, err := restClient.Request(method, p, body)
if err != nil {
return "", err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return "", HandleHTTPError(resp)
}
if resp.StatusCode == http.StatusNoContent {
return "", nil
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
err = json.Unmarshal(b, &data)
if err != nil {
return "", err
}
var next string
for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) {
if len(m) > 2 && m[2] == "next" {
next = m[1]
}
}
return next, nil
}
// HandleHTTPError parses a http.Response into a HTTPError.
func HandleHTTPError(resp *http.Response) error {
return handleResponse(ghAPI.HandleHTTPError(resp))
}
// handleResponse takes a ghAPI.HTTPError or ghAPI.GQLError and converts it into an
// HTTPError or GraphQLError respectively.
func handleResponse(err error) error {
if err == nil {
return nil
}
var restErr ghAPI.HTTPError
if errors.As(err, &restErr) {
return HTTPError{
HTTPError: restErr,
scopesSuggestion: generateScopesSuggestion(restErr.StatusCode,
restErr.Headers.Get("X-Accepted-Oauth-Scopes"),
restErr.Headers.Get("X-Oauth-Scopes"),
restErr.RequestURL.Hostname()),
}
}
var gqlErr ghAPI.GQLError
if errors.As(err, &gqlErr) {
return GraphQLError{
GQLError: gqlErr,
}
}
return err
}
// ScopesSuggestion is an error messaging utility that prints the suggestion to request additional OAuth
// scopes in case a server response indicates that there are missing scopes.
func ScopesSuggestion(resp *http.Response) string {
if resp.StatusCode < 400 || resp.StatusCode > 499 || resp.StatusCode == 422 {
return generateScopesSuggestion(resp.StatusCode,
resp.Header.Get("X-Accepted-Oauth-Scopes"),
resp.Header.Get("X-Oauth-Scopes"),
resp.Request.URL.Hostname())
}
// EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the
// server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the
// OAuth scopes they need.
func EndpointNeedsScopes(resp *http.Response, s string) *http.Response {
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
oldScopes := resp.Header.Get("X-Accepted-Oauth-Scopes")
resp.Header.Set("X-Accepted-Oauth-Scopes", fmt.Sprintf("%s, %s", oldScopes, s))
}
return resp
}
func generateScopesSuggestion(statusCode int, endpointNeedsScopes, tokenHasScopes, hostname string) string {
if statusCode < 400 || statusCode > 499 || statusCode == 422 {
return ""
}
endpointNeedsScopes := resp.Header.Get("X-Accepted-Oauth-Scopes")
tokenHasScopes := resp.Header.Get("X-Oauth-Scopes")
if tokenHasScopes == "" {
return ""
}
@ -267,205 +237,25 @@ func ScopesSuggestion(resp *http.Response) string {
return fmt.Sprintf(
"This API operation needs the %[1]q scope. To request it, run: gh auth refresh -h %[2]s -s %[1]s",
s,
ghinstance.NormalizeHostname(resp.Request.URL.Hostname()),
ghinstance.NormalizeHostname(hostname),
)
}
return ""
}
// EndpointNeedsScopes adds additional OAuth scopes to an HTTP response as if they were returned from the
// server endpoint. This improves HTTP 4xx error messaging for endpoints that don't explicitly list the
// OAuth scopes they need.
func EndpointNeedsScopes(resp *http.Response, s string) *http.Response {
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
oldScopes := resp.Header.Get("X-Accepted-Oauth-Scopes")
resp.Header.Set("X-Accepted-Oauth-Scopes", fmt.Sprintf("%s, %s", oldScopes, s))
func clientOptions(hostname string, transport http.RoundTripper) ghAPI.ClientOptions {
// AuthToken, and Headers are being handled by transport,
// so let go-gh know that it does not need to resolve them.
opts := ghAPI.ClientOptions{
AuthToken: "none",
Headers: map[string]string{
authorization: "",
},
Host: hostname,
SkipDefaultHeaders: true,
Transport: transport,
LogIgnoreEnv: true,
}
return resp
}
// GraphQL performs a GraphQL request and parses the response. If there are errors in the response,
// *GraphQLErrorResponse will be returned, but the data will also be parsed into the receiver.
func (c Client) GraphQL(hostname string, query string, variables map[string]interface{}, data interface{}) error {
reqBody, err := json.Marshal(map[string]interface{}{"query": query, "variables": variables})
if err != nil {
return err
}
req, err := http.NewRequest("POST", ghinstance.GraphQLEndpoint(hostname), bytes.NewBuffer(reqBody))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
return handleResponse(resp, data)
}
func graphQLClient(h *http.Client, hostname string) *graphql.Client {
return graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), h)
}
// REST performs a REST request and parses the response.
func (c Client) REST(hostname string, method string, p string, body io.Reader, data interface{}) error {
_, err := c.RESTWithNext(hostname, method, p, body, data)
return err
}
func (c Client) RESTWithNext(hostname string, method string, p string, body io.Reader, data interface{}) (string, error) {
req, err := http.NewRequest(method, restURL(hostname, p), body)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := c.http.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return "", HandleHTTPError(resp)
}
if resp.StatusCode == http.StatusNoContent {
return "", nil
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
err = json.Unmarshal(b, &data)
if err != nil {
return "", err
}
var next string
for _, m := range linkRE.FindAllStringSubmatch(resp.Header.Get("Link"), -1) {
if len(m) > 2 && m[2] == "next" {
next = m[1]
}
}
return next, nil
}
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
func restURL(hostname string, pathOrURL string) string {
if strings.HasPrefix(pathOrURL, "https://") || strings.HasPrefix(pathOrURL, "http://") {
return pathOrURL
}
return ghinstance.RESTPrefix(hostname) + pathOrURL
}
func handleResponse(resp *http.Response, data interface{}) error {
success := resp.StatusCode >= 200 && resp.StatusCode < 300
if !success {
return HandleHTTPError(resp)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return err
}
gr := &graphQLResponse{Data: data}
err = json.Unmarshal(body, &gr)
if err != nil {
return err
}
if len(gr.Errors) > 0 {
return &GraphQLErrorResponse{Errors: gr.Errors}
}
return nil
}
func HandleHTTPError(resp *http.Response) error {
httpError := HTTPError{
StatusCode: resp.StatusCode,
RequestURL: resp.Request.URL,
scopesSuggestion: ScopesSuggestion(resp),
}
if !jsonTypeRE.MatchString(resp.Header.Get("Content-Type")) {
httpError.Message = resp.Status
return httpError
}
body, err := io.ReadAll(resp.Body)
if err != nil {
httpError.Message = err.Error()
return httpError
}
var parsedBody struct {
Message string `json:"message"`
Errors []json.RawMessage
}
if err := json.Unmarshal(body, &parsedBody); err != nil {
return httpError
}
var messages []string
if parsedBody.Message != "" {
messages = append(messages, parsedBody.Message)
}
for _, raw := range parsedBody.Errors {
switch raw[0] {
case '"':
var errString string
_ = json.Unmarshal(raw, &errString)
messages = append(messages, errString)
httpError.Errors = append(httpError.Errors, HTTPErrorItem{Message: errString})
case '{':
var errInfo HTTPErrorItem
_ = json.Unmarshal(raw, &errInfo)
msg := errInfo.Message
if errInfo.Code != "" && errInfo.Code != "custom" {
msg = fmt.Sprintf("%s.%s %s", errInfo.Resource, errInfo.Field, errorCodeToMessage(errInfo.Code))
}
if msg != "" {
messages = append(messages, msg)
}
httpError.Errors = append(httpError.Errors, errInfo)
}
}
httpError.Message = strings.Join(messages, "\n")
return httpError
}
func errorCodeToMessage(code string) string {
// https://docs.github.com/en/rest/overview/resources-in-the-rest-api#client-errors
switch code {
case "missing", "missing_field":
return "is missing"
case "invalid", "unprocessable":
return "is invalid"
case "already_exists":
return "already exists"
default:
return code
}
}
var jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
func inspectableMIMEType(t string) bool {
return strings.HasPrefix(t, "text/") || jsonTypeRE.MatchString(t)
return opts
}

View file

@ -5,18 +5,23 @@ import (
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/stretchr/testify/assert"
)
func newTestClient(reg *httpmock.Registry) *Client {
client := &http.Client{}
httpmock.ReplaceTripper(client, reg)
return NewClientFromHTTP(client)
}
func TestGraphQL(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(
ReplaceTripper(http),
AddHeader("Authorization", "token OTOKEN"),
)
client := newTestClient(http)
vars := map[string]interface{}{"name": "Mona"}
response := struct {
@ -37,16 +42,15 @@ func TestGraphQL(t *testing.T) {
req := http.Requests[0]
reqBody, _ := io.ReadAll(req.Body)
assert.Equal(t, `{"query":"QUERY","variables":{"name":"Mona"}}`, string(reqBody))
assert.Equal(t, "token OTOKEN", req.Header.Get("Authorization"))
}
func TestGraphQLError(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
reg := &httpmock.Registry{}
client := newTestClient(reg)
response := struct{}{}
http.Register(
reg.Register(
httpmock.GraphQL(""),
httpmock.StringResponse(`
{ "errors": [
@ -73,10 +77,7 @@ func TestGraphQLError(t *testing.T) {
func TestRESTGetDelete(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(
ReplaceTripper(http),
)
client := newTestClient(http)
http.Register(
httpmock.REST("DELETE", "applications/CLIENTID/grant"),
@ -90,7 +91,7 @@ func TestRESTGetDelete(t *testing.T) {
func TestRESTWithFullURL(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
client := newTestClient(http)
http.Register(
httpmock.REST("GET", "api/v3/user/repos"),
@ -110,7 +111,7 @@ func TestRESTWithFullURL(t *testing.T) {
func TestRESTError(t *testing.T) {
fakehttp := &httpmock.Registry{}
client := NewClient(ReplaceTripper(fakehttp))
client := newTestClient(fakehttp)
fakehttp.Register(httpmock.MatchAny, func(req *http.Request) (*http.Response, error) {
return &http.Response{
@ -134,7 +135,6 @@ func TestRESTError(t *testing.T) {
}
if httpErr.Error() != "HTTP 422: OH NO (https://api.github.com/repos/branch)" {
t.Errorf("got %q", httpErr.Error())
}
}
@ -223,3 +223,36 @@ func TestHTTPError_ScopesSuggestion(t *testing.T) {
})
}
}
func TestHTTPHeaders(t *testing.T) {
var gotReq *http.Request
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotReq = r
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()
ios, _, _, stderr := iostreams.Test()
httpClient, err := NewHTTPClient(HTTPClientOptions{
AppVersion: "v1.2.3",
Config: tinyConfig{ts.URL[7:] + ":oauth_token": "MYTOKEN"},
Log: ios.ErrOut,
SkipAcceptHeaders: false,
})
assert.NoError(t, err)
client := NewClientFromHTTP(httpClient)
err = client.REST(ts.URL, "GET", ts.URL+"/user/repos", nil, nil)
assert.NoError(t, err)
wantHeader := map[string]string{
"Accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
"Authorization": "token MYTOKEN",
"Content-Type": "application/json; charset=utf-8",
"User-Agent": "GitHub CLI v1.2.3",
}
for name, value := range wantHeader {
assert.Equal(t, value, gotReq.Header.Get(name), name)
}
assert.Equal(t, "", stderr.String())
}

View file

@ -11,6 +11,18 @@ func (issue *Issue) ExportData(fields []string) map[string]interface{} {
for _, f := range fields {
switch f {
case "author":
author := map[string]interface{}{
"is_bot": issue.Author.IsBot(),
}
if issue.Author.IsBot() {
author["login"] = "app/" + issue.Author.Login
} else {
author["login"] = issue.Author.Login
author["name"] = issue.Author.Name
author["id"] = issue.Author.ID
}
data[f] = author
case "comments":
data[f] = issue.Comments.Nodes
case "assignees":
@ -34,11 +46,46 @@ func (pr *PullRequest) ExportData(fields []string) map[string]interface{} {
for _, f := range fields {
switch f {
case "author":
author := map[string]interface{}{
"is_bot": pr.Author.IsBot(),
}
if pr.Author.IsBot() {
author["login"] = "app/" + pr.Author.Login
} else {
author["login"] = pr.Author.Login
author["name"] = pr.Author.Name
author["id"] = pr.Author.ID
}
data[f] = author
case "headRepository":
data[f] = pr.HeadRepository
case "statusCheckRollup":
if n := pr.StatusCheckRollup.Nodes; len(n) > 0 {
data[f] = n[0].Commit.StatusCheckRollup.Contexts.Nodes
checks := make([]interface{}, 0, len(n[0].Commit.StatusCheckRollup.Contexts.Nodes))
for _, c := range n[0].Commit.StatusCheckRollup.Contexts.Nodes {
if c.TypeName == "CheckRun" {
checks = append(checks, map[string]interface{}{
"__typename": c.TypeName,
"name": c.Name,
"workflowName": c.CheckSuite.WorkflowRun.Workflow.Name,
"status": c.Status,
"conclusion": c.Conclusion,
"startedAt": c.StartedAt,
"completedAt": c.CompletedAt,
"detailsUrl": c.DetailsURL,
})
} else {
checks = append(checks, map[string]interface{}{
"__typename": c.TypeName,
"context": c.Context,
"state": c.State,
"targetUrl": c.TargetURL,
"startedAt": c.CreatedAt,
})
}
}
data[f] = checks
} else {
data[f] = nil
}

View file

@ -140,11 +140,19 @@ func TestPullRequest_ExportData(t *testing.T) {
{
"__typename": "CheckRun",
"name": "mycheck",
"checkSuite": {"workflowRun": {"workflow": {"name": "myworkflow"}}},
"status": "COMPLETED",
"conclusion": "SUCCESS",
"startedAt": "2020-08-31T15:44:24+02:00",
"completedAt": "2020-08-31T15:45:24+02:00",
"detailsUrl": "http://example.com/details"
},
{
"__typename": "StatusContext",
"context": "mycontext",
"state": "SUCCESS",
"createdAt": "2020-08-31T15:44:24+02:00",
"targetUrl": "http://example.com/details"
}
] } } } }
] } }
@ -155,11 +163,19 @@ func TestPullRequest_ExportData(t *testing.T) {
{
"__typename": "CheckRun",
"name": "mycheck",
"workflowName": "myworkflow",
"status": "COMPLETED",
"conclusion": "SUCCESS",
"startedAt": "2020-08-31T15:44:24+02:00",
"completedAt": "2020-08-31T15:45:24+02:00",
"detailsUrl": "http://example.com/details"
},
{
"__typename": "StatusContext",
"context": "mycontext",
"state": "SUCCESS",
"startedAt": "2020-08-31T15:44:24+02:00",
"targetUrl": "http://example.com/details"
}
]
}
@ -178,7 +194,14 @@ func TestPullRequest_ExportData(t *testing.T) {
enc := json.NewEncoder(&buf)
enc.SetIndent("", "\t")
require.NoError(t, enc.Encode(exported))
assert.Equal(t, tt.outputJSON, buf.String())
var gotData interface{}
dec = json.NewDecoder(&buf)
require.NoError(t, dec.Decode(&gotData))
var expectData interface{}
require.NoError(t, json.Unmarshal([]byte(tt.outputJSON), &expectData))
assert.Equal(t, expectData, gotData)
})
}
}

131
api/http_client.go Normal file
View file

@ -0,0 +1,131 @@
package api
import (
"fmt"
"io"
"net/http"
"strings"
"time"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/utils"
"github.com/cli/go-gh"
ghAPI "github.com/cli/go-gh/pkg/api"
)
type tokenGetter interface {
AuthToken(string) (string, string)
}
type HTTPClientOptions struct {
AppVersion string
CacheTTL time.Duration
Config tokenGetter
EnableCache bool
Log io.Writer
LogColorize bool
SkipAcceptHeaders bool
}
func NewHTTPClient(opts HTTPClientOptions) (*http.Client, error) {
// Provide invalid host, and token values so gh.HTTPClient will not automatically resolve them.
// The real host and token are inserted at request time.
clientOpts := ghAPI.ClientOptions{
Host: "none",
AuthToken: "none",
LogIgnoreEnv: true,
}
if debugEnabled, debugValue := utils.IsDebugEnabled(); debugEnabled {
clientOpts.Log = opts.Log
clientOpts.LogColorize = opts.LogColorize
clientOpts.LogVerboseHTTP = strings.Contains(debugValue, "api")
}
headers := map[string]string{
userAgent: fmt.Sprintf("GitHub CLI %s", opts.AppVersion),
}
if opts.SkipAcceptHeaders {
headers[accept] = ""
}
clientOpts.Headers = headers
if opts.EnableCache {
clientOpts.EnableCache = opts.EnableCache
clientOpts.CacheTTL = opts.CacheTTL
}
client, err := gh.HTTPClient(&clientOpts)
if err != nil {
return nil, err
}
if opts.Config != nil {
client.Transport = AddAuthTokenHeader(client.Transport, opts.Config)
}
return client, nil
}
func NewCachedHTTPClient(httpClient *http.Client, ttl time.Duration) *http.Client {
newClient := *httpClient
newClient.Transport = AddCacheTTLHeader(httpClient.Transport, ttl)
return &newClient
}
// AddCacheTTLHeader adds an header to the request telling the cache that the request
// should be cached for a specified amount of time.
func AddCacheTTLHeader(rt http.RoundTripper, ttl time.Duration) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
// If the header is already set in the request, don't overwrite it.
if req.Header.Get(cacheTTL) == "" {
req.Header.Set(cacheTTL, ttl.String())
}
return rt.RoundTrip(req)
}}
}
// AddAuthToken adds an authentication token header for the host specified by the request.
func AddAuthTokenHeader(rt http.RoundTripper, cfg tokenGetter) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
// If the header is already set in the request, don't overwrite it.
if req.Header.Get(authorization) == "" {
hostname := ghinstance.NormalizeHostname(getHost(req))
if token, _ := cfg.AuthToken(hostname); token != "" {
req.Header.Set(authorization, fmt.Sprintf("token %s", token))
}
}
return rt.RoundTrip(req)
}}
}
// ExtractHeader extracts a named header from any response received by this client and,
// if non-blank, saves it to dest.
func ExtractHeader(name string, dest *string) func(http.RoundTripper) http.RoundTripper {
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 {
if value := res.Header.Get(name); value != "" {
*dest = value
}
}
return res, err
}}
}
}
type funcTripper struct {
roundTrip func(*http.Request) (*http.Response, error)
}
func (tr funcTripper) RoundTrip(req *http.Request) (*http.Response, error) {
return tr.roundTrip(req)
}
func getHost(r *http.Request) string {
if r.Host != "" {
return r.Host
}
return r.URL.Hostname()
}

View file

@ -1,4 +1,4 @@
package factory
package api
import (
"fmt"
@ -16,7 +16,7 @@ import (
func TestNewHTTPClient(t *testing.T) {
type args struct {
config configGetter
config tokenGetter
appVersion string
setAccept bool
}
@ -27,10 +27,8 @@ func TestNewHTTPClient(t *testing.T) {
setGhDebug bool
envGhDebug string
host string
sso string
wantHeader map[string]string
wantStderr string
wantSSO string
}{
{
name: "github.com with Accept header",
@ -99,6 +97,8 @@ func TestNewHTTPClient(t *testing.T) {
> Host: github.com
> Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
> Authorization: token
> Content-Type: application/json; charset=utf-8
> Time-Zone: <timezone>
> User-Agent: GitHub CLI v1.2.3
< HTTP/1.1 204 No Content
@ -129,6 +129,8 @@ func TestNewHTTPClient(t *testing.T) {
> Host: github.com
> Accept: application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview
> Authorization: token
> Content-Type: application/json; charset=utf-8
> Time-Zone: <timezone>
> User-Agent: GitHub CLI v1.2.3
< HTTP/1.1 204 No Content
@ -148,62 +150,44 @@ func TestNewHTTPClient(t *testing.T) {
wantHeader: map[string]string{
"authorization": "token GHETOKEN",
"user-agent": "GitHub CLI v1.2.3",
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview, application/vnd.github.antiope-preview, application/vnd.github.shadow-cat-preview",
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.nebula-preview",
},
wantStderr: "",
},
{
name: "SSO challenge in response header",
args: args{
config: tinyConfig{},
appVersion: "v1.2.3",
},
host: "github.com",
sso: "required; url=https://github.com/login/sso?return_to=xyz&param=123abc; another",
wantStderr: "",
wantSSO: "https://github.com/login/sso?return_to=xyz&param=123abc",
},
}
var gotReq *http.Request
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotReq = r
if sso := r.URL.Query().Get("sso"); sso != "" {
w.Header().Set("X-GitHub-SSO", sso)
}
w.WriteHeader(http.StatusNoContent)
}))
defer ts.Close()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oldDebug := os.Getenv("DEBUG")
oldGhDebug := os.Getenv("GH_DEBUG")
os.Setenv("DEBUG", tt.envDebug)
t.Setenv("DEBUG", tt.envDebug)
if tt.setGhDebug {
os.Setenv("GH_DEBUG", tt.envGhDebug)
t.Setenv("GH_DEBUG", tt.envGhDebug)
} else {
os.Unsetenv("GH_DEBUG")
}
t.Cleanup(func() {
os.Setenv("DEBUG", oldDebug)
os.Setenv("GH_DEBUG", oldGhDebug)
})
ios, _, _, stderr := iostreams.Test()
client, err := NewHTTPClient(ios, tt.args.config, tt.args.appVersion, tt.args.setAccept)
client, err := NewHTTPClient(HTTPClientOptions{
AppVersion: tt.args.appVersion,
Config: tt.args.config,
Log: ios.ErrOut,
SkipAcceptHeaders: !tt.args.setAccept,
})
require.NoError(t, err)
req, err := http.NewRequest("GET", ts.URL, nil)
if tt.sso != "" {
q := req.URL.Query()
q.Set("sso", tt.sso)
req.URL.RawQuery = q.Encode()
}
req.Header.Set("time-zone", "Europe/Amsterdam")
req.Host = tt.host
require.NoError(t, err)
res, err := client.Do(req)
require.NoError(t, err)
for name, value := range tt.wantHeader {
@ -212,26 +196,27 @@ func TestNewHTTPClient(t *testing.T) {
assert.Equal(t, 204, res.StatusCode)
assert.Equal(t, tt.wantStderr, normalizeVerboseLog(stderr.String()))
assert.Equal(t, tt.wantSSO, SSOURL())
})
}
}
type tinyConfig map[string]string
func (c tinyConfig) Get(host, key string) (string, error) {
return c[fmt.Sprintf("%s:%s", host, key)], nil
func (c tinyConfig) AuthToken(host string) (string, string) {
return c[fmt.Sprintf("%s:%s", host, "oauth_token")], "oauth_token"
}
var requestAtRE = regexp.MustCompile(`(?m)^\* Request at .+`)
var dateRE = regexp.MustCompile(`(?m)^< Date: .+`)
var hostWithPortRE = regexp.MustCompile(`127\.0\.0\.1:\d+`)
var durationRE = regexp.MustCompile(`(?m)^\* Request took .+`)
var timezoneRE = regexp.MustCompile(`(?m)^> Time-Zone: .+`)
func normalizeVerboseLog(t string) string {
t = requestAtRE.ReplaceAllString(t, "* Request at <time>")
t = hostWithPortRE.ReplaceAllString(t, "<host>:<port>")
t = dateRE.ReplaceAllString(t, "< Date: <time>")
t = durationRE.ReplaceAllString(t, "* Request took <duration>")
t = timezoneRE.ReplaceAllString(t, "> Time-Zone: <timezone>")
return t
}

View file

@ -0,0 +1,219 @@
package api
import (
"fmt"
"github.com/cli/cli/v2/internal/ghrepo"
)
type LinkedBranch struct {
ID string
BranchName string
RepoUrl string
}
// method to return url of linked branch, adds the branch name to the end of the repo url
func (b *LinkedBranch) Url() string {
return fmt.Sprintf("%s/tree/%s", b.RepoUrl, b.BranchName)
}
func nameParam(params map[string]interface{}) string {
if params["name"] != "" {
return "name: $name,"
}
return ""
}
func nameArg(params map[string]interface{}) string {
if params["name"] != "" {
return "$name: String, "
}
return ""
}
func CreateBranchIssueReference(client *Client, repo *Repository, params map[string]interface{}) (*LinkedBranch, error) {
query := fmt.Sprintf(`
mutation CreateLinkedBranch($issueId: ID!, $oid: GitObjectID!, %[1]s$repositoryId: ID) {
createLinkedBranch(input: {
issueId: $issueId,
%[2]s
oid: $oid,
repositoryId: $repositoryId
}) {
linkedBranch {
id
ref {
name
}
}
}
}`, nameArg(params), nameParam(params))
inputParams := map[string]interface{}{
"repositoryId": repo.ID,
}
for key, val := range params {
switch key {
case "issueId", "name", "oid":
inputParams[key] = val
}
}
result := struct {
CreateLinkedBranch struct {
LinkedBranch struct {
ID string
Ref struct {
Name string
}
}
}
}{}
err := client.GraphQL(repo.RepoHost(), query, inputParams, &result)
if err != nil {
return nil, err
}
ref := LinkedBranch{
ID: result.CreateLinkedBranch.LinkedBranch.ID,
BranchName: result.CreateLinkedBranch.LinkedBranch.Ref.Name,
}
return &ref, nil
}
func ListLinkedBranches(client *Client, repo ghrepo.Interface, issueNumber int) ([]LinkedBranch, error) {
query := `
query BranchIssueReferenceListLinkedBranches($repositoryName: String!, $repositoryOwner: String!, $issueNumber: Int!) {
repository(name: $repositoryName, owner: $repositoryOwner) {
issue(number: $issueNumber) {
linkedBranches(first: 30) {
edges {
node {
ref {
name
repository {
url
}
}
}
}
}
}
}
}
`
variables := map[string]interface{}{
"repositoryName": repo.RepoName(),
"repositoryOwner": repo.RepoOwner(),
"issueNumber": issueNumber,
}
result := struct {
Repository struct {
Issue struct {
LinkedBranches struct {
Edges []struct {
Node struct {
Ref struct {
Name string
Repository struct {
NameWithOwner string
Url string
}
}
}
}
}
}
}
}{}
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
var branchNames []LinkedBranch
if err != nil {
return branchNames, err
}
for _, edge := range result.Repository.Issue.LinkedBranches.Edges {
branch := LinkedBranch{
BranchName: edge.Node.Ref.Name,
RepoUrl: edge.Node.Ref.Repository.Url,
}
branchNames = append(branchNames, branch)
}
return branchNames, nil
}
// introspects the schema to see if we expose the LinkedBranch type
func CheckLinkedBranchFeature(client *Client, host string) (err error) {
var featureDetection struct {
Name struct {
Fields []struct {
Name string
}
} `graphql:"LinkedBranch: __type(name: \"LinkedBranch\")"`
}
err = client.Query(host, "LinkedBranch_fields", &featureDetection, nil)
if err != nil {
return err
}
if len(featureDetection.Name.Fields) == 0 {
return fmt.Errorf("the `gh issue develop` command is not currently available")
}
return nil
}
// This fetches the oids for the repo's default branch (`main`, etc) and the name the user might have provided in one shot.
func FindBaseOid(client *Client, repo *Repository, ref string) (string, string, error) {
query := `
query BranchIssueReferenceFindBaseOid($repositoryName: String!, $repositoryOwner: String!, $ref: String!) {
repository(name: $repositoryName, owner: $repositoryOwner) {
defaultBranchRef {
target {
oid
}
}
ref(qualifiedName: $ref) {
target {
oid
}
}
}
}`
variables := map[string]interface{}{
"repositoryName": repo.Name,
"repositoryOwner": repo.RepoOwner(),
"ref": ref,
}
result := struct {
Repository struct {
DefaultBranchRef struct {
Target struct {
Oid string
}
}
Ref struct {
Target struct {
Oid string
}
}
}
}{}
err := client.GraphQL(repo.RepoHost(), query, variables, &result)
if err != nil {
return "", "", err
}
return result.Repository.Ref.Target.Oid, result.Repository.DefaultBranchRef.Target.Oid, nil
}

View file

@ -1,10 +1,8 @@
package api
import (
"context"
"time"
graphql "github.com/cli/shurcooL-graphql"
"github.com/shurcooL/githubv4"
)
@ -17,7 +15,18 @@ type Comments struct {
}
}
func (cs Comments) CurrentUserComments() []Comment {
var comments []Comment
for _, c := range cs.Nodes {
if c.ViewerDidAuthor {
comments = append(comments, c)
}
}
return comments
}
type Comment struct {
ID string `json:"id"`
Author Author `json:"author"`
AuthorAssociation string `json:"authorAssociation"`
Body string `json:"body"`
@ -26,6 +35,8 @@ type Comment struct {
IsMinimized bool `json:"isMinimized"`
MinimizedReason string `json:"minimizedReason"`
ReactionGroups ReactionGroups `json:"reactionGroups"`
URL string `json:"url,omitempty"`
ViewerDidAuthor bool `json:"viewerDidAuthor"`
}
type CommentCreateInput struct {
@ -33,6 +44,11 @@ type CommentCreateInput struct {
SubjectId string
}
type CommentUpdateInput struct {
Body string
CommentId string
}
func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (string, error) {
var mutation struct {
AddComment struct {
@ -47,12 +63,11 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (
variables := map[string]interface{}{
"input": githubv4.AddCommentInput{
Body: githubv4.String(params.Body),
SubjectID: graphql.ID(params.SubjectId),
SubjectID: githubv4.ID(params.SubjectId),
},
}
gql := graphQLClient(client.http, repoHost)
err := gql.MutateNamed(context.Background(), "CommentCreate", &mutation, variables)
err := client.Mutate(repoHost, "CommentCreate", &mutation, variables)
if err != nil {
return "", err
}
@ -60,6 +75,34 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (
return mutation.AddComment.CommentEdge.Node.URL, nil
}
func CommentUpdate(client *Client, repoHost string, params CommentUpdateInput) (string, error) {
var mutation struct {
UpdateIssueComment struct {
IssueComment struct {
URL string
}
} `graphql:"updateIssueComment(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.UpdateIssueCommentInput{
Body: githubv4.String(params.Body),
ID: githubv4.ID(params.CommentId),
},
}
err := client.Mutate(repoHost, "CommentUpdate", &mutation, variables)
if err != nil {
return "", err
}
return mutation.UpdateIssueComment.IssueComment.URL, nil
}
func (c Comment) Identifier() string {
return c.ID
}
func (c Comment) AuthorLogin() string {
return c.Author.Login
}
@ -89,7 +132,7 @@ func (c Comment) IsHidden() bool {
}
func (c Comment) Link() string {
return ""
return c.URL
}
func (c Comment) Reactions() ReactionGroups {

View file

@ -26,6 +26,7 @@ type Issue struct {
Title string
URL string
State string
StateReason string
Closed bool
Body string
CreatedAt time.Time
@ -38,6 +39,7 @@ type Issue struct {
ProjectCards ProjectCards
Milestone *Milestone
ReactionGroups ReactionGroups
IsPinned bool
}
func (i Issue) IsPullRequest() bool {
@ -110,12 +112,15 @@ type Owner struct {
}
type Author struct {
// adding these breaks generated GraphQL requests
//ID string `json:"id,omitempty"`
//Name string `json:"name,omitempty"`
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Login string `json:"login"`
}
func (author *Author) IsBot() bool {
return author.ID == ""
}
// IssueCreate creates an issue in a GitHub repository
func IssueCreate(client *Client, repo *Repository, params map[string]interface{}) (*Issue, error) {
query := `
@ -175,7 +180,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, options IssueStatusOptio
}
}
fragments := fmt.Sprintf("fragment issue on Issue{%s}", PullRequestGraphQL(options.Fields))
fragments := fmt.Sprintf("fragment issue on Issue{%s}", IssueGraphQL(options.Fields))
query := fragments + `
query IssueStatus($owner: String!, $repo: String!, $viewer: String!, $per_page: Int = 10) {
repository(owner: $owner, name: $repo) {
@ -242,3 +247,7 @@ func (i Issue) Link() string {
func (i Issue) Identifier() string {
return i.ID
}
func (i Issue) CurrentUserComments() []Comment {
return i.Comments.CurrentUserComments()
}

View file

@ -1,8 +1,6 @@
package api
import (
"context"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
@ -26,12 +24,10 @@ func OrganizationProjects(client *Client, repo ghrepo.Interface) ([]RepoProject,
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var projects []RepoProject
for {
var query responseData
err := gql.QueryNamed(context.Background(), "OrganizationProjectList", &query, variables)
err := client.Query(repo.RepoHost(), "OrganizationProjectList", &query, variables)
if err != nil {
return nil, err
}
@ -70,12 +66,10 @@ func OrganizationTeams(client *Client, repo ghrepo.Interface) ([]OrgTeam, error)
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var teams []OrgTeam
for {
var query responseData
err := gql.QueryNamed(context.Background(), "OrganizationTeamList", &query, variables)
err := client.Query(repo.RepoHost(), "OrganizationTeamList", &query, variables)
if err != nil {
return nil, err
}

View file

@ -1,9 +1,9 @@
package api
import (
"context"
"fmt"
"net/http"
"net/url"
"time"
"github.com/cli/cli/v2/internal/ghrepo"
@ -17,24 +17,27 @@ type PullRequestAndTotalCount struct {
}
type PullRequest struct {
ID string
Number int
Title string
State string
Closed bool
URL string
BaseRefName string
HeadRefName string
Body string
Mergeable string
Additions int
Deletions int
ChangedFiles int
MergeStateStatus string
CreatedAt time.Time
UpdatedAt time.Time
ClosedAt *time.Time
MergedAt *time.Time
ID string
Number int
Title string
State string
Closed bool
URL string
BaseRefName string
HeadRefName string
HeadRefOid string
Body string
Mergeable string
Additions int
Deletions int
ChangedFiles int
MergeStateStatus string
IsInMergeQueue bool
IsMergeQueueEnabled bool // Indicates whether the pull request's base ref has a merge queue enabled.
CreatedAt time.Time
UpdatedAt time.Time
ClosedAt *time.Time
MergedAt *time.Time
MergeCommit *Commit
PotentialMergeCommit *Commit
@ -65,19 +68,7 @@ type PullRequest struct {
Nodes []PullRequestCommit
}
StatusCheckRollup struct {
Nodes []struct {
Commit struct {
StatusCheckRollup struct {
Contexts struct {
Nodes []CheckContext
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
}
}
}
Nodes []StatusCheckRollupNode
}
Assignees Assignees
@ -91,17 +82,52 @@ type PullRequest struct {
ReviewRequests ReviewRequests
}
type StatusCheckRollupNode struct {
Commit StatusCheckRollupCommit
}
type StatusCheckRollupCommit struct {
StatusCheckRollup CommitStatusCheckRollup
}
type CommitStatusCheckRollup struct {
Contexts CheckContexts
}
type CheckContexts struct {
Nodes []CheckContext
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
type CheckContext struct {
TypeName string `json:"__typename"`
Name string `json:"name"`
Context string `json:"context,omitempty"`
State string `json:"state,omitempty"`
Status string `json:"status"`
TypeName string `json:"__typename"`
Name string `json:"name"`
IsRequired bool `json:"isRequired"`
CheckSuite struct {
WorkflowRun struct {
Workflow struct {
Name string `json:"name"`
} `json:"workflow"`
} `json:"workflowRun"`
} `json:"checkSuite"`
// QUEUED IN_PROGRESS COMPLETED WAITING PENDING REQUESTED
Status string `json:"status"`
// ACTION_REQUIRED TIMED_OUT CANCELLED FAILURE SUCCESS NEUTRAL SKIPPED STARTUP_FAILURE STALE
Conclusion string `json:"conclusion"`
StartedAt time.Time `json:"startedAt"`
CompletedAt time.Time `json:"completedAt"`
DetailsURL string `json:"detailsUrl"`
TargetURL string `json:"targetUrl,omitempty"`
/* StatusContext fields */
Context string `json:"context"`
// EXPECTED ERROR FAILURE PENDING SUCCESS
State string `json:"state"`
TargetURL string `json:"targetUrl"`
CreatedAt time.Time `json:"createdAt"`
}
type PRRepository struct {
@ -188,6 +214,10 @@ func (pr PullRequest) Identifier() string {
return pr.ID
}
func (pr PullRequest) CurrentUserComments() []Comment {
return pr.Comments.CurrentUserComments()
}
func (pr PullRequest) IsOpen() bool {
return pr.State == "OPEN"
}
@ -243,6 +273,7 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
}
summary.Total++
}
return
}
@ -359,8 +390,7 @@ func UpdatePullRequestReviews(client *Client, repo ghrepo.Interface, params gith
} `graphql:"requestReviews(input: $input)"`
}
variables := map[string]interface{}{"input": params}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.MutateNamed(context.Background(), "PullRequestUpdateRequestReviews", &mutation, variables)
err := client.Mutate(repo.RepoHost(), "PullRequestUpdateRequestReviews", &mutation, variables)
return err
}
@ -390,8 +420,8 @@ func PullRequestClose(httpClient *http.Client, repo ghrepo.Interface, prID strin
},
}
gql := graphQLClient(httpClient, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestClose", &mutation, variables)
client := NewClientFromHTTP(httpClient)
return client.Mutate(repo.RepoHost(), "PullRequestClose", &mutation, variables)
}
func PullRequestReopen(httpClient *http.Client, repo ghrepo.Interface, prID string) error {
@ -409,8 +439,8 @@ func PullRequestReopen(httpClient *http.Client, repo ghrepo.Interface, prID stri
},
}
gql := graphQLClient(httpClient, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestReopen", &mutation, variables)
client := NewClientFromHTTP(httpClient)
return client.Mutate(repo.RepoHost(), "PullRequestReopen", &mutation, variables)
}
func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
@ -428,11 +458,28 @@ func PullRequestReady(client *Client, repo ghrepo.Interface, pr *PullRequest) er
},
}
gql := graphQLClient(client.http, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestReadyForReview", &mutation, variables)
return client.Mutate(repo.RepoHost(), "PullRequestReadyForReview", &mutation, variables)
}
func ConvertPullRequestToDraft(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
var mutation struct {
ConvertPullRequestToDraft struct {
PullRequest struct {
ID githubv4.ID
}
} `graphql:"convertPullRequestToDraft(input: $input)"`
}
variables := map[string]interface{}{
"input": githubv4.ConvertPullRequestToDraftInput{
PullRequestID: pr.ID,
},
}
return client.Mutate(repo.RepoHost(), "ConvertPullRequestToDraft", &mutation, variables)
}
func BranchDeleteRemote(client *Client, repo ghrepo.Interface, branch string) error {
path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), branch)
path := fmt.Sprintf("repos/%s/%s/git/refs/heads/%s", repo.RepoOwner(), repo.RepoName(), url.PathEscape(branch))
return client.REST(repo.RepoHost(), "DELETE", path, nil, nil)
}

View file

@ -1,7 +1,6 @@
package api
import (
"context"
"time"
"github.com/cli/cli/v2/internal/ghrepo"
@ -31,6 +30,7 @@ type PullRequestReviews struct {
}
type PullRequestReview struct {
ID string `json:"id"`
Author Author `json:"author"`
AuthorAssociation string `json:"authorAssociation"`
Body string `json:"body"`
@ -39,6 +39,7 @@ type PullRequestReview struct {
ReactionGroups ReactionGroups `json:"reactionGroups"`
State string `json:"state"`
URL string `json:"url,omitempty"`
Commit Commit `json:"commit"`
}
func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *PullRequestReviewInput) error {
@ -65,8 +66,11 @@ func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *Pu
},
}
gql := graphQLClient(client.http, repo.RepoHost())
return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables)
return client.Mutate(repo.RepoHost(), "PullRequestReviewAdd", &mutation, variables)
}
func (prr PullRequestReview) Identifier() string {
return prr.ID
}
func (prr PullRequestReview) AuthorLogin() string {

View file

@ -11,36 +11,44 @@ import (
func TestBranchDeleteRemote(t *testing.T) {
var tests = []struct {
name string
responseStatus int
responseBody string
expectError bool
name string
branch string
httpStubs func(*httpmock.Registry)
expectError bool
}{
{
name: "success",
responseStatus: 204,
responseBody: "",
expectError: false,
name: "success",
branch: "owner/branch#123",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/owner%2Fbranch%23123"),
httpmock.StatusStringResponse(204, ""))
},
expectError: false,
},
{
name: "error",
responseStatus: 500,
responseBody: `{"message": "oh no"}`,
expectError: true,
name: "error",
branch: "my-branch",
httpStubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/my-branch"),
httpmock.StatusStringResponse(500, `{"message": "oh no"}`))
},
expectError: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
http := &httpmock.Registry{}
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/branch"),
httpmock.StatusStringResponse(tt.responseStatus, tt.responseBody))
if tt.httpStubs != nil {
tt.httpStubs(http)
}
client := NewClient(ReplaceTripper(http))
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
err := BranchDeleteRemote(client, repo, "branch")
err := BranchDeleteRemote(client, repo, tt.branch)
if (err != nil) != tt.expectError {
t.Fatalf("unexpected result: %v", err)
}

View file

@ -2,8 +2,8 @@ package api
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
@ -14,6 +14,7 @@ import (
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
ghAPI "github.com/cli/go-gh/pkg/api"
"github.com/shurcooL/githubv4"
)
@ -254,11 +255,13 @@ func FetchRepository(client *Client, repo ghrepo.Interface, fields []string) (*R
// The GraphQL API should have returned an error in case of a missing repository, but this isn't
// guaranteed to happen when an authentication token with insufficient permissions is being used.
if result.Repository == nil {
return nil, GraphQLErrorResponse{
Errors: []GraphQLError{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
return nil, GraphQLError{
GQLError: ghAPI.GQLError{
Errors: []ghAPI.GQLErrorItem{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
},
}
}
@ -305,11 +308,13 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
// The GraphQL API should have returned an error in case of a missing repository, but this isn't
// guaranteed to happen when an authentication token with insufficient permissions is being used.
if result.Repository == nil {
return nil, GraphQLErrorResponse{
Errors: []GraphQLError{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
return nil, GraphQLError{
GQLError: ghAPI.GQLError{
Errors: []ghAPI.GQLErrorItem{{
Type: "NOT_FOUND",
Message: fmt.Sprintf("Could not resolve to a Repository with the name '%s/%s'.", repo.RepoOwner(), repo.RepoName()),
}},
},
}
}
@ -359,8 +364,7 @@ func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error)
"name": githubv4.String(repo.RepoName()),
}
gql := graphQLClient(client.http, repo.RepoHost())
err := gql.QueryNamed(context.Background(), "RepositoryFindParent", &query, variables)
err := client.Query(repo.RepoHost(), "RepositoryFindParent", &query, variables)
if err != nil {
return nil, err
}
@ -419,8 +423,8 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e
%s
}
`, strings.Join(queries, "")), nil, &graphqlResult)
graphqlError, isGraphQLError := err.(*GraphQLErrorResponse)
if isGraphQLError {
var graphqlError GraphQLError
if errors.As(err, &graphqlError) {
// If the only errors are that certain repositories are not found,
// continue processing this response instead of returning an error
tolerated := true
@ -497,13 +501,16 @@ type repositoryV3 struct {
}
// ForkRepo forks the repository on GitHub and returns the new repository
func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, error) {
func ForkRepo(client *Client, repo ghrepo.Interface, org, newName string) (*Repository, error) {
path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo))
params := map[string]interface{}{}
if org != "" {
params["organization"] = org
}
if newName != "" {
params["name"] = newName
}
body := &bytes.Buffer{}
enc := json.NewEncoder(body)
@ -517,7 +524,7 @@ func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, e
return nil, err
}
return &Repository{
newRepo := &Repository{
ID: result.NodeID,
Name: result.Name,
CreatedAt: result.CreatedAt,
@ -526,7 +533,15 @@ func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, e
},
ViewerPermission: "WRITE",
hostname: repo.RepoHost(),
}, nil
}
// The GitHub API will happily return a HTTP 200 when attempting to fork own repo even though no forking
// actually took place. Ensure that we raise an error instead.
if ghrepo.IsSame(repo, newRepo) {
return newRepo, fmt.Errorf("%s cannot be forked", ghrepo.FullName(repo))
}
return newRepo, nil
}
// RenameRepo renames the repository on GitHub and returns the renamed repository
@ -573,8 +588,7 @@ func LastCommit(client *Client, repo ghrepo.Interface) (*Commit, error) {
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()), "repo": githubv4.String(repo.RepoName()),
}
gql := graphQLClient(client.http, repo.RepoHost())
if err := gql.QueryNamed(context.Background(), "LastCommit", &responseData, variables); err != nil {
if err := client.Query(repo.RepoHost(), "LastCommit", &responseData, variables); err != nil {
return nil, err
}
return &responseData.Repository.DefaultBranchRef.Target.Commit, nil
@ -629,6 +643,7 @@ func RepoFindForks(client *Client, repo ghrepo.Interface, limit int) ([]*Reposit
}
type RepoMetadataResult struct {
CurrentLogin string
AssignableUsers []RepoAssignee
Labels []RepoLabel
Projects []RepoProject
@ -805,6 +820,17 @@ func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput
errc <- nil
}()
}
if input.Reviewers {
count++
go func() {
login, err := CurrentLoginName(client, repo.RepoHost())
if err != nil {
err = fmt.Errorf("error fetching current login: %w", err)
}
result.CurrentLogin = login
errc <- err
}()
}
if input.Labels {
count++
go func() {
@ -979,12 +1005,10 @@ func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error)
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var projects []RepoProject
for {
var query responseData
err := gql.QueryNamed(context.Background(), "RepositoryProjectList", &query, variables)
err := client.Query(repo.RepoHost(), "RepositoryProjectList", &query, variables)
if err != nil {
return nil, err
}
@ -1050,12 +1074,10 @@ func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee,
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var users []RepoAssignee
for {
var query responseData
err := gql.QueryNamed(context.Background(), "RepositoryAssignableUsers", &query, variables)
err := client.Query(repo.RepoHost(), "RepositoryAssignableUsers", &query, variables)
if err != nil {
return nil, err
}
@ -1095,12 +1117,10 @@ func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) {
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var labels []RepoLabel
for {
var query responseData
err := gql.QueryNamed(context.Background(), "RepositoryLabelList", &query, variables)
err := client.Query(repo.RepoHost(), "RepositoryLabelList", &query, variables)
if err != nil {
return nil, err
}
@ -1153,12 +1173,10 @@ func RepoMilestones(client *Client, repo ghrepo.Interface, state string) ([]Repo
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var milestones []RepoMilestone
for {
var query responseData
err := gql.QueryNamed(context.Background(), "RepositoryMilestoneList", &query, variables)
err := client.Query(repo.RepoHost(), "RepositoryMilestoneList", &query, variables)
if err != nil {
return nil, err
}

View file

@ -18,7 +18,7 @@ func TestGitHubRepo_notFound(t *testing.T) {
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`{ "data": { "repository": null } }`))
client := NewClient(ReplaceTripper(httpReg))
client := newTestClient(httpReg)
repo, err := GitHubRepo(client, ghrepo.New("OWNER", "REPO"))
if err == nil {
t.Fatal("GitHubRepo did not return an error")
@ -33,7 +33,7 @@ func TestGitHubRepo_notFound(t *testing.T) {
func Test_RepoMetadata(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
input := RepoMetadataInput{
@ -110,6 +110,11 @@ func Test_RepoMetadata(t *testing.T) {
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`
{ "data": { "viewer": { "login": "monalisa" } } }
`))
result, err := RepoMetadata(client, repo, input)
if err != nil {
@ -160,6 +165,11 @@ func Test_RepoMetadata(t *testing.T) {
if milestoneID != expectedMilestoneID {
t.Errorf("expected milestone %v, got %v", expectedMilestoneID, milestoneID)
}
expectedCurrentLogin := "monalisa"
if result.CurrentLogin != expectedCurrentLogin {
t.Errorf("expected current user %v, got %v", expectedCurrentLogin, result.CurrentLogin)
}
}
func Test_ProjectsToPaths(t *testing.T) {
@ -182,7 +192,7 @@ func Test_ProjectsToPaths(t *testing.T) {
func Test_ProjectNamesToPaths(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
@ -221,7 +231,7 @@ func Test_ProjectNamesToPaths(t *testing.T) {
func Test_RepoResolveMetadataIDs(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
client := newTestClient(http)
repo, _ := ghrepo.FromFullName("OWNER/REPO")
input := RepoResolveInput{
@ -350,7 +360,7 @@ func Test_RepoMilestones(t *testing.T) {
query = buf.String()
return httpmock.StringResponse("{}")(req)
})
client := NewClient(ReplaceTripper(reg))
client := newTestClient(reg)
_, err := RepoMilestones(client, ghrepo.New("OWNER", "REPO"), tt.state)
if (err != nil) != tt.wantErr {

View file

@ -1,8 +1,8 @@
package api
import (
"context"
)
type Organization struct {
Login string
}
func CurrentLoginName(client *Client, hostname string) (string, error) {
var query struct {
@ -10,18 +10,36 @@ func CurrentLoginName(client *Client, hostname string) (string, error) {
Login string
}
}
gql := graphQLClient(client.http, hostname)
err := gql.QueryNamed(context.Background(), "UserCurrent", &query, nil)
err := client.Query(hostname, "UserCurrent", &query, nil)
return query.Viewer.Login, err
}
func CurrentLoginNameAndOrgs(client *Client, hostname string) (string, []string, error) {
var query struct {
Viewer struct {
Login string
Organizations struct {
Nodes []Organization
} `graphql:"organizations(first: 100)"`
}
}
err := client.Query(hostname, "UserCurrent", &query, nil)
if err != nil {
return "", nil, err
}
orgNames := []string{}
for _, org := range query.Viewer.Organizations.Nodes {
orgNames = append(orgNames, org.Login)
}
return query.Viewer.Login, orgNames, err
}
func CurrentUserID(client *Client, hostname string) (string, error) {
var query struct {
Viewer struct {
ID string
}
}
gql := graphQLClient(client.http, hostname)
err := gql.QueryNamed(context.Background(), "UserCurrent", &query, nil)
err := client.Query(hostname, "UserCurrent", &query, nil)
return query.Viewer.ID, err
}

View file

@ -3,6 +3,8 @@ package api
import (
"fmt"
"strings"
"github.com/cli/cli/v2/pkg/set"
)
func squeeze(r rune) rune {
@ -21,6 +23,7 @@ func shortenQuery(q string) string {
var issueComments = shortenQuery(`
comments(first: 100) {
nodes {
id,
author{login},
authorAssociation,
body,
@ -28,7 +31,9 @@ var issueComments = shortenQuery(`
includesCreatedEdit,
isMinimized,
minimizedReason,
reactionGroups{content,users{totalCount}}
reactionGroups{content,users{totalCount}},
url,
viewerDidAuthor
},
pageInfo{hasNextPage,endCursor},
totalCount
@ -70,11 +75,13 @@ var prReviewRequests = shortenQuery(`
var prReviews = shortenQuery(`
reviews(first: 100) {
nodes {
id,
author{login},
authorAssociation,
submittedAt,
body,
state,
commit{oid},
reactionGroups{content,users{totalCount}}
}
pageInfo{hasNextPage,endCursor}
@ -141,10 +148,12 @@ func StatusCheckRollupGraphQL(after string) string {
...on StatusContext {
context,
state,
targetUrl
targetUrl,
createdAt
},
...on CheckRun {
name,
checkSuite{workflowRun{workflow{name}}},
status,
conclusion,
startedAt,
@ -160,6 +169,45 @@ func StatusCheckRollupGraphQL(after string) string {
}`), afterClause)
}
func RequiredStatusCheckRollupGraphQL(prID, after string) string {
var afterClause string
if after != "" {
afterClause = ",after:" + after
}
return fmt.Sprintf(shortenQuery(`
statusCheckRollup: commits(last: 1) {
nodes {
commit {
statusCheckRollup {
contexts(first:100%[1]s) {
nodes {
__typename
...on StatusContext {
context,
state,
targetUrl,
createdAt,
isRequired(pullRequestId: %[2]s)
},
...on CheckRun {
name,
checkSuite{workflowRun{workflow{name}}},
status,
conclusion,
startedAt,
completedAt,
detailsUrl,
isRequired(pullRequestId: %[2]s)
}
},
pageInfo{hasNextPage,endCursor}
}
}
}
}
}`), afterClause, prID)
}
var IssueFields = []string{
"assignees",
"author",
@ -188,6 +236,7 @@ var PullRequestFields = append(IssueFields,
"deletions",
"files",
"headRefName",
"headRefOid",
"headRepository",
"headRepositoryOwner",
"isCrossRepository",
@ -206,14 +255,13 @@ var PullRequestFields = append(IssueFields,
"statusCheckRollup",
)
// PullRequestGraphQL constructs a GraphQL query fragment for a set of pull request fields. Since GitHub
// pull requests are also technically issues, this function can be used to query issues as well.
func PullRequestGraphQL(fields []string) string {
// IssueGraphQL constructs a GraphQL query fragment for a set of issue fields.
func IssueGraphQL(fields []string) string {
var q []string
for _, field := range fields {
switch field {
case "author":
q = append(q, `author{login}`)
q = append(q, `author{login,...on User{id,name}}`)
case "mergedBy":
q = append(q, `mergedBy{login}`)
case "headRepositoryOwner":
@ -263,6 +311,16 @@ func PullRequestGraphQL(fields []string) string {
return strings.Join(q, ",")
}
// PullRequestGraphQL constructs a GraphQL query fragment for a set of pull request fields.
// It will try to sanitize the fields to just those available on pull request.
func PullRequestGraphQL(fields []string) string {
invalidFields := []string{"isPinned", "stateReason"}
s := set.NewStringSet()
s.AddValues(fields)
s.RemoveValues(invalidFields)
return IssueGraphQL(s.ToSlice())
}
var RepositoryFields = []string{
"id",
"name",

View file

@ -21,13 +21,18 @@ func TestPullRequestGraphQL(t *testing.T) {
{
name: "fields with nested structures",
fields: []string{"author", "assignees"},
want: "author{login},assignees(first:100){nodes{id,login,name},totalCount}",
want: "author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name},totalCount}",
},
{
name: "compressed query",
fields: []string{"files"},
want: "files(first: 100) {nodes {additions,deletions,path}}",
},
{
name: "invalid fields",
fields: []string{"isPinned", "stateReason", "number"},
want: "number",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
@ -37,3 +42,39 @@ func TestPullRequestGraphQL(t *testing.T) {
})
}
}
func TestIssueGraphQL(t *testing.T) {
tests := []struct {
name string
fields []string
want string
}{
{
name: "empty",
fields: []string(nil),
want: "",
},
{
name: "simple fields",
fields: []string{"number", "title"},
want: "number,title",
},
{
name: "fields with nested structures",
fields: []string{"author", "assignees"},
want: "author{login,...on User{id,name}},assignees(first:100){nodes{id,login,name},totalCount}",
},
{
name: "compressed query",
fields: []string{"files"},
want: "files(first: 100) {nodes {additions,deletions,path}}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IssueGraphQL(tt.fields); got != tt.want {
t.Errorf("IssueGraphQL() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -13,23 +13,23 @@ import (
surveyCore "github.com/AlecAivazis/survey/v2/core"
"github.com/AlecAivazis/survey/v2/terminal"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/build"
"github.com/cli/cli/v2/internal/config"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/internal/update"
"github.com/cli/cli/v2/pkg/cmd/alias/expand"
"github.com/cli/cli/v2/pkg/cmd/factory"
"github.com/cli/cli/v2/pkg/cmd/root"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/text"
"github.com/cli/cli/v2/utils"
"github.com/cli/safeexec"
"github.com/mattn/go-colorable"
"github.com/mattn/go-isatty"
"github.com/mgutz/ansi"
"github.com/spf13/cobra"
)
@ -64,12 +64,9 @@ func mainRun() exitCode {
cmdFactory := factory.New(buildVersion)
stderr := cmdFactory.IOStreams.ErrOut
if spec := os.Getenv("GH_FORCE_TTY"); spec != "" {
cmdFactory.IOStreams.ForceTerminal(spec)
}
if !cmdFactory.IOStreams.ColorEnabled() {
surveyCore.DisableColor = true
ansi.DisableColors(true)
} else {
// override survey's poor choice of color
surveyCore.TemplateFuncsWithColor["color"] = func(style string) string {
@ -99,11 +96,6 @@ func mainRun() exitCode {
return exitError
}
// TODO: remove after FromFullName has been revisited
if host, err := cfg.DefaultHost(); err == nil {
ghrepo.SetDefaultHost(host)
}
expandedArgs := []string{}
if len(os.Args) > 0 {
expandedArgs = os.Args[1:]
@ -171,18 +163,17 @@ func mainRun() exitCode {
// provide completions for aliases and extensions
rootCmd.ValidArgsFunction = func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var results []string
if aliases, err := cfg.Aliases(); err == nil {
for aliasName, aliasValue := range aliases.All() {
if strings.HasPrefix(aliasName, toComplete) {
var s string
if strings.HasPrefix(aliasValue, "!") {
s = fmt.Sprintf("%s\tShell alias", aliasName)
} else {
aliasValue = text.Truncate(80, aliasValue)
s = fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue)
}
results = append(results, s)
aliases := cfg.Aliases()
for aliasName, aliasValue := range aliases.All() {
if strings.HasPrefix(aliasName, toComplete) {
var s string
if strings.HasPrefix(aliasValue, "!") {
s = fmt.Sprintf("%s\tShell alias", aliasName)
} else {
aliasValue = text.Truncate(80, aliasValue)
s = fmt.Sprintf("%s\tAlias for %s", aliasName, aliasValue)
}
results = append(results, s)
}
}
for _, ext := range cmdFactory.ExtensionManager.List() {
@ -205,15 +196,11 @@ func mainRun() exitCode {
return results, cobra.ShellCompDirectiveNoFileComp
}
cs := cmdFactory.IOStreams.ColorScheme()
authError := errors.New("authError")
rootCmd.PersistentPreRunE = func(cmd *cobra.Command, args []string) error {
// require that the user is authenticated before running most commands
if 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`.")
fmt.Fprint(stderr, authHelp())
return authError
}
@ -282,7 +269,7 @@ func mainRun() exitCode {
ansi.Color(strings.TrimPrefix(buildVersion, "v"), "cyan"),
ansi.Color(strings.TrimPrefix(newRelease.Version, "v"), "cyan"))
if isHomebrew {
fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew update && brew upgrade gh")
fmt.Fprintf(stderr, "To upgrade, run: %s\n", "brew upgrade gh")
}
fmt.Fprintf(stderr, "%s\n\n",
ansi.Color(newRelease.URL, "yellow"))
@ -319,6 +306,27 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
}
}
func authHelp() string {
if os.Getenv("GITHUB_ACTIONS") == "true" {
return heredoc.Doc(`
gh: To use GitHub CLI in a GitHub Actions workflow, set the GH_TOKEN environment variable. Example:
env:
GH_TOKEN: ${{ github.token }}
`)
}
if os.Getenv("CI") != "" {
return heredoc.Doc(`
gh: To use GitHub CLI in automation, set the GH_TOKEN environment variable.
`)
}
return heredoc.Doc(`
To get started with GitHub CLI, please run: gh auth login
Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token.
`)
}
func shouldCheckForUpdate() bool {
if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
return false
@ -326,7 +334,11 @@ func shouldCheckForUpdate() bool {
if os.Getenv("CODESPACES") != "" {
return false
}
return updaterEnabled != "" && !isCI() && utils.IsTerminal(os.Stdout) && utils.IsTerminal(os.Stderr)
return updaterEnabled != "" && !isCI() && isTerminal(os.Stdout) && isTerminal(os.Stderr)
}
func isTerminal(f *os.File) bool {
return isatty.IsTerminal(f.Fd()) || isatty.IsCygwinTerminal(f.Fd())
}
// based on https://github.com/watson/ci-info/blob/HEAD/index.js
@ -340,40 +352,19 @@ func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) {
if !shouldCheckForUpdate() {
return nil, nil
}
client, err := basicClient(currentVersion)
httpClient, err := api.NewHTTPClient(api.HTTPClientOptions{
AppVersion: currentVersion,
Log: os.Stderr,
})
if err != nil {
return nil, err
}
client := api.NewClientFromHTTP(httpClient)
repo := updaterEnabled
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
return update.CheckForUpdate(client, stateFilePath, repo, currentVersion)
}
// BasicClient returns an API client for github.com only that borrows from but
// does not depend on user configuration
func basicClient(currentVersion string) (*api.Client, error) {
var opts []api.ClientOption
if isVerbose, debugValue := utils.IsDebugEnabled(); isVerbose {
colorize := utils.IsTerminal(os.Stderr)
logTraffic := strings.Contains(debugValue, "api")
opts = append(opts, api.VerboseLog(colorable.NewColorable(os.Stderr), logTraffic, colorize))
}
opts = append(opts, api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", currentVersion)))
token, _ := config.AuthTokenFromEnv(ghinstance.Default())
if token == "" {
if c, err := config.ParseDefaultConfig(); err == nil {
token, _ = c.Get(ghinstance.Default(), "oauth_token")
}
}
if token != "" {
opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
}
return api.NewClient(opts...), nil
}
func isRecentRelease(publishedAt time.Time) bool {
return !publishedAt.IsZero() && time.Since(publishedAt) < time.Hour*24
}

View file

@ -2,16 +2,15 @@
package context
import (
"context"
"errors"
"sort"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghrepo"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/pkg/prompt"
)
// cap the number of git remotes looked up, since the user might have an
@ -60,7 +59,11 @@ type ResolvedRemotes struct {
apiClient *api.Client
}
func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, error) {
type iprompter interface {
Select(string, string, []string) (int, error)
}
func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams, p iprompter) (ghrepo.Interface, error) {
if r.baseOverride != nil {
return r.baseOverride, nil
}
@ -102,13 +105,11 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, e
// hide the spinner in case a command started the progress indicator before base repo was fully
// resolved, e.g. in `gh issue view`
io.StopProgressIndicator()
err := prompt.SurveyAskOne(&survey.Select{
Message: "Which should be the base repository (used for e.g. querying issues) for this directory?",
Options: repoNames,
}, &baseName)
selected, err := p.Select("Which should be the base repository (used for e.g. querying issues) for this directory?", "", repoNames)
if err != nil {
return nil, err
}
baseName = repoNames[selected]
}
// determine corresponding git remote
@ -122,7 +123,8 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, e
}
// cache the result to git config
err = git.SetRemoteResolution(remote.Name, resolution)
c := &git.Client{}
err = c.SetRemoteResolution(context.Background(), remote.Name, resolution)
return selectedRepo, err
}

View file

@ -98,15 +98,18 @@ func (r Remote) RepoHost() string {
return r.Repo.RepoHost()
}
// TODO: accept an interface instead of git.RemoteSet
func TranslateRemotes(gitRemotes git.RemoteSet, urlTranslate func(*url.URL) *url.URL) (remotes Remotes) {
type Translator interface {
Translate(*url.URL) *url.URL
}
func TranslateRemotes(gitRemotes git.RemoteSet, translator Translator) (remotes Remotes) {
for _, r := range gitRemotes {
var repo ghrepo.Interface
if r.FetchURL != nil {
repo, _ = ghrepo.FromURL(urlTranslate(r.FetchURL))
repo, _ = ghrepo.FromURL(translator.Translate(r.FetchURL))
}
if r.PushURL != nil && repo == nil {
repo, _ = ghrepo.FromURL(urlTranslate(r.PushURL))
repo, _ = ghrepo.FromURL(translator.Translate(r.PushURL))
}
if repo == nil {
continue

View file

@ -28,6 +28,12 @@ func Test_Remotes_FindByName(t *testing.T) {
assert.Error(t, err, "no GitHub remotes found")
}
type identityTranslator struct{}
func (it identityTranslator) Translate(u *url.URL) *url.URL {
return u
}
func Test_translateRemotes(t *testing.T) {
publicURL, _ := url.Parse("https://github.com/monalisa/hello")
originURL, _ := url.Parse("http://example.com/repo")
@ -43,10 +49,7 @@ func Test_translateRemotes(t *testing.T) {
},
}
identityURL := func(u *url.URL) *url.URL {
return u
}
result := TranslateRemotes(gitRemotes, identityURL)
result := TranslateRemotes(gitRemotes, identityTranslator{})
if len(result) != 1 {
t.Errorf("got %d results", len(result))

View file

@ -14,12 +14,17 @@ our release schedule.
Install:
```bash
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update
sudo apt install gh
type -p curl >/dev/null || sudo apt install curl -y
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg \
&& sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null \
&& sudo apt update \
&& sudo apt install gh -y
```
> **Note**
> We were recently forced to change our GPG signing key. If you've previously downloaded the `githubcli-archive-keyring.gpg` file, you should re-download it again per above instructions. If you are using a keyserver to download the key, the ID of the new key is `23F3D4EA75716059`.
Upgrade:
```bash
@ -49,6 +54,24 @@ Upgrade:
sudo dnf update gh
```
### Amazon Linux 2 (yum)
Install using our package repository for immediate access to latest releases:
```bash
sudo yum-config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo
sudo yum install gh
```
> **Note**
> We were recently forced to change our GPG signing key. If you've added the repository previously and now you're getting a GPG signing key error, disable the repository first with `sudo yum-config-manager --disable gh-cli` and add it again with `sudo yum-config-manager --add-repo https://cli.github.com/packages/rpm/gh-cli.repo`.
Upgrade:
```bash
sudo yum update gh
```
### openSUSE/SUSE Linux (zypper)
Install:

View file

@ -1,6 +1,6 @@
# Installation from source
1. Verify that you have Go 1.18+ installed
1. Verify that you have Go 1.19+ installed
```sh
$ go version
@ -19,7 +19,7 @@
#### Unix-like systems
```sh
# installs to '/usr/local' by default; sudo may be required
# installs to '/usr/local' by default; sudo may be required, or sudo -E for configured go environments
$ make install
# or, install to a different location

618
git/client.go Normal file
View file

@ -0,0 +1,618 @@
package git
import (
"bytes"
"context"
"errors"
"fmt"
"io"
"net/url"
"os/exec"
"path"
"regexp"
"runtime"
"sort"
"strings"
"sync"
"github.com/cli/safeexec"
)
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
type Client struct {
GhPath string
RepoDir string
GitPath string
Stderr io.Writer
Stdin io.Reader
Stdout io.Writer
commandContext commandCtx
mu sync.Mutex
}
func (c *Client) Command(ctx context.Context, args ...string) (*Command, error) {
if c.RepoDir != "" {
args = append([]string{"-C", c.RepoDir}, args...)
}
commandContext := exec.CommandContext
if c.commandContext != nil {
commandContext = c.commandContext
}
var err error
c.mu.Lock()
if c.GitPath == "" {
c.GitPath, err = resolveGitPath()
}
c.mu.Unlock()
if err != nil {
return nil, err
}
cmd := commandContext(ctx, c.GitPath, args...)
cmd.Stderr = c.Stderr
cmd.Stdin = c.Stdin
cmd.Stdout = c.Stdout
return &Command{cmd}, nil
}
// AuthenticatedCommand is a wrapper around Command that included configuration to use gh
// as the credential helper for git.
func (c *Client) AuthenticatedCommand(ctx context.Context, args ...string) (*Command, error) {
preArgs := []string{"-c", "credential.helper="}
if c.GhPath == "" {
// Assumes that gh is in PATH.
c.GhPath = "gh"
}
credHelper := fmt.Sprintf("!%q auth git-credential", c.GhPath)
preArgs = append(preArgs, "-c", fmt.Sprintf("credential.helper=%s", credHelper))
args = append(preArgs, args...)
return c.Command(ctx, args...)
}
func (c *Client) Remotes(ctx context.Context) (RemoteSet, error) {
remoteArgs := []string{"remote", "-v"}
remoteCmd, err := c.Command(ctx, remoteArgs...)
if err != nil {
return nil, err
}
remoteOut, remoteErr := remoteCmd.Output()
if remoteErr != nil {
return nil, remoteErr
}
configArgs := []string{"config", "--get-regexp", `^remote\..*\.gh-resolved$`}
configCmd, err := c.Command(ctx, configArgs...)
if err != nil {
return nil, err
}
configOut, configErr := configCmd.Output()
if configErr != nil {
// Ignore exit code 1 as it means there are no resolved remotes.
var gitErr *GitError
if ok := errors.As(configErr, &gitErr); ok && gitErr.ExitCode != 1 {
return nil, gitErr
}
}
remotes := parseRemotes(outputLines(remoteOut))
populateResolvedRemotes(remotes, outputLines(configOut))
sort.Sort(remotes)
return remotes, nil
}
func (c *Client) UpdateRemoteURL(ctx context.Context, name, url string) error {
args := []string{"remote", "set-url", name, url}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) SetRemoteResolution(ctx context.Context, name, resolution string) error {
args := []string{"config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
// CurrentBranch reads the checked-out branch for the git repository.
func (c *Client) CurrentBranch(ctx context.Context) (string, error) {
args := []string{"symbolic-ref", "--quiet", "HEAD"}
cmd, err := c.Command(ctx, args...)
if err != nil {
return "", err
}
out, err := cmd.Output()
if err != nil {
var gitErr *GitError
if ok := errors.As(err, &gitErr); ok && len(gitErr.Stderr) == 0 {
gitErr.Stderr = "not on any branch"
return "", gitErr
}
return "", err
}
branch := firstLine(out)
return strings.TrimPrefix(branch, "refs/heads/"), nil
}
// ShowRefs resolves fully-qualified refs to commit hashes.
func (c *Client) ShowRefs(ctx context.Context, refs []string) ([]Ref, error) {
args := append([]string{"show-ref", "--verify", "--"}, refs...)
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
// This functionality relies on parsing output from the git command despite
// an error status being returned from git.
out, err := cmd.Output()
var verified []Ref
for _, line := range outputLines(out) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
verified = append(verified, Ref{
Hash: parts[0],
Name: parts[1],
})
}
return verified, err
}
func (c *Client) Config(ctx context.Context, name string) (string, error) {
args := []string{"config", name}
cmd, err := c.Command(ctx, args...)
if err != nil {
return "", err
}
out, err := cmd.Output()
if err != nil {
var gitErr *GitError
if ok := errors.As(err, &gitErr); ok && gitErr.ExitCode == 1 {
gitErr.Stderr = fmt.Sprintf("unknown config key %s", name)
return "", gitErr
}
return "", err
}
return firstLine(out), nil
}
func (c *Client) UncommittedChangeCount(ctx context.Context) (int, error) {
args := []string{"status", "--porcelain"}
cmd, err := c.Command(ctx, args...)
if err != nil {
return 0, err
}
out, err := cmd.Output()
if err != nil {
return 0, err
}
lines := strings.Split(string(out), "\n")
count := 0
for _, l := range lines {
if l != "" {
count++
}
}
return count, nil
}
func (c *Client) Commits(ctx context.Context, baseRef, headRef string) ([]*Commit, error) {
args := []string{"-c", "log.ShowSignature=false", "log", "--pretty=format:%H,%s", "--cherry", fmt.Sprintf("%s...%s", baseRef, headRef)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
out, err := cmd.Output()
if err != nil {
return nil, err
}
commits := []*Commit{}
sha := 0
title := 1
for _, line := range outputLines(out) {
split := strings.SplitN(line, ",", 2)
if len(split) != 2 {
continue
}
commits = append(commits, &Commit{
Sha: split[sha],
Title: split[title],
})
}
if len(commits) == 0 {
return nil, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef)
}
return commits, nil
}
func (c *Client) LastCommit(ctx context.Context) (*Commit, error) {
output, err := c.lookupCommit(ctx, "HEAD", "%H,%s")
if err != nil {
return nil, err
}
idx := bytes.IndexByte(output, ',')
return &Commit{
Sha: string(output[0:idx]),
Title: strings.TrimSpace(string(output[idx+1:])),
}, nil
}
func (c *Client) CommitBody(ctx context.Context, sha string) (string, error) {
output, err := c.lookupCommit(ctx, sha, "%b")
return string(output), err
}
func (c *Client) lookupCommit(ctx context.Context, sha, format string) ([]byte, error) {
args := []string{"-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:" + format, sha}
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
out, err := cmd.Output()
if err != nil {
return nil, err
}
return out, nil
}
// ReadBranchConfig parses the `branch.BRANCH.(remote|merge)` part of git config.
func (c *Client) ReadBranchConfig(ctx context.Context, branch string) (cfg BranchConfig) {
prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
args := []string{"config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix)}
cmd, err := c.Command(ctx, args...)
if err != nil {
return
}
out, err := cmd.Output()
if err != nil {
return
}
for _, line := range outputLines(out) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
keys := strings.Split(parts[0], ".")
switch keys[len(keys)-1] {
case "remote":
if strings.Contains(parts[1], ":") {
u, err := ParseURL(parts[1])
if err != nil {
continue
}
cfg.RemoteURL = u
} else if !isFilesystemPath(parts[1]) {
cfg.RemoteName = parts[1]
}
case "merge":
cfg.MergeRef = parts[1]
}
}
return
}
func (c *Client) DeleteLocalBranch(ctx context.Context, branch string) error {
args := []string{"branch", "-D", branch}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) CheckoutBranch(ctx context.Context, branch string) error {
args := []string{"checkout", branch}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) CheckoutNewBranch(ctx context.Context, remoteName, branch string) error {
track := fmt.Sprintf("%s/%s", remoteName, branch)
args := []string{"checkout", "-b", branch, "--track", track}
cmd, err := c.Command(ctx, args...)
if err != nil {
return err
}
_, err = cmd.Output()
if err != nil {
return err
}
return nil
}
func (c *Client) HasLocalBranch(ctx context.Context, branch string) bool {
_, err := c.revParse(ctx, "--verify", "refs/heads/"+branch)
return err == nil
}
// ToplevelDir returns the top-level directory path of the current repository.
func (c *Client) ToplevelDir(ctx context.Context) (string, error) {
out, err := c.revParse(ctx, "--show-toplevel")
if err != nil {
return "", err
}
return firstLine(out), nil
}
func (c *Client) GitDir(ctx context.Context) (string, error) {
out, err := c.revParse(ctx, "--git-dir")
if err != nil {
return "", err
}
return firstLine(out), nil
}
// Show current directory relative to the top-level directory of repository.
func (c *Client) PathFromRoot(ctx context.Context) string {
out, err := c.revParse(ctx, "--show-prefix")
if err != nil {
return ""
}
if path := firstLine(out); path != "" {
return path[:len(path)-1]
}
return ""
}
func (c *Client) revParse(ctx context.Context, args ...string) ([]byte, error) {
args = append([]string{"rev-parse"}, args...)
cmd, err := c.Command(ctx, args...)
if err != nil {
return nil, err
}
return cmd.Output()
}
// Below are commands that make network calls and need authentication credentials supplied from gh.
func (c *Client) Fetch(ctx context.Context, remote string, refspec string, mods ...CommandModifier) error {
args := []string{"fetch", remote, refspec}
cmd, err := c.AuthenticatedCommand(ctx, args...)
if err != nil {
return err
}
for _, mod := range mods {
mod(cmd)
}
return cmd.Run()
}
func (c *Client) Pull(ctx context.Context, remote, branch string, mods ...CommandModifier) error {
args := []string{"pull", "--ff-only"}
if remote != "" && branch != "" {
args = append(args, remote, branch)
}
cmd, err := c.AuthenticatedCommand(ctx, args...)
if err != nil {
return err
}
for _, mod := range mods {
mod(cmd)
}
return cmd.Run()
}
func (c *Client) Push(ctx context.Context, remote string, ref string, mods ...CommandModifier) error {
args := []string{"push", "--set-upstream", remote, ref}
cmd, err := c.AuthenticatedCommand(ctx, args...)
if err != nil {
return err
}
for _, mod := range mods {
mod(cmd)
}
return cmd.Run()
}
func (c *Client) Clone(ctx context.Context, cloneURL string, args []string, mods ...CommandModifier) (string, error) {
cloneArgs, target := parseCloneArgs(args)
cloneArgs = append(cloneArgs, cloneURL)
// If the args contain an explicit target, pass it to clone otherwise,
// parse the URL to determine where git cloned it to so we can return it.
if target != "" {
cloneArgs = append(cloneArgs, target)
} else {
target = path.Base(strings.TrimSuffix(cloneURL, ".git"))
}
cloneArgs = append([]string{"clone"}, cloneArgs...)
cmd, err := c.AuthenticatedCommand(ctx, cloneArgs...)
if err != nil {
return "", err
}
for _, mod := range mods {
mod(cmd)
}
err = cmd.Run()
if err != nil {
return "", err
}
return target, nil
}
func (c *Client) AddRemote(ctx context.Context, name, urlStr string, trackingBranches []string, mods ...CommandModifier) (*Remote, error) {
args := []string{"remote", "add"}
for _, branch := range trackingBranches {
args = append(args, "-t", branch)
}
args = append(args, "-f", name, urlStr)
cmd, err := c.AuthenticatedCommand(ctx, args...)
if err != nil {
return nil, err
}
for _, mod := range mods {
mod(cmd)
}
if _, err := cmd.Output(); err != nil {
return nil, err
}
var urlParsed *url.URL
if strings.HasPrefix(urlStr, "https") {
urlParsed, err = url.Parse(urlStr)
if err != nil {
return nil, err
}
} else {
urlParsed, err = ParseURL(urlStr)
if err != nil {
return nil, err
}
}
remote := &Remote{
Name: name,
FetchURL: urlParsed,
PushURL: urlParsed,
}
return remote, nil
}
func (c *Client) InGitDirectory(ctx context.Context) (bool, error) {
showCmd, err := c.Command(ctx, "rev-parse", "--is-inside-work-tree")
if err != nil {
return false, err
}
out, err := showCmd.Output()
if err != nil {
return false, err
}
split := strings.Split(string(out), "\n")
if len(split) > 0 {
return split[0] == "true", nil
}
return false, nil
}
func (c *Client) UnsetRemoteResolution(ctx context.Context, name string) error {
unsetCmd, err := c.Command(ctx, "config", "--unset", fmt.Sprintf("remote.%s.gh-resolved", name))
if err != nil {
return err
}
return unsetCmd.Run()
}
func resolveGitPath() (string, error) {
path, err := safeexec.LookPath("git")
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
programName := "git"
if runtime.GOOS == "windows" {
programName = "Git for Windows"
}
return "", &NotInstalled{
message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName),
err: err,
}
}
return "", err
}
return path, nil
}
func isFilesystemPath(p string) bool {
return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
}
func outputLines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
return strings.Split(lines, "\n")
}
func firstLine(output []byte) string {
if i := bytes.IndexAny(output, "\n"); i >= 0 {
return string(output)[0:i]
}
return string(output)
}
func parseCloneArgs(extraArgs []string) (args []string, target string) {
args = extraArgs
if len(args) > 0 {
if !strings.HasPrefix(args[0], "-") {
target, args = args[0], args[1:]
}
}
return
}
func parseRemotes(remotesStr []string) RemoteSet {
remotes := RemoteSet{}
for _, r := range remotesStr {
match := remoteRE.FindStringSubmatch(r)
if match == nil {
continue
}
name := strings.TrimSpace(match[1])
urlStr := strings.TrimSpace(match[2])
urlType := strings.TrimSpace(match[3])
url, err := ParseURL(urlStr)
if err != nil {
continue
}
var rem *Remote
if len(remotes) > 0 {
rem = remotes[len(remotes)-1]
if name != rem.Name {
rem = nil
}
}
if rem == nil {
rem = &Remote{Name: name}
remotes = append(remotes, rem)
}
switch urlType {
case "fetch":
rem.FetchURL = url
case "push":
rem.PushURL = url
}
}
return remotes
}
func populateResolvedRemotes(remotes RemoteSet, resolved []string) {
for _, l := range resolved {
parts := strings.SplitN(l, " ", 2)
if len(parts) < 2 {
continue
}
rp := strings.SplitN(parts[0], ".", 3)
if len(rp) < 2 {
continue
}
name := rp[1]
for _, r := range remotes {
if r.Name == name {
r.Resolved = parts[1]
break
}
}
}
}

1170
git/client_test.go Normal file

File diff suppressed because it is too large Load diff

100
git/command.go Normal file
View file

@ -0,0 +1,100 @@
package git
import (
"bytes"
"context"
"errors"
"io"
"os/exec"
"github.com/cli/cli/v2/internal/run"
)
type commandCtx = func(ctx context.Context, name string, args ...string) *exec.Cmd
type Command struct {
*exec.Cmd
}
func (gc *Command) Run() error {
stderr := &bytes.Buffer{}
if gc.Cmd.Stderr == nil {
gc.Cmd.Stderr = stderr
}
// This is a hack in order to not break the hundreds of
// existing tests that rely on `run.PrepareCmd` to be invoked.
err := run.PrepareCmd(gc.Cmd).Run()
if err != nil {
ge := GitError{err: err, Stderr: stderr.String()}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
ge.ExitCode = exitError.ExitCode()
}
return &ge
}
return nil
}
func (gc *Command) Output() ([]byte, error) {
gc.Stdout = nil
gc.Stderr = nil
// This is a hack in order to not break the hundreds of
// existing tests that rely on `run.PrepareCmd` to be invoked.
out, err := run.PrepareCmd(gc.Cmd).Output()
if err != nil {
ge := GitError{err: err}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
ge.Stderr = string(exitError.Stderr)
ge.ExitCode = exitError.ExitCode()
}
err = &ge
}
return out, err
}
func (gc *Command) setRepoDir(repoDir string) {
for i, arg := range gc.Args {
if arg == "-C" {
gc.Args[i+1] = repoDir
return
}
}
// Handle "--" invocations for testing purposes.
var index int
for i, arg := range gc.Args {
if arg == "--" {
index = i + 1
}
}
gc.Args = append(gc.Args[:index+3], gc.Args[index+1:]...)
gc.Args[index+1] = "-C"
gc.Args[index+2] = repoDir
}
// Allow individual commands to be modified from the default client options.
type CommandModifier func(*Command)
func WithStderr(stderr io.Writer) CommandModifier {
return func(gc *Command) {
gc.Stderr = stderr
}
}
func WithStdout(stdout io.Writer) CommandModifier {
return func(gc *Command) {
gc.Stdout = stdout
}
}
func WithStdin(stdin io.Reader) CommandModifier {
return func(gc *Command) {
gc.Stdin = stdin
}
}
func WithRepoDir(repoDir string) CommandModifier {
return func(gc *Command) {
gc.setRepoDir(repoDir)
}
}

39
git/errors.go Normal file
View file

@ -0,0 +1,39 @@
package git
import (
"errors"
"fmt"
)
// ErrNotOnAnyBranch indicates that the user is in detached HEAD state.
var ErrNotOnAnyBranch = errors.New("git: not on any branch")
type NotInstalled struct {
message string
err error
}
func (e *NotInstalled) Error() string {
return e.message
}
func (e *NotInstalled) Unwrap() error {
return e.err
}
type GitError struct {
ExitCode int
Stderr string
err error
}
func (ge *GitError) Error() string {
if ge.Stderr == "" {
return fmt.Sprintf("failed to run git: %v", ge.err)
}
return fmt.Sprintf("failed to run git: %s", ge.Stderr)
}
func (ge *GitError) Unwrap() error {
return ge.err
}

View file

@ -1,453 +0,0 @@
package git
import (
"bytes"
"errors"
"fmt"
"io"
"net/url"
"os"
"os/exec"
"path"
"regexp"
"runtime"
"strings"
"github.com/cli/cli/v2/internal/run"
"github.com/cli/safeexec"
)
// ErrNotOnAnyBranch indicates that the user is in detached HEAD state
var ErrNotOnAnyBranch = errors.New("git: not on any branch")
// Ref represents a git commit reference
type Ref struct {
Hash string
Name string
}
// TrackingRef represents a ref for a remote tracking branch
type TrackingRef struct {
RemoteName string
BranchName string
}
func (r TrackingRef) String() string {
return "refs/remotes/" + r.RemoteName + "/" + r.BranchName
}
// ShowRefs resolves fully-qualified refs to commit hashes
func ShowRefs(ref ...string) ([]Ref, error) {
args := append([]string{"show-ref", "--verify", "--"}, ref...)
showRef, err := GitCommand(args...)
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(showRef).Output()
var refs []Ref
for _, line := range outputLines(output) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
refs = append(refs, Ref{
Hash: parts[0],
Name: parts[1],
})
}
return refs, err
}
// CurrentBranch reads the checked-out branch for the git repository
func CurrentBranch() (string, error) {
refCmd, err := GitCommand("symbolic-ref", "--quiet", "HEAD")
if err != nil {
return "", err
}
stderr := bytes.Buffer{}
refCmd.Stderr = &stderr
output, err := run.PrepareCmd(refCmd).Output()
if err == nil {
// Found the branch name
return getBranchShortName(output), nil
}
if stderr.Len() == 0 {
// Detached head
return "", ErrNotOnAnyBranch
}
return "", fmt.Errorf("%sgit: %s", stderr.String(), err)
}
func listRemotesForPath(path string) ([]string, error) {
remoteCmd, err := GitCommand("-C", path, "remote", "-v")
if err != nil {
return nil, err
}
output, err := run.PrepareCmd(remoteCmd).Output()
return outputLines(output), err
}
func listRemotes() ([]string, error) {
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, 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)
}
return firstLine(output), nil
}
type NotInstalled struct {
message string
error
}
func (e *NotInstalled) Error() string {
return e.message
}
func GitCommand(args ...string) (*exec.Cmd, error) {
gitExe, err := safeexec.LookPath("git")
if err != nil {
if errors.Is(err, exec.ErrNotFound) {
programName := "git"
if runtime.GOOS == "windows" {
programName = "Git for Windows"
}
return nil, &NotInstalled{
message: fmt.Sprintf("unable to find git executable in PATH; please install %s before retrying", programName),
error: err,
}
}
return nil, err
}
return exec.Command(gitExe, args...), nil
}
func UncommittedChangeCount() (int, error) {
statusCmd, err := GitCommand("status", "--porcelain")
if err != nil {
return 0, err
}
output, err := run.PrepareCmd(statusCmd).Output()
if err != nil {
return 0, err
}
lines := strings.Split(string(output), "\n")
count := 0
for _, l := range lines {
if l != "" {
count++
}
}
return count, nil
}
type Commit struct {
Sha string
Title string
}
func Commits(baseRef, headRef string) ([]*Commit, error) {
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
}
commits := []*Commit{}
sha := 0
title := 1
for _, line := range outputLines(output) {
split := strings.SplitN(line, ",", 2)
if len(split) != 2 {
continue
}
commits = append(commits, &Commit{
Sha: split[sha],
Title: split[title],
})
}
if len(commits) == 0 {
return commits, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef)
}
return commits, nil
}
func lookupCommit(sha, format string) ([]byte, error) {
logCmd, err := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:"+format, sha)
if err != nil {
return nil, err
}
return run.PrepareCmd(logCmd).Output()
}
func LastCommit() (*Commit, error) {
output, err := lookupCommit("HEAD", "%H,%s")
if err != nil {
return nil, err
}
idx := bytes.IndexByte(output, ',')
return &Commit{
Sha: string(output[0:idx]),
Title: strings.TrimSpace(string(output[idx+1:])),
}, nil
}
func CommitBody(sha string) (string, error) {
output, err := lookupCommit(sha, "%b")
return string(output), err
}
// Push publishes a git ref to a remote and sets up upstream configuration
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()
}
type BranchConfig struct {
RemoteName string
RemoteURL *url.URL
MergeRef string
}
// 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, 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
}
for _, line := range outputLines(output) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
keys := strings.Split(parts[0], ".")
switch keys[len(keys)-1] {
case "remote":
if strings.Contains(parts[1], ":") {
u, err := ParseURL(parts[1])
if err != nil {
continue
}
cfg.RemoteURL = u
} else if !isFilesystemPath(parts[1]) {
cfg.RemoteName = parts[1]
}
case "merge":
cfg.MergeRef = parts[1]
}
}
return
}
func DeleteLocalBranch(branch string) error {
branchCmd, err := GitCommand("branch", "-D", branch)
if err != nil {
return err
}
return run.PrepareCmd(branchCmd).Run()
}
func HasLocalBranch(branch string) bool {
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, err := GitCommand("checkout", branch)
if err != nil {
return err
}
return run.PrepareCmd(configCmd).Run()
}
func CheckoutNewBranch(remoteName, branch string) error {
track := fmt.Sprintf("%s/%s", remoteName, branch)
configCmd, err := GitCommand("checkout", "-b", branch, "--track", track)
if err != nil {
return err
}
return run.PrepareCmd(configCmd).Run()
}
// pull changes from remote branch without version history
func Pull(remote, branch string) error {
pullCmd, err := GitCommand("pull", "--ff-only", remote, branch)
if err != nil {
return err
}
pullCmd.Stdout = os.Stdout
pullCmd.Stderr = os.Stderr
pullCmd.Stdin = os.Stdin
return run.PrepareCmd(pullCmd).Run()
}
func parseCloneArgs(extraArgs []string) (args []string, target string) {
args = extraArgs
if len(args) > 0 {
if !strings.HasPrefix(args[0], "-") {
target, args = args[0], args[1:]
}
}
return
}
func RunClone(cloneURL string, args []string) (target string, err error) {
cloneArgs, target := parseCloneArgs(args)
cloneArgs = append(cloneArgs, cloneURL)
// If the args contain an explicit target, pass it to clone
// otherwise, parse the URL to determine where git cloned it to so we can return it
if target != "" {
cloneArgs = append(cloneArgs, target)
} else {
target = path.Base(strings.TrimSuffix(cloneURL, ".git"))
}
cloneArgs = append([]string{"clone"}, cloneArgs...)
cloneCmd, err := GitCommand(cloneArgs...)
if err != nil {
return "", err
}
cloneCmd.Stdin = os.Stdin
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
err = run.PrepareCmd(cloneCmd).Run()
return
}
func AddUpstreamRemote(upstreamURL, cloneDir string, branches []string) error {
args := []string{"-C", cloneDir, "remote", "add"}
for _, branch := range branches {
args = append(args, "-t", branch)
}
args = append(args, "-f", "upstream", upstreamURL)
cloneCmd, err := GitCommand(args...)
if err != nil {
return err
}
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
return run.PrepareCmd(cloneCmd).Run()
}
func isFilesystemPath(p string) bool {
return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
}
// ToplevelDir returns the top-level directory path of the current repository
func ToplevelDir() (string, error) {
showCmd, err := GitCommand("rev-parse", "--show-toplevel")
if err != nil {
return "", err
}
output, err := run.PrepareCmd(showCmd).Output()
return firstLine(output), err
}
// ToplevelDirFromPath returns the top-level given path of the current repository
func GetDirFromPath(p string) (string, error) {
showCmd, err := GitCommand("-C", p, "rev-parse", "--git-dir")
if err != nil {
return "", err
}
output, err := run.PrepareCmd(showCmd).Output()
return firstLine(output), err
}
func PathFromRepoRoot() string {
showCmd, err := GitCommand("rev-parse", "--show-prefix")
if err != nil {
return ""
}
output, err := run.PrepareCmd(showCmd).Output()
if err != nil {
return ""
}
if path := firstLine(output); path != "" {
return path[:len(path)-1]
}
return ""
}
func outputLines(output []byte) []string {
lines := strings.TrimSuffix(string(output), "\n")
return strings.Split(lines, "\n")
}
func firstLine(output []byte) string {
if i := bytes.IndexAny(output, "\n"); i >= 0 {
return string(output)[0:i]
}
return string(output)
}
func getBranchShortName(output []byte) string {
branch := firstLine(output)
return strings.TrimPrefix(branch, "refs/heads/")
}
func IsGitDirectory() bool {
showCmd, err := GitCommand("rev-parse", "--is-inside-work-tree")
if err != nil {
return false
}
output, err := run.PrepareCmd(showCmd).Output()
if err != nil {
return false
}
out := firstLine(output)
return out == "true"
}

View file

@ -1,220 +0,0 @@
package git
import (
"os"
"reflect"
"testing"
"github.com/cli/cli/v2/internal/run"
)
func setGitDir(t *testing.T, dir string) {
// TODO: also set XDG_CONFIG_HOME, GIT_CONFIG_NOSYSTEM
old_GIT_DIR := os.Getenv("GIT_DIR")
os.Setenv("GIT_DIR", dir)
t.Cleanup(func() {
os.Setenv("GIT_DIR", old_GIT_DIR)
})
}
func TestLastCommit(t *testing.T) {
setGitDir(t, "./fixtures/simple.git")
c, err := LastCommit()
if err != nil {
t.Fatalf("LastCommit error: %v", err)
}
if c.Sha != "6f1a2405cace1633d89a79c74c65f22fe78f9659" {
t.Errorf("expected sha %q, got %q", "6f1a2405cace1633d89a79c74c65f22fe78f9659", c.Sha)
}
if c.Title != "Second commit" {
t.Errorf("expected title %q, got %q", "Second commit", c.Title)
}
}
func TestCommitBody(t *testing.T) {
setGitDir(t, "./fixtures/simple.git")
body, err := CommitBody("6f1a2405cace1633d89a79c74c65f22fe78f9659")
if err != nil {
t.Fatalf("CommitBody error: %v", err)
}
if body != "I'm starting to get the hang of things\n" {
t.Errorf("expected %q, got %q", "I'm starting to get the hang of things\n", body)
}
}
/*
NOTE: below this are stubbed git tests, i.e. those that do not actually invoke `git`. If possible, utilize
`setGitDir()` to allow new tests to interact with `git`. For write operations, you can use `t.TempDir()` to
host a temporary git repository that is safe to be changed.
*/
func Test_UncommittedChangeCount(t *testing.T) {
type c struct {
Label string
Expected int
Output string
}
cases := []c{
{Label: "no changes", Expected: 0, Output: ""},
{Label: "one change", Expected: 1, Output: " M poem.txt"},
{Label: "untracked file", Expected: 2, Output: " M poem.txt\n?? new.txt"},
}
for _, v := range cases {
t.Run(v.Label, func(t *testing.T) {
cs, restore := run.Stub()
defer restore(t)
cs.Register(`git status --porcelain`, 0, v.Output)
ucc, _ := UncommittedChangeCount()
if ucc != v.Expected {
t.Errorf("UncommittedChangeCount() = %d, expected %d", ucc, v.Expected)
}
})
}
}
func Test_CurrentBranch(t *testing.T) {
type c struct {
Stub string
Expected string
}
cases := []c{
{
Stub: "branch-name\n",
Expected: "branch-name",
},
{
Stub: "refs/heads/branch-name\n",
Expected: "branch-name",
},
{
Stub: "refs/heads/branch\u00A0with\u00A0non\u00A0breaking\u00A0space\n",
Expected: "branch\u00A0with\u00A0non\u00A0breaking\u00A0space",
},
}
for _, v := range cases {
cs, teardown := run.Stub()
cs.Register(`git symbolic-ref --quiet HEAD`, 0, v.Stub)
result, err := CurrentBranch()
if err != nil {
t.Errorf("got unexpected error: %v", err)
}
if result != v.Expected {
t.Errorf("unexpected branch name: %s instead of %s", result, v.Expected)
}
teardown(t)
}
}
func Test_CurrentBranch_detached_head(t *testing.T) {
cs, teardown := run.Stub()
defer teardown(t)
cs.Register(`git symbolic-ref --quiet HEAD`, 1, "")
_, err := CurrentBranch()
if err == nil {
t.Fatal("expected an error, got nil")
}
if err != ErrNotOnAnyBranch {
t.Errorf("got unexpected error: %s instead of %s", err, ErrNotOnAnyBranch)
}
}
func TestParseExtraCloneArgs(t *testing.T) {
type Wanted struct {
args []string
dir string
}
tests := []struct {
name string
args []string
want Wanted
}{
{
name: "args and target",
args: []string{"target_directory", "-o", "upstream", "--depth", "1"},
want: Wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "target_directory",
},
},
{
name: "only args",
args: []string{"-o", "upstream", "--depth", "1"},
want: Wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "",
},
},
{
name: "only target",
args: []string{"target_directory"},
want: Wanted{
args: []string{},
dir: "target_directory",
},
},
{
name: "no args",
args: []string{},
want: Wanted{
args: []string{},
dir: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args, dir := parseCloneArgs(tt.args)
got := Wanted{
args: args,
dir: dir,
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %#v want %#v", got, tt.want)
}
})
}
}
func TestAddUpstreamRemote(t *testing.T) {
tests := []struct {
name string
upstreamURL string
cloneDir string
branches []string
want string
}{
{
name: "fetch all",
upstreamURL: "URL",
cloneDir: "DIRECTORY",
branches: []string{},
want: "git -C DIRECTORY remote add -f upstream URL",
},
{
name: "fetch specific branches only",
upstreamURL: "URL",
cloneDir: "DIRECTORY",
branches: []string{"master", "dev"},
want: "git -C DIRECTORY remote add -t master -t dev -f upstream URL",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(tt.want, 0, "")
err := AddUpstreamRemote(tt.upstreamURL, tt.cloneDir, tt.branches)
if err != nil {
t.Fatalf("error running command `git remote add -f`: %v", err)
}
})
}
}

76
git/objects.go Normal file
View file

@ -0,0 +1,76 @@
package git
import (
"net/url"
"strings"
)
// RemoteSet is a slice of git remotes.
type RemoteSet []*Remote
func (r RemoteSet) Len() int { return len(r) }
func (r RemoteSet) Swap(i, j int) { r[i], r[j] = r[j], r[i] }
func (r RemoteSet) Less(i, j int) bool {
return remoteNameSortScore(r[i].Name) > remoteNameSortScore(r[j].Name)
}
func remoteNameSortScore(name string) int {
switch strings.ToLower(name) {
case "upstream":
return 3
case "github":
return 2
case "origin":
return 1
default:
return 0
}
}
// Remote is a parsed git remote.
type Remote struct {
Name string
Resolved string
FetchURL *url.URL
PushURL *url.URL
}
func (r *Remote) String() string {
return r.Name
}
func NewRemote(name string, u string) *Remote {
pu, _ := url.Parse(u)
return &Remote{
Name: name,
FetchURL: pu,
PushURL: pu,
}
}
// Ref represents a git commit reference.
type Ref struct {
Hash string
Name string
}
// TrackingRef represents a ref for a remote tracking branch.
type TrackingRef struct {
RemoteName string
BranchName string
}
func (r TrackingRef) String() string {
return "refs/remotes/" + r.RemoteName + "/" + r.BranchName
}
type Commit struct {
Sha string
Title string
}
type BranchConfig struct {
RemoteName string
RemoteURL *url.URL
MergeRef string
}

View file

@ -1,177 +0,0 @@
package git
import (
"fmt"
"net/url"
"regexp"
"strings"
"github.com/cli/cli/v2/internal/run"
)
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
// RemoteSet is a slice of git remotes
type RemoteSet []*Remote
func NewRemote(name string, u string) *Remote {
pu, _ := url.Parse(u)
return &Remote{
Name: name,
FetchURL: pu,
PushURL: pu,
}
}
// Remote is a parsed git remote
type Remote struct {
Name string
Resolved string
FetchURL *url.URL
PushURL *url.URL
}
func (r *Remote) String() string {
return r.Name
}
func remotes(path string, remoteList []string) (RemoteSet, error) {
remotes := parseRemotes(remoteList)
// this is affected by SetRemoteResolution
remoteCmd, err := GitCommand("-C", path, "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)
if len(parts) < 2 {
continue
}
rp := strings.SplitN(parts[0], ".", 3)
if len(rp) < 2 {
continue
}
name := rp[1]
for _, r := range remotes {
if r.Name == name {
r.Resolved = parts[1]
break
}
}
}
return remotes, nil
}
func RemotesForPath(path string) (RemoteSet, error) {
list, err := listRemotesForPath(path)
if err != nil {
return nil, err
}
return remotes(path, list)
}
// Remotes gets the git remotes set for the current repo
func Remotes() (RemoteSet, error) {
list, err := listRemotes()
if err != nil {
return nil, err
}
return remotes(".", list)
}
func parseRemotes(gitRemotes []string) (remotes RemoteSet) {
for _, r := range gitRemotes {
match := remoteRE.FindStringSubmatch(r)
if match == nil {
continue
}
name := strings.TrimSpace(match[1])
urlStr := strings.TrimSpace(match[2])
urlType := strings.TrimSpace(match[3])
var rem *Remote
if len(remotes) > 0 {
rem = remotes[len(remotes)-1]
if name != rem.Name {
rem = nil
}
}
if rem == nil {
rem = &Remote{Name: name}
remotes = append(remotes, rem)
}
u, err := ParseURL(urlStr)
if err != nil {
continue
}
switch urlType {
case "fetch":
rem.FetchURL = u
case "push":
rem.PushURL = u
}
}
return
}
// AddRemote adds a new git remote and auto-fetches objects from it
func AddRemote(name, u string) (*Remote, error) {
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
}
var urlParsed *url.URL
if strings.HasPrefix(u, "https") {
urlParsed, err = url.Parse(u)
if err != nil {
return nil, err
}
} else {
urlParsed, err = ParseURL(u)
if err != nil {
return nil, err
}
}
return &Remote{
Name: name,
FetchURL: urlParsed,
PushURL: urlParsed,
}, nil
}
func UpdateRemoteURL(name, u string) error {
addCmd, err := GitCommand("remote", "set-url", name, u)
if err != nil {
return err
}
return run.PrepareCmd(addCmd).Run()
}
func SetRemoteResolution(name, resolution string) error {
addCmd, err := GitCommand("config", "--add", fmt.Sprintf("remote.%s.gh-resolved", name), resolution)
if err != nil {
return err
}
return run.PrepareCmd(addCmd).Run()
}
func UnsetRemoteResolution(name string) error {
unsetCmd, err := GitCommand("config", "--unset", fmt.Sprintf("remote.%s.gh-resolved", name))
if err != nil {
return err
}
return run.PrepareCmd(unsetCmd).Run()
}

View file

@ -1,35 +0,0 @@
package git
import (
"testing"
"github.com/stretchr/testify/assert"
)
func Test_parseRemotes(t *testing.T) {
remoteList := []string{
"mona\tgit@github.com:monalisa/myfork.git (fetch)",
"origin\thttps://github.com/monalisa/octo-cat.git (fetch)",
"origin\thttps://github.com/monalisa/octo-cat-push.git (push)",
"upstream\thttps://example.com/nowhere.git (fetch)",
"upstream\thttps://github.com/hubot/tools (push)",
"zardoz\thttps://example.com/zed.git (push)",
}
r := parseRemotes(remoteList)
assert.Equal(t, 4, len(r))
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)
}
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)
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)
assert.Equal(t, "zardoz", r[3].Name)
}

View file

@ -1,171 +0,0 @@
package git
import (
"bufio"
"io"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"github.com/cli/cli/v2/internal/config"
)
var (
sshConfigLineRE = regexp.MustCompile(`\A\s*(?P<keyword>[A-Za-z][A-Za-z0-9]*)(?:\s+|\s*=\s*)(?P<argument>.+)`)
sshTokenRE = regexp.MustCompile(`%[%h]`)
)
// SSHAliasMap encapsulates the translation of SSH hostname aliases
type SSHAliasMap map[string]string
// Translator returns a function that applies hostname aliases to URLs
func (m SSHAliasMap) Translator() func(*url.URL) *url.URL {
return func(u *url.URL) *url.URL {
if u.Scheme != "ssh" {
return u
}
resolvedHost, ok := m[u.Hostname()]
if !ok {
return u
}
if strings.EqualFold(resolvedHost, "ssh.github.com") {
resolvedHost = "github.com"
}
newURL, _ := url.Parse(u.String())
newURL.Host = resolvedHost
return newURL
}
}
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 {
configFiles := []string{
"/etc/ssh_config",
"/etc/ssh/ssh_config",
}
p := sshParser{}
if sshDir, err := config.HomeDirPath(".ssh"); err == nil {
userConfig := filepath.Join(sshDir, "config")
configFiles = append([]string{userConfig}, configFiles...)
p.homeDir = filepath.Dir(sshDir)
}
for _, file := range configFiles {
_ = p.read(file)
}
return p.aliasMap
}
func sshExpandTokens(text, host string) string {
return sshTokenRE.ReplaceAllStringFunc(text, func(match string) string {
switch match {
case "%h":
return host
case "%%":
return "%"
}
return ""
})
}

View file

@ -1,148 +0,0 @@
package git
import (
"bytes"
"fmt"
"io"
"net/url"
"path/filepath"
"testing"
"github.com/MakeNowJust/heredoc"
)
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_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) {
m := SSHAliasMap{
"gh": "github.com",
"github.com": "ssh.github.com",
"my.gh.com": "ssh.github.com",
}
tr := m.Translator()
cases := [][]string{
{"ssh://gh/o/r", "ssh://github.com/o/r"},
{"ssh://github.com/o/r", "ssh://github.com/o/r"},
{"ssh://my.gh.com", "ssh://github.com"},
{"https://gh/o/r", "https://gh/o/r"},
}
for _, c := range cases {
u, _ := url.Parse(c[0])
got := tr(u)
if got.String() != c[1] {
t.Errorf("%q: expected %q, got %q", c[0], c[1], got)
}
}
}

60
go.mod
View file

@ -1,73 +1,83 @@
module github.com/cli/cli/v2
go 1.18
go 1.19
require (
github.com/AlecAivazis/survey/v2 v2.3.4
github.com/AlecAivazis/survey/v2 v2.3.6
github.com/MakeNowJust/heredoc v1.0.0
github.com/briandowns/spinner v1.18.1
github.com/charmbracelet/glamour v0.4.0
github.com/cenkalti/backoff/v4 v4.1.3
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da
github.com/charmbracelet/lipgloss v0.5.0
github.com/cli/browser v1.1.0
github.com/cli/go-gh v1.0.0
github.com/cli/oauth v0.9.0
github.com/cli/safeexec v1.0.0
github.com/cli/shurcooL-graphql v0.0.1
github.com/cli/safeexec v1.0.1
github.com/cpuguy83/go-md2man/v2 v2.0.2
github.com/creack/pty v1.1.18
github.com/gabriel-vasile/mimetype v1.4.0
github.com/google/go-cmp v0.5.8
github.com/gabriel-vasile/mimetype v1.4.1
github.com/gdamore/tcell/v2 v2.5.3
github.com/google/go-cmp v0.5.9
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/gorilla/websocket v1.4.2
github.com/hashicorp/go-multierror v1.1.1
github.com/hashicorp/go-version v1.3.0
github.com/henvic/httpretty v0.0.6
github.com/itchyny/gojq v0.12.7
github.com/joho/godotenv v1.4.0
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.12
github.com/mattn/go-isatty v0.0.14
github.com/mattn/go-colorable v0.1.13
github.com/mattn/go-isatty v0.0.16
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d
github.com/muesli/reflow v0.3.0
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38
github.com/opentracing/opentracing-go v1.1.0
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d
github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00
github.com/sourcegraph/jsonrpc2 v0.1.0
github.com/spf13/cobra v1.4.0
github.com/spf13/cobra v1.5.0
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.7.1
github.com/stretchr/testify v1.7.5
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5
golang.org/x/sync v0.0.0-20210220032951-036812b2e83c
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211
golang.org/x/text v0.3.7
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b
golang.org/x/text v0.3.8
google.golang.org/grpc v1.49.0
google.golang.org/protobuf v1.27.1
gopkg.in/yaml.v3 v3.0.1
)
require (
github.com/alecthomas/chroma v0.10.0 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
github.com/cli/browser v1.1.0 // indirect
github.com/cli/shurcooL-graphql v0.0.2 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dlclark/regexp2 v1.4.0 // indirect
github.com/fatih/color v1.7.0 // indirect
github.com/gdamore/encoding v1.0.0 // indirect
github.com/golang/protobuf v1.5.2 // indirect
github.com/gorilla/css v1.0.0 // indirect
github.com/hashicorp/errwrap v1.0.0 // indirect
github.com/inconshreveable/mousetrap v1.0.0 // indirect
github.com/itchyny/gojq v0.12.8 // indirect
github.com/itchyny/timefmt-go v0.1.3 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/mattn/go-runewidth v0.0.13 // indirect
github.com/microcosm-cc/bluemonday v1.0.17 // indirect
github.com/microcosm-cc/bluemonday v1.0.20 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.12.0 // indirect
github.com/olekukonko/tablewriter v0.0.5 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/rivo/uniseg v0.4.2 // indirect
github.com/russross/blackfriday/v2 v2.1.0 // indirect
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a // indirect
github.com/stretchr/objx v0.1.0 // indirect
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 // indirect
github.com/stretchr/objx v0.4.0 // indirect
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e // indirect
github.com/yuin/goldmark v1.4.4 // indirect
github.com/yuin/goldmark-emoji v1.0.1 // indirect
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd // indirect
golang.org/x/net v0.0.0-20220923203811-8be639271d50 // indirect
golang.org/x/oauth2 v0.0.0-20220309155454-6242fa91716a // indirect
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 // indirect
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab // indirect
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 // indirect
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect
)
replace golang.org/x/crypto => github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03

123
go.sum
View file

@ -31,8 +31,8 @@ cloud.google.com/go/storage v1.6.0/go.mod h1:N7U0C8pVQ/+NIKOBQyamJIeKQKkZ+mxpohl
cloud.google.com/go/storage v1.8.0/go.mod h1:Wv1Oy7z6Yz3DshWRJFhqM/UCfaWIRTdp0RXyy7KQOVs=
cloud.google.com/go/storage v1.10.0/go.mod h1:FLPqc6j+Ki4BU591ie1oL6qBQGu2Bl/tZ9ullr3+Kg0=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AlecAivazis/survey/v2 v2.3.4 h1:pchTU9rsLUSvWEl2Aq9Pv3k0IE2fkqtGxazskAMd9Ng=
github.com/AlecAivazis/survey/v2 v2.3.4/go.mod h1:hrV6Y/kQCLhIZXGcriDCUBtB3wnN7156gMXJ3+b23xM=
github.com/AlecAivazis/survey/v2 v2.3.6 h1:NvTuVHISgTHEHeBFqt6BHOe4Ny/NwGZr7w+F8S9ziyw=
github.com/AlecAivazis/survey/v2 v2.3.6/go.mod h1:4AuI9b7RjAR+G7v9+C4YSlX/YL3K3cWNXgWXOhllqvI=
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=
github.com/MakeNowJust/heredoc v1.0.0 h1:cXCdzVdstXyiTqTvfqk9SDHpKNjxuom+DOlyEeQ4pzQ=
@ -45,9 +45,11 @@ github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuP
github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4=
github.com/briandowns/spinner v1.18.1 h1:yhQmQtM1zsqFsouh09Bk/jCjd50pC3EOGsh28gLVvwY=
github.com/briandowns/spinner v1.18.1/go.mod h1:mQak9GHqbspjC/5iUx3qMlIho8xBS/ppAL/hX5SmPJU=
github.com/cenkalti/backoff/v4 v4.1.3 h1:cFAlzYUlVYDysBEH2T5hyJZMh3+5+WCBvSnK6Q8UtC4=
github.com/cenkalti/backoff/v4 v4.1.3/go.mod h1:scbssz8iZGpm3xbr14ovlUdkxfGXNInqkPWOWmG2CLw=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/charmbracelet/glamour v0.4.0 h1:scR+smyB7WdmrlIaff6IVlm48P48JaNM7JypM/VGl4k=
github.com/charmbracelet/glamour v0.4.0/go.mod h1:9ZRtG19AUIzcTm7FGLGbq3D5WKQ5UyZBbQsMQN0XIqc=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da h1:FGz53GWQRiKQ/5xUsoCCkewSQIC7u81Scaxx2nUy3nM=
github.com/charmbracelet/glamour v0.5.1-0.20220727184942-e70ff2d969da/go.mod h1:HXz79SMFnF9arKxqeoHWxmo1BhplAH7wehlRhKQIL94=
github.com/charmbracelet/lipgloss v0.5.0 h1:lulQHuVeodSgDez+3rGiuxlPVXSnhth442DATR2/8t8=
github.com/charmbracelet/lipgloss v0.5.0/go.mod h1:EZLha/HbzEt7cYqdFPovlqy5FZPj0xFhg5SaqxScmgs=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
@ -58,15 +60,17 @@ github.com/cli/browser v1.1.0 h1:xOZBfkfY9L9vMBgqb1YwRirGu6QFaQ5dP/vXt5ENSOY=
github.com/cli/browser v1.1.0/go.mod h1:HKMQAt9t12kov91Mn7RfZxyJQQgWgyS/3SZswlZ5iTI=
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03 h1:3f4uHLfWx4/WlnMPXGai03eoWAI+oGHJwr+5OXfxCr8=
github.com/cli/crypto v0.0.0-20210929142629-6be313f59b03/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
github.com/cli/go-gh v1.0.0 h1:zE1YUAUYqGXNZuICEBeOkIMJ5F50BS0ftvtoWGlsEFI=
github.com/cli/go-gh v1.0.0/go.mod h1:bqxLdCoTZ73BuiPEJx4olcO/XKhVZaFDchFagYRBweE=
github.com/cli/oauth v0.9.0 h1:nxBC0Df4tUzMkqffAB+uZvisOwT3/N9FpkfdTDtafxc=
github.com/cli/oauth v0.9.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.1 h1:/9J3t9O6p1B8zdBBtQighq5g7DQRItBwuwGh3SocsKM=
github.com/cli/shurcooL-graphql v0.0.1/go.mod h1:U7gCSuMZP/Qy7kbqkk5PrqXEeDgtfG5K+W+u8weorps=
github.com/cli/safeexec v1.0.1 h1:e/C79PbXF4yYTN/wauC4tviMxEV13BwljGj0N9j+N00=
github.com/cli/safeexec v1.0.1/go.mod h1:Z/D4tTN8Vs5gXYHDCbaM1S/anmEDnJb1iW0+EJ5zx3Q=
github.com/cli/shurcooL-graphql v0.0.2 h1:rwP5/qQQ2fM0TzkUTwtt6E2LbIYf6R+39cUXTa04NYk=
github.com/cli/shurcooL-graphql v0.0.2/go.mod h1:tlrLmw/n5Q/+4qSvosT+9/W5zc8ZMjnJeYBxSdb4nWA=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
github.com/cpuguy83/go-md2man/v2 v2.0.1/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/cpuguy83/go-md2man/v2 v2.0.2 h1:p1EgwI/C7NhT0JmVkwCD2ZBK8j4aeHQX2pMHHBfMQ6w=
github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o=
github.com/creack/pty v1.1.17/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
@ -83,8 +87,12 @@ github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1m
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/gabriel-vasile/mimetype v1.4.0 h1:Cn9dkdYsMIu56tGho+fqzh7XmvY2YyGU0FnbhiOsEro=
github.com/gabriel-vasile/mimetype v1.4.0/go.mod h1:fA8fi6KUiG7MgQQ+mEWotXoEOvmxRtOJlERCzSmRvr8=
github.com/gabriel-vasile/mimetype v1.4.1 h1:TRWk7se+TOjCYgRth7+1/OYLNiRNIotknkFtf/dnN7Q=
github.com/gabriel-vasile/mimetype v1.4.1/go.mod h1:05Vi0w3Y9c/lNvJOdmIwvrrAhX3rYhfQQCaf9VJcv7M=
github.com/gdamore/encoding v1.0.0 h1:+7OoQ1Bc6eTm5niUzBa0Ctsh6JbMW6Ra+YNuAtDBdko=
github.com/gdamore/encoding v1.0.0/go.mod h1:alR0ol34c49FCSBLjhosxzcPHQbf2trDkoo5dl+VrEg=
github.com/gdamore/tcell/v2 v2.5.3 h1:b9XQrT6QGbgI7JvZOJXFNczOQeIYbo8BfeSMzt2sAV0=
github.com/gdamore/tcell/v2 v2.5.3/go.mod h1:wSkrPaXoiIWZqW/g7Px4xc79di6FTcpB8tvaKJ6uGBo=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
@ -111,8 +119,10 @@ github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrU
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
github.com/golang/protobuf v1.4.2 h1:+Z5KGCizgyZCbGh1KZqA0fcLLkwbsjIzS4aV2v7wJX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw=
github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
@ -123,8 +133,9 @@ github.com/google/go-cmp v0.4.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.1/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.4/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.8/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/martian/v3 v3.0.0/go.mod h1:y5Zk1BBys9G+gd6Jrk0W3cC1+ELVxBWuIGO+w/tUAp0=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
@ -144,6 +155,7 @@ github.com/gorilla/css v1.0.0/go.mod h1:Dn721qIggHpt4+EFCcTLTU/vk5ySda2ReITrtgBl
github.com/gorilla/websocket v1.4.1/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/gorilla/websocket v1.4.2 h1:+/TMaTYc4QFitKJxsQ7Yye35DkWvkdLcvGKqM+x0Ufc=
github.com/gorilla/websocket v1.4.2/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/h2non/parth v0.0.0-20190131123155-b4df798d6542 h1:2VTzZjLZBgl62/EtslCrtky5vbi9dd7HrQPQIx6wqiw=
github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo=
@ -159,8 +171,8 @@ github.com/hinshun/vt10x v0.0.0-20220119200601-820417d04eec/go.mod h1:Q48J4R4Dvx
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/itchyny/gojq v0.12.7 h1:hYPTpeWfrJ1OT+2j6cvBScbhl0TkdwGM4bc66onUSOQ=
github.com/itchyny/gojq v0.12.7/go.mod h1:ZdvNHVlzPgUf8pgjnuDTmGfHA/21KoutQUJ3An/xNuw=
github.com/itchyny/gojq v0.12.8 h1:Zxcwq8w4IeR8JJYEtoG2MWJZUv0RGY6QqJcO1cqV8+A=
github.com/itchyny/gojq v0.12.8/go.mod h1:gE2kZ9fVRU0+JAksaTzjIlgnCa2akU+a1V0WXgJQN5c=
github.com/itchyny/timefmt-go v0.1.3 h1:7M3LGVDsqcd0VZH2U+x393obrzZisp7C0uEe921iRkU=
github.com/itchyny/timefmt-go v0.1.3/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A=
github.com/joho/godotenv v1.4.0 h1:3l4+N6zfMWnkbPEXKng2o2/MR5mSwTrBih4ZEkkz1lg=
@ -178,12 +190,12 @@ github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.13/go.mod h1:cbi8OIDigv2wuxKPP5vlRcQ1OAZbq2CE4Kysco4FUpU=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
github.com/mattn/go-runewidth v0.0.10/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
github.com/mattn/go-runewidth v0.0.12/go.mod h1:RAqKPSqVFrSLVXbA8x7dzmKdmGzieGRCM46jaSJTDAk=
@ -192,14 +204,16 @@ github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/microcosm-cc/bluemonday v1.0.17 h1:Z1a//hgsQ4yjC+8zEkV8IWySkXnsxmdSY642CTFQb5Y=
github.com/microcosm-cc/bluemonday v1.0.17/go.mod h1:Z0r70sCuXHig8YpBzCc5eGHAap2K7e/u082ZUpDRRqM=
github.com/microcosm-cc/bluemonday v1.0.19/go.mod h1:QNzV2UbLK2/53oIIwTOyLUSABMkjZ4tqiyC1g/DyqxE=
github.com/microcosm-cc/bluemonday v1.0.20 h1:flpzsq4KU3QIYAYGV/szUat7H+GPOXR0B2JU5A1Wp8Y=
github.com/microcosm-cc/bluemonday v1.0.20/go.mod h1:yfBmMi8mxvaZut3Yytv+jTXRY8mxyjJ0/kQBTElld50=
github.com/muesli/reflow v0.2.1-0.20210115123740-9e1d0d53df68/go.mod h1:Xk+z4oIWdQqJzsxyjgl3P22oYZnHdZ8FFTHAQQt5BMQ=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.9.0/go.mod h1:R/LzAKf+suGs4IsO95y7+7DpFHO0KABgnZqtlyx2mBw=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0 h1:STjmj0uFfRryL9fzRA/OupNppeAID6QJYPMavTL7jtY=
github.com/muesli/termenv v0.11.0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.11.1-0.20220204035834-5ac8409525e0/go.mod h1:Bd5NYQ7pd+SrtBSrSNoBBmXlcY8+Xj4BMJgh8qcZrvs=
github.com/muesli/termenv v0.12.0 h1:KuQRUE3PgxRFWhq4gHvZtPSLCGDqM5q/cYr1pZ39ytc=
github.com/muesli/termenv v0.12.0/go.mod h1:WCCv32tusQ/EEZ5S8oUIIrC/nIuBcxCVqlN4Xfkv+7A=
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38 h1:0FrBxrkJ0hVembTb/e4EU5Ml6vLcOusAqymmYISg5Uo=
github.com/muhammadmuzzammil1998/jsonc v0.0.0-20201229145248-615b0916ca38/go.mod h1:saF2fIVw4banK0H4+/EuqfFLpRnoy5S+ECwTOCcRcSU=
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
@ -209,29 +223,36 @@ github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFSt
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d h1:jKIUJdMcIVGOSHi6LSqJqw9RqblyblE2ZrHvFbWR3S0=
github.com/rivo/tview v0.0.0-20221029100920-c4a7e501810d/go.mod h1:YX2wUZOcJGOIycErz2s9KvDaP0jnWwRCirQMPLPpQ+Y=
github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.2 h1:YwD0ulJSJytLpiaWua0sBDusfsCZohxjxzVTYjwxfV8=
github.com/rivo/uniseg v0.4.2/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
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/graphql v0.0.0-20200928012149-18c5c3165e3a h1:KikTa6HtAK8cS1qjvUvvq4QO21QnwC+EfvB+OAuZ/ZU=
github.com/shurcooL/graphql v0.0.0-20200928012149-18c5c3165e3a/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00 h1:fiFvD4lT0aWjuuAb64LlZ/67v87m+Kc9Qsu5cMFNK0w=
github.com/shurcooL/githubv4 v0.0.0-20220520033151-0b4e3294ff00/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29 h1:B1PEwpArrNp4dkQrfxh/abbBAOZBVp0ds+fBEOUOqOc=
github.com/shurcooL/graphql v0.0.0-20220606043923-3cf50f8a0a29/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/sourcegraph/jsonrpc2 v0.1.0 h1:ohJHjZ+PcaLxDUjqk2NC3tIGsVa5bXThe1ZheSXOjuk=
github.com/sourcegraph/jsonrpc2 v0.1.0/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra5qMlU4gTRwIo=
github.com/spf13/cobra v1.4.0 h1:y+wJpx64xcgO1V+RcnwW0LEHxTKRi2ZDPSBjWnrg88Q=
github.com/spf13/cobra v1.4.0/go.mod h1:Wo4iy3BUC+X2Fybo0PDqwJIv3dNRiZLHQymsfxlB84g=
github.com/spf13/cobra v1.5.0 h1:X+jTBEBqF0bHN+9cSMgmfuvv2VHJ9ezmFNf9Y/XstYU=
github.com/spf13/cobra v1.5.0/go.mod h1:dWXEIy2H428czQCjInthrTRUg7yKbok+2Qi/yBIJoUM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0 h1:M2gUjqZET1qApGOWNSnZ49BAIMX4F/1plDv3+l31EJ4=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1 h1:5TQK59W5E3v0r2duFAb7P95B6hEeOyEnHRa8MjYSMTY=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.5 h1:s5PTfem8p8EbKQOctVV53k6jCJt3UX4IEJzwh+C324Q=
github.com/stretchr/testify v1.7.5/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e h1:BuzhfgfWQbX0dWzYzT1zsORLnHRv3bcRcsaUk0VmXA8=
github.com/thlib/go-timezone-local v0.0.0-20210907160436-ef149e42d28e/go.mod h1:/Tnicc6m/lsJE0irFMA0LfIwTBo4QP7A8IfyIv4zZKI=
github.com/yuin/goldmark v1.1.25/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.32/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@ -301,11 +322,11 @@ golang.org/x/net v0.0.0-20200625001655-4c5254603344/go.mod h1:/O7V0waA8r7cgGh81R
golang.org/x/net v0.0.0-20200707034311-ab3426394381/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20200822124328-c89045814202/go.mod h1:/O7V0waA8r7cgGh81Ro3o1hOxt32SMVPicZroKQ2sZA=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20210505024714-0287a6fb4125/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20210614182718-04defd469f4e/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd h1:O7DYs+zxREGLKzKoMQrtrEacpb0ZVXA5rIwylE2Xchk=
golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk=
golang.org/x/net v0.0.0-20220624214902-1bab6f366d9e/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.0.0-20220923203811-8be639271d50 h1:vKyz8L3zkd+xrMeIaBsQ/MNVPVFSffdaU3ZyYlBGFnI=
golang.org/x/net v0.0.0-20220923203811-8be639271d50/go.mod h1:YDH+HFinaLZZlnHAfSS6ZXJJ9M9t4Dl22yv3iI2vPwk=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
@ -337,7 +358,6 @@ golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200113162924-86b910548bc1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200122134326-e047566fdf82/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200202164722-d101bd2416d5/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
@ -355,11 +375,17 @@ golang.org/x/sys v0.0.0-20210319071255-635bc2c9138d/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210831042530-f4d43177bf5e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9 h1:nhht2DYV/Sn3qOayu8lM+cU1ii9sTLUeBQwQQfUHtrs=
golang.org/x/sys v0.0.0-20220227234510-4e6760a101f9/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220209214540-3681064d5158/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220318055525-2edf467146b5/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220422013727-9388b58f7150/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220728004956-3c1f35247d10/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab h1:2QkjZIsXupsJbJIdSjjUOgWK3aEtzyuh2mPt3l/CkeU=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20201210144234-2321bbc49cbf/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211 h1:JGgROgKl9N8DuW20oFS5gxc+lE67/N3FcwmBPMe7ArY=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
@ -369,8 +395,9 @@ golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7 h1:olpwvP2KacW1ZWvsR7uQhoyTYvKAupfQrRGBFM352Gk=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
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/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
@ -469,6 +496,7 @@ google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEY
google.golang.org/genproto v0.0.0-20200618031413-b414f8b61790/go.mod h1:jDfRM7FcilCzHH/e9qn6dsT145K34l5v+OpcnNgKAAA=
google.golang.org/genproto v0.0.0-20200729003335-053ba62fc06f/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200804131852-c06518451d9c/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987 h1:PDIOdWxZ8eRizhKa1AAvY53xsvLB1cWorMjslvY3VA8=
google.golang.org/genproto v0.0.0-20200825200019-8632dd797987/go.mod h1:FWY/as6DDZQgahTzZj3fqbO1CbirC29ZNUFHwi0/+no=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
@ -482,6 +510,8 @@ google.golang.org/grpc v1.28.0/go.mod h1:rpkK4SK4GF4Ach/+MFLZUBavHOvF2JJB5uozKKa
google.golang.org/grpc v1.29.1/go.mod h1:itym6AZVZYACWQqET3MqgPpjcuV5QH3BxFS3IjizoKk=
google.golang.org/grpc v1.30.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.31.0/go.mod h1:N36X2cJ7JwdamYAgDz+s+rVMFjt3numwzf/HckM8pak=
google.golang.org/grpc v1.49.0 h1:WTLtQzmQori5FUH25Pq4WT22oCsv8USpQ+F6rqtsmxw=
google.golang.org/grpc v1.49.0/go.mod h1:ZgQEeidpAuNRZ8iRrlBKXZQP1ghovWIVhdJRyCDK+GI=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
@ -491,17 +521,22 @@ google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
google.golang.org/protobuf v1.24.0/go.mod h1:r/3tXBNzIEhYS9I1OUVjXDlt8tc493IdKGjtUeSXeh4=
google.golang.org/protobuf v1.25.0 h1:Ejskq+SyPohKW+1uil0JJMtmHCgJPJ/qWTxr8qp+R4c=
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw=
google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
google.golang.org/protobuf v1.27.1 h1:SnqbnDw1V7RiZcXPx5MEeqPv2s79L9i7BJUlG/+RurQ=
google.golang.org/protobuf v1.27.1/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/h2non/gock.v1 v1.1.2 h1:jBbHXgGBK/AoPVfJh5x4r/WxIrElvbLel8TCZkkZJoY=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=

View file

@ -7,14 +7,16 @@ import (
"net/http"
"net/url"
"os"
"regexp"
"strings"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/browser"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/cmdutil"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/cli/v2/utils"
"github.com/cli/oauth"
"github.com/henvic/httpretty"
)
var (
@ -22,13 +24,14 @@ var (
oauthClientID = "178c6fc778ccc68e1d6a"
// This value is safe to be embedded in version control
oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b"
jsonTypeRE = regexp.MustCompile(`[/+]json($|;)`)
)
type iconfig interface {
Get(string, string) (string, error)
Set(string, string, string) error
Set(string, string, string)
Write() error
WriteHosts() error
}
func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice string, additionalScopes []string, isInteractive bool) (string, error) {
@ -49,27 +52,21 @@ func AuthFlowWithConfig(cfg iconfig, IO *iostreams.IOStreams, hostname, notice s
return "", err
}
err = cfg.Set(hostname, "user", userLogin)
if err != nil {
return "", err
}
err = cfg.Set(hostname, "oauth_token", token)
if err != nil {
return "", err
}
cfg.Set(hostname, "user", userLogin)
cfg.Set(hostname, "oauth_token", token)
return token, cfg.WriteHosts()
return token, cfg.Write()
}
func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, additionalScopes []string, isInteractive bool, browserLauncher string) (string, string, error) {
w := IO.ErrOut
cs := IO.ColorScheme()
httpClient := http.DefaultClient
httpClient := &http.Client{}
debugEnabled, debugValue := utils.IsDebugEnabled()
if debugEnabled {
logTraffic := strings.Contains(debugValue, "api")
httpClient.Transport = api.VerboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport)
httpClient.Transport = verboseLog(IO.ErrOut, logTraffic, IO.ColorEnabled())(httpClient.Transport)
}
minimumScopes := []string{"repo", "read:org", "gist"}
@ -109,8 +106,8 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
fmt.Fprintf(w, "%s to open %s in your browser... ", cs.Bold("Press Enter"), oauthHost)
_ = waitForEnter(IO.In)
browser := cmdutil.NewBrowser(browserLauncher, IO.Out, IO.ErrOut)
if err := browser.Browse(authURL); err != nil {
b := browser.New(browserLauncher, IO.Out, IO.ErrOut)
if err := b.Browse(authURL); err != nil {
fmt.Fprintf(w, "%s Failed opening a web browser at %s\n", cs.Red("!"), authURL)
fmt.Fprintf(w, " %s\n", err)
fmt.Fprint(w, " Please try entering the URL in your browser manually\n")
@ -132,7 +129,7 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
return "", "", err
}
userLogin, err := getViewer(oauthHost, token.Token)
userLogin, err := getViewer(oauthHost, token.Token, IO.ErrOut)
if err != nil {
return "", "", err
}
@ -140,9 +137,24 @@ func authFlow(oauthHost string, IO *iostreams.IOStreams, notice string, addition
return token.Token, userLogin, nil
}
func getViewer(hostname, token string) (string, error) {
http := api.NewClient(api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
return api.CurrentLoginName(http, hostname)
type cfg struct {
authToken string
}
func (c cfg) AuthToken(hostname string) (string, string) {
return c.authToken, "oauth_token"
}
func getViewer(hostname, token string, logWriter io.Writer) (string, error) {
opts := api.HTTPClientOptions{
Config: cfg{authToken: token},
Log: logWriter,
}
client, err := api.NewHTTPClient(opts)
if err != nil {
return "", err
}
return api.CurrentLoginName(api.NewClientFromHTTP(client), hostname)
}
func waitForEnter(r io.Reader) error {
@ -150,3 +162,28 @@ func waitForEnter(r io.Reader) error {
scanner.Scan()
return scanner.Err()
}
func verboseLog(out io.Writer, logTraffic bool, colorize bool) func(http.RoundTripper) http.RoundTripper {
logger := &httpretty.Logger{
Time: true,
TLS: false,
Colors: colorize,
RequestHeader: logTraffic,
RequestBody: logTraffic,
ResponseHeader: logTraffic,
ResponseBody: logTraffic,
Formatters: []httpretty.Formatter{&httpretty.JSONFormatter{}},
MaxResponseBody: 10000,
}
logger.SetOutput(out)
logger.SetBodyFilter(func(h http.Header) (skip bool, err error) {
return !inspectableMIMEType(h.Get("Content-Type")), nil
})
return logger.RoundTripper
}
func inspectableMIMEType(t string) bool {
return strings.HasPrefix(t, "text/") ||
strings.HasPrefix(t, "application/x-www-form-urlencoded") ||
jsonTypeRE.MatchString(t)
}

View file

@ -0,0 +1,16 @@
package browser
import (
"io"
ghBrowser "github.com/cli/go-gh/pkg/browser"
)
type Browser interface {
Browse(string) error
}
func New(launcher string, stdout, stderr io.Writer) Browser {
b := ghBrowser.New(launcher, stdout, stderr)
return &b
}

40
internal/browser/stub.go Normal file
View file

@ -0,0 +1,40 @@
package browser
type Stub struct {
urls []string
}
func (b *Stub) Browse(url string) error {
b.urls = append(b.urls, url)
return nil
}
func (b *Stub) BrowsedURL() string {
if len(b.urls) > 0 {
return b.urls[0]
}
return ""
}
type _testing interface {
Errorf(string, ...interface{})
Helper()
}
func (b *Stub) Verify(t _testing, url string) {
t.Helper()
if url != "" {
switch len(b.urls) {
case 0:
t.Errorf("expected browser to open URL %q, but it was never invoked", url)
case 1:
if url != b.urls[0] {
t.Errorf("expected browser to open URL %q, got %q", url, b.urls[0])
}
default:
t.Errorf("expected browser to open one URL, but was invoked %d times", len(b.urls))
}
} else if len(b.urls) > 0 {
t.Errorf("expected no browser to open, but was invoked %d times: %v", len(b.urls), b.urls)
}
}

View file

@ -13,7 +13,6 @@ package api
// - github.GetUser(github.Client)
// - github.GetRepository(Client)
// - github.ReadFile(Client, nwo, branch, path) // was GetCodespaceRepositoryContents
// - github.AuthorizedKeys(Client, user)
// - codespaces.Create(Client, user, repo, sku, branch, location)
// - codespaces.Delete(Client, user, token, name)
// - codespaces.Get(Client, token, owner, name)
@ -94,6 +93,7 @@ func New(serverURL, apiURL, vscsURL string, httpClient httpClient) *API {
// User represents a GitHub user.
type User struct {
Login string `json:"login"`
Type string `json:"type"`
}
// GetUser returns the user associated with the given token.
@ -210,6 +210,8 @@ const (
CodespaceStateShutdown = "Shutdown"
// CodespaceStateStarting is the state for a starting codespace environment.
CodespaceStateStarting = "Starting"
// CodespaceStateRebuilding is the state for a rebuilding codespace environment.
CodespaceStateRebuilding = "Rebuilding"
)
type CodespaceConnection struct {
@ -267,15 +269,49 @@ func (c *Codespace) ExportData(fields []string) map[string]interface{} {
return data
}
type ListCodespacesOptions struct {
OrgName string
UserName string
RepoName string
Limit int
}
// ListCodespaces returns a list of codespaces for the user. Pass a negative limit to request all pages from
// the API until all codespaces have been fetched.
func (a *API) ListCodespaces(ctx context.Context, limit int) (codespaces []*Codespace, err error) {
perPage := 100
func (a *API) ListCodespaces(ctx context.Context, opts ListCodespacesOptions) (codespaces []*Codespace, err error) {
var (
perPage = 100
limit = opts.Limit
)
if limit > 0 && limit < 100 {
perPage = limit
}
listURL := fmt.Sprintf("%s/user/codespaces?per_page=%d", a.githubAPI, perPage)
var (
listURL string
spanName string
)
if opts.RepoName != "" {
listURL = fmt.Sprintf("%s/repos/%s/codespaces?per_page=%d", a.githubAPI, opts.RepoName, perPage)
spanName = "/repos/*/codespaces"
} else if opts.OrgName != "" {
// the endpoints below can only be called by the organization admins
orgName := opts.OrgName
if opts.UserName != "" {
userName := opts.UserName
listURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces?per_page=%d", a.githubAPI, orgName, userName, perPage)
spanName = "/orgs/*/members/*/codespaces"
} else {
listURL = fmt.Sprintf("%s/orgs/%s/codespaces?per_page=%d", a.githubAPI, orgName, perPage)
spanName = "/orgs/*/codespaces"
}
} else {
listURL = fmt.Sprintf("%s/user/codespaces?per_page=%d", a.githubAPI, perPage)
spanName = "/user/codespaces"
}
for {
req, err := http.NewRequest(http.MethodGet, listURL, nil)
if err != nil {
@ -283,7 +319,7 @@ func (a *API) ListCodespaces(ctx context.Context, limit int) (codespaces []*Code
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces")
resp, err := a.do(ctx, req, spanName)
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
@ -334,6 +370,52 @@ func findNextPage(linkValue string) string {
return ""
}
func (a *API) GetOrgMemberCodespace(ctx context.Context, orgName string, userName string, codespaceName string) (*Codespace, error) {
perPage := 100
listURL := fmt.Sprintf("%s/orgs/%s/members/%s/codespaces?per_page=%d", a.githubAPI, orgName, userName, perPage)
for {
req, err := http.NewRequest(http.MethodGet, listURL, nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/orgs/*/members/*/codespaces")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
var response struct {
Codespaces []*Codespace `json:"codespaces"`
}
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(&response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
for _, cs := range response.Codespaces {
if cs.Name == codespaceName {
return cs, nil
}
}
nextURL := findNextPage(resp.Header.Get("Link"))
if nextURL == "" {
break
}
listURL = nextURL
}
return nil, fmt.Errorf("codespace not found for user %s with name %s", userName, codespaceName)
}
// GetCodespace returns the user codespace based on the provided name.
// If the codespace is not found, an error is returned.
// If includeConnection is true, it will return the connection information for the codespace.
@ -409,18 +491,25 @@ func (a *API) StartCodespace(ctx context.Context, codespaceName string) error {
return nil
}
func (a *API) StopCodespace(ctx context.Context, codespaceName string) error {
req, err := http.NewRequest(
http.MethodPost,
a.githubAPI+"/user/codespaces/"+codespaceName+"/stop",
nil,
)
func (a *API) StopCodespace(ctx context.Context, codespaceName string, orgName string, userName string) error {
var stopURL string
var spanName string
if orgName != "" {
stopURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces/%s/stop", a.githubAPI, orgName, userName, codespaceName)
spanName = "/orgs/*/members/*/codespaces/*/stop"
} else {
stopURL = fmt.Sprintf("%s/user/codespaces/%s/stop", a.githubAPI, codespaceName)
spanName = "/user/codespaces/*/stop"
}
req, err := http.NewRequest(http.MethodPost, stopURL, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*/stop")
resp, err := a.do(ctx, req, spanName)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
@ -440,7 +529,7 @@ type Machine struct {
}
// GetCodespacesMachines returns the codespaces machines for the given repo, branch and location.
func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string) ([]*Machine, error) {
func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, location string, devcontainerPath string) ([]*Machine, error) {
reqURL := fmt.Sprintf("%s/repositories/%d/codespaces/machines", a.githubAPI, repoID)
req, err := http.NewRequest(http.MethodGet, reqURL, nil)
if err != nil {
@ -450,6 +539,7 @@ func (a *API) GetCodespacesMachines(ctx context.Context, repoID int, branch, loc
q := req.URL.Query()
q.Add("location", location)
q.Add("ref", branch)
q.Add("devcontainer_path", devcontainerPath)
req.URL.RawQuery = q.Encode()
a.setHeaders(req)
@ -556,6 +646,50 @@ func (a *API) GetCodespaceRepoSuggestions(ctx context.Context, partialSearch str
return repoNames, nil
}
// GetCodespaceBillableOwner returns the billable owner and expected default values for
// codespaces created by the user for a given repository.
func (a *API) GetCodespaceBillableOwner(ctx context.Context, nwo string) (*User, error) {
req, err := http.NewRequest(http.MethodGet, a.githubAPI+"/repos/"+nwo+"/codespaces/new", nil)
if err != nil {
return nil, fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/repos/*/codespaces/new")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, nil
} else if resp.StatusCode == http.StatusForbidden {
return nil, fmt.Errorf("you cannot create codespaces with that repository")
} else if resp.StatusCode != http.StatusOK {
return nil, api.HandleHTTPError(resp)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response struct {
BillableOwner User `json:"billable_owner"`
Defaults struct {
DevcontainerPath string `json:"devcontainer_path"`
Location string `json:"location"`
}
}
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
// While this response contains further helpful information ahead of codespace creation,
// we're only referencing the billable owner today.
return &response.BillableOwner, nil
}
// CreateCodespaceParams are the required parameters for provisioning a Codespace.
type CreateCodespaceParams struct {
RepositoryID int
@ -574,7 +708,7 @@ type CreateCodespaceParams struct {
// fails to create.
func (a *API) CreateCodespace(ctx context.Context, params *CreateCodespaceParams) (*Codespace, error) {
codespace, err := a.startCreate(ctx, params)
if err != errProvisioningInProgress {
if !errors.Is(err, errProvisioningInProgress) {
return codespace, err
}
@ -670,7 +804,17 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
defer resp.Body.Close()
if resp.StatusCode == http.StatusAccepted {
return nil, errProvisioningInProgress // RPC finished before result of creation known
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
var response Codespace
if err := json.Unmarshal(b, &response); err != nil {
return nil, fmt.Errorf("error unmarshaling response: %w", err)
}
return &response, errProvisioningInProgress // RPC finished before result of creation known
} else if resp.StatusCode == http.StatusUnauthorized {
var (
ue AcceptPermissionsRequiredError
@ -712,14 +856,25 @@ func (a *API) startCreate(ctx context.Context, params *CreateCodespaceParams) (*
}
// DeleteCodespace deletes the given codespace.
func (a *API) DeleteCodespace(ctx context.Context, codespaceName string) error {
req, err := http.NewRequest(http.MethodDelete, a.githubAPI+"/user/codespaces/"+codespaceName, nil)
func (a *API) DeleteCodespace(ctx context.Context, codespaceName string, orgName string, userName string) error {
var deleteURL string
var spanName string
if orgName != "" && userName != "" {
deleteURL = fmt.Sprintf("%s/orgs/%s/members/%s/codespaces/%s", a.githubAPI, orgName, userName, codespaceName)
spanName = "/orgs/*/members/*/codespaces/*"
} else {
deleteURL = a.githubAPI + "/user/codespaces/" + codespaceName
spanName = "/user/codespaces/*"
}
req, err := http.NewRequest(http.MethodDelete, deleteURL, nil)
if err != nil {
return fmt.Errorf("error creating request: %w", err)
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces/*")
resp, err := a.do(ctx, req, spanName)
if err != nil {
return fmt.Errorf("error making request: %w", err)
}
@ -807,7 +962,6 @@ type EditCodespaceParams struct {
func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *EditCodespaceParams) (*Codespace, error) {
requestBody, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("error marshaling request: %w", err)
}
@ -818,7 +972,7 @@ func (a *API) EditCodespace(ctx context.Context, codespaceName string, params *E
}
a.setHeaders(req)
resp, err := a.do(ctx, req, "/user/codespaces")
resp, err := a.do(ctx, req, "/user/codespaces/*")
if err != nil {
return nil, fmt.Errorf("error making request: %w", err)
}
@ -908,31 +1062,6 @@ func (a *API) GetCodespaceRepositoryContents(ctx context.Context, codespace *Cod
return decoded, nil
}
// AuthorizedKeys returns the public keys (in ~/.ssh/authorized_keys
// format) registered by the specified GitHub user.
func (a *API) AuthorizedKeys(ctx context.Context, user string) ([]byte, error) {
url := fmt.Sprintf("%s/%s.keys", a.githubServer, user)
req, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
return nil, err
}
resp, err := a.do(ctx, req, "/user.keys")
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("server returned %s", resp.Status)
}
b, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("error reading response body: %w", err)
}
return b, nil
}
// do executes the given request and returns the response. It creates an
// opentracing span to track the length of the request.
func (a *API) do(ctx context.Context, req *http.Request, spanName string) (*http.Response, error) {

View file

@ -66,47 +66,93 @@ func createFakeListEndpointServer(t *testing.T, initalTotal int, finalTotal int)
}))
}
func createFakeCreateEndpointServer(t *testing.T, initalTotal int, finalTotal int) *httptest.Server {
func createFakeCreateEndpointServer(t *testing.T, wantStatus int) *httptest.Server {
t.Helper()
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/user/codespaces" {
t.Fatal("Incorrect path")
// create endpoint
if r.URL.Path == "/user/codespaces" {
body := r.Body
if body == nil {
t.Fatal("No body")
}
defer body.Close()
var params startCreateRequest
err := json.NewDecoder(body).Decode(&params)
if err != nil {
t.Fatal("error:", err)
}
if params.RepositoryID != 1 {
t.Fatal("Expected RepositoryID to be 1. Got: ", params.RepositoryID)
}
if params.IdleTimeoutMinutes != 10 {
t.Fatal("Expected IdleTimeoutMinutes to be 10. Got: ", params.IdleTimeoutMinutes)
}
if *params.RetentionPeriodMinutes != 0 {
t.Fatal("Expected RetentionPeriodMinutes to be 0. Got: ", *params.RetentionPeriodMinutes)
}
response := Codespace{
Name: "codespace-1",
}
if wantStatus == 0 {
wantStatus = http.StatusCreated
}
data, _ := json.Marshal(response)
w.WriteHeader(wantStatus)
fmt.Fprint(w, string(data))
return
}
body := r.Body
if body == nil {
t.Fatal("No body")
}
defer body.Close()
var params startCreateRequest
err := json.NewDecoder(body).Decode(&params)
if err != nil {
t.Fatal("error:", err)
// get endpoint hit for testing pending status
if r.URL.Path == "/user/codespaces/codespace-1" {
response := Codespace{
Name: "codespace-1",
State: CodespaceStateAvailable,
}
data, _ := json.Marshal(response)
w.WriteHeader(http.StatusOK)
fmt.Fprint(w, string(data))
return
}
if params.RepositoryID != 1 {
t.Fatal("Expected RepositoryID to be 1. Got: ", params.RepositoryID)
}
if params.IdleTimeoutMinutes != 10 {
t.Fatal("Expected IdleTimeoutMinutes to be 10. Got: ", params.IdleTimeoutMinutes)
}
if *params.RetentionPeriodMinutes != 0 {
t.Fatal("Expected RetentionPeriodMinutes to be 0. Got: ", *params.RetentionPeriodMinutes)
}
response := Codespace{
Name: "codespace-1",
}
data, _ := json.Marshal(response)
fmt.Fprint(w, string(data))
t.Fatal("Incorrect path")
}))
}
func TestCreateCodespaces(t *testing.T) {
svr := createFakeCreateEndpointServer(t, 200, 200)
svr := createFakeCreateEndpointServer(t, http.StatusCreated)
defer svr.Close()
api := API{
githubAPI: svr.URL,
client: &http.Client{},
}
ctx := context.TODO()
retentionPeriod := 0
params := &CreateCodespaceParams{
RepositoryID: 1,
IdleTimeoutMinutes: 10,
RetentionPeriodMinutes: &retentionPeriod,
}
codespace, err := api.CreateCodespace(ctx, params)
if err != nil {
t.Fatal(err)
}
if codespace.Name != "codespace-1" {
t.Fatalf("expected codespace-1, got %s", codespace.Name)
}
}
func TestCreateCodespaces_Pending(t *testing.T) {
svr := createFakeCreateEndpointServer(t, http.StatusAccepted)
defer svr.Close()
api := API{
@ -140,7 +186,7 @@ func TestListCodespaces_limited(t *testing.T) {
client: &http.Client{},
}
ctx := context.TODO()
codespaces, err := api.ListCodespaces(ctx, 200)
codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{Limit: 200})
if err != nil {
t.Fatal(err)
}
@ -165,7 +211,7 @@ func TestListCodespaces_unlimited(t *testing.T) {
client: &http.Client{},
}
ctx := context.TODO()
codespaces, err := api.ListCodespaces(ctx, -1)
codespaces, err := api.ListCodespaces(ctx, ListCodespacesOptions{})
if err != nil {
t.Fatal(err)
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"time"
"github.com/cenkalti/backoff/v4"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/pkg/liveshare"
)
@ -38,17 +39,24 @@ type logger interface {
func ConnectToLiveshare(ctx context.Context, progress progressIndicator, sessionLogger logger, apiClient apiClient, codespace *api.Codespace) (sess *liveshare.Session, err error) {
if codespace.State != api.CodespaceStateAvailable {
progress.StartProgressIndicatorWithLabel("Starting codespace")
defer progress.StopProgressIndicator()
if err := apiClient.StartCodespace(ctx, codespace.Name); err != nil {
return nil, fmt.Errorf("error starting codespace: %w", err)
}
}
expBackoff := backoff.NewExponentialBackOff()
expBackoff.Multiplier = 1.1
expBackoff.MaxInterval = 10 * time.Second
expBackoff.MaxElapsedTime = 5 * time.Minute
for retries := 0; !connectionReady(codespace); retries++ {
if retries > 1 {
time.Sleep(1 * time.Second)
duration := expBackoff.NextBackOff()
time.Sleep(duration)
}
if retries == 30 {
if expBackoff.GetElapsedTime() >= expBackoff.MaxElapsedTime {
return nil, errors.New("timed out while waiting for the codespace to start")
}

View file

@ -0,0 +1,145 @@
package grpc
// gRPC client implementation to be able to connect to the gRPC server and perform the following operations:
// - Start a remote JupyterLab server
import (
"context"
"fmt"
"net"
"strconv"
"time"
"github.com/cli/cli/v2/internal/codespaces/grpc/jupyter"
"github.com/cli/cli/v2/pkg/liveshare"
"golang.org/x/crypto/ssh"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials/insecure"
"google.golang.org/grpc/metadata"
)
const (
ConnectionTimeout = 5 * time.Second
RequestTimeout = 30 * time.Second
)
const (
codespacesInternalPort = 16634
codespacesInternalSessionName = "CodespacesInternal"
)
type Client struct {
conn *grpc.ClientConn
token string
listener net.Listener
jupyterClient jupyter.JupyterServerHostClient
cancelPF context.CancelFunc
}
type liveshareSession interface {
KeepAlive(string)
OpenStreamingChannel(context.Context, liveshare.ChannelID) (ssh.Channel, error)
StartSharing(context.Context, string, int) (liveshare.ChannelID, error)
}
// Finds a free port to listen on and creates a new gRPC client that connects to that port
func Connect(ctx context.Context, session liveshareSession, token string) (*Client, error) {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", 0))
if err != nil {
return nil, fmt.Errorf("failed to listen to local port over tcp: %w", err)
}
localAddress := fmt.Sprintf("127.0.0.1:%d", listener.Addr().(*net.TCPAddr).Port)
client := &Client{
token: token,
listener: listener,
}
// Create a cancelable context to be able to cancel background tasks
// if we encounter an error while connecting to the gRPC server
connectctx, cancel := context.WithCancel(context.Background())
defer func() {
if err != nil {
cancel()
}
}()
ch := make(chan error, 2) // Buffered channel to ensure we don't block on the goroutine
// Ensure we close the port forwarder if we encounter an error
// or once the gRPC connection is closed. pfcancel is retained
// to close the PF whenever we close the gRPC connection.
pfctx, pfcancel := context.WithCancel(connectctx)
client.cancelPF = pfcancel
// Tunnel the remote gRPC server port to the local port
go func() {
fwd := liveshare.NewPortForwarder(session, codespacesInternalSessionName, codespacesInternalPort, true)
ch <- fwd.ForwardToListener(pfctx, listener)
}()
var conn *grpc.ClientConn
go func() {
// Attempt to connect to the port
opts := []grpc.DialOption{
grpc.WithTransportCredentials(insecure.NewCredentials()),
grpc.WithBlock(),
}
conn, err = grpc.DialContext(connectctx, localAddress, opts...)
ch <- err // nil if we successfully connected
}()
// Wait for the connection to be established or for the context to be cancelled
select {
case <-ctx.Done():
return nil, ctx.Err()
case err := <-ch:
if err != nil {
return nil, err
}
}
client.conn = conn
client.jupyterClient = jupyter.NewJupyterServerHostClient(conn)
return client, nil
}
// Closes the gRPC connection
func (g *Client) Close() error {
g.cancelPF()
// Closing the local listener effectively closes the gRPC connection
if err := g.listener.Close(); err != nil {
g.conn.Close() // If we fail to close the listener, explicitly close the gRPC connection and ignore any error
return fmt.Errorf("failed to close local tcp port listener: %w", err)
}
return nil
}
// Appends the authentication token to the gRPC context
func (g *Client) appendMetadata(ctx context.Context) context.Context {
return metadata.AppendToOutgoingContext(ctx, "Authorization", "Bearer "+g.token)
}
// Starts a remote JupyterLab server to allow the user to connect to the codespace via JupyterLab in their browser
func (g *Client) StartJupyterServer(ctx context.Context) (port int, serverUrl string, err error) {
ctx = g.appendMetadata(ctx)
response, err := g.jupyterClient.GetRunningServer(ctx, &jupyter.GetRunningServerRequest{})
if err != nil {
return 0, "", fmt.Errorf("failed to invoke JupyterLab RPC: %w", err)
}
if !response.Result {
return 0, "", fmt.Errorf("failed to start JupyterLab: %s", response.Message)
}
port, err = strconv.Atoi(response.Port)
if err != nil {
return 0, "", fmt.Errorf("failed to parse JupyterLab port: %w", err)
}
return port, response.ServerUrl, err
}

View file

@ -0,0 +1,84 @@
package grpc
import (
"context"
"fmt"
"log"
"os"
"testing"
grpctest "github.com/cli/cli/v2/internal/codespaces/grpc/test"
)
func startServer(t *testing.T) {
t.Helper()
if os.Getenv("GITHUB_ACTIONS") == "true" {
t.Skip("fails intermittently in CI: https://github.com/cli/cli/issues/5663")
}
ctx, cancel := context.WithCancel(context.Background())
// Start the gRPC server in the background
go func() {
err := grpctest.StartServer(ctx)
if err != nil && err != context.Canceled {
log.Println(fmt.Errorf("error starting test server: %v", err))
}
}()
// Stop the gRPC server when the test is done
t.Cleanup(func() {
cancel()
})
}
func connect(t *testing.T) (client *Client) {
t.Helper()
client, err := Connect(context.Background(), &grpctest.Session{}, "token")
if err != nil {
t.Fatalf("error connecting to internal server: %v", err)
}
t.Cleanup(func() {
client.Close()
})
return client
}
// Test that the gRPC client returns the correct port and URL when the JupyterLab server starts successfully
func TestStartJupyterServerSuccess(t *testing.T) {
startServer(t)
client := connect(t)
port, url, err := client.StartJupyterServer(context.Background())
if err != nil {
t.Fatalf("expected %v, got %v", nil, err)
}
if port != grpctest.JupyterPort {
t.Fatalf("expected %d, got %d", grpctest.JupyterPort, port)
}
if url != grpctest.JupyterServerUrl {
t.Fatalf("expected %s, got %s", grpctest.JupyterServerUrl, url)
}
}
// Test that the gRPC client returns an error when the JupyterLab server fails to start
func TestStartJupyterServerFailure(t *testing.T) {
startServer(t)
client := connect(t)
grpctest.JupyterMessage = "error message"
grpctest.JupyterResult = false
errorMessage := fmt.Sprintf("failed to start JupyterLab: %s", grpctest.JupyterMessage)
port, url, err := client.StartJupyterServer(context.Background())
if err.Error() != errorMessage {
t.Fatalf("expected %v, got %v", errorMessage, err)
}
if port != 0 {
t.Fatalf("expected %d, got %d", 0, port)
}
if url != "" {
t.Fatalf("expected %s, got %s", "", url)
}
}

View file

@ -0,0 +1,16 @@
# Protocol Buffers for Codespaces
Instructions for generating and adding gRPC protocol buffers.
## Generate Protocol Buffers
1. [Download `protoc`](https://grpc.io/docs/protoc-installation/)
2. [Download protocol compiler plugins for Go](https://grpc.io/docs/languages/go/quickstart/)
3. Run `./generate.sh` from the `internal/codespaces/grpc` directory
## Add New Protocol Buffers
1. Download a `.proto` contract from the service repo
2. Create a new directory and copy the `.proto` to it
3. Update `generate.sh` to include the include the new `.proto`
4. Follow the instructions to [Generate Protocol Buffers](#generate-protocol-buffers)

View file

@ -0,0 +1,26 @@
#!/bin/bash
set -e
if ! protoc --version; then
echo 'ERROR: protoc is not on your PATH'
exit 1
fi
if ! protoc-gen-go --version; then
echo 'ERROR: protoc-gen-go is not on your PATH'
exit 1
fi
if ! protoc-gen-go-grpc --version; then
echo 'ERROR: protoc-gen-go-grpc is not on your PATH'
fi
function generate {
local contract="$1"
protoc --go_out=. --go_opt=paths=source_relative --go-grpc_out=. --go-grpc_opt=paths=source_relative "$contract"
echo "Generated protocol buffers for $contract"
}
generate jupyter/JupyterServerHostService.v1.proto
echo 'Done!'

View file

@ -0,0 +1,241 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.28.1
// protoc v3.12.4
// source: jupyter/JupyterServerHostService.v1.proto
package jupyter
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type GetRunningServerRequest struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
}
func (x *GetRunningServerRequest) Reset() {
*x = GetRunningServerRequest{}
if protoimpl.UnsafeEnabled {
mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GetRunningServerRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetRunningServerRequest) ProtoMessage() {}
func (x *GetRunningServerRequest) ProtoReflect() protoreflect.Message {
mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[0]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetRunningServerRequest.ProtoReflect.Descriptor instead.
func (*GetRunningServerRequest) Descriptor() ([]byte, []int) {
return file_jupyter_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{0}
}
type GetRunningServerResponse struct {
state protoimpl.MessageState
sizeCache protoimpl.SizeCache
unknownFields protoimpl.UnknownFields
Result bool `protobuf:"varint,1,opt,name=Result,proto3" json:"Result,omitempty"`
Message string `protobuf:"bytes,2,opt,name=Message,proto3" json:"Message,omitempty"`
Port string `protobuf:"bytes,3,opt,name=Port,proto3" json:"Port,omitempty"`
ServerUrl string `protobuf:"bytes,4,opt,name=ServerUrl,proto3" json:"ServerUrl,omitempty"`
}
func (x *GetRunningServerResponse) Reset() {
*x = GetRunningServerResponse{}
if protoimpl.UnsafeEnabled {
mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
}
func (x *GetRunningServerResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*GetRunningServerResponse) ProtoMessage() {}
func (x *GetRunningServerResponse) ProtoReflect() protoreflect.Message {
mi := &file_jupyter_JupyterServerHostService_v1_proto_msgTypes[1]
if protoimpl.UnsafeEnabled && x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use GetRunningServerResponse.ProtoReflect.Descriptor instead.
func (*GetRunningServerResponse) Descriptor() ([]byte, []int) {
return file_jupyter_JupyterServerHostService_v1_proto_rawDescGZIP(), []int{1}
}
func (x *GetRunningServerResponse) GetResult() bool {
if x != nil {
return x.Result
}
return false
}
func (x *GetRunningServerResponse) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *GetRunningServerResponse) GetPort() string {
if x != nil {
return x.Port
}
return ""
}
func (x *GetRunningServerResponse) GetServerUrl() string {
if x != nil {
return x.ServerUrl
}
return ""
}
var File_jupyter_JupyterServerHostService_v1_proto protoreflect.FileDescriptor
var file_jupyter_JupyterServerHostService_v1_proto_rawDesc = []byte{
0x0a, 0x29, 0x6a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x2f, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65,
0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69,
0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x2b, 0x43, 0x6f, 0x64,
0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70,
0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65,
0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x22, 0x19, 0x0a, 0x17, 0x47, 0x65, 0x74, 0x52,
0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71, 0x75,
0x65, 0x73, 0x74, 0x22, 0x7e, 0x0a, 0x18, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e,
0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12,
0x16, 0x0a, 0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x52,
0x06, 0x52, 0x65, 0x73, 0x75, 0x6c, 0x74, 0x12, 0x18, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61,
0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67,
0x65, 0x12, 0x12, 0x0a, 0x04, 0x50, 0x6f, 0x72, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52,
0x04, 0x50, 0x6f, 0x72, 0x74, 0x12, 0x1c, 0x0a, 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x55,
0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72,
0x55, 0x72, 0x6c, 0x32, 0xb5, 0x01, 0x0a, 0x11, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53,
0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x12, 0x9f, 0x01, 0x0a, 0x10, 0x47, 0x65,
0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x12, 0x44,
0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x73, 0x2e, 0x47, 0x72, 0x70, 0x63,
0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x48, 0x6f,
0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74,
0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x65, 0x72, 0x52, 0x65, 0x71,
0x75, 0x65, 0x73, 0x74, 0x1a, 0x45, 0x2e, 0x43, 0x6f, 0x64, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65,
0x73, 0x2e, 0x47, 0x72, 0x70, 0x63, 0x2e, 0x4a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x53, 0x65,
0x72, 0x76, 0x65, 0x72, 0x48, 0x6f, 0x73, 0x74, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x2e,
0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x52, 0x75, 0x6e, 0x6e, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72,
0x76, 0x65, 0x72, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x42, 0x0b, 0x5a, 0x09, 0x2e,
0x2f, 0x6a, 0x75, 0x70, 0x79, 0x74, 0x65, 0x72, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
}
var (
file_jupyter_JupyterServerHostService_v1_proto_rawDescOnce sync.Once
file_jupyter_JupyterServerHostService_v1_proto_rawDescData = file_jupyter_JupyterServerHostService_v1_proto_rawDesc
)
func file_jupyter_JupyterServerHostService_v1_proto_rawDescGZIP() []byte {
file_jupyter_JupyterServerHostService_v1_proto_rawDescOnce.Do(func() {
file_jupyter_JupyterServerHostService_v1_proto_rawDescData = protoimpl.X.CompressGZIP(file_jupyter_JupyterServerHostService_v1_proto_rawDescData)
})
return file_jupyter_JupyterServerHostService_v1_proto_rawDescData
}
var file_jupyter_JupyterServerHostService_v1_proto_msgTypes = make([]protoimpl.MessageInfo, 2)
var file_jupyter_JupyterServerHostService_v1_proto_goTypes = []interface{}{
(*GetRunningServerRequest)(nil), // 0: Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerRequest
(*GetRunningServerResponse)(nil), // 1: Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerResponse
}
var file_jupyter_JupyterServerHostService_v1_proto_depIdxs = []int32{
0, // 0: Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost.GetRunningServer:input_type -> Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerRequest
1, // 1: Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost.GetRunningServer:output_type -> Codespaces.Grpc.JupyterServerHostService.v1.GetRunningServerResponse
1, // [1:2] is the sub-list for method output_type
0, // [0:1] is the sub-list for method input_type
0, // [0:0] is the sub-list for extension type_name
0, // [0:0] is the sub-list for extension extendee
0, // [0:0] is the sub-list for field type_name
}
func init() { file_jupyter_JupyterServerHostService_v1_proto_init() }
func file_jupyter_JupyterServerHostService_v1_proto_init() {
if File_jupyter_JupyterServerHostService_v1_proto != nil {
return
}
if !protoimpl.UnsafeEnabled {
file_jupyter_JupyterServerHostService_v1_proto_msgTypes[0].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*GetRunningServerRequest); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
file_jupyter_JupyterServerHostService_v1_proto_msgTypes[1].Exporter = func(v interface{}, i int) interface{} {
switch v := v.(*GetRunningServerResponse); i {
case 0:
return &v.state
case 1:
return &v.sizeCache
case 2:
return &v.unknownFields
default:
return nil
}
}
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: file_jupyter_JupyterServerHostService_v1_proto_rawDesc,
NumEnums: 0,
NumMessages: 2,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_jupyter_JupyterServerHostService_v1_proto_goTypes,
DependencyIndexes: file_jupyter_JupyterServerHostService_v1_proto_depIdxs,
MessageInfos: file_jupyter_JupyterServerHostService_v1_proto_msgTypes,
}.Build()
File_jupyter_JupyterServerHostService_v1_proto = out.File
file_jupyter_JupyterServerHostService_v1_proto_rawDesc = nil
file_jupyter_JupyterServerHostService_v1_proto_goTypes = nil
file_jupyter_JupyterServerHostService_v1_proto_depIdxs = nil
}

View file

@ -0,0 +1,19 @@
syntax = "proto3";
option go_package = "./jupyter";
package Codespaces.Grpc.JupyterServerHostService.v1;
service JupyterServerHost {
rpc GetRunningServer (GetRunningServerRequest) returns (GetRunningServerResponse);
}
message GetRunningServerRequest {
}
message GetRunningServerResponse {
bool Result = 1;
string Message = 2;
string Port = 3;
string ServerUrl = 4;
}

View file

@ -0,0 +1,105 @@
// Code generated by protoc-gen-go-grpc. DO NOT EDIT.
// versions:
// - protoc-gen-go-grpc v1.2.0
// - protoc v3.12.4
// source: jupyter/JupyterServerHostService.v1.proto
package jupyter
import (
context "context"
grpc "google.golang.org/grpc"
codes "google.golang.org/grpc/codes"
status "google.golang.org/grpc/status"
)
// This is a compile-time assertion to ensure that this generated file
// is compatible with the grpc package it is being compiled against.
// Requires gRPC-Go v1.32.0 or later.
const _ = grpc.SupportPackageIsVersion7
// JupyterServerHostClient is the client API for JupyterServerHost service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream.
type JupyterServerHostClient interface {
GetRunningServer(ctx context.Context, in *GetRunningServerRequest, opts ...grpc.CallOption) (*GetRunningServerResponse, error)
}
type jupyterServerHostClient struct {
cc grpc.ClientConnInterface
}
func NewJupyterServerHostClient(cc grpc.ClientConnInterface) JupyterServerHostClient {
return &jupyterServerHostClient{cc}
}
func (c *jupyterServerHostClient) GetRunningServer(ctx context.Context, in *GetRunningServerRequest, opts ...grpc.CallOption) (*GetRunningServerResponse, error) {
out := new(GetRunningServerResponse)
err := c.cc.Invoke(ctx, "/Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost/GetRunningServer", in, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// JupyterServerHostServer is the server API for JupyterServerHost service.
// All implementations must embed UnimplementedJupyterServerHostServer
// for forward compatibility
type JupyterServerHostServer interface {
GetRunningServer(context.Context, *GetRunningServerRequest) (*GetRunningServerResponse, error)
mustEmbedUnimplementedJupyterServerHostServer()
}
// UnimplementedJupyterServerHostServer must be embedded to have forward compatible implementations.
type UnimplementedJupyterServerHostServer struct {
}
func (UnimplementedJupyterServerHostServer) GetRunningServer(context.Context, *GetRunningServerRequest) (*GetRunningServerResponse, error) {
return nil, status.Errorf(codes.Unimplemented, "method GetRunningServer not implemented")
}
func (UnimplementedJupyterServerHostServer) mustEmbedUnimplementedJupyterServerHostServer() {}
// UnsafeJupyterServerHostServer may be embedded to opt out of forward compatibility for this service.
// Use of this interface is not recommended, as added methods to JupyterServerHostServer will
// result in compilation errors.
type UnsafeJupyterServerHostServer interface {
mustEmbedUnimplementedJupyterServerHostServer()
}
func RegisterJupyterServerHostServer(s grpc.ServiceRegistrar, srv JupyterServerHostServer) {
s.RegisterService(&JupyterServerHost_ServiceDesc, srv)
}
func _JupyterServerHost_GetRunningServer_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) {
in := new(GetRunningServerRequest)
if err := dec(in); err != nil {
return nil, err
}
if interceptor == nil {
return srv.(JupyterServerHostServer).GetRunningServer(ctx, in)
}
info := &grpc.UnaryServerInfo{
Server: srv,
FullMethod: "/Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost/GetRunningServer",
}
handler := func(ctx context.Context, req interface{}) (interface{}, error) {
return srv.(JupyterServerHostServer).GetRunningServer(ctx, req.(*GetRunningServerRequest))
}
return interceptor(ctx, in, info, handler)
}
// JupyterServerHost_ServiceDesc is the grpc.ServiceDesc for JupyterServerHost service.
// It's only intended for direct use with grpc.RegisterService,
// and not to be introspected or modified (even as a copy)
var JupyterServerHost_ServiceDesc = grpc.ServiceDesc{
ServiceName: "Codespaces.Grpc.JupyterServerHostService.v1.JupyterServerHost",
HandlerType: (*JupyterServerHostServer)(nil),
Methods: []grpc.MethodDesc{
{
MethodName: "GetRunningServer",
Handler: _JupyterServerHost_GetRunningServer_Handler,
},
},
Streams: []grpc.StreamDesc{},
Metadata: "jupyter/JupyterServerHostService.v1.proto",
}

View file

@ -0,0 +1,34 @@
package test
import (
"io"
"net"
)
type Channel struct {
conn net.Conn
}
func (c *Channel) Read(data []byte) (int, error) {
return c.conn.Read(data)
}
func (c *Channel) Write(data []byte) (int, error) {
return c.conn.Write(data)
}
func (c *Channel) Close() error {
return c.conn.Close()
}
func (c *Channel) CloseWrite() error {
return nil
}
func (c *Channel) SendRequest(name string, wantReply bool, payload []byte) (bool, error) {
return false, nil
}
func (c *Channel) Stderr() io.ReadWriter {
return nil
}

View file

@ -0,0 +1,62 @@
package test
import (
"context"
"fmt"
"net"
"strconv"
"github.com/cli/cli/v2/internal/codespaces/grpc/jupyter"
"google.golang.org/grpc"
)
const (
ServerPort = 50051
)
var (
JupyterPort = 1234
JupyterServerUrl = "http://localhost:1234?token=1234"
JupyterMessage = ""
JupyterResult = true
)
type server struct {
jupyter.UnimplementedJupyterServerHostServer
}
func (s *server) GetRunningServer(ctx context.Context, in *jupyter.GetRunningServerRequest) (*jupyter.GetRunningServerResponse, error) {
return &jupyter.GetRunningServerResponse{
Port: strconv.Itoa(JupyterPort),
ServerUrl: JupyterServerUrl,
Message: JupyterMessage,
Result: JupyterResult,
}, nil
}
// Starts the mock gRPC server listening on port 50051
func StartServer(ctx context.Context) error {
listener, err := net.Listen("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort))
if err != nil {
return fmt.Errorf("failed to listen: %v", err)
}
defer listener.Close()
s := grpc.NewServer()
jupyter.RegisterJupyterServerHostServer(s, &server{})
ch := make(chan error, 1)
go func() {
if err := s.Serve(listener); err != nil {
ch <- fmt.Errorf("failed to serve: %v", err)
}
}()
select {
case <-ctx.Done():
s.Stop()
return ctx.Err()
case err := <-ch:
return err
}
}

View file

@ -0,0 +1,31 @@
package test
import (
"context"
"fmt"
"net"
"github.com/cli/cli/v2/pkg/liveshare"
"golang.org/x/crypto/ssh"
)
type Session struct {
channel ssh.Channel
}
func (s *Session) KeepAlive(reason string) {
}
func (s *Session) StartSharing(ctx context.Context, sessionName string, port int) (liveshare.ChannelID, error) {
conn, err := net.Dial("tcp", fmt.Sprintf("127.0.0.1:%d", ServerPort))
if err != nil {
return liveshare.ChannelID{}, err
}
s.channel = &Channel{conn}
return liveshare.ChannelID{}, nil
}
// Creates mock SSH channel connected to the mock gRPC server
func (s *Session) OpenStreamingChannel(ctx context.Context, id liveshare.ChannelID) (ssh.Channel, error) {
return s.channel, nil
}

View file

@ -56,7 +56,11 @@ func NewRemoteCommand(ctx context.Context, tunnelPort int, destination string, s
// newSSHCommand populates an exec.Cmd to run a command (or if blank,
// an interactive shell) over ssh.
func newSSHCommand(ctx context.Context, port int, dst string, cmdArgs []string) (*exec.Cmd, []string, error) {
connArgs := []string{"-p", strconv.Itoa(port), "-o", "NoHostAuthenticationForLocalhost=yes"}
connArgs := []string{
"-p", strconv.Itoa(port),
"-o", "NoHostAuthenticationForLocalhost=yes",
"-o", "PasswordAuthentication=no",
}
// The ssh command syntax is: ssh [flags] user@host command [args...]
// There is no way to specify the user@host destination as a flag.
@ -101,6 +105,7 @@ func newSCPCommand(ctx context.Context, port int, dst string, cmdArgs []string)
connArgs := []string{
"-P", strconv.Itoa(port),
"-o", "NoHostAuthenticationForLocalhost=yes",
"-o", "PasswordAuthentication=no",
"-C", // compression
}

View file

@ -11,8 +11,8 @@ import (
"time"
"github.com/cli/cli/v2/internal/codespaces/api"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/liveshare"
"github.com/cli/cli/v2/pkg/text"
)
// PostCreateStateStatus is a string value representing the different statuses a state can have.

View file

@ -1,60 +0,0 @@
package config
import (
"fmt"
)
type AliasConfig struct {
ConfigMap
Parent Config
}
func (a *AliasConfig) Get(alias string) (string, bool) {
if a.Empty() {
return "", false
}
value, _ := a.GetStringValue(alias)
return value, value != ""
}
func (a *AliasConfig) Add(alias, expansion string) error {
err := a.SetStringValue(alias, expansion)
if err != nil {
return fmt.Errorf("failed to update config: %w", err)
}
err = a.Parent.Write()
if err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
func (a *AliasConfig) Delete(alias string) error {
a.RemoveEntry(alias)
err := a.Parent.Write()
if err != nil {
return fmt.Errorf("failed to write config: %w", err)
}
return nil
}
func (a *AliasConfig) All() map[string]string {
out := map[string]string{}
if a.Empty() {
return out
}
for i := 0; i < len(a.Root.Content)-1; i += 2 {
key := a.Root.Content[i].Value
value := a.Root.Content[i+1].Value
out[key] = value
}
return out
}

225
internal/config/config.go Normal file
View file

@ -0,0 +1,225 @@
package config
import (
"os"
"path/filepath"
ghAuth "github.com/cli/go-gh/pkg/auth"
ghConfig "github.com/cli/go-gh/pkg/config"
)
const (
hosts = "hosts"
aliases = "aliases"
)
// This interface describes interacting with some persistent configuration for gh.
//
//go:generate moq -rm -out config_mock.go . Config
type Config interface {
AuthToken(string) (string, string)
Get(string, string) (string, error)
GetOrDefault(string, string) (string, error)
Set(string, string, string)
UnsetHost(string)
Hosts() []string
DefaultHost() (string, string)
Aliases() *AliasConfig
Write() error
}
func NewConfig() (Config, error) {
c, err := ghConfig.Read()
if err != nil {
return nil, err
}
return &cfg{c}, nil
}
// Implements Config interface
type cfg struct {
cfg *ghConfig.Config
}
func (c *cfg) AuthToken(hostname string) (string, string) {
return ghAuth.TokenForHost(hostname)
}
func (c *cfg) Get(hostname, key string) (string, error) {
if hostname != "" {
val, err := c.cfg.Get([]string{hosts, hostname, key})
if err == nil {
return val, err
}
}
return c.cfg.Get([]string{key})
}
func (c *cfg) GetOrDefault(hostname, key string) (string, error) {
var val string
var err error
if hostname != "" {
val, err = c.cfg.Get([]string{hosts, hostname, key})
if err == nil {
return val, err
}
}
val, err = c.cfg.Get([]string{key})
if err == nil {
return val, err
}
if defaultExists(key) {
return defaultFor(key), nil
}
return val, err
}
func (c *cfg) Set(hostname, key, value string) {
if hostname == "" {
c.cfg.Set([]string{key}, value)
return
}
c.cfg.Set([]string{hosts, hostname, key}, value)
}
func (c *cfg) UnsetHost(hostname string) {
if hostname == "" {
return
}
_ = c.cfg.Remove([]string{hosts, hostname})
}
func (c *cfg) Hosts() []string {
return ghAuth.KnownHosts()
}
func (c *cfg) DefaultHost() (string, string) {
return ghAuth.DefaultHost()
}
func (c *cfg) Aliases() *AliasConfig {
return &AliasConfig{cfg: c.cfg}
}
func (c *cfg) Write() error {
return ghConfig.Write(c.cfg)
}
func defaultFor(key string) string {
for _, co := range configOptions {
if co.Key == key {
return co.DefaultValue
}
}
return ""
}
func defaultExists(key string) bool {
for _, co := range configOptions {
if co.Key == key {
return true
}
}
return false
}
type AliasConfig struct {
cfg *ghConfig.Config
}
func (a *AliasConfig) Get(alias string) (string, error) {
return a.cfg.Get([]string{aliases, alias})
}
func (a *AliasConfig) Add(alias, expansion string) {
a.cfg.Set([]string{aliases, alias}, expansion)
}
func (a *AliasConfig) Delete(alias string) error {
return a.cfg.Remove([]string{aliases, alias})
}
func (a *AliasConfig) All() map[string]string {
out := map[string]string{}
keys, err := a.cfg.Keys([]string{aliases})
if err != nil {
return out
}
for _, key := range keys {
val, _ := a.cfg.Get([]string{aliases, key})
out[key] = val
}
return out
}
type ConfigOption struct {
Key string
Description string
DefaultValue string
AllowedValues []string
}
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: "",
},
{
Key: "http_unix_socket",
Description: "the path to a Unix socket through which to make an HTTP connection",
DefaultValue: "",
},
{
Key: "browser",
Description: "the web browser to use for opening URLs",
DefaultValue: "",
},
}
func ConfigOptions() []ConfigOption {
return configOptions
}
func HomeDirPath(subdir string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
newPath := filepath.Join(homeDir, subdir)
return newPath, nil
}
func StateDir() string {
return ghConfig.StateDir()
}
func DataDir() string {
return ghConfig.DataDir()
}
func ConfigDir() string {
return ghConfig.ConfigDir()
}

View file

@ -1,349 +0,0 @@
package config
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"runtime"
"syscall"
"gopkg.in/yaml.v3"
)
const (
GH_CONFIG_DIR = "GH_CONFIG_DIR"
XDG_CONFIG_HOME = "XDG_CONFIG_HOME"
XDG_STATE_HOME = "XDG_STATE_HOME"
XDG_DATA_HOME = "XDG_DATA_HOME"
APP_DATA = "AppData"
LOCAL_APP_DATA = "LocalAppData"
)
// Config path precedence
// 1. GH_CONFIG_DIR
// 2. XDG_CONFIG_HOME
// 3. AppData (windows only)
// 4. HOME
func ConfigDir() string {
var path string
if a := os.Getenv(GH_CONFIG_DIR); a != "" {
path = a
} else if b := os.Getenv(XDG_CONFIG_HOME); b != "" {
path = filepath.Join(b, "gh")
} else if c := os.Getenv(APP_DATA); runtime.GOOS == "windows" && c != "" {
path = filepath.Join(c, "GitHub CLI")
} else {
d, _ := os.UserHomeDir()
path = filepath.Join(d, ".config", "gh")
}
// If the path does not exist and the GH_CONFIG_DIR flag is not set try
// migrating config from default paths.
if !dirExists(path) && os.Getenv(GH_CONFIG_DIR) == "" {
_ = autoMigrateConfigDir(path)
}
return path
}
// State path precedence
// 1. XDG_STATE_HOME
// 2. LocalAppData (windows only)
// 3. HOME
func StateDir() string {
var path string
if a := os.Getenv(XDG_STATE_HOME); a != "" {
path = filepath.Join(a, "gh")
} else if b := os.Getenv(LOCAL_APP_DATA); runtime.GOOS == "windows" && b != "" {
path = filepath.Join(b, "GitHub CLI")
} else {
c, _ := os.UserHomeDir()
path = filepath.Join(c, ".local", "state", "gh")
}
// If the path does not exist try migrating state from default paths
if !dirExists(path) {
_ = autoMigrateStateDir(path)
}
return path
}
// Data path precedence
// 1. XDG_DATA_HOME
// 2. LocalAppData (windows only)
// 3. HOME
func DataDir() string {
var path string
if a := os.Getenv(XDG_DATA_HOME); a != "" {
path = filepath.Join(a, "gh")
} else if b := os.Getenv(LOCAL_APP_DATA); runtime.GOOS == "windows" && b != "" {
path = filepath.Join(b, "GitHub CLI")
} else {
c, _ := os.UserHomeDir()
path = filepath.Join(c, ".local", "share", "gh")
}
return path
}
var errSamePath = errors.New("same path")
var errNotExist = errors.New("not exist")
// Check default path, os.UserHomeDir, for existing configs
// If configs exist then move them to newPath
func autoMigrateConfigDir(newPath string) error {
path, err := os.UserHomeDir()
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
return migrateDir(oldPath, newPath)
}
return errNotExist
}
// Check default path, os.UserHomeDir, for existing state file (state.yml)
// If state file exist then move it to newPath
func autoMigrateStateDir(newPath string) error {
path, err := os.UserHomeDir()
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
return migrateFile(oldPath, newPath, "state.yml")
}
return errNotExist
}
func migrateFile(oldPath, newPath, file string) error {
if oldPath == newPath {
return errSamePath
}
oldFile := filepath.Join(oldPath, file)
newFile := filepath.Join(newPath, file)
if !fileExists(oldFile) {
return errNotExist
}
_ = os.MkdirAll(filepath.Dir(newFile), 0755)
return os.Rename(oldFile, newFile)
}
func migrateDir(oldPath, newPath string) error {
if oldPath == newPath {
return errSamePath
}
if !dirExists(oldPath) {
return errNotExist
}
_ = os.MkdirAll(filepath.Dir(newPath), 0755)
return os.Rename(oldPath, newPath)
}
func dirExists(path string) bool {
f, err := os.Stat(path)
return err == nil && f.IsDir()
}
func fileExists(path string) bool {
f, err := os.Stat(path)
return err == nil && !f.IsDir()
}
func ConfigFile() string {
return filepath.Join(ConfigDir(), "config.yml")
}
func HostsConfigFile() string {
return filepath.Join(ConfigDir(), "hosts.yml")
}
func ParseDefaultConfig() (Config, error) {
return parseConfig(ConfigFile())
}
func HomeDirPath(subdir string) (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", err
}
newPath := filepath.Join(homeDir, subdir)
return newPath, nil
}
var ReadConfigFile = func(filename string) ([]byte, error) {
f, err := os.Open(filename)
if err != nil {
return nil, pathError(err)
}
defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
return nil, err
}
return data, nil
}
var WriteConfigFile = func(filename string, data []byte) error {
err := os.MkdirAll(filepath.Dir(filename), 0771)
if err != nil {
return pathError(err)
}
cfgFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) // cargo coded from setup
if err != nil {
return err
}
defer cfgFile.Close()
_, err = cfgFile.Write(data)
return err
}
var BackupConfigFile = func(filename string) error {
return os.Rename(filename, filename+".bak")
}
func parseConfigFile(filename string) ([]byte, *yaml.Node, error) {
data, err := ReadConfigFile(filename)
if err != nil {
return nil, nil, err
}
root, err := parseConfigData(data)
if err != nil {
return nil, nil, err
}
return data, root, err
}
func parseConfigData(data []byte) (*yaml.Node, error) {
var root yaml.Node
err := yaml.Unmarshal(data, &root)
if err != nil {
return nil, err
}
if len(root.Content) == 0 {
return &yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{{Kind: yaml.MappingNode}},
}, nil
}
if root.Content[0].Kind != yaml.MappingNode {
return &root, fmt.Errorf("expected a top level map")
}
return &root, nil
}
func isLegacy(root *yaml.Node) bool {
for _, v := range root.Content[0].Content {
if v.Value == "github.com" {
return true
}
}
return false
}
func migrateConfig(filename string) error {
b, err := ReadConfigFile(filename)
if err != nil {
return err
}
var hosts map[string][]yaml.Node
err = yaml.Unmarshal(b, &hosts)
if err != nil {
return fmt.Errorf("error decoding legacy format: %w", err)
}
cfg := NewBlankConfig()
for hostname, entries := range hosts {
if len(entries) < 1 {
continue
}
mapContent := entries[0].Content
for i := 0; i < len(mapContent)-1; i += 2 {
if err := cfg.Set(hostname, mapContent[i].Value, mapContent[i+1].Value); err != nil {
return err
}
}
}
err = BackupConfigFile(filename)
if err != nil {
return fmt.Errorf("failed to back up existing config: %w", err)
}
return cfg.Write()
}
func parseConfig(filename string) (Config, error) {
_, root, err := parseConfigFile(filename)
if err != nil {
if os.IsNotExist(err) {
root = NewBlankRoot()
} else {
return nil, err
}
}
if isLegacy(root) {
err = migrateConfig(filename)
if err != nil {
return nil, fmt.Errorf("error migrating legacy config: %w", err)
}
_, root, err = parseConfigFile(filename)
if err != nil {
return nil, fmt.Errorf("failed to reparse migrated config: %w", err)
}
} else {
if _, hostsRoot, err := parseConfigFile(HostsConfigFile()); err == nil {
if len(hostsRoot.Content[0].Content) > 0 {
newContent := []*yaml.Node{
{Value: "hosts"},
hostsRoot.Content[0],
}
restContent := root.Content[0].Content
root.Content[0].Content = append(newContent, restContent...)
}
} else if !errors.Is(err, os.ErrNotExist) {
return nil, err
}
}
return NewConfig(root), nil
}
func pathError(err error) error {
var pathError *os.PathError
if errors.As(err, &pathError) && errors.Is(pathError.Err, syscall.ENOTDIR) {
if p := findRegularFile(pathError.Path); p != "" {
return fmt.Errorf("remove or rename regular file `%s` (must be a directory)", p)
}
}
return err
}
func findRegularFile(p string) string {
for {
if s, err := os.Stat(p); err == nil && s.Mode().IsRegular() {
return p
}
newPath := filepath.Dir(p)
if newPath == p || newPath == "/" || newPath == "." {
break
}
p = newPath
}
return ""
}

View file

@ -1,576 +0,0 @@
package config
import (
"bytes"
"fmt"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
)
func Test_parseConfig(t *testing.T) {
defer stubConfig(`---
hosts:
github.com:
user: monalisa
oauth_token: OTOKEN
`, "")()
config, err := parseConfig("config.yml")
assert.NoError(t, err)
user, err := config.Get("github.com", "user")
assert.NoError(t, err)
assert.Equal(t, "monalisa", user)
token, err := config.Get("github.com", "oauth_token")
assert.NoError(t, err)
assert.Equal(t, "OTOKEN", token)
}
func Test_parseConfig_multipleHosts(t *testing.T) {
defer stubConfig(`---
hosts:
example.com:
user: wronguser
oauth_token: NOTTHIS
github.com:
user: monalisa
oauth_token: OTOKEN
`, "")()
config, err := parseConfig("config.yml")
assert.NoError(t, err)
user, err := config.Get("github.com", "user")
assert.NoError(t, err)
assert.Equal(t, "monalisa", user)
token, err := config.Get("github.com", "oauth_token")
assert.NoError(t, err)
assert.Equal(t, "OTOKEN", token)
}
func Test_parseConfig_hostsFile(t *testing.T) {
defer stubConfig("", `---
github.com:
user: monalisa
oauth_token: OTOKEN
`)()
config, err := parseConfig("config.yml")
assert.NoError(t, err)
user, err := config.Get("github.com", "user")
assert.NoError(t, err)
assert.Equal(t, "monalisa", user)
token, err := config.Get("github.com", "oauth_token")
assert.NoError(t, err)
assert.Equal(t, "OTOKEN", token)
}
func Test_parseConfig_hostFallback(t *testing.T) {
defer stubConfig(`---
git_protocol: ssh
`, `---
github.com:
user: monalisa
oauth_token: OTOKEN
example.com:
user: wronguser
oauth_token: NOTTHIS
git_protocol: https
`)()
config, err := parseConfig("config.yml")
assert.NoError(t, err)
val, err := config.GetOrDefault("example.com", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "https", val)
val, err = config.GetOrDefault("github.com", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "ssh", val)
val, err = config.GetOrDefault("nonexistent.io", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "ssh", val)
}
func Test_parseConfig_migrateConfig(t *testing.T) {
defer stubConfig(`---
github.com:
- user: keiyuri
oauth_token: 123456
`, "")()
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer StubWriteConfig(&mainBuf, &hostsBuf)()
defer StubBackupConfig()()
_, err := parseConfig("config.yml")
assert.NoError(t, err)
expectedHosts := `github.com:
user: keiyuri
oauth_token: "123456"
`
assert.Equal(t, expectedHosts, hostsBuf.String())
assert.NotContains(t, mainBuf.String(), "github.com")
assert.NotContains(t, mainBuf.String(), "oauth_token")
}
func Test_parseConfigFile(t *testing.T) {
tests := []struct {
contents string
wantsErr bool
}{
{
contents: "",
wantsErr: true,
},
{
contents: " ",
wantsErr: false,
},
{
contents: "\n",
wantsErr: false,
},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("contents: %q", tt.contents), func(t *testing.T) {
defer stubConfig(tt.contents, "")()
_, yamlRoot, err := parseConfigFile("config.yml")
if tt.wantsErr != (err != nil) {
t.Fatalf("got error: %v", err)
}
if tt.wantsErr {
return
}
assert.Equal(t, yaml.MappingNode, yamlRoot.Content[0].Kind)
assert.Equal(t, 0, len(yamlRoot.Content[0].Content))
})
}
}
func Test_ConfigDir(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
onlyWindows bool
env map[string]string
output string
}{
{
name: "HOME/USERPROFILE specified",
env: map[string]string{
"GH_CONFIG_DIR": "",
"XDG_CONFIG_HOME": "",
"AppData": "",
"USERPROFILE": tempDir,
"HOME": tempDir,
},
output: filepath.Join(tempDir, ".config", "gh"),
},
{
name: "GH_CONFIG_DIR specified",
env: map[string]string{
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
},
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "XDG_CONFIG_HOME specified",
env: map[string]string{
"XDG_CONFIG_HOME": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
{
name: "GH_CONFIG_DIR and XDG_CONFIG_HOME specified",
env: map[string]string{
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
"XDG_CONFIG_HOME": tempDir,
},
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "AppData specified",
onlyWindows: true,
env: map[string]string{
"AppData": tempDir,
},
output: filepath.Join(tempDir, "GitHub CLI"),
},
{
name: "GH_CONFIG_DIR and AppData specified",
onlyWindows: true,
env: map[string]string{
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
"AppData": tempDir,
},
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "XDG_CONFIG_HOME and AppData specified",
onlyWindows: true,
env: map[string]string{
"XDG_CONFIG_HOME": tempDir,
"AppData": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
}
for _, tt := range tests {
if tt.onlyWindows && runtime.GOOS != "windows" {
continue
}
t.Run(tt.name, func(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
old := os.Getenv(k)
os.Setenv(k, v)
defer os.Setenv(k, old)
}
}
// Create directory to skip auto migration code
// which gets run when target directory does not exist
_ = os.MkdirAll(tt.output, 0755)
assert.Equal(t, tt.output, ConfigDir())
})
}
}
func Test_configFile_Write_toDisk(t *testing.T) {
configDir := filepath.Join(t.TempDir(), ".config", "gh")
_ = os.MkdirAll(configDir, 0755)
os.Setenv(GH_CONFIG_DIR, configDir)
defer os.Unsetenv(GH_CONFIG_DIR)
cfg := NewFromString(`pager: less`)
err := cfg.Write()
if err != nil {
t.Fatal(err)
}
expectedConfig := "pager: less\n"
if configBytes, err := os.ReadFile(filepath.Join(configDir, "config.yml")); err != nil {
t.Error(err)
} else if string(configBytes) != expectedConfig {
t.Errorf("expected config.yml %q, got %q", expectedConfig, string(configBytes))
}
if configBytes, err := os.ReadFile(filepath.Join(configDir, "hosts.yml")); err != nil {
t.Error(err)
} else if string(configBytes) != "" {
t.Errorf("unexpected hosts.yml: %q", string(configBytes))
}
}
func Test_configFile_WriteHosts_toDisk(t *testing.T) {
configDir := filepath.Join(t.TempDir(), ".config", "gh")
_ = os.MkdirAll(configDir, 0755)
os.Setenv(GH_CONFIG_DIR, configDir)
defer os.Unsetenv(GH_CONFIG_DIR)
cfg := NewFromString(heredoc.Doc(`
hosts:
github.com:
user: monalisa
oauth_token: TOKEN
`))
err := cfg.WriteHosts()
if err != nil {
t.Fatal(err)
}
expectedConfig := "github.com:\n user: monalisa\n oauth_token: TOKEN\n"
actualConfig, err := os.ReadFile(filepath.Join(configDir, "hosts.yml"))
assert.NoError(t, err)
assert.Equal(t, expectedConfig, string(actualConfig))
_, nonExistErr := os.Stat(filepath.Join(configDir, "config.yml"))
assert.Error(t, nonExistErr)
}
func Test_autoMigrateConfigDir_noMigration_notExist(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := autoMigrateConfigDir(migrateDir)
assert.Equal(t, errNotExist, err)
files, err := os.ReadDir(migrateDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
}
func Test_autoMigrateConfigDir_noMigration_samePath(t *testing.T) {
homeDir := t.TempDir()
migrateDir := filepath.Join(homeDir, ".config", "gh")
err := os.MkdirAll(migrateDir, 0755)
assert.NoError(t, err)
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err = autoMigrateConfigDir(migrateDir)
assert.Equal(t, errSamePath, err)
files, err := os.ReadDir(migrateDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
}
func Test_autoMigrateConfigDir_migration(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeConfigDir := filepath.Join(homeDir, ".config", "gh")
migrateConfigDir := filepath.Join(migrateDir, ".config", "gh")
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := os.MkdirAll(homeConfigDir, 0755)
assert.NoError(t, err)
f, err := os.CreateTemp(homeConfigDir, "")
assert.NoError(t, err)
f.Close()
err = autoMigrateConfigDir(migrateConfigDir)
assert.NoError(t, err)
_, err = os.ReadDir(homeConfigDir)
assert.True(t, os.IsNotExist(err))
files, err := os.ReadDir(migrateConfigDir)
assert.NoError(t, err)
assert.Equal(t, 1, len(files))
}
func Test_StateDir(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
onlyWindows bool
env map[string]string
output string
}{
{
name: "HOME/USERPROFILE specified",
env: map[string]string{
"XDG_STATE_HOME": "",
"GH_CONFIG_DIR": "",
"XDG_CONFIG_HOME": "",
"LocalAppData": "",
"USERPROFILE": tempDir,
"HOME": tempDir,
},
output: filepath.Join(tempDir, ".local", "state", "gh"),
},
{
name: "XDG_STATE_HOME specified",
env: map[string]string{
"XDG_STATE_HOME": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
{
name: "LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "GitHub CLI"),
},
{
name: "XDG_STATE_HOME and LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"XDG_STATE_HOME": tempDir,
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
}
for _, tt := range tests {
if tt.onlyWindows && runtime.GOOS != "windows" {
continue
}
t.Run(tt.name, func(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
old := os.Getenv(k)
os.Setenv(k, v)
defer os.Setenv(k, old)
}
}
// Create directory to skip auto migration code
// which gets run when target directory does not exist
_ = os.MkdirAll(tt.output, 0755)
assert.Equal(t, tt.output, StateDir())
})
}
}
func Test_autoMigrateStateDir_noMigration_notExist(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := autoMigrateStateDir(migrateDir)
assert.Equal(t, errNotExist, err)
files, err := os.ReadDir(migrateDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
}
func Test_autoMigrateStateDir_noMigration_samePath(t *testing.T) {
homeDir := t.TempDir()
migrateDir := filepath.Join(homeDir, ".config", "gh")
err := os.MkdirAll(migrateDir, 0755)
assert.NoError(t, err)
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err = autoMigrateStateDir(migrateDir)
assert.Equal(t, errSamePath, err)
files, err := os.ReadDir(migrateDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
}
func Test_autoMigrateStateDir_migration(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeConfigDir := filepath.Join(homeDir, ".config", "gh")
migrateStateDir := filepath.Join(migrateDir, ".local", "state", "gh")
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := os.MkdirAll(homeConfigDir, 0755)
assert.NoError(t, err)
err = os.WriteFile(filepath.Join(homeConfigDir, "state.yml"), nil, 0755)
assert.NoError(t, err)
err = autoMigrateStateDir(migrateStateDir)
assert.NoError(t, err)
files, err := os.ReadDir(homeConfigDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
files, err = os.ReadDir(migrateStateDir)
assert.NoError(t, err)
assert.Equal(t, 1, len(files))
assert.Equal(t, "state.yml", files[0].Name())
}
func Test_DataDir(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
onlyWindows bool
env map[string]string
output string
}{
{
name: "HOME/USERPROFILE specified",
env: map[string]string{
"XDG_DATA_HOME": "",
"GH_CONFIG_DIR": "",
"XDG_CONFIG_HOME": "",
"LocalAppData": "",
"USERPROFILE": tempDir,
"HOME": tempDir,
},
output: filepath.Join(tempDir, ".local", "share", "gh"),
},
{
name: "XDG_DATA_HOME specified",
env: map[string]string{
"XDG_DATA_HOME": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
{
name: "LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "GitHub CLI"),
},
{
name: "XDG_DATA_HOME and LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"XDG_DATA_HOME": tempDir,
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
}
for _, tt := range tests {
if tt.onlyWindows && runtime.GOOS != "windows" {
continue
}
t.Run(tt.name, func(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
old := os.Getenv(k)
os.Setenv(k, v)
defer os.Setenv(k, old)
}
}
assert.Equal(t, tt.output, DataDir())
})
}
}

View file

@ -1,113 +0,0 @@
package config
import (
"errors"
"gopkg.in/yaml.v3"
)
// This type implements a low-level get/set config that is backed by an in-memory tree of yaml
// nodes. It allows us to interact with a yaml-based config programmatically, preserving any
// comments that were present when the yaml was parsed.
type ConfigMap struct {
Root *yaml.Node
}
type ConfigEntry struct {
KeyNode *yaml.Node
ValueNode *yaml.Node
Index int
}
type NotFoundError struct {
error
}
func (cm *ConfigMap) Empty() bool {
return cm.Root == nil || len(cm.Root.Content) == 0
}
func (cm *ConfigMap) GetStringValue(key string) (string, error) {
entry, err := cm.FindEntry(key)
if err != nil {
return "", err
}
return entry.ValueNode.Value, nil
}
func (cm *ConfigMap) SetStringValue(key, value string) error {
entry, err := cm.FindEntry(key)
if err == nil {
entry.ValueNode.Value = value
return nil
}
var notFound *NotFoundError
if err != nil && !errors.As(err, &notFound) {
return err
}
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: key,
}
valueNode := &yaml.Node{
Kind: yaml.ScalarNode,
Tag: "!!str",
Value: value,
}
cm.Root.Content = append(cm.Root.Content, keyNode, valueNode)
return nil
}
func (cm *ConfigMap) FindEntry(key string) (*ConfigEntry, error) {
ce := &ConfigEntry{}
if cm.Empty() {
return ce, &NotFoundError{errors.New("not found")}
}
// Content slice goes [key1, value1, key2, value2, ...].
topLevelPairs := cm.Root.Content
for i, v := range topLevelPairs {
// Skip every other slice item since we only want to check against keys.
if i%2 != 0 {
continue
}
if v.Value == key {
ce.KeyNode = v
ce.Index = i
if i+1 < len(topLevelPairs) {
ce.ValueNode = topLevelPairs[i+1]
}
return ce, nil
}
}
return ce, &NotFoundError{errors.New("not found")}
}
func (cm *ConfigMap) RemoveEntry(key string) {
if cm.Empty() {
return
}
newContent := []*yaml.Node{}
var skipNext bool
for i, v := range cm.Root.Content {
if skipNext {
skipNext = false
continue
}
if i%2 != 0 || v.Value != key {
newContent = append(newContent, v)
} else {
// Don't append current node and skip the next which is this key's value.
skipNext = true
}
}
cm.Root.Content = newContent
}

View file

@ -1,187 +0,0 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"gopkg.in/yaml.v3"
)
func TestFindEntry(t *testing.T) {
tests := []struct {
name string
key string
output string
wantErr bool
}{
{
name: "find key",
key: "valid",
output: "present",
},
{
name: "find key that is not present",
key: "invalid",
wantErr: true,
},
{
name: "find key with blank value",
key: "blank",
output: "",
},
{
name: "find key that has same content as a value",
key: "same",
output: "logical",
},
}
for _, tt := range tests {
cm := ConfigMap{Root: testYaml()}
t.Run(tt.name, func(t *testing.T) {
out, err := cm.FindEntry(tt.key)
if tt.wantErr {
assert.EqualError(t, err, "not found")
return
}
assert.NoError(t, err)
assert.Equal(t, tt.output, out.ValueNode.Value)
})
}
}
func TestEmpty(t *testing.T) {
cm := ConfigMap{}
assert.Equal(t, true, cm.Empty())
cm.Root = &yaml.Node{
Content: []*yaml.Node{
{
Value: "test",
},
},
}
assert.Equal(t, false, cm.Empty())
}
func TestGetStringValue(t *testing.T) {
tests := []struct {
name string
key string
wantValue string
wantErr bool
}{
{
name: "get key",
key: "valid",
wantValue: "present",
},
{
name: "get key that is not present",
key: "invalid",
wantErr: true,
},
{
name: "get key that has same content as a value",
key: "same",
wantValue: "logical",
},
}
for _, tt := range tests {
cm := ConfigMap{Root: testYaml()}
t.Run(tt.name, func(t *testing.T) {
val, err := cm.GetStringValue(tt.key)
if tt.wantErr {
assert.EqualError(t, err, "not found")
return
}
assert.Equal(t, tt.wantValue, val)
})
}
}
func TestSetStringValue(t *testing.T) {
tests := []struct {
name string
key string
value string
}{
{
name: "set key that is not present",
key: "notPresent",
value: "test1",
},
{
name: "set key that is present",
key: "erroneous",
value: "test2",
},
{
name: "set key that is blank",
key: "blank",
value: "test3",
},
{
name: "set key that has same content as a value",
key: "present",
value: "test4",
},
}
for _, tt := range tests {
cm := ConfigMap{Root: testYaml()}
t.Run(tt.name, func(t *testing.T) {
err := cm.SetStringValue(tt.key, tt.value)
assert.NoError(t, err)
val, err := cm.GetStringValue(tt.key)
assert.NoError(t, err)
assert.Equal(t, tt.value, val)
})
}
}
func TestRemoveEntry(t *testing.T) {
tests := []struct {
name string
key string
wantLength int
}{
{
name: "remove key",
key: "erroneous",
wantLength: 6,
},
{
name: "remove key that is not present",
key: "invalid",
wantLength: 8,
},
{
name: "remove key that has same content as a value",
key: "same",
wantLength: 6,
},
}
for _, tt := range tests {
cm := ConfigMap{Root: testYaml()}
t.Run(tt.name, func(t *testing.T) {
cm.RemoveEntry(tt.key)
assert.Equal(t, tt.wantLength, len(cm.Root.Content))
_, err := cm.FindEntry(tt.key)
assert.EqualError(t, err, "not found")
})
}
}
func testYaml() *yaml.Node {
var root yaml.Node
var data = `
valid: present
erroneous: same
blank:
same: logical
`
_ = yaml.Unmarshal([]byte(data), &root)
return root.Content[0]
}

View file

@ -0,0 +1,422 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package config
import (
"sync"
)
// Ensure, that ConfigMock does implement Config.
// If this is not the case, regenerate this file with moq.
var _ Config = &ConfigMock{}
// ConfigMock is a mock implementation of Config.
//
// func TestSomethingThatUsesConfig(t *testing.T) {
//
// // make and configure a mocked Config
// mockedConfig := &ConfigMock{
// AliasesFunc: func() *AliasConfig {
// panic("mock out the Aliases method")
// },
// AuthTokenFunc: func(s string) (string, string) {
// panic("mock out the AuthToken method")
// },
// DefaultHostFunc: func() (string, string) {
// panic("mock out the DefaultHost method")
// },
// GetFunc: func(s1 string, s2 string) (string, error) {
// panic("mock out the Get method")
// },
// GetOrDefaultFunc: func(s1 string, s2 string) (string, error) {
// panic("mock out the GetOrDefault method")
// },
// HostsFunc: func() []string {
// panic("mock out the Hosts method")
// },
// SetFunc: func(s1 string, s2 string, s3 string) {
// panic("mock out the Set method")
// },
// UnsetHostFunc: func(s string) {
// panic("mock out the UnsetHost method")
// },
// WriteFunc: func() error {
// panic("mock out the Write method")
// },
// }
//
// // use mockedConfig in code that requires Config
// // and then make assertions.
//
// }
type ConfigMock struct {
// AliasesFunc mocks the Aliases method.
AliasesFunc func() *AliasConfig
// AuthTokenFunc mocks the AuthToken method.
AuthTokenFunc func(s string) (string, string)
// DefaultHostFunc mocks the DefaultHost method.
DefaultHostFunc func() (string, string)
// GetFunc mocks the Get method.
GetFunc func(s1 string, s2 string) (string, error)
// GetOrDefaultFunc mocks the GetOrDefault method.
GetOrDefaultFunc func(s1 string, s2 string) (string, error)
// HostsFunc mocks the Hosts method.
HostsFunc func() []string
// SetFunc mocks the Set method.
SetFunc func(s1 string, s2 string, s3 string)
// UnsetHostFunc mocks the UnsetHost method.
UnsetHostFunc func(s string)
// WriteFunc mocks the Write method.
WriteFunc func() error
// calls tracks calls to the methods.
calls struct {
// Aliases holds details about calls to the Aliases method.
Aliases []struct {
}
// AuthToken holds details about calls to the AuthToken method.
AuthToken []struct {
// S is the s argument value.
S string
}
// DefaultHost holds details about calls to the DefaultHost method.
DefaultHost []struct {
}
// Get holds details about calls to the Get method.
Get []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
}
// GetOrDefault holds details about calls to the GetOrDefault method.
GetOrDefault []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
}
// Hosts holds details about calls to the Hosts method.
Hosts []struct {
}
// Set holds details about calls to the Set method.
Set []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
// S3 is the s3 argument value.
S3 string
}
// UnsetHost holds details about calls to the UnsetHost method.
UnsetHost []struct {
// S is the s argument value.
S string
}
// Write holds details about calls to the Write method.
Write []struct {
}
}
lockAliases sync.RWMutex
lockAuthToken sync.RWMutex
lockDefaultHost sync.RWMutex
lockGet sync.RWMutex
lockGetOrDefault sync.RWMutex
lockHosts sync.RWMutex
lockSet sync.RWMutex
lockUnsetHost sync.RWMutex
lockWrite sync.RWMutex
}
// Aliases calls AliasesFunc.
func (mock *ConfigMock) Aliases() *AliasConfig {
if mock.AliasesFunc == nil {
panic("ConfigMock.AliasesFunc: method is nil but Config.Aliases was just called")
}
callInfo := struct {
}{}
mock.lockAliases.Lock()
mock.calls.Aliases = append(mock.calls.Aliases, callInfo)
mock.lockAliases.Unlock()
return mock.AliasesFunc()
}
// AliasesCalls gets all the calls that were made to Aliases.
// Check the length with:
//
// len(mockedConfig.AliasesCalls())
func (mock *ConfigMock) AliasesCalls() []struct {
} {
var calls []struct {
}
mock.lockAliases.RLock()
calls = mock.calls.Aliases
mock.lockAliases.RUnlock()
return calls
}
// AuthToken calls AuthTokenFunc.
func (mock *ConfigMock) AuthToken(s string) (string, string) {
if mock.AuthTokenFunc == nil {
panic("ConfigMock.AuthTokenFunc: method is nil but Config.AuthToken was just called")
}
callInfo := struct {
S string
}{
S: s,
}
mock.lockAuthToken.Lock()
mock.calls.AuthToken = append(mock.calls.AuthToken, callInfo)
mock.lockAuthToken.Unlock()
return mock.AuthTokenFunc(s)
}
// AuthTokenCalls gets all the calls that were made to AuthToken.
// Check the length with:
//
// len(mockedConfig.AuthTokenCalls())
func (mock *ConfigMock) AuthTokenCalls() []struct {
S string
} {
var calls []struct {
S string
}
mock.lockAuthToken.RLock()
calls = mock.calls.AuthToken
mock.lockAuthToken.RUnlock()
return calls
}
// DefaultHost calls DefaultHostFunc.
func (mock *ConfigMock) DefaultHost() (string, string) {
if mock.DefaultHostFunc == nil {
panic("ConfigMock.DefaultHostFunc: method is nil but Config.DefaultHost was just called")
}
callInfo := struct {
}{}
mock.lockDefaultHost.Lock()
mock.calls.DefaultHost = append(mock.calls.DefaultHost, callInfo)
mock.lockDefaultHost.Unlock()
return mock.DefaultHostFunc()
}
// DefaultHostCalls gets all the calls that were made to DefaultHost.
// Check the length with:
//
// len(mockedConfig.DefaultHostCalls())
func (mock *ConfigMock) DefaultHostCalls() []struct {
} {
var calls []struct {
}
mock.lockDefaultHost.RLock()
calls = mock.calls.DefaultHost
mock.lockDefaultHost.RUnlock()
return calls
}
// Get calls GetFunc.
func (mock *ConfigMock) Get(s1 string, s2 string) (string, error) {
if mock.GetFunc == nil {
panic("ConfigMock.GetFunc: method is nil but Config.Get was just called")
}
callInfo := struct {
S1 string
S2 string
}{
S1: s1,
S2: s2,
}
mock.lockGet.Lock()
mock.calls.Get = append(mock.calls.Get, callInfo)
mock.lockGet.Unlock()
return mock.GetFunc(s1, s2)
}
// GetCalls gets all the calls that were made to Get.
// Check the length with:
//
// len(mockedConfig.GetCalls())
func (mock *ConfigMock) GetCalls() []struct {
S1 string
S2 string
} {
var calls []struct {
S1 string
S2 string
}
mock.lockGet.RLock()
calls = mock.calls.Get
mock.lockGet.RUnlock()
return calls
}
// GetOrDefault calls GetOrDefaultFunc.
func (mock *ConfigMock) GetOrDefault(s1 string, s2 string) (string, error) {
if mock.GetOrDefaultFunc == nil {
panic("ConfigMock.GetOrDefaultFunc: method is nil but Config.GetOrDefault was just called")
}
callInfo := struct {
S1 string
S2 string
}{
S1: s1,
S2: s2,
}
mock.lockGetOrDefault.Lock()
mock.calls.GetOrDefault = append(mock.calls.GetOrDefault, callInfo)
mock.lockGetOrDefault.Unlock()
return mock.GetOrDefaultFunc(s1, s2)
}
// GetOrDefaultCalls gets all the calls that were made to GetOrDefault.
// Check the length with:
//
// len(mockedConfig.GetOrDefaultCalls())
func (mock *ConfigMock) GetOrDefaultCalls() []struct {
S1 string
S2 string
} {
var calls []struct {
S1 string
S2 string
}
mock.lockGetOrDefault.RLock()
calls = mock.calls.GetOrDefault
mock.lockGetOrDefault.RUnlock()
return calls
}
// Hosts calls HostsFunc.
func (mock *ConfigMock) Hosts() []string {
if mock.HostsFunc == nil {
panic("ConfigMock.HostsFunc: method is nil but Config.Hosts was just called")
}
callInfo := struct {
}{}
mock.lockHosts.Lock()
mock.calls.Hosts = append(mock.calls.Hosts, callInfo)
mock.lockHosts.Unlock()
return mock.HostsFunc()
}
// HostsCalls gets all the calls that were made to Hosts.
// Check the length with:
//
// len(mockedConfig.HostsCalls())
func (mock *ConfigMock) HostsCalls() []struct {
} {
var calls []struct {
}
mock.lockHosts.RLock()
calls = mock.calls.Hosts
mock.lockHosts.RUnlock()
return calls
}
// Set calls SetFunc.
func (mock *ConfigMock) Set(s1 string, s2 string, s3 string) {
if mock.SetFunc == nil {
panic("ConfigMock.SetFunc: method is nil but Config.Set was just called")
}
callInfo := struct {
S1 string
S2 string
S3 string
}{
S1: s1,
S2: s2,
S3: s3,
}
mock.lockSet.Lock()
mock.calls.Set = append(mock.calls.Set, callInfo)
mock.lockSet.Unlock()
mock.SetFunc(s1, s2, s3)
}
// SetCalls gets all the calls that were made to Set.
// Check the length with:
//
// len(mockedConfig.SetCalls())
func (mock *ConfigMock) SetCalls() []struct {
S1 string
S2 string
S3 string
} {
var calls []struct {
S1 string
S2 string
S3 string
}
mock.lockSet.RLock()
calls = mock.calls.Set
mock.lockSet.RUnlock()
return calls
}
// UnsetHost calls UnsetHostFunc.
func (mock *ConfigMock) UnsetHost(s string) {
if mock.UnsetHostFunc == nil {
panic("ConfigMock.UnsetHostFunc: method is nil but Config.UnsetHost was just called")
}
callInfo := struct {
S string
}{
S: s,
}
mock.lockUnsetHost.Lock()
mock.calls.UnsetHost = append(mock.calls.UnsetHost, callInfo)
mock.lockUnsetHost.Unlock()
mock.UnsetHostFunc(s)
}
// UnsetHostCalls gets all the calls that were made to UnsetHost.
// Check the length with:
//
// len(mockedConfig.UnsetHostCalls())
func (mock *ConfigMock) UnsetHostCalls() []struct {
S string
} {
var calls []struct {
S string
}
mock.lockUnsetHost.RLock()
calls = mock.calls.UnsetHost
mock.lockUnsetHost.RUnlock()
return calls
}
// Write calls WriteFunc.
func (mock *ConfigMock) Write() error {
if mock.WriteFunc == nil {
panic("ConfigMock.WriteFunc: method is nil but Config.Write was just called")
}
callInfo := struct {
}{}
mock.lockWrite.Lock()
mock.calls.Write = append(mock.calls.Write, callInfo)
mock.lockWrite.Unlock()
return mock.WriteFunc()
}
// WriteCalls gets all the calls that were made to Write.
// Check the length with:
//
// len(mockedConfig.WriteCalls())
func (mock *ConfigMock) WriteCalls() []struct {
} {
var calls []struct {
}
mock.lockWrite.RLock()
calls = mock.calls.Write
mock.lockWrite.RUnlock()
return calls
}

View file

@ -1,218 +0,0 @@
package config
import (
"fmt"
"gopkg.in/yaml.v3"
)
// This interface describes interacting with some persistent configuration for gh.
type Config interface {
Get(string, string) (string, error)
GetOrDefault(string, string) (string, error)
GetWithSource(string, string) (string, string, error)
GetOrDefaultWithSource(string, string) (string, string, error)
Default(string) string
Set(string, string, string) error
UnsetHost(string)
Hosts() ([]string, error)
DefaultHost() (string, error)
DefaultHostWithSource() (string, string, error)
Aliases() (*AliasConfig, error)
CheckWriteable(string, string) error
Write() error
WriteHosts() error
}
type ConfigOption struct {
Key string
Description string
DefaultValue string
AllowedValues []string
}
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: "",
},
{
Key: "http_unix_socket",
Description: "the path to a Unix socket through which to make an HTTP connection",
DefaultValue: "",
},
{
Key: "browser",
Description: "the web browser to use for opening URLs",
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}
}
func NewConfig(root *yaml.Node) Config {
return &fileConfig{
ConfigMap: ConfigMap{Root: root.Content[0]},
documentRoot: root,
}
}
// NewFromString initializes a Config from a yaml string
func NewFromString(str string) Config {
root, err := parseConfigData([]byte(str))
if err != nil {
panic(err)
}
return NewConfig(root)
}
// NewBlankConfig initializes a config file pre-populated with comments and default values
func NewBlankConfig() Config {
return NewConfig(NewBlankRoot())
}
func NewBlankRoot() *yaml.Node {
return &yaml.Node{
Kind: yaml.DocumentNode,
Content: []*yaml.Node{
{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{
HeadComment: "What protocol to use when performing git operations. Supported values: ssh, https",
Kind: yaml.ScalarNode,
Value: "git_protocol",
},
{
Kind: yaml.ScalarNode,
Value: "https",
},
{
HeadComment: "What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.",
Kind: yaml.ScalarNode,
Value: "editor",
},
{
Kind: yaml.ScalarNode,
Value: "",
},
{
HeadComment: "When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled",
Kind: yaml.ScalarNode,
Value: "prompt",
},
{
Kind: yaml.ScalarNode,
Value: "enabled",
},
{
HeadComment: "A pager program to send command output to, e.g. \"less\". Set the value to \"cat\" to disable the pager.",
Kind: yaml.ScalarNode,
Value: "pager",
},
{
Kind: yaml.ScalarNode,
Value: "",
},
{
HeadComment: "Aliases allow you to create nicknames for gh commands",
Kind: yaml.ScalarNode,
Value: "aliases",
},
{
Kind: yaml.MappingNode,
Content: []*yaml.Node{
{
Kind: yaml.ScalarNode,
Value: "co",
},
{
Kind: yaml.ScalarNode,
Value: "pr checkout",
},
},
},
{
HeadComment: "The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.",
Kind: yaml.ScalarNode,
Value: "http_unix_socket",
},
{
Kind: yaml.ScalarNode,
Value: "",
},
{
HeadComment: "What web browser gh should use when opening URLs. If blank, will refer to environment.",
Kind: yaml.ScalarNode,
Value: "browser",
},
{
Kind: yaml.ScalarNode,
Value: "",
},
},
},
},
}
}

View file

@ -1,118 +0,0 @@
package config
import (
"bytes"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/stretchr/testify/assert"
)
func Test_fileConfig_Set(t *testing.T) {
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer StubWriteConfig(&mainBuf, &hostsBuf)()
c := NewBlankConfig()
assert.NoError(t, c.Set("", "editor", "nano"))
assert.NoError(t, c.Set("github.com", "git_protocol", "ssh"))
assert.NoError(t, c.Set("example.com", "editor", "vim"))
assert.NoError(t, c.Set("github.com", "user", "hubot"))
assert.NoError(t, c.Write())
assert.Contains(t, mainBuf.String(), "editor: nano")
assert.Contains(t, mainBuf.String(), "git_protocol: https")
assert.Equal(t, `github.com:
git_protocol: ssh
user: hubot
example.com:
editor: vim
`, hostsBuf.String())
}
func Test_defaultConfig(t *testing.T) {
mainBuf := bytes.Buffer{}
hostsBuf := bytes.Buffer{}
defer StubWriteConfig(&mainBuf, &hostsBuf)()
cfg := NewBlankConfig()
assert.NoError(t, cfg.Write())
expected := heredoc.Doc(`
# What protocol to use when performing git operations. Supported values: ssh, https
git_protocol: https
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
editor:
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
prompt: enabled
# A pager program to send command output to, e.g. "less". Set the value to "cat" to disable the pager.
pager:
# Aliases allow you to create nicknames for gh commands
aliases:
co: pr checkout
# The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
http_unix_socket:
# What web browser gh should use when opening URLs. If blank, will refer to environment.
browser:
`)
assert.Equal(t, expected, mainBuf.String())
assert.Equal(t, "", hostsBuf.String())
proto, err := cfg.GetOrDefault("", "git_protocol")
assert.NoError(t, err)
assert.Equal(t, "https", proto)
editor, err := cfg.Get("", "editor")
assert.NoError(t, err)
assert.Equal(t, "", editor)
aliases, err := cfg.Aliases()
assert.NoError(t, err)
assert.Equal(t, len(aliases.All()), 1)
expansion, _ := aliases.Get("co")
assert.Equal(t, expansion, "pr checkout")
browser, err := cfg.Get("", "browser")
assert.NoError(t, err)
assert.Equal(t, "", browser)
}
func Test_ValidateValue(t *testing.T) {
err := ValidateValue("git_protocol", "sshpps")
assert.EqualError(t, err, "invalid value")
err = ValidateValue("git_protocol", "ssh")
assert.NoError(t, err)
err = ValidateValue("editor", "vim")
assert.NoError(t, err)
err = ValidateValue("got", "123")
assert.NoError(t, err)
err = ValidateValue("http_unix_socket", "really_anything/is/allowed/and/net.Dial\\(...\\)/will/ultimately/validate")
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)
err = ValidateKey("http_unix_socket")
assert.NoError(t, err)
err = ValidateKey("browser")
assert.NoError(t, err)
}

View file

@ -1,156 +0,0 @@
package config
import (
"fmt"
"os"
"sort"
"strconv"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/set"
)
const (
GH_HOST = "GH_HOST"
GH_TOKEN = "GH_TOKEN"
GITHUB_TOKEN = "GITHUB_TOKEN"
GH_ENTERPRISE_TOKEN = "GH_ENTERPRISE_TOKEN"
GITHUB_ENTERPRISE_TOKEN = "GITHUB_ENTERPRISE_TOKEN"
CODESPACES = "CODESPACES"
)
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}
}
type envConfig struct {
Config
}
func (c *envConfig) Hosts() ([]string, error) {
hosts, err := c.Config.Hosts()
if err != nil {
return nil, err
}
hostSet := set.NewStringSet()
hostSet.AddValues(hosts)
// If GH_HOST is set then add it to list.
if host := os.Getenv(GH_HOST); host != "" {
hostSet.Add(host)
}
// If there is a valid environment variable token for the
// default host then add default host to list.
if token, _ := AuthTokenFromEnv(ghinstance.Default()); token != "" {
hostSet.Add(ghinstance.Default())
}
s := hostSet.ToSlice()
// If default host is in list then move it to the front.
sort.SliceStable(s, func(i, j int) bool { return s[i] == ghinstance.Default() })
return s, nil
}
func (c *envConfig) DefaultHost() (string, error) {
val, _, err := c.DefaultHostWithSource()
return val, err
}
func (c *envConfig) DefaultHostWithSource() (string, string, error) {
if host := os.Getenv(GH_HOST); host != "" {
return host, GH_HOST, nil
}
return c.Config.DefaultHostWithSource()
}
func (c *envConfig) Get(hostname, key string) (string, error) {
val, _, err := c.GetWithSource(hostname, key)
return val, err
}
func (c *envConfig) GetWithSource(hostname, key string) (string, string, error) {
if hostname != "" && key == "oauth_token" {
if token, env := AuthTokenFromEnv(hostname); token != "" {
return token, env, nil
}
}
return c.Config.GetWithSource(hostname, key)
}
func (c *envConfig) GetOrDefault(hostname, key string) (val string, err error) {
val, _, err = c.GetOrDefaultWithSource(hostname, key)
return
}
func (c *envConfig) GetOrDefaultWithSource(hostname, key string) (val string, src string, err error) {
val, src, err = c.GetWithSource(hostname, key)
if err == nil && val == "" {
val = c.Default(key)
}
return
}
func (c *envConfig) Default(key string) string {
return c.Config.Default(key)
}
func (c *envConfig) CheckWriteable(hostname, key string) error {
if hostname != "" && key == "oauth_token" {
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
}
if token := os.Getenv(GITHUB_ENTERPRISE_TOKEN); token != "" {
return token, GITHUB_ENTERPRISE_TOKEN
}
if isCodespaces, _ := strconv.ParseBool(os.Getenv(CODESPACES)); isCodespaces {
return os.Getenv(GITHUB_TOKEN), GITHUB_TOKEN
}
return "", ""
}
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) != ""
}
func IsHostEnv(src string) bool {
return src == GH_HOST
}
func IsEnterpriseEnv(src string) bool {
return src == GH_ENTERPRISE_TOKEN || src == GITHUB_ENTERPRISE_TOKEN
}

View file

@ -1,389 +0,0 @@
package config
import (
"os"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/stretchr/testify/assert"
)
func setenv(t *testing.T, key, newValue string) {
oldValue, hasValue := os.LookupEnv(key)
os.Setenv(key, newValue)
t.Cleanup(func() {
if hasValue {
os.Setenv(key, oldValue)
} else {
os.Unsetenv(key)
}
})
}
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")
orig_AppData := os.Getenv("AppData")
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)
os.Setenv("AppData", orig_AppData)
})
type wants struct {
hosts []string
token string
source string
writeable bool
}
tests := []struct {
name string
baseConfig string
GH_HOST string
GITHUB_TOKEN string
GITHUB_ENTERPRISE_TOKEN string
GH_TOKEN string
GH_ENTERPRISE_TOKEN string
CODESPACES string
hostname string
wants wants
}{
{
name: "blank",
baseConfig: ``,
hostname: "github.com",
wants: wants{
hosts: []string{},
token: "",
source: ".config.gh.config.yml",
writeable: true,
},
},
{
name: "GITHUB_TOKEN over blank config",
baseConfig: ``,
GITHUB_TOKEN: "OTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
source: "GITHUB_TOKEN",
writeable: false,
},
},
{
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: "",
source: ".config.gh.config.yml",
writeable: true,
},
},
{
name: "GITHUB_TOKEN allowed in Codespaces",
baseConfig: ``,
GITHUB_TOKEN: "OTOKEN",
hostname: "example.org",
CODESPACES: "true",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
source: "GITHUB_TOKEN",
writeable: false,
},
},
{
name: "GITHUB_ENTERPRISE_TOKEN over blank config",
baseConfig: ``,
GITHUB_ENTERPRISE_TOKEN: "ENTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{},
token: "ENTOKEN",
source: "GITHUB_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "GH_ENTERPRISE_TOKEN over blank config",
baseConfig: ``,
GH_ENTERPRISE_TOKEN: "ENTOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{},
token: "ENTOKEN",
source: "GH_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "token from file",
baseConfig: heredoc.Doc(`
hosts:
github.com:
oauth_token: OTOKEN
`),
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "OTOKEN",
source: ".config.gh.hosts.yml",
writeable: true,
},
},
{
name: "GITHUB_TOKEN shadows token from file",
baseConfig: heredoc.Doc(`
hosts:
github.com:
oauth_token: OTOKEN
`),
GITHUB_TOKEN: "ENVTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com"},
token: "ENVTOKEN",
source: "GITHUB_TOKEN",
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{},
token: "GHTOKEN",
source: "GH_ENTERPRISE_TOKEN",
writeable: false,
},
},
{
name: "GITHUB_TOKEN adds host entry",
baseConfig: heredoc.Doc(`
hosts:
example.org:
oauth_token: OTOKEN
`),
GITHUB_TOKEN: "ENVTOKEN",
hostname: "github.com",
wants: wants{
hosts: []string{"github.com", "example.org"},
token: "ENVTOKEN",
source: "GITHUB_TOKEN",
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,
},
},
{
name: "GH_HOST adds host entry when paired with environment token",
baseConfig: ``,
GH_HOST: "example.org",
GH_ENTERPRISE_TOKEN: "GH_ENTERPRISE_TOKEN",
hostname: "example.org",
wants: wants{
hosts: []string{"example.org"},
token: "GH_ENTERPRISE_TOKEN",
source: "GH_ENTERPRISE_TOKEN",
writeable: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
setenv(t, "GH_HOST", tt.GH_HOST)
setenv(t, "GITHUB_TOKEN", tt.GITHUB_TOKEN)
setenv(t, "GITHUB_ENTERPRISE_TOKEN", tt.GITHUB_ENTERPRISE_TOKEN)
setenv(t, "GH_TOKEN", tt.GH_TOKEN)
setenv(t, "GH_ENTERPRISE_TOKEN", tt.GH_ENTERPRISE_TOKEN)
setenv(t, "AppData", "")
setenv(t, "CODESPACES", tt.CODESPACES)
baseCfg := NewFromString(tt.baseConfig)
cfg := InheritEnv(baseCfg)
hosts, _ := cfg.Hosts()
assert.Equal(t, tt.wants.hosts, hosts)
val, source, _ := cfg.GetWithSource(tt.hostname, "oauth_token")
assert.Equal(t, tt.wants.token, val)
assert.Regexp(t, tt.wants.source, source)
val, _ = cfg.Get(tt.hostname, "oauth_token")
assert.Equal(t, tt.wants.token, val)
err := cfg.CheckWriteable(tt.hostname, "oauth_token")
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())
})
}
}

View file

@ -1,342 +0,0 @@
package config
import (
"bytes"
"errors"
"fmt"
"sort"
"strings"
"github.com/cli/cli/v2/internal/ghinstance"
"gopkg.in/yaml.v3"
)
// This type implements a Config interface and represents a config file on disk.
type fileConfig struct {
ConfigMap
documentRoot *yaml.Node
}
type HostConfig struct {
ConfigMap
Host string
}
func (c *fileConfig) Root() *yaml.Node {
return c.ConfigMap.Root
}
func (c *fileConfig) Get(hostname, key string) (string, error) {
val, _, err := c.GetWithSource(hostname, key)
return val, err
}
func (c *fileConfig) GetWithSource(hostname, key string) (string, string, error) {
if hostname != "" {
var notFound *NotFoundError
hostCfg, err := c.configForHost(hostname)
if err != nil && !errors.As(err, &notFound) {
return "", "", err
}
var hostValue string
if hostCfg != nil {
hostValue, err = hostCfg.GetStringValue(key)
if err != nil && !errors.As(err, &notFound) {
return "", "", err
}
}
if hostValue != "" {
return hostValue, HostsConfigFile(), nil
}
}
defaultSource := ConfigFile()
value, err := c.GetStringValue(key)
var notFound *NotFoundError
if err != nil && errors.As(err, &notFound) {
return defaultFor(key), defaultSource, nil
} else if err != nil {
return "", defaultSource, err
}
return value, defaultSource, nil
}
func (c *fileConfig) GetOrDefault(hostname, key string) (val string, err error) {
val, _, err = c.GetOrDefaultWithSource(hostname, key)
return
}
func (c *fileConfig) GetOrDefaultWithSource(hostname, key string) (val string, src string, err error) {
val, src, err = c.GetWithSource(hostname, key)
if err != nil && val == "" {
val = c.Default(key)
}
return
}
func (c *fileConfig) Default(key string) string {
return defaultFor(key)
}
func (c *fileConfig) Set(hostname, key, value string) error {
if hostname == "" {
return c.SetStringValue(key, value)
} else {
hostCfg, err := c.configForHost(hostname)
var notFound *NotFoundError
if errors.As(err, &notFound) {
hostCfg = c.makeConfigForHost(hostname)
} else if err != nil {
return err
}
return hostCfg.SetStringValue(key, value)
}
}
func (c *fileConfig) UnsetHost(hostname string) {
if hostname == "" {
return
}
hostsEntry, err := c.FindEntry("hosts")
if err != nil {
return
}
cm := ConfigMap{hostsEntry.ValueNode}
cm.RemoveEntry(hostname)
}
func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
hosts, err := c.hostEntries()
if err != nil {
return nil, err
}
for _, hc := range hosts {
if strings.EqualFold(hc.Host, hostname) {
return hc, nil
}
}
return nil, &NotFoundError{fmt.Errorf("could not find config entry for %q", hostname)}
}
func (c *fileConfig) CheckWriteable(hostname, key string) error {
// TODO: check filesystem permissions
return nil
}
func (c *fileConfig) Write() error {
mainData := yaml.Node{Kind: yaml.MappingNode}
nodes := c.documentRoot.Content[0].Content
for i := 0; i < len(nodes)-1; i += 2 {
if nodes[i].Value != "hosts" {
mainData.Content = append(mainData.Content, nodes[i], nodes[i+1])
}
}
mainBytes, err := yaml.Marshal(&mainData)
if err != nil {
return err
}
err = WriteConfigFile(ConfigFile(), yamlNormalize(mainBytes))
if err != nil {
return err
}
return c.WriteHosts()
}
// Write the hosts config file only, so as to allow logging in when the main
// config file is not writable.
func (c *fileConfig) WriteHosts() error {
hostsData := yaml.Node{Kind: yaml.MappingNode}
nodes := c.documentRoot.Content[0].Content
for i := 0; i < len(nodes)-1; i += 2 {
if nodes[i].Value == "hosts" {
hostsData.Content = append(hostsData.Content, nodes[i+1].Content...)
}
}
hostsBytes, err := yaml.Marshal(&hostsData)
if err != nil {
return err
}
return WriteConfigFile(HostsConfigFile(), yamlNormalize(hostsBytes))
}
func (c *fileConfig) Aliases() (*AliasConfig, error) {
// The complexity here is for dealing with either a missing or empty aliases key. It's something
// we'll likely want for other config sections at some point.
entry, err := c.FindEntry("aliases")
var nfe *NotFoundError
notFound := errors.As(err, &nfe)
if err != nil && !notFound {
return nil, err
}
toInsert := []*yaml.Node{}
keyNode := entry.KeyNode
valueNode := entry.ValueNode
if keyNode == nil {
keyNode = &yaml.Node{
Kind: yaml.ScalarNode,
Value: "aliases",
}
toInsert = append(toInsert, keyNode)
}
if valueNode == nil || valueNode.Kind != yaml.MappingNode {
valueNode = &yaml.Node{
Kind: yaml.MappingNode,
Value: "",
}
toInsert = append(toInsert, valueNode)
}
if len(toInsert) > 0 {
newContent := []*yaml.Node{}
if notFound {
newContent = append(c.Root().Content, keyNode, valueNode)
} else {
for i := 0; i < len(c.Root().Content); i++ {
if i == entry.Index {
newContent = append(newContent, keyNode, valueNode)
i++
} else {
newContent = append(newContent, c.Root().Content[i])
}
}
}
c.Root().Content = newContent
}
return &AliasConfig{
Parent: c,
ConfigMap: ConfigMap{Root: valueNode},
}, nil
}
func (c *fileConfig) hostEntries() ([]*HostConfig, error) {
entry, err := c.FindEntry("hosts")
if err != nil {
return []*HostConfig{}, nil
}
hostConfigs, err := c.parseHosts(entry.ValueNode)
if err != nil {
return nil, fmt.Errorf("could not parse hosts config: %w", err)
}
return hostConfigs, nil
}
// Hosts returns a list of all known hostnames configured in hosts.yml
func (c *fileConfig) Hosts() ([]string, error) {
entries, err := c.hostEntries()
if err != nil {
return nil, err
}
hostnames := []string{}
for _, entry := range entries {
hostnames = append(hostnames, entry.Host)
}
sort.SliceStable(hostnames, func(i, j int) bool { return hostnames[i] == ghinstance.Default() })
return hostnames, nil
}
func (c *fileConfig) DefaultHost() (string, error) {
val, _, err := c.DefaultHostWithSource()
return val, err
}
func (c *fileConfig) DefaultHostWithSource() (string, string, error) {
hosts, err := c.Hosts()
if err == nil && len(hosts) == 1 {
return hosts[0], HostsConfigFile(), nil
}
return ghinstance.Default(), "", nil
}
func (c *fileConfig) makeConfigForHost(hostname string) *HostConfig {
hostRoot := &yaml.Node{Kind: yaml.MappingNode}
hostCfg := &HostConfig{
Host: hostname,
ConfigMap: ConfigMap{Root: hostRoot},
}
var notFound *NotFoundError
hostsEntry, err := c.FindEntry("hosts")
if errors.As(err, &notFound) {
hostsEntry.KeyNode = &yaml.Node{
Kind: yaml.ScalarNode,
Value: "hosts",
}
hostsEntry.ValueNode = &yaml.Node{Kind: yaml.MappingNode}
root := c.Root()
root.Content = append(root.Content, hostsEntry.KeyNode, hostsEntry.ValueNode)
} else if err != nil {
panic(err)
}
hostsEntry.ValueNode.Content = append(hostsEntry.ValueNode.Content,
&yaml.Node{
Kind: yaml.ScalarNode,
Value: hostname,
}, hostRoot)
return hostCfg
}
func (c *fileConfig) parseHosts(hostsEntry *yaml.Node) ([]*HostConfig, error) {
hostConfigs := []*HostConfig{}
for i := 0; i < len(hostsEntry.Content)-1; i = i + 2 {
hostname := hostsEntry.Content[i].Value
hostRoot := hostsEntry.Content[i+1]
hostConfig := HostConfig{
ConfigMap: ConfigMap{Root: hostRoot},
Host: hostname,
}
hostConfigs = append(hostConfigs, &hostConfig)
}
if len(hostConfigs) == 0 {
return nil, errors.New("could not find any host configurations")
}
return hostConfigs, nil
}
func yamlNormalize(b []byte) []byte {
if bytes.Equal(b, []byte("{}\n")) {
return []byte{}
}
return b
}
func defaultFor(key string) string {
for _, co := range configOptions {
if co.Key == key {
return co.DefaultValue
}
}
return ""
}

View file

@ -1,15 +0,0 @@
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func Test_fileConfig_Hosts(t *testing.T) {
c := NewBlankConfig()
hosts, err := c.Hosts()
require.NoError(t, err)
assert.Equal(t, []string{}, hosts)
}

View file

@ -1,80 +1,105 @@
package config
import (
"errors"
"io"
"os"
"path/filepath"
"testing"
ghConfig "github.com/cli/go-gh/pkg/config"
)
type ConfigStub map[string]string
func NewBlankConfig() *ConfigMock {
defaultStr := `
# What protocol to use when performing git operations. Supported values: ssh, https
git_protocol: https
# What editor gh should run when creating issues, pull requests, etc. If blank, will refer to environment.
editor:
# When to interactively prompt. This is a global config that cannot be overridden by hostname. Supported values: enabled, disabled
prompt: enabled
# A pager program to send command output to, e.g. "less". Set the value to "cat" to disable the pager.
pager:
# Aliases allow you to create nicknames for gh commands
aliases:
co: pr checkout
# The path to a unix socket through which send HTTP connections. If blank, HTTP traffic will be handled by net/http.DefaultTransport.
http_unix_socket:
# What web browser gh should use when opening URLs. If blank, will refer to environment.
browser:
`
return NewFromString(defaultStr)
}
func genKey(host, key string) string {
if host != "" {
return host + ":" + key
func NewFromString(cfgStr string) *ConfigMock {
c := ghConfig.ReadFromString(cfgStr)
cfg := cfg{c}
mock := &ConfigMock{}
mock.AuthTokenFunc = func(host string) (string, string) {
token, _ := c.Get([]string{"hosts", host, "oauth_token"})
return token, "oauth_token"
}
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
mock.GetFunc = func(host, key string) (string, error) {
return cfg.Get(host, key)
}
return "", "", errors.New("not found")
}
func (c ConfigStub) GetOrDefault(hostname, key string) (val string, err error) {
val, _, err = c.GetOrDefaultWithSource(hostname, key)
return
}
func (c ConfigStub) GetOrDefaultWithSource(hostname, key string) (val string, src string, err error) {
val, src, err = c.GetWithSource(hostname, key)
if err == nil && val == "" {
val = c.Default(key)
mock.GetOrDefaultFunc = func(host, key string) (string, error) {
return cfg.GetOrDefault(host, key)
}
return
mock.SetFunc = func(host, key, value string) {
cfg.Set(host, key, value)
}
mock.UnsetHostFunc = func(host string) {
cfg.UnsetHost(host)
}
mock.HostsFunc = func() []string {
keys, _ := c.Keys([]string{"hosts"})
return keys
}
mock.DefaultHostFunc = func() (string, string) {
return "github.com", "default"
}
mock.AliasesFunc = func() *AliasConfig {
return &AliasConfig{cfg: c}
}
mock.WriteFunc = func() error {
return cfg.Write()
}
return mock
}
func (c ConfigStub) Default(key string) string {
return defaultFor(key)
}
// StubWriteConfig stubs out the filesystem where config file are written.
// It then returns a function that will read in the config files into io.Writers.
// It automatically cleans up environment variables and written files.
func StubWriteConfig(t *testing.T) func(io.Writer, io.Writer) {
t.Helper()
tempDir := t.TempDir()
t.Setenv("GH_CONFIG_DIR", tempDir)
return func(wc io.Writer, wh io.Writer) {
config, err := os.Open(filepath.Join(tempDir, "config.yml"))
if err != nil {
return
}
defer config.Close()
configData, err := io.ReadAll(config)
if err != nil {
return
}
_, err = wc.Write(configData)
if err != nil {
return
}
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
}
func (c ConfigStub) WriteHosts() error {
return nil
}
func (c ConfigStub) DefaultHost() (string, error) {
return "", nil
}
func (c ConfigStub) DefaultHostWithSource() (string, string, error) {
return "", "", nil
hosts, err := os.Open(filepath.Join(tempDir, "hosts.yml"))
if err != nil {
return
}
defer hosts.Close()
hostsData, err := io.ReadAll(hosts)
if err != nil {
return
}
_, err = wh.Write(hostsData)
if err != nil {
return
}
}
}

View file

@ -1,64 +0,0 @@
package config
import (
"fmt"
"io"
"os"
"path/filepath"
)
func StubBackupConfig() func() {
orig := BackupConfigFile
BackupConfigFile = func(_ string) error {
return nil
}
return func() {
BackupConfigFile = orig
}
}
func StubWriteConfig(wc io.Writer, wh io.Writer) func() {
orig := WriteConfigFile
WriteConfigFile = func(fn string, data []byte) error {
switch filepath.Base(fn) {
case "config.yml":
_, err := wc.Write(data)
return err
case "hosts.yml":
_, err := wh.Write(data)
return err
default:
return fmt.Errorf("write to unstubbed file: %q", fn)
}
}
return func() {
WriteConfigFile = orig
}
}
func stubConfig(main, hosts string) func() {
orig := ReadConfigFile
ReadConfigFile = func(fn string) ([]byte, error) {
switch filepath.Base(fn) {
case "config.yml":
if main == "" {
return []byte(nil), os.ErrNotExist
} else {
return []byte(main), nil
}
case "hosts.yml":
if hosts == "" {
return []byte(nil), os.ErrNotExist
} else {
return []byte(hosts), nil
}
default:
return []byte(nil), fmt.Errorf("read from unstubbed file: %q", fn)
}
}
return func() {
ReadConfigFile = orig
}
}

View file

@ -85,6 +85,7 @@ func GenMarkdown(cmd *cobra.Command, w io.Writer) error {
// GenMarkdownCustom creates custom markdown output.
func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string) string) error {
fmt.Fprint(w, "{% raw %}")
fmt.Fprintf(w, "## %s\n\n", cmd.CommandPath())
hasLong := cmd.Long != ""
@ -112,6 +113,7 @@ func GenMarkdownCustom(cmd *cobra.Command, w io.Writer, linkHandler func(string)
if err := printOptions(w, cmd); err != nil {
return err
}
fmt.Fprint(w, "{% endraw %}\n")
if len(cmd.Example) > 0 {
fmt.Fprint(w, "### Examples\n\n{% highlight bash %}{% raw %}\n")
@ -190,7 +192,7 @@ func GenMarkdownTree(cmd *cobra.Command, dir string) error {
return GenMarkdownTreeCustom(cmd, dir, emptyStr, identity)
}
// GenMarkdownTreeCustom is the the same as GenMarkdownTree, but
// GenMarkdownTreeCustom is 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() {

View file

@ -1,13 +1,10 @@
package featuredetection
import (
"context"
"net/http"
"time"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/internal/ghinstance"
graphql "github.com/cli/shurcooL-graphql"
)
type Detector interface {
@ -16,32 +13,42 @@ type Detector interface {
RepositoryFeatures() (RepositoryFeatures, error)
}
type IssueFeatures struct{}
type IssueFeatures struct {
StateReason bool
}
var allIssueFeatures = IssueFeatures{}
var allIssueFeatures = IssueFeatures{
StateReason: true,
}
type PullRequestFeatures struct {
ReviewDecision bool
StatusCheckRollup bool
BranchProtectionRule bool
MergeQueue bool
}
var allPullRequestFeatures = PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
}
type RepositoryFeatures struct {
IssueTemplateMutation bool
IssueTemplateQuery bool
PullRequestTemplateQuery bool
VisibilityField bool
AutoMerge bool
}
var allRepositoryFeatures = RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
}
type detector struct {
@ -50,9 +57,8 @@ type detector struct {
}
func NewDetector(httpClient *http.Client, host string) Detector {
cachedClient := api.NewCachedClient(httpClient, time.Hour*48)
return &detector{
httpClient: cachedClient,
httpClient: httpClient,
host: host,
}
}
@ -62,15 +68,68 @@ func (d *detector) IssueFeatures() (IssueFeatures, error) {
return allIssueFeatures, nil
}
return allIssueFeatures, nil
features := IssueFeatures{
StateReason: false,
}
var featureDetection struct {
Issue struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"Issue: __type(name: \"Issue\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "Issue_fields", &featureDetection, nil)
if err != nil {
return features, err
}
for _, field := range featureDetection.Issue.Fields {
if field.Name == "stateReason" {
features.StateReason = true
}
}
return features, nil
}
func (d *detector) PullRequestFeatures() (PullRequestFeatures, error) {
if !ghinstance.IsEnterprise(d.host) {
return allPullRequestFeatures, nil
// TODO: reinstate the short-circuit once the APIs are fully available on github.com
// https://github.com/cli/cli/issues/5778
//
// if !ghinstance.IsEnterprise(d.host) {
// return allPullRequestFeatures, nil
// }
features := PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
}
return allPullRequestFeatures, nil
var featureDetection struct {
PullRequest struct {
Fields []struct {
Name string
} `graphql:"fields(includeDeprecated: true)"`
} `graphql:"PullRequest: __type(name: \"PullRequest\")"`
}
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.Query(d.host, "PullRequest_fields", &featureDetection, nil)
if err != nil {
return features, err
}
for _, field := range featureDetection.PullRequest.Fields {
if field.Name == "isInMergeQueue" {
features.MergeQueue = true
}
}
return features, nil
}
func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
@ -91,9 +150,9 @@ func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
} `graphql:"Repository: __type(name: \"Repository\")"`
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(d.host), d.httpClient)
gql := api.NewClientFromHTTP(d.httpClient)
err := gql.QueryNamed(context.Background(), "Repository_fields", &featureDetection, nil)
err := gql.Query(d.host, "Repository_fields", &featureDetection, nil)
if err != nil {
return features, err
}
@ -102,6 +161,12 @@ func (d *detector) RepositoryFeatures() (RepositoryFeatures, error) {
if field.Name == "pullRequestTemplates" {
features.PullRequestTemplateQuery = true
}
if field.Name == "visibility" {
features.VisibilityField = true
}
if field.Name == "autoMergeAllowed" {
features.AutoMerge = true
}
}
return features, nil

View file

@ -1,14 +1,78 @@
package featuredetection
import (
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/v2/api"
"github.com/cli/cli/v2/pkg/httpmock"
"github.com/stretchr/testify/assert"
)
func TestIssueFeatures(t *testing.T) {
tests := []struct {
name string
hostname string
queryResponse map[string]string
wantFeatures IssueFeatures
wantErr bool
}{
{
name: "github.com",
hostname: "github.com",
wantFeatures: IssueFeatures{
StateReason: true,
},
wantErr: false,
},
{
name: "GHE empty response",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Issue_fields\b`: `{"data": {}}`,
},
wantFeatures: IssueFeatures{
StateReason: false,
},
wantErr: false,
},
{
name: "GHE has state reason field",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Issue_fields\b`: heredoc.Doc(`
{ "data": { "Issue": { "fields": [
{"name": "stateReason"}
] } } }
`),
},
wantFeatures: IssueFeatures{
StateReason: true,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotFeatures, err := detector.IssueFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}
func TestPullRequestFeatures(t *testing.T) {
tests := []struct {
name string
@ -20,39 +84,75 @@ func TestPullRequestFeatures(t *testing.T) {
{
name: "github.com",
hostname: "github.com",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "PullRequest": { "fields": [
{"name": "isInMergeQueue"},
{"name": "isMergeQueueEnabled"}
] } } }
`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
},
wantErr: false,
},
{
name: "github.com with no merge queue",
hostname: "github.com",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "PullRequest": { "fields": [
] } } }
`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: false,
},
wantErr: false,
},
{
name: "GHE",
hostname: "git.my.org",
queryResponse: map[string]string{
`query PullRequest_fields\b`: heredoc.Doc(`
{ "data": { "PullRequest": { "fields": [
{"name": "isInMergeQueue"},
{"name": "isMergeQueueEnabled"}
] } } }
`),
},
wantFeatures: PullRequestFeatures{
ReviewDecision: true,
StatusCheckRollup: true,
BranchProtectionRule: true,
MergeQueue: true,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeHTTP := &httpmock.Registry{}
httpClient := api.NewHTTPClient(api.ReplaceTripper(fakeHTTP))
reg := &httpmock.Registry{}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
fakeHTTP.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotPrFeatures, err := detector.PullRequestFeatures()
gotFeatures, err := detector.PullRequestFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotPrFeatures)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}
@ -72,6 +172,8 @@ func TestRepositoryFeatures(t *testing.T) {
IssueTemplateMutation: true,
IssueTemplateQuery: true,
PullRequestTemplateQuery: true,
VisibilityField: true,
AutoMerge: true,
},
wantErr: false,
},
@ -105,23 +207,58 @@ func TestRepositoryFeatures(t *testing.T) {
},
wantErr: false,
},
{
name: "GHE has visibility field",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Repository_fields\b`: heredoc.Doc(`
{ "data": { "Repository": { "fields": [
{"name": "visibility"}
] } } }
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
VisibilityField: true,
},
wantErr: false,
},
{
name: "GHE has automerge field",
hostname: "git.my.org",
queryResponse: map[string]string{
`query Repository_fields\b`: heredoc.Doc(`
{ "data": { "Repository": { "fields": [
{"name": "autoMergeAllowed"}
] } } }
`),
},
wantFeatures: RepositoryFeatures{
IssueTemplateMutation: true,
IssueTemplateQuery: true,
AutoMerge: true,
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fakeHTTP := &httpmock.Registry{}
httpClient := api.NewHTTPClient(api.ReplaceTripper(fakeHTTP))
reg := &httpmock.Registry{}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
for query, resp := range tt.queryResponse {
fakeHTTP.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
reg.Register(httpmock.GraphQL(query), httpmock.StringResponse(resp))
}
detector := detector{host: tt.hostname, httpClient: httpClient}
gotPrFeatures, err := detector.RepositoryFeatures()
gotFeatures, err := detector.RepositoryFeatures()
if tt.wantErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantFeatures, gotPrFeatures)
assert.Equal(t, tt.wantFeatures, gotFeatures)
})
}
}

View file

@ -22,6 +22,10 @@ func IsEnterprise(h string) bool {
return normalizedHostName != defaultHostname && normalizedHostName != localhost
}
func isGarage(h string) bool {
return strings.EqualFold(h, "garage.github.com")
}
// NormalizeHostname returns the canonical host name of a GitHub instance
func NormalizeHostname(h string) string {
hostname := strings.ToLower(h)
@ -36,12 +40,7 @@ func NormalizeHostname(h string) string {
return hostname
}
func HostnameValidator(v interface{}) error {
hostname, valid := v.(string)
if !valid {
return errors.New("hostname is not a string")
}
func HostnameValidator(hostname string) error {
if len(strings.TrimSpace(hostname)) < 1 {
return errors.New("a value is required")
}
@ -52,6 +51,9 @@ func HostnameValidator(v interface{}) error {
}
func GraphQLEndpoint(hostname string) string {
if isGarage(hostname) {
return fmt.Sprintf("https://%s/api/graphql", hostname)
}
if IsEnterprise(hostname) {
return fmt.Sprintf("https://%s/api/graphql", hostname)
}
@ -62,6 +64,9 @@ func GraphQLEndpoint(hostname string) string {
}
func RESTPrefix(hostname string) string {
if isGarage(hostname) {
return fmt.Sprintf("https://%s/api/v3/", hostname)
}
if IsEnterprise(hostname) {
return fmt.Sprintf("https://%s/api/v3/", hostname)
}
@ -82,6 +87,9 @@ func GistPrefix(hostname string) string {
}
func GistHost(hostname string) string {
if isGarage(hostname) {
return fmt.Sprintf("%s/gist/", hostname)
}
if IsEnterprise(hostname) {
return fmt.Sprintf("%s/gist/", hostname)
}

View file

@ -27,6 +27,10 @@ func TestIsEnterprise(t *testing.T) {
host: "api.github.localhost",
want: false,
},
{
host: "garage.github.com",
want: false,
},
{
host: "ghe.io",
want: true,
@ -74,6 +78,10 @@ func TestNormalizeHostname(t *testing.T) {
host: "api.github.localhost",
want: "github.localhost",
},
{
host: "garage.github.com",
want: "github.com",
},
{
host: "GHE.IO",
want: "ghe.io",
@ -95,7 +103,7 @@ func TestNormalizeHostname(t *testing.T) {
func TestHostnameValidator(t *testing.T) {
tests := []struct {
name string
input interface{}
input string
wantsErr bool
}{
{
@ -118,11 +126,6 @@ func TestHostnameValidator(t *testing.T) {
input: "internal.instance:2205",
wantsErr: true,
},
{
name: "non-string hostname",
input: 62,
wantsErr: true,
},
}
for _, tt := range tests {
@ -149,6 +152,10 @@ func TestGraphQLEndpoint(t *testing.T) {
host: "github.localhost",
want: "http://api.github.localhost/graphql",
},
{
host: "garage.github.com",
want: "https://garage.github.com/api/graphql",
},
{
host: "ghe.io",
want: "https://ghe.io/api/graphql",
@ -176,6 +183,10 @@ func TestRESTPrefix(t *testing.T) {
host: "github.localhost",
want: "http://api.github.localhost/",
},
{
host: "garage.github.com",
want: "https://garage.github.com/api/v3/",
},
{
host: "ghe.io",
want: "https://ghe.io/api/v3/",

View file

@ -5,8 +5,9 @@ import (
"net/url"
"strings"
"github.com/cli/cli/v2/git"
"github.com/cli/cli/v2/internal/ghinstance"
ghAuth "github.com/cli/go-gh/pkg/auth"
"github.com/cli/go-gh/pkg/repository"
)
// Interface describes an object that represents a GitHub repository
@ -35,19 +36,9 @@ func FullName(r Interface) string {
return fmt.Sprintf("%s/%s", r.RepoOwner(), r.RepoName())
}
var defaultHostOverride string
func defaultHost() string {
if defaultHostOverride != "" {
return defaultHostOverride
}
return ghinstance.Default()
}
// SetDefaultHost overrides the default GitHub hostname for FromFullName.
// TODO: remove after FromFullName approach is revisited
func SetDefaultHost(host string) {
defaultHostOverride = host
host, _ := ghAuth.DefaultHost()
return host
}
// FromFullName extracts the GitHub repository information from the following
@ -59,28 +50,11 @@ func FromFullName(nwo string) (Interface, error) {
// FromFullNameWithHost is like FromFullName that defaults to a specific host for values that don't
// explicitly include a hostname.
func FromFullNameWithHost(nwo, fallbackHost string) (Interface, error) {
if git.IsURL(nwo) {
u, err := git.ParseURL(nwo)
if err != nil {
return nil, err
}
return FromURL(u)
}
parts := strings.SplitN(nwo, "/", 4)
for _, p := range parts {
if len(p) == 0 {
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
}
}
switch len(parts) {
case 3:
return NewWithHost(parts[1], parts[2], parts[0]), nil
case 2:
return NewWithHost(parts[0], parts[1], fallbackHost), nil
default:
return nil, fmt.Errorf(`expected the "[HOST/]OWNER/REPO" format, got %q`, nwo)
repo, err := repository.ParseWithHost(nwo, fallbackHost)
if err != nil {
return nil, err
}
return NewWithHost(repo.Owner(), repo.Name(), repo.Host()), nil
}
// FromURL extracts the GitHub repository information from a git remote URL

View file

@ -194,7 +194,7 @@ func TestFromFullName(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.hostOverride != "" {
SetDefaultHost(tt.hostOverride)
t.Setenv("GH_HOST", tt.hostOverride)
}
r, err := FromFullName(tt.input)
if tt.wantErr != nil {

View file

@ -1,21 +0,0 @@
// package httpunix provides an http.RoundTripper which dials a server via a unix socket.
package httpunix
import (
"net"
"net/http"
)
// NewRoundTripper returns an http.RoundTripper which sends requests via a unix
// socket at socketPath.
func NewRoundTripper(socketPath string) http.RoundTripper {
dial := func(network, addr string) (net.Conn, error) {
return net.Dial("unix", socketPath)
}
return &http.Transport{
Dial: dial,
DialTLS: dial,
DisableKeepAlives: true,
}
}

View file

@ -0,0 +1,168 @@
package prompter
import (
"fmt"
"io"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/v2/internal/ghinstance"
"github.com/cli/cli/v2/pkg/surveyext"
)
//go:generate moq -rm -out prompter_mock.go . Prompter
type Prompter interface {
Select(string, string, []string) (int, error)
MultiSelect(string, string, []string) (int, error)
Input(string, string) (string, error)
InputHostname() (string, error)
Password(string) (string, error)
AuthToken() (string, error)
Confirm(string, bool) (bool, error)
ConfirmDeletion(string) error
MarkdownEditor(string, string, bool) (string, error)
}
type fileWriter interface {
io.Writer
Fd() uintptr
}
type fileReader interface {
io.Reader
Fd() uintptr
}
func New(editorCmd string, stdin fileReader, stdout fileWriter, stderr io.Writer) Prompter {
return &surveyPrompter{
editorCmd: editorCmd,
stdin: stdin,
stdout: stdout,
stderr: stderr,
}
}
type surveyPrompter struct {
editorCmd string
stdin fileReader
stdout fileWriter
stderr io.Writer
}
func (p *surveyPrompter) Select(message, defaultValue string, options []string) (result int, err error) {
q := &survey.Select{
Message: message,
Options: options,
PageSize: 20,
}
if defaultValue != "" {
q.Default = defaultValue
}
err = p.ask(q, &result)
return
}
func (p *surveyPrompter) MultiSelect(message, defaultValue string, options []string) (result int, err error) {
q := &survey.MultiSelect{
Message: message,
Options: options,
PageSize: 20,
}
if defaultValue != "" {
q.Default = defaultValue
}
err = p.ask(q, &result)
return
}
func (p *surveyPrompter) ask(q survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
opts = append(opts, survey.WithStdio(p.stdin, p.stdout, p.stderr))
err := survey.AskOne(q, response, opts...)
if err == nil {
return nil
}
return fmt.Errorf("could not prompt: %w", err)
}
func (p *surveyPrompter) Input(prompt, defaultValue string) (result string, err error) {
err = p.ask(&survey.Input{
Message: prompt,
Default: defaultValue,
}, &result)
return
}
func (p *surveyPrompter) ConfirmDeletion(requiredValue string) error {
var result string
return p.ask(
&survey.Input{
Message: fmt.Sprintf("Type %s to confirm deletion:", requiredValue),
},
&result,
survey.WithValidator(
func(val interface{}) error {
if str := val.(string); !strings.EqualFold(str, requiredValue) {
return fmt.Errorf("You entered %s", str)
}
return nil
}))
}
func (p *surveyPrompter) InputHostname() (result string, err error) {
err = p.ask(
&survey.Input{
Message: "GHE hostname:",
}, &result, survey.WithValidator(func(v interface{}) error {
return ghinstance.HostnameValidator(v.(string))
}))
return
}
func (p *surveyPrompter) Password(prompt string) (result string, err error) {
err = p.ask(&survey.Password{
Message: prompt,
}, &result)
return
}
func (p *surveyPrompter) Confirm(prompt string, defaultValue bool) (result bool, err error) {
err = p.ask(&survey.Confirm{
Message: prompt,
Default: defaultValue,
}, &result)
return
}
func (p *surveyPrompter) MarkdownEditor(message, defaultValue string, blankAllowed bool) (result string, err error) {
err = p.ask(&surveyext.GhEditor{
BlankAllowed: blankAllowed,
EditorCommand: p.editorCmd,
Editor: &survey.Editor{
Message: message,
Default: defaultValue,
FileName: "*.md",
HideDefault: true,
AppendDefault: true,
},
}, &result)
return
}
func (p *surveyPrompter) AuthToken() (result string, err error) {
err = p.ask(&survey.Password{
Message: "Paste your authentication token:",
}, &result, survey.WithValidator(survey.Required))
return
}

View file

@ -0,0 +1,460 @@
// Code generated by moq; DO NOT EDIT.
// github.com/matryer/moq
package prompter
import (
"sync"
)
// Ensure, that PrompterMock does implement Prompter.
// If this is not the case, regenerate this file with moq.
var _ Prompter = &PrompterMock{}
// PrompterMock is a mock implementation of Prompter.
//
// func TestSomethingThatUsesPrompter(t *testing.T) {
//
// // make and configure a mocked Prompter
// mockedPrompter := &PrompterMock{
// AuthTokenFunc: func() (string, error) {
// panic("mock out the AuthToken method")
// },
// ConfirmFunc: func(s string, b bool) (bool, error) {
// panic("mock out the Confirm method")
// },
// ConfirmDeletionFunc: func(s string) error {
// panic("mock out the ConfirmDeletion method")
// },
// InputFunc: func(s1 string, s2 string) (string, error) {
// panic("mock out the Input method")
// },
// InputHostnameFunc: func() (string, error) {
// panic("mock out the InputHostname method")
// },
// MarkdownEditorFunc: func(s1 string, s2 string, b bool) (string, error) {
// panic("mock out the MarkdownEditor method")
// },
// MultiSelectFunc: func(s1 string, s2 string, strings []string) (int, error) {
// panic("mock out the MultiSelect method")
// },
// PasswordFunc: func(s string) (string, error) {
// panic("mock out the Password method")
// },
// SelectFunc: func(s1 string, s2 string, strings []string) (int, error) {
// panic("mock out the Select method")
// },
// }
//
// // use mockedPrompter in code that requires Prompter
// // and then make assertions.
//
// }
type PrompterMock struct {
// AuthTokenFunc mocks the AuthToken method.
AuthTokenFunc func() (string, error)
// ConfirmFunc mocks the Confirm method.
ConfirmFunc func(s string, b bool) (bool, error)
// ConfirmDeletionFunc mocks the ConfirmDeletion method.
ConfirmDeletionFunc func(s string) error
// InputFunc mocks the Input method.
InputFunc func(s1 string, s2 string) (string, error)
// InputHostnameFunc mocks the InputHostname method.
InputHostnameFunc func() (string, error)
// MarkdownEditorFunc mocks the MarkdownEditor method.
MarkdownEditorFunc func(s1 string, s2 string, b bool) (string, error)
// MultiSelectFunc mocks the MultiSelect method.
MultiSelectFunc func(s1 string, s2 string, strings []string) (int, error)
// PasswordFunc mocks the Password method.
PasswordFunc func(s string) (string, error)
// SelectFunc mocks the Select method.
SelectFunc func(s1 string, s2 string, strings []string) (int, error)
// calls tracks calls to the methods.
calls struct {
// AuthToken holds details about calls to the AuthToken method.
AuthToken []struct {
}
// Confirm holds details about calls to the Confirm method.
Confirm []struct {
// S is the s argument value.
S string
// B is the b argument value.
B bool
}
// ConfirmDeletion holds details about calls to the ConfirmDeletion method.
ConfirmDeletion []struct {
// S is the s argument value.
S string
}
// Input holds details about calls to the Input method.
Input []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
}
// InputHostname holds details about calls to the InputHostname method.
InputHostname []struct {
}
// MarkdownEditor holds details about calls to the MarkdownEditor method.
MarkdownEditor []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
// B is the b argument value.
B bool
}
// MultiSelect holds details about calls to the MultiSelect method.
MultiSelect []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
// Strings is the strings argument value.
Strings []string
}
// Password holds details about calls to the Password method.
Password []struct {
// S is the s argument value.
S string
}
// Select holds details about calls to the Select method.
Select []struct {
// S1 is the s1 argument value.
S1 string
// S2 is the s2 argument value.
S2 string
// Strings is the strings argument value.
Strings []string
}
}
lockAuthToken sync.RWMutex
lockConfirm sync.RWMutex
lockConfirmDeletion sync.RWMutex
lockInput sync.RWMutex
lockInputHostname sync.RWMutex
lockMarkdownEditor sync.RWMutex
lockMultiSelect sync.RWMutex
lockPassword sync.RWMutex
lockSelect sync.RWMutex
}
// AuthToken calls AuthTokenFunc.
func (mock *PrompterMock) AuthToken() (string, error) {
if mock.AuthTokenFunc == nil {
panic("PrompterMock.AuthTokenFunc: method is nil but Prompter.AuthToken was just called")
}
callInfo := struct {
}{}
mock.lockAuthToken.Lock()
mock.calls.AuthToken = append(mock.calls.AuthToken, callInfo)
mock.lockAuthToken.Unlock()
return mock.AuthTokenFunc()
}
// AuthTokenCalls gets all the calls that were made to AuthToken.
// Check the length with:
//
// len(mockedPrompter.AuthTokenCalls())
func (mock *PrompterMock) AuthTokenCalls() []struct {
} {
var calls []struct {
}
mock.lockAuthToken.RLock()
calls = mock.calls.AuthToken
mock.lockAuthToken.RUnlock()
return calls
}
// Confirm calls ConfirmFunc.
func (mock *PrompterMock) Confirm(s string, b bool) (bool, error) {
if mock.ConfirmFunc == nil {
panic("PrompterMock.ConfirmFunc: method is nil but Prompter.Confirm was just called")
}
callInfo := struct {
S string
B bool
}{
S: s,
B: b,
}
mock.lockConfirm.Lock()
mock.calls.Confirm = append(mock.calls.Confirm, callInfo)
mock.lockConfirm.Unlock()
return mock.ConfirmFunc(s, b)
}
// ConfirmCalls gets all the calls that were made to Confirm.
// Check the length with:
//
// len(mockedPrompter.ConfirmCalls())
func (mock *PrompterMock) ConfirmCalls() []struct {
S string
B bool
} {
var calls []struct {
S string
B bool
}
mock.lockConfirm.RLock()
calls = mock.calls.Confirm
mock.lockConfirm.RUnlock()
return calls
}
// ConfirmDeletion calls ConfirmDeletionFunc.
func (mock *PrompterMock) ConfirmDeletion(s string) error {
if mock.ConfirmDeletionFunc == nil {
panic("PrompterMock.ConfirmDeletionFunc: method is nil but Prompter.ConfirmDeletion was just called")
}
callInfo := struct {
S string
}{
S: s,
}
mock.lockConfirmDeletion.Lock()
mock.calls.ConfirmDeletion = append(mock.calls.ConfirmDeletion, callInfo)
mock.lockConfirmDeletion.Unlock()
return mock.ConfirmDeletionFunc(s)
}
// ConfirmDeletionCalls gets all the calls that were made to ConfirmDeletion.
// Check the length with:
//
// len(mockedPrompter.ConfirmDeletionCalls())
func (mock *PrompterMock) ConfirmDeletionCalls() []struct {
S string
} {
var calls []struct {
S string
}
mock.lockConfirmDeletion.RLock()
calls = mock.calls.ConfirmDeletion
mock.lockConfirmDeletion.RUnlock()
return calls
}
// Input calls InputFunc.
func (mock *PrompterMock) Input(s1 string, s2 string) (string, error) {
if mock.InputFunc == nil {
panic("PrompterMock.InputFunc: method is nil but Prompter.Input was just called")
}
callInfo := struct {
S1 string
S2 string
}{
S1: s1,
S2: s2,
}
mock.lockInput.Lock()
mock.calls.Input = append(mock.calls.Input, callInfo)
mock.lockInput.Unlock()
return mock.InputFunc(s1, s2)
}
// InputCalls gets all the calls that were made to Input.
// Check the length with:
//
// len(mockedPrompter.InputCalls())
func (mock *PrompterMock) InputCalls() []struct {
S1 string
S2 string
} {
var calls []struct {
S1 string
S2 string
}
mock.lockInput.RLock()
calls = mock.calls.Input
mock.lockInput.RUnlock()
return calls
}
// InputHostname calls InputHostnameFunc.
func (mock *PrompterMock) InputHostname() (string, error) {
if mock.InputHostnameFunc == nil {
panic("PrompterMock.InputHostnameFunc: method is nil but Prompter.InputHostname was just called")
}
callInfo := struct {
}{}
mock.lockInputHostname.Lock()
mock.calls.InputHostname = append(mock.calls.InputHostname, callInfo)
mock.lockInputHostname.Unlock()
return mock.InputHostnameFunc()
}
// InputHostnameCalls gets all the calls that were made to InputHostname.
// Check the length with:
//
// len(mockedPrompter.InputHostnameCalls())
func (mock *PrompterMock) InputHostnameCalls() []struct {
} {
var calls []struct {
}
mock.lockInputHostname.RLock()
calls = mock.calls.InputHostname
mock.lockInputHostname.RUnlock()
return calls
}
// MarkdownEditor calls MarkdownEditorFunc.
func (mock *PrompterMock) MarkdownEditor(s1 string, s2 string, b bool) (string, error) {
if mock.MarkdownEditorFunc == nil {
panic("PrompterMock.MarkdownEditorFunc: method is nil but Prompter.MarkdownEditor was just called")
}
callInfo := struct {
S1 string
S2 string
B bool
}{
S1: s1,
S2: s2,
B: b,
}
mock.lockMarkdownEditor.Lock()
mock.calls.MarkdownEditor = append(mock.calls.MarkdownEditor, callInfo)
mock.lockMarkdownEditor.Unlock()
return mock.MarkdownEditorFunc(s1, s2, b)
}
// MarkdownEditorCalls gets all the calls that were made to MarkdownEditor.
// Check the length with:
//
// len(mockedPrompter.MarkdownEditorCalls())
func (mock *PrompterMock) MarkdownEditorCalls() []struct {
S1 string
S2 string
B bool
} {
var calls []struct {
S1 string
S2 string
B bool
}
mock.lockMarkdownEditor.RLock()
calls = mock.calls.MarkdownEditor
mock.lockMarkdownEditor.RUnlock()
return calls
}
// MultiSelect calls MultiSelectFunc.
func (mock *PrompterMock) MultiSelect(s1 string, s2 string, strings []string) (int, error) {
if mock.MultiSelectFunc == nil {
panic("PrompterMock.MultiSelectFunc: method is nil but Prompter.MultiSelect was just called")
}
callInfo := struct {
S1 string
S2 string
Strings []string
}{
S1: s1,
S2: s2,
Strings: strings,
}
mock.lockMultiSelect.Lock()
mock.calls.MultiSelect = append(mock.calls.MultiSelect, callInfo)
mock.lockMultiSelect.Unlock()
return mock.MultiSelectFunc(s1, s2, strings)
}
// MultiSelectCalls gets all the calls that were made to MultiSelect.
// Check the length with:
//
// len(mockedPrompter.MultiSelectCalls())
func (mock *PrompterMock) MultiSelectCalls() []struct {
S1 string
S2 string
Strings []string
} {
var calls []struct {
S1 string
S2 string
Strings []string
}
mock.lockMultiSelect.RLock()
calls = mock.calls.MultiSelect
mock.lockMultiSelect.RUnlock()
return calls
}
// Password calls PasswordFunc.
func (mock *PrompterMock) Password(s string) (string, error) {
if mock.PasswordFunc == nil {
panic("PrompterMock.PasswordFunc: method is nil but Prompter.Password was just called")
}
callInfo := struct {
S string
}{
S: s,
}
mock.lockPassword.Lock()
mock.calls.Password = append(mock.calls.Password, callInfo)
mock.lockPassword.Unlock()
return mock.PasswordFunc(s)
}
// PasswordCalls gets all the calls that were made to Password.
// Check the length with:
//
// len(mockedPrompter.PasswordCalls())
func (mock *PrompterMock) PasswordCalls() []struct {
S string
} {
var calls []struct {
S string
}
mock.lockPassword.RLock()
calls = mock.calls.Password
mock.lockPassword.RUnlock()
return calls
}
// Select calls SelectFunc.
func (mock *PrompterMock) Select(s1 string, s2 string, strings []string) (int, error) {
if mock.SelectFunc == nil {
panic("PrompterMock.SelectFunc: method is nil but Prompter.Select was just called")
}
callInfo := struct {
S1 string
S2 string
Strings []string
}{
S1: s1,
S2: s2,
Strings: strings,
}
mock.lockSelect.Lock()
mock.calls.Select = append(mock.calls.Select, callInfo)
mock.lockSelect.Unlock()
return mock.SelectFunc(s1, s2, strings)
}
// SelectCalls gets all the calls that were made to Select.
// Check the length with:
//
// len(mockedPrompter.SelectCalls())
func (mock *PrompterMock) SelectCalls() []struct {
S1 string
S2 string
Strings []string
} {
var calls []struct {
S1 string
S2 string
Strings []string
}
mock.lockSelect.RLock()
calls = mock.calls.Select
mock.lockSelect.RUnlock()
return calls
}

31
internal/prompter/test.go Normal file
View file

@ -0,0 +1,31 @@
package prompter
import (
"fmt"
"testing"
"github.com/stretchr/testify/assert"
)
// Test helpers
func IndexFor(options []string, answer string) (int, error) {
for ix, a := range options {
if a == answer {
return ix, nil
}
}
return -1, NoSuchAnswerErr(answer)
}
func AssertOptions(t *testing.T, expected, actual []string) {
assert.Equal(t, expected, actual)
}
func NoSuchAnswerErr(answer string) error {
return fmt.Errorf("no such answer '%s'", answer)
}
func NoSuchPromptErr(prompt string) error {
return fmt.Errorf("no such prompt '%s'", prompt)
}

View file

@ -2,6 +2,7 @@ package run
import (
"bytes"
"errors"
"fmt"
"io"
"os"
@ -33,16 +34,19 @@ func (c cmdWithStderr) Output() ([]byte, error) {
if isVerbose, _ := utils.IsDebugEnabled(); isVerbose {
_ = printArgs(os.Stderr, c.Cmd.Args)
}
if c.Cmd.Stderr != nil {
return c.Cmd.Output()
}
errStream := &bytes.Buffer{}
c.Cmd.Stderr = errStream
out, err := c.Cmd.Output()
if err != nil {
err = &CmdError{errStream, c.Cmd.Args, err}
if c.Cmd.Stderr != nil || err == nil {
return out, err
}
return out, err
cmdErr := &CmdError{
Args: c.Cmd.Args,
Err: err,
}
var exitError *exec.ExitError
if errors.As(err, &exitError) {
cmdErr.Stderr = bytes.NewBuffer(exitError.Stderr)
}
return out, cmdErr
}
func (c cmdWithStderr) Run() error {
@ -56,16 +60,20 @@ func (c cmdWithStderr) Run() error {
c.Cmd.Stderr = errStream
err := c.Cmd.Run()
if err != nil {
err = &CmdError{errStream, c.Cmd.Args, err}
err = &CmdError{
Args: c.Cmd.Args,
Err: err,
Stderr: errStream,
}
}
return err
}
// CmdError provides more visibility into why an exec.Cmd had failed
type CmdError struct {
Stderr *bytes.Buffer
Args []string
Err error
Stderr *bytes.Buffer
}
func (e CmdError) Error() string {
@ -76,6 +84,10 @@ func (e CmdError) Error() string {
return fmt.Sprintf("%s%s: %s", msg, e.Args[0], e.Err)
}
func (e CmdError) Unwrap() error {
return e.Err
}
func printArgs(w io.Writer, args []string) error {
if len(args) > 0 {
// print commands, but omit the full path to an executable

View file

@ -8,6 +8,10 @@ import (
"strings"
)
const (
gitAuthRE = `-c credential.helper= -c credential.helper=!"[^"]+" auth git-credential `
)
type T interface {
Helper()
Errorf(string, ...interface{})
@ -71,6 +75,9 @@ func (cs *CommandStubber) Register(pattern string, exitStatus int, output string
if len(pattern) < 1 {
panic("cannot use empty regexp pattern")
}
if strings.HasPrefix(pattern, "git") {
pattern = addGitAuthentication(pattern)
}
cs.stubs = append(cs.stubs, &commandStub{
pattern: regexp.MustCompile(pattern),
exitStatus: exitStatus,
@ -114,3 +121,13 @@ func (s *commandStub) Output() ([]byte, error) {
}
return []byte(s.stdout), nil
}
// Inject git authentication string for specific git commands.
func addGitAuthentication(s string) string {
pattern := regexp.MustCompile(`( fetch | pull | push | clone | remote add.+-f | submodule )`)
loc := pattern.FindStringIndex(s)
if loc == nil {
return s
}
return s[:loc[0]+1] + gitAuthRE + s[loc[0]+1:]
}

View file

@ -0,0 +1,52 @@
package tableprinter
import (
"strings"
"time"
"github.com/cli/cli/v2/internal/text"
"github.com/cli/cli/v2/pkg/iostreams"
"github.com/cli/go-gh/pkg/tableprinter"
)
type TablePrinter struct {
tableprinter.TablePrinter
isTTY bool
}
func (t *TablePrinter) HeaderRow(columns ...string) {
if !t.isTTY {
return
}
for _, col := range columns {
t.AddField(strings.ToUpper(col))
}
t.EndRow()
}
func (tp *TablePrinter) AddTimeField(t time.Time, c func(string) string) {
tf := t.Format(time.RFC3339)
if tp.isTTY {
// TODO: use a static time.Now
tf = text.FuzzyAgo(time.Now(), t)
}
tp.AddField(tf, tableprinter.WithColor(c))
}
var (
WithTruncate = tableprinter.WithTruncate
WithColor = tableprinter.WithColor
)
func New(ios *iostreams.IOStreams) *TablePrinter {
maxWidth := 80
isTTY := ios.IsStdoutTTY()
if isTTY {
maxWidth = ios.TerminalWidth()
}
tp := tableprinter.New(ios.Out, isTTY, maxWidth)
return &TablePrinter{
TablePrinter: tp,
isTTY: isTTY,
}
}

74
internal/text/text.go Normal file
View file

@ -0,0 +1,74 @@
package text
import (
"fmt"
"net/url"
"regexp"
"strings"
"time"
"github.com/cli/go-gh/pkg/text"
"golang.org/x/text/cases"
"golang.org/x/text/language"
)
var whitespaceRE = regexp.MustCompile(`\s+`)
func Indent(s, indent string) string {
return text.Indent(s, indent)
}
// Title returns a copy of the string s with all Unicode letters that begin words mapped to their Unicode title case.
func Title(s string) string {
c := cases.Title(language.English)
return c.String(s)
}
// RemoveExcessiveWhitespace returns a copy of the string s with excessive whitespace removed.
func RemoveExcessiveWhitespace(s string) string {
return whitespaceRE.ReplaceAllString(strings.TrimSpace(s), " ")
}
func DisplayWidth(s string) int {
return text.DisplayWidth(s)
}
func Truncate(maxWidth int, s string) string {
return text.Truncate(maxWidth, s)
}
func Pluralize(num int, thing string) string {
return text.Pluralize(num, thing)
}
func FuzzyAgo(a, b time.Time) string {
return text.RelativeTimeAgo(a, b)
}
// FuzzyAgoAbbr is an abbreviated version of FuzzyAgo. It returns a human readable string of the
// time duration between a and b that is estimated to the nearest unit of time.
func FuzzyAgoAbbr(a, b time.Time) string {
ago := a.Sub(b)
if ago < time.Hour {
return fmt.Sprintf("%d%s", int(ago.Minutes()), "m")
}
if ago < 24*time.Hour {
return fmt.Sprintf("%d%s", int(ago.Hours()), "h")
}
if ago < 30*24*time.Hour {
return fmt.Sprintf("%d%s", int(ago.Hours())/24, "d")
}
return b.Format("Jan _2, 2006")
}
// DisplayURL returns a copy of the string urlStr removing everything except the hostname and path.
// If there is an error parsing urlStr then urlStr is returned without modification.
func DisplayURL(urlStr string) string {
u, err := url.Parse(urlStr)
if err != nil {
return urlStr
}
return u.Hostname() + u.Path
}

View file

@ -0,0 +1,56 @@
package text
import (
"testing"
"time"
"github.com/stretchr/testify/assert"
)
func TestRemoveExcessiveWhitespace(t *testing.T) {
tests := []struct {
name string
input string
want string
}{
{
name: "nothing to remove",
input: "one two three",
want: "one two three",
},
{
name: "whitespace b-gone",
input: "\n one\n\t two three\r\n ",
want: "one two three",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := RemoveExcessiveWhitespace(tt.input)
assert.Equal(t, tt.want, got)
})
}
}
func TestFuzzyAgoAbbr(t *testing.T) {
const form = "2006-Jan-02 15:04:05"
now, _ := time.Parse(form, "2020-Nov-22 14:00:00")
cases := map[string]string{
"2020-Nov-22 14:00:00": "0m",
"2020-Nov-22 13:59:00": "1m",
"2020-Nov-22 13:30:00": "30m",
"2020-Nov-22 13:00:00": "1h",
"2020-Nov-22 02:00:00": "12h",
"2020-Nov-21 14:00:00": "1d",
"2020-Nov-07 14:00:00": "15d",
"2020-Oct-24 14:00:00": "29d",
"2020-Oct-23 14:00:00": "Oct 23, 2020",
"2019-Nov-22 14:00:00": "Nov 22, 2019",
}
for createdAt, expected := range cases {
d, err := time.Parse(form, createdAt)
assert.NoError(t, err)
fuzzy := FuzzyAgoAbbr(now, d)
assert.Equal(t, expected, fuzzy)
}
}

View file

@ -3,6 +3,7 @@ package update
import (
"fmt"
"log"
"net/http"
"os"
"testing"
@ -71,10 +72,12 @@ func TestCheckForUpdate(t *testing.T) {
for _, s := range scenarios {
t.Run(s.Name, func(t *testing.T) {
http := &httpmock.Registry{}
client := api.NewClient(api.ReplaceTripper(http))
reg := &httpmock.Registry{}
httpClient := &http.Client{}
httpmock.ReplaceTripper(httpClient, reg)
client := api.NewClientFromHTTP(httpClient)
http.Register(
reg.Register(
httpmock.REST("GET", "repos/OWNER/REPO/releases/latest"),
httpmock.StringResponse(fmt.Sprintf(`{
"tag_name": "%s",
@ -87,10 +90,10 @@ func TestCheckForUpdate(t *testing.T) {
t.Fatal(err)
}
if len(http.Requests) != 1 {
t.Fatalf("expected 1 HTTP request, got %d", len(http.Requests))
if len(reg.Requests) != 1 {
t.Fatalf("expected 1 HTTP request, got %d", len(reg.Requests))
}
requestPath := http.Requests[0].URL.Path
requestPath := reg.Requests[0].URL.Path
if requestPath != "/repos/OWNER/REPO/releases/latest" {
t.Errorf("HTTP path: %q", requestPath)
}

View file

@ -45,13 +45,10 @@ func deleteRun(opts *DeleteOptions) error {
return err
}
aliasCfg, err := cfg.Aliases()
if err != nil {
return fmt.Errorf("couldn't read aliases config: %w", err)
}
aliasCfg := cfg.Aliases()
expansion, ok := aliasCfg.Get(opts.Name)
if !ok {
expansion, err := aliasCfg.Get(opts.Name)
if err != nil {
return fmt.Errorf("no such alias %s", opts.Name)
}
@ -61,6 +58,11 @@ func deleteRun(opts *DeleteOptions) error {
return fmt.Errorf("failed to delete alias %s: %w", opts.Name, err)
}
err = cfg.Write()
if err != nil {
return err
}
if opts.IO.IsStdoutTTY() {
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.ErrOut, "%s Deleted alias %s; was %s\n", cs.SuccessIconWithColor(cs.Red), opts.Name, expansion)

View file

@ -15,6 +15,8 @@ import (
)
func TestAliasDelete(t *testing.T) {
_ = config.StubWriteConfig(t)
tests := []struct {
name string
config string
@ -48,8 +50,6 @@ func TestAliasDelete(t *testing.T) {
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer config.StubWriteConfig(io.Discard, io.Discard)()
cfg := config.NewFromString(tt.config)
ios, _, stdout, stderr := iostreams.Test()

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