Merge pull request #2 from bchadwic/first-browse-pull

firstbrowse
This commit is contained in:
Thanh D Tran 2021-06-05 22:18:44 -07:00 committed by GitHub
commit 0174dbcdc4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
37 changed files with 1157 additions and 348 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"
@ -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)
}

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

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

View file

@ -37,12 +37,13 @@ type BrowseOptions struct {
type exitCode int
const (
exitSuccess exitCode = 0
exitNotInRepo exitCode = 1
exitTooManyFlags exitCode = 2
exitTooManyArgs exitCode = 3
exitExpectedArg exitCode = 4
exitInvalidCombo exitCode = 5
exitUrlSuccess exitCode = 0
exitNonUrlSuccess exitCode = 1
exitNotInRepo exitCode = 2
exitTooManyFlags exitCode = 3
exitTooManyArgs exitCode = 4
exitExpectedArg exitCode = 5
exitInvalidCombo exitCode = 6
)
func NewCmdBrowse(f *cmdutil.Factory) *cobra.Command {
@ -55,32 +56,38 @@ func NewCmdBrowse(f *cmdutil.Factory) *cobra.Command {
}
cmd := &cobra.Command{
Long: "Work with GitHub in the browser", // displays when you are on the help page of this command
Short: "Open GitHub in the browser", // displays in the gh root help
Use: "browse {<number> | <file> | <branch>}", // necessary!!! This is the cmd that gets passed on the prompt
Args: cobra.RangeArgs(0, 2), // make sure only one arg at most is passed
Long: "Open specific pages in the browser on GitHub",
Short: "Open GitHub in the browser",
Use: "browse {<number> | <path>}",
Args: cobra.RangeArgs(0, 2),
Example: heredoc.Doc(`
$ gh browse
#=> Opens repository in browser
#=> Open current repository to the default branch
$ gh browse 217
#=> Opens issue or pull request 217
#=> Open issue or pull request 217
$ gh browse --settings
#=> Opens repository settings in browser
#=> Open repository settings
$ gh browse src/fileName:312 --branch main
#=> Opens src/fileName at line 312 in main branch
$ gh browse main.go:312
#=> Open main.go at line 312
$ gh browse main.go --branch main
#=> Open main.go in the main branch
`),
Annotations: map[string]string{
"IsCore": "true",
"help:arguments": heredoc.Doc(`
A browse location can be supplied as an argument in any of the following formats:
- by number, e.g. "123"; or
- by file or branch name, e.g. "main.java" or "trunk".
A browser location can be specified using arguments in the following format:
- by number for issue or pull request, e.g. "123"; or
- by path for opening folders and files, e.g. "cmd/gh/main.go"
`),
"help:environment": heredoc.Doc(`
To configure a web browser other than the default, use the BROWSER environment variable
`),
},
Run: func(cmd *cobra.Command, args []string) {
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 1 {
opts.AdditionalArg = args[1]
@ -89,37 +96,35 @@ func NewCmdBrowse(f *cmdutil.Factory) *cobra.Command {
if len(args) > 0 {
opts.SelectorArg = args[0]
}
openInBrowser(cmd, opts) // run gets rid of the usage / runs function
return openInBrowser(cmd, opts)
},
}
cmdutil.EnableRepoOverride(cmd, f)
cmd.Flags().BoolVarP(&opts.ProjectsFlag, "projects", "p", false, "Open projects tab in browser")
cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Opens the wiki in browser")
cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Opens the settings in browser")
cmd.Flags().BoolVarP(&opts.BranchFlag, "branch", "b", false, "Opens a branch in the browser")
cmd.Flags().BoolVarP(&opts.ProjectsFlag, "projects", "p", false, "Open repository projects")
cmd.Flags().BoolVarP(&opts.WikiFlag, "wiki", "w", false, "Open repository wiki")
cmd.Flags().BoolVarP(&opts.SettingsFlag, "settings", "s", false, "Open repository settings")
cmd.Flags().BoolVarP(&opts.BranchFlag, "branch", "b", false, "Select another branch by passing in the branch name")
return cmd
}
func openInBrowser(cmd *cobra.Command, opts *BrowseOptions) (exitCode, string) {
func openInBrowser(cmd *cobra.Command, opts *BrowseOptions) error {
baseRepo, err := opts.BaseRepo()
httpClient, _ := opts.HttpClient()
apiClient := api.NewClientFromHTTP(httpClient)
branchName, err := api.RepoDefaultBranch(apiClient, baseRepo)
if !inRepo(err) { // must be in a repo to execute
printExit(exitNotInRepo, cmd, opts, "")
return exitNotInRepo, ""
if !inRepo(err) {
return printExit(exitNotInRepo, cmd, opts, "")
}
if getFlagAmount(cmd) > 1 { // command can't have more than one flag
printExit(exitTooManyFlags, cmd, opts, "")
return exitTooManyFlags, ""
if getFlagAmount(cmd) > 1 {
return printExit(exitTooManyFlags, cmd, opts, "")
}
repoUrl := ghrepo.GenerateRepoURL(baseRepo, "")
response := exitSuccess
response := exitUrlSuccess
if !hasArg(opts) && hasFlag(cmd) {
response, repoUrl = addFlag(opts, repoUrl)
@ -129,40 +134,40 @@ func openInBrowser(cmd *cobra.Command, opts *BrowseOptions) (exitCode, string) {
response, repoUrl = addCombined(opts, repoUrl, branchName)
}
if response == exitSuccess {
opts.Browser.Browse(repoUrl) // otherwise open repo
if response == exitUrlSuccess || response == exitNonUrlSuccess {
opts.Browser.Browse(repoUrl)
}
printExit(response, cmd, opts, repoUrl) // print success
return response, repoUrl
return printExit(response, cmd, opts, repoUrl)
}
func addCombined(opts *BrowseOptions, url string, branchName string) (exitCode, string) {
if !opts.BranchFlag { // gh browse --settings main.go
if !opts.BranchFlag {
return exitInvalidCombo, ""
}
if opts.AdditionalArg == "" {
return exitSuccess, url + "/tree/" + opts.SelectorArg
return exitUrlSuccess, url + "/tree/" + opts.SelectorArg
}
arr := parseFileArg(opts)
if len(arr) > 1 {
return exitSuccess, url + "/tree/" + opts.AdditionalArg + "/" + arr[0] + "#L" + arr[1]
return exitUrlSuccess, url + "/tree/" + opts.AdditionalArg + "/" + arr[0] + "#L" + arr[1]
}
return exitSuccess, url + "/tree/" + opts.AdditionalArg + "/" + arr[0]
return exitUrlSuccess, url + "/tree/" + opts.AdditionalArg + "/" + arr[0]
}
func addFlag(opts *BrowseOptions, url string) (exitCode, string) {
if opts.ProjectsFlag {
return exitSuccess, url + "/projects"
return exitUrlSuccess, url + "/projects"
} else if opts.SettingsFlag {
return exitSuccess, url + "/settings"
return exitUrlSuccess, url + "/settings"
} else if opts.WikiFlag {
return exitSuccess, url + "/wiki"
return exitUrlSuccess, url + "/wiki"
}
return exitExpectedArg, "" // Flag is a branch and needs an argument
}
@ -175,15 +180,15 @@ func addArg(opts *BrowseOptions, url string, branchName string) (exitCode, strin
if isNumber(opts.SelectorArg) {
url += "/issues/" + opts.SelectorArg
return exitSuccess, url
return exitNonUrlSuccess, url
}
arr := parseFileArg(opts)
if len(arr) > 1 {
return exitSuccess, url + "/tree/" + branchName + "/" + arr[0] + "#L" + arr[1]
return exitUrlSuccess, url + "/tree/" + branchName + "/" + arr[0] + "#L" + arr[1]
}
return exitSuccess, url + "/tree/" + branchName + "/" + arr[0]
return exitUrlSuccess, url + "/tree/" + branchName + "/" + arr[0]
}
func parseFileArg(opts *BrowseOptions) []string {
@ -191,37 +196,30 @@ func parseFileArg(opts *BrowseOptions) []string {
return arr
}
func printExit(exit exitCode, cmd *cobra.Command, opts *BrowseOptions, url string) {
func printExit(exit exitCode, cmd *cobra.Command, opts *BrowseOptions, url string) error {
w := opts.IO.ErrOut
cs := opts.IO.ColorScheme()
help := "Use 'gh browse --help' for more information about browse\n"
switch exit {
case exitSuccess:
fmt.Fprintf(w, "%s now opening %s in browser . . .\n",
cs.Green("✓"), cs.Bold(url))
case exitUrlSuccess:
fmt.Fprintf(w, "now opening %s in browser . . .\n", cs.Bold(url))
break
case exitNonUrlSuccess:
fmt.Fprintf(w, "now opening issue/pr in browser . . .\n")
break
case exitNotInRepo:
fmt.Fprintf(w, "%s change directory to a repository to open in browser\n%s",
cs.Red("x"), help)
break
return fmt.Errorf("change directory to a repository to open in browser\n%s", help)
case exitTooManyFlags:
fmt.Fprintf(w, "%s accepts 1 flag, %d flag(s) were recieved\n%s",
cs.Red("x"), getFlagAmount(cmd), help)
break
return fmt.Errorf("accepts 1 flag, %d flag(s) were recieved\n%s", getFlagAmount(cmd), help)
case exitTooManyArgs:
fmt.Fprintf(w, "%s accepts 1 arg, 2 arg(s) were received \n%s",
cs.Red("x"), help)
break
return fmt.Errorf("accepts 1 arg, 2 arg(s) were received \n%s", help)
case exitExpectedArg:
fmt.Fprintf(w, "%s expected argument with this flag %s\n%s",
cs.Red("x"), cs.Bold(url), help)
break
return fmt.Errorf("expected argument with this flag %s\n%s", cs.Bold(url), help)
case exitInvalidCombo:
fmt.Fprintf(w, "%s invalid use of flag and argument\n%s",
cs.Red("x"), help)
break
return fmt.Errorf("invalid use of flag and argument\n%s", help)
}
return nil
}
func hasFlag(cmd *cobra.Command) bool {

View file

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

View file

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

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
}