new config infrastructure

- adds config get and config set commands
- supports arbitrary k/v strings set at top and host level
- supports writing an updated config, preserving comments
- supports mostly lazy evaluation of yaml
This commit is contained in:
vilmibm 2020-04-14 17:05:44 -05:00
parent 82bd7b97cf
commit a325db3051
15 changed files with 858 additions and 150 deletions

111
command/config.go Normal file
View file

@ -0,0 +1,111 @@
package command
import (
"fmt"
"github.com/spf13/cobra"
)
func init() {
RootCmd.AddCommand(configCmd)
configCmd.AddCommand(configGetCmd)
configCmd.AddCommand(configSetCmd)
configGetCmd.Flags().StringP("host", "h", "", "Get per-host setting")
configSetCmd.Flags().StringP("host", "h", "", "Set per-host setting")
}
var configCmd = &cobra.Command{
Use: "config",
Short: "Set and get gh settings",
Long: `Get and set key/value strings. They can be optionally scoped to a particular host.
Current respected settings:
- git_protocol: https or ssh. Default is https.
- editor: if unset, defaults to environment variables.
`,
}
var configGetCmd = &cobra.Command{
Use: "get",
Short: "Prints the value of a given configuration key.",
Long: `Get the value for a given configuration key, optionally scoping by hostname.
Examples:
gh config get git_protocol
https
gh config get --host example.com
ssh
`,
Args: cobra.ExactArgs(1),
RunE: configGet,
}
var configSetCmd = &cobra.Command{
Use: "set",
Short: "Updates configuration with the value of a given key.",
Long: `Update the configuration by setting a key to a value, optionally scoping by hostname.
Examples:
gh config set editor vim
gh config set --host example.com editor eclipse
`,
Args: cobra.ExactArgs(2),
RunE: configSet,
}
func configGet(cmd *cobra.Command, args []string) error {
key := args[0]
hostname, err := cmd.Flags().GetString("host")
if err != nil {
return err
}
ctx := contextForCommand(cmd)
cfg, err := ctx.Config()
if err != nil {
return err
}
val, err := cfg.Get(hostname, key)
if err != nil {
return err
}
if val != "" {
out := colorableOut(cmd)
fmt.Fprintf(out, "%s\n", val)
}
return nil
}
func configSet(cmd *cobra.Command, args []string) error {
key := args[0]
value := args[1]
hostname, err := cmd.Flags().GetString("host")
if err != nil {
return err
}
ctx := contextForCommand(cmd)
cfg, err := ctx.Config()
if err != nil {
return err
}
err = cfg.Set(hostname, key, value)
if err != nil {
return fmt.Errorf("failed to set %q to %q: %s", key, value, err)
}
err = cfg.Write()
if err != nil {
return fmt.Errorf("failed to write config to disk: %s", err)
}
return nil
}

171
command/config_test.go Normal file
View file

