Merge branch 'trunk' of https://github.com/cli/cli into feature/repo-with-gitignore-license
This commit is contained in:
commit
7c8b6867f4
54 changed files with 2543 additions and 923 deletions
2
.github/CONTRIBUTING.md
vendored
2
.github/CONTRIBUTING.md
vendored
|
|
@ -36,7 +36,7 @@ Run the new binary as:
|
|||
|
||||
Run tests with: `go test ./...`
|
||||
|
||||
See [project layout documentation](../project-layout.md) for information on where to find specific source files.
|
||||
See [project layout documentation](../docs/project-layout.md) for information on where to find specific source files.
|
||||
|
||||
## Submitting a pull request
|
||||
|
||||
|
|
|
|||
22
README.md
22
README.md
|
|
@ -14,13 +14,12 @@ GitHub CLI is available for repositories hosted on GitHub.com and GitHub Enterpr
|
|||
|
||||
If anything feels off, or if you feel that some functionality is missing, please check out the [contributing page][contributing]. There you will find instructions for sharing your feedback, building the tool locally, and submitting pull requests to the project.
|
||||
|
||||
|
||||
<!-- this anchor is linked to from elsewhere, so avoid renaming it -->
|
||||
## Installation
|
||||
|
||||
### macOS
|
||||
|
||||
`gh` is available via [Homebrew][], [MacPorts][], and as a downloadable binary from the [releases page][].
|
||||
`gh` is available via [Homebrew][], [MacPorts][], [Conda][], and as a downloadable binary from the [releases page][].
|
||||
|
||||
#### Homebrew
|
||||
|
||||
|
|
@ -34,24 +33,29 @@ If anything feels off, or if you feel that some functionality is missing, please
|
|||
| ---------------------- | ---------------------------------------------- |
|
||||
| `sudo port install gh` | `sudo port selfupdate && sudo port upgrade gh` |
|
||||
|
||||
#### Conda
|
||||
|
||||
| Install: | Upgrade: |
|
||||
|------------------------------------------|-----------------------------------------|
|
||||
| `conda install gh --channel conda-forge` | `conda update gh --channel conda-forge` |
|
||||
|
||||
Additional Conda installation options available on the [gh-feedstock page](https://github.com/conda-forge/gh-feedstock#installing-gh).
|
||||
|
||||
### Linux
|
||||
|
||||
`gh` is available via [Homebrew](#homebrew), and as downloadable binaries from the [releases page][].
|
||||
`gh` is available via [Homebrew](#homebrew), [Conda](#Conda), and as downloadable binaries from the [releases page][].
|
||||
|
||||
For more information and distro-specific instructions, see the [Linux installation docs](./docs/install_linux.md).
|
||||
|
||||
### Windows
|
||||
|
||||
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], and as downloadable MSI.
|
||||
|
||||
`gh` is available via [WinGet][], [scoop][], [Chocolatey][], [Conda](#Conda), and as downloadable MSI.
|
||||
|
||||
#### WinGet
|
||||
|
||||
| Install: | Upgrade: |
|
||||
| ------------------- | --------------------|
|
||||
| `winget install gh` | `winget install gh` |
|
||||
|
||||
<i>WinGet does not have a specialized `upgrade` command yet, but the `install` command should work for upgrading to a newer version of GitHub CLI.</i>
|
||||
| `winget install gh` | `winget upgrade gh` |
|
||||
|
||||
#### scoop
|
||||
|
||||
|
|
@ -88,13 +92,13 @@ what an official GitHub CLI tool can look like with a fundamentally different de
|
|||
tools bring GitHub to the terminal, `hub` behaves as a proxy to `git`, and `gh` is a standalone
|
||||
tool. Check out our [more detailed explanation][gh-vs-hub] to learn more.
|
||||
|
||||
|
||||
[manual]: https://cli.github.com/manual/
|
||||
[Homebrew]: https://brew.sh
|
||||
[MacPorts]: https://www.macports.org
|
||||
[winget]: https://github.com/microsoft/winget-cli
|
||||
[scoop]: https://scoop.sh
|
||||
[Chocolatey]: https://chocolatey.org
|
||||
[Conda]: https://docs.conda.io/en/latest/
|
||||
[releases page]: https://github.com/cli/cli/releases/latest
|
||||
[hub]: https://github.com/github/hub
|
||||
[contributing]: ./.github/CONTRIBUTING.md
|
||||
|
|
|
|||
|
|
@ -80,10 +80,20 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
|
|||
case "reviewRequests":
|
||||
requests := make([]interface{}, 0, len(pr.ReviewRequests.Nodes))
|
||||
for _, req := range pr.ReviewRequests.Nodes {
|
||||
if req.RequestedReviewer.TypeName == "" {
|
||||
continue
|
||||
r := req.RequestedReviewer
|
||||
switch r.TypeName {
|
||||
case "User":
|
||||
requests = append(requests, map[string]string{
|
||||
"__typename": r.TypeName,
|
||||
"login": r.Login,
|
||||
})
|
||||
case "Team":
|
||||
requests = append(requests, map[string]string{
|
||||
"__typename": r.TypeName,
|
||||
"name": r.Name,
|
||||
"slug": r.LoginOrSlug(),
|
||||
})
|
||||
}
|
||||
requests = append(requests, req.RequestedReviewer)
|
||||
}
|
||||
data[f] = &requests
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -150,28 +150,33 @@ type PullRequestFile struct {
|
|||
|
||||
type ReviewRequests struct {
|
||||
Nodes []struct {
|
||||
RequestedReviewer struct {
|
||||
TypeName string `json:"__typename"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Organization struct {
|
||||
Login string `json:"login"`
|
||||
}
|
||||
}
|
||||
RequestedReviewer RequestedReviewer
|
||||
}
|
||||
}
|
||||
|
||||
type RequestedReviewer struct {
|
||||
TypeName string `json:"__typename"`
|
||||
Login string `json:"login"`
|
||||
Name string `json:"name"`
|
||||
Slug string `json:"slug"`
|
||||
Organization struct {
|
||||
Login string `json:"login"`
|
||||
} `json:"organization"`
|
||||
}
|
||||
|
||||
func (r RequestedReviewer) LoginOrSlug() string {
|
||||
if r.TypeName == teamTypeName {
|
||||
return fmt.Sprintf("%s/%s", r.Organization.Login, r.Slug)
|
||||
}
|
||||
return r.Login
|
||||
}
|
||||
|
||||
const teamTypeName = "Team"
|
||||
|
||||
func (r ReviewRequests) Logins() []string {
|
||||
logins := make([]string, len(r.Nodes))
|
||||
for i, a := range r.Nodes {
|
||||
if a.RequestedReviewer.TypeName == teamTypeName {
|
||||
logins[i] = fmt.Sprintf("%s/%s", a.RequestedReviewer.Organization.Login, a.RequestedReviewer.Slug)
|
||||
} else {
|
||||
logins[i] = a.RequestedReviewer.Login
|
||||
}
|
||||
for i, r := range r.Nodes {
|
||||
logins[i] = r.RequestedReviewer.LoginOrSlug()
|
||||
}
|
||||
return logins
|
||||
}
|
||||
|
|
@ -391,7 +396,7 @@ func PullRequestStatus(client *Client, repo ghrepo.Interface, options StatusOpti
|
|||
// these are always necessary to find the PR for the current branch
|
||||
fields.AddValues([]string{"isCrossRepository", "headRepositoryOwner", "headRefName"})
|
||||
gr := PullRequestGraphQL(fields.ToSlice())
|
||||
fragments = fmt.Sprintf("fragment pr on PullRequest{%[1]s}fragment prWithReviews on PullRequest{%[1]s}", gr)
|
||||
fragments = fmt.Sprintf("fragment pr on PullRequest{%s}fragment prWithReviews on PullRequest{...pr}", gr)
|
||||
} else {
|
||||
var err error
|
||||
fragments, err = pullRequestFragment(client.http, repo.RepoHost())
|
||||
|
|
@ -526,67 +531,23 @@ func pullRequestFragment(httpClient *http.Client, hostname string) (string, erro
|
|||
return "", err
|
||||
}
|
||||
|
||||
var reviewsFragment string
|
||||
if prFeatures.HasReviewDecision {
|
||||
reviewsFragment = "reviewDecision"
|
||||
fields := []string{
|
||||
"number", "title", "state", "url", "isDraft", "isCrossRepository",
|
||||
"requiresStrictStatusChecks", "headRefName", "headRepositoryOwner", "mergeStateStatus",
|
||||
}
|
||||
|
||||
var statusesFragment string
|
||||
if prFeatures.HasStatusCheckRollup {
|
||||
statusesFragment = `
|
||||
commits(last: 1) {
|
||||
nodes {
|
||||
commit {
|
||||
statusCheckRollup {
|
||||
contexts(last: 100) {
|
||||
nodes {
|
||||
...on StatusContext {
|
||||
state
|
||||
}
|
||||
...on CheckRun {
|
||||
conclusion
|
||||
status
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
fields = append(fields, "statusCheckRollup")
|
||||
}
|
||||
|
||||
var requiresStrictStatusChecks string
|
||||
if prFeatures.HasBranchProtectionRule {
|
||||
requiresStrictStatusChecks = `
|
||||
baseRef {
|
||||
branchProtectionRule {
|
||||
requiresStrictStatusChecks
|
||||
}
|
||||
}`
|
||||
var reviewFields []string
|
||||
if prFeatures.HasReviewDecision {
|
||||
reviewFields = append(reviewFields, "reviewDecision")
|
||||
}
|
||||
|
||||
fragments := fmt.Sprintf(`
|
||||
fragment pr on PullRequest {
|
||||
number
|
||||
title
|
||||
state
|
||||
url
|
||||
headRefName
|
||||
mergeStateStatus
|
||||
headRepositoryOwner {
|
||||
login
|
||||
}
|
||||
%s
|
||||
isCrossRepository
|
||||
isDraft
|
||||
%s
|
||||
}
|
||||
fragment prWithReviews on PullRequest {
|
||||
...pr
|
||||
%s
|
||||
}
|
||||
`, requiresStrictStatusChecks, statusesFragment, reviewsFragment)
|
||||
fragment pr on PullRequest {%s}
|
||||
fragment prWithReviews on PullRequest {...pr,%s}
|
||||
`, PullRequestGraphQL(fields), PullRequestGraphQL(reviewFields))
|
||||
return fragments, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -216,6 +216,8 @@ func PullRequestGraphQL(fields []string) string {
|
|||
q = append(q, `commits(last:1){nodes{commit{oid}}}`)
|
||||
case "commitsCount": // pseudo-field
|
||||
q = append(q, `commits{totalCount}`)
|
||||
case "requiresStrictStatusChecks": // pseudo-field
|
||||
q = append(q, `baseRef{branchProtectionRule{requiresStrictStatusChecks}}`)
|
||||
case "statusCheckRollup":
|
||||
q = append(q, StatusCheckRollupGraphQL(""))
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import (
|
|||
"github.com/cli/cli/internal/run"
|
||||
"github.com/cli/cli/internal/update"
|
||||
"github.com/cli/cli/pkg/cmd/alias/expand"
|
||||
"github.com/cli/cli/pkg/cmd/extensions"
|
||||
"github.com/cli/cli/pkg/cmd/factory"
|
||||
"github.com/cli/cli/pkg/cmd/root"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
|
|
@ -92,14 +93,6 @@ func mainRun() exitCode {
|
|||
return exitError
|
||||
}
|
||||
|
||||
if prompt, _ := cfg.Get("", "prompt"); prompt == "disabled" {
|
||||
cmdFactory.IOStreams.SetNeverPrompt(true)
|
||||
}
|
||||
|
||||
if pager, _ := cfg.Get("", "pager"); pager != "" {
|
||||
cmdFactory.IOStreams.SetPager(pager)
|
||||
}
|
||||
|
||||
// TODO: remove after FromFullName has been revisited
|
||||
if host, err := cfg.DefaultHost(); err == nil {
|
||||
ghrepo.SetDefaultHost(host)
|
||||
|
|
@ -140,15 +133,27 @@ func mainRun() exitCode {
|
|||
|
||||
err = preparedCmd.Run()
|
||||
if err != nil {
|
||||
if ee, ok := err.(*exec.ExitError); ok {
|
||||
return exitCode(ee.ExitCode())
|
||||
var execError *exec.ExitError
|
||||
if errors.As(err, &execError) {
|
||||
return exitCode(execError.ExitCode())
|
||||
}
|
||||
|
||||
fmt.Fprintf(stderr, "failed to run external command: %s", err)
|
||||
return exitError
|
||||
}
|
||||
|
||||
return exitOK
|
||||
} else if c, _, err := rootCmd.Traverse(expandedArgs); err == nil && c == rootCmd && len(expandedArgs) > 0 {
|
||||
extensionManager := extensions.NewManager()
|
||||
if found, err := extensionManager.Dispatch(expandedArgs, os.Stdin, os.Stdout, os.Stderr); err != nil {
|
||||
var execError *exec.ExitError
|
||||
if errors.As(err, &execError) {
|
||||
return exitCode(execError.ExitCode())
|
||||
}
|
||||
fmt.Fprintf(stderr, "failed to run extension: %s", err)
|
||||
return exitError
|
||||
} else if found {
|
||||
return exitOK
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -264,7 +269,7 @@ func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) {
|
|||
}
|
||||
|
||||
repo := updaterEnabled
|
||||
stateFilePath := filepath.Join(config.ConfigDir(), "state.yml")
|
||||
stateFilePath := filepath.Join(config.StateDir(), "state.yml")
|
||||
return update.CheckForUpdate(client, stateFilePath, repo, currentVersion)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -75,7 +75,7 @@ and talk through which code gets run in order.
|
|||
This task might be tricky. Typically, gh commands do things like look up information from the git repository
|
||||
in the current directory, query the GitHub API, scan the user's `~/.ssh/config` file, clone or fetch git
|
||||
repositories, etc. Naturally, none of these things should ever happen for real when running tests, unless
|
||||
you are sure that any filesystem operations are stricly scoped to a location made for and maintained by the
|
||||
you are sure that any filesystem operations are strictly scoped to a location made for and maintained by the
|
||||
test itself. To avoid actually running things like making real API requests or shelling out to `git`
|
||||
commands, we stub them. You should look at how that's done within some existing tests.
|
||||
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -11,6 +11,7 @@ require (
|
|||
github.com/cli/oauth v0.8.0
|
||||
github.com/cli/safeexec v1.0.0
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0
|
||||
github.com/creack/pty v1.1.13
|
||||
github.com/gabriel-vasile/mimetype v1.1.2
|
||||
github.com/google/go-cmp v0.5.2
|
||||
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -62,6 +62,8 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7
|
|||
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
|
||||
github.com/creack/pty v1.1.13 h1:rTPnd/xocYRjutMfqide2zle1u96upp1gm6eUHKi7us=
|
||||
github.com/creack/pty v1.1.13/go.mod h1:MOBLtS5ELjhRRrroQr9kyvTxUAFNvYEK993ew/Vr4O4=
|
||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
|
||||
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
|
|
|||
|
|
@ -16,7 +16,10 @@ import (
|
|||
const (
|
||||
GH_CONFIG_DIR = "GH_CONFIG_DIR"
|
||||
XDG_CONFIG_HOME = "XDG_CONFIG_HOME"
|
||||
XDG_STATE_HOME = "XDG_STATE_HOME"
|
||||
XDG_DATA_HOME = "XDG_DATA_HOME"
|
||||
APP_DATA = "AppData"
|
||||
LOCAL_APP_DATA = "LocalAppData"
|
||||
)
|
||||
|
||||
// Config path precedence
|
||||
|
|
@ -38,27 +41,118 @@ func ConfigDir() string {
|
|||
}
|
||||
|
||||
// If the path does not exist try migrating config from default paths
|
||||
if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) {
|
||||
autoMigrateConfigDir(path)
|
||||
if !dirExists(path) {
|
||||
_ = autoMigrateConfigDir(path)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// State path precedence
|
||||
// 1. XDG_CONFIG_HOME
|
||||
// 2. LocalAppData (windows only)
|
||||
// 3. HOME
|
||||
func StateDir() string {
|
||||
var path string
|
||||
if a := os.Getenv(XDG_STATE_HOME); a != "" {
|
||||
path = filepath.Join(a, "gh")
|
||||
} else if b := os.Getenv(LOCAL_APP_DATA); runtime.GOOS == "windows" && b != "" {
|
||||
path = filepath.Join(b, "GitHub CLI")
|
||||
} else {
|
||||
c, _ := os.UserHomeDir()
|
||||
path = filepath.Join(c, ".local", "state", "gh")
|
||||
}
|
||||
|
||||
// If the path does not exist try migrating state from default paths
|
||||
if !dirExists(path) {
|
||||
_ = autoMigrateStateDir(path)
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
// Data path precedence
|
||||
// 1. XDG_DATA_HOME
|
||||
// 2. LocalAppData (windows only)
|
||||
// 3. HOME
|
||||
func DataDir() string {
|
||||
var path string
|
||||
if a := os.Getenv(XDG_DATA_HOME); a != "" {
|
||||
path = filepath.Join(a, "gh")
|
||||
} else if b := os.Getenv(LOCAL_APP_DATA); runtime.GOOS == "windows" && b != "" {
|
||||
path = filepath.Join(b, "GitHub CLI")
|
||||
} else {
|
||||
c, _ := os.UserHomeDir()
|
||||
path = filepath.Join(c, ".local", "share", "gh")
|
||||
}
|
||||
|
||||
return path
|
||||
}
|
||||
|
||||
var errSamePath = errors.New("same path")
|
||||
var errNotExist = errors.New("not exist")
|
||||
|
||||
// Check default paths (os.UserHomeDir, and homedir.Dir) for existing configs
|
||||
// If configs exist then move them to newPath
|
||||
// TODO: Remove support for homedir.Dir location in v2
|
||||
func autoMigrateConfigDir(newPath string) {
|
||||
func autoMigrateConfigDir(newPath string) error {
|
||||
path, err := os.UserHomeDir()
|
||||
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
|
||||
migrateConfigDir(oldPath, newPath)
|
||||
return
|
||||
return migrateDir(oldPath, newPath)
|
||||
}
|
||||
|
||||
path, err = homedir.Dir()
|
||||
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
|
||||
migrateConfigDir(oldPath, newPath)
|
||||
return migrateDir(oldPath, newPath)
|
||||
}
|
||||
|
||||
return errNotExist
|
||||
}
|
||||
|
||||
// Check default paths (os.UserHomeDir, and homedir.Dir) for existing state file (state.yml)
|
||||
// If state file exist then move it to newPath
|
||||
// TODO: Remove support for homedir.Dir location in v2
|
||||
func autoMigrateStateDir(newPath string) error {
|
||||
path, err := os.UserHomeDir()
|
||||
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
|
||||
return migrateFile(oldPath, newPath, "state.yml")
|
||||
}
|
||||
|
||||
path, err = homedir.Dir()
|
||||
if oldPath := filepath.Join(path, ".config", "gh"); err == nil && dirExists(oldPath) {
|
||||
return migrateFile(oldPath, newPath, "state.yml")
|
||||
}
|
||||
|
||||
return errNotExist
|
||||
}
|
||||
|
||||
func migrateFile(oldPath, newPath, file string) error {
|
||||
if oldPath == newPath {
|
||||
return errSamePath
|
||||
}
|
||||
|
||||
oldFile := filepath.Join(oldPath, file)
|
||||
newFile := filepath.Join(newPath, file)
|
||||
|
||||
if !fileExists(oldFile) {
|
||||
return errNotExist
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(filepath.Dir(newFile), 0755)
|
||||
return os.Rename(oldFile, newFile)
|
||||
}
|
||||
|
||||
func migrateDir(oldPath, newPath string) error {
|
||||
if oldPath == newPath {
|
||||
return errSamePath
|
||||
}
|
||||
|
||||
if !dirExists(oldPath) {
|
||||
return errNotExist
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(filepath.Dir(newPath), 0755)
|
||||
return os.Rename(oldPath, newPath)
|
||||
}
|
||||
|
||||
func dirExists(path string) bool {
|
||||
|
|
@ -66,13 +160,9 @@ func dirExists(path string) bool {
|
|||
return err == nil && f.IsDir()
|
||||
}
|
||||
|
||||
var migrateConfigDir = func(oldPath, newPath string) {
|
||||
if oldPath == newPath {
|
||||
return
|
||||
}
|
||||
|
||||
_ = os.MkdirAll(filepath.Dir(newPath), 0755)
|
||||
_ = os.Rename(oldPath, newPath)
|
||||
func fileExists(path string) bool {
|
||||
f, err := os.Stat(path)
|
||||
return err == nil && !f.IsDir()
|
||||
}
|
||||
|
||||
func ConfigFile() string {
|
||||
|
|
|
|||
|
|
@ -152,6 +152,8 @@ func Test_parseConfigFile(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_ConfigDir(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
onlyWindows bool
|
||||
|
|
@ -159,63 +161,63 @@ func Test_ConfigDir(t *testing.T) {
|
|||
output string
|
||||
}{
|
||||
{
|
||||
name: "no envVars",
|
||||
name: "HOME/USERPROFILE specified",
|
||||
env: map[string]string{
|
||||
"GH_CONFIG_DIR": "",
|
||||
"XDG_CONFIG_HOME": "",
|
||||
"AppData": "",
|
||||
"USERPROFILE": "",
|
||||
"HOME": "",
|
||||
"USERPROFILE": tempDir,
|
||||
"HOME": tempDir,
|
||||
},
|
||||
output: ".config/gh",
|
||||
output: filepath.Join(tempDir, ".config", "gh"),
|
||||
},
|
||||
{
|
||||
name: "GH_CONFIG_DIR specified",
|
||||
env: map[string]string{
|
||||
"GH_CONFIG_DIR": "/tmp/gh_config_dir",
|
||||
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
|
||||
},
|
||||
output: "/tmp/gh_config_dir",
|
||||
output: filepath.Join(tempDir, "gh_config_dir"),
|
||||
},
|
||||
{
|
||||
name: "XDG_CONFIG_HOME specified",
|
||||
env: map[string]string{
|
||||
"XDG_CONFIG_HOME": "/tmp",
|
||||
"XDG_CONFIG_HOME": tempDir,
|
||||
},
|
||||
output: "/tmp/gh",
|
||||
output: filepath.Join(tempDir, "gh"),
|
||||
},
|
||||
{
|
||||
name: "GH_CONFIG_DIR and XDG_CONFIG_HOME specified",
|
||||
env: map[string]string{
|
||||
"GH_CONFIG_DIR": "/tmp/gh_config_dir",
|
||||
"XDG_CONFIG_HOME": "/tmp",
|
||||
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
|
||||
"XDG_CONFIG_HOME": tempDir,
|
||||
},
|
||||
output: "/tmp/gh_config_dir",
|
||||
output: filepath.Join(tempDir, "gh_config_dir"),
|
||||
},
|
||||
{
|
||||
name: "AppData specified",
|
||||
onlyWindows: true,
|
||||
env: map[string]string{
|
||||
"AppData": "/tmp/",
|
||||
"AppData": tempDir,
|
||||
},
|
||||
output: "/tmp/GitHub CLI",
|
||||
output: filepath.Join(tempDir, "GitHub CLI"),
|
||||
},
|
||||
{
|
||||
name: "GH_CONFIG_DIR and AppData specified",
|
||||
onlyWindows: true,
|
||||
env: map[string]string{
|
||||
"GH_CONFIG_DIR": "/tmp/gh_config_dir",
|
||||
"AppData": "/tmp",
|
||||
"GH_CONFIG_DIR": filepath.Join(tempDir, "gh_config_dir"),
|
||||
"AppData": tempDir,
|
||||
},
|
||||
output: "/tmp/gh_config_dir",
|
||||
output: filepath.Join(tempDir, "gh_config_dir"),
|
||||
},
|
||||
{
|
||||
name: "XDG_CONFIG_HOME and AppData specified",
|
||||
onlyWindows: true,
|
||||
env: map[string]string{
|
||||
"XDG_CONFIG_HOME": "/tmp",
|
||||
"AppData": "/tmp",
|
||||
"XDG_CONFIG_HOME": tempDir,
|
||||
"AppData": tempDir,
|
||||
},
|
||||
output: "/tmp/gh",
|
||||
output: filepath.Join(tempDir, "gh"),
|
||||
},
|
||||
}
|
||||
|
||||
|
|
@ -227,22 +229,25 @@ func Test_ConfigDir(t *testing.T) {
|
|||
if tt.env != nil {
|
||||
for k, v := range tt.env {
|
||||
old := os.Getenv(k)
|
||||
os.Setenv(k, filepath.FromSlash(v))
|
||||
os.Setenv(k, v)
|
||||
defer os.Setenv(k, old)
|
||||
}
|
||||
}
|
||||
|
||||
defer stubMigrateConfigDir()()
|
||||
assert.Equal(t, filepath.FromSlash(tt.output), ConfigDir())
|
||||
// Create directory to skip auto migration code
|
||||
// which gets run when target directory does not exist
|
||||
_ = os.MkdirAll(tt.output, 0755)
|
||||
|
||||
assert.Equal(t, tt.output, ConfigDir())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_configFile_Write_toDisk(t *testing.T) {
|
||||
configDir := filepath.Join(t.TempDir(), ".config", "gh")
|
||||
_ = os.MkdirAll(configDir, 0755)
|
||||
os.Setenv(GH_CONFIG_DIR, configDir)
|
||||
defer os.Unsetenv(GH_CONFIG_DIR)
|
||||
defer stubMigrateConfigDir()()
|
||||
|
||||
cfg := NewFromString(`pager: less`)
|
||||
err := cfg.Write()
|
||||
|
|
@ -264,7 +269,8 @@ func Test_configFile_Write_toDisk(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func Test_autoMigrateConfigDir_noMigration(t *testing.T) {
|
||||
func Test_autoMigrateConfigDir_noMigration_notExist(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
migrateDir := t.TempDir()
|
||||
|
||||
homeEnvVar := "HOME"
|
||||
|
|
@ -272,10 +278,11 @@ func Test_autoMigrateConfigDir_noMigration(t *testing.T) {
|
|||
homeEnvVar = "USERPROFILE"
|
||||
}
|
||||
old := os.Getenv(homeEnvVar)
|
||||
os.Setenv(homeEnvVar, "/nonexistent-dir")
|
||||
os.Setenv(homeEnvVar, homeDir)
|
||||
defer os.Setenv(homeEnvVar, old)
|
||||
|
||||
autoMigrateConfigDir(migrateDir)
|
||||
err := autoMigrateConfigDir(migrateDir)
|
||||
assert.Equal(t, errNotExist, err)
|
||||
|
||||
files, err := ioutil.ReadDir(migrateDir)
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -283,17 +290,21 @@ func Test_autoMigrateConfigDir_noMigration(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_autoMigrateConfigDir_noMigration_samePath(t *testing.T) {
|
||||
migrateDir := t.TempDir()
|
||||
homeDir := t.TempDir()
|
||||
migrateDir := filepath.Join(homeDir, ".config", "gh")
|
||||
err := os.MkdirAll(migrateDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
|
||||
homeEnvVar := "HOME"
|
||||
if runtime.GOOS == "windows" {
|
||||
homeEnvVar = "USERPROFILE"
|
||||
}
|
||||
old := os.Getenv(homeEnvVar)
|
||||
os.Setenv(homeEnvVar, migrateDir)
|
||||
os.Setenv(homeEnvVar, homeDir)
|
||||
defer os.Setenv(homeEnvVar, old)
|
||||
|
||||
autoMigrateConfigDir(migrateDir)
|
||||
err = autoMigrateConfigDir(migrateDir)
|
||||
assert.Equal(t, errSamePath, err)
|
||||
|
||||
files, err := ioutil.ReadDir(migrateDir)
|
||||
assert.NoError(t, err)
|
||||
|
|
@ -301,31 +312,240 @@ func Test_autoMigrateConfigDir_noMigration_samePath(t *testing.T) {
|
|||
}
|
||||
|
||||
func Test_autoMigrateConfigDir_migration(t *testing.T) {
|
||||
defaultDir := t.TempDir()
|
||||
dd := filepath.Join(defaultDir, ".config", "gh")
|
||||
homeDir := t.TempDir()
|
||||
migrateDir := t.TempDir()
|
||||
md := filepath.Join(migrateDir, ".config", "gh")
|
||||
homeConfigDir := filepath.Join(homeDir, ".config", "gh")
|
||||
migrateConfigDir := filepath.Join(migrateDir, ".config", "gh")
|
||||
|
||||
homeEnvVar := "HOME"
|
||||
if runtime.GOOS == "windows" {
|
||||
homeEnvVar = "USERPROFILE"
|
||||
}
|
||||
old := os.Getenv(homeEnvVar)
|
||||
os.Setenv(homeEnvVar, defaultDir)
|
||||
os.Setenv(homeEnvVar, homeDir)
|
||||
defer os.Setenv(homeEnvVar, old)
|
||||
|
||||
err := os.MkdirAll(dd, 0777)
|
||||
err := os.MkdirAll(homeConfigDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
f, err := ioutil.TempFile(dd, "")
|
||||
f, err := ioutil.TempFile(homeConfigDir, "")
|
||||
assert.NoError(t, err)
|
||||
f.Close()
|
||||
|
||||
autoMigrateConfigDir(md)
|
||||
err = autoMigrateConfigDir(migrateConfigDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = ioutil.ReadDir(dd)
|
||||
_, err = ioutil.ReadDir(homeConfigDir)
|
||||
assert.True(t, os.IsNotExist(err))
|
||||
|
||||
files, err := ioutil.ReadDir(md)
|
||||
files, err := ioutil.ReadDir(migrateConfigDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(files))
|
||||
}
|
||||
|
||||
func Test_StateDir(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
onlyWindows bool
|
||||
env map[string]string
|
||||
output string
|
||||
}{
|
||||
{
|
||||
name: "HOME/USERPROFILE specified",
|
||||
env: map[string]string{
|
||||
"XDG_STATE_HOME": "",
|
||||
"GH_CONFIG_DIR": "",
|
||||
"XDG_CONFIG_HOME": "",
|
||||
"LocalAppData": "",
|
||||
"USERPROFILE": tempDir,
|
||||
"HOME": tempDir,
|
||||
},
|
||||
output: filepath.Join(tempDir, ".local", "state", "gh"),
|
||||
},
|
||||
{
|
||||
name: "XDG_STATE_HOME specified",
|
||||
env: map[string]string{
|
||||
"XDG_STATE_HOME": tempDir,
|
||||
},
|
||||
output: filepath.Join(tempDir, "gh"),
|
||||
},
|
||||
{
|
||||
name: "LocalAppData specified",
|
||||
onlyWindows: true,
|
||||
env: map[string]string{
|
||||
"LocalAppData": tempDir,
|
||||
},
|
||||
output: filepath.Join(tempDir, "GitHub CLI"),
|
||||
},
|
||||
{
|
||||
name: "XDG_STATE_HOME and LocalAppData specified",
|
||||
onlyWindows: true,
|
||||
env: map[string]string{
|
||||
"XDG_STATE_HOME": tempDir,
|
||||
"LocalAppData": tempDir,
|
||||
},
|
||||
output: filepath.Join(tempDir, "gh"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if tt.onlyWindows && runtime.GOOS != "windows" {
|
||||
continue
|
||||
}
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != nil {
|
||||
for k, v := range tt.env {
|
||||
old := os.Getenv(k)
|
||||
os.Setenv(k, v)
|
||||
defer os.Setenv(k, old)
|
||||
}
|
||||
}
|
||||
|
||||
// Create directory to skip auto migration code
|
||||
// which gets run when target directory does not exist
|
||||
_ = os.MkdirAll(tt.output, 0755)
|
||||
|
||||
assert.Equal(t, tt.output, StateDir())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_autoMigrateStateDir_noMigration_notExist(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
migrateDir := t.TempDir()
|
||||
|
||||
homeEnvVar := "HOME"
|
||||
if runtime.GOOS == "windows" {
|
||||
homeEnvVar = "USERPROFILE"
|
||||
}
|
||||
old := os.Getenv(homeEnvVar)
|
||||
os.Setenv(homeEnvVar, homeDir)
|
||||
defer os.Setenv(homeEnvVar, old)
|
||||
|
||||
err := autoMigrateStateDir(migrateDir)
|
||||
assert.Equal(t, errNotExist, err)
|
||||
|
||||
files, err := ioutil.ReadDir(migrateDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(files))
|
||||
}
|
||||
|
||||
func Test_autoMigrateStateDir_noMigration_samePath(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
migrateDir := filepath.Join(homeDir, ".config", "gh")
|
||||
err := os.MkdirAll(migrateDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
|
||||
homeEnvVar := "HOME"
|
||||
if runtime.GOOS == "windows" {
|
||||
homeEnvVar = "USERPROFILE"
|
||||
}
|
||||
old := os.Getenv(homeEnvVar)
|
||||
os.Setenv(homeEnvVar, homeDir)
|
||||
defer os.Setenv(homeEnvVar, old)
|
||||
|
||||
err = autoMigrateStateDir(migrateDir)
|
||||
assert.Equal(t, errSamePath, err)
|
||||
|
||||
files, err := ioutil.ReadDir(migrateDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(files))
|
||||
}
|
||||
|
||||
func Test_autoMigrateStateDir_migration(t *testing.T) {
|
||||
homeDir := t.TempDir()
|
||||
migrateDir := t.TempDir()
|
||||
homeConfigDir := filepath.Join(homeDir, ".config", "gh")
|
||||
migrateStateDir := filepath.Join(migrateDir, ".local", "state", "gh")
|
||||
|
||||
homeEnvVar := "HOME"
|
||||
if runtime.GOOS == "windows" {
|
||||
homeEnvVar = "USERPROFILE"
|
||||
}
|
||||
old := os.Getenv(homeEnvVar)
|
||||
os.Setenv(homeEnvVar, homeDir)
|
||||
defer os.Setenv(homeEnvVar, old)
|
||||
|
||||
err := os.MkdirAll(homeConfigDir, 0755)
|
||||
assert.NoError(t, err)
|
||||
err = ioutil.WriteFile(filepath.Join(homeConfigDir, "state.yml"), nil, 0755)
|
||||
assert.NoError(t, err)
|
||||
|
||||
err = autoMigrateStateDir(migrateStateDir)
|
||||
assert.NoError(t, err)
|
||||
|
||||
files, err := ioutil.ReadDir(homeConfigDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0, len(files))
|
||||
|
||||
files, err = ioutil.ReadDir(migrateStateDir)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 1, len(files))
|
||||
assert.Equal(t, "state.yml", files[0].Name())
|
||||
}
|
||||
|
||||
func Test_DataDir(t *testing.T) {
|
||||
tempDir := t.TempDir()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
onlyWindows bool
|
||||
env map[string]string
|
||||
output string
|
||||
}{
|
||||
{
|
||||
name: "HOME/USERPROFILE specified",
|
||||
env: map[string]string{
|
||||
"XDG_DATA_HOME": "",
|
||||
"GH_CONFIG_DIR": "",
|
||||
"XDG_CONFIG_HOME": "",
|
||||
"LocalAppData": "",
|
||||
"USERPROFILE": tempDir,
|
||||
"HOME": tempDir,
|
||||
},
|
||||
output: filepath.Join(tempDir, ".local", "share", "gh"),
|
||||
},
|
||||
{
|
||||
name: "XDG_DATA_HOME specified",
|
||||
env: map[string]string{
|
||||
"XDG_DATA_HOME": tempDir,
|
||||
},
|
||||
output: filepath.Join(tempDir, "gh"),
|
||||
},
|
||||
{
|
||||
name: "LocalAppData specified",
|
||||
onlyWindows: true,
|
||||
env: map[string]string{
|
||||
"LocalAppData": tempDir,
|
||||
},
|
||||
output: filepath.Join(tempDir, "GitHub CLI"),
|
||||
},
|
||||
{
|
||||
name: "XDG_DATA_HOME and LocalAppData specified",
|
||||
onlyWindows: true,
|
||||
env: map[string]string{
|
||||
"XDG_DATA_HOME": tempDir,
|
||||
"LocalAppData": tempDir,
|
||||
},
|
||||
output: filepath.Join(tempDir, "gh"),
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
if tt.onlyWindows && runtime.GOOS != "windows" {
|
||||
continue
|
||||
}
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != nil {
|
||||
for k, v := range tt.env {
|
||||
old := os.Getenv(k)
|
||||
os.Setenv(k, v)
|
||||
defer os.Setenv(k, old)
|
||||
}
|
||||
}
|
||||
|
||||
assert.Equal(t, tt.output, DataDir())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -62,11 +62,3 @@ func stubConfig(main, hosts string) func() {
|
|||
ReadConfigFile = orig
|
||||
}
|
||||
}
|
||||
|
||||
func stubMigrateConfigDir() func() {
|
||||
orig := migrateConfigDir
|
||||
migrateConfigDir = func(_, _ string) {}
|
||||
return func() {
|
||||
migrateConfigDir = orig
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ package update
|
|||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
|
@ -83,9 +85,14 @@ func setStateEntry(stateFilePath string, t time.Time, r ReleaseInfo) error {
|
|||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_ = ioutil.WriteFile(stateFilePath, content, 0600)
|
||||
|
||||
return nil
|
||||
err = os.MkdirAll(filepath.Dir(stateFilePath), 0755)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
err = ioutil.WriteFile(stateFilePath, content, 0600)
|
||||
return err
|
||||
}
|
||||
|
||||
func versionGreaterThan(v, w string) bool {
|
||||
|
|
|
|||
77
pkg/cmd/extensions/command.go
Normal file
77
pkg/cmd/extensions/command.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package extensions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
func NewCmdExtensions(io *iostreams.IOStreams) *cobra.Command {
|
||||
m := NewManager()
|
||||
|
||||
extCmd := cobra.Command{
|
||||
Use: "extensions",
|
||||
Short: "Manage gh extensions",
|
||||
}
|
||||
|
||||
extCmd.AddCommand(
|
||||
&cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List installed extension commands",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
cmds := m.List()
|
||||
if len(cmds) == 0 {
|
||||
return errors.New("no extensions installed")
|
||||
}
|
||||
for _, c := range cmds {
|
||||
name := filepath.Base(c)
|
||||
parts := strings.SplitN(name, "-", 2)
|
||||
fmt.Fprintf(io.Out, "%s %s\n", parts[0], parts[1])
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "install <repo>",
|
||||
Short: "Install a gh extension from a repository",
|
||||
Args: cmdutil.MinimumArgs(1, "must specify a repository to install from"),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
if args[0] == "." {
|
||||
wd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return m.InstallLocal(wd)
|
||||
}
|
||||
repo, err := ghrepo.FromFullName(args[0])
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !strings.HasPrefix(repo.RepoName(), "gh-") {
|
||||
return errors.New("the repository name must start with `gh-`")
|
||||
}
|
||||
protocol := "https" // TODO: respect user's preferred protocol
|
||||
return m.Install(ghrepo.FormatRemoteURL(repo, protocol), io.Out, io.ErrOut)
|
||||
},
|
||||
},
|
||||
&cobra.Command{
|
||||
Use: "upgrade",
|
||||
Short: "Upgrade installed extensions",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
return m.Upgrade(io.Out, io.ErrOut)
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
extCmd.Hidden = true
|
||||
return &extCmd
|
||||
}
|
||||
121
pkg/cmd/extensions/manager.go
Normal file
121
pkg/cmd/extensions/manager.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package extensions
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/safeexec"
|
||||
)
|
||||
|
||||
type Manager struct {
|
||||
dataDir func() string
|
||||
lookPath func(string) (string, error)
|
||||
}
|
||||
|
||||
func NewManager() *Manager {
|
||||
return &Manager{
|
||||
dataDir: config.ConfigDir,
|
||||
lookPath: safeexec.LookPath,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) Dispatch(args []string, stdin io.Reader, stdout, stderr io.Writer) (bool, error) {
|
||||
if len(args) == 0 {
|
||||
return false, errors.New("too few arguments in list")
|
||||
}
|
||||
|
||||
var exe string
|
||||
extName := "gh-" + args[0]
|
||||
forwardArgs := args[1:]
|
||||
|
||||
for _, e := range m.List() {
|
||||
if filepath.Base(e) == extName {
|
||||
exe = e
|
||||
break
|
||||
}
|
||||
}
|
||||
if exe == "" {
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// TODO: parse the shebang on Windows and invoke the correct interpreter instead of invoking directly
|
||||
externalCmd := exec.Command(exe, forwardArgs...)
|
||||
externalCmd.Stdin = stdin
|
||||
externalCmd.Stdout = stdout
|
||||
externalCmd.Stderr = stderr
|
||||
return true, externalCmd.Run()
|
||||
}
|
||||
|
||||
func (m *Manager) List() []string {
|
||||
dir := m.installDir()
|
||||
entries, err := ioutil.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
var results []string
|
||||
for _, f := range entries {
|
||||
if !strings.HasPrefix(f.Name(), "gh-") || !(f.IsDir() || f.Mode()&os.ModeSymlink != 0) {
|
||||
continue
|
||||
}
|
||||
results = append(results, filepath.Join(dir, f.Name(), f.Name()))
|
||||
}
|
||||
return results
|
||||
}
|
||||
|
||||
func (m *Manager) InstallLocal(dir string) error {
|
||||
name := filepath.Base(dir)
|
||||
targetDir := filepath.Join(m.installDir(), name)
|
||||
return os.Symlink(dir, targetDir)
|
||||
}
|
||||
|
||||
func (m *Manager) Install(cloneURL string, stdout, stderr io.Writer) error {
|
||||
exe, err := m.lookPath("git")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
name := strings.TrimSuffix(path.Base(cloneURL), ".git")
|
||||
targetDir := filepath.Join(m.installDir(), name)
|
||||
|
||||
externalCmd := exec.Command(exe, "clone", cloneURL, targetDir)
|
||||
externalCmd.Stdout = stdout
|
||||
externalCmd.Stderr = stderr
|
||||
return externalCmd.Run()
|
||||
}
|
||||
|
||||
func (m *Manager) Upgrade(stdout, stderr io.Writer) error {
|
||||
exe, err := m.lookPath("git")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
exts := m.List()
|
||||
if len(exts) == 0 {
|
||||
return errors.New("no extensions installed")
|
||||
}
|
||||
|
||||
for _, f := range exts {
|
||||
fmt.Fprintf(stdout, "[%s]: ", filepath.Base(f))
|
||||
dir := filepath.Dir(f)
|
||||
externalCmd := exec.Command(exe, "-C", dir, "--git-dir="+filepath.Join(dir, ".git"), "pull", "--ff-only")
|
||||
externalCmd.Stdout = stdout
|
||||
externalCmd.Stderr = stderr
|
||||
if e := externalCmd.Run(); e != nil {
|
||||
err = e
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *Manager) installDir() string {
|
||||
return filepath.Join(m.dataDir(), "extensions")
|
||||
}
|
||||
|
|
@ -6,6 +6,8 @@ import (
|
|||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/context"
|
||||
"github.com/cli/cli/git"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
|
|
@ -14,11 +16,93 @@ import (
|
|||
)
|
||||
|
||||
func New(appVersion string) *cmdutil.Factory {
|
||||
io := iostreams.System()
|
||||
f := &cmdutil.Factory{
|
||||
Config: configFunc(), // No factory dependencies
|
||||
Branch: branchFunc(), // No factory dependencies
|
||||
Executable: executable(), // No factory dependencies
|
||||
}
|
||||
|
||||
f.IOStreams = ioStreams(f) // Depends on Config
|
||||
f.HttpClient = httpClientFunc(f, appVersion) // Depends on Config, IOStreams, and appVersion
|
||||
f.Remotes = remotesFunc(f) // Depends on Config
|
||||
f.BaseRepo = BaseRepoFunc(f) // Depends on Remotes
|
||||
f.Browser = browser(f) // Depends on IOStreams
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func BaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) {
|
||||
return func() (ghrepo.Interface, error) {
|
||||
remotes, err := f.Remotes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return remotes[0], nil
|
||||
}
|
||||
}
|
||||
|
||||
func SmartBaseRepoFunc(f *cmdutil.Factory) func() (ghrepo.Interface, error) {
|
||||
return func() (ghrepo.Interface, error) {
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
remotes, err := f.Remotes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseRepo, err := repoContext.BaseRepo(f.IOStreams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return baseRepo, nil
|
||||
}
|
||||
}
|
||||
|
||||
func remotesFunc(f *cmdutil.Factory) func() (context.Remotes, error) {
|
||||
rr := &remoteResolver{
|
||||
readRemotes: git.Remotes,
|
||||
getConfig: f.Config,
|
||||
}
|
||||
return rr.Resolver()
|
||||
}
|
||||
|
||||
func httpClientFunc(f *cmdutil.Factory, appVersion string) func() (*http.Client, error) {
|
||||
return func() (*http.Client, error) {
|
||||
io := f.IOStreams
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewHTTPClient(io, cfg, appVersion, true), nil
|
||||
}
|
||||
}
|
||||
|
||||
func browser(f *cmdutil.Factory) cmdutil.Browser {
|
||||
io := f.IOStreams
|
||||
return cmdutil.NewBrowser(os.Getenv("BROWSER"), io.Out, io.ErrOut)
|
||||
}
|
||||
|
||||
func executable() string {
|
||||
gh := "gh"
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
gh = exe
|
||||
}
|
||||
return gh
|
||||
}
|
||||
|
||||
func configFunc() func() (config.Config, error) {
|
||||
var cachedConfig config.Config
|
||||
var configError error
|
||||
configFunc := func() (config.Config, error) {
|
||||
return func() (config.Config, error) {
|
||||
if cachedConfig != nil || configError != nil {
|
||||
return cachedConfig, configError
|
||||
}
|
||||
|
|
@ -30,45 +114,38 @@ func New(appVersion string) *cmdutil.Factory {
|
|||
cachedConfig = config.InheritEnv(cachedConfig)
|
||||
return cachedConfig, configError
|
||||
}
|
||||
}
|
||||
|
||||
rr := &remoteResolver{
|
||||
readRemotes: git.Remotes,
|
||||
getConfig: configFunc,
|
||||
}
|
||||
remotesFunc := rr.Resolver()
|
||||
|
||||
ghExecutable := "gh"
|
||||
if exe, err := os.Executable(); err == nil {
|
||||
ghExecutable = exe
|
||||
}
|
||||
|
||||
return &cmdutil.Factory{
|
||||
IOStreams: io,
|
||||
Config: configFunc,
|
||||
Remotes: remotesFunc,
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
cfg, err := configFunc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return NewHTTPClient(io, cfg, appVersion, true), nil
|
||||
},
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
remotes, err := remotesFunc()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return remotes[0], nil
|
||||
},
|
||||
Branch: func() (string, error) {
|
||||
currentBranch, err := git.CurrentBranch()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine current branch: %w", err)
|
||||
}
|
||||
return currentBranch, nil
|
||||
},
|
||||
Executable: ghExecutable,
|
||||
Browser: cmdutil.NewBrowser(os.Getenv("BROWSER"), io.Out, io.ErrOut),
|
||||
func branchFunc() func() (string, error) {
|
||||
return func() (string, error) {
|
||||
currentBranch, err := git.CurrentBranch()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("could not determine current branch: %w", err)
|
||||
}
|
||||
return currentBranch, nil
|
||||
}
|
||||
}
|
||||
|
||||
func ioStreams(f *cmdutil.Factory) *iostreams.IOStreams {
|
||||
io := iostreams.System()
|
||||
cfg, err := f.Config()
|
||||
if err != nil {
|
||||
return io
|
||||
}
|
||||
|
||||
if prompt, _ := cfg.Get("", "prompt"); prompt == "disabled" {
|
||||
io.SetNeverPrompt(true)
|
||||
}
|
||||
|
||||
// Pager precedence
|
||||
// 1. GH_PAGER
|
||||
// 2. pager from config
|
||||
// 3. PAGER
|
||||
if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists {
|
||||
io.SetPager(ghPager)
|
||||
} else if pager, _ := cfg.Get("", "pager"); pager != "" {
|
||||
io.SetPager(pager)
|
||||
}
|
||||
|
||||
return io
|
||||
}
|
||||
|
|
|
|||
391
pkg/cmd/factory/default_test.go
Normal file
391
pkg/cmd/factory/default_test.go
Normal file
|
|
@ -0,0 +1,391 @@
|
|||
package factory
|
||||
|
||||
import (
|
||||
"net/url"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/git"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_BaseRepo(t *testing.T) {
|
||||
orig_GH_HOST := os.Getenv("GH_HOST")
|
||||
t.Cleanup(func() {
|
||||
os.Setenv("GH_HOST", orig_GH_HOST)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remotes git.RemoteSet
|
||||
config config.Config
|
||||
override string
|
||||
wantsErr bool
|
||||
wantsName string
|
||||
wantsOwner string
|
||||
wantsHost string
|
||||
}{
|
||||
{
|
||||
name: "matching remote",
|
||||
remotes: git.RemoteSet{
|
||||
git.NewRemote("origin", "https://nonsense.com/owner/repo.git"),
|
||||
},
|
||||
config: defaultConfig(),
|
||||
wantsName: "repo",
|
||||
wantsOwner: "owner",
|
||||
wantsHost: "nonsense.com",
|
||||
},
|
||||
{
|
||||
name: "no matching remote",
|
||||
remotes: git.RemoteSet{
|
||||
git.NewRemote("origin", "https://test.com/owner/repo.git"),
|
||||
},
|
||||
config: defaultConfig(),
|
||||
wantsErr: true,
|
||||
},
|
||||
{
|
||||
name: "override with matching remote",
|
||||
remotes: git.RemoteSet{
|
||||
git.NewRemote("origin", "https://test.com/owner/repo.git"),
|
||||
},
|
||||
config: defaultConfig(),
|
||||
override: "test.com",
|
||||
wantsName: "repo",
|
||||
wantsOwner: "owner",
|
||||
wantsHost: "test.com",
|
||||
},
|
||||
{
|
||||
name: "override with no matching remote",
|
||||
remotes: git.RemoteSet{
|
||||
git.NewRemote("origin", "https://nonsense.com/owner/repo.git"),
|
||||
},
|
||||
config: defaultConfig(),
|
||||
override: "test.com",
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.override != "" {
|
||||
os.Setenv("GH_HOST", tt.override)
|
||||
} else {
|
||||
os.Unsetenv("GH_HOST")
|
||||
}
|
||||
f := New("1")
|
||||
rr := &remoteResolver{
|
||||
readRemotes: func() (git.RemoteSet, error) {
|
||||
return tt.remotes, nil
|
||||
},
|
||||
getConfig: func() (config.Config, error) {
|
||||
return tt.config, nil
|
||||
},
|
||||
}
|
||||
f.Remotes = rr.Resolver()
|
||||
f.BaseRepo = BaseRepoFunc(f)
|
||||
repo, err := f.BaseRepo()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantsName, repo.RepoName())
|
||||
assert.Equal(t, tt.wantsOwner, repo.RepoOwner())
|
||||
assert.Equal(t, tt.wantsHost, repo.RepoHost())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_SmartBaseRepo(t *testing.T) {
|
||||
pu, _ := url.Parse("https://test.com/newowner/newrepo.git")
|
||||
orig_GH_HOST := os.Getenv("GH_HOST")
|
||||
t.Cleanup(func() {
|
||||
os.Setenv("GH_HOST", orig_GH_HOST)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remotes git.RemoteSet
|
||||
config config.Config
|
||||
override string
|
||||
wantsErr bool
|
||||
wantsName string
|
||||
wantsOwner string
|
||||
wantsHost string
|
||||
}{
|
||||
{
|
||||
name: "override with matching remote",
|
||||
remotes: git.RemoteSet{
|
||||
git.NewRemote("origin", "https://test.com/owner/repo.git"),
|
||||
},
|
||||
config: defaultConfig(),
|
||||
override: "test.com",
|
||||
wantsName: "repo",
|
||||
wantsOwner: "owner",
|
||||
wantsHost: "test.com",
|
||||
},
|
||||
{
|
||||
name: "override with matching remote and base resolution",
|
||||
remotes: git.RemoteSet{
|
||||
&git.Remote{Name: "origin",
|
||||
Resolved: "base",
|
||||
FetchURL: pu,
|
||||
PushURL: pu},
|
||||
},
|
||||
config: defaultConfig(),
|
||||
override: "test.com",
|
||||
wantsName: "newrepo",
|
||||
wantsOwner: "newowner",
|
||||
wantsHost: "test.com",
|
||||
},
|
||||
{
|
||||
name: "override with matching remote and nonbase resolution",
|
||||
remotes: git.RemoteSet{
|
||||
&git.Remote{Name: "origin",
|
||||
Resolved: "johnny/test",
|
||||
FetchURL: pu,
|
||||
PushURL: pu},
|
||||
},
|
||||
config: defaultConfig(),
|
||||
override: "test.com",
|
||||
wantsName: "test",
|
||||
wantsOwner: "johnny",
|
||||
wantsHost: "test.com",
|
||||
},
|
||||
{
|
||||
name: "override with no matching remote",
|
||||
remotes: git.RemoteSet{
|
||||
git.NewRemote("origin", "https://example.com/owner/repo.git"),
|
||||
},
|
||||
config: defaultConfig(),
|
||||
override: "test.com",
|
||||
wantsErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.override != "" {
|
||||
os.Setenv("GH_HOST", tt.override)
|
||||
} else {
|
||||
os.Unsetenv("GH_HOST")
|
||||
}
|
||||
f := New("1")
|
||||
rr := &remoteResolver{
|
||||
readRemotes: func() (git.RemoteSet, error) {
|
||||
return tt.remotes, nil
|
||||
},
|
||||
getConfig: func() (config.Config, error) {
|
||||
return tt.config, nil
|
||||
},
|
||||
}
|
||||
f.Remotes = rr.Resolver()
|
||||
f.BaseRepo = SmartBaseRepoFunc(f)
|
||||
repo, err := f.BaseRepo()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantsName, repo.RepoName())
|
||||
assert.Equal(t, tt.wantsOwner, repo.RepoOwner())
|
||||
assert.Equal(t, tt.wantsHost, repo.RepoHost())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Defined in pkg/cmdutil/repo_override.go but test it along with other BaseRepo functions
|
||||
func Test_OverrideBaseRepo(t *testing.T) {
|
||||
orig_GH_HOST := os.Getenv("GH_REPO")
|
||||
t.Cleanup(func() {
|
||||
os.Setenv("GH_REPO", orig_GH_HOST)
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
remotes git.RemoteSet
|
||||
config config.Config
|
||||
envOverride string
|
||||
argOverride string
|
||||
wantsErr bool
|
||||
wantsName string
|
||||
wantsOwner string
|
||||
wantsHost string
|
||||
}{
|
||||
{
|
||||
name: "override from argument",
|
||||
argOverride: "override/test",
|
||||
wantsHost: "github.com",
|
||||
wantsOwner: "override",
|
||||
wantsName: "test",
|
||||
},
|
||||
{
|
||||
name: "override from environment",
|
||||
envOverride: "somehost.com/override/test",
|
||||
wantsHost: "somehost.com",
|
||||
wantsOwner: "override",
|
||||
wantsName: "test",
|
||||
},
|
||||
{
|
||||
name: "no override",
|
||||
remotes: git.RemoteSet{
|
||||
git.NewRemote("origin", "https://nonsense.com/owner/repo.git"),
|
||||
},
|
||||
config: defaultConfig(),
|
||||
wantsHost: "nonsense.com",
|
||||
wantsOwner: "owner",
|
||||
wantsName: "repo",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.envOverride != "" {
|
||||
os.Setenv("GH_REPO", tt.envOverride)
|
||||
} else {
|
||||
os.Unsetenv("GH_REPO")
|
||||
}
|
||||
f := New("1")
|
||||
rr := &remoteResolver{
|
||||
readRemotes: func() (git.RemoteSet, error) {
|
||||
return tt.remotes, nil
|
||||
},
|
||||
getConfig: func() (config.Config, error) {
|
||||
return tt.config, nil
|
||||
},
|
||||
}
|
||||
f.Remotes = rr.Resolver()
|
||||
f.BaseRepo = cmdutil.OverrideBaseRepoFunc(f, tt.argOverride)
|
||||
repo, err := f.BaseRepo()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
return
|
||||
}
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, tt.wantsName, repo.RepoName())
|
||||
assert.Equal(t, tt.wantsOwner, repo.RepoOwner())
|
||||
assert.Equal(t, tt.wantsHost, repo.RepoHost())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ioStreams_pager(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
env map[string]string
|
||||
config config.Config
|
||||
wantPager string
|
||||
}{
|
||||
{
|
||||
name: "GH_PAGER and PAGER set",
|
||||
env: map[string]string{
|
||||
"GH_PAGER": "GH_PAGER",
|
||||
"PAGER": "PAGER",
|
||||
},
|
||||
wantPager: "GH_PAGER",
|
||||
},
|
||||
{
|
||||
name: "GH_PAGER and config pager set",
|
||||
env: map[string]string{
|
||||
"GH_PAGER": "GH_PAGER",
|
||||
},
|
||||
config: pagerConfig(),
|
||||
wantPager: "GH_PAGER",
|
||||
},
|
||||
{
|
||||
name: "config pager and PAGER set",
|
||||
env: map[string]string{
|
||||
"PAGER": "PAGER",
|
||||
},
|
||||
config: pagerConfig(),
|
||||
wantPager: "CONFIG_PAGER",
|
||||
},
|
||||
{
|
||||
name: "only PAGER set",
|
||||
env: map[string]string{
|
||||
"PAGER": "PAGER",
|
||||
},
|
||||
wantPager: "PAGER",
|
||||
},
|
||||
{
|
||||
name: "GH_PAGER set to blank string",
|
||||
env: map[string]string{
|
||||
"GH_PAGER": "",
|
||||
"PAGER": "PAGER",
|
||||
},
|
||||
wantPager: "",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if tt.env != nil {
|
||||
for k, v := range tt.env {
|
||||
old := os.Getenv(k)
|
||||
os.Setenv(k, v)
|
||||
if k == "GH_PAGER" {
|
||||
defer os.Unsetenv(k)
|
||||
} else {
|
||||
defer os.Setenv(k, old)
|
||||
}
|
||||
}
|
||||
}
|
||||
f := New("1")
|
||||
if tt.config != nil {
|
||||
f.Config = func() (config.Config, error) {
|
||||
return tt.config, nil
|
||||
}
|
||||
}
|
||||
io := ioStreams(f)
|
||||
assert.Equal(t, tt.wantPager, io.GetPager())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_ioStreams_prompt(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
config config.Config
|
||||
promptDisabled bool
|
||||
}{
|
||||
{
|
||||
name: "default config",
|
||||
promptDisabled: false,
|
||||
},
|
||||
{
|
||||
name: "config with prompt disabled",
|
||||
config: disablePromptConfig(),
|
||||
promptDisabled: true,
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
f := New("1")
|
||||
if tt.config != nil {
|
||||
f.Config = func() (config.Config, error) {
|
||||
return tt.config, nil
|
||||
}
|
||||
}
|
||||
io := ioStreams(f)
|
||||
assert.Equal(t, tt.promptDisabled, io.GetNeverPrompt())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func defaultConfig() config.Config {
|
||||
return config.InheritEnv(config.NewFromString(heredoc.Doc(`
|
||||
hosts:
|
||||
nonsense.com:
|
||||
oauth_token: BLAH
|
||||
`)))
|
||||
}
|
||||
|
||||
func pagerConfig() config.Config {
|
||||
return config.NewFromString("pager: CONFIG_PAGER")
|
||||
}
|
||||
|
||||
func disablePromptConfig() config.Config {
|
||||
return config.NewFromString("prompt: disabled")
|
||||
}
|
||||
|
|
@ -8,7 +8,6 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
)
|
||||
|
|
@ -53,8 +52,12 @@ var timezoneNames = map[int]string{
|
|||
50400: "Pacific/Kiritimati",
|
||||
}
|
||||
|
||||
type configGetter interface {
|
||||
Get(string, string) (string, error)
|
||||
}
|
||||
|
||||
// generic authenticated HTTP client for commands
|
||||
func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string, setAccept bool) *http.Client {
|
||||
func NewHTTPClient(io *iostreams.IOStreams, cfg configGetter, appVersion string, setAccept bool) *http.Client {
|
||||
var opts []api.ClientOption
|
||||
if verbose := os.Getenv("DEBUG"); verbose != "" {
|
||||
logTraffic := strings.Contains(verbose, "api")
|
||||
|
|
@ -64,7 +67,7 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string
|
|||
opts = append(opts,
|
||||
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", appVersion)),
|
||||
api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) {
|
||||
hostname := ghinstance.NormalizeHostname(req.URL.Hostname())
|
||||
hostname := ghinstance.NormalizeHostname(getHost(req))
|
||||
if token, err := cfg.Get(hostname, "oauth_token"); err == nil && token != "" {
|
||||
return fmt.Sprintf("token %s", token), nil
|
||||
}
|
||||
|
|
@ -85,13 +88,10 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string
|
|||
if setAccept {
|
||||
opts = append(opts,
|
||||
api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) {
|
||||
// antiope-preview: Checks
|
||||
accept := "application/vnd.github.antiope-preview+json"
|
||||
// introduced for #2952: pr branch up to date status
|
||||
accept += ", application/vnd.github.merge-info-preview+json"
|
||||
if ghinstance.IsEnterprise(req.URL.Hostname()) {
|
||||
// shadow-cat-preview: Draft pull requests
|
||||
accept += ", application/vnd.github.shadow-cat-preview"
|
||||
accept := "application/vnd.github.merge-info-preview+json" // PullRequest.mergeStateStatus
|
||||
if ghinstance.IsEnterprise(getHost(req)) {
|
||||
accept += ", application/vnd.github.antiope-preview" // Commit.statusCheckRollup
|
||||
accept += ", application/vnd.github.shadow-cat-preview" // PullRequest.isDraft
|
||||
}
|
||||
return accept, nil
|
||||
}),
|
||||
|
|
@ -100,3 +100,10 @@ func NewHTTPClient(io *iostreams.IOStreams, cfg config.Config, appVersion string
|
|||
|
||||
return api.NewHTTPClient(opts...)
|
||||
}
|
||||
|
||||
func getHost(r *http.Request) string {
|
||||
if r.Host != "" {
|
||||
return r.Host
|
||||
}
|
||||
return r.URL.Hostname()
|
||||
}
|
||||
|
|
|
|||
174
pkg/cmd/factory/http_test.go
Normal file
174
pkg/cmd/factory/http_test.go
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
package factory
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"regexp"
|
||||
"testing"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/pkg/iostreams"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewHTTPClient(t *testing.T) {
|
||||
type args struct {
|
||||
config configGetter
|
||||
appVersion string
|
||||
setAccept bool
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
envDebug string
|
||||
host string
|
||||
wantHeader map[string]string
|
||||
wantStderr string
|
||||
}{
|
||||
{
|
||||
name: "github.com with Accept header",
|
||||
args: args{
|
||||
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
setAccept: true,
|
||||
},
|
||||
host: "github.com",
|
||||
wantHeader: map[string]string{
|
||||
"authorization": "token MYTOKEN",
|
||||
"user-agent": "GitHub CLI v1.2.3",
|
||||
"accept": "application/vnd.github.merge-info-preview+json",
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "github.com no Accept header",
|
||||
args: args{
|
||||
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
setAccept: false,
|
||||
},
|
||||
host: "github.com",
|
||||
wantHeader: map[string]string{
|
||||
"authorization": "token MYTOKEN",
|
||||
"user-agent": "GitHub CLI v1.2.3",
|
||||
"accept": "",
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "github.com no authentication token",
|
||||
args: args{
|
||||
config: tinyConfig{"example.com:oauth_token": "MYTOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
setAccept: true,
|
||||
},
|
||||
host: "github.com",
|
||||
wantHeader: map[string]string{
|
||||
"authorization": "",
|
||||
"user-agent": "GitHub CLI v1.2.3",
|
||||
"accept": "application/vnd.github.merge-info-preview+json",
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
{
|
||||
name: "github.com in verbose mode",
|
||||
args: args{
|
||||
config: tinyConfig{"github.com:oauth_token": "MYTOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
setAccept: true,
|
||||
},
|
||||
host: "github.com",
|
||||
envDebug: "api",
|
||||
wantHeader: map[string]string{
|
||||
"authorization": "token MYTOKEN",
|
||||
"user-agent": "GitHub CLI v1.2.3",
|
||||
"accept": "application/vnd.github.merge-info-preview+json",
|
||||
},
|
||||
wantStderr: heredoc.Doc(`
|
||||
* Request at <time>
|
||||
* Request to http://<host>:<port>
|
||||
> GET / HTTP/1.1
|
||||
> Host: github.com
|
||||
> Accept: application/vnd.github.merge-info-preview+json
|
||||
> Authorization: token ████████████████████
|
||||
> User-Agent: GitHub CLI v1.2.3
|
||||
|
||||
< HTTP/1.1 204 No Content
|
||||
< Date: <time>
|
||||
|
||||
* Request took <duration>
|
||||
`),
|
||||
},
|
||||
{
|
||||
name: "GHES Accept header",
|
||||
args: args{
|
||||
config: tinyConfig{"example.com:oauth_token": "GHETOKEN"},
|
||||
appVersion: "v1.2.3",
|
||||
setAccept: true,
|
||||
},
|
||||
host: "example.com",
|
||||
wantHeader: map[string]string{
|
||||
"authorization": "token GHETOKEN",
|
||||
"user-agent": "GitHub CLI v1.2.3",
|
||||
"accept": "application/vnd.github.merge-info-preview+json, application/vnd.github.antiope-preview, application/vnd.github.shadow-cat-preview",
|
||||
},
|
||||
wantStderr: "",
|
||||
},
|
||||
}
|
||||
|
||||
var gotReq *http.Request
|
||||
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
gotReq = r
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
defer ts.Close()
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
oldDebug := os.Getenv("DEBUG")
|
||||
os.Setenv("DEBUG", tt.envDebug)
|
||||
t.Cleanup(func() {
|
||||
os.Setenv("DEBUG", oldDebug)
|
||||
})
|
||||
|
||||
io, _, _, stderr := iostreams.Test()
|
||||
client := NewHTTPClient(io, tt.args.config, tt.args.appVersion, tt.args.setAccept)
|
||||
|
||||
req, err := http.NewRequest("GET", ts.URL, nil)
|
||||
req.Host = tt.host
|
||||
require.NoError(t, err)
|
||||
|
||||
res, err := client.Do(req)
|
||||
require.NoError(t, err)
|
||||
|
||||
for name, value := range tt.wantHeader {
|
||||
assert.Equal(t, value, gotReq.Header.Get(name), name)
|
||||
}
|
||||
|
||||
assert.Equal(t, 204, res.StatusCode)
|
||||
assert.Equal(t, tt.wantStderr, normalizeVerboseLog(stderr.String()))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type tinyConfig map[string]string
|
||||
|
||||
func (c tinyConfig) Get(host, key string) (string, error) {
|
||||
return c[fmt.Sprintf("%s:%s", host, key)], nil
|
||||
}
|
||||
|
||||
var requestAtRE = regexp.MustCompile(`(?m)^\* Request at .+`)
|
||||
var dateRE = regexp.MustCompile(`(?m)^< Date: .+`)
|
||||
var hostWithPortRE = regexp.MustCompile(`127\.0\.0\.1:\d+`)
|
||||
var durationRE = regexp.MustCompile(`(?m)^\* Request took .+`)
|
||||
|
||||
func normalizeVerboseLog(t string) string {
|
||||
t = requestAtRE.ReplaceAllString(t, "* Request at <time>")
|
||||
t = hostWithPortRE.ReplaceAllString(t, "<host>:<port>")
|
||||
t = dateRE.ReplaceAllString(t, "< Date: <time>")
|
||||
t = durationRE.ReplaceAllString(t, "* Request took <duration>")
|
||||
return t
|
||||
}
|
||||
|
|
@ -234,10 +234,6 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if tb.Body == "" {
|
||||
tb.Body = templateContent
|
||||
}
|
||||
}
|
||||
|
||||
openURL, err = generatePreviewURL(apiClient, baseRepo, tb)
|
||||
|
|
|
|||
|
|
@ -114,6 +114,8 @@ func TestNewCmdCreate(t *testing.T) {
|
|||
args, err := shlex.Split(tt.cli)
|
||||
require.NoError(t, err)
|
||||
cmd.SetArgs(args)
|
||||
cmd.SetOut(ioutil.Discard)
|
||||
cmd.SetErr(ioutil.Discard)
|
||||
_, err = cmd.ExecuteC()
|
||||
if tt.wantsErr {
|
||||
assert.Error(t, err)
|
||||
|
|
@ -168,7 +170,7 @@ func Test_createRun(t *testing.T) {
|
|||
WebMode: true,
|
||||
Assignees: []string{"monalisa"},
|
||||
},
|
||||
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=monalisa",
|
||||
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=monalisa&body=",
|
||||
wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n",
|
||||
},
|
||||
{
|
||||
|
|
@ -185,7 +187,7 @@ func Test_createRun(t *testing.T) {
|
|||
"viewer": { "login": "MonaLisa" }
|
||||
} }`))
|
||||
},
|
||||
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=MonaLisa",
|
||||
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?assignees=MonaLisa&body=",
|
||||
wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n",
|
||||
},
|
||||
{
|
||||
|
|
@ -214,7 +216,7 @@ func Test_createRun(t *testing.T) {
|
|||
"pageInfo": { "hasNextPage": false }
|
||||
} } } }`))
|
||||
},
|
||||
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?projects=OWNER%2FREPO%2F1",
|
||||
wantsBrowse: "https://github.com/OWNER/REPO/issues/new?body=&projects=OWNER%2FREPO%2F1",
|
||||
wantsStderr: "Opening github.com/OWNER/REPO/issues/new in your browser.\n",
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -160,6 +160,7 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
|
|||
milestoneTitle = issue.Milestone.Title
|
||||
}
|
||||
fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle)
|
||||
fmt.Fprintf(out, "number:\t%d\n", issue.Number)
|
||||
fmt.Fprintln(out, "--")
|
||||
fmt.Fprintln(out, issue.Body)
|
||||
return nil
|
||||
|
|
@ -172,7 +173,7 @@ func printHumanIssuePreview(opts *ViewOptions, issue *api.Issue) error {
|
|||
cs := opts.IO.ColorScheme()
|
||||
|
||||
// Header (Title and State)
|
||||
fmt.Fprintln(out, cs.Bold(issue.Title))
|
||||
fmt.Fprintf(out, "%s #%d\n", cs.Bold(issue.Title), issue.Number)
|
||||
fmt.Fprintf(out,
|
||||
"%s • %s opened %s • %s\n",
|
||||
issueStateTitleWithColor(cs, issue.State),
|
||||
|
|
|
|||
|
|
@ -112,6 +112,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
|
|||
`comments:\t9`,
|
||||
`author:\tmarseilles`,
|
||||
`assignees:`,
|
||||
`number:\t123\n`,
|
||||
`\*\*bold story\*\*`,
|
||||
},
|
||||
},
|
||||
|
|
@ -126,6 +127,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
|
|||
`labels:\tone, two, three, four, five`,
|
||||
`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\), Project 4 \(Awaiting triage\)\n`,
|
||||
`milestone:\tuluru\n`,
|
||||
`number:\t123\n`,
|
||||
`\*\*bold story\*\*`,
|
||||
},
|
||||
},
|
||||
|
|
@ -136,6 +138,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
|
|||
`state:\tOPEN`,
|
||||
`author:\tmarseilles`,
|
||||
`labels:\ttarot`,
|
||||
`number:\t123\n`,
|
||||
},
|
||||
},
|
||||
"Closed issue": {
|
||||
|
|
@ -146,6 +149,7 @@ func TestIssueView_nontty_Preview(t *testing.T) {
|
|||
`\*\*bold story\*\*`,
|
||||
`author:\tmarseilles`,
|
||||
`labels:\ttarot`,
|
||||
`number:\t123\n`,
|
||||
`\*\*bold story\*\*`,
|
||||
},
|
||||
},
|
||||
|
|
@ -178,7 +182,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
|
|||
"Open issue without metadata": {
|
||||
fixture: "./fixtures/issueView_preview.json",
|
||||
expectedOutputs: []string{
|
||||
`ix of coins`,
|
||||
`ix of coins #123`,
|
||||
`Open.*marseilles opened about 9 years ago.*9 comments`,
|
||||
`bold story`,
|
||||
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
|
||||
|
|
@ -187,7 +191,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
|
|||
"Open issue with metadata": {
|
||||
fixture: "./fixtures/issueView_previewWithMetadata.json",
|
||||
expectedOutputs: []string{
|
||||
`ix of coins`,
|
||||
`ix of coins #123`,
|
||||
`Open.*marseilles opened about 9 years ago.*9 comments`,
|
||||
`8 \x{1f615} • 7 \x{1f440} • 6 \x{2764}\x{fe0f} • 5 \x{1f389} • 4 \x{1f604} • 3 \x{1f680} • 2 \x{1f44e} • 1 \x{1f44d}`,
|
||||
`Assignees:.*marseilles, monaco\n`,
|
||||
|
|
@ -201,7 +205,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
|
|||
"Open issue with empty body": {
|
||||
fixture: "./fixtures/issueView_previewWithEmptyBody.json",
|
||||
expectedOutputs: []string{
|
||||
`ix of coins`,
|
||||
`ix of coins #123`,
|
||||
`Open.*marseilles opened about 9 years ago.*9 comments`,
|
||||
`No description provided`,
|
||||
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
|
||||
|
|
@ -210,7 +214,7 @@ func TestIssueView_tty_Preview(t *testing.T) {
|
|||
"Closed issue": {
|
||||
fixture: "./fixtures/issueView_previewClosedState.json",
|
||||
expectedOutputs: []string{
|
||||
`ix of coins`,
|
||||
`ix of coins #123`,
|
||||
`Closed.*marseilles opened about 9 years ago.*9 comments`,
|
||||
`bold story`,
|
||||
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
|
||||
|
|
@ -310,7 +314,7 @@ func TestIssueView_tty_Comments(t *testing.T) {
|
|||
"IssueByNumber": "./fixtures/issueView_previewSingleComment.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`some title`,
|
||||
`some title #123`,
|
||||
`some body`,
|
||||
`———————— Not showing 5 comments ————————`,
|
||||
`marseilles \(Collaborator\) • Jan 1, 2020 • Newest comment`,
|
||||
|
|
@ -326,7 +330,7 @@ func TestIssueView_tty_Comments(t *testing.T) {
|
|||
"CommentsForIssue": "./fixtures/issueView_previewFullComments.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`some title`,
|
||||
`some title #123`,
|
||||
`some body`,
|
||||
`monalisa • Jan 1, 2020 • Edited`,
|
||||
`1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`,
|
||||
|
|
@ -386,6 +390,7 @@ func TestIssueView_nontty_Comments(t *testing.T) {
|
|||
`state:\tOPEN`,
|
||||
`author:\tmarseilles`,
|
||||
`comments:\t6`,
|
||||
`number:\t123`,
|
||||
`some body`,
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -302,10 +302,6 @@ func createRun(opts *CreateOptions) (err error) {
|
|||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
if state.Body == "" {
|
||||
state.Body = templateContent
|
||||
}
|
||||
}
|
||||
|
||||
openURL, err = generateCompareURL(*ctx, *state)
|
||||
|
|
|
|||
|
|
@ -239,7 +239,7 @@ func TestPRCreate_nontty_web(t *testing.T) {
|
|||
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "", output.Stderr())
|
||||
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1", output.BrowsedURL)
|
||||
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1", output.BrowsedURL)
|
||||
}
|
||||
|
||||
func TestPRCreate_recover(t *testing.T) {
|
||||
|
|
@ -780,7 +780,7 @@ func TestPRCreate_web(t *testing.T) {
|
|||
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n", output.Stderr())
|
||||
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1", output.BrowsedURL)
|
||||
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1", output.BrowsedURL)
|
||||
}
|
||||
|
||||
func TestPRCreate_webLongURL(t *testing.T) {
|
||||
|
|
@ -851,7 +851,7 @@ func TestPRCreate_webProject(t *testing.T) {
|
|||
|
||||
assert.Equal(t, "", output.String())
|
||||
assert.Equal(t, "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n", output.Stderr())
|
||||
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?expand=1&projects=ORG%2F1", output.BrowsedURL)
|
||||
assert.Equal(t, "https://github.com/OWNER/REPO/compare/master...feature?body=&expand=1&projects=ORG%2F1", output.BrowsedURL)
|
||||
}
|
||||
|
||||
func Test_determineTrackingBranch_empty(t *testing.T) {
|
||||
|
|
@ -965,7 +965,7 @@ func Test_generateCompareURL(t *testing.T) {
|
|||
BaseBranch: "main",
|
||||
HeadBranchLabel: "feature",
|
||||
},
|
||||
want: "https://github.com/OWNER/REPO/compare/main...feature?expand=1",
|
||||
want: "https://github.com/OWNER/REPO/compare/main...feature?body=&expand=1",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -978,7 +978,7 @@ func Test_generateCompareURL(t *testing.T) {
|
|||
state: prShared.IssueMetadataState{
|
||||
Labels: []string{"one", "two three"},
|
||||
},
|
||||
want: "https://github.com/OWNER/REPO/compare/a...b?expand=1&labels=one%2Ctwo+three",
|
||||
want: "https://github.com/OWNER/REPO/compare/a...b?body=&expand=1&labels=one%2Ctwo+three",
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -988,7 +988,7 @@ func Test_generateCompareURL(t *testing.T) {
|
|||
BaseBranch: "main/trunk",
|
||||
HeadBranchLabel: "owner:feature",
|
||||
},
|
||||
want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner%3Afeature?expand=1",
|
||||
want: "https://github.com/OWNER/REPO/compare/main%2Ftrunk...owner%3Afeature?body=&expand=1",
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,13 +2,13 @@ package shared
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"github.com/google/shlex"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/githubsearch"
|
||||
"github.com/google/shlex"
|
||||
)
|
||||
|
||||
func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, baseURL string, state IssueMetadataState) (string, error) {
|
||||
|
|
@ -20,9 +20,10 @@ func WithPrAndIssueQueryParams(client *api.Client, baseRepo ghrepo.Interface, ba
|
|||
if state.Title != "" {
|
||||
q.Set("title", state.Title)
|
||||
}
|
||||
if state.Body != "" {
|
||||
q.Set("body", state.Body)
|
||||
}
|
||||
// We always want to send the body parameter, even if it's empty, to prevent the web interface from
|
||||
// applying the default template. Since the user has the option to select a template in the terminal,
|
||||
// assume that empty body here means that the user either skipped it or erased its contents.
|
||||
q.Set("body", state.Body)
|
||||
if len(state.Assignees) > 0 {
|
||||
q.Set("assignees", strings.Join(state.Assignees, ","))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package shared
|
||||
|
||||
import (
|
||||
"github.com/stretchr/testify/assert"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
|
@ -9,6 +8,7 @@ import (
|
|||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/httpmock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func Test_listURLWithQuery(t *testing.T) {
|
||||
|
|
@ -192,3 +192,56 @@ func Test_QueryHasStateClause(t *testing.T) {
|
|||
assert.Equal(t, tt.hasState, gotState)
|
||||
}
|
||||
}
|
||||
|
||||
func Test_WithPrAndIssueQueryParams(t *testing.T) {
|
||||
type args struct {
|
||||
baseURL string
|
||||
state IssueMetadataState
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
args args
|
||||
want string
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "blank",
|
||||
args: args{
|
||||
baseURL: "",
|
||||
state: IssueMetadataState{},
|
||||
},
|
||||
want: "?body=",
|
||||
},
|
||||
{
|
||||
name: "no values",
|
||||
args: args{
|
||||
baseURL: "http://example.com/hey",
|
||||
state: IssueMetadataState{},
|
||||
},
|
||||
want: "http://example.com/hey?body=",
|
||||
},
|
||||
{
|
||||
name: "title and body",
|
||||
args: args{
|
||||
baseURL: "http://example.com/hey",
|
||||
state: IssueMetadataState{
|
||||
Title: "my title",
|
||||
Body: "my bodeh",
|
||||
},
|
||||
},
|
||||
want: "http://example.com/hey?body=my+bodeh&title=my+title",
|
||||
},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, err := WithPrAndIssueQueryParams(nil, nil, tt.args.baseURL, tt.args.state)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("WithPrAndIssueQueryParams() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("WithPrAndIssueQueryParams() = %v, want %v", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -110,7 +110,7 @@ func BodySurvey(state *IssueMetadataState, templateContent, editorCommand string
|
|||
return err
|
||||
}
|
||||
|
||||
if state.Body != "" && preBody != state.Body {
|
||||
if preBody != state.Body {
|
||||
state.MarkDirty()
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -21,10 +21,12 @@
|
|||
}
|
||||
},
|
||||
{
|
||||
"requestedReviewer": {
|
||||
"__typename": "Team",
|
||||
"name": "Team 1"
|
||||
}
|
||||
"requestedReviewer": {
|
||||
"__typename": "Team",
|
||||
"name": "Team 1",
|
||||
"slug": "team-1",
|
||||
"organization": {"login": "my-org"}
|
||||
}
|
||||
},
|
||||
{
|
||||
"requestedReviewer": {
|
||||
|
|
|
|||
|
|
@ -170,7 +170,7 @@ func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error {
|
|||
cs := opts.IO.ColorScheme()
|
||||
|
||||
// Header (Title and State)
|
||||
fmt.Fprintln(out, cs.Bold(pr.Title))
|
||||
fmt.Fprintf(out, "%s #%d\n", cs.Bold(pr.Title), pr.Number)
|
||||
fmt.Fprintf(out,
|
||||
"%s • %s wants to merge %s into %s from %s • %s %s \n",
|
||||
shared.StateTitleWithColor(cs, *pr),
|
||||
|
|
@ -294,8 +294,6 @@ func prReviewerList(pr api.PullRequest, cs *iostreams.ColorScheme) string {
|
|||
return reviewerList
|
||||
}
|
||||
|
||||
const teamTypeName = "Team"
|
||||
|
||||
const ghostName = "ghost"
|
||||
|
||||
// parseReviewers parses given Reviews and ReviewRequests
|
||||
|
|
@ -317,10 +315,7 @@ func parseReviewers(pr api.PullRequest) []*reviewerState {
|
|||
|
||||
// Overwrite reviewer's state if a review request for the same reviewer exists.
|
||||
for _, reviewRequest := range pr.ReviewRequests.Nodes {
|
||||
name := reviewRequest.RequestedReviewer.Login
|
||||
if reviewRequest.RequestedReviewer.TypeName == teamTypeName {
|
||||
name = reviewRequest.RequestedReviewer.Name
|
||||
}
|
||||
name := reviewRequest.RequestedReviewer.LoginOrSlug()
|
||||
reviewerStates[name] = &reviewerState{
|
||||
Name: name,
|
||||
State: requestedReviewState,
|
||||
|
|
|
|||
|
|
@ -250,7 +250,7 @@ func TestPRView_Preview_nontty(t *testing.T) {
|
|||
`milestone:\t\n`,
|
||||
`additions:\t100\n`,
|
||||
`deletions:\t10\n`,
|
||||
`reviewers:\tDEF \(Commented\), def \(Changes requested\), ghost \(Approved\), hubot \(Commented\), xyz \(Approved\), 123 \(Requested\), Team 1 \(Requested\), abc \(Requested\)\n`,
|
||||
`reviewers:\tDEF \(Commented\), def \(Changes requested\), ghost \(Approved\), hubot \(Commented\), xyz \(Approved\), 123 \(Requested\), abc \(Requested\), my-org\/team-1 \(Requested\)\n`,
|
||||
`\*\*blueberries taste good\*\*`,
|
||||
},
|
||||
},
|
||||
|
|
@ -350,7 +350,7 @@ func TestPRView_Preview(t *testing.T) {
|
|||
"PullRequestByNumber": "./fixtures/prViewPreview.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`Blueberries are from a fork`,
|
||||
`Blueberries are from a fork #12`,
|
||||
`Open.*nobody wants to merge 12 commits into master from blueberries.+100.-10`,
|
||||
`blueberries taste good`,
|
||||
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
|
||||
|
|
@ -363,7 +363,7 @@ func TestPRView_Preview(t *testing.T) {
|
|||
"PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`Blueberries are from a fork`,
|
||||
`Blueberries are from a fork #12`,
|
||||
`Open.*nobody wants to merge 12 commits into master from blueberries.+100.-10`,
|
||||
`Reviewers:.*1 \(.*Requested.*\)\n`,
|
||||
`Assignees:.*marseilles, monaco\n`,
|
||||
|
|
@ -382,8 +382,8 @@ func TestPRView_Preview(t *testing.T) {
|
|||
"ReviewsForPullRequest": "./fixtures/prViewPreviewManyReviews.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`Blueberries are from a fork`,
|
||||
`Reviewers:.*DEF \(.*Commented.*\), def \(.*Changes requested.*\), ghost \(.*Approved.*\), hubot \(Commented\), xyz \(.*Approved.*\), 123 \(.*Requested.*\), Team 1 \(.*Requested.*\), abc \(.*Requested.*\)\n`,
|
||||
`Blueberries are from a fork #12`,
|
||||
`Reviewers: DEF \(Commented\), def \(Changes requested\), ghost \(Approved\), hubot \(Commented\), xyz \(Approved\), 123 \(Requested\), abc \(Requested\), my-org\/team-1 \(Requested\)`,
|
||||
`blueberries taste good`,
|
||||
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
|
||||
},
|
||||
|
|
@ -395,7 +395,7 @@ func TestPRView_Preview(t *testing.T) {
|
|||
"PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`Blueberries are from a fork`,
|
||||
`Blueberries are from a fork #12`,
|
||||
`Closed.*nobody wants to merge 12 commits into master from blueberries.+100.-10`,
|
||||
`blueberries taste good`,
|
||||
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
|
||||
|
|
@ -408,7 +408,7 @@ func TestPRView_Preview(t *testing.T) {
|
|||
"PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`Blueberries are from a fork`,
|
||||
`Blueberries are from a fork #12`,
|
||||
`Merged.*nobody wants to merge 12 commits into master from blueberries.+100.-10`,
|
||||
`blueberries taste good`,
|
||||
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
|
||||
|
|
@ -421,7 +421,7 @@ func TestPRView_Preview(t *testing.T) {
|
|||
"PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`Blueberries are from a fork`,
|
||||
`Blueberries are from a fork #12`,
|
||||
`Draft.*nobody wants to merge 12 commits into master from blueberries.+100.-10`,
|
||||
`blueberries taste good`,
|
||||
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
|
||||
|
|
@ -501,7 +501,7 @@ func TestPRView_tty_Comments(t *testing.T) {
|
|||
"ReviewsForPullRequest": "./fixtures/prViewPreviewReviews.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`some title`,
|
||||
`some title #12`,
|
||||
`1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f}`,
|
||||
`some body`,
|
||||
`———————— Not showing 9 comments ————————`,
|
||||
|
|
@ -521,7 +521,7 @@ func TestPRView_tty_Comments(t *testing.T) {
|
|||
"CommentsForPullRequest": "./fixtures/prViewPreviewFullComments.json",
|
||||
},
|
||||
expectedOutputs: []string{
|
||||
`some title`,
|
||||
`some title #12`,
|
||||
`some body`,
|
||||
`monalisa • Jan 1, 2020 • Edited`,
|
||||
`1 \x{1f615} • 2 \x{1f440} • 3 \x{2764}\x{fe0f} • 4 \x{1f389} • 5 \x{1f604} • 6 \x{1f680} • 7 \x{1f44e} • 8 \x{1f44d}`,
|
||||
|
|
|
|||
|
|
@ -22,12 +22,15 @@ import (
|
|||
"github.com/spf13/pflag"
|
||||
)
|
||||
|
||||
const defaultRemoteName = "origin"
|
||||
|
||||
type ForkOptions struct {
|
||||
HttpClient func() (*http.Client, error)
|
||||
Config func() (config.Config, error)
|
||||
IO *iostreams.IOStreams
|
||||
BaseRepo func() (ghrepo.Interface, error)
|
||||
Remotes func() (context.Remotes, error)
|
||||
Since func(time.Time) time.Duration
|
||||
|
||||
GitArgs []string
|
||||
Repository string
|
||||
|
|
@ -40,9 +43,9 @@ type ForkOptions struct {
|
|||
Rename bool
|
||||
}
|
||||
|
||||
var Since = func(t time.Time) time.Duration {
|
||||
return time.Since(t)
|
||||
}
|
||||
// TODO warn about useless flags (--remote, --remote-name) when running from outside a repository
|
||||
// TODO output over STDOUT not STDERR
|
||||
// TODO remote-name has no effect on its own; error that or change behavior
|
||||
|
||||
func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Command {
|
||||
opts := &ForkOptions{
|
||||
|
|
@ -51,6 +54,7 @@ func NewCmdFork(f *cmdutil.Factory, runF func(*ForkOptions) error) *cobra.Comman
|
|||
Config: f.Config,
|
||||
BaseRepo: f.BaseRepo,
|
||||
Remotes: f.Remotes,
|
||||
Since: time.Since,
|
||||
}
|
||||
|
||||
cmd := &cobra.Command{
|
||||
|
|
@ -110,7 +114,7 @@ Additional 'git clone' flags can be passed in by listing them after '--'.`,
|
|||
|
||||
cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork {true|false}")
|
||||
cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add remote for fork {true|false}")
|
||||
cmd.Flags().StringVar(&opts.RemoteName, "remote-name", "origin", "Specify a name for a fork's new remote.")
|
||||
cmd.Flags().StringVar(&opts.RemoteName, "remote-name", defaultRemoteName, "Specify a name for a fork's new remote.")
|
||||
cmd.Flags().StringVar(&opts.Organization, "org", "", "Create the fork in an organization")
|
||||
|
||||
return cmd
|
||||
|
|
@ -182,7 +186,7 @@ func forkRun(opts *ForkOptions) error {
|
|||
// returns the fork repo data even if it already exists -- with no change in status code or
|
||||
// anything. We thus check the created time to see if the repo is brand new or not; if it's not,
|
||||
// we assume the fork already existed and report an error.
|
||||
createdAgo := Since(forkedRepo.CreatedAt)
|
||||
createdAgo := opts.Since(forkedRepo.CreatedAt)
|
||||
if createdAgo > time.Minute {
|
||||
if connectedToTerminal {
|
||||
fmt.Fprintf(stderr, "%s %s %s\n",
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -4,15 +4,13 @@ import (
|
|||
"net/http"
|
||||
|
||||
"github.com/MakeNowJust/heredoc"
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/context"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
actionsCmd "github.com/cli/cli/pkg/cmd/actions"
|
||||
aliasCmd "github.com/cli/cli/pkg/cmd/alias"
|
||||
apiCmd "github.com/cli/cli/pkg/cmd/api"
|
||||
authCmd "github.com/cli/cli/pkg/cmd/auth"
|
||||
completionCmd "github.com/cli/cli/pkg/cmd/completion"
|
||||
configCmd "github.com/cli/cli/pkg/cmd/config"
|
||||
extensionsCmd "github.com/cli/cli/pkg/cmd/extensions"
|
||||
"github.com/cli/cli/pkg/cmd/factory"
|
||||
gistCmd "github.com/cli/cli/pkg/cmd/gist"
|
||||
issueCmd "github.com/cli/cli/pkg/cmd/issue"
|
||||
|
|
@ -80,6 +78,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
|
|||
cmd.AddCommand(creditsCmd.NewCmdCredits(f, nil))
|
||||
cmd.AddCommand(gistCmd.NewCmdGist(f))
|
||||
cmd.AddCommand(completionCmd.NewCmdCompletion(f.IOStreams))
|
||||
cmd.AddCommand(extensionsCmd.NewCmdExtensions(f.IOStreams))
|
||||
cmd.AddCommand(secretCmd.NewCmdSecret(f))
|
||||
cmd.AddCommand(sshKeyCmd.NewCmdSSHKey(f))
|
||||
|
||||
|
|
@ -91,7 +90,7 @@ func NewCmdRoot(f *cmdutil.Factory, version, buildDate string) *cobra.Command {
|
|||
|
||||
// below here at the commands that require the "intelligent" BaseRepo resolver
|
||||
repoResolvingCmdFactory := *f
|
||||
repoResolvingCmdFactory.BaseRepo = resolvedBaseRepo(f)
|
||||
repoResolvingCmdFactory.BaseRepo = factory.SmartBaseRepoFunc(f)
|
||||
|
||||
cmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory))
|
||||
cmd.AddCommand(issueCmd.NewCmdIssue(&repoResolvingCmdFactory))
|
||||
|
|
@ -124,29 +123,3 @@ func bareHTTPClient(f *cmdutil.Factory, version string) func() (*http.Client, er
|
|||
return factory.NewHTTPClient(f.IOStreams, cfg, version, false), nil
|
||||
}
|
||||
}
|
||||
|
||||
func resolvedBaseRepo(f *cmdutil.Factory) func() (ghrepo.Interface, error) {
|
||||
return func() (ghrepo.Interface, error) {
|
||||
httpClient, err := f.HttpClient()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
apiClient := api.NewClientFromHTTP(httpClient)
|
||||
|
||||
remotes, err := f.Remotes()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
repoContext, err := context.ResolveRemotesToRepos(remotes, apiClient, "")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
baseRepo, err := repoContext.BaseRepo(f.IOStreams)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return baseRepo, nil
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -462,7 +462,7 @@ func promptForJob(cs *iostreams.ColorScheme, jobs []shared.Job) (*shared.Job, er
|
|||
}
|
||||
|
||||
func logFilenameRegexp(job shared.Job, step shared.Step) *regexp.Regexp {
|
||||
re := fmt.Sprintf(`%s\/%d_.*\.txt`, job.Name, step.Number)
|
||||
re := fmt.Sprintf(`%s\/%d_.*\.txt`, regexp.QuoteMeta(job.Name), step.Number)
|
||||
return regexp.MustCompile(re)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -926,6 +926,17 @@ func Test_attachRunLog(t *testing.T) {
|
|||
},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "escape metacharacters in job name",
|
||||
job: shared.Job{
|
||||
Name: "metacharacters .+*?()|[]{}^$ job",
|
||||
Steps: []shared.Step{{
|
||||
Name: "fob the barz",
|
||||
Number: 0,
|
||||
}},
|
||||
},
|
||||
wantMatch: false,
|
||||
},
|
||||
{
|
||||
name: "mismatching job name",
|
||||
job: shared.Job{
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
package list
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cli/cli/api"
|
||||
"github.com/cli/cli/internal/config"
|
||||
"github.com/cli/cli/internal/ghinstance"
|
||||
"github.com/cli/cli/internal/ghrepo"
|
||||
"github.com/cli/cli/pkg/cmd/secret/shared"
|
||||
"github.com/cli/cli/pkg/cmdutil"
|
||||
|
|
@ -23,6 +26,7 @@ type ListOptions struct {
|
|||
BaseRepo func() (ghrepo.Interface, error)
|
||||
|
||||
OrgName string
|
||||
EnvName string
|
||||
}
|
||||
|
||||
func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Command {
|
||||
|
|
@ -35,12 +39,16 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
cmd := &cobra.Command{
|
||||
Use: "list",
|
||||
Short: "List secrets",
|
||||
Long: "List secrets for a repository or organization",
|
||||
Long: "List secrets for a repository, environment, or organization",
|
||||
Args: cobra.NoArgs,
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if runF != nil {
|
||||
return runF(opts)
|
||||
}
|
||||
|
|
@ -50,18 +58,19 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
|
|||
}
|
||||
|
||||
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization")
|
||||
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "List secrets for an environment")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
||||
func listRun(opts *ListOptions) error {
|
||||
c, err := opts.HttpClient()
|
||||
client, err := opts.HttpClient()
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not create http client: %w", err)
|
||||
}
|
||||
client := api.NewClientFromHTTP(c)
|
||||
|
||||
orgName := opts.OrgName
|
||||
envName := opts.EnvName
|
||||
|
||||
var baseRepo ghrepo.Interface
|
||||
if orgName == "" {
|
||||
|
|
@ -73,7 +82,11 @@ func listRun(opts *ListOptions) error {
|
|||
|
||||
var secrets []*Secret
|
||||
if orgName == "" {
|
||||
secrets, err = getRepoSecrets(client, baseRepo)
|
||||
if envName == "" {
|
||||
secrets, err = getRepoSecrets(client, baseRepo)
|
||||
} else {
|
||||
secrets, err = getEnvSecrets(client, baseRepo, envName)
|
||||
}
|
||||
} else {
|
||||
var cfg config.Config
|
||||
var host string
|
||||
|
|
@ -145,7 +158,7 @@ func fmtVisibility(s Secret) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func getOrgSecrets(client *api.Client, host, orgName string) ([]*Secret, error) {
|
||||
func getOrgSecrets(client httpClient, host, orgName string) ([]*Secret, error) {
|
||||
secrets, err := getSecrets(client, host, fmt.Sprintf("orgs/%s/actions/secrets", orgName))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
|
@ -160,7 +173,7 @@ func getOrgSecrets(client *api.Client, host, orgName string) ([]*Secret, error)
|
|||
continue
|
||||
}
|
||||
var result responseData
|
||||
if err := client.REST(host, "GET", secret.SelectedReposURL, nil, &result); err != nil {
|
||||
if _, err := apiGet(client, secret.SelectedReposURL, &result); err != nil {
|
||||
return nil, fmt.Errorf("failed determining selected repositories for %s: %w", secret.Name, err)
|
||||
}
|
||||
secret.NumSelectedRepos = result.TotalCount
|
||||
|
|
@ -169,7 +182,12 @@ func getOrgSecrets(client *api.Client, host, orgName string) ([]*Secret, error)
|
|||
return secrets, nil
|
||||
}
|
||||
|
||||
func getRepoSecrets(client *api.Client, repo ghrepo.Interface) ([]*Secret, error) {
|
||||
func getEnvSecrets(client httpClient, repo ghrepo.Interface, envName string) ([]*Secret, error) {
|
||||
path := fmt.Sprintf("repos/%s/environments/%s/secrets", ghrepo.FullName(repo), envName)
|
||||
return getSecrets(client, repo.RepoHost(), path)
|
||||
}
|
||||
|
||||
func getRepoSecrets(client httpClient, repo ghrepo.Interface) ([]*Secret, error) {
|
||||
return getSecrets(client, repo.RepoHost(), fmt.Sprintf("repos/%s/actions/secrets",
|
||||
ghrepo.FullName(repo)))
|
||||
}
|
||||
|
|
@ -178,13 +196,63 @@ type secretsPayload struct {
|
|||
Secrets []*Secret
|
||||
}
|
||||
|
||||
func getSecrets(client *api.Client, host, path string) ([]*Secret, error) {
|
||||
result := secretsPayload{}
|
||||
type httpClient interface {
|
||||
Do(*http.Request) (*http.Response, error)
|
||||
}
|
||||
|
||||
err := client.REST(host, "GET", path, nil, &result)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
func getSecrets(client httpClient, host, path string) ([]*Secret, error) {
|
||||
var results []*Secret
|
||||
url := fmt.Sprintf("%s%s?per_page=100", ghinstance.RESTPrefix(host), path)
|
||||
|
||||
for {
|
||||
var payload secretsPayload
|
||||
nextURL, err := apiGet(client, url, &payload)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results = append(results, payload.Secrets...)
|
||||
|
||||
if nextURL == "" {
|
||||
break
|
||||
}
|
||||
url = nextURL
|
||||
}
|
||||
|
||||
return result.Secrets, nil
|
||||
return results, nil
|
||||
}
|
||||
|
||||
func apiGet(client httpClient, url string, data interface{}) (string, error) {
|
||||
req, err := http.NewRequest("GET", url, nil)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode > 299 {
|
||||
return "", api.HandleHTTPError(resp)
|
||||
}
|
||||
|
||||
dec := json.NewDecoder(resp.Body)
|
||||
if err := dec.Decode(data); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return findNextPage(resp.Header.Get("Link")), nil
|
||||
}
|
||||
|
||||
var linkRE = regexp.MustCompile(`<([^>]+)>;\s*rel="([^"]+)"`)
|
||||
|
||||
func findNextPage(link string) string {
|
||||
for _, m := range linkRE.FindAllStringSubmatch(link, -1) {
|
||||
if len(m) >= 2 && m[2] == "next" {
|
||||
return m[1]
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,9 @@ package list
|
|||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
|
|
@ -38,6 +40,13 @@ func Test_NewCmdList(t *testing.T) {
|
|||
OrgName: "UmbrellaCorporation",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env",
|
||||
cli: "-eDevelopment",
|
||||
wants: ListOptions{
|
||||
EnvName: "Development",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -64,7 +73,7 @@ func Test_NewCmdList(t *testing.T) {
|
|||
assert.NoError(t, err)
|
||||
|
||||
assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
|
||||
|
||||
assert.Equal(t, tt.wants.EnvName, gotOpts.EnvName)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -120,16 +129,44 @@ func Test_listRun(t *testing.T) {
|
|||
"SECRET_THREE\t1975-11-30\tSELECTED",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env tty",
|
||||
tty: true,
|
||||
opts: &ListOptions{
|
||||
EnvName: "Development",
|
||||
},
|
||||
wantOut: []string{
|
||||
"SECRET_ONE.*Updated 1988-10-11",
|
||||
"SECRET_TWO.*Updated 2020-12-04",
|
||||
"SECRET_THREE.*Updated 1975-11-30",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env not tty",
|
||||
tty: false,
|
||||
opts: &ListOptions{
|
||||
EnvName: "Development",
|
||||
},
|
||||
wantOut: []string{
|
||||
"SECRET_ONE\t1988-10-11",
|
||||
"SECRET_TWO\t2020-12-04",
|
||||
"SECRET_THREE\t1975-11-30",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
|
||||
path := "repos/owner/repo/actions/secrets"
|
||||
if tt.opts.EnvName != "" {
|
||||
path = fmt.Sprintf("repos/owner/repo/environments/%s/secrets", tt.opts.EnvName)
|
||||
}
|
||||
|
||||
t0, _ := time.Parse("2006-01-02", "1988-10-11")
|
||||
t1, _ := time.Parse("2006-01-02", "2020-12-04")
|
||||
t2, _ := time.Parse("2006-01-02", "1975-11-30")
|
||||
path := "repos/owner/repo/actions/secrets"
|
||||
payload := secretsPayload{}
|
||||
payload.Secrets = []*Secret{
|
||||
{
|
||||
|
|
@ -200,3 +237,32 @@ func Test_listRun(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Test_getSecrets_pagination(t *testing.T) {
|
||||
var requests []*http.Request
|
||||
var client testClient = func(req *http.Request) (*http.Response, error) {
|
||||
header := make(map[string][]string)
|
||||
if len(requests) == 0 {
|
||||
header["Link"] = []string{`<http://example.com/page/0>; rel="previous", <http://example.com/page/2>; rel="next"`}
|
||||
}
|
||||
requests = append(requests, req)
|
||||
return &http.Response{
|
||||
Request: req,
|
||||
Body: ioutil.NopCloser(strings.NewReader(`{"secrets":[{},{}]}`)),
|
||||
Header: header,
|
||||
}, nil
|
||||
}
|
||||
|
||||
secrets, err := getSecrets(client, "github.com", "path/to")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 2, len(requests))
|
||||
assert.Equal(t, 4, len(secrets))
|
||||
assert.Equal(t, "https://api.github.com/path/to?per_page=100", requests[0].URL.String())
|
||||
assert.Equal(t, "http://example.com/page/2", requests[1].URL.String())
|
||||
}
|
||||
|
||||
type testClient func(*http.Request) (*http.Response, error)
|
||||
|
||||
func (c testClient) Do(req *http.Request) (*http.Response, error) {
|
||||
return c(req)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ type RemoveOptions struct {
|
|||
|
||||
SecretName string
|
||||
OrgName string
|
||||
EnvName string
|
||||
}
|
||||
|
||||
func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Command {
|
||||
|
|
@ -31,12 +32,17 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co
|
|||
|
||||
cmd := &cobra.Command{
|
||||
Use: "remove <secret-name>",
|
||||
Short: "Remove an organization or repository secret",
|
||||
Short: "Remove secrets",
|
||||
Long: "Remove a secret for a repository, environment, or organization",
|
||||
Args: cobra.ExactArgs(1),
|
||||
RunE: func(cmd *cobra.Command, args []string) error {
|
||||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts.SecretName = args[0]
|
||||
|
||||
if runF != nil {
|
||||
|
|
@ -46,7 +52,8 @@ func NewCmdRemove(f *cmdutil.Factory, runF func(*RemoveOptions) error) *cobra.Co
|
|||
return removeRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization")
|
||||
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Remove a secret for an organization")
|
||||
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Remove a secret for an environment")
|
||||
|
||||
return cmd
|
||||
}
|
||||
|
|
@ -59,6 +66,7 @@ func removeRun(opts *RemoveOptions) error {
|
|||
client := api.NewClientFromHTTP(c)
|
||||
|
||||
orgName := opts.OrgName
|
||||
envName := opts.EnvName
|
||||
|
||||
var baseRepo ghrepo.Interface
|
||||
if orgName == "" {
|
||||
|
|
@ -69,10 +77,12 @@ func removeRun(opts *RemoveOptions) error {
|
|||
}
|
||||
|
||||
var path string
|
||||
if orgName == "" {
|
||||
path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName)
|
||||
} else {
|
||||
if orgName != "" {
|
||||
path = fmt.Sprintf("orgs/%s/actions/secrets/%s", orgName, opts.SecretName)
|
||||
} else if envName != "" {
|
||||
path = fmt.Sprintf("repos/%s/environments/%s/secrets/%s", ghrepo.FullName(baseRepo), envName, opts.SecretName)
|
||||
} else {
|
||||
path = fmt.Sprintf("repos/%s/actions/secrets/%s", ghrepo.FullName(baseRepo), opts.SecretName)
|
||||
}
|
||||
|
||||
cfg, err := opts.Config()
|
||||
|
|
@ -96,7 +106,11 @@ func removeRun(opts *RemoveOptions) error {
|
|||
target = ghrepo.FullName(baseRepo)
|
||||
}
|
||||
cs := opts.IO.ColorScheme()
|
||||
fmt.Fprintf(opts.IO.Out, "%s Removed secret %s from %s\n", cs.SuccessIconWithColor(cs.Red), opts.SecretName, target)
|
||||
if envName != "" {
|
||||
fmt.Fprintf(opts.IO.Out, "%s Removed secret %s from %s environment on %s\n", cs.SuccessIconWithColor(cs.Red), opts.SecretName, envName, target)
|
||||
} else {
|
||||
fmt.Fprintf(opts.IO.Out, "%s Removed secret %s from %s\n", cs.SuccessIconWithColor(cs.Red), opts.SecretName, target)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
|
|
|
|||
|
|
@ -41,6 +41,14 @@ func TestNewCmdRemove(t *testing.T) {
|
|||
OrgName: "anOrg",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env",
|
||||
cli: "cool --env anEnv",
|
||||
wants: RemoveOptions{
|
||||
SecretName: "cool",
|
||||
EnvName: "anEnv",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -72,6 +80,7 @@ func TestNewCmdRemove(t *testing.T) {
|
|||
|
||||
assert.Equal(t, tt.wants.SecretName, gotOpts.SecretName)
|
||||
assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
|
||||
assert.Equal(t, tt.wants.EnvName, gotOpts.EnvName)
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -106,6 +115,36 @@ func Test_removeRun_repo(t *testing.T) {
|
|||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func Test_removeRun_env(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
|
||||
reg.Register(
|
||||
httpmock.REST("DELETE", "repos/owner/repo/environments/development/secrets/cool_secret"),
|
||||
httpmock.StatusStringResponse(204, "No Content"))
|
||||
|
||||
io, _, _, _ := iostreams.Test()
|
||||
|
||||
opts := &RemoveOptions{
|
||||
IO: io,
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) {
|
||||
return config.NewBlankConfig(), nil
|
||||
},
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName("owner/repo")
|
||||
},
|
||||
SecretName: "cool_secret",
|
||||
EnvName: "development",
|
||||
}
|
||||
|
||||
err := removeRun(opts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
reg.Verify(t)
|
||||
}
|
||||
|
||||
func Test_removeRun_org(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -15,8 +15,8 @@ func NewCmdSecret(f *cmdutil.Factory) *cobra.Command {
|
|||
Use: "secret <command>",
|
||||
Short: "Manage GitHub secrets",
|
||||
Long: heredoc.Doc(`
|
||||
Secrets can be set at the repository or organization level for use in GitHub Actions.
|
||||
Run "gh help secret set" to learn how to get started.
|
||||
Secrets can be set at the repository, environment, or organization level for use in
|
||||
GitHub Actions. Run "gh help secret set" to learn how to get started.
|
||||
`),
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -55,6 +55,11 @@ func getRepoPubKey(client *api.Client, repo ghrepo.Interface) (*PubKey, error) {
|
|||
ghrepo.FullName(repo)))
|
||||
}
|
||||
|
||||
func getEnvPubKey(client *api.Client, repo ghrepo.Interface, envName string) (*PubKey, error) {
|
||||
return getPubKey(client, repo.RepoHost(), fmt.Sprintf("repos/%s/environments/%s/secrets/public-key",
|
||||
ghrepo.FullName(repo), envName))
|
||||
}
|
||||
|
||||
func putSecret(client *api.Client, host, path string, payload SecretPayload) error {
|
||||
payloadBytes, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
|
|
@ -90,6 +95,15 @@ func putOrgSecret(client *api.Client, host string, pk *PubKey, opts SetOptions,
|
|||
return putSecret(client, host, path, payload)
|
||||
}
|
||||
|
||||
func putEnvSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, envName string, secretName, eValue string) error {
|
||||
payload := SecretPayload{
|
||||
EncryptedValue: eValue,
|
||||
KeyID: pk.ID,
|
||||
}
|
||||
path := fmt.Sprintf("repos/%s/environments/%s/secrets/%s", ghrepo.FullName(repo), envName, secretName)
|
||||
return putSecret(client, repo.RepoHost(), path, payload)
|
||||
}
|
||||
|
||||
func putRepoSecret(client *api.Client, pk *PubKey, repo ghrepo.Interface, secretName, eValue string) error {
|
||||
payload := SecretPayload{
|
||||
EncryptedValue: eValue,
|
||||
|
|
|
|||
|
|
@ -33,6 +33,7 @@ type SetOptions struct {
|
|||
|
||||
SecretName string
|
||||
OrgName string
|
||||
EnvName string
|
||||
Body string
|
||||
Visibility string
|
||||
RepositoryNames []string
|
||||
|
|
@ -48,7 +49,7 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
|
|||
cmd := &cobra.Command{
|
||||
Use: "set <secret-name>",
|
||||
Short: "Create or update secrets",
|
||||
Long: "Locally encrypt a new or updated secret at either the repository or organization level and send it to GitHub for storage.",
|
||||
Long: "Locally encrypt a new or updated secret at either the repository, environment, or organization level and send it to GitHub for storage.",
|
||||
Example: heredoc.Doc(`
|
||||
Paste secret in prompt
|
||||
$ gh secret set MYSECRET
|
||||
|
|
@ -59,6 +60,9 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
|
|||
Use file as secret value
|
||||
$ gh secret set MYSECRET < file.json
|
||||
|
||||
Set environment level secret
|
||||
$ gh secret set MYSECRET -bval --env=anEnv
|
||||
|
||||
Set organization level secret visible to entire organization
|
||||
$ gh secret set MYSECRET -bval --org=anOrg --visibility=all
|
||||
|
||||
|
|
@ -75,6 +79,10 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
|
|||
// support `-R, --repo` override
|
||||
opts.BaseRepo = f.BaseRepo
|
||||
|
||||
if err := cmdutil.MutuallyExclusive("specify only one of `--org` or `--env`", opts.OrgName != "", opts.EnvName != ""); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
opts.SecretName = args[0]
|
||||
|
||||
err := validSecretName(opts.SecretName)
|
||||
|
|
@ -115,7 +123,8 @@ func NewCmdSet(f *cmdutil.Factory, runF func(*SetOptions) error) *cobra.Command
|
|||
return setRun(opts)
|
||||
},
|
||||
}
|
||||
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "List secrets for an organization")
|
||||
cmd.Flags().StringVarP(&opts.OrgName, "org", "o", "", "Set a secret for an organization")
|
||||
cmd.Flags().StringVarP(&opts.EnvName, "env", "e", "", "Set a secret for an environment")
|
||||
cmd.Flags().StringVarP(&opts.Visibility, "visibility", "v", "private", "Set visibility for an organization secret: `all`, `private`, or `selected`")
|
||||
cmd.Flags().StringSliceVarP(&opts.RepositoryNames, "repos", "r", []string{}, "List of repository names for `selected` visibility")
|
||||
cmd.Flags().StringVarP(&opts.Body, "body", "b", "", "A value for the secret. Reads from STDIN if not specified.")
|
||||
|
|
@ -136,6 +145,7 @@ func setRun(opts *SetOptions) error {
|
|||
client := api.NewClientFromHTTP(c)
|
||||
|
||||
orgName := opts.OrgName
|
||||
envName := opts.EnvName
|
||||
|
||||
var baseRepo ghrepo.Interface
|
||||
if orgName == "" {
|
||||
|
|
@ -158,6 +168,8 @@ func setRun(opts *SetOptions) error {
|
|||
var pk *PubKey
|
||||
if orgName != "" {
|
||||
pk, err = getOrgPublicKey(client, host, orgName)
|
||||
} else if envName != "" {
|
||||
pk, err = getEnvPubKey(client, baseRepo, envName)
|
||||
} else {
|
||||
pk, err = getRepoPubKey(client, baseRepo)
|
||||
}
|
||||
|
|
@ -174,6 +186,8 @@ func setRun(opts *SetOptions) error {
|
|||
|
||||
if orgName != "" {
|
||||
err = putOrgSecret(client, host, pk, *opts, encoded)
|
||||
} else if envName != "" {
|
||||
err = putEnvSecret(client, pk, baseRepo, envName, opts.SecretName, encoded)
|
||||
} else {
|
||||
err = putRepoSecret(client, pk, baseRepo, opts.SecretName, encoded)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -100,6 +100,17 @@ func TestNewCmdSet(t *testing.T) {
|
|||
OrgName: "",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "env",
|
||||
cli: `cool_secret -b"a secret" -eRelease`,
|
||||
wants: SetOptions{
|
||||
SecretName: "cool_secret",
|
||||
Visibility: shared.Private,
|
||||
Body: "a secret",
|
||||
OrgName: "",
|
||||
EnvName: "Release",
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "vis all",
|
||||
cli: `cool_secret --org coolOrg -b"cool" -vall`,
|
||||
|
|
@ -160,6 +171,7 @@ func TestNewCmdSet(t *testing.T) {
|
|||
assert.Equal(t, tt.wants.Body, gotOpts.Body)
|
||||
assert.Equal(t, tt.wants.Visibility, gotOpts.Visibility)
|
||||
assert.Equal(t, tt.wants.OrgName, gotOpts.OrgName)
|
||||
assert.Equal(t, tt.wants.EnvName, gotOpts.EnvName)
|
||||
assert.ElementsMatch(t, tt.wants.RepositoryNames, gotOpts.RepositoryNames)
|
||||
})
|
||||
}
|
||||
|
|
@ -204,6 +216,46 @@ func Test_setRun_repo(t *testing.T) {
|
|||
assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=")
|
||||
}
|
||||
|
||||
func Test_setRun_env(t *testing.T) {
|
||||
reg := &httpmock.Registry{}
|
||||
|
||||
reg.Register(httpmock.REST("GET", "repos/owner/repo/environments/development/secrets/public-key"),
|
||||
httpmock.JSONResponse(PubKey{ID: "123", Key: "CDjXqf7AJBXWhMczcy+Fs7JlACEptgceysutztHaFQI="}))
|
||||
|
||||
reg.Register(httpmock.REST("PUT", "repos/owner/repo/environments/development/secrets/cool_secret"), httpmock.StatusStringResponse(201, `{}`))
|
||||
|
||||
io, _, _, _ := iostreams.Test()
|
||||
|
||||
opts := &SetOptions{
|
||||
HttpClient: func() (*http.Client, error) {
|
||||
return &http.Client{Transport: reg}, nil
|
||||
},
|
||||
Config: func() (config.Config, error) { return config.NewBlankConfig(), nil },
|
||||
BaseRepo: func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName("owner/repo")
|
||||
},
|
||||
EnvName: "development",
|
||||
IO: io,
|
||||
SecretName: "cool_secret",
|
||||
Body: "a secret",
|
||||
// Cribbed from https://github.com/golang/crypto/commit/becbf705a91575484002d598f87d74f0002801e7
|
||||
RandomOverride: bytes.NewReader([]byte{5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5, 5}),
|
||||
}
|
||||
|
||||
err := setRun(opts)
|
||||
assert.NoError(t, err)
|
||||
|
||||
reg.Verify(t)
|
||||
|
||||
data, err := ioutil.ReadAll(reg.Requests[1].Body)
|
||||
assert.NoError(t, err)
|
||||
var payload SecretPayload
|
||||
err = json.Unmarshal(data, &payload)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, payload.KeyID, "123")
|
||||
assert.Equal(t, payload.EncryptedValue, "UKYUCbHd0DJemxa3AOcZ6XcsBwALG9d4bpB8ZT0gSV39vl3BHiGSgj8zJapDxgB2BwqNqRhpjC4=")
|
||||
}
|
||||
|
||||
func Test_setRun_org(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
|
|||
|
|
@ -12,14 +12,18 @@ func EnableRepoOverride(cmd *cobra.Command, f *Factory) {
|
|||
|
||||
cmd.PersistentPreRun = func(cmd *cobra.Command, args []string) {
|
||||
repoOverride, _ := cmd.Flags().GetString("repo")
|
||||
if repoFromEnv := os.Getenv("GH_REPO"); repoOverride == "" && repoFromEnv != "" {
|
||||
repoOverride = repoFromEnv
|
||||
}
|
||||
if repoOverride != "" {
|
||||
// NOTE: this mutates the factory
|
||||
f.BaseRepo = func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName(repoOverride)
|
||||
}
|
||||
}
|
||||
f.BaseRepo = OverrideBaseRepoFunc(f, repoOverride)
|
||||
}
|
||||
}
|
||||
|
||||
func OverrideBaseRepoFunc(f *Factory, override string) func() (ghrepo.Interface, error) {
|
||||
if override == "" {
|
||||
override = os.Getenv("GH_REPO")
|
||||
}
|
||||
if override != "" {
|
||||
return func() (ghrepo.Interface, error) {
|
||||
return ghrepo.FromFullName(override)
|
||||
}
|
||||
}
|
||||
return f.BaseRepo
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,27 +2,10 @@ package httpmock
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// TODO: clean up methods in this file when there are no more callers
|
||||
|
||||
func (r *Registry) StubWithFixturePath(status int, fixturePath string) func() {
|
||||
fixtureFile, err := os.Open(fixturePath)
|
||||
r.Register(MatchAny, func(req *http.Request) (*http.Response, error) {
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return httpResponse(200, req, fixtureFile), nil
|
||||
})
|
||||
return func() {
|
||||
if err == nil {
|
||||
fixtureFile.Close()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Registry) StubRepoInfoResponse(owner, repo, branch string) {
|
||||
r.Register(
|
||||
GraphQL(`query RepositoryInfo\b`),
|
||||
|
|
|
|||
|
|
@ -140,6 +140,10 @@ func (s *IOStreams) SetPager(cmd string) {
|
|||
s.pagerCommand = cmd
|
||||
}
|
||||
|
||||
func (s *IOStreams) GetPager() string {
|
||||
return s.pagerCommand
|
||||
}
|
||||
|
||||
func (s *IOStreams) StartPager() error {
|
||||
if s.pagerCommand == "" || s.pagerCommand == "cat" || !s.IsStdoutTTY() {
|
||||
return nil
|
||||
|
|
@ -202,6 +206,10 @@ func (s *IOStreams) CanPrompt() bool {
|
|||
return s.IsStdinTTY() && s.IsStdoutTTY()
|
||||
}
|
||||
|
||||
func (s *IOStreams) GetNeverPrompt() bool {
|
||||
return s.neverPrompt
|
||||
}
|
||||
|
||||
func (s *IOStreams) SetNeverPrompt(v bool) {
|
||||
s.neverPrompt = v
|
||||
}
|
||||
|
|
@ -281,13 +289,6 @@ func System() *IOStreams {
|
|||
stdoutIsTTY := isTerminal(os.Stdout)
|
||||
stderrIsTTY := isTerminal(os.Stderr)
|
||||
|
||||
var pagerCommand string
|
||||
if ghPager, ghPagerExists := os.LookupEnv("GH_PAGER"); ghPagerExists {
|
||||
pagerCommand = ghPager
|
||||
} else {
|
||||
pagerCommand = os.Getenv("PAGER")
|
||||
}
|
||||
|
||||
io := &IOStreams{
|
||||
In: os.Stdin,
|
||||
originalOut: os.Stdout,
|
||||
|
|
@ -295,7 +296,7 @@ func System() *IOStreams {
|
|||
ErrOut: colorable.NewColorable(os.Stderr),
|
||||
colorEnabled: EnvColorForced() || (!EnvColorDisabled() && stdoutIsTTY),
|
||||
is256enabled: Is256ColorSupported(),
|
||||
pagerCommand: pagerCommand,
|
||||
pagerCommand: os.Getenv("PAGER"),
|
||||
}
|
||||
|
||||
if stdoutIsTTY && stderrIsTTY {
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@ var Confirm = func(prompt string, result *bool) error {
|
|||
Message: prompt,
|
||||
Default: true,
|
||||
}
|
||||
return survey.AskOne(p, result)
|
||||
return SurveyAskOne(p, result)
|
||||
}
|
||||
|
||||
var SurveyAskOne = func(p survey.Prompt, response interface{}, opts ...survey.AskOpt) error {
|
||||
|
|
|
|||
|
|
@ -35,6 +35,8 @@ type GhEditor struct {
|
|||
*survey.Editor
|
||||
EditorCommand string
|
||||
BlankAllowed bool
|
||||
|
||||
lookPath func(string) ([]string, []string, error)
|
||||
}
|
||||
|
||||
func (e *GhEditor) editorCommand() string {
|
||||
|
|
@ -105,7 +107,7 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int
|
|||
}
|
||||
if r == '\r' || r == '\n' {
|
||||
if e.BlankAllowed {
|
||||
return "", nil
|
||||
return initialValue, nil
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
|
|
@ -136,7 +138,11 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int
|
|||
}
|
||||
|
||||
stdio := e.Stdio()
|
||||
text, err := Edit(e.editorCommand(), e.FileName, initialValue, stdio.In, stdio.Out, stdio.Err, cursor)
|
||||
lookPath := e.lookPath
|
||||
if lookPath == nil {
|
||||
lookPath = defaultLookPath
|
||||
}
|
||||
text, err := edit(e.editorCommand(), e.FileName, initialValue, stdio.In, stdio.Out, stdio.Err, cursor, lookPath)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,18 @@ type showable interface {
|
|||
}
|
||||
|
||||
func Edit(editorCommand, fn, initialValue string, stdin io.Reader, stdout io.Writer, stderr io.Writer, cursor showable) (string, error) {
|
||||
return edit(editorCommand, fn, initialValue, stdin, stdout, stderr, cursor, defaultLookPath)
|
||||
}
|
||||
|
||||
func defaultLookPath(name string) ([]string, []string, error) {
|
||||
exe, err := safeexec.LookPath(name)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return []string{exe}, nil, nil
|
||||
}
|
||||
|
||||
func edit(editorCommand, fn, initialValue string, stdin io.Reader, stdout io.Writer, stderr io.Writer, cursor showable, lookPath func(string) ([]string, []string, error)) (string, error) {
|
||||
// prepare the temp file
|
||||
pattern := fn
|
||||
if pattern == "" {
|
||||
|
|
@ -56,12 +68,14 @@ func Edit(editorCommand, fn, initialValue string, stdin io.Reader, stdout io.Wri
|
|||
}
|
||||
args = append(args, f.Name())
|
||||
|
||||
editorExe, err := safeexec.LookPath(args[0])
|
||||
editorExe, env, err := lookPath(args[0])
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
args = append(editorExe, args[1:]...)
|
||||
|
||||
cmd := exec.Command(editorExe, args[1:]...)
|
||||
cmd := exec.Command(args[0], args[1:]...)
|
||||
cmd.Env = env
|
||||
cmd.Stdin = stdin
|
||||
cmd.Stdout = stdout
|
||||
cmd.Stderr = stderr
|
||||
|
|
|
|||
284
pkg/surveyext/editor_test.go
Normal file
284
pkg/surveyext/editor_test.go
Normal file
|
|
@ -0,0 +1,284 @@
|
|||
package surveyext
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/AlecAivazis/survey/v2"
|
||||
"github.com/AlecAivazis/survey/v2/terminal"
|
||||
pseudotty "github.com/creack/pty"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func testLookPath(s string) ([]string, []string, error) {
|
||||
return []string{os.Args[0], "-test.run=TestHelperProcess", "--", s}, []string{"GH_WANT_HELPER_PROCESS=1"}, nil
|
||||
}
|
||||
|
||||
func TestHelperProcess(t *testing.T) {
|
||||
if os.Getenv("GH_WANT_HELPER_PROCESS") != "1" {
|
||||
return
|
||||
}
|
||||
if err := func(args []string) error {
|
||||
switch args[0] {
|
||||
// "vim" appends a message to the file
|
||||
case "vim":
|
||||
f, err := os.OpenFile(args[1], os.O_APPEND|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
_, err = f.WriteString(" - added by vim")
|
||||
return err
|
||||
// "nano" truncates the contents of the file
|
||||
case "nano":
|
||||
f, err := os.OpenFile(args[1], os.O_TRUNC|os.O_WRONLY, 0)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return f.Close()
|
||||
default:
|
||||
return fmt.Errorf("unrecognized arguments: %#v\n", args)
|
||||
}
|
||||
}(os.Args[3:]); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
|
||||
func Test_GhEditor_Prompt_skip(t *testing.T) {
|
||||
pty := newTerminal(t)
|
||||
|
||||
e := &GhEditor{
|
||||
BlankAllowed: true,
|
||||
EditorCommand: "vim",
|
||||
Editor: &survey.Editor{
|
||||
Message: "Body",
|
||||
FileName: "*.md",
|
||||
Default: "initial value",
|
||||
HideDefault: true,
|
||||
AppendDefault: true,
|
||||
},
|
||||
lookPath: func(s string) ([]string, []string, error) {
|
||||
return nil, nil, errors.New("no editor allowed")
|
||||
},
|
||||
}
|
||||
e.WithStdio(pty.Stdio())
|
||||
|
||||
// wait until the prompt is rendered and send the Enter key
|
||||
go func() {
|
||||
pty.WaitForOutput("Body")
|
||||
assert.Equal(t, "\x1b[0G\x1b[2K\x1b[0;1;92m? \x1b[0m\x1b[0;1;99mBody \x1b[0m\x1b[0;36m[(e) to launch vim, enter to skip] \x1b[0m", normalizeANSI(pty.Output()))
|
||||
pty.ResetOutput()
|
||||
assert.NoError(t, pty.SendKey('\n'))
|
||||
}()
|
||||
|
||||
res, err := e.Prompt(defaultPromptConfig())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "initial value", res)
|
||||
assert.Equal(t, "", normalizeANSI(pty.Output()))
|
||||
}
|
||||
|
||||
func Test_GhEditor_Prompt_editorAppend(t *testing.T) {
|
||||
pty := newTerminal(t)
|
||||
|
||||
e := &GhEditor{
|
||||
BlankAllowed: true,
|
||||
EditorCommand: "vim",
|
||||
Editor: &survey.Editor{
|
||||
Message: "Body",
|
||||
FileName: "*.md",
|
||||
Default: "initial value",
|
||||
HideDefault: true,
|
||||
AppendDefault: true,
|
||||
},
|
||||
lookPath: testLookPath,
|
||||
}
|
||||
e.WithStdio(pty.Stdio())
|
||||
|
||||
// wait until the prompt is rendered and send the 'e' key
|
||||
go func() {
|
||||
pty.WaitForOutput("Body")
|
||||
assert.Equal(t, "\x1b[0G\x1b[2K\x1b[0;1;92m? \x1b[0m\x1b[0;1;99mBody \x1b[0m\x1b[0;36m[(e) to launch vim, enter to skip] \x1b[0m", normalizeANSI(pty.Output()))
|
||||
pty.ResetOutput()
|
||||
assert.NoError(t, pty.SendKey('e'))
|
||||
}()
|
||||
|
||||
res, err := e.Prompt(defaultPromptConfig())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "initial value - added by vim", res)
|
||||
assert.Equal(t, "", normalizeANSI(pty.Output()))
|
||||
}
|
||||
|
||||
func Test_GhEditor_Prompt_editorTruncate(t *testing.T) {
|
||||
pty := newTerminal(t)
|
||||
|
||||
e := &GhEditor{
|
||||
BlankAllowed: true,
|
||||
EditorCommand: "nano",
|
||||
Editor: &survey.Editor{
|
||||
Message: "Body",
|
||||
FileName: "*.md",
|
||||
Default: "initial value",
|
||||
HideDefault: true,
|
||||
AppendDefault: true,
|
||||
},
|
||||
lookPath: testLookPath,
|
||||
}
|
||||
e.WithStdio(pty.Stdio())
|
||||
|
||||
// wait until the prompt is rendered and send the 'e' key
|
||||
go func() {
|
||||
pty.WaitForOutput("Body")
|
||||
assert.Equal(t, "\x1b[0G\x1b[2K\x1b[0;1;92m? \x1b[0m\x1b[0;1;99mBody \x1b[0m\x1b[0;36m[(e) to launch nano, enter to skip] \x1b[0m", normalizeANSI(pty.Output()))
|
||||
pty.ResetOutput()
|
||||
assert.NoError(t, pty.SendKey('e'))
|
||||
}()
|
||||
|
||||
res, err := e.Prompt(defaultPromptConfig())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "", res)
|
||||
assert.Equal(t, "", normalizeANSI(pty.Output()))
|
||||
}
|
||||
|
||||
// survey doesn't expose this
|
||||
func defaultPromptConfig() *survey.PromptConfig {
|
||||
return &survey.PromptConfig{
|
||||
PageSize: 7,
|
||||
HelpInput: "?",
|
||||
SuggestInput: "tab",
|
||||
Icons: survey.IconSet{
|
||||
Error: survey.Icon{
|
||||
Text: "X",
|
||||
Format: "red",
|
||||
},
|
||||
Help: survey.Icon{
|
||||
Text: "?",
|
||||
Format: "cyan",
|
||||
},
|
||||
Question: survey.Icon{
|
||||
Text: "?",
|
||||
Format: "green+hb",
|
||||
},
|
||||
MarkedOption: survey.Icon{
|
||||
Text: "[x]",
|
||||
Format: "green",
|
||||
},
|
||||
UnmarkedOption: survey.Icon{
|
||||
Text: "[ ]",
|
||||
Format: "default+hb",
|
||||
},
|
||||
SelectFocus: survey.Icon{
|
||||
Text: ">",
|
||||
Format: "cyan+b",
|
||||
},
|
||||
},
|
||||
Filter: func(filter string, value string, index int) (include bool) {
|
||||
filter = strings.ToLower(filter)
|
||||
return strings.Contains(strings.ToLower(value), filter)
|
||||
},
|
||||
KeepFilter: false,
|
||||
}
|
||||
}
|
||||
|
||||
type testTerminal struct {
|
||||
pty *os.File
|
||||
tty *os.File
|
||||
stdout *teeWriter
|
||||
stderr *teeWriter
|
||||
}
|
||||
|
||||
func newTerminal(t *testing.T) *testTerminal {
|
||||
t.Helper()
|
||||
|
||||
pty, tty, err := pseudotty.Open()
|
||||
if errors.Is(err, pseudotty.ErrUnsupported) {
|
||||
t.SkipNow()
|
||||
return nil
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
pty.Close()
|
||||
tty.Close()
|
||||
})
|
||||
|
||||
if err := pseudotty.Setsize(tty, &pseudotty.Winsize{Cols: 72, Rows: 30}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
return &testTerminal{pty: pty, tty: tty}
|
||||
}
|
||||
|
||||
func (t *testTerminal) SendKey(c rune) error {
|
||||
_, err := t.pty.WriteString(string(c))
|
||||
return err
|
||||
}
|
||||
|
||||
func (t *testTerminal) WaitForOutput(s string) {
|
||||
for {
|
||||
time.Sleep(time.Millisecond)
|
||||
if strings.Contains(t.stdout.String(), s) {
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (t *testTerminal) Output() string {
|
||||
return t.stdout.String()
|
||||
}
|
||||
|
||||
func (t *testTerminal) ResetOutput() {
|
||||
t.stdout.Reset()
|
||||
}
|
||||
|
||||
func (t *testTerminal) Stdio() terminal.Stdio {
|
||||
t.stdout = &teeWriter{File: t.tty}
|
||||
t.stderr = t.stdout
|
||||
return terminal.Stdio{
|
||||
In: t.tty,
|
||||
Out: t.stdout,
|
||||
Err: t.stderr,
|
||||
}
|
||||
}
|
||||
|
||||
// teeWriter is a writer that duplicates all writes to a file into a buffer
|
||||
type teeWriter struct {
|
||||
*os.File
|
||||
buf bytes.Buffer
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func (f *teeWriter) Write(p []byte) (n int, err error) {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
_, _ = f.buf.Write(p)
|
||||
return f.File.Write(p)
|
||||
}
|
||||
|
||||
func (f *teeWriter) String() string {
|
||||
f.mu.Lock()
|
||||
s := f.buf.String()
|
||||
f.mu.Unlock()
|
||||
return s
|
||||
}
|
||||
|
||||
func (f *teeWriter) Reset() {
|
||||
f.mu.Lock()
|
||||
f.buf.Reset()
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// strips some ANSI escape sequences that we do not want tests to be concerned with
|
||||
func normalizeANSI(t string) string {
|
||||
t = strings.ReplaceAll(t, "\x1b[?25h", "") // strip sequence that shows cursor
|
||||
t = strings.ReplaceAll(t, "\x1b[?25l", "") // strip sequence that hides cursor
|
||||
return t
|
||||
}
|
||||
1
script/build.bat
Normal file
1
script/build.bat
Normal file
|
|
@ -0,0 +1 @@
|
|||
go run script\build.go %*
|
||||
|
|
@ -23,6 +23,7 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"os"
|
||||
|
|
@ -136,8 +137,14 @@ func date() string {
|
|||
|
||||
func sourceFilesLaterThan(t time.Time) bool {
|
||||
foundLater := false
|
||||
_ = filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
|
||||
err := filepath.Walk(".", func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
// Ignore errors that occur when the project contains a symlink to a filesystem or volume that
|
||||
// Windows doesn't have access to.
|
||||
if path != "." && isAccessDenied(err) {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", path, err)
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
if foundLater {
|
||||
|
|
@ -151,6 +158,9 @@ func sourceFilesLaterThan(t time.Time) bool {
|
|||
}
|
||||
}
|
||||
if info.IsDir() {
|
||||
if name := filepath.Base(path); name == "vendor" || name == "node_modules" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if path == "go.mod" || path == "go.sum" || (strings.HasSuffix(path, ".go") && !strings.HasSuffix(path, "_test.go")) {
|
||||
|
|
@ -160,9 +170,18 @@ func sourceFilesLaterThan(t time.Time) bool {
|
|||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return foundLater
|
||||
}
|
||||
|
||||
func isAccessDenied(err error) bool {
|
||||
var pe *os.PathError
|
||||
// we would use `syscall.ERROR_ACCESS_DENIED` if this script supported build tags
|
||||
return errors.As(err, &pe) && strings.Contains(pe.Err.Error(), "Access is denied")
|
||||
}
|
||||
|
||||
func rmrf(targets ...string) error {
|
||||
args := append([]string{"rm", "-rf"}, targets...)
|
||||
announce(args...)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue