Merge branch 'trunk' of https://github.com/cli/cli into feature/repo-with-gitignore-license

This commit is contained in:
Gowtham Munukutla 2021-06-16 09:27:53 +05:30
commit 7c8b6867f4
54 changed files with 2543 additions and 923 deletions

View file

@ -36,7 +36,7 @@ Run the new binary as:
Run tests with: `go test ./...`
See [project layout documentation](../project-layout.md) for information on where to find specific source files.
See [project layout documentation](../docs/project-layout.md) for information on where to find specific source files.
## Submitting a pull request

View file

@ -14,13 +14,12 @@ GitHub CLI is available for repositories hosted on GitHub.com and GitHub Enterpr
If anything feels off, or if you feel that some functionality is missing, please check out the [contributing page][contributing]. There you will find instructions for sharing your feedback, building the tool locally, and submitting pull requests to the project.
<!-- this anchor is linked to from elsewhere, so avoid renaming it -->
## Installation
### macOS
`gh` is available via [Homebrew][], [MacPorts][], and as a downloadable binary from the [releases page][].
`gh` is available via [Homebrew][], [MacPorts][], [Conda][], and as a downloadable binary from the [releases page][].
#### Homebrew
@ -34,24 +33,29 @@ If anything feels off, or if you feel that some functionality is missing, please
| ---------------------- | ---------------------------------------------- |
| `sudo port install gh` | `sudo port selfupdate && sudo port upgrade gh` |
#### Conda
| Install: | Upgrade: |
|------------------------------------------|-----------------------------------------|
| `conda install gh --channel conda-forge` | `conda update gh --channel conda-forge` |
Additional Conda installation options available on the [gh-feedstock page](https://github.com/conda-forge/gh-feedstock#installing-gh).
### Linux
`gh` is available via [Homebrew](#homebrew), and as downloadable binaries from the [releases page][].
`gh` is available via [Homebrew](#homebrew), [Conda](#Conda), and as downloadable binaries from the [releases page][].
For more information and distro-specific instructions, see the [Linux installation docs](./docs/install_linux.md).
### Windows
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], and as downloadable MSI.
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#Conda), and as downloadable MSI.
#### WinGet
| Install: | Upgrade: |
| ------------------- | --------------------|
| `winget install gh` | `winget install gh` |
<i>WinGet does not have a specialized `upgrade` command yet, but the `install` command should work for upgrading to a newer version of GitHub CLI.</i>
| `winget install gh` | `winget upgrade gh` |
#### scoop
@ -88,13 +92,13 @@ what an official GitHub CLI tool can look like with a fundamentally different de
tools bring GitHub to the terminal, `hub` behaves as a proxy to `git`, and `gh` is a standalone
tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
[manual]: https://cli.github.com/manual/
[Homebrew]: https://brew.sh
[MacPorts]: https://www.macports.org
[winget]: https://github.com/microsoft/winget-cli
[scoop]: https://scoop.sh
[Chocolatey]: https://chocolatey.org
[Conda]: https://docs.conda.io/en/latest/
[releases page]: https://github.com/cli/cli/releases/latest
[hub]: https://github.com/github/hub
[contributing]: ./.github/CONTRIBUTING.md

View file

@ -80,10 +80,20 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
case "reviewRequests":
requests := make([]interface{}, 0, len(pr.ReviewRequests.Nodes))
for _, req := range pr.ReviewRequests.Nodes {
if req.RequestedReviewer.TypeName == "" {
continue
r := req.RequestedReviewer
switch r.TypeName {
case "User":
requests = append(requests, map[string]string{
"__typename": r.TypeName,
"login": r.Login,
})
case "Team":
requests = append(requests, map[string]string{
"__typename": r.TypeName,
"name": r.Name,
"slug": r.LoginOrSlug(),
})
}
requests = append(requests, req.RequestedReviewer)
}
data[f] = &requests
default:

View file

@ -150,28 +150,33 @@ type PullRequestFile struct {
type ReviewRequests struct {
Nodes []struct {
RequestedReviewer struct {
TypeName string `json:"__typename"`
Login string `json:"login"`
Name string `json:"name"`
Slug string `json:"slug"`
Organization struct {
Login string `json:"login"`
}
}
RequestedReviewer RequestedReviewer
}
}
type RequestedReviewer struct {
TypeName string `json:"__typename"`
Login string `json:"login"`
Name string `json:"name"`
Slug string `json:"slug"`
Organization struct {
Login string `json:"login"`
} `json:"organization"`
}
func (r RequestedReviewer) LoginOrSlug() string {
if r.TypeName == teamTypeName {
return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
}
return r.Login
}
const teamTypeName = "Team"
func (r ReviewRequests) Logins() []string {
logins := make([]string, len(r.Nodes))
for i, a := range r.Nodes {
if a.RequestedReviewer.TypeName == teamTypeName {
logins[i] = fmt.Sprintf("%s/%s", a.RequestedReviewer.Organization.Login, a.RequestedReviewer.Slug)
} else {
logins[i] = a.RequestedReviewer.Login
}
for i, r := range r.Nodes {
logins[i] = r.RequestedReviewer.LoginOrSlug()
}
return logins
}
@ -391,7 +396,7 @@ func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOpti
// these are always necessary to find the PR for the current branch
fields.AddValues([]string{"isCrossRepository", "headRepositoryOwner", "headRefName"})
gr := PullRequestGraphQL(fields.ToSlice())
fragments = fmt.Sprintf("fragment pr on PullRequest{%[1]s}fragment prWithReviews on PullRequest{%[1]s}", gr)
fragments = fmt.Sprintf("fragment pr on PullRequest{%s}fragment prWithReviews on PullRequest{...pr}", gr)
} else {
var err error
fragments, err = pullRequestFragment(client.http, repo.RepoHost())
@ -526,67 +531,23 @@ func pullRequestFragment(httpClient *http.Client, hostname string) (string, erro
return "", err
}
var reviewsFragment string
if prFeatures.HasReviewDecision {
reviewsFragment = "reviewDecision"
fields := []string{
"number", "title", "state", "url", "isDraft", "isCrossRepository",
"requiresStrictStatusChecks", "headRefName", "headRepositoryOwner", "mergeStateStatus",
}
var statusesFragment string
if prFeatures.HasStatusCheckRollup {
statusesFragment = `
commits(last: 1) {
nodes {
commit {
statusCheckRollup {
contexts(last: 100) {
nodes {
...on StatusContext {
state
}
...on CheckRun {
conclusion
status
}
}
}
}
}
}
}
`
fields = append(fields, "statusCheckRollup")
}
var requiresStrictStatusChecks string
if prFeatures.HasBranchProtectionRule {
requiresStrictStatusChecks = `
baseRef {
branchProtectionRule {
requiresStrictStatusChecks
}
}`
var reviewFields []string
if prFeatures.HasReviewDecision {
reviewFields = append(reviewFields, "reviewDecision")
}
fragments := fmt.Sprintf(`
fragment pr on PullRequest {
number
title
state
url
headRefName
mergeStateStatus
headRepositoryOwner {
login
}
%s
isCrossRepository
isDraft
%s
}
fragment prWithReviews on PullRequest {
...pr
%s
}
`, requiresStrictStatusChecks, statusesFragment, reviewsFragment)
fragment pr on PullRequest {%s}
fragment prWithReviews on PullRequest {...pr,%s}
`, PullRequestGraphQL(fields), PullRequestGraphQL(reviewFields))
return fragments, nil
}

View file

@ -216,6 +216,8 @@ func PullRequestGraphQL(fields []string) string {
q = append(q, `commits(last:1){nodes{commit{oid}}}`)
case "commitsCount": // pseudo-field
q = append(q, `commits{totalCount}`)
case "requiresStrictStatusChecks": // pseudo-field
q = append(q, `baseRef{branchProtectionRule{requiresStrictStatusChecks}}`)
case "statusCheckRollup":
q = append(q, StatusCheckRollupGraphQL(""))
default:

View file

@ -21,6 +21,7 @@ import (
"github.com/cli/cli/internal/run"
"github.com/cli/cli/internal/update"
"github.com/cli/cli/pkg/cmd/alias/expand"
"github.com/cli/cli/pkg/cmd/extensions"
"github.com/cli/cli/pkg/cmd/factory"
"github.com/cli/cli/pkg/cmd/root"
"github.com/cli/cli/pkg/cmdutil"
@ -92,14 +93,6 @@ func mainRun() exitCode {
return exitError
}
if prompt, _ := cfg.Get("", "prompt"); prompt == "disabled" {
cmdFactory.IOStreams.SetNeverPrompt(true)
}
if pager, _ := cfg.Get("", "pager"); pager != "" {
cmdFactory.IOStreams.SetPager(pager)
}
// TODO: remove after FromFullName has been revisited
if host, err := cfg.DefaultHost(); err == nil {
ghrepo.SetDefaultHost(host)
@ -140,15 +133,27 @@ func mainRun() exitCode {
err = preparedCmd.Run()
if err != nil {
if ee, ok := err.(*exec.ExitError); ok {
return exitCode(ee.ExitCode())
var execError *exec.ExitError
if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
fmt.Fprintf(stderr, "failed to run external command: %s", err)
return exitError
}
return exitOK
} else if c, _, err := rootCmd.Traverse(expandedArgs); err == nil && c == rootCmd && len(expandedArgs) > 0 {
extensionManager := extensions.NewManager()
if found, err := extensionManager.Dispatch(expandedArgs, os.Stdin, os.Stdout, os.Stderr); err != nil {
var execError *exec.ExitError
if errors.As(err, &execError) {
return exitCode(execError.ExitCode())
}
fmt.Fprintf(stderr, "failed to run extension: %s", err)
return exitError
} else if found {
return exitOK
}
}
}
@ -264,7 +269,7 @@ func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) {
}
repo := updaterEnabled
stateFilePath := filepath.Join(config.ConfigDir(), "state.yml")
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
return update.CheckForUpdate(client, stateFilePath, repo, currentVersion)
}

View file

@ -75,7 +75,7 @@ and talk through which code gets run in order.
This task might be tricky. Typically, gh commands do things like look up information from the git repository
in the current directory, query the GitHub API, scan the user's `~/.ssh/config` file, clone or fetch git
repositories, etc. Naturally, none of these things should ever happen for real when running tests, unless
you are sure that any filesystem operations are stricly scoped to a location made for and maintained by the
you are sure that any filesystem operations are strictly scoped to a location made for and maintained by the
test itself. To avoid actually running things like making real API requests or shelling out to `git`
commands, we stub them. You should look at how that's done within some existing tests.

1
go.mod
View file

@ -11,6 +11,7 @@ require (
github.com/cli/oauth v0.8.0
github.com/cli/safeexec v1.0.0
github.com/cpuguy83/go-md2man/v2 v2.0.0
github.com/creack/pty v1.1.13
github.com/gabriel-vasile/mimetype v1.1.2
github.com/google/go-cmp v0.5.2
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510

2
go.sum
View file

@ -62,6 +62,8 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/creack/pty v1.1.13 h1:rTPnd/xocYRjutMfqide2zle1u96upp1gm6eUHKi7us=
github.com/creack/pty v1.1.13/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=

View file

@ -16,7 +16,10 @@ import (
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
@ -38,27 +41,118 @@ func ConfigDir() string {
}
// If the path does not exist try migrating config from default paths
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
autoMigrateConfigDir(path)
if !dirExists(path) {
_ = autoMigrateConfigDir(path)
}
return path
}
// State path precedence
// 1. XDG_CONFIG_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 paths (os.UserHomeDir, and homedir.Dir) for existing configs
// If configs exist then move them to newPath
// TODO: Remove support for homedir.Dir location in v2
func autoMigrateConfigDir(newPath string) {
func autoMigrateConfigDir(newPath string) error {
path, err := os.UserHomeDir()
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
migrateConfigDir(oldPath, newPath)
return
return migrateDir(oldPath, newPath)
}
path, err = homedir.Dir()
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
migrateConfigDir(oldPath, newPath)
return migrateDir(oldPath, newPath)
}
return errNotExist
}
// Check default paths (os.UserHomeDir, and homedir.Dir) for existing state file (state.yml)
// If state file exist then move it to newPath
// TODO: Remove support for homedir.Dir location in v2
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")
}
path, err = homedir.Dir()
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 {
@ -66,13 +160,9 @@ func dirExists(path string) bool {
return err == nil && f.IsDir()
}
var migrateConfigDir = func(oldPath, newPath string) {
if oldPath == newPath {
return
}
_ = os.MkdirAll(filepath.Dir(newPath), 0755)
_ = os.Rename(oldPath, newPath)
func fileExists(path string) bool {
f, err := os.Stat(path)
return err == nil && !f.IsDir()
}
func ConfigFile() string {

View file

@ -152,6 +152,8 @@ func Test_parseConfigFile(t *testing.T) {
}
func Test_ConfigDir(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
onlyWindows bool
@ -159,63 +161,63 @@ func Test_ConfigDir(t *testing.T) {
output string
}{
{
name: "no envVars",
name: "HOME/USERPROFILE specified",
env: map[string]string{
"GH_CONFIG_DIR": "",
"XDG_CONFIG_HOME": "",
"AppData": "",
"USERPROFILE": "",
"HOME": "",
"USERPROFILE": tempDir,
"HOME": tempDir,
},
output: ".config/gh",
output: filepath.Join(tempDir, ".config", "gh"),
},
{
name: "GH_CONFIG_DIR specified",
env: map[string]string{
"GH_CONFIG_DIR": "/tmp/gh_config_dir",
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
},
output: "/tmp/gh_config_dir",
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "XDG_CONFIG_HOME specified",
env: map[string]string{
"XDG_CONFIG_HOME": "/tmp",
"XDG_CONFIG_HOME": tempDir,
},
output: "/tmp/gh",
output: filepath.Join(tempDir, "gh"),
},
{
name: "GH_CONFIG_DIR and XDG_CONFIG_HOME specified",
env: map[string]string{
"GH_CONFIG_DIR": "/tmp/gh_config_dir",
"XDG_CONFIG_HOME": "/tmp",
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
"XDG_CONFIG_HOME": tempDir,
},
output: "/tmp/gh_config_dir",
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "AppData specified",
onlyWindows: true,
env: map[string]string{
"AppData": "/tmp/",
"AppData": tempDir,
},
output: "/tmp/GitHub CLI",
output: filepath.Join(tempDir, "GitHub CLI"),
},
{
name: "GH_CONFIG_DIR and AppData specified",
onlyWindows: true,
env: map[string]string{
"GH_CONFIG_DIR": "/tmp/gh_config_dir",
"AppData": "/tmp",
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
"AppData": tempDir,
},
output: "/tmp/gh_config_dir",
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "XDG_CONFIG_HOME and AppData specified",
onlyWindows: true,
env: map[string]string{
"XDG_CONFIG_HOME": "/tmp",
"AppData": "/tmp",
"XDG_CONFIG_HOME": tempDir,
"AppData": tempDir,
},
output: "/tmp/gh",
output: filepath.Join(tempDir, "gh"),
},
}
@ -227,22 +229,25 @@ func Test_ConfigDir(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
old := os.Getenv(k)
os.Setenv(k, filepath.FromSlash(v))
os.Setenv(k, v)
defer os.Setenv(k, old)
}
}
defer stubMigrateConfigDir()()
assert.Equal(t, filepath.FromSlash(tt.output), ConfigDir())
// 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)
defer stubMigrateConfigDir()()
cfg := NewFromString(`pager: less`)
err := cfg.Write()
@ -264,7 +269,8 @@ func Test_configFile_Write_toDisk(t *testing.T) {
}
}
func Test_autoMigrateConfigDir_noMigration(t *testing.T) {
func Test_autoMigrateConfigDir_noMigration_notExist(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeEnvVar := "HOME"
@ -272,10 +278,11 @@ func Test_autoMigrateConfigDir_noMigration(t *testing.T) {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, "/nonexistent-dir")
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
autoMigrateConfigDir(migrateDir)
err := autoMigrateConfigDir(migrateDir)
assert.Equal(t, errNotExist, err)
files, err := ioutil.ReadDir(migrateDir)
assert.NoError(t, err)
@ -283,17 +290,21 @@ func Test_autoMigrateConfigDir_noMigration(t *testing.T) {
}
func Test_autoMigrateConfigDir_noMigration_samePath(t *testing.T) {
migrateDir := t.TempDir()
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, migrateDir)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
autoMigrateConfigDir(migrateDir)
err = autoMigrateConfigDir(migrateDir)
assert.Equal(t, errSamePath, err)
files, err := ioutil.ReadDir(migrateDir)
assert.NoError(t, err)
@ -301,31 +312,240 @@ func Test_autoMigrateConfigDir_noMigration_samePath(t *testing.T) {
}
func Test_autoMigrateConfigDir_migration(t *testing.T) {
defaultDir := t.TempDir()
dd := filepath.Join(defaultDir, ".config", "gh")
homeDir := t.TempDir()
migrateDir := t.TempDir()
md := filepath.Join(migrateDir, ".config", "gh")
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, defaultDir)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := os.MkdirAll(dd, 0777)
err := os.MkdirAll(homeConfigDir, 0755)
assert.NoError(t, err)
f, err := ioutil.TempFile(dd, "")
f, err := ioutil.TempFile(homeConfigDir, "")
assert.NoError(t, err)
f.Close()
autoMigrateConfigDir(md)
err = autoMigrateConfigDir(migrateConfigDir)
assert.NoError(t, err)
_, err = ioutil.ReadDir(dd)
_, err = ioutil.ReadDir(homeConfigDir)
assert.True(t, os.IsNotExist(err))
files, err := ioutil.ReadDir(md)
files, err := ioutil.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 := ioutil.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 := ioutil.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 = ioutil.WriteFile(filepath.Join(homeConfigDir, "state.yml"), nil, 0755)
assert.NoError(t, err)
err = autoMigrateStateDir(migrateStateDir)
assert.NoError(t, err)
files, err := ioutil.ReadDir(homeConfigDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
files, err = ioutil.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

@ -62,11 +62,3 @@ func stubConfig(main, hosts string) func() {
ReadConfigFile = orig
}
}
func stubMigrateConfigDir() func() {
orig := migrateConfigDir
migrateConfigDir = func(_, _ string) {}
return func() {
migrateConfigDir = orig
}
}

View file

@ -3,6 +3,8 @@ package update
import (
"fmt"
"io/ioutil"
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
@ -83,9 +85,14 @@ func setStateEntry(stateFilePath string, t time.Time, r ReleaseInfo) error {
if err != nil {
return err
}
_ = ioutil.WriteFile(stateFilePath, content, 0600)
return nil
err = os.MkdirAll(filepath.Dir(stateFilePath), 0755)
if err != nil {
return err
}
err = ioutil.WriteFile(stateFilePath, content, 0600)
return err
}
func versionGreaterThan(v, w string) bool {

View file

@ -0,0 +1,77 @@
package extensions
import (
"errors"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/spf13/cobra"
)
func NewCmdExtensions(io *iostreams.IOStreams) *cobra.Command {
m := NewManager()
extCmd := cobra.Command{
Use: "extensions",
Short: "Manage gh extensions",
}
extCmd.AddCommand(
&cobra.Command{
Use: "list",
Short: "List installed extension commands",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
cmds := m.List()
if len(cmds) == 0 {
return errors.New("no extensions installed")
}
for _, c := range cmds {
name := filepath.Base(c)
parts := strings.SplitN(name, "-", 2)
fmt.Fprintf(io.Out, "%s %s\n", parts[0], parts[1])
}
return nil
},
},
&cobra.Command{
Use: "install <repo>",
Short: "Install a gh extension from a repository",
Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"),
RunE: func(cmd *cobra.Command, args []string) error {
if args[0] == "." {
wd, err := os.Getwd()
if err != nil {
return err
}
return m.InstallLocal(wd)
}
repo, err := ghrepo.FromFullName(args[0])
if err != nil {
return err
}
if !strings.HasPrefix(repo.RepoName(), "gh-") {
return errors.New("the repository name must start with `gh-`")
}
protocol := "https" // TODO: respect user's preferred protocol
return m.Install(ghrepo.FormatRemoteURL(repo, protocol), io.Out, io.ErrOut)
},
},
&cobra.Command{
Use: "upgrade",
Short: "Upgrade installed extensions",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return m.Upgrade(io.Out, io.ErrOut)
},
},
)
extCmd.Hidden = true
return &extCmd
}

View file

@ -0,0 +1,121 @@
package extensions
import (
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"os/exec"
"path"
"path/filepath"
"strings"
"github.com/cli/cli/internal/config"
"github.com/cli/safeexec"
)
type Manager struct {
dataDir func() string
lookPath func(string) (string, error)
}
func NewManager() *Manager {
return &Manager{
dataDir: config.ConfigDir,
lookPath: safeexec.LookPath,
}
}
func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) {
if len(args) == 0 {
return false, errors.New("too few arguments in list")
}
var exe string
extName := "gh-" + args[0]
forwardArgs := args[1:]
for _, e := range m.List() {
if filepath.Base(e) == extName {
exe = e
break
}
}
if exe == "" {
return false, nil
}
// TODO: parse the shebang on Windows and invoke the correct interpreter instead of invoking directly
externalCmd := exec.Command(exe, forwardArgs...)
externalCmd.Stdin = stdin
externalCmd.Stdout = stdout
externalCmd.Stderr = stderr
return true, externalCmd.Run()
}
func (m *Manager) List() []string {
dir := m.installDir()
entries, err := ioutil.ReadDir(dir)
if err != nil {
return nil
}
var results []string
for _, f := range entries {
if !strings.HasPrefix(f.Name(), "gh-") || !(f.IsDir() || f.Mode()&os.ModeSymlink != 0) {
continue
}
results = append(results, filepath.Join(dir, f.Name(), f.Name()))
}
return results
}
func (m *Manager) InstallLocal(dir string) error {
name := filepath.Base(dir)
targetDir := filepath.Join(m.installDir(), name)
return os.Symlink(dir, targetDir)
}
func (m *Manager) Install(cloneURL string, stdout, stderr io.Writer) error {
exe, err := m.lookPath("git")
if err != nil {
return err
}
name := strings.TrimSuffix(path.Base(cloneURL), ".git")
targetDir := filepath.Join(m.installDir(), name)
externalCmd := exec.Command(exe, "clone", cloneURL, targetDir)
externalCmd.Stdout = stdout
externalCmd.Stderr = stderr
return externalCmd.Run()
}
func (m *Manager) Upgrade(stdout, stderr io.Writer) error {
exe, err := m.lookPath("git")
if err != nil {
return err
}
exts := m.List()
if len(exts) == 0 {
return errors.New("no extensions installed")
}
for _, f := range exts {
fmt.Fprintf(stdout, "[%s]: ", filepath.Base(f))
dir := filepath.Dir(f)
externalCmd := exec.Command(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "pull", "--ff-only")
externalCmd.Stdout = stdout
externalCmd.Stderr = stderr
if e := externalCmd.Run(); e != nil {
err = e
}
}
return err
}
func (m *Manager) installDir() string {
return filepath.Join(m.dataDir(), "extensions")
}

View file

@ -6,6 +6,8 @@ import (
"net/http"
"os"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
@ -14,11 +16,93 @@ import (
)
func New(appVersion string) *cmdutil.Factory {
io := iostreams.System()
f := &cmdutil.Factory{
Config: configFunc(), // No factory dependencies
Branch: branchFunc(), // No factory dependencies
Executable: executable(), // No factory dependencies
}
f.IOStreams = ioStreams(f) // Depends on Config
f.HttpClient = httpClientFunc(f, appVersion) // Depends on Config, IOStreams, and appVersion
f.Remotes = remotesFunc(f) // Depends on Config
f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes
f.Browser = browser(f) // Depends on IOStreams
return f
}
func BaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) {
return func() (ghrepo.Interface, error) {
remotes, err := f.Remotes()
if err != nil {
return nil, err
}
return remotes[0], nil
}
}
func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) {
return func() (ghrepo.Interface, error) {
httpClient, err := f.HttpClient()
if err != nil {
return nil, err
}
apiClient := api.NewClientFromHTTP(httpClient)
remotes, err := f.Remotes()
if err != nil {
return nil, err
}
repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "")
if err != nil {
return nil, err
}
baseRepo, err := repoContext.BaseRepo(f.IOStreams)
if err != nil {
return nil, err
}
return baseRepo, nil
}
}
func remotesFunc(f *cmdutil.Factory) func() (context.Remotes, error) {
rr := &remoteResolver{
readRemotes: git.Remotes,
getConfig: f.Config,
}
return rr.Resolver()
}
func httpClientFunc(f *cmdutil.Factory, appVersion string) func() (*http.Client, error) {
return func() (*http.Client, error) {
io := f.IOStreams
cfg, err := f.Config()
if err != nil {
return nil, err
}
return NewHTTPClient(io, cfg, appVersion, true), nil
}
}
func browser(f *cmdutil.Factory) cmdutil.Browser {
io := f.IOStreams
return cmdutil.NewBrowser(os.Getenv("BROWSER"), io.Out, io.ErrOut)
}
func executable() string {
gh := "gh"
if exe, err := os.Executable(); err == nil {
gh = exe
}
return gh
}
func configFunc() func() (config.Config, error) {
var cachedConfig config.Config
var configError error
configFunc := func() (config.Config, error) {
return func() (config.Config, error) {
if cachedConfig != nil || configError != nil {
return cachedConfig, configError
}
@ -30,45 +114,38 @@ func New(appVersion string) *cmdutil.Factory {
cachedConfig = config.InheritEnv(cachedConfig)
return cachedConfig, configError
}
}
rr := &remoteResolver{
readRemotes: git.Remotes,
getConfig: configFunc,
}
remotesFunc := rr.Resolver()
ghExecutable := "gh"
if exe, err := os.Executable(); err == nil {
ghExecutable = exe
}
return &cmdutil.Factory{
IOStreams: io,
Config: configFunc,
Remotes: remotesFunc,
HttpClient: func() (*http.Client, error) {
cfg, err := configFunc()
if err != nil {
return nil, err
}
return NewHTTPClient(io, cfg, appVersion, true), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
remotes, err := remotesFunc()
if err != nil {
return nil, err
}
return remotes[0], nil
},
Branch: func() (string, error) {
currentBranch, err := git.CurrentBranch()
if err != nil {
return "", fmt.Errorf("could not determine current branch: %w", err)
}
return currentBranch, nil
},
Executable: ghExecutable,
Browser: cmdutil.NewBrowser(os.Getenv("BROWSER"), io.Out, io.ErrOut),
func branchFunc() func() (string, error) {
return func() (string, error) {
currentBranch, err := git.CurrentBranch()
if err != nil {
return "", fmt.Errorf("could not determine current branch: %w", err)
}
return currentBranch, nil
}
}
func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
io := iostreams.System()
cfg, err := f.Config()
if err != nil {
return io
}
if prompt, _ := cfg.Get("", "prompt"); prompt == "disabled" {
io.SetNeverPrompt(true)
}
// Pager precedence
// 1. GH_PAGER
// 2. pager from config
// 3. PAGER
if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists {
io.SetPager(ghPager)
} else if pager, _ := cfg.Get("", "pager"); pager != "" {
io.SetPager(pager)
}
return io
}

View file

@ -0,0 +1,391 @@
package factory
import (
"net/url"
"os"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/stretchr/testify/assert"
)
func Test_BaseRepo(t *testing.T) {
orig_GH_HOST := os.Getenv("GH_HOST")
t.Cleanup(func() {
os.Setenv("GH_HOST", orig_GH_HOST)
})
tests := []struct {
name string
remotes git.RemoteSet
config config.Config
override string
wantsErr bool
wantsName string
wantsOwner string
wantsHost string
}{
{
name: "matching remote",
remotes: git.RemoteSet{
git.NewRemote("origin", "https://nonsense.com/owner/repo.git"),
},
config: defaultConfig(),
wantsName: "repo",
wantsOwner: "owner",
wantsHost: "nonsense.com",
},
{
name: "no matching remote",
remotes: git.RemoteSet{
git.NewRemote("origin", "https://test.com/owner/repo.git"),
},
config: defaultConfig(),
wantsErr: true,
},
{
name: "override with matching remote",
remotes: git.RemoteSet{
git.NewRemote("origin", "https://test.com/owner/repo.git"),
},
config: defaultConfig(),
override: "test.com",
wantsName: "repo",
wantsOwner: "owner",
wantsHost: "test.com",
},
{
name: "override with no matching remote",
remotes: git.RemoteSet{
git.NewRemote("origin", "https://nonsense.com/owner/repo.git"),
},
config: defaultConfig(),
override: "test.com",
wantsErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.override != "" {
os.Setenv("GH_HOST", tt.override)
} else {
os.Unsetenv("GH_HOST")
}
f := New("1")
rr := &remoteResolver{
readRemotes: func() (git.RemoteSet, error) {
return tt.remotes, nil
},
getConfig: func() (config.Config, error) {
return tt.config, nil
},
}
f.Remotes = rr.Resolver()
f.BaseRepo = BaseRepoFunc(f)
repo, err := f.BaseRepo()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantsName, repo.RepoName())
assert.Equal(t, tt.wantsOwner, repo.RepoOwner())
assert.Equal(t, tt.wantsHost, repo.RepoHost())
})
}
}
func Test_SmartBaseRepo(t *testing.T) {
pu, _ := url.Parse("https://test.com/newowner/newrepo.git")
orig_GH_HOST := os.Getenv("GH_HOST")
t.Cleanup(func() {
os.Setenv("GH_HOST", orig_GH_HOST)
})
tests := []struct {
name string
remotes git.RemoteSet
config config.Config
override string
wantsErr bool
wantsName string
wantsOwner string
wantsHost string
}{
{
name: "override with matching remote",
remotes: git.RemoteSet{
git.NewRemote("origin", "https://test.com/owner/repo.git"),
},
config: defaultConfig(),
override: "test.com",
wantsName: "repo",
wantsOwner: "owner",
wantsHost: "test.com",
},
{
name: "override with matching remote and base resolution",
remotes: git.RemoteSet{
&git.Remote{Name: "origin",
Resolved: "base",
FetchURL: pu,
PushURL: pu},
},
config: defaultConfig(),
override: "test.com",
wantsName: "newrepo",
wantsOwner: "newowner",
wantsHost: "test.com",
},
{
name: "override with matching remote and nonbase resolution",
remotes: git.RemoteSet{
&git.Remote{Name: "origin",
Resolved: "johnny/test",
FetchURL: pu,
PushURL: pu},
},
config: defaultConfig(),
override: "test.com",
wantsName: "test",
wantsOwner: "johnny",
wantsHost: "test.com",
},
{
name: "override with no matching remote",
remotes: git.RemoteSet{
git.NewRemote("origin", "https://example.com/owner/repo.git"),
},
config: defaultConfig(),
override: "test.com",
wantsErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.override != "" {
os.Setenv("GH_HOST", tt.override)
} else {
os.Unsetenv("GH_HOST")
}
f := New("1")
rr := &remoteResolver{
readRemotes: func() (git.RemoteSet, error) {
return tt.remotes, nil
},
getConfig: func() (config.Config, error) {
return tt.config, nil
},
}
f.Remotes = rr.Resolver()
f.BaseRepo = SmartBaseRepoFunc(f)
repo, err := f.BaseRepo()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantsName, repo.RepoName())
assert.Equal(t, tt.wantsOwner, repo.RepoOwner())
assert.Equal(t, tt.wantsHost, repo.RepoHost())
})
}
}
// Defined in pkg/cmdutil/repo_override.go but test it along with other BaseRepo functions
func Test_OverrideBaseRepo(t *testing.T) {
orig_GH_HOST := os.Getenv("GH_REPO")
t.Cleanup(func() {
os.Setenv("GH_REPO", orig_GH_HOST)
})
tests := []struct {
name string
remotes git.RemoteSet
config config.Config
envOverride string
argOverride string
wantsErr bool
wantsName string
wantsOwner string
wantsHost string
}{
{
name: "override from argument",
argOverride: "override/test",
wantsHost: "github.com",
wantsOwner: "override",
wantsName: "test",
},
{
name: "override from environment",
envOverride: "somehost.com/override/test",
wantsHost: "somehost.com",
wantsOwner: "override",
wantsName: "test",
},
{
name: "no override",
remotes: git.RemoteSet{
git.NewRemote("origin", "https://nonsense.com/owner/repo.git"),
},
config: defaultConfig(),
wantsHost: "nonsense.com",
wantsOwner: "owner",
wantsName: "repo",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.envOverride != "" {
os.Setenv("GH_REPO", tt.envOverride)
} else {
os.Unsetenv("GH_REPO")
}
f := New("1")
rr := &remoteResolver{
readRemotes: func() (git.RemoteSet, error) {
return tt.remotes, nil
},
getConfig: func() (config.Config, error) {
return tt.config, nil
},
}
f.Remotes = rr.Resolver()
f.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, tt.argOverride)
repo, err := f.BaseRepo()
if tt.wantsErr {
assert.Error(t, err)
return
}
assert.NoError(t, err)
assert.Equal(t, tt.wantsName, repo.RepoName())
assert.Equal(t, tt.wantsOwner, repo.RepoOwner())
assert.Equal(t, tt.wantsHost, repo.RepoHost())
})
}
}
func Test_ioStreams_pager(t *testing.T) {
tests := []struct {
name string
env map[string]string
config config.Config
wantPager string
}{
{
name: "GH_PAGER and PAGER set",
env: map[string]string{
"GH_PAGER": "GH_PAGER",
"PAGER": "PAGER",
},
wantPager: "GH_PAGER",
},
{
name: "GH_PAGER and config pager set",
env: map[string]string{
"GH_PAGER": "GH_PAGER",
},
config: pagerConfig(),
wantPager: "GH_PAGER",
},
{
name: "config pager and PAGER set",
env: map[string]string{
"PAGER": "PAGER",
},
config: pagerConfig(),
wantPager: "CONFIG_PAGER",
},
{
name: "only PAGER set",
env: map[string]string{
"PAGER": "PAGER",
},
wantPager: "PAGER",
},
{
name: "GH_PAGER set to blank string",
env: map[string]string{
"GH_PAGER": "",
"PAGER": "PAGER",
},
wantPager: "",
},
}
for _, tt := range tests {
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)
if k == "GH_PAGER" {
defer os.Unsetenv(k)
} else {
defer os.Setenv(k, old)
}
}
}
f := New("1")
if tt.config != nil {
f.Config = func() (config.Config, error) {
return tt.config, nil
}
}
io := ioStreams(f)
assert.Equal(t, tt.wantPager, io.GetPager())
})
}
}
func Test_ioStreams_prompt(t *testing.T) {
tests := []struct {
name string
config config.Config
promptDisabled bool
}{
{
name: "default config",
promptDisabled: false,
},
{
name: "config with prompt disabled",
config: disablePromptConfig(),
promptDisabled: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
f := New("1")
if tt.config != nil {
f.Config = func() (config.Config, error) {
return tt.config, nil
}
}
io := ioStreams(f)
assert.Equal(t, tt.promptDisabled, io.GetNeverPrompt())
})
}
}
func defaultConfig() config.Config {
return config.InheritEnv(config.NewFromString(heredoc.Doc(`
hosts:
nonsense.com:
oauth_token: BLAH
`)))
}
func pagerConfig() config.Config {
return config.NewFromString("pager: CONFIG_PAGER")
}
func disablePromptConfig() config.Config {
return config.NewFromString("prompt: disabled")
}

View file

@ -8,7 +8,6 @@ import (
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/pkg/iostreams"
)
@ -53,8 +52,12 @@ var timezoneNames = map[int]string{
50400: "Pacific/Kiritimati",
}
type configGetter interface {
Get(string, string) (string, error)
}
// generic authenticated HTTP client for commands
func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, setAccept bool) *http.Client {
func NewHTTPClient(io *iostreams.IOStreams, cfg configGetter, appVersion string, setAccept bool) *http.Client {
var opts []api.ClientOption
if verbose := os.Getenv("DEBUG"); verbose != "" {
logTraffic := strings.Contains(verbose, "api")
@ -64,7 +67,7 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string
opts = append(opts,
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", appVersion)),
api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) {
hostname := ghinstance.NormalizeHostname(req.URL.Hostname())
hostname := ghinstance.NormalizeHostname(getHost(req))
if token, err := cfg.Get(hostname, "oauth_token"); err == nil && token != "" {
return fmt.Sprintf("token %s", token), nil
}
@ -85,13 +88,10 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string
if setAccept {
opts = append(opts,
api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) {
// antiope-preview: Checks
accept := "application/vnd.github.antiope-preview+json"
// introduced for #2952: pr branch up to date status
accept += ", application/vnd.github.merge-info-preview+json"
if ghinstance.IsEnterprise(req.URL.Hostname()) {
// shadow-cat-preview: Draft pull requests
accept += ", application/vnd.github.shadow-cat-preview"
accept := "application/vnd.github.merge-info-preview+json" // PullRequest.mergeStateStatus
if ghinstance.IsEnterprise(getHost(req)) {
accept += ", application/vnd.github.antiope-preview" // Commit.statusCheckRollup
accept += ", application/vnd.github.shadow-cat-preview" // PullRequest.isDraft
}
return accept, nil
}),
@ -100,3 +100,10 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string
return api.NewHTTPClient(opts...)
}
func getHost(r *http.Request) string {
if r.Host != "" {
return r.Host
}
return r.URL.Hostname()
}

View file

@ -0,0 +1,174 @@
package factory
import (
"fmt"
"net/http"
"net/http/httptest"
"os"
"regexp"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/pkg/iostreams"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewHTTPClient(t *testing.T) {
type args struct {
config configGetter
appVersion string
setAccept bool
}
tests := []struct {
name string
args args
envDebug string
host string
wantHeader map[string]string
wantStderr string
}{
{
name: "github.com with Accept header",
args: args{
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
},
host: "github.com",
wantHeader: map[string]string{
"authorization": "token MYTOKEN",
"user-agent": "GitHub CLI v1.2.3",
"accept": "application/vnd.github.merge-info-preview+json",
},
wantStderr: "",
},
{
name: "github.com no Accept header",
args: args{
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: false,
},
host: "github.com",
wantHeader: map[string]string{
"authorization": "token MYTOKEN",
"user-agent": "GitHub CLI v1.2.3",
"accept": "",
},
wantStderr: "",
},
{
name: "github.com no authentication token",
args: args{
config: tinyConfig{"example.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
},
host: "github.com",
wantHeader: map[string]string{
"authorization": "",
"user-agent": "GitHub CLI v1.2.3",
"accept": "application/vnd.github.merge-info-preview+json",
},
wantStderr: "",
},
{
name: "github.com in verbose mode",
args: args{
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
appVersion: "v1.2.3",
setAccept: true,
},
host: "github.com",
envDebug: "api",
wantHeader: map[string]string{
"authorization": "token MYTOKEN",
"user-agent": "GitHub CLI v1.2.3",
"accept": "application/vnd.github.merge-info-preview+json",
},
wantStderr: heredoc.Doc(`
* Request at <time>
* Request to http://<host>:<port>
> GET / HTTP/1.1
> Host: github.com
> Accept: application/vnd.github.merge-info-preview+json
> Authorization: token
> User-Agent: GitHub CLI v1.2.3
< HTTP/1.1 204 No Content
< Date: <time>
* Request took <duration>
`),
},
{
name: "GHES Accept header",
args: args{
config: tinyConfig{"example.com:oauth_token": "GHETOKEN"},
appVersion: "v1.2.3",
setAccept: true,
},
host: "example.com",
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.antiope-preview, application/vnd.github.shadow-cat-preview",
},
wantStderr: "",
},
}
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()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
oldDebug := os.Getenv("DEBUG")
os.Setenv("DEBUG", tt.envDebug)
t.Cleanup(func() {
os.Setenv("DEBUG", oldDebug)
})
io, _, _, stderr := iostreams.Test()
client := NewHTTPClient(io, tt.args.config, tt.args.appVersion, tt.args.setAccept)
req, err := http.NewRequest("GET", ts.URL, nil)
req.Host = tt.host
require.NoError(t, err)
res, err := client.Do(req)
require.NoError(t, err)
for name, value := range tt.wantHeader {
assert.Equal(t, value, gotReq.Header.Get(name), name)
}
assert.Equal(t, 204, res.StatusCode)
assert.Equal(t, tt.wantStderr, normalizeVerboseLog(stderr.String()))
})
}
}
type tinyConfig map[string]string
func (c tinyConfig) Get(host, key string) (string, error) {
return c[fmt.Sprintf("%s:%s", host, key)], nil
}
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 .+`)
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>")
return t
}

View file

@ -234,10 +234,6 @@ func createRun(opts *CreateOptions) (err error) {
if err != nil {
return
}
if tb.Body == "" {
tb.Body = templateContent
}
}
openURL, err = generatePreviewURL(apiClient, baseRepo, tb)

View file

@ -114,6 +114,8 @@ func TestNewCmdCreate(t *testing.T) {
args, err := shlex.Split(tt.cli)
require.NoError(t, err)
cmd.SetArgs(args)
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
_, err = cmd.ExecuteC()
if tt.wantsErr {
assert.Error(t, err)
@ -168,7 +170,7 @@ func Test_createRun(t *testing.T) {
WebMode: true,
Assignees: []string{"monalisa"},
},
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=monalisa",
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=monalisa&body=",
wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n",
},
{
@ -185,7 +187,7 @@ func Test_createRun(t *testing.T) {
"viewer": { "login": "MonaLisa" }
} }`))
},
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=MonaLisa",
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=MonaLisa&body=",
wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n",
},
{
@ -214,7 +216,7 @@ func Test_createRun(t *testing.T) {
"pageInfo": { "hasNextPage": false }
} } } }`))
},
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?projects=OWNER%2FREPO%2F1",
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?body=&projects=OWNER%2FREPO%2F1",
wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n",
},
{

View file

@ -160,6 +160,7 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
milestoneTitle = issue.Milestone.Title
}
fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle)
fmt.Fprintf(out, "number:\t%d\n", issue.Number)
fmt.Fprintln(out, "--")
fmt.Fprintln(out, issue.Body)
return nil
@ -172,7 +173,7 @@ func printHumanIssuePreview(opts *ViewOptions, issue *api.Issue) error {
cs := opts.IO.ColorScheme()
// Header (Title and State)
fmt.Fprintln(out, cs.Bold(issue.Title))
fmt.Fprintf(out, "%s #%d\n", cs.Bold(issue.Title), issue.Number)
fmt.Fprintf(out,
"%s • %s opened %s • %s\n",
issueStateTitleWithColor(cs, issue.State),

View file

@ -112,6 +112,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
`comments:\t9`,
`author:\tmarseilles`,
`assignees:`,
`number:\t123\n`,
`\*\*bold story\*\*`,
},
},
@ -126,6 +127,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
`labels:\tone, two, three, four, five`,
`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
`milestone:\tuluru\n`,
`number:\t123\n`,
`\*\*bold story\*\*`,
},
},
@ -136,6 +138,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
`state:\tOPEN`,
`author:\tmarseilles`,
`labels:\ttarot`,
`number:\t123\n`,
},
},
"Closed issue": {
@ -146,6 +149,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
`\*\*bold story\*\*`,
`author:\tmarseilles`,
`labels:\ttarot`,
`number:\t123\n`,
`\*\*bold story\*\*`,
},
},
@ -178,7 +182,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
"Open issue without metadata": {
fixture: "./fixtures/issueView_preview.json",
expectedOutputs: []string{
`ix of coins`,
`ix of coins #123`,
`Open.*marseilles opened about 9 years ago.*9 comments`,
`bold story`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
@ -187,7 +191,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
"Open issue with metadata": {
fixture: "./fixtures/issueView_previewWithMetadata.json",
expectedOutputs: []string{
`ix of coins`,
`ix of coins #123`,
`Open.*marseilles opened about 9 years ago.*9 comments`,
`8 \x{1f615} • 7 \x{1f440} • 6 \x{2764}\x{fe0f} • 5 \x{1f389} • 4 \x{1f604} • 3 \x{1f680} • 2 \x{1f44e} • 1 \x{1f44d}`,
`Assignees:.*marseilles, monaco\n`,
@ -201,7 +205,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
"Open issue with empty body": {
fixture: "./fixtures/issueView_previewWithEmptyBody.json",
expectedOutputs: []string{
`ix of coins`,
`ix of coins #123`,
`Open.*marseilles opened about 9 years ago.*9 comments`,
`No description provided`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
@ -210,7 +214,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
"Closed issue": {
fixture: "./fixtures/issueView_previewClosedState.json",
expectedOutputs: []string{
`ix of coins`,
`ix of coins #123`,
`Closed.*marseilles opened about 9 years ago.*9 comments`,
`bold story`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
@ -310,7 +314,7 @@ func TestIssueView_tty_Comments(t *testing.T) {
"IssueByNumber": "./fixtures/issueView_previewSingleComment.json",
},
expectedOutputs: []string{
`some title`,
`some title #123`,
`some body`,
`———————— Not showing 5 comments ————————`,
`marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`,
@ -326,7 +330,7 @@ func TestIssueView_tty_Comments(t *testing.T) {
"CommentsForIssue": "./fixtures/issueView_previewFullComments.json",
},
expectedOutputs: []string{
`some title`,
`some title #123`,
`some body`,
`monalisa • Jan 1, 2020 • Edited`,
`1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`,
@ -386,6 +390,7 @@ func TestIssueView_nontty_Comments(t *testing.T) {
`state:\tOPEN`,
`author:\tmarseilles`,
`comments:\t6`,
`number:\t123`,
`some body`,
},
},

View file

@ -302,10 +302,6 @@ func createRun(opts *CreateOptions) (err error) {
if err != nil {
return
}
if state.Body == "" {
state.Body = templateContent
}
}
openURL, err = generateCompareURL(*ctx, *state)

View file

@ -239,7 +239,7 @@ func TestPRCreate_nontty_web(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1", output.BrowsedURL)
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1", output.BrowsedURL)
}
func TestPRCreate_recover(t *testing.T) {
@ -780,7 +780,7 @@ func TestPRCreate_web(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n", output.Stderr())
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1", output.BrowsedURL)
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1", output.BrowsedURL)
}
func TestPRCreate_webLongURL(t *testing.T) {
@ -851,7 +851,7 @@ func TestPRCreate_webProject(t *testing.T) {
assert.Equal(t, "", output.String())
assert.Equal(t, "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n", output.Stderr())
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1&projects=ORG%2F1", output.BrowsedURL)
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1&projects=ORG%2F1", output.BrowsedURL)
}
func Test_determineTrackingBranch_empty(t *testing.T) {
@ -965,7 +965,7 @@ func Test_generateCompareURL(t *testing.T) {
BaseBranch: "main",
HeadBranchLabel: "feature",
},
want: "https://github.com/OWNER/REPO/compare/main...feature?expand=1",
want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1",
wantErr: false,
},
{
@ -978,7 +978,7 @@ func Test_generateCompareURL(t *testing.T) {
state: prShared.IssueMetadataState{
Labels: []string{"one", "two three"},
},
want: "https://github.com/OWNER/REPO/compare/a...b?expand=1&labels=one%2Ctwo+three",
want: "https://github.com/OWNER/REPO/compare/a...b?body=&expand=1&labels=one%2Ctwo+three",
wantErr: false,
},
{
@ -988,7 +988,7 @@ func Test_generateCompareURL(t *testing.T) {
BaseBranch: "main/trunk",
HeadBranchLabel: "owner:feature",
},
want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner%3Afeature?expand=1",
want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner%3Afeature?body=&expand=1",
wantErr: false,
},
}

View file

@ -2,13 +2,13 @@ package shared
import (
"fmt"
"github.com/google/shlex"
"net/url"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/githubsearch"
"github.com/google/shlex"
)
func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState) (string, error) {
@ -20,9 +20,10 @@ func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, ba
if state.Title != "" {
q.Set("title", state.Title)
}
if state.Body != "" {
q.Set("body", state.Body)
}
// We always want to send the body parameter, even if it's empty, to prevent the web interface from
// applying the default template. Since the user has the option to select a template in the terminal,
// assume that empty body here means that the user either skipped it or erased its contents.
q.Set("body", state.Body)
if len(state.Assignees) > 0 {
q.Set("assignees", strings.Join(state.Assignees, ","))
}

View file

@ -1,7 +1,6 @@
package shared
import (
"github.com/stretchr/testify/assert"
"net/http"
"reflect"
"testing"
@ -9,6 +8,7 @@ import (
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/httpmock"
"github.com/stretchr/testify/assert"
)
func Test_listURLWithQuery(t *testing.T) {
@ -192,3 +192,56 @@ func Test_QueryHasStateClause(t *testing.T) {
assert.Equal(t, tt.hasState, gotState)
}
}
func Test_WithPrAndIssueQueryParams(t *testing.T) {
type args struct {
baseURL string
state IssueMetadataState
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
name: "blank",
args: args{
baseURL: "",
state: IssueMetadataState{},
},
want: "?body=",
},
{
name: "no values",
args: args{
baseURL: "http://example.com/hey",
state: IssueMetadataState{},
},
want: "http://example.com/hey?body=",
},
{
name: "title and body",
args: args{
baseURL: "http://example.com/hey",
state: IssueMetadataState{
Title: "my title",
Body: "my bodeh",
},
},
want: "http://example.com/hey?body=my+bodeh&title=my+title",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := WithPrAndIssueQueryParams(nil, nil, tt.args.baseURL, tt.args.state)
if (err != nil) != tt.wantErr {
t.Errorf("WithPrAndIssueQueryParams() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("WithPrAndIssueQueryParams() = %v, want %v", got, tt.want)
}
})
}
}

View file

@ -110,7 +110,7 @@ func BodySurvey(state *IssueMetadataState, templateContent, editorCommand string
return err
}
if state.Body != "" && preBody != state.Body {
if preBody != state.Body {
state.MarkDirty()
}

View file

@ -21,10 +21,12 @@
}
},
{
"requestedReviewer": {
"__typename": "Team",
"name": "Team 1"
}
"requestedReviewer": {
"__typename": "Team",
"name": "Team 1",
"slug": "team-1",
"organization": {"login": "my-org"}
}
},
{
"requestedReviewer": {

View file

@ -170,7 +170,7 @@ func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error {
cs := opts.IO.ColorScheme()
// Header (Title and State)
fmt.Fprintln(out, cs.Bold(pr.Title))
fmt.Fprintf(out, "%s #%d\n", cs.Bold(pr.Title), pr.Number)
fmt.Fprintf(out,
"%s • %s wants to merge %s into %s from %s • %s %s \n",
shared.StateTitleWithColor(cs, *pr),
@ -294,8 +294,6 @@ func prReviewerList(pr api.PullRequest, cs *iostreams.ColorScheme) string {
return reviewerList
}
const teamTypeName = "Team"
const ghostName = "ghost"
// parseReviewers parses given Reviews and ReviewRequests
@ -317,10 +315,7 @@ func parseReviewers(pr api.PullRequest) []*reviewerState {
// Overwrite reviewer's state if a review request for the same reviewer exists.
for _, reviewRequest := range pr.ReviewRequests.Nodes {
name := reviewRequest.RequestedReviewer.Login
if reviewRequest.RequestedReviewer.TypeName == teamTypeName {
name = reviewRequest.RequestedReviewer.Name
}
name := reviewRequest.RequestedReviewer.LoginOrSlug()
reviewerStates[name] = &reviewerState{
Name: name,
State: requestedReviewState,

View file

@ -250,7 +250,7 @@ func TestPRView_Preview_nontty(t *testing.T) {
`milestone:\t\n`,
`additions:\t100\n`,
`deletions:\t10\n`,
`reviewers:\tDEF \(Commented\), def \(Changes requested\), ghost \(Approved\), hubot \(Commented\), xyz \(Approved\), 123 \(Requested\), Team 1 \(Requested\), abc \(Requested\)\n`,
`reviewers:\tDEF \(Commented\), def \(Changes requested\), ghost \(Approved\), hubot \(Commented\), xyz \(Approved\), 123 \(Requested\), abc \(Requested\), my-org\/team-1 \(Requested\)\n`,
`\*\*blueberries taste good\*\*`,
},
},
@ -350,7 +350,7 @@ func TestPRView_Preview(t *testing.T) {
"PullRequestByNumber": "./fixtures/prViewPreview.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Blueberries are from a fork #12`,
`Open.*nobody wants to merge 12 commits into master from blueberries.+100.-10`,
`blueberries taste good`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
@ -363,7 +363,7 @@ func TestPRView_Preview(t *testing.T) {
"PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Blueberries are from a fork #12`,
`Open.*nobody wants to merge 12 commits into master from blueberries.+100.-10`,
`Reviewers:.*1 \(.*Requested.*\)\n`,
`Assignees:.*marseilles, monaco\n`,
@ -382,8 +382,8 @@ func TestPRView_Preview(t *testing.T) {
"ReviewsForPullRequest": "./fixtures/prViewPreviewManyReviews.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`,
`Blueberries are from a fork #12`,
`Reviewers: DEF \(Commented\), def \(Changes requested\), ghost \(Approved\), hubot \(Commented\), xyz \(Approved\), 123 \(Requested\), abc \(Requested\), my-org\/team-1 \(Requested\)`,
`blueberries taste good`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
},
@ -395,7 +395,7 @@ func TestPRView_Preview(t *testing.T) {
"PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Blueberries are from a fork #12`,
`Closed.*nobody wants to merge 12 commits into master from blueberries.+100.-10`,
`blueberries taste good`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
@ -408,7 +408,7 @@ func TestPRView_Preview(t *testing.T) {
"PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Blueberries are from a fork #12`,
`Merged.*nobody wants to merge 12 commits into master from blueberries.+100.-10`,
`blueberries taste good`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
@ -421,7 +421,7 @@ func TestPRView_Preview(t *testing.T) {
"PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
`Blueberries are from a fork #12`,
`Draft.*nobody wants to merge 12 commits into master from blueberries.+100.-10`,
`blueberries taste good`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
@ -501,7 +501,7 @@ func TestPRView_tty_Comments(t *testing.T) {
"ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json",
},
expectedOutputs: []string{
`some title`,
`some title #12`,
`1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f}`,
`some body`,
`———————— Not showing 9 comments ————————`,
@ -521,7 +521,7 @@ func TestPRView_tty_Comments(t *testing.T) {
"CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json",
},
expectedOutputs: []string{
`some title`,
`some title #12`,
`some body`,
`monalisa • Jan 1, 2020 • Edited`,
`1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`,

View file

@ -22,12 +22,15 @@ import (
"github.com/spf13/pflag"
)
const defaultRemoteName = "origin"
type ForkOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Since func(time.Time) time.Duration
GitArgs []string
Repository string
@ -40,9 +43,9 @@ type ForkOptions struct {
Rename bool
}
var Since = func(t time.Time) time.Duration {
return time.Since(t)
}
// TODO warn about useless flags (--remote, --remote-name) when running from outside a repository
// TODO output over STDOUT not STDERR
// TODO remote-name has no effect on its own; error that or change behavior
func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Command {
opts := &ForkOptions{
@ -51,6 +54,7 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman
Config: f.Config,
BaseRepo: f.BaseRepo,
Remotes: f.Remotes,
Since: time.Since,
}
cmd := &cobra.Command{
@ -110,7 +114,7 @@ Additional 'git clone' flags can be passed in by listing them after '--'.`,
cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork {true|false}")
cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add remote for fork {true|false}")
cmd.Flags().StringVar(&opts.RemoteName, "remote-name", "origin", "Specify a name for a fork's new remote.")
cmd.Flags().StringVar(&opts.RemoteName, "remote-name", defaultRemoteName, "Specify a name for a fork's new remote.")
cmd.Flags().StringVar(&opts.Organization, "org", "", "Create the fork in an organization")
return cmd
@ -182,7 +186,7 @@ func forkRun(opts *ForkOptions) error {
// returns the fork repo data even if it already exists -- with no change in status code or
// anything. We thus check the created time to see if the repo is brand new or not; if it's not,
// we assume the fork already existed and report an error.
createdAgo := Since(forkedRepo.CreatedAt)
createdAgo := opts.Since(forkedRepo.CreatedAt)
if createdAgo > time.Minute {
if connectedToTerminal {
fmt.Fprintf(stderr, "%s %s %s\n",

File diff suppressed because it is too large Load diff

View file

@ -64,6 +64,12 @@ var HelpTopics = map[string]map[string]string{
GH_NO_UPDATE_NOTIFIER: set to any value to disable update notifications. By default, gh
checks for new releases once every 24 hours and displays an upgrade notice on standard
error if a newer version was found.
GH_CONFIG_DIR, XDG_CONFIG_HOME (in order of precedence): the directory where gh will store configuration files.
XDG_STATE_HOME: the directory where gh will store state files.
XDG_DATA_HOME: the directory where gh will store data files.
`),
},
"reference": {

View file

@ -4,15 +4,13 @@ import (
"net/http"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/ghrepo"
actionsCmd "github.com/cli/cli/pkg/cmd/actions"
aliasCmd "github.com/cli/cli/pkg/cmd/alias"
apiCmd "github.com/cli/cli/pkg/cmd/api"
authCmd "github.com/cli/cli/pkg/cmd/auth"
completionCmd "github.com/cli/cli/pkg/cmd/completion"
configCmd "github.com/cli/cli/pkg/cmd/config"
extensionsCmd "github.com/cli/cli/pkg/cmd/extensions"
"github.com/cli/cli/pkg/cmd/factory"
gistCmd "github.com/cli/cli/pkg/cmd/gist"
issueCmd "github.com/cli/cli/pkg/cmd/issue"
@ -80,6 +78,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil))
cmd.AddCommand(gistCmd.NewCmdGist(f))
cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams))
cmd.AddCommand(extensionsCmd.NewCmdExtensions(f.IOStreams))
cmd.AddCommand(secretCmd.NewCmdSecret(f))
cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
@ -91,7 +90,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
// below here at the commands that require the "intelligent" BaseRepo resolver
repoResolvingCmdFactory := *f
repoResolvingCmdFactory.BaseRepo = resolvedBaseRepo(f)
repoResolvingCmdFactory.BaseRepo = factory.SmartBaseRepoFunc(f)
cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory))
cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory))
@ -124,29 +123,3 @@ func bareHTTPClient(f *cmdutil.Factory, version string) func() (*http.Client, er
return factory.NewHTTPClient(f.IOStreams, cfg, version, false), nil
}
}
func resolvedBaseRepo(f *cmdutil.Factory) func() (ghrepo.Interface, error) {
return func() (ghrepo.Interface, error) {
httpClient, err := f.HttpClient()
if err != nil {
return nil, err
}
apiClient := api.NewClientFromHTTP(httpClient)
remotes, err := f.Remotes()
if err != nil {
return nil, err
}
repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "")
if err != nil {
return nil, err
}
baseRepo, err := repoContext.BaseRepo(f.IOStreams)
if err != nil {
return nil, err
}
return baseRepo, nil
}
}

View file

@ -462,7 +462,7 @@ func promptForJob(cs *iostreams.ColorScheme, jobs []shared.Job) (*shared.Job, er
}
func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
re := fmt.Sprintf(`%s\/%d_.*\.txt`, job.Name, step.Number)
re := fmt.Sprintf(`%s\/%d_.*\.txt`, regexp.QuoteMeta(job.Name), step.Number)
return regexp.MustCompile(re)
}

View file

@ -926,6 +926,17 @@ func Test_attachRunLog(t *testing.T) {
},
wantMatch: false,
},
{
name: "escape metacharacters in job name",
job: shared.Job{
Name: "metacharacters .+*?()|[]{}^$ job",
Steps: []shared.Step{{
Name: "fob the barz",
Number: 0,
}},
},
wantMatch: false,
},
{
name: "mismatching job name",
job: shared.Job{

View file

@ -1,13 +1,16 @@
package list
import (
"encoding/json"
"fmt"
"net/http"
"regexp"
"strings"
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/secret/shared"
"github.com/cli/cli/pkg/cmdutil"
@ -23,6 +26,7 @@ type ListOptions struct {
BaseRepo func() (ghrepo.Interface, error)
OrgName string
EnvName string
}
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
@ -35,12 +39,16 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
cmd := &cobra.Command{
Use: "list",
Short: "List secrets",
Long: "List secrets for a repository or organization",
Long: "List secrets for a repository, environment, or organization",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil {
return err
}
if runF != nil {
return runF(opts)
}
@ -50,18 +58,19 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
}
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization")
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "List secrets for an environment")
return cmd
}
func listRun(opts *ListOptions) error {
c, err := opts.HttpClient()
client, err := opts.HttpClient()
if err != nil {
return fmt.Errorf("could not create http client: %w", err)
}
client := api.NewClientFromHTTP(c)
orgName := opts.OrgName
envName := opts.EnvName
var baseRepo ghrepo.Interface
if orgName == "" {
@ -73,7 +82,11 @@ func listRun(opts *ListOptions) error {
var secrets []*Secret
if orgName == "" {
secrets, err = getRepoSecrets(client, baseRepo)
if envName == "" {
secrets, err = getRepoSecrets(client, baseRepo)
} else {
secrets, err = getEnvSecrets(client, baseRepo, envName)
}
} else {
var cfg config.Config
var host string
@ -145,7 +158,7 @@ func fmtVisibility(s Secret) string {
return ""
}
func getOrgSecrets(client *api.Client, host, orgName string) ([]*Secret, error) {
func getOrgSecrets(client httpClient, host, orgName string) ([]*Secret, error) {
secrets, err := getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName))
if err != nil {
return nil, err
@ -160,7 +173,7 @@ func getOrgSecrets(client *api.Client, host, orgName string) ([]*Secret, error)
continue
}
var result responseData
if err := client.REST(host, "GET", secret.SelectedReposURL, nil, &result); err != nil {
if _, err := apiGet(client, secret.SelectedReposURL, &result); err != nil {
return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err)
}
secret.NumSelectedRepos = result.TotalCount
@ -169,7 +182,12 @@ func getOrgSecrets(client *api.Client, host, orgName string) ([]*Secret, error)
return secrets, nil
}
func getRepoSecrets(client *api.Client, repo ghrepo.Interface) ([]*Secret, error) {
func getEnvSecrets(client httpClient, repo ghrepo.Interface, envName string) ([]*Secret, error) {
path := fmt.Sprintf("repos/%s/environments/%s/secrets", ghrepo.FullName(repo), envName)
return getSecrets(client, repo.RepoHost(), path)
}
func getRepoSecrets(client httpClient, repo ghrepo.Interface) ([]*Secret, error) {
return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets",
ghrepo.FullName(repo)))
}
@ -178,13 +196,63 @@ type secretsPayload struct {
Secrets []*Secret
}
func getSecrets(client *api.Client, host, path string) ([]*Secret, error) {
result := secretsPayload{}
type httpClient interface {
Do(*http.Request) (*http.Response, error)
}
err := client.REST(host, "GET", path, nil, &result)
if err != nil {
return nil, err
func getSecrets(client httpClient, host, path string) ([]*Secret, error) {
var results []*Secret
url := fmt.Sprintf("%s%s?per_page=100", ghinstance.RESTPrefix(host), path)
for {
var payload secretsPayload
nextURL, err := apiGet(client, url, &payload)
if err != nil {
return nil, err
}
results = append(results, payload.Secrets...)
if nextURL == "" {
break
}
url = nextURL
}
return result.Secrets, nil
return results, nil
}
func apiGet(client httpClient, url string, data interface{}) (string, error) {
req, err := http.NewRequest("GET", url, nil)
if err != nil {
return "", err
}
req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
if resp.StatusCode > 299 {
return "", api.HandleHTTPError(resp)
}
dec := json.NewDecoder(resp.Body)
if err := dec.Decode(data); err != nil {
return "", err
}
return findNextPage(resp.Header.Get("Link")), nil
}
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
func findNextPage(link string) string {
for _, m := range linkRE.FindAllStringSubmatch(link, -1) {
if len(m) >= 2 && m[2] == "next" {
return m[1]
}
}
return ""
}

View file

@ -3,7 +3,9 @@ package list
import (
"bytes"
"fmt"
"io/ioutil"
"net/http"
"strings"
"testing"
"time"
@ -38,6 +40,13 @@ func Test_NewCmdList(t *testing.T) {
OrgName: "UmbrellaCorporation",
},
},
{
name: "env",
cli: "-eDevelopment",
wants: ListOptions{
EnvName: "Development",
},
},
}
for _, tt := range tests {
@ -64,7 +73,7 @@ func Test_NewCmdList(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
assert.Equal(t, tt.wants.EnvName, gotOpts.EnvName)
})
}
}
@ -120,16 +129,44 @@ func Test_listRun(t *testing.T) {
"SECRET_THREE\t1975-11-30\tSELECTED",
},
},
{
name: "env tty",
tty: true,
opts: &ListOptions{
EnvName: "Development",
},
wantOut: []string{
"SECRET_ONE.*Updated 1988-10-11",
"SECRET_TWO.*Updated 2020-12-04",
"SECRET_THREE.*Updated 1975-11-30",
},
},
{
name: "env not tty",
tty: false,
opts: &ListOptions{
EnvName: "Development",
},
wantOut: []string{
"SECRET_ONE\t1988-10-11",
"SECRET_TWO\t2020-12-04",
"SECRET_THREE\t1975-11-30",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
path := "repos/owner/repo/actions/secrets"
if tt.opts.EnvName != "" {
path = fmt.Sprintf("repos/owner/repo/environments/%s/secrets", tt.opts.EnvName)
}
t0, _ := time.Parse("2006-01-02", "1988-10-11")
t1, _ := time.Parse("2006-01-02", "2020-12-04")
t2, _ := time.Parse("2006-01-02", "1975-11-30")
path := "repos/owner/repo/actions/secrets"
payload := secretsPayload{}
payload.Secrets = []*Secret{
{
@ -200,3 +237,32 @@ func Test_listRun(t *testing.T) {
})
}
}
func Test_getSecrets_pagination(t *testing.T) {
var requests []*http.Request
var client testClient = func(req *http.Request) (*http.Response, error) {
header := make(map[string][]string)
if len(requests) == 0 {
header["Link"] = []string{`<http://example.com/page/0>; rel="previous", <http://example.com/page/2>; rel="next"`}
}
requests = append(requests, req)
return &http.Response{
Request: req,
Body: ioutil.NopCloser(strings.NewReader(`{"secrets":[{},{}]}`)),
Header: header,
}, nil
}
secrets, err := getSecrets(client, "github.com", "path/to")
assert.NoError(t, err)
assert.Equal(t, 2, len(requests))
assert.Equal(t, 4, len(secrets))
assert.Equal(t, "https://api.github.com/path/to?per_page=100", requests[0].URL.String())
assert.Equal(t, "http://example.com/page/2", requests[1].URL.String())
}
type testClient func(*http.Request) (*http.Response, error)
func (c testClient) Do(req *http.Request) (*http.Response, error) {
return c(req)
}

View file

@ -20,6 +20,7 @@ type RemoveOptions struct {
SecretName string
OrgName string
EnvName string
}
func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Command {
@ -31,12 +32,17 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co
cmd := &cobra.Command{
Use: "remove <secret-name>",
Short: "Remove an organization or repository secret",
Short: "Remove secrets",
Long: "Remove a secret for a repository, environment, or organization",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil {
return err
}
opts.SecretName = args[0]
if runF != nil {
@ -46,7 +52,8 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co
return removeRun(opts)
},
}
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization")
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Remove a secret for an organization")
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Remove a secret for an environment")
return cmd
}
@ -59,6 +66,7 @@ func removeRun(opts *RemoveOptions) error {
client := api.NewClientFromHTTP(c)
orgName := opts.OrgName
envName := opts.EnvName
var baseRepo ghrepo.Interface
if orgName == "" {
@ -69,10 +77,12 @@ func removeRun(opts *RemoveOptions) error {
}
var path string
if orgName == "" {
path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName)
} else {
if orgName != "" {
path = fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, opts.SecretName)
} else if envName != "" {
path = fmt.Sprintf("repos/%s/environments/%s/secrets/%s", ghrepo.FullName(baseRepo), envName, opts.SecretName)
} else {
path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName)
}
cfg, err := opts.Config()
@ -96,7 +106,11 @@ func removeRun(opts *RemoveOptions) error {
target = ghrepo.FullName(baseRepo)
}
cs := opts.IO.ColorScheme()
fmt.Fprintf(opts.IO.Out, "%s Removed secret %s from %s\n", cs.SuccessIconWithColor(cs.Red), opts.SecretName, target)
if envName != "" {
fmt.Fprintf(opts.IO.Out, "%s Removed secret %s from %s environment on %s\n", cs.SuccessIconWithColor(cs.Red), opts.SecretName, envName, target)
} else {
fmt.Fprintf(opts.IO.Out, "%s Removed secret %s from %s\n", cs.SuccessIconWithColor(cs.Red), opts.SecretName, target)
}
}
return nil

View file

@ -41,6 +41,14 @@ func TestNewCmdRemove(t *testing.T) {
OrgName: "anOrg",
},
},
{
name: "env",
cli: "cool --env anEnv",
wants: RemoveOptions{
SecretName: "cool",
EnvName: "anEnv",
},
},
}
for _, tt := range tests {
@ -72,6 +80,7 @@ func TestNewCmdRemove(t *testing.T) {
assert.Equal(t, tt.wants.SecretName, gotOpts.SecretName)
assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
assert.Equal(t, tt.wants.EnvName, gotOpts.EnvName)
})
}
@ -106,6 +115,36 @@ func Test_removeRun_repo(t *testing.T) {
reg.Verify(t)
}
func Test_removeRun_env(t *testing.T) {
reg := &httpmock.Registry{}
reg.Register(
httpmock.REST("DELETE", "repos/owner/repo/environments/development/secrets/cool_secret"),
httpmock.StatusStringResponse(204, "No Content"))
io, _, _, _ := iostreams.Test()
opts := &RemoveOptions{
IO: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("owner/repo")
},
SecretName: "cool_secret",
EnvName: "development",
}
err := removeRun(opts)
assert.NoError(t, err)
reg.Verify(t)
}
func Test_removeRun_org(t *testing.T) {
tests := []struct {
name string

View file

@ -15,8 +15,8 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command {
Use: "secret <command>",
Short: "Manage GitHub secrets",
Long: heredoc.Doc(`
Secrets can be set at the repository or organization level for use in GitHub Actions.
Run "gh help secret set" to learn how to get started.
Secrets can be set at the repository, environment, or organization level for use in
GitHub Actions. Run "gh help secret set" to learn how to get started.
`),
}

View file

@ -55,6 +55,11 @@ func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) {
ghrepo.FullName(repo)))
}
func getEnvPubKey(client *api.Client, repo ghrepo.Interface, envName string) (*PubKey, error) {
return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/environments/%s/secrets/public-key",
ghrepo.FullName(repo), envName))
}
func putSecret(client *api.Client, host, path string, payload SecretPayload) error {
payloadBytes, err := json.Marshal(payload)
if err != nil {
@ -90,6 +95,15 @@ func putOrgSecret(client *api.Client, host string, pk *PubKey, opts SetOptions,
return putSecret(client, host, path, payload)
}
func putEnvSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, envName string, secretName, eValue string) error {
payload := SecretPayload{
EncryptedValue: eValue,
KeyID: pk.ID,
}
path := fmt.Sprintf("repos/%s/environments/%s/secrets/%s", ghrepo.FullName(repo), envName, secretName)
return putSecret(client, repo.RepoHost(), path, payload)
}
func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string) error {
payload := SecretPayload{
EncryptedValue: eValue,

View file

@ -33,6 +33,7 @@ type SetOptions struct {
SecretName string
OrgName string
EnvName string
Body string
Visibility string
RepositoryNames []string
@ -48,7 +49,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
cmd := &cobra.Command{
Use: "set <secret-name>",
Short: "Create or update secrets",
Long: "Locally encrypt a new or updated secret at either the repository or organization level and send it to GitHub for storage.",
Long: "Locally encrypt a new or updated secret at either the repository, environment, or organization level and send it to GitHub for storage.",
Example: heredoc.Doc(`
Paste secret in prompt
$ gh secret set MYSECRET
@ -59,6 +60,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
Use file as secret value
$ gh secret set MYSECRET < file.json
Set environment level secret
$ gh secret set MYSECRET -bval --env=anEnv
Set organization level secret visible to entire organization
$ gh secret set MYSECRET -bval --org=anOrg --visibility=all
@ -75,6 +79,10 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil {
return err
}
opts.SecretName = args[0]
err := validSecretName(opts.SecretName)
@ -115,7 +123,8 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
return setRun(opts)
},
}
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization")
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Set a secret for an organization")
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Set a secret for an environment")
cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`")
cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility")
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "A value for the secret. Reads from STDIN if not specified.")
@ -136,6 +145,7 @@ func setRun(opts *SetOptions) error {
client := api.NewClientFromHTTP(c)
orgName := opts.OrgName
envName := opts.EnvName
var baseRepo ghrepo.Interface
if orgName == "" {
@ -158,6 +168,8 @@ func setRun(opts *SetOptions) error {
var pk *PubKey
if orgName != "" {
pk, err = getOrgPublicKey(client, host, orgName)
} else if envName != "" {
pk, err = getEnvPubKey(client, baseRepo, envName)
} else {
pk, err = getRepoPubKey(client, baseRepo)
}
@ -174,6 +186,8 @@ func setRun(opts *SetOptions) error {
if orgName != "" {
err = putOrgSecret(client, host, pk, *opts, encoded)
} else if envName != "" {
err = putEnvSecret(client, pk, baseRepo, envName, opts.SecretName, encoded)
} else {
err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded)
}

View file

@ -100,6 +100,17 @@ func TestNewCmdSet(t *testing.T) {
OrgName: "",
},
},
{
name: "env",
cli: `cool_secret -b"a secret" -eRelease`,
wants: SetOptions{
SecretName: "cool_secret",
Visibility: shared.Private,
Body: "a secret",
OrgName: "",
EnvName: "Release",
},
},
{
name: "vis all",
cli: `cool_secret --org coolOrg -b"cool" -vall`,
@ -160,6 +171,7 @@ func TestNewCmdSet(t *testing.T) {
assert.Equal(t, tt.wants.Body, gotOpts.Body)
assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility)
assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
assert.Equal(t, tt.wants.EnvName, gotOpts.EnvName)
assert.ElementsMatch(t, tt.wants.RepositoryNames, gotOpts.RepositoryNames)
})
}
@ -204,6 +216,46 @@ func Test_setRun_repo(t *testing.T) {
assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=")
}
func Test_setRun_env(t *testing.T) {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("GET", "repos/owner/repo/environments/development/secrets/public-key"),
httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="}))
reg.Register(httpmock.REST("PUT", "repos/owner/repo/environments/development/secrets/cool_secret"), httpmock.StatusStringResponse(201, `{}`))
io, _, _, _ := iostreams.Test()
opts := &SetOptions{
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
Config: func() (config.Config, error) { return config.NewBlankConfig(), nil },
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("owner/repo")
},
EnvName: "development",
IO: io,
SecretName: "cool_secret",
Body: "a secret",
// Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7
RandomOverride: bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}),
}
err := setRun(opts)
assert.NoError(t, err)
reg.Verify(t)
data, err := ioutil.ReadAll(reg.Requests[1].Body)
assert.NoError(t, err)
var payload SecretPayload
err = json.Unmarshal(data, &payload)
assert.NoError(t, err)
assert.Equal(t, payload.KeyID, "123")
assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=")
}
func Test_setRun_org(t *testing.T) {
tests := []struct {
name string

View file

@ -12,14 +12,18 @@ func EnableRepoOverride(cmd *cobra.Command, f *Factory) {
cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
repoOverride, _ := cmd.Flags().GetString("repo")
if repoFromEnv := os.Getenv("GH_REPO"); repoOverride == "" && repoFromEnv != "" {
repoOverride = repoFromEnv
}
if repoOverride != "" {
// NOTE: this mutates the factory
f.BaseRepo = func() (ghrepo.Interface, error) {
return ghrepo.FromFullName(repoOverride)
}
}
f.BaseRepo = OverrideBaseRepoFunc(f, repoOverride)
}
}
func OverrideBaseRepoFunc(f *Factory, override string) func() (ghrepo.Interface, error) {
if override == "" {
override = os.Getenv("GH_REPO")
}
if override != "" {
return func() (ghrepo.Interface, error) {
return ghrepo.FromFullName(override)
}
}
return f.BaseRepo
}

View file

@ -2,27 +2,10 @@ package httpmock
import (
"fmt"
"net/http"
"os"
)
// TODO: clean up methods in this file when there are no more callers
func (r *Registry) StubWithFixturePath(status int, fixturePath string) func() {
fixtureFile, err := os.Open(fixturePath)
r.Register(MatchAny, func(req *http.Request) (*http.Response, error) {
if err != nil {
return nil, err
}
return httpResponse(200, req, fixtureFile), nil
})
return func() {
if err == nil {
fixtureFile.Close()
}
}
}
func (r *Registry) StubRepoInfoResponse(owner, repo, branch string) {
r.Register(
GraphQL(`query RepositoryInfo\b`),

View file

@ -140,6 +140,10 @@ func (s *IOStreams) SetPager(cmd string) {
s.pagerCommand = cmd
}
func (s *IOStreams) GetPager() string {
return s.pagerCommand
}
func (s *IOStreams) StartPager() error {
if s.pagerCommand == "" || s.pagerCommand == "cat" || !s.IsStdoutTTY() {
return nil
@ -202,6 +206,10 @@ func (s *IOStreams) CanPrompt() bool {
return s.IsStdinTTY() && s.IsStdoutTTY()
}
func (s *IOStreams) GetNeverPrompt() bool {
return s.neverPrompt
}
func (s *IOStreams) SetNeverPrompt(v bool) {
s.neverPrompt = v
}
@ -281,13 +289,6 @@ func System() *IOStreams {
stdoutIsTTY := isTerminal(os.Stdout)
stderrIsTTY := isTerminal(os.Stderr)
var pagerCommand string
if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists {
pagerCommand = ghPager
} else {
pagerCommand = os.Getenv("PAGER")
}
io := &IOStreams{
In: os.Stdin,
originalOut: os.Stdout,
@ -295,7 +296,7 @@ func System() *IOStreams {
ErrOut: colorable.NewColorable(os.Stderr),
colorEnabled: EnvColorForced() || (!EnvColorDisabled() && stdoutIsTTY),
is256enabled: Is256ColorSupported(),
pagerCommand: pagerCommand,
pagerCommand: os.Getenv("PAGER"),
}
if stdoutIsTTY && stderrIsTTY {

View file

@ -18,7 +18,7 @@ var Confirm = func(prompt string, result *bool) error {
Message: prompt,
Default: true,
}
return survey.AskOne(p, result)
return SurveyAskOne(p, result)
}
var SurveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {

View file

@ -35,6 +35,8 @@ type GhEditor struct {
*survey.Editor
EditorCommand string
BlankAllowed bool
lookPath func(string) ([]string, []string, error)
}
func (e *GhEditor) editorCommand() string {
@ -105,7 +107,7 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int
}
if r == '\r' || r == '\n' {
if e.BlankAllowed {
return "", nil
return initialValue, nil
} else {
continue
}
@ -136,7 +138,11 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int
}
stdio := e.Stdio()
text, err := Edit(e.editorCommand(), e.FileName, initialValue, stdio.In, stdio.Out, stdio.Err, cursor)
lookPath := e.lookPath
if lookPath == nil {
lookPath = defaultLookPath
}
text, err := edit(e.editorCommand(), e.FileName, initialValue, stdio.In, stdio.Out, stdio.Err, cursor, lookPath)
if err != nil {
return "", err
}

View file

@ -16,6 +16,18 @@ type showable interface {
}
func Edit(editorCommand, fn, initialValue string, stdin io.Reader, stdout io.Writer, stderr io.Writer, cursor showable) (string, error) {
return edit(editorCommand, fn, initialValue, stdin, stdout, stderr, cursor, defaultLookPath)
}
func defaultLookPath(name string) ([]string, []string, error) {
exe, err := safeexec.LookPath(name)
if err != nil {
return nil, nil, err
}
return []string{exe}, nil, nil
}
func edit(editorCommand, fn, initialValue string, stdin io.Reader, stdout io.Writer, stderr io.Writer, cursor showable, lookPath func(string) ([]string, []string, error)) (string, error) {
// prepare the temp file
pattern := fn
if pattern == "" {
@ -56,12 +68,14 @@ func Edit(editorCommand, fn, initialValue string, stdin io.Reader, stdout io.Wri
}
args = append(args, f.Name())
editorExe, err := safeexec.LookPath(args[0])
editorExe, env, err := lookPath(args[0])
if err != nil {
return "", err
}
args = append(editorExe, args[1:]...)
cmd := exec.Command(editorExe, args[1:]...)
cmd := exec.Command(args[0], args[1:]...)
cmd.Env = env
cmd.Stdin = stdin
cmd.Stdout = stdout
cmd.Stderr = stderr

View file

@ -0,0 +1,284 @@
package surveyext
import (
"bytes"
"errors"
"fmt"
"os"
"strings"
"sync"
"testing"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/terminal"
pseudotty "github.com/creack/pty"
"github.com/stretchr/testify/assert"
)
func testLookPath(s string) ([]string, []string, error) {
return []string{os.Args[0], "-test.run=TestHelperProcess", "--", s}, []string{"GH_WANT_HELPER_PROCESS=1"}, nil
}
func TestHelperProcess(t *testing.T) {
if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" {
return
}
if err := func(args []string) error {
switch args[0] {
// "vim" appends a message to the file
case "vim":
f, err := os.OpenFile(args[1], os.O_APPEND|os.O_WRONLY, 0)
if err != nil {
return err
}
defer f.Close()
_, err = f.WriteString(" - added by vim")
return err
// "nano" truncates the contents of the file
case "nano":
f, err := os.OpenFile(args[1], os.O_TRUNC|os.O_WRONLY, 0)
if err != nil {
return err
}
return f.Close()
default:
return fmt.Errorf("unrecognized arguments: %#v\n", args)
}
}(os.Args[3:]); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
os.Exit(0)
}
func Test_GhEditor_Prompt_skip(t *testing.T) {
pty := newTerminal(t)
e := &GhEditor{
BlankAllowed: true,
EditorCommand: "vim",
Editor: &survey.Editor{
Message: "Body",
FileName: "*.md",
Default: "initial value",
HideDefault: true,
AppendDefault: true,
},
lookPath: func(s string) ([]string, []string, error) {
return nil, nil, errors.New("no editor allowed")
},
}
e.WithStdio(pty.Stdio())
// wait until the prompt is rendered and send the Enter key
go func() {
pty.WaitForOutput("Body")
assert.Equal(t, "\x1b[0G\x1b[2K\x1b[0;1;92m? \x1b[0m\x1b[0;1;99mBody \x1b[0m\x1b[0;36m[(e) to launch vim, enter to skip] \x1b[0m", normalizeANSI(pty.Output()))
pty.ResetOutput()
assert.NoError(t, pty.SendKey('\n'))
}()
res, err := e.Prompt(defaultPromptConfig())
assert.NoError(t, err)
assert.Equal(t, "initial value", res)
assert.Equal(t, "", normalizeANSI(pty.Output()))
}
func Test_GhEditor_Prompt_editorAppend(t *testing.T) {
pty := newTerminal(t)
e := &GhEditor{
BlankAllowed: true,
EditorCommand: "vim",
Editor: &survey.Editor{
Message: "Body",
FileName: "*.md",
Default: "initial value",
HideDefault: true,
AppendDefault: true,
},
lookPath: testLookPath,
}
e.WithStdio(pty.Stdio())
// wait until the prompt is rendered and send the 'e' key
go func() {
pty.WaitForOutput("Body")
assert.Equal(t, "\x1b[0G\x1b[2K\x1b[0;1;92m? \x1b[0m\x1b[0;1;99mBody \x1b[0m\x1b[0;36m[(e) to launch vim, enter to skip] \x1b[0m", normalizeANSI(pty.Output()))
pty.ResetOutput()
assert.NoError(t, pty.SendKey('e'))
}()
res, err := e.Prompt(defaultPromptConfig())
assert.NoError(t, err)
assert.Equal(t, "initial value - added by vim", res)
assert.Equal(t, "", normalizeANSI(pty.Output()))
}
func Test_GhEditor_Prompt_editorTruncate(t *testing.T) {
pty := newTerminal(t)
e := &GhEditor{
BlankAllowed: true,
EditorCommand: "nano",
Editor: &survey.Editor{
Message: "Body",
FileName: "*.md",
Default: "initial value",
HideDefault: true,
AppendDefault: true,
},
lookPath: testLookPath,
}
e.WithStdio(pty.Stdio())
// wait until the prompt is rendered and send the 'e' key
go func() {
pty.WaitForOutput("Body")
assert.Equal(t, "\x1b[0G\x1b[2K\x1b[0;1;92m? \x1b[0m\x1b[0;1;99mBody \x1b[0m\x1b[0;36m[(e) to launch nano, enter to skip] \x1b[0m", normalizeANSI(pty.Output()))
pty.ResetOutput()
assert.NoError(t, pty.SendKey('e'))
}()
res, err := e.Prompt(defaultPromptConfig())
assert.NoError(t, err)
assert.Equal(t, "", res)
assert.Equal(t, "", normalizeANSI(pty.Output()))
}
// survey doesn't expose this
func defaultPromptConfig() *survey.PromptConfig {
return &survey.PromptConfig{
PageSize: 7,
HelpInput: "?",
SuggestInput: "tab",
Icons: survey.IconSet{
Error: survey.Icon{
Text: "X",
Format: "red",
},
Help: survey.Icon{
Text: "?",
Format: "cyan",
},
Question: survey.Icon{
Text: "?",
Format: "green+hb",
},
MarkedOption: survey.Icon{
Text: "[x]",
Format: "green",
},
UnmarkedOption: survey.Icon{
Text: "[ ]",
Format: "default+hb",
},
SelectFocus: survey.Icon{
Text: ">",
Format: "cyan+b",
},
},
Filter: func(filter string, value string, index int) (include bool) {
filter = strings.ToLower(filter)
return strings.Contains(strings.ToLower(value), filter)
},
KeepFilter: false,
}
}
type testTerminal struct {
pty *os.File
tty *os.File
stdout *teeWriter
stderr *teeWriter
}
func newTerminal(t *testing.T) *testTerminal {
t.Helper()
pty, tty, err := pseudotty.Open()
if errors.Is(err, pseudotty.ErrUnsupported) {
t.SkipNow()
return nil
}
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
pty.Close()
tty.Close()
})
if err := pseudotty.Setsize(tty, &pseudotty.Winsize{Cols: 72, Rows: 30}); err != nil {
t.Fatal(err)
}
return &testTerminal{pty: pty, tty: tty}
}
func (t *testTerminal) SendKey(c rune) error {
_, err := t.pty.WriteString(string(c))
return err
}
func (t *testTerminal) WaitForOutput(s string) {
for {
time.Sleep(time.Millisecond)
if strings.Contains(t.stdout.String(), s) {
return
}
}
}
func (t *testTerminal) Output() string {
return t.stdout.String()
}
func (t *testTerminal) ResetOutput() {
t.stdout.Reset()
}
func (t *testTerminal) Stdio() terminal.Stdio {
t.stdout = &teeWriter{File: t.tty}
t.stderr = t.stdout
return terminal.Stdio{
In: t.tty,
Out: t.stdout,
Err: t.stderr,
}
}
// teeWriter is a writer that duplicates all writes to a file into a buffer
type teeWriter struct {
*os.File
buf bytes.Buffer
mu sync.Mutex
}
func (f *teeWriter) Write(p []byte) (n int, err error) {
f.mu.Lock()
defer f.mu.Unlock()
_, _ = f.buf.Write(p)
return f.File.Write(p)
}
func (f *teeWriter) String() string {
f.mu.Lock()
s := f.buf.String()
f.mu.Unlock()
return s
}
func (f *teeWriter) Reset() {
f.mu.Lock()
f.buf.Reset()
f.mu.Unlock()
}
// strips some ANSI escape sequences that we do not want tests to be concerned with
func normalizeANSI(t string) string {
t = strings.ReplaceAll(t, "\x1b[?25h", "") // strip sequence that shows cursor
t = strings.ReplaceAll(t, "\x1b[?25l", "") // strip sequence that hides cursor
return t
}

1
script/build.bat Normal file
View file

@ -0,0 +1 @@
go run script\build.go %*

View file

@ -23,6 +23,7 @@
package main
import (
"errors"
"fmt"
"io/ioutil"
"os"
@ -136,8 +137,14 @@ func date() string {
func sourceFilesLaterThan(t time.Time) bool {
foundLater := false
_ = filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
if err != nil {
// Ignore errors that occur when the project contains a symlink to a filesystem or volume that
// Windows doesn't have access to.
if path != "." && isAccessDenied(err) {
fmt.Fprintf(os.Stderr, "%s: %v\n", path, err)
return nil
}
return err
}
if foundLater {
@ -151,6 +158,9 @@ func sourceFilesLaterThan(t time.Time) bool {
}
}
if info.IsDir() {
if name := filepath.Base(path); name == "vendor" || name == "node_modules" {
return filepath.SkipDir
}
return nil
}
if path == "go.mod" || path == "go.sum" || (strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go")) {
@ -160,9 +170,18 @@ func sourceFilesLaterThan(t time.Time) bool {
}
return nil
})
if err != nil {
panic(err)
}
return foundLater
}
func isAccessDenied(err error) bool {
var pe *os.PathError
// we would use `syscall.ERROR_ACCESS_DENIED` if this script supported build tags
return errors.As(err, &pe) && strings.Contains(pe.Err.Error(), "Access is denied")
}
func rmrf(targets ...string) error {
args := append([]string{"rm", "-rf"}, targets...)
announce(args...)