commit
0174dbcdc4
37 changed files with 1157 additions and 348 deletions
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -49,9 +49,7 @@ For more information and distro-specific instructions, see the [Linux installati
|
|||
|
||||
| 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
@ -45,7 +46,6 @@ const (
|
|||
func main() {
|
||||
code := mainRun()
|
||||
os.Exit(int(code))
|
||||
|
||||
}
|
||||
|
||||
func mainRun() exitCode {
|
||||
|
|
@ -141,15 +141,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -265,7 +277,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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
1
go.mod
|
|
@ -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
2
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,11 +62,3 @@ func stubConfig(main, hosts string) func() {
|
|||
ReadConfigFile = orig
|
||||
}
|
||||
}
|
||||
|
||||
func stubMigrateConfigDir() func() {
|
||||
orig := migrateConfigDir
|
||||
migrateConfigDir = func(_, _ string) {}
|
||||
return func() {
|
||||
migrateConfigDir = orig
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -43,7 +43,7 @@ func Test_NewCmdApi(t *testing.T) {
|
|||
RequestInputFile: "",
|
||||
RawFields: []string(nil),
|
||||
MagicFields: []string(nil),
|
||||
RequestHeaders: []string(nil),
|
||||
RequestHeaders: []string(nil),
|
||||
ShowResponseHeaders: false,
|
||||
Paginate: false,
|
||||
Silent: false,
|
||||
|
|
|
|||
|
|
@ -37,12 +37,13 @@ type BrowseOptions struct {
|
|||
type exitCode int
|
||||
|
||||
const (
|
||||
exitSuccess exitCode = 0
|
||||
exitNotInRepo exitCode = 1
|
||||
exitTooManyFlags exitCode = 2
|
||||
exitTooManyArgs exitCode = 3
|
||||
exitExpectedArg exitCode = 4
|
||||
exitInvalidCombo exitCode = 5
|
||||
exitUrlSuccess exitCode = 0
|
||||
exitNonUrlSuccess exitCode = 1
|
||||
exitNotInRepo exitCode = 2
|
||||
exitTooManyFlags exitCode = 3
|
||||
exitTooManyArgs exitCode = 4
|
||||
exitExpectedArg exitCode = 5
|
||||
exitInvalidCombo exitCode = 6
|
||||
)
|
||||
|
||||
func NewCmdBrowse(f *cmdutil.Factory) *cobra.Command {
|
||||
|
|
@ -55,32 +56,38 @@ func NewCmdBrowse(f *cmdutil.Factory) *cobra.Command {
|
|||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
Long: "Work with GitHub in the browser", // displays when you are on the help page of this command
|
||||
Short: "Open GitHub in the browser", // displays in the gh root help
|
||||
Use: "browse {<number> | <file> | <branch>}", // necessary!!! This is the cmd that gets passed on the prompt
|
||||
Args: cobra.RangeArgs(0, 2), // make sure only one arg at most is passed
|
||||
Long: "Open specific pages in the browser on GitHub",
|
||||
Short: "Open GitHub in the browser",
|
||||
Use: "browse {<number> | <path>}",
|
||||
Args: cobra.RangeArgs(0, 2),
|
||||
Example: heredoc.Doc(`
|
||||
$ gh browse
|
||||
#=> Opens repository in browser
|
||||
#=> Open current repository to the default branch
|
||||
|
||||
$ gh browse 217
|
||||
#=> Opens issue or pull request 217
|
||||
#=> Open issue or pull request 217
|
||||
|
||||
$ gh browse --settings
|
||||
#=> Opens repository settings in browser
|
||||
#=> Open repository settings
|
||||
|
||||
$ gh browse src/fileName:312 --branch main
|
||||
#=> Opens src/fileName at line 312 in main branch
|
||||
$ gh browse main.go:312
|
||||
#=> Open main.go at line 312
|
||||
|
||||
$ gh browse main.go --branch main
|
||||
#=> Open main.go in the main branch
|
||||
`),
|
||||
Annotations: map[string]string{
|
||||
"IsCore": "true",
|
||||
"help:arguments": heredoc.Doc(`
|
||||
A browse location can be supplied as an argument in any of the following formats:
|
||||
- by number, e.g. "123"; or
|
||||
- by file or branch name, e.g. "main.java" or "trunk".
|
||||
A browser location can be specified using arguments in the following format:
|
||||
- by number for issue or pull request, e.g. "123"; or
|
||||
- by path for opening folders and files, e.g. "cmd/gh/main.go"
|
||||
`),
|
||||
"help:environment": heredoc.Doc(`
|
||||
To configure a web browser other than the default, use the BROWSER environment variable
|
||||
`),
|
||||
},
|
||||
Run: func(cmd *cobra.Command, args []string) {
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
|
||||
if len(args) > 1 {
|
||||
opts.AdditionalArg = args[1]
|
||||
|
|
@ -89,37 +96,35 @@ func NewCmdBrowse(f *cmdutil.Factory) *cobra.Command {
|
|||
if len(args) > 0 {
|
||||
opts.SelectorArg = args[0]
|
||||
}
|
||||
openInBrowser(cmd, opts) // run gets rid of the usage / runs function
|
||||
return openInBrowser(cmd, opts)
|
||||
},
|
||||
}
|
||||
cmdutil.EnableRepoOverride(cmd, f)
|
||||
cmd.Flags().BoolVarP(&opts.ProjectsFlag, "projects", "p", false, "Open projects tab in browser")
|
||||
cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Opens the wiki in browser")
|
||||
cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Opens the settings in browser")
|
||||
cmd.Flags().BoolVarP(&opts.BranchFlag, "branch", "b", false, "Opens a branch in the browser")
|
||||
cmd.Flags().BoolVarP(&opts.ProjectsFlag, "projects", "p", false, "Open repository projects")
|
||||
cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki")
|
||||
cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings")
|
||||
cmd.Flags().BoolVarP(&opts.BranchFlag, "branch", "b", false, "Select another branch by passing in the branch name")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func openInBrowser(cmd *cobra.Command, opts *BrowseOptions) (exitCode, string) {
|
||||
func openInBrowser(cmd *cobra.Command, opts *BrowseOptions) error {
|
||||
|
||||
baseRepo, err := opts.BaseRepo()
|
||||
httpClient, _ := opts.HttpClient()
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
branchName, err := api.RepoDefaultBranch(apiClient, baseRepo)
|
||||
|
||||
if !inRepo(err) { // must be in a repo to execute
|
||||
printExit(exitNotInRepo, cmd, opts, "")
|
||||
return exitNotInRepo, ""
|
||||
if !inRepo(err) {
|
||||
return printExit(exitNotInRepo, cmd, opts, "")
|
||||
}
|
||||
|
||||
if getFlagAmount(cmd) > 1 { // command can't have more than one flag
|
||||
printExit(exitTooManyFlags, cmd, opts, "")
|
||||
return exitTooManyFlags, ""
|
||||
if getFlagAmount(cmd) > 1 {
|
||||
return printExit(exitTooManyFlags, cmd, opts, "")
|
||||
}
|
||||
|
||||
repoUrl := ghrepo.GenerateRepoURL(baseRepo, "")
|
||||
response := exitSuccess
|
||||
response := exitUrlSuccess
|
||||
|
||||
if !hasArg(opts) && hasFlag(cmd) {
|
||||
response, repoUrl = addFlag(opts, repoUrl)
|
||||
|
|
@ -129,40 +134,40 @@ func openInBrowser(cmd *cobra.Command, opts *BrowseOptions) (exitCode, string) {
|
|||
response, repoUrl = addCombined(opts, repoUrl, branchName)
|
||||
}
|
||||
|
||||
if response == exitSuccess {
|
||||
opts.Browser.Browse(repoUrl) // otherwise open repo
|
||||
if response == exitUrlSuccess || response == exitNonUrlSuccess {
|
||||
opts.Browser.Browse(repoUrl)
|
||||
}
|
||||
|
||||
printExit(response, cmd, opts, repoUrl) // print success
|
||||
return response, repoUrl
|
||||
return printExit(response, cmd, opts, repoUrl)
|
||||
|
||||
}
|
||||
|
||||
func addCombined(opts *BrowseOptions, url string, branchName string) (exitCode, string) {
|
||||
|
||||
if !opts.BranchFlag { // gh browse --settings main.go
|
||||
if !opts.BranchFlag {
|
||||
return exitInvalidCombo, ""
|
||||
}
|
||||
|
||||
if opts.AdditionalArg == "" {
|
||||
return exitSuccess, url + "/tree/" + opts.SelectorArg
|
||||
return exitUrlSuccess, url + "/tree/" + opts.SelectorArg
|
||||
}
|
||||
|
||||
arr := parseFileArg(opts)
|
||||
if len(arr) > 1 {
|
||||
return exitSuccess, url + "/tree/" + opts.AdditionalArg + "/" + arr[0] + "#L" + arr[1]
|
||||
return exitUrlSuccess, url + "/tree/" + opts.AdditionalArg + "/" + arr[0] + "#L" + arr[1]
|
||||
}
|
||||
|
||||
return exitSuccess, url + "/tree/" + opts.AdditionalArg + "/" + arr[0]
|
||||
return exitUrlSuccess, url + "/tree/" + opts.AdditionalArg + "/" + arr[0]
|
||||
|
||||
}
|
||||
|
||||
func addFlag(opts *BrowseOptions, url string) (exitCode, string) {
|
||||
if opts.ProjectsFlag {
|
||||
return exitSuccess, url + "/projects"
|
||||
return exitUrlSuccess, url + "/projects"
|
||||
} else if opts.SettingsFlag {
|
||||
return exitSuccess, url + "/settings"
|
||||
return exitUrlSuccess, url + "/settings"
|
||||
} else if opts.WikiFlag {
|
||||
return exitSuccess, url + "/wiki"
|
||||
return exitUrlSuccess, url + "/wiki"
|
||||
}
|
||||
return exitExpectedArg, "" // Flag is a branch and needs an argument
|
||||
}
|
||||
|
|
@ -175,15 +180,15 @@ func addArg(opts *BrowseOptions, url string, branchName string) (exitCode, strin
|
|||
|
||||
if isNumber(opts.SelectorArg) {
|
||||
url += "/issues/" + opts.SelectorArg
|
||||
return exitSuccess, url
|
||||
return exitNonUrlSuccess, url
|
||||
}
|
||||
|
||||
arr := parseFileArg(opts)
|
||||
if len(arr) > 1 {
|
||||
return exitSuccess, url + "/tree/" + branchName + "/" + arr[0] + "#L" + arr[1]
|
||||
return exitUrlSuccess, url + "/tree/" + branchName + "/" + arr[0] + "#L" + arr[1]
|
||||
}
|
||||
|
||||
return exitSuccess, url + "/tree/" + branchName + "/" + arr[0]
|
||||
return exitUrlSuccess, url + "/tree/" + branchName + "/" + arr[0]
|
||||
}
|
||||
|
||||
func parseFileArg(opts *BrowseOptions) []string {
|
||||
|
|
@ -191,37 +196,30 @@ func parseFileArg(opts *BrowseOptions) []string {
|
|||
return arr
|
||||
}
|
||||
|
||||
func printExit(exit exitCode, cmd *cobra.Command, opts *BrowseOptions, url string) {
|
||||
func printExit(exit exitCode, cmd *cobra.Command, opts *BrowseOptions, url string) error {
|
||||
w := opts.IO.ErrOut
|
||||
cs := opts.IO.ColorScheme()
|
||||
help := "Use 'gh browse --help' for more information about browse\n"
|
||||
|
||||
switch exit {
|
||||
case exitSuccess:
|
||||
fmt.Fprintf(w, "%s now opening %s in browser . . .\n",
|
||||
cs.Green("✓"), cs.Bold(url))
|
||||
case exitUrlSuccess:
|
||||
fmt.Fprintf(w, "now opening %s in browser . . .\n", cs.Bold(url))
|
||||
break
|
||||
case exitNonUrlSuccess:
|
||||
fmt.Fprintf(w, "now opening issue/pr in browser . . .\n")
|
||||
break
|
||||
case exitNotInRepo:
|
||||
fmt.Fprintf(w, "%s change directory to a repository to open in browser\n%s",
|
||||
cs.Red("x"), help)
|
||||
break
|
||||
return fmt.Errorf("change directory to a repository to open in browser\n%s", help)
|
||||
case exitTooManyFlags:
|
||||
fmt.Fprintf(w, "%s accepts 1 flag, %d flag(s) were recieved\n%s",
|
||||
cs.Red("x"), getFlagAmount(cmd), help)
|
||||
break
|
||||
return fmt.Errorf("accepts 1 flag, %d flag(s) were recieved\n%s", getFlagAmount(cmd), help)
|
||||
case exitTooManyArgs:
|
||||
fmt.Fprintf(w, "%s accepts 1 arg, 2 arg(s) were received \n%s",
|
||||
cs.Red("x"), help)
|
||||
break
|
||||
return fmt.Errorf("accepts 1 arg, 2 arg(s) were received \n%s", help)
|
||||
case exitExpectedArg:
|
||||
fmt.Fprintf(w, "%s expected argument with this flag %s\n%s",
|
||||
cs.Red("x"), cs.Bold(url), help)
|
||||
break
|
||||
return fmt.Errorf("expected argument with this flag %s\n%s", cs.Bold(url), help)
|
||||
case exitInvalidCombo:
|
||||
fmt.Fprintf(w, "%s invalid use of flag and argument\n%s",
|
||||
cs.Red("x"), help)
|
||||
break
|
||||
return fmt.Errorf("invalid use of flag and argument\n%s", help)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func hasFlag(cmd *cobra.Command) bool {
|
||||
|
|
|
|||
|
|
@ -1,116 +0,0 @@
|
|||
package browse
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"testing"
|
||||
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/cli/cli/test"
|
||||
"github.com/google/shlex"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func TestNewCmdBrowse(t *testing.T) {
|
||||
// TODO test the use of the api using "gh browse"
|
||||
// instead of opening multiple browsers for each test,
|
||||
// we can test the http code sent back after calling a site
|
||||
|
||||
}
|
||||
|
||||
func runCommand(isTTY bool, cli string) (*test.CmdOut, error) {
|
||||
io, _, stdout, stderr := iostreams.Test()
|
||||
io.SetStdoutTTY(isTTY)
|
||||
io.SetStdinTTY(isTTY)
|
||||
io.SetStderrTTY(isTTY)
|
||||
|
||||
factory := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
return &http.Client{Transport: rt}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.New("OWNER", "REPO"), nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd := NewCmdBrowse(factory)
|
||||
|
||||
argv, err := shlex.Split(cli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd.SetArgs(argv)
|
||||
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(ioutil.Discard)
|
||||
cmd.SetErr(ioutil.Discard)
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
return &test.CmdOut{
|
||||
OutBuf: stdout,
|
||||
ErrBuf: stderr,
|
||||
}, err
|
||||
}
|
||||
|
||||
func TestBrowseOpen(t *testing.T) {
|
||||
runCommand(true, "")
|
||||
}
|
||||
|
||||
func Test_browseList(t *testing.T) {
|
||||
type args struct {
|
||||
repo ghrepo.Interface
|
||||
cli string
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
urlExpected string
|
||||
exitExpected exitCode
|
||||
}{}
|
||||
for _, test := range tests {
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
func createCommand(repo ghrepo.Interface, cli string) *cobra.Command {
|
||||
io, _, stdout, stderr := iostreams.Test()
|
||||
io.SetStdoutTTY(false)
|
||||
io.SetStdinTTY(false) // Ask the team about TTY
|
||||
io.SetStderrTTY(false)
|
||||
|
||||
factory := &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
Config: func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.New("OWNER", "REPO"), nil
|
||||
},
|
||||
}
|
||||
|
||||
cmd := NewCmdView(factory, nil)
|
||||
|
||||
argv, err := shlex.Split(cli)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
cmd.SetArgs(argv)
|
||||
|
||||
cmd.SetIn(&bytes.Buffer{})
|
||||
cmd.SetOut(ioutil.Discard)
|
||||
cmd.SetErr(ioutil.Discard)
|
||||
|
||||
_, err = cmd.ExecuteC()
|
||||
return &test.CmdOut{
|
||||
OutBuf: stdout,
|
||||
ErrBuf: stderr,
|
||||
}, err
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
|
||||
Slack channel for help
|
||||
https://app.slack.com/client/T021TMCB3EU/C01VB0F524
|
||||
|
||||
Project layout
|
||||
https://github.com/cli/cli/blob/trunk/docs/project-layout.md
|
||||
|
||||
Our working fork
|
||||
https://github.com/bchadwic/cli/tree/trunk
|
||||
|
||||
https://umarcor.github.io/cobra/#example
|
||||
|
||||
5/15/21
|
||||
Moving forward, since we can open in the browser we need to discuss logic of arguments
|
||||
|
||||
This includes:
|
||||
1. Open the repo in browser with just "gh browse"
|
||||
2. Make the help instruction display while opening using IOStreams
|
||||
3. Find out how "cmd" stores args within, to be parsed
|
||||
4. Make a table of all the possible inputs that are valid
|
||||
5. Possibly work on error handling
|
||||
6. Clarify table with client before moving forward with args
|
||||
77
pkg/cmd/extensions/command.go
Normal file
77
pkg/cmd/extensions/command.go
Normal 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
|
||||
}
|
||||
121
pkg/cmd/extensions/manager.go
Normal file
121
pkg/cmd/extensions/manager.go
Normal 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")
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -21,10 +21,12 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"requestedReviewer": {
|
||||
"__typename": "Team",
|
||||
"name": "Team 1"
|
||||
}
|
||||
"requestedReviewer": {
|
||||
"__typename": "Team",
|
||||
"name": "Team 1",
|
||||
"slug": "team-1",
|
||||
"organization": {"login": "my-org"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"requestedReviewer": {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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}`,
|
||||
|
|
|
|||
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import (
|
|||
browseCmd "github.com/cli/cli/pkg/cmd/browse"
|
||||
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"
|
||||
|
|
@ -81,6 +82,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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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 ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
`),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -90,6 +90,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,
|
||||
|
|
|
|||
|
|
@ -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 == "" {
|
||||
|
|
@ -174,6 +184,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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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/actions/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
|
||||
|
|
|
|||
|
|
@ -105,7 +105,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
|
||||
}
|
||||
|
|
|
|||
132
pkg/surveyext/editor_test.go
Normal file
132
pkg/surveyext/editor_test.go
Normal file
|
|
@ -0,0 +1,132 @@
|
|||
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"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func Test_GhEditor_Prompt(t *testing.T) {
|
||||
e := &GhEditor{
|
||||
BlankAllowed: true,
|
||||
EditorCommand: "false",
|
||||
Editor: &survey.Editor{
|
||||
Message: "Body",
|
||||
FileName: "*.md",
|
||||
Default: "initial value",
|
||||
HideDefault: true,
|
||||
AppendDefault: true,
|
||||
},
|
||||
}
|
||||
|
||||
pty, tty, err := pseudotty.Open()
|
||||
if errors.Is(err, pseudotty.ErrUnsupported) {
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
defer pty.Close()
|
||||
defer tty.Close()
|
||||
|
||||
err = pseudotty.Setsize(tty, &pseudotty.Winsize{Cols: 72, Rows: 30})
|
||||
require.NoError(t, err)
|
||||
|
||||
out := teeWriter{File: tty}
|
||||
e.WithStdio(terminal.Stdio{
|
||||
In: tty,
|
||||
Out: &out,
|
||||
Err: tty,
|
||||
})
|
||||
|
||||
var res string
|
||||
errc := make(chan error)
|
||||
|
||||
go func() {
|
||||
r, err := e.Prompt(defaultPromptConfig())
|
||||
if r != nil {
|
||||
res = r.(string)
|
||||
}
|
||||
errc <- err
|
||||
}()
|
||||
|
||||
time.Sleep(5 * time.Millisecond)
|
||||
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 false, enter to skip] \x1b[0m\x1b[?25l", out.String())
|
||||
fmt.Fprint(pty, "\n") // send Enter key
|
||||
|
||||
err = <-errc
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "initial value", res)
|
||||
assert.Equal(t, "\x1b[?25h", out.String())
|
||||
}
|
||||
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
|
||||
// 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.buf.Reset()
|
||||
f.mu.Unlock()
|
||||
return s
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue