diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md
index d339a0685..d48b17ae2 100644
--- a/.github/CONTRIBUTING.md
+++ b/.github/CONTRIBUTING.md
@@ -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
diff --git a/README.md b/README.md
index 6255b5d81..2886e3471 100644
--- a/README.md
+++ b/README.md
@@ -49,9 +49,7 @@ For more information and distro-specific instructions, see the [Linux installati
| Install: | Upgrade: |
| ------------------- | --------------------|
-| `winget install gh` | `winget install gh` |
-
-WinGet does not have a specialized `upgrade` command yet, but the `install` command should work for upgrading to a newer version of GitHub CLI.
+| `winget install gh` | `winget upgrade gh` |
#### scoop
diff --git a/api/export_pr.go b/api/export_pr.go
index 4bd0aabc7..29a5c4a63 100644
--- a/api/export_pr.go
+++ b/api/export_pr.go
@@ -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:
diff --git a/api/queries_pr.go b/api/queries_pr.go
index 364784f9f..3032f6364 100644
--- a/api/queries_pr.go
+++ b/api/queries_pr.go
@@ -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
}
diff --git a/cmd/gh/main.go b/cmd/gh/main.go
index 93aa98575..72b635810 100644
--- a/cmd/gh/main.go
+++ b/cmd/gh/main.go
@@ -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)
}
diff --git a/docs/project-layout.md b/docs/project-layout.md
index cf594a2e7..cf9758b46 100644
--- a/docs/project-layout.md
+++ b/docs/project-layout.md
@@ -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.
diff --git a/go.mod b/go.mod
index 80b4b523a..080876810 100644
--- a/go.mod
+++ b/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
diff --git a/go.sum b/go.sum
index 7f89f8dd8..aad4401e7 100644
--- a/go.sum
+++ b/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=
diff --git a/internal/config/config_file.go b/internal/config/config_file.go
index bcf475f64..304ea3327 100644
--- a/internal/config/config_file.go
+++ b/internal/config/config_file.go
@@ -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 {
diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go
index 188f10ff0..4c35f24f9 100644
--- a/internal/config/config_file_test.go
+++ b/internal/config/config_file_test.go
@@ -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())
+ })
+ }
+}
diff --git a/internal/config/testing.go b/internal/config/testing.go
index 1908ac30e..31a5fb2a8 100644
--- a/internal/config/testing.go
+++ b/internal/config/testing.go
@@ -62,11 +62,3 @@ func stubConfig(main, hosts string) func() {
ReadConfigFile = orig
}
}
-
-func stubMigrateConfigDir() func() {
- orig := migrateConfigDir
- migrateConfigDir = func(_, _ string) {}
- return func() {
- migrateConfigDir = orig
- }
-}
diff --git a/internal/update/update.go b/internal/update/update.go
index 024fd2377..f525544e9 100644
--- a/internal/update/update.go
+++ b/internal/update/update.go
@@ -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 {
diff --git a/pkg/cmd/api/api_test.go b/pkg/cmd/api/api_test.go
index d0ccc0a70..0d7290835 100644
--- a/pkg/cmd/api/api_test.go
+++ b/pkg/cmd/api/api_test.go
@@ -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,
diff --git a/pkg/cmd/browse/browse.go b/pkg/cmd/browse/browse.go
index f2b05e27e..c1b5c8592 100644
--- a/pkg/cmd/browse/browse.go
+++ b/pkg/cmd/browse/browse.go
@@ -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 { | | }", // 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 { | }",
+ 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 {
diff --git a/pkg/cmd/browse/browse_test.go b/pkg/cmd/browse/browse_test.go
deleted file mode 100644
index f4bf7cb42..000000000
--- a/pkg/cmd/browse/browse_test.go
+++ /dev/null
@@ -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
-}
diff --git a/pkg/cmd/browse/helpful-resources.txt b/pkg/cmd/browse/helpful-resources.txt
deleted file mode 100644
index 3705651ba..000000000
--- a/pkg/cmd/browse/helpful-resources.txt
+++ /dev/null
@@ -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
\ No newline at end of file
diff --git a/pkg/cmd/extensions/command.go b/pkg/cmd/extensions/command.go
new file mode 100644
index 000000000..298787dcb
--- /dev/null
+++ b/pkg/cmd/extensions/command.go
@@ -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 ",
+ 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
+}
diff --git a/pkg/cmd/extensions/manager.go b/pkg/cmd/extensions/manager.go
new file mode 100644
index 000000000..c3988b483
--- /dev/null
+++ b/pkg/cmd/extensions/manager.go
@@ -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")
+}
diff --git a/pkg/cmd/issue/view/view.go b/pkg/cmd/issue/view/view.go
index 78be7df09..d4f96bbd8 100644
--- a/pkg/cmd/issue/view/view.go
+++ b/pkg/cmd/issue/view/view.go
@@ -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),
diff --git a/pkg/cmd/issue/view/view_test.go b/pkg/cmd/issue/view/view_test.go
index dc5c5dfb5..01ec824fc 100644
--- a/pkg/cmd/issue/view/view_test.go
+++ b/pkg/cmd/issue/view/view_test.go
@@ -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`,
},
},
diff --git a/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json b/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json
index 59b1ed1a7..1b1a1e304 100644
--- a/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json
+++ b/pkg/cmd/pr/view/fixtures/prViewPreviewWithReviewersByNumber.json
@@ -21,10 +21,12 @@
}
},
{
- "requestedReviewer": {
- "__typename": "Team",
- "name": "Team 1"
- }
+ "requestedReviewer": {
+ "__typename": "Team",
+ "name": "Team 1",
+ "slug": "team-1",
+ "organization": {"login": "my-org"}
+ }
},
{
"requestedReviewer": {
diff --git a/pkg/cmd/pr/view/view.go b/pkg/cmd/pr/view/view.go
index 91087d74b..905f7476d 100644
--- a/pkg/cmd/pr/view/view.go
+++ b/pkg/cmd/pr/view/view.go
@@ -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,
diff --git a/pkg/cmd/pr/view/view_test.go b/pkg/cmd/pr/view/view_test.go
index 78d588f1e..7de09f02b 100644
--- a/pkg/cmd/pr/view/view_test.go
+++ b/pkg/cmd/pr/view/view_test.go
@@ -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}`,
diff --git a/pkg/cmd/root/help_topic.go b/pkg/cmd/root/help_topic.go
index 968b8a617..040c9fa12 100644
--- a/pkg/cmd/root/help_topic.go
+++ b/pkg/cmd/root/help_topic.go
@@ -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": {
diff --git a/pkg/cmd/root/root.go b/pkg/cmd/root/root.go
index f4f44263c..76099a4b7 100644
--- a/pkg/cmd/root/root.go
+++ b/pkg/cmd/root/root.go
@@ -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))
diff --git a/pkg/cmd/run/view/view.go b/pkg/cmd/run/view/view.go
index 17a8525f3..5dba471fc 100644
--- a/pkg/cmd/run/view/view.go
+++ b/pkg/cmd/run/view/view.go
@@ -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)
}
diff --git a/pkg/cmd/run/view/view_test.go b/pkg/cmd/run/view/view_test.go
index 13ef80b7e..4e284b354 100644
--- a/pkg/cmd/run/view/view_test.go
+++ b/pkg/cmd/run/view/view_test.go
@@ -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{
diff --git a/pkg/cmd/secret/list/list.go b/pkg/cmd/secret/list/list.go
index e8694c62c..35d8db675 100644
--- a/pkg/cmd/secret/list/list.go
+++ b/pkg/cmd/secret/list/list.go
@@ -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 ""
}
diff --git a/pkg/cmd/secret/list/list_test.go b/pkg/cmd/secret/list/list_test.go
index 8620ca012..42504a961 100644
--- a/pkg/cmd/secret/list/list_test.go
+++ b/pkg/cmd/secret/list/list_test.go
@@ -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{`; rel="previous", ; 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)
+}
diff --git a/pkg/cmd/secret/remove/remove.go b/pkg/cmd/secret/remove/remove.go
index d52269d73..fea4415ee 100644
--- a/pkg/cmd/secret/remove/remove.go
+++ b/pkg/cmd/secret/remove/remove.go
@@ -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 ",
- 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
diff --git a/pkg/cmd/secret/remove/remove_test.go b/pkg/cmd/secret/remove/remove_test.go
index 7cec1030c..b47703049 100644
--- a/pkg/cmd/secret/remove/remove_test.go
+++ b/pkg/cmd/secret/remove/remove_test.go
@@ -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
diff --git a/pkg/cmd/secret/secret.go b/pkg/cmd/secret/secret.go
index e8b82a41d..dc04fc85c 100644
--- a/pkg/cmd/secret/secret.go
+++ b/pkg/cmd/secret/secret.go
@@ -15,8 +15,8 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command {
Use: "secret ",
Short: "Manage GitHub secrets",
Long: heredoc.Doc(`
- Secrets can be set at the repository or organization level for use in GitHub Actions.
- Run "gh help secret set" to learn how to get started.
+ 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.
`),
}
diff --git a/pkg/cmd/secret/set/http.go b/pkg/cmd/secret/set/http.go
index 8c589adec..3ecee37c0 100644
--- a/pkg/cmd/secret/set/http.go
+++ b/pkg/cmd/secret/set/http.go
@@ -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,
diff --git a/pkg/cmd/secret/set/set.go b/pkg/cmd/secret/set/set.go
index cb1975ad3..c1c75ced7 100644
--- a/pkg/cmd/secret/set/set.go
+++ b/pkg/cmd/secret/set/set.go
@@ -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 ",
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)
}
diff --git a/pkg/cmd/secret/set/set_test.go b/pkg/cmd/secret/set/set_test.go
index 7f9273eae..f1c2b7d76 100644
--- a/pkg/cmd/secret/set/set_test.go
+++ b/pkg/cmd/secret/set/set_test.go
@@ -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
diff --git a/pkg/surveyext/editor.go b/pkg/surveyext/editor.go
index 21a358aa0..f868ed671 100644
--- a/pkg/surveyext/editor.go
+++ b/pkg/surveyext/editor.go
@@ -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
}
diff --git a/pkg/surveyext/editor_test.go b/pkg/surveyext/editor_test.go
new file mode 100644
index 000000000..03ad27989
--- /dev/null
+++ b/pkg/surveyext/editor_test.go
@@ -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
+}