@ -0,0 +1,171 @@
package command
import (
"bytes"
"testing"
"github.com/cli/cli/context"
)
func TestConfigGet(t *testing.T) {
cfg := `---
hosts:
github.com:
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
editor: ed
`
initBlankContext(cfg, "OWNER/REPO", "master")
output, err := RunCommand(configGetCmd, "config get editor")
if err != nil {
t.Fatalf("error running command `config get editor`: %v", err)
}
eq(t, output.String(), "ed\n")
}
func TestConfigGet_default(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
output, err := RunCommand(configGetCmd, "config get git_protocol")
if err != nil {
t.Fatalf("error running command `config get git_protocol`: %v", err)
}
eq(t, output.String(), "https\n")
}
func TestConfigGet_not_found(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
output, err := RunCommand(configGetCmd, "config get missing")
if err != nil {
t.Fatalf("error running command `config get missing`: %v", err)
}
eq(t, output.String(), "")
}
func TestConfigSet(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
buf := bytes.NewBufferString("")
defer context.StubWriteConfig(buf)()
output, err := RunCommand(configSetCmd, "config set editor ed")
if err != nil {
t.Fatalf("error running command `config set editor ed`: %v", err)
}
eq(t, output.String(), "")
expected := `hosts:
github.com:
user: OWNER
oauth_token: 1234567890
editor: ed
`
eq(t, buf.String(), expected)
}
func TestConfigSet_update(t *testing.T) {
cfg := `---
hosts:
github.com:
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
editor: ed
`
initBlankContext(cfg, "OWNER/REPO", "master")
buf := bytes.NewBufferString("")
defer context.StubWriteConfig(buf)()
output, err := RunCommand(configSetCmd, "config set editor vim")
if err != nil {
t.Fatalf("error running command `config get editor`: %v", err)
}
eq(t, output.String(), "")
expected := `hosts:
github.com:
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
editor: vim
`
eq(t, buf.String(), expected)
}
func TestConfigGetHost(t *testing.T) {
cfg := `---
hosts:
github.com:
git_protocol: ssh
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
editor: ed
git_protocol: https
`
initBlankContext(cfg, "OWNER/REPO", "master")
output, err := RunCommand(configGetCmd, "config get -hgithub.com git_protocol")
if err != nil {
t.Fatalf("error running command `config get editor`: %v", err)
}
eq(t, output.String(), "ssh\n")
}
func TestConfigSetHost(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
buf := bytes.NewBufferString("")
defer context.StubWriteConfig(buf)()
output, err := RunCommand(configSetCmd, "config set -hgithub.com git_protocol ssh")
if err != nil {
t.Fatalf("error running command `config set editor ed`: %v", err)
}
eq(t, output.String(), "")
expected := `hosts:
github.com:
user: OWNER
oauth_token: 1234567890
git_protocol: ssh
`
eq(t, buf.String(), expected)
}
func TestConfigSetHost_update(t *testing.T) {
cfg := `---
hosts:
github.com:
git_protocol: ssh
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
`
initBlankContext(cfg, "OWNER/REPO", "master")
buf := bytes.NewBufferString("")
defer context.StubWriteConfig(buf)()
output, err := RunCommand(configSetCmd, "config set -hgithub.com git_protocol https")
if err != nil {
t.Fatalf("error running command `config get editor`: %v", err)
}
eq(t, output.String(), "")
expected := `hosts:
github.com:
git_protocol: https
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
`
eq(t, buf.String(), expected)
}

View file

@ -16,7 +16,7 @@ import (
)
func TestIssueStatus(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -45,7 +45,7 @@ func TestIssueStatus(t *testing.T) {
}
func TestIssueStatus_blankSlate(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -82,7 +82,7 @@ Issues opened by you
}
func TestIssueStatus_disabledIssues(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -99,7 +99,7 @@ func TestIssueStatus_disabledIssues(t *testing.T) {
}
func TestIssueList(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -132,7 +132,7 @@ Showing 3 of 3 issues in OWNER/REPO
}
func TestIssueList_withFlags(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -172,7 +172,7 @@ No issues match your search in OWNER/REPO
}
func TestIssueList_nullAssigneeLabels(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -201,7 +201,7 @@ func TestIssueList_nullAssigneeLabels(t *testing.T) {
}
func TestIssueList_disabledIssues(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -218,7 +218,7 @@ func TestIssueList_disabledIssues(t *testing.T) {
}
func TestIssueView_web(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -252,7 +252,7 @@ func TestIssueView_web(t *testing.T) {
}
func TestIssueView_web_numberArgWithHash(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -342,7 +342,7 @@ func TestIssueView_Preview(t *testing.T) {
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
initBlankContext("OWNER/REPO", tc.ownerRepo)
initBlankContext("", "OWNER/REPO", tc.ownerRepo)
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -363,7 +363,7 @@ func TestIssueView_Preview(t *testing.T) {
}
func TestIssueView_web_notFound(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
@ -390,7 +390,7 @@ func TestIssueView_web_notFound(t *testing.T) {
}
func TestIssueView_disabledIssues(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -408,7 +408,7 @@ func TestIssueView_disabledIssues(t *testing.T) {
}
func TestIssueView_web_urlArg(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -441,7 +441,7 @@ func TestIssueView_web_urlArg(t *testing.T) {
}
func TestIssueCreate(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -482,7 +482,7 @@ func TestIssueCreate(t *testing.T) {
}
func TestIssueCreate_disabledIssues(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -500,7 +500,7 @@ func TestIssueCreate_disabledIssues(t *testing.T) {
}
func TestIssueCreate_web(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -526,7 +526,7 @@ func TestIssueCreate_web(t *testing.T) {
}
func TestIssueCreate_webTitleBody(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")

View file

@ -13,7 +13,7 @@ import (
)
func TestPRCreate(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
@ -66,7 +66,7 @@ func TestPRCreate(t *testing.T) {
}
func TestPRCreate_withForking(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponseWithPermission("OWNER", "REPO", "READ")
http.StubResponse(200, bytes.NewBufferString(`
@ -109,7 +109,7 @@ func TestPRCreate_withForking(t *testing.T) {
}
func TestPRCreate_alreadyExists(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
@ -142,7 +142,7 @@ func TestPRCreate_alreadyExists(t *testing.T) {
}
func TestPRCreate_alreadyExistsDifferentBase(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
@ -174,7 +174,7 @@ func TestPRCreate_alreadyExistsDifferentBase(t *testing.T) {
}
func TestPRCreate_web(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
@ -205,7 +205,7 @@ func TestPRCreate_web(t *testing.T) {
}
func TestPRCreate_ReportsUncommittedChanges(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -330,7 +330,7 @@ func TestPRCreate_cross_repo_same_branch(t *testing.T) {
}
func TestPRCreate_survey_defaults_multicommit(t *testing.T) {
initBlankContext("OWNER/REPO", "cool_bug-fixes")
initBlankContext("", "OWNER/REPO", "cool_bug-fixes")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
@ -406,7 +406,7 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) {
}
func TestPRCreate_survey_defaults_monocommit(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
@ -483,7 +483,7 @@ func TestPRCreate_survey_defaults_monocommit(t *testing.T) {
}
func TestPRCreate_survey_autofill(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
@ -541,7 +541,7 @@ func TestPRCreate_survey_autofill(t *testing.T) {
}
func TestPRCreate_defaults_error_autofill(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -559,7 +559,7 @@ func TestPRCreate_defaults_error_autofill(t *testing.T) {
}
func TestPRCreate_defaults_error_web(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -577,7 +577,7 @@ func TestPRCreate_defaults_error_web(t *testing.T) {
}
func TestPRCreate_defaults_error_interactive(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`

View file

@ -74,7 +74,7 @@ func RunCommand(cmd *cobra.Command, args string) (*cmdOut, error) {
}
func TestPRStatus(t *testing.T) {
initBlankContext("OWNER/REPO", "blueberries")
initBlankContext("", "OWNER/REPO", "blueberries")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -102,7 +102,7 @@ func TestPRStatus(t *testing.T) {
}
func TestPRStatus_fork(t *testing.T) {
initBlankContext("OWNER/REPO", "blueberries")
initBlankContext("", "OWNER/REPO", "blueberries")
http := initFakeHTTP()
http.StubForkedRepoResponse("OWNER/REPO", "PARENT/REPO")
@ -132,7 +132,7 @@ branch.blueberries.merge refs/heads/blueberries`)}
}
func TestPRStatus_reviewsAndChecks(t *testing.T) {
initBlankContext("OWNER/REPO", "blueberries")
initBlankContext("", "OWNER/REPO", "blueberries")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -159,7 +159,7 @@ func TestPRStatus_reviewsAndChecks(t *testing.T) {
}
func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) {
initBlankContext("OWNER/REPO", "blueberries")
initBlankContext("", "OWNER/REPO", "blueberries")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -191,7 +191,7 @@ func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) {
}
func TestPRStatus_currentBranch_Closed(t *testing.T) {
initBlankContext("OWNER/REPO", "blueberries")
initBlankContext("", "OWNER/REPO", "blueberries")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -212,7 +212,7 @@ func TestPRStatus_currentBranch_Closed(t *testing.T) {
}
func TestPRStatus_currentBranch_Merged(t *testing.T) {
initBlankContext("OWNER/REPO", "blueberries")
initBlankContext("", "OWNER/REPO", "blueberries")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -233,7 +233,7 @@ func TestPRStatus_currentBranch_Merged(t *testing.T) {
}
func TestPRStatus_blankSlate(t *testing.T) {
initBlankContext("OWNER/REPO", "blueberries")
initBlankContext("", "OWNER/REPO", "blueberries")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -265,7 +265,7 @@ Requesting a code review from you
}
func TestPRList(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -289,7 +289,7 @@ Showing 3 of 3 pull requests in OWNER/REPO
}
func TestPRList_filtering(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -321,7 +321,7 @@ No pull requests match your search in OWNER/REPO
}
func TestPRList_filteringRemoveDuplicate(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -341,7 +341,7 @@ func TestPRList_filteringRemoveDuplicate(t *testing.T) {
}
func TestPRList_filteringClosed(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -365,7 +365,7 @@ func TestPRList_filteringClosed(t *testing.T) {
}
func TestPRList_filteringAssignee(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -389,7 +389,7 @@ func TestPRList_filteringAssignee(t *testing.T) {
}
func TestPRList_filteringAssigneeLabels(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -519,7 +519,7 @@ func TestPRView_Preview(t *testing.T) {
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
initBlankContext("OWNER/REPO", tc.ownerRepo)
initBlankContext("", "OWNER/REPO", tc.ownerRepo)
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -540,7 +540,7 @@ func TestPRView_Preview(t *testing.T) {
}
func TestPRView_web_currentBranch(t *testing.T) {
initBlankContext("OWNER/REPO", "blueberries")
initBlankContext("", "OWNER/REPO", "blueberries")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -578,7 +578,7 @@ func TestPRView_web_currentBranch(t *testing.T) {
}
func TestPRView_web_noResultsForBranch(t *testing.T) {
initBlankContext("OWNER/REPO", "blueberries")
initBlankContext("", "OWNER/REPO", "blueberries")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -609,7 +609,7 @@ func TestPRView_web_noResultsForBranch(t *testing.T) {
}
func TestPRView_web_numberArg(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -641,7 +641,7 @@ func TestPRView_web_numberArg(t *testing.T) {
}
func TestPRView_web_numberArgWithHash(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -673,7 +673,7 @@ func TestPRView_web_numberArgWithHash(t *testing.T) {
}
func TestPRView_web_urlArg(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
@ -704,7 +704,7 @@ func TestPRView_web_urlArg(t *testing.T) {
}
func TestPRView_web_branchArg(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -738,7 +738,7 @@ func TestPRView_web_branchArg(t *testing.T) {
}
func TestPRView_web_branchWithOwnerArg(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")

View file

@ -77,7 +77,7 @@ func stubSince(d time.Duration) func() {
}
func TestRepoFork_in_parent(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -134,7 +134,7 @@ func TestRepoFork_outside(t *testing.T) {
}
func TestRepoFork_in_parent_yes(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -261,7 +261,7 @@ func TestRepoFork_outside_survey_no(t *testing.T) {
}
func TestRepoFork_in_parent_survey_yes(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -303,7 +303,7 @@ func TestRepoFork_in_parent_survey_yes(t *testing.T) {
}
func TestRepoFork_in_parent_survey_no(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -678,7 +678,7 @@ func TestRepoCreate_orgWithTeam(t *testing.T) {
}
func TestRepoView_web(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
@ -773,7 +773,7 @@ func TestRepoView_web_fullURL(t *testing.T) {
}
func TestRepoView(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
@ -801,7 +801,7 @@ func TestRepoView(t *testing.T) {
}
func TestRepoView_nonmarkdown_readme(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
@ -828,7 +828,7 @@ func TestRepoView_nonmarkdown_readme(t *testing.T) {
}
func TestRepoView_blanks(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString("{}"))

View file

@ -17,6 +17,9 @@ import (
"github.com/spf13/pflag"
)
// TODO these are sprinkled across command, context, and ghrepo
const defaultHostname = "github.com"
// Version is dynamically set by the toolchain or overridden by the Makefile.
var Version = "DEV"
@ -107,7 +110,9 @@ func BasicClient() (*api.Client, error) {
}
opts = append(opts, api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)))
if c, err := context.ParseDefaultConfig(); err == nil {
opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", c.Token)))
if token, err := c.Get(defaultHostname, "oauth_token"); err == nil {
opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
}
}
return api.NewClient(opts...), nil
}

View file

@ -12,6 +12,12 @@ import (
"github.com/cli/cli/context"
)
const defaultTestConfig = `hosts:
github.com:
user: OWNER
oauth_token: 1234567890
`
type askStubber struct {
Asks [][]*survey.Question
Count int
@ -63,7 +69,7 @@ func (as *askStubber) Stub(stubbedQuestions []*QuestionStub) {
as.Stubs = append(as.Stubs, stubbedQuestions)
}
func initBlankContext(repo, branch string) {
func initBlankContext(cfg, repo, branch string) {
initContext = func() context.Context {
ctx := context.NewBlank()
ctx.SetBaseRepo(repo)
@ -71,6 +77,15 @@ func initBlankContext(repo, branch string) {
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
if cfg == "" {
cfg = defaultTestConfig
}
// NOTE we are not restoring the original readConfig; we never want to touch the config file on
// disk during tests.
context.StubConfig(cfg)
return ctx
}
}

View file

@ -22,6 +22,14 @@ type blankContext struct {
remotes Remotes
}
func (c *blankContext) Config() (Config, error) {
config, err := ParseConfig("boom.txt")
if err != nil {
panic(fmt.Sprintf("failed to parse config during tests. did you remember to stub? error: %s", err))
}
return config, nil
}
func (c *blankContext) AuthToken() (string, error) {
return c.authToken, nil
}

View file

@ -10,57 +10,159 @@ import (
"gopkg.in/yaml.v3"
)
const defaultHostname = "github.com"
type configEntry struct {
User string
Token string `yaml:"oauth_token"`
}
func parseOrSetupConfigFile(fn string) (*configEntry, error) {
entry, err := parseConfigFile(fn)
func parseOrSetupConfigFile(fn string) (Config, error) {
config, err := ParseConfig(fn)
if err != nil && errors.Is(err, os.ErrNotExist) {
return setupConfigFile(fn)
}
return entry, err
return config, err
}
func parseConfigFile(fn string) (*configEntry, error) {
func ParseDefaultConfig() (Config, error) {
return ParseConfig(configFile())
}
var ReadConfigFile = func(fn string) ([]byte, error) {
f, err := os.Open(fn)
if err != nil {
return nil, err
}
defer f.Close()
return parseConfig(f)
}
// ParseDefaultConfig reads the configuration file
func ParseDefaultConfig() (*configEntry, error) {
return parseConfigFile(configFile())
}
func parseConfig(r io.Reader) (*configEntry, error) {
data, err := ioutil.ReadAll(r)
data, err := ioutil.ReadAll(f)
if err != nil {
return nil, err
}
var config yaml.Node
err = yaml.Unmarshal(data, &config)
return data, nil
}
var WriteConfigFile = func(fn string, data []byte) error {
cfgFile, err := os.OpenFile(configFile(), os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) // cargo coded from setup
if err != nil {
return nil, err
return err
}
if len(config.Content) < 1 {
return nil, fmt.Errorf("malformed config")
defer cfgFile.Close()
n, err := cfgFile.Write(data)
if err == nil && n < len(data) {
err = io.ErrShortWrite
}
for i := 0; i < len(config.Content[0].Content)-1; i = i + 2 {
if config.Content[0].Content[i].Value == defaultHostname {
var entries []configEntry
err = config.Content[0].Content[i+1].Decode(&entries)
if err != nil {
return nil, err
}
return &entries[0], nil
return err
}
var BackupConfigFile = func(fn string) error {
return os.Rename(fn, fn+".bak")
}
func parseConfigFile(fn string) ([]byte, *yaml.Node, error) {
data, err := ReadConfigFile(fn)
if err != nil {
return nil, nil, err
}
var root yaml.Node
err = yaml.Unmarshal(data, &root)
if err != nil {
return data, nil, err
}
if len(root.Content) < 1 {
return data, &root, fmt.Errorf("malformed config")
}
if root.Content[0].Kind != yaml.MappingNode {
return data, &root, fmt.Errorf("expected a top level map")
}
return data, &root, nil
}
func isLegacy(root *yaml.Node) bool {
for _, v := range root.Content[0].Content {
if v.Value == "hosts" {
return false
}
}
return nil, fmt.Errorf("could not find config entry for %q", defaultHostname)
return true
}
func migrateConfig(fn string, root *yaml.Node) error {
// TODO i'm sorry
newConfigData := map[string]map[string]map[string]string{}
newConfigData["hosts"] = map[string]map[string]string{}
topLevelKeys := root.Content[0].Content
for i, x := range topLevelKeys {
if x.Value == "" {
continue
}
if i+1 == len(topLevelKeys) {
break
}
hostname := x.Value
newConfigData["hosts"][hostname] = map[string]string{}
authKeys := topLevelKeys[i+1].Content[0].Content
for j, y := range authKeys {
if j+1 == len(authKeys) {
break
}
switch y.Value {
case "user":
newConfigData["hosts"][hostname]["user"] = authKeys[j+1].Value
case "oauth_token":
newConfigData["hosts"][hostname]["oauth_token"] = authKeys[j+1].Value
}
}
}
if _, ok := newConfigData["hosts"][defaultHostname]; !ok {
return errors.New("could not find default host configuration")
}
defaultHostConfig := newConfigData["hosts"][defaultHostname]
if _, ok := defaultHostConfig["user"]; !ok {
return errors.New("default host configuration missing user")
}
if _, ok := defaultHostConfig["oauth_token"]; !ok {
return errors.New("default host configuration missing oauth_token")
}
newConfig, err := yaml.Marshal(newConfigData)
if err != nil {
return err
}
err = BackupConfigFile(fn)
if err != nil {
return fmt.Errorf("failed to back up existing config: %s", err)
}
return WriteConfigFile(fn, newConfig)
}
func ParseConfig(fn string) (Config, error) {
_, root, err := parseConfigFile(fn)
if err != nil {
return nil, err
}
if isLegacy(root) {
err = migrateConfig(fn, root)
if err != nil {
return nil, err
}
_, root, err = parseConfigFile(fn)
if err != nil {
return nil, fmt.Errorf("failed to reparse migrated config: %s", err)
}
}
return NewConfig(root), nil
}

View file

@ -1,55 +1,88 @@
package context
import (
"bytes"
"errors"
"reflect"
"strings"
"testing"
"gopkg.in/yaml.v3"
)
func eq(t *testing.T, got interface{}, expected interface{}) {
t.Helper()
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected: %v, got: %v", expected, got)
}
}
func Test_parseConfig(t *testing.T) {
c := strings.NewReader(`---
github.com:
- user: monalisa
oauth_token: OTOKEN
protocol: https
- user: wronguser
oauth_token: NOTTHIS
`)
entry, err := parseConfig(c)
defer StubConfig(`---
hosts:
github.com:
user: monalisa
oauth_token: OTOKEN
`)()
config, err := ParseConfig("filename")
eq(t, err, nil)
eq(t, entry.User, "monalisa")
eq(t, entry.Token, "OTOKEN")
user, err := config.Get("github.com", "user")
eq(t, err, nil)
eq(t, user, "monalisa")
token, err := config.Get("github.com", "oauth_token")
eq(t, err, nil)
eq(t, token, "OTOKEN")
}
func Test_parseConfig_multipleHosts(t *testing.T) {
c := strings.NewReader(`---
example.com:
- user: wronguser
oauth_token: NOTTHIS
github.com:
- user: monalisa
oauth_token: OTOKEN
`)
entry, err := parseConfig(c)
defer StubConfig(`---
hosts:
example.com:
user: wronguser
oauth_token: NOTTHIS
github.com:
user: monalisa
oauth_token: OTOKEN
`)()
config, err := ParseConfig("filename")
eq(t, err, nil)
eq(t, entry.User, "monalisa")
eq(t, entry.Token, "OTOKEN")
user, err := config.Get("github.com", "user")
eq(t, err, nil)
eq(t, user, "monalisa")
token, err := config.Get("github.com", "oauth_token")
eq(t, err, nil)
eq(t, token, "OTOKEN")
}
func Test_parseConfig_notFound(t *testing.T) {
c := strings.NewReader(`---
example.com:
- user: wronguser
oauth_token: NOTTHIS
`)
_, err := parseConfig(c)
defer StubConfig(`---
hosts:
example.com:
user: wronguser
oauth_token: NOTTHIS
`)()
config, err := ParseConfig("filename")
eq(t, err, nil)
_, err = config.configForHost("github.com")
eq(t, err, errors.New(`could not find config entry for "github.com"`))
}
func Test_migrateConfig(t *testing.T) {
oldStyle := `---
github.com:
- user: keiyuri
oauth_token: 123456`
var root yaml.Node
err := yaml.Unmarshal([]byte(oldStyle), &root)
if err != nil {
panic("failed to parse test yaml")
}
buf := bytes.NewBufferString("")
defer StubWriteConfig(buf)()
defer StubBackupConfig()()
err = migrateConfig("boom.txt", &root)
eq(t, err, nil)
expected := `hosts:
github.com:
oauth_token: "123456"
user: keiyuri
`
eq(t, buf.String(), expected)
}

View file

@ -26,7 +26,7 @@ var (
// TODO: have a conversation about whether this belongs in the "context" package
// FIXME: make testable
func setupConfigFile(filename string) (*configEntry, error) {
func setupConfigFile(filename string) (Config, error) {
var verboseStream io.Writer
if strings.Contains(os.Getenv("DEBUG"), "oauth") {
verboseStream = os.Stderr
@ -54,29 +54,37 @@ func setupConfigFile(filename string) (*configEntry, error) {
if err != nil {
return nil, err
}
entry := configEntry{
User: userLogin,
Token: token,
// TODO this sucks. It precludes us laying out a nice config with comments and such.
type yamlConfig struct {
Hosts map[string]map[string]string
}
yamlHosts := map[string]map[string]string{}
yamlHosts[flow.Hostname] = map[string]string{}
yamlHosts[flow.Hostname]["user"] = userLogin
yamlHosts[flow.Hostname]["oauth_token"] = token
defaultConfig := yamlConfig{
Hosts: yamlHosts,
}
data := make(map[string][]configEntry)
data[flow.Hostname] = []configEntry{entry}
err = os.MkdirAll(filepath.Dir(filename), 0771)
if err != nil {
return nil, err
}
config, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
cfgFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return nil, err
}
defer config.Close()
defer cfgFile.Close()
yamlData, err := yaml.Marshal(data)
yamlData, err := yaml.Marshal(defaultConfig)
if err != nil {
return nil, err
}
n, err := config.Write(yamlData)
n, err := cfgFile.Write(yamlData)
if err == nil && n < len(yamlData) {
err = io.ErrShortWrite
}
@ -86,7 +94,8 @@ func setupConfigFile(filename string) (*configEntry, error) {
_ = waitForEnter(os.Stdin)
}
return &entry, err
// TODO cleaner error handling? this "should" always work given that we /just/ wrote the file...
return ParseConfig(filename)
}
func getViewer(token string) (string, error) {

205
context/config_type.go Normal file
View file

@ -0,0 +1,205 @@
package context
import (
"errors"
"fmt"
"gopkg.in/yaml.v3"
)
const defaultHostname = "github.com"
const defaultGitProtocol = "https"
// This interface describes interacting with some persistent configuration for gh.
type Config interface {
Hosts() ([]*HostConfig, error)
Get(string, string) (string, error)
Set(string, string, string) error
Write() error
configForHost(string) (*HostConfig, error)
parseHosts(*yaml.Node) ([]*HostConfig, error)
}
type NotFoundError struct {
error
}
type HostConfig struct {
ConfigMap
Host string
}
// This type implements a low-level get/set config that is backed by an in-memory tree of Yaml
// nodes. It allows us to interact with a yaml-based config programmatically, preserving any
// comments that were present when the yaml waas parsed.
type ConfigMap struct {
Root *yaml.Node
}
func (cm *ConfigMap) GetStringValue(key string) (string, error) {
_, valueNode, err := cm.FindEntry(key)
var notFound *NotFoundError
if err != nil && errors.As(err, &notFound) {
return defaultFor(key), nil
} else if err != nil {
return "", err
}
if valueNode.Value == "" {
return defaultFor(key), nil
}
return valueNode.Value, nil
}
func (cm *ConfigMap) SetStringValue(key, value string) error {
_, valueNode, err := cm.FindEntry(key)
var notFound *NotFoundError
if err != nil && errors.As(err, &notFound) {
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: key,
}
valueNode = &yaml.Node{
Kind: yaml.ScalarNode,
Value: "",
}
cm.Root.Content = append(cm.Root.Content, keyNode, valueNode)
} else if err != nil {
return err
}
valueNode.Value = value
return nil
}
func (cm *ConfigMap) FindEntry(key string) (keyNode, valueNode *yaml.Node, err error) {
err = nil
topLevelKeys := cm.Root.Content
for i, v := range topLevelKeys {
if v.Value == key && i+1 < len(topLevelKeys) {
keyNode = v
valueNode = topLevelKeys[i+1]
return
}
}
return nil, nil, &NotFoundError{errors.New("not found")}
}
func NewConfig(root *yaml.Node) Config {
return &fileConfig{
ConfigMap: ConfigMap{Root: root.Content[0]},
documentRoot: root,
}
}
// This type implements a Config interface and represents a config file on disk.
type fileConfig struct {
ConfigMap
documentRoot *yaml.Node
hosts []*HostConfig
}
func (c *fileConfig) Get(hostname, key string) (string, error) {
if hostname == "" {
return c.GetStringValue(key)
} else {
hostCfg, err := c.configForHost(hostname)
if err != nil {
return "", err
}
return hostCfg.GetStringValue(key)
}
}
func (c *fileConfig) Set(hostname, key, value string) error {
if hostname == "" {
return c.SetStringValue(key, value)
} else {
hostCfg, err := c.configForHost(hostname)
if err != nil {
return err
}
return hostCfg.SetStringValue(key, value)
}
}
func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
hosts, err := c.Hosts()
if err != nil {
return nil, fmt.Errorf("failed to parse hosts config: %s", err)
}
for _, hc := range hosts {
if hc.Host == hostname {
return hc, nil
}
}
return nil, fmt.Errorf("could not find config entry for %q", hostname)
}
func (c *fileConfig) Write() error {
marshalled, err := yaml.Marshal(c.documentRoot)
if err != nil {
return err
}
return WriteConfigFile(configFile(), marshalled)
}
func (c *fileConfig) Hosts() ([]*HostConfig, error) {
if len(c.hosts) > 0 {
return c.hosts, nil
}
_, hostsEntry, err := c.FindEntry("hosts")
if err != nil {
return nil, fmt.Errorf("could not find hosts config: %s", err)
}
hostConfigs, err := c.parseHosts(hostsEntry)
if err != nil {
return nil, fmt.Errorf("could not parse hosts config: %s", err)
}
c.hosts = hostConfigs
return hostConfigs, nil
}
func (c *fileConfig) parseHosts(hostsEntry *yaml.Node) ([]*HostConfig, error) {
hostConfigs := []*HostConfig{}
for i := 0; i < len(hostsEntry.Content)-1; i = i + 2 {
hostname := hostsEntry.Content[i].Value
hostRoot := hostsEntry.Content[i+1]
hostConfig := HostConfig{
ConfigMap: ConfigMap{Root: hostRoot},
Host: hostname,
}
hostConfigs = append(hostConfigs, &hostConfig)
}
if len(hostConfigs) == 0 {
return nil, errors.New("could not find any host configurations")
}
return hostConfigs, nil
}
func defaultFor(key string) string {
// we only have a set default for one setting right now
switch key {
case "git_protocol":
return defaultGitProtocol
default:
return ""
}
}

View file

@ -22,6 +22,7 @@ type Context interface {
Remotes() (Remotes, error)
BaseRepo() (ghrepo.Interface, error)
SetBaseRepo(string)
Config() (Config, error)
}
// cap the number of git remotes looked up, since the user might have an
@ -151,7 +152,7 @@ func New() Context {
// A Context implementation that queries the filesystem
type fsContext struct {
config *configEntry
config Config
remotes Remotes
branch string
baseRepo ghrepo.Interface
@ -167,13 +168,13 @@ func configFile() string {
return path.Join(ConfigDir(), "config.yml")
}
func (c *fsContext) getConfig() (*configEntry, error) {
func (c *fsContext) Config() (Config, error) {
if c.config == nil {
entry, err := parseOrSetupConfigFile(configFile())
config, err := parseOrSetupConfigFile(configFile())
if err != nil {
return nil, err
}
c.config = entry
c.config = config
c.authToken = ""
}
return c.config, nil
@ -184,11 +185,12 @@ func (c *fsContext) AuthToken() (string, error) {
return c.authToken, nil
}
config, err := c.getConfig()
config, err := c.Config()
if err != nil {
return "", err
}
return config.Token, nil
return config.Get(defaultHostname, "oauth_token")
}
func (c *fsContext) SetAuthToken(t string) {
@ -196,11 +198,12 @@ func (c *fsContext) SetAuthToken(t string) {
}
func (c *fsContext) AuthLogin() (string, error) {
config, err := c.getConfig()
config, err := c.Config()
if err != nil {
return "", err
}
return config.User, nil
return config.Get(defaultHostname, "user")
}
func (c *fsContext) Branch() (string, error) {

46
context/testing.go Normal file
View file

@ -0,0 +1,46 @@
package context
import (
"io"
"reflect"
"testing"
)
func StubBackupConfig() func() {
orig := BackupConfigFile
BackupConfigFile = func(_ string) error {
return nil
}
return func() {
BackupConfigFile = orig
}
}
func StubWriteConfig(w io.Writer) func() {
orig := WriteConfigFile
WriteConfigFile = func(fn string, data []byte) error {
_, err := w.Write(data)
return err
}
return func() {
WriteConfigFile = orig
}
}
func StubConfig(content string) func() {
orig := ReadConfigFile
ReadConfigFile = func(fn string) ([]byte, error) {
return []byte(content), nil
}
return func() {
ReadConfigFile = orig
}
}
func eq(t *testing.T, got interface{}, expected interface{}) {
t.Helper()
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected: %v, got: %v", expected, got)
}
}