Merge branch 'cli:trunk' into first-browse-pull

This commit is contained in:
Thanh D Tran 2021-06-05 20:43:39 -07:00
commit ed87cf71b7
33 changed files with 1093 additions and 143 deletions

View file

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

View file

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

View file

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

View file

@ -150,28 +150,33 @@ type PullRequestFile struct {
type ReviewRequests struct {
Nodes []struct {
RequestedReviewer struct {
TypeName string `json:"__typename"`
Login string `json:"login"`
Name string `json:"name"`
Slug string `json:"slug"`
Organization struct {
Login string `json:"login"`
}
}
RequestedReviewer RequestedReviewer
}
}
type RequestedReviewer struct {
TypeName string `json:"__typename"`
Login string `json:"login"`
Name string `json:"name"`
Slug string `json:"slug"`
Organization struct {
Login string `json:"login"`
} `json:"organization"`
}
func (r RequestedReviewer) LoginOrSlug() string {
if r.TypeName == teamTypeName {
return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
}
return r.Login
}
const teamTypeName = "Team"
func (r ReviewRequests) Logins() []string {
logins := make([]string, len(r.Nodes))
for i, a := range r.Nodes {
if a.RequestedReviewer.TypeName == teamTypeName {
logins[i] = fmt.Sprintf("%s/%s", a.RequestedReviewer.Organization.Login, a.RequestedReviewer.Slug)
} else {
logins[i] = a.RequestedReviewer.Login
}
for i, r := range r.Nodes {
logins[i] = r.RequestedReviewer.LoginOrSlug()
}
return logins
}

View file

@ -21,6 +21,7 @@ import (
"github.com/cli/cli/internal/run"
"github.com/cli/cli/internal/update"
"github.com/cli/cli/pkg/cmd/alias/expand"
"github.com/cli/cli/pkg/cmd/extensions"
"github.com/cli/cli/pkg/cmd/factory"
"github.com/cli/cli/pkg/cmd/root"
"github.com/cli/cli/pkg/cmdutil"
@ -140,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
}
}
}
@ -264,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)
}

View file

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

1
go.mod
View file

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

2
go.sum
View file

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

View file

@ -16,7 +16,10 @@ import (
const (
GH_CONFIG_DIR = "GH_CONFIG_DIR"
XDG_CONFIG_HOME = "XDG_CONFIG_HOME"
XDG_STATE_HOME = "XDG_STATE_HOME"
XDG_DATA_HOME = "XDG_DATA_HOME"
APP_DATA = "AppData"
LOCAL_APP_DATA = "LocalAppData"
)
// Config path precedence
@ -38,27 +41,118 @@ func ConfigDir() string {
}
// If the path does not exist try migrating config from default paths
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
autoMigrateConfigDir(path)
if !dirExists(path) {
_ = autoMigrateConfigDir(path)
}
return path
}
// State path precedence
// 1. XDG_CONFIG_HOME
// 2. LocalAppData (windows only)
// 3. HOME
func StateDir() string {
var path string
if a := os.Getenv(XDG_STATE_HOME); a != "" {
path = filepath.Join(a, "gh")
} else if b := os.Getenv(LOCAL_APP_DATA); runtime.GOOS == "windows" && b != "" {
path = filepath.Join(b, "GitHub CLI")
} else {
c, _ := os.UserHomeDir()
path = filepath.Join(c, ".local", "state", "gh")
}
// If the path does not exist try migrating state from default paths
if !dirExists(path) {
_ = autoMigrateStateDir(path)
}
return path
}
// Data path precedence
// 1. XDG_DATA_HOME
// 2. LocalAppData (windows only)
// 3. HOME
func DataDir() string {
var path string
if a := os.Getenv(XDG_DATA_HOME); a != "" {
path = filepath.Join(a, "gh")
} else if b := os.Getenv(LOCAL_APP_DATA); runtime.GOOS == "windows" && b != "" {
path = filepath.Join(b, "GitHub CLI")
} else {
c, _ := os.UserHomeDir()
path = filepath.Join(c, ".local", "share", "gh")
}
return path
}
var errSamePath = errors.New("same path")
var errNotExist = errors.New("not exist")
// Check default paths (os.UserHomeDir, and homedir.Dir) for existing configs
// If configs exist then move them to newPath
// TODO: Remove support for homedir.Dir location in v2
func autoMigrateConfigDir(newPath string) {
func autoMigrateConfigDir(newPath string) error {
path, err := os.UserHomeDir()
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
migrateConfigDir(oldPath, newPath)
return
return migrateDir(oldPath, newPath)
}
path, err = homedir.Dir()
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
migrateConfigDir(oldPath, newPath)
return migrateDir(oldPath, newPath)
}
return errNotExist
}
// Check default paths (os.UserHomeDir, and homedir.Dir) for existing state file (state.yml)
// If state file exist then move it to newPath
// TODO: Remove support for homedir.Dir location in v2
func autoMigrateStateDir(newPath string) error {
path, err := os.UserHomeDir()
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
return migrateFile(oldPath, newPath, "state.yml")
}
path, err = homedir.Dir()
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
return migrateFile(oldPath, newPath, "state.yml")
}
return errNotExist
}
func migrateFile(oldPath, newPath, file string) error {
if oldPath == newPath {
return errSamePath
}
oldFile := filepath.Join(oldPath, file)
newFile := filepath.Join(newPath, file)
if !fileExists(oldFile) {
return errNotExist
}
_ = os.MkdirAll(filepath.Dir(newFile), 0755)
return os.Rename(oldFile, newFile)
}
func migrateDir(oldPath, newPath string) error {
if oldPath == newPath {
return errSamePath
}
if !dirExists(oldPath) {
return errNotExist
}
_ = os.MkdirAll(filepath.Dir(newPath), 0755)
return os.Rename(oldPath, newPath)
}
func dirExists(path string) bool {
@ -66,13 +160,9 @@ func dirExists(path string) bool {
return err == nil && f.IsDir()
}
var migrateConfigDir = func(oldPath, newPath string) {
if oldPath == newPath {
return
}
_ = os.MkdirAll(filepath.Dir(newPath), 0755)
_ = os.Rename(oldPath, newPath)
func fileExists(path string) bool {
f, err := os.Stat(path)
return err == nil && !f.IsDir()
}
func ConfigFile() string {

View file

@ -152,6 +152,8 @@ func Test_parseConfigFile(t *testing.T) {
}
func Test_ConfigDir(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
onlyWindows bool
@ -159,63 +161,63 @@ func Test_ConfigDir(t *testing.T) {
output string
}{
{
name: "no envVars",
name: "HOME/USERPROFILE specified",
env: map[string]string{
"GH_CONFIG_DIR": "",
"XDG_CONFIG_HOME": "",
"AppData": "",
"USERPROFILE": "",
"HOME": "",
"USERPROFILE": tempDir,
"HOME": tempDir,
},
output: ".config/gh",
output: filepath.Join(tempDir, ".config", "gh"),
},
{
name: "GH_CONFIG_DIR specified",
env: map[string]string{
"GH_CONFIG_DIR": "/tmp/gh_config_dir",
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
},
output: "/tmp/gh_config_dir",
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "XDG_CONFIG_HOME specified",
env: map[string]string{
"XDG_CONFIG_HOME": "/tmp",
"XDG_CONFIG_HOME": tempDir,
},
output: "/tmp/gh",
output: filepath.Join(tempDir, "gh"),
},
{
name: "GH_CONFIG_DIR and XDG_CONFIG_HOME specified",
env: map[string]string{
"GH_CONFIG_DIR": "/tmp/gh_config_dir",
"XDG_CONFIG_HOME": "/tmp",
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
"XDG_CONFIG_HOME": tempDir,
},
output: "/tmp/gh_config_dir",
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "AppData specified",
onlyWindows: true,
env: map[string]string{
"AppData": "/tmp/",
"AppData": tempDir,
},
output: "/tmp/GitHub CLI",
output: filepath.Join(tempDir, "GitHub CLI"),
},
{
name: "GH_CONFIG_DIR and AppData specified",
onlyWindows: true,
env: map[string]string{
"GH_CONFIG_DIR": "/tmp/gh_config_dir",
"AppData": "/tmp",
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
"AppData": tempDir,
},
output: "/tmp/gh_config_dir",
output: filepath.Join(tempDir, "gh_config_dir"),
},
{
name: "XDG_CONFIG_HOME and AppData specified",
onlyWindows: true,
env: map[string]string{
"XDG_CONFIG_HOME": "/tmp",
"AppData": "/tmp",
"XDG_CONFIG_HOME": tempDir,
"AppData": tempDir,
},
output: "/tmp/gh",
output: filepath.Join(tempDir, "gh"),
},
}
@ -227,22 +229,25 @@ func Test_ConfigDir(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
old := os.Getenv(k)
os.Setenv(k, filepath.FromSlash(v))
os.Setenv(k, v)
defer os.Setenv(k, old)
}
}
defer stubMigrateConfigDir()()
assert.Equal(t, filepath.FromSlash(tt.output), ConfigDir())
// Create directory to skip auto migration code
// which gets run when target directory does not exist
_ = os.MkdirAll(tt.output, 0755)
assert.Equal(t, tt.output, ConfigDir())
})
}
}
func Test_configFile_Write_toDisk(t *testing.T) {
configDir := filepath.Join(t.TempDir(), ".config", "gh")
_ = os.MkdirAll(configDir, 0755)
os.Setenv(GH_CONFIG_DIR, configDir)
defer os.Unsetenv(GH_CONFIG_DIR)
defer stubMigrateConfigDir()()
cfg := NewFromString(`pager: less`)
err := cfg.Write()
@ -264,7 +269,8 @@ func Test_configFile_Write_toDisk(t *testing.T) {
}
}
func Test_autoMigrateConfigDir_noMigration(t *testing.T) {
func Test_autoMigrateConfigDir_noMigration_notExist(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeEnvVar := "HOME"
@ -272,10 +278,11 @@ func Test_autoMigrateConfigDir_noMigration(t *testing.T) {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, "/nonexistent-dir")
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
autoMigrateConfigDir(migrateDir)
err := autoMigrateConfigDir(migrateDir)
assert.Equal(t, errNotExist, err)
files, err := ioutil.ReadDir(migrateDir)
assert.NoError(t, err)
@ -283,17 +290,21 @@ func Test_autoMigrateConfigDir_noMigration(t *testing.T) {
}
func Test_autoMigrateConfigDir_noMigration_samePath(t *testing.T) {
migrateDir := t.TempDir()
homeDir := t.TempDir()
migrateDir := filepath.Join(homeDir, ".config", "gh")
err := os.MkdirAll(migrateDir, 0755)
assert.NoError(t, err)
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, migrateDir)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
autoMigrateConfigDir(migrateDir)
err = autoMigrateConfigDir(migrateDir)
assert.Equal(t, errSamePath, err)
files, err := ioutil.ReadDir(migrateDir)
assert.NoError(t, err)
@ -301,31 +312,240 @@ func Test_autoMigrateConfigDir_noMigration_samePath(t *testing.T) {
}
func Test_autoMigrateConfigDir_migration(t *testing.T) {
defaultDir := t.TempDir()
dd := filepath.Join(defaultDir, ".config", "gh")
homeDir := t.TempDir()
migrateDir := t.TempDir()
md := filepath.Join(migrateDir, ".config", "gh")
homeConfigDir := filepath.Join(homeDir, ".config", "gh")
migrateConfigDir := filepath.Join(migrateDir, ".config", "gh")
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, defaultDir)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := os.MkdirAll(dd, 0777)
err := os.MkdirAll(homeConfigDir, 0755)
assert.NoError(t, err)
f, err := ioutil.TempFile(dd, "")
f, err := ioutil.TempFile(homeConfigDir, "")
assert.NoError(t, err)
f.Close()
autoMigrateConfigDir(md)
err = autoMigrateConfigDir(migrateConfigDir)
assert.NoError(t, err)
_, err = ioutil.ReadDir(dd)
_, err = ioutil.ReadDir(homeConfigDir)
assert.True(t, os.IsNotExist(err))
files, err := ioutil.ReadDir(md)
files, err := ioutil.ReadDir(migrateConfigDir)
assert.NoError(t, err)
assert.Equal(t, 1, len(files))
}
func Test_StateDir(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
onlyWindows bool
env map[string]string
output string
}{
{
name: "HOME/USERPROFILE specified",
env: map[string]string{
"XDG_STATE_HOME": "",
"GH_CONFIG_DIR": "",
"XDG_CONFIG_HOME": "",
"LocalAppData": "",
"USERPROFILE": tempDir,
"HOME": tempDir,
},
output: filepath.Join(tempDir, ".local", "state", "gh"),
},
{
name: "XDG_STATE_HOME specified",
env: map[string]string{
"XDG_STATE_HOME": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
{
name: "LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "GitHub CLI"),
},
{
name: "XDG_STATE_HOME and LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"XDG_STATE_HOME": tempDir,
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
}
for _, tt := range tests {
if tt.onlyWindows && runtime.GOOS != "windows" {
continue
}
t.Run(tt.name, func(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
old := os.Getenv(k)
os.Setenv(k, v)
defer os.Setenv(k, old)
}
}
// Create directory to skip auto migration code
// which gets run when target directory does not exist
_ = os.MkdirAll(tt.output, 0755)
assert.Equal(t, tt.output, StateDir())
})
}
}
func Test_autoMigrateStateDir_noMigration_notExist(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := autoMigrateStateDir(migrateDir)
assert.Equal(t, errNotExist, err)
files, err := ioutil.ReadDir(migrateDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
}
func Test_autoMigrateStateDir_noMigration_samePath(t *testing.T) {
homeDir := t.TempDir()
migrateDir := filepath.Join(homeDir, ".config", "gh")
err := os.MkdirAll(migrateDir, 0755)
assert.NoError(t, err)
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err = autoMigrateStateDir(migrateDir)
assert.Equal(t, errSamePath, err)
files, err := ioutil.ReadDir(migrateDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
}
func Test_autoMigrateStateDir_migration(t *testing.T) {
homeDir := t.TempDir()
migrateDir := t.TempDir()
homeConfigDir := filepath.Join(homeDir, ".config", "gh")
migrateStateDir := filepath.Join(migrateDir, ".local", "state", "gh")
homeEnvVar := "HOME"
if runtime.GOOS == "windows" {
homeEnvVar = "USERPROFILE"
}
old := os.Getenv(homeEnvVar)
os.Setenv(homeEnvVar, homeDir)
defer os.Setenv(homeEnvVar, old)
err := os.MkdirAll(homeConfigDir, 0755)
assert.NoError(t, err)
err = ioutil.WriteFile(filepath.Join(homeConfigDir, "state.yml"), nil, 0755)
assert.NoError(t, err)
err = autoMigrateStateDir(migrateStateDir)
assert.NoError(t, err)
files, err := ioutil.ReadDir(homeConfigDir)
assert.NoError(t, err)
assert.Equal(t, 0, len(files))
files, err = ioutil.ReadDir(migrateStateDir)
assert.NoError(t, err)
assert.Equal(t, 1, len(files))
assert.Equal(t, "state.yml", files[0].Name())
}
func Test_DataDir(t *testing.T) {
tempDir := t.TempDir()
tests := []struct {
name string
onlyWindows bool
env map[string]string
output string
}{
{
name: "HOME/USERPROFILE specified",
env: map[string]string{
"XDG_DATA_HOME": "",
"GH_CONFIG_DIR": "",
"XDG_CONFIG_HOME": "",
"LocalAppData": "",
"USERPROFILE": tempDir,
"HOME": tempDir,
},
output: filepath.Join(tempDir, ".local", "share", "gh"),
},
{
name: "XDG_DATA_HOME specified",
env: map[string]string{
"XDG_DATA_HOME": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
{
name: "LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "GitHub CLI"),
},
{
name: "XDG_DATA_HOME and LocalAppData specified",
onlyWindows: true,
env: map[string]string{
"XDG_DATA_HOME": tempDir,
"LocalAppData": tempDir,
},
output: filepath.Join(tempDir, "gh"),
},
}
for _, tt := range tests {
if tt.onlyWindows && runtime.GOOS != "windows" {
continue
}
t.Run(tt.name, func(t *testing.T) {
if tt.env != nil {
for k, v := range tt.env {
old := os.Getenv(k)
os.Setenv(k, v)
defer os.Setenv(k, old)
}
}
assert.Equal(t, tt.output, DataDir())
})
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -100,6 +100,17 @@ func TestNewCmdSet(t *testing.T) {
OrgName: "",
},
},
{
name: "env",
cli: `cool_secret -b"a secret" -eRelease`,
wants: SetOptions{
SecretName: "cool_secret",
Visibility: shared.Private,
Body: "a secret",
OrgName: "",
EnvName: "Release",
},
},
{
name: "vis all",
cli: `cool_secret --org coolOrg -b"cool" -vall`,
@ -160,6 +171,7 @@ func TestNewCmdSet(t *testing.T) {
assert.Equal(t, tt.wants.Body, gotOpts.Body)
assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility)
assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
assert.Equal(t, tt.wants.EnvName, gotOpts.EnvName)
assert.ElementsMatch(t, tt.wants.RepositoryNames, gotOpts.RepositoryNames)
})
}
@ -204,6 +216,46 @@ func Test_setRun_repo(t *testing.T) {
assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=")
}
func Test_setRun_env(t *testing.T) {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("GET", "repos/owner/repo/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

View file

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

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