diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e5ad39f5c..06d3837b9 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -6,27 +6,42 @@ Hi! Thanks for your interest in contributing to the GitHub CLI! -Given that this project is very early and still in beta, we're only accepting pull requests for bug fixes right now. We'd love to -hear about ideas for new features as issues, though! +We accept pull requests for bug fixes and features where we've discussed the approach in an issue and given the go-ahead for a community member to work on it. We'd also love to hear about ideas for new features as issues. Please do: * open an issue if things aren't working as expected * open an issue to propose a significant change -* open a PR to fix a bug +* open a pull request to fix a bug +* open a pull request to fix documentation about a command +* open a pull request if a member of the GitHub CLI team has given the ok after discussion in an issue -## Submitting a bug fix +Please avoid: -0. Clone this repository -0. Create a new branch: `git checkout -b my-branch-name` -0. Make your change, add tests, and ensure tests pass -0. Make a PR: `gh pr create --web` +* adding installation instructions specifically for your OS/package manager + +## Building the project + +Prerequisites: +- Go 1.13 + +Build with: `make` or `go build -o bin/gh ./cmd/gh` + +Run the new binary as: `./bin/gh` + +Run tests with: `make test` or `go test ./...` + +## Submitting a pull request + +1. Create a new branch: `git checkout -b my-branch-name` +1. Make your change, add tests, and ensure tests pass +1. Submit a pull request: `gh pr create --web` Contributions to this project are [released][legal] to the public under the [project's open source license][license]. Please note that this project adheres to a [Contributor Code of Conduct][code-of-conduct]. By participating in this project you agree to abide by its terms. -We generate manual pages from source on every release! You do not need to submit PRs for those specifically; the docs will get updated if your PR gets accepted. +We generate manual pages from source on every release. You do not need to submit pull requests for documentation specifically; manual pages for commands will automatically get updated after your pull requests gets accepted. ## Resources diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index eab903246..6dcbc365e 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -10,7 +10,7 @@ jobs: steps: - name: Set up Go 1.13 - uses: actions/setup-go@v1 + uses: actions/setup-go@v2-beta with: go-version: 1.13 diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 213185e7e..144061c9f 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,9 +2,9 @@ name: Lint on: push: paths: - - '**.go' - - go.mod - - go.sum + - "**.go" + - go.mod + - go.sum jobs: lint: @@ -12,7 +12,7 @@ jobs: steps: - name: Set up Go 1.13 - uses: actions/setup-go@v1 + uses: actions/setup-go@v2-beta with: go-version: 1.13 diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml index aa6669845..2f9908167 100644 --- a/.github/workflows/releases.yml +++ b/.github/workflows/releases.yml @@ -12,7 +12,7 @@ jobs: - name: Checkout uses: actions/checkout@v2 - name: Set up Go 1.13 - uses: actions/setup-go@v1 + uses: actions/setup-go@v2-beta with: go-version: 1.13 - name: Generate changelog diff --git a/README.md b/README.md index f257d0eb6..5c96a685f 100644 --- a/README.md +++ b/README.md @@ -93,7 +93,7 @@ MSI installers are available for download on the [releases page][]. Install and upgrade: 1. Download the `.deb` file from the [releases page][] -2. `sudo apt install git && sudo dpkg -i gh_*_linux_amd64.deb` install the downloaded file +2. `sudo apt install ./gh_*_linux_amd64.deb` install the downloaded file ### Fedora Linux diff --git a/api/client.go b/api/client.go index a0ed6a859..2fc72aaa6 100644 --- a/api/client.go +++ b/api/client.go @@ -146,6 +146,10 @@ func (c Client) REST(method string, p string, body io.Reader, data interface{}) return handleHTTPError(resp) } + if resp.StatusCode == http.StatusNoContent { + return nil + } + b, err := ioutil.ReadAll(resp.Body) if err != nil { return err diff --git a/api/client_test.go b/api/client_test.go index 12bfbe408..492609e5c 100644 --- a/api/client_test.go +++ b/api/client_test.go @@ -50,3 +50,17 @@ func TestGraphQLError(t *testing.T) { t.Fatalf("got %q", err.Error()) } } + +func TestRESTGetDelete(t *testing.T) { + http := &FakeHTTP{} + + client := NewClient( + ReplaceTripper(http), + ) + + http.StubResponse(204, bytes.NewBuffer([]byte{})) + + r := bytes.NewReader([]byte(`{}`)) + err := client.REST("DELETE", "applications/CLIENTID/grant", r, nil) + eq(t, err, nil) +} diff --git a/api/queries_issue.go b/api/queries_issue.go index 2c2010307..cc01c039f 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -18,6 +18,7 @@ type IssuesAndTotalCount struct { TotalCount int } +// Ref. https://developer.github.com/v4/object/issue/ type Issue struct { Number int Title string @@ -32,15 +33,32 @@ type Issue struct { Author struct { Login string } - - Labels struct { - Nodes []IssueLabel + Assignees struct { + Nodes []struct { + Login string + } TotalCount int } -} - -type IssueLabel struct { - Name string + Labels struct { + Nodes []struct { + Name string + } + TotalCount int + } + ProjectCards struct { + Nodes []struct { + Project struct { + Name string + } + Column struct { + Name string + } + } + TotalCount int + } + Milestone struct { + Title string + } } const fragments = ` @@ -287,14 +305,35 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e comments { totalCount } - labels(first: 3) { - nodes { - name - } - } number url createdAt + assignees(first: 100) { + nodes { + login + } + totalCount + } + labels(first: 100) { + nodes { + name + } + totalCount + } + projectCards(first: 100) { + nodes { + project { + name + } + column { + name + } + } + totalCount + } + milestone{ + title + } } } }` diff --git a/cmd/gh/main.go b/cmd/gh/main.go index 17f2042f2..9ddf79315 100644 --- a/cmd/gh/main.go +++ b/cmd/gh/main.go @@ -10,7 +10,7 @@ import ( "strings" "github.com/cli/cli/command" - "github.com/cli/cli/context" + "github.com/cli/cli/internal/config" "github.com/cli/cli/update" "github.com/cli/cli/utils" "github.com/mgutz/ansi" @@ -88,6 +88,6 @@ func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) { } repo := updaterEnabled - stateFilePath := path.Join(context.ConfigDir(), "state.yml") + stateFilePath := path.Join(config.ConfigDir(), "state.yml") return update.CheckForUpdate(client, stateFilePath, repo, currentVersion) } diff --git a/command/config.go b/command/config.go new file mode 100644 index 000000000..0350e528b --- /dev/null +++ b/command/config.go @@ -0,0 +1,112 @@ +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") + + // TODO reveal and add usage once we properly support multiple hosts + _ = configGetCmd.Flags().MarkHidden("host") + // TODO reveal and add usage once we properly support multiple hosts + _ = configSetCmd.Flags().MarkHidden("host") +} + +var configCmd = &cobra.Command{ + Use: "config", + Short: "Set and get gh settings", + Long: `Get and set key/value strings. + +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. + +Examples: + $ gh config get git_protocol + https +`, + 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. + +Examples: + $ gh config set editor vim +`, + 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: %w", key, value, err) + } + + err = cfg.Write() + if err != nil { + return fmt.Errorf("failed to write config to disk: %w", err) + } + + return nil +} diff --git a/command/config_test.go b/command/config_test.go new file mode 100644 index 000000000..cdfeb743a --- /dev/null +++ b/command/config_test.go @@ -0,0 +1,191 @@ +package command + +import ( + "bytes" + "testing" + + "github.com/cli/cli/internal/config" +) + +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 config.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 config.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 TestConfigGetHost_unset(t *testing.T) { + cfg := `--- +hosts: + github.com: + user: OWNER + oauth_token: MUSTBEHIGHCUZIMATOKEN + +editor: ed +git_protocol: ssh +` + 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 -hgithub.com git_protocol`: %v", err) + } + + eq(t, output.String(), "ssh\n") +} + +func TestConfigSetHost(t *testing.T) { + initBlankContext("", "OWNER/REPO", "master") + + buf := bytes.NewBufferString("") + defer config.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 config.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) +} diff --git a/command/issue.go b/command/issue.go index fd2e03eef..2f4e145af 100644 --- a/command/issue.go +++ b/command/issue.go @@ -259,8 +259,9 @@ func printIssuePreview(out io.Writer, issue *api.Issue) error { now := time.Now() ago := now.Sub(issue.CreatedAt) + // Header (Title and State) fmt.Fprintln(out, utils.Bold(issue.Title)) - fmt.Fprintf(out, "%s", issueStateTitleWithColor(issue.State)) + fmt.Fprint(out, issueStateTitleWithColor(issue.State)) fmt.Fprintln(out, utils.Gray(fmt.Sprintf( " • %s opened %s • %s", issue.Author.Login, @@ -268,6 +269,26 @@ func printIssuePreview(out io.Writer, issue *api.Issue) error { utils.Pluralize(issue.Comments.TotalCount, "comment"), ))) + // Metadata + fmt.Fprintln(out) + if assignees := issueAssigneeList(*issue); assignees != "" { + fmt.Fprint(out, utils.Bold("Assignees: ")) + fmt.Fprintln(out, assignees) + } + if labels := issueLabelList(*issue); labels != "" { + fmt.Fprint(out, utils.Bold("Labels: ")) + fmt.Fprintln(out, labels) + } + if projects := issueProjectList(*issue); projects != "" { + fmt.Fprint(out, utils.Bold("Projects: ")) + fmt.Fprintln(out, projects) + } + if issue.Milestone.Title != "" { + fmt.Fprint(out, utils.Bold("Milestone: ")) + fmt.Fprintln(out, issue.Milestone.Title) + } + + // Body if issue.Body != "" { fmt.Fprintln(out) md, err := utils.RenderMarkdown(issue.Body) @@ -275,9 +296,10 @@ func printIssuePreview(out io.Writer, issue *api.Issue) error { return err } fmt.Fprintln(out, md) - fmt.Fprintln(out) } + fmt.Fprintln(out) + // Footer fmt.Fprintf(out, utils.Gray("View this issue on GitHub: %s\n"), issue.URL) return nil } @@ -422,7 +444,7 @@ func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) issueNum = "#" + issueNum } issueNum = prefix + issueNum - labels := labelList(issue) + labels := issueLabelList(issue) if labels != "" && table.IsTTY() { labels = fmt.Sprintf("(%s)", labels) } @@ -441,7 +463,24 @@ func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) } } -func labelList(issue api.Issue) string { +func issueAssigneeList(issue api.Issue) string { + if len(issue.Assignees.Nodes) == 0 { + return "" + } + + AssigneeNames := make([]string, 0, len(issue.Assignees.Nodes)) + for _, assignee := range issue.Assignees.Nodes { + AssigneeNames = append(AssigneeNames, assignee.Login) + } + + list := strings.Join(AssigneeNames, ", ") + if issue.Assignees.TotalCount > len(issue.Assignees.Nodes) { + list += ", …" + } + return list +} + +func issueLabelList(issue api.Issue) string { if len(issue.Labels.Nodes) == 0 { return "" } @@ -458,6 +497,23 @@ func labelList(issue api.Issue) string { return list } +func issueProjectList(issue api.Issue) string { + if len(issue.ProjectCards.Nodes) == 0 { + return "" + } + + projectNames := make([]string, 0, len(issue.ProjectCards.Nodes)) + for _, project := range issue.ProjectCards.Nodes { + projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, project.Column.Name)) + } + + list := strings.Join(projectNames, ", ") + if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) { + list += ", …" + } + return list +} + func displayURL(urlStr string) string { u, err := url.Parse(urlStr) if err != nil { diff --git a/command/issue_test.go b/command/issue_test.go index 099f80f17..b7f91031d 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -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") @@ -292,26 +292,30 @@ func TestIssueView_Preview(t *testing.T) { fixture string expectedOutputs []string }{ - "Open issue": { + "Open issue without metadata": { ownerRepo: "master", command: "issue view 123", fixture: "../test/fixtures/issueView_preview.json", expectedOutputs: []string{ - "ix of coins", - "Open • marseilles opened about 292 years ago • 9 comments", - "bold story", - "View this issue on GitHub: https://github.com/OWNER/REPO/issues/123", + `ix of coins`, + `Open • marseilles opened about 292 years ago • 9 comments`, + `bold story`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, - "Open issue with no label": { + "Open issue with metadata": { ownerRepo: "master", command: "issue view 123", - fixture: "../test/fixtures/issueView_previewNoLabel.json", + fixture: "../test/fixtures/issueView_previewWithMetadata.json", expectedOutputs: []string{ - "ix of coins", - "Open • marseilles opened about 292 years ago • 9 comments", - "bold story", - "View this issue on GitHub: https://github.com/OWNER/REPO/issues/123", + `ix of coins`, + `Open • marseilles opened about 292 years ago • 9 comments`, + `Assignees: marseilles, monaco\n`, + `Labels: one, two, three, four, five\n`, + `Projects: Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`, + `Milestone: uluru\n`, + `bold story`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, "Open issue with empty body": { @@ -319,9 +323,9 @@ func TestIssueView_Preview(t *testing.T) { command: "issue view 123", fixture: "../test/fixtures/issueView_previewWithEmptyBody.json", expectedOutputs: []string{ - "ix of coins", - "Open • marseilles opened about 292 years ago • 9 comments", - "View this issue on GitHub: https://github.com/OWNER/REPO/issues/123", + `ix of coins`, + `Open • marseilles opened about 292 years ago • 9 comments`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, "Closed issue": { @@ -329,16 +333,16 @@ func TestIssueView_Preview(t *testing.T) { command: "issue view 123", fixture: "../test/fixtures/issueView_previewClosedState.json", expectedOutputs: []string{ - "ix of coins", - "Closed • marseilles opened about 292 years ago • 9 comments", - "bold story", - "View this issue on GitHub: https://github.com/OWNER/REPO/issues/123", + `ix of coins`, + `Closed • marseilles opened about 292 years ago • 9 comments`, + `bold story`, + `View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`, }, }, } 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") @@ -359,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(` @@ -386,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") @@ -404,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") @@ -437,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") @@ -478,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") @@ -496,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") @@ -522,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") diff --git a/command/pr_create_test.go b/command/pr_create_test.go index b88023b35..f3a0954c8 100644 --- a/command/pr_create_test.go +++ b/command/pr_create_test.go @@ -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(` @@ -361,17 +361,17 @@ func TestPRCreate_survey_defaults_multicommit(t *testing.T) { defer surveyTeardown() as.Stub([]*QuestionStub{ - &QuestionStub{ + { Name: "title", Default: true, }, - &QuestionStub{ + { Name: "body", Default: true, }, }) as.Stub([]*QuestionStub{ - &QuestionStub{ + { Name: "confirmation", Value: 1, }, @@ -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(` @@ -438,17 +438,17 @@ func TestPRCreate_survey_defaults_monocommit(t *testing.T) { defer surveyTeardown() as.Stub([]*QuestionStub{ - &QuestionStub{ + { Name: "title", Default: true, }, - &QuestionStub{ + { Name: "body", Default: true, }, }) as.Stub([]*QuestionStub{ - &QuestionStub{ + { Name: "confirmation", Value: 1, }, @@ -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(` @@ -605,17 +605,17 @@ func TestPRCreate_defaults_error_interactive(t *testing.T) { defer surveyTeardown() as.Stub([]*QuestionStub{ - &QuestionStub{ + { Name: "title", Default: true, }, - &QuestionStub{ + { Name: "body", Value: "social distancing", }, }) as.Stub([]*QuestionStub{ - &QuestionStub{ + { Name: "confirmation", Value: 0, }, diff --git a/command/pr_test.go b/command/pr_test.go index 06d403efe..7f8a0ab7f 100644 --- a/command/pr_test.go +++ b/command/pr_test.go @@ -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") @@ -531,7 +531,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") @@ -552,7 +552,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") @@ -590,7 +590,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") @@ -621,7 +621,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") @@ -653,7 +653,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") @@ -685,7 +685,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(` @@ -716,7 +716,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") @@ -750,7 +750,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") diff --git a/command/repo_test.go b/command/repo_test.go index cf3fe3423..bd10e4218 100644 --- a/command/repo_test.go +++ b/command/repo_test.go @@ -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("{}")) diff --git a/command/root.go b/command/root.go index fa65d7e05..31a0ebca6 100644 --- a/command/root.go +++ b/command/root.go @@ -10,6 +10,7 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/context" + "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" "github.com/cli/cli/utils" @@ -17,6 +18,9 @@ import ( "github.com/spf13/pflag" ) +// TODO these are sprinkled across command, context, config, and ghrepo +const defaultHostname = "github.com" + // Version is dynamically set by the toolchain or overridden by the Makefile. var Version = "DEV" @@ -106,9 +110,21 @@ func BasicClient() (*api.Client, error) { opts = append(opts, apiVerboseLog()) } 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))) + + c, err := config.ParseDefaultConfig() + if err != nil { + return nil, err } + + token, err := c.Get(defaultHostname, "oauth_token") + if err != nil { + return nil, err + } + if token == "" { + return nil, fmt.Errorf("no oauth_token set in config") + } + + opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", token))) return api.NewClient(opts...), nil } diff --git a/command/testing.go b/command/testing.go index 11b99a99f..b9b0ba04a 100644 --- a/command/testing.go +++ b/command/testing.go @@ -10,8 +10,15 @@ import ( "github.com/cli/cli/api" "github.com/cli/cli/context" + "github.com/cli/cli/internal/config" ) +const defaultTestConfig = `hosts: + github.com: + user: OWNER + oauth_token: 1234567890 +` + type askStubber struct { Asks [][]*survey.Question Count int @@ -63,7 +70,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 +78,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. + config.StubConfig(cfg) + return ctx } } diff --git a/context/blank_context.go b/context/blank_context.go index 110b43768..c5ceddff7 100644 --- a/context/blank_context.go +++ b/context/blank_context.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" ) @@ -22,6 +23,14 @@ type blankContext struct { remotes Remotes } +func (c *blankContext) Config() (config.Config, error) { + cfg, err := config.ParseConfig("boom.txt") + if err != nil { + panic(fmt.Sprintf("failed to parse config during tests. did you remember to stub? error: %s", err)) + } + return cfg, nil +} + func (c *blankContext) AuthToken() (string, error) { return c.authToken, nil } diff --git a/context/config_file.go b/context/config_file.go deleted file mode 100644 index f3b07393b..000000000 --- a/context/config_file.go +++ /dev/null @@ -1,66 +0,0 @@ -package context - -import ( - "errors" - "fmt" - "io" - "io/ioutil" - "os" - - "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) - if err != nil && errors.Is(err, os.ErrNotExist) { - return setupConfigFile(fn) - } - return entry, err -} - -func parseConfigFile(fn string) (*configEntry, 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) - if err != nil { - return nil, err - } - var config yaml.Node - err = yaml.Unmarshal(data, &config) - if err != nil { - return nil, err - } - if len(config.Content) < 1 { - return nil, fmt.Errorf("malformed config") - } - 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 nil, fmt.Errorf("could not find config entry for %q", defaultHostname) -} diff --git a/context/config_file_test.go b/context/config_file_test.go deleted file mode 100644 index 5a915e78d..000000000 --- a/context/config_file_test.go +++ /dev/null @@ -1,55 +0,0 @@ -package context - -import ( - "errors" - "reflect" - "strings" - "testing" -) - -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) - eq(t, err, nil) - eq(t, entry.User, "monalisa") - eq(t, entry.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) - eq(t, err, nil) - eq(t, entry.User, "monalisa") - eq(t, entry.Token, "OTOKEN") -} - -func Test_parseConfig_notFound(t *testing.T) { - c := strings.NewReader(`--- -example.com: -- user: wronguser - oauth_token: NOTTHIS -`) - _, err := parseConfig(c) - eq(t, err, errors.New(`could not find config entry for "github.com"`)) -} diff --git a/context/context.go b/context/context.go index eb1ace0eb..a5cd823fc 100644 --- a/context/context.go +++ b/context/context.go @@ -3,15 +3,17 @@ package context import ( "errors" "fmt" - "path" "sort" "github.com/cli/cli/api" "github.com/cli/cli/git" + "github.com/cli/cli/internal/config" "github.com/cli/cli/internal/ghrepo" - "github.com/mitchellh/go-homedir" ) +// TODO these are sprinkled across command, context, config, and ghrepo +const defaultHostname = "github.com" + // Context represents the interface for querying information about the current environment type Context interface { AuthToken() (string, error) @@ -22,6 +24,7 @@ type Context interface { Remotes() (Remotes, error) BaseRepo() (ghrepo.Interface, error) SetBaseRepo(string) + Config() (config.Config, error) } // cap the number of git remotes looked up, since the user might have an @@ -151,29 +154,20 @@ func New() Context { // A Context implementation that queries the filesystem type fsContext struct { - config *configEntry + config config.Config remotes Remotes branch string baseRepo ghrepo.Interface authToken string } -func ConfigDir() string { - dir, _ := homedir.Expand("~/.config/gh") - return dir -} - -func configFile() string { - return path.Join(ConfigDir(), "config.yml") -} - -func (c *fsContext) getConfig() (*configEntry, error) { +func (c *fsContext) Config() (config.Config, error) { if c.config == nil { - entry, err := parseOrSetupConfigFile(configFile()) + config, err := config.ParseOrSetupConfigFile(config.ConfigFile()) if err != nil { return nil, err } - c.config = entry + c.config = config c.authToken = "" } return c.config, nil @@ -184,11 +178,17 @@ func (c *fsContext) AuthToken() (string, error) { return c.authToken, nil } - config, err := c.getConfig() + cfg, err := c.Config() if err != nil { return "", err } - return config.Token, nil + + token, err := cfg.Get(defaultHostname, "oauth_token") + if token == "" || err != nil { + return "", err + } + + return token, nil } func (c *fsContext) SetAuthToken(t string) { @@ -196,11 +196,17 @@ 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 + + login, err := config.Get(defaultHostname, "user") + if login == "" || err != nil { + return "", err + } + + return login, nil } func (c *fsContext) Branch() (string, error) { diff --git a/context/remote_test.go b/context/remote_test.go index 5c3c46bc7..4b6838e23 100644 --- a/context/remote_test.go +++ b/context/remote_test.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "net/url" + "reflect" "testing" "github.com/cli/cli/api" @@ -11,6 +12,13 @@ import ( "github.com/cli/cli/internal/ghrepo" ) +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_Remotes_FindByName(t *testing.T) { list := Remotes{ &Remote{Remote: &git.Remote{Name: "mona"}, Owner: "monalisa", Repo: "myfork"}, diff --git a/go.mod b/go.mod index 896bfd300..193320caa 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,7 @@ go 1.13 require ( github.com/AlecAivazis/survey/v2 v2.0.7 - github.com/briandowns/spinner v1.9.0 + github.com/briandowns/spinner v1.10.1-0.20200410162419-bf6cf7ae6727 github.com/charmbracelet/glamour v0.1.1-0.20200320173916-301d3bcf3058 github.com/dlclark/regexp2 v1.2.0 // indirect github.com/google/go-cmp v0.2.0 diff --git a/go.sum b/go.sum index 40379e814..807e68f62 100644 --- a/go.sum +++ b/go.sum @@ -20,8 +20,8 @@ github.com/alecthomas/units v0.0.0-20151022065526-2efee857e7cf/go.mod h1:ybxpYRF github.com/armon/consul-api v0.0.0-20180202201655-eb2c6b5be1b6/go.mod h1:grANhF5doyWs3UAsr3K4I6qtAmlQcZDesFNEHPZAzj8= github.com/beorn7/perks v0.0.0-20180321164747-3a771d992973/go.mod h1:Dwedo/Wpr24TaqPxmxbtue+5NUziq4I4S80YR8gNf3Q= github.com/beorn7/perks v1.0.0/go.mod h1:KWe93zE9D1o94FZ5RNwFwVgaQK1VOXiVxmqh+CedLV8= -github.com/briandowns/spinner v1.9.0 h1:+OMAisemaHar1hjuJ3Z2hIvNhQl9Y7GLPWUwwz2Pxo8= -github.com/briandowns/spinner v1.9.0/go.mod h1://Zf9tMcxfRUA36V23M6YGEAv+kECGfvpnLTnb8n4XQ= +github.com/briandowns/spinner v1.10.1-0.20200410162419-bf6cf7ae6727 h1:DOyHtIQZmwFEOt/makVyey2RMTPkpi1IQsWsWX0OcGE= +github.com/briandowns/spinner v1.10.1-0.20200410162419-bf6cf7ae6727/go.mod h1:QOuQk7x+EaDASo80FEXwlwiA+j/PPIcX3FScO+3/ZPQ= github.com/cespare/xxhash v1.1.0/go.mod h1:XrSqR1VqqWfGrhpAt58auRo0WTKS1nRRg3ghfAqPWnc= github.com/charmbracelet/glamour v0.1.1-0.20200320173916-301d3bcf3058 h1:Ks+RZ6s6UriHnL+yusm3OoaLwpV9WPvMV+FXQ6qMD7M= github.com/charmbracelet/glamour v0.1.1-0.20200320173916-301d3bcf3058/go.mod h1:sC1EP6T+3nFnl5vwf0TYEs1inMigQxZ7n912YKoxJow= diff --git a/internal/config/config_file.go b/internal/config/config_file.go new file mode 100644 index 000000000..2670aff20 --- /dev/null +++ b/internal/config/config_file.go @@ -0,0 +1,181 @@ +package config + +import ( + "errors" + "fmt" + "io" + "io/ioutil" + "os" + "path" + + "github.com/mitchellh/go-homedir" + "gopkg.in/yaml.v3" +) + +func ConfigDir() string { + dir, _ := homedir.Expand("~/.config/gh") + return dir +} + +func ConfigFile() string { + return path.Join(ConfigDir(), "config.yml") +} + +func ParseOrSetupConfigFile(fn string) (Config, error) { + config, err := ParseConfig(fn) + if err != nil && errors.Is(err, os.ErrNotExist) { + return setupConfigFile(fn) + } + return config, err +} + +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() + + data, err := ioutil.ReadAll(f) + if err != nil { + return nil, err + } + + 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 err + } + defer cfgFile.Close() + + n, err := cfgFile.Write(data) + if err == nil && n < len(data) { + err = io.ErrShortWrite + } + + 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 true +} + +func migrateConfig(fn string, root *yaml.Node) error { + type ConfigEntry map[string]string + type ConfigHash map[string]ConfigEntry + + newConfigData := map[string]ConfigHash{} + newConfigData["hosts"] = ConfigHash{} + + 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] = ConfigEntry{} + + 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: %w", 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: %w", err) + } + } + + return NewConfig(root), nil +} diff --git a/internal/config/config_file_test.go b/internal/config/config_file_test.go new file mode 100644 index 000000000..4ddd63435 --- /dev/null +++ b/internal/config/config_file_test.go @@ -0,0 +1,96 @@ +package config + +import ( + "bytes" + "errors" + "reflect" + "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) { + defer StubConfig(`--- +hosts: + github.com: + user: monalisa + oauth_token: OTOKEN +`)() + config, err := ParseConfig("filename") + eq(t, err, nil) + 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) { + 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) + 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) { + defer StubConfig(`--- +hosts: + example.com: + user: wronguser + oauth_token: NOTTHIS +`)() + config, err := ParseConfig("filename") + eq(t, err, nil) + _, err = config.Get("github.com", "user") + 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) +} diff --git a/context/config_setup.go b/internal/config/config_setup.go similarity index 71% rename from context/config_setup.go rename to internal/config/config_setup.go index 56bdfe55a..9bc0fb775 100644 --- a/context/config_setup.go +++ b/internal/config/config_setup.go @@ -1,4 +1,4 @@ -package context +package config import ( "bufio" @@ -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) { diff --git a/context/config_success.go b/internal/config/config_success.go similarity index 98% rename from context/config_success.go rename to internal/config/config_success.go index 1919fc1a4..c7a681bac 100644 --- a/context/config_success.go +++ b/internal/config/config_success.go @@ -1,4 +1,4 @@ -package context +package config const oauthSuccessPage = ` diff --git a/internal/config/config_type.go b/internal/config/config_type.go new file mode 100644 index 000000000..e37c3e884 --- /dev/null +++ b/internal/config/config_type.go @@ -0,0 +1,218 @@ +package config + +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 +} + +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) + if err != nil { + return "", err + } + 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, ¬Found) { + 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 != "" { + hostCfg, err := c.configForHost(hostname) + if err != nil { + return "", err + } + + hostValue, err := hostCfg.GetStringValue(key) + var notFound *NotFoundError + + if err != nil && !errors.As(err, ¬Found) { + return "", err + } + + if hostValue != "" { + return hostValue, nil + } + } + + value, err := c.GetStringValue(key) + + var notFound *NotFoundError + + if err != nil && errors.As(err, ¬Found) { + return defaultFor(key), nil + } else if err != nil { + return "", err + } + + if value == "" { + return defaultFor(key), nil + } + + return value, nil +} + +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: %w", 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: %w", err) + } + + hostConfigs, err := c.parseHosts(hostsEntry) + if err != nil { + return nil, fmt.Errorf("could not parse hosts config: %w", 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 "" + } +} diff --git a/internal/config/testing.go b/internal/config/testing.go new file mode 100644 index 000000000..a91cedfae --- /dev/null +++ b/internal/config/testing.go @@ -0,0 +1,37 @@ +package config + +import ( + "io" +) + +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 + } +} diff --git a/internal/ghrepo/repo.go b/internal/ghrepo/repo.go index a4bfa82d6..dc3115b90 100644 --- a/internal/ghrepo/repo.go +++ b/internal/ghrepo/repo.go @@ -6,6 +6,7 @@ import ( "strings" ) +// TODO these are sprinkled across command, context, config, and ghrepo const defaultHostname = "github.com" // Interface describes an object that represents a GitHub repository diff --git a/test/fixtures/issueView_preview.json b/test/fixtures/issueView_preview.json index db971d849..e25090a61 100644 --- a/test/fixtures/issueView_preview.json +++ b/test/fixtures/issueView_preview.json @@ -11,12 +11,20 @@ "author": { "login": "marseilles" }, + "assignees": { + "nodes": [], + "totalcount": 0 + }, "labels": { - "nodes": [ - { - "name": "tarot" - } - ] + "nodes": [], + "totalcount": 0 + }, + "projectcards": { + "nodes": [], + "totalcount": 0 + }, + "milestone": { + "title": "" }, "comments": { "totalCount": 9 diff --git a/test/fixtures/issueView_previewNoLabel.json b/test/fixtures/issueView_previewNoLabel.json deleted file mode 100644 index 1891100eb..000000000 --- a/test/fixtures/issueView_previewNoLabel.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "data": { - "repository": { - "hasIssuesEnabled": true, - "issue": { - "number": 123, - "body": "**bold story**", - "title": "ix of coins", - "state": "OPEN", - "created_at": "2011-01-26T19:01:12Z", - "author": { - "login": "marseilles" - }, - "labels": { - "nodes": [ - ] - }, - "comments": { - "totalCount": 9 - }, - "url": "https://github.com/OWNER/REPO/issues/123" - } - } - } -} diff --git a/test/fixtures/issueView_previewWithMetadata.json b/test/fixtures/issueView_previewWithMetadata.json new file mode 100644 index 000000000..a420eec66 --- /dev/null +++ b/test/fixtures/issueView_previewWithMetadata.json @@ -0,0 +1,84 @@ +{ + "data": { + "repository": { + "hasIssuesEnabled": true, + "issue": { + "number": 123, + "body": "**bold story**", + "title": "ix of coins", + "state": "OPEN", + "created_at": "2011-01-26T19:01:12Z", + "author": { + "login": "marseilles" + }, + "assignees": { + "nodes": [ + { + "login": "marseilles" + }, + { + "login": "monaco" + } + ], + "totalcount": 2 + }, + "labels": { + "nodes": [ + { + "name": "one" + }, + { + "name": "two" + }, + { + "name": "three" + }, + { + "name": "four" + }, + { + "name": "five" + } + ], + "totalcount": 5 + }, + "projectcards": { + "nodes": [ + { + "project": { + "name": "Project 1" + }, + "column": { + "name": "column A" + } + }, + { + "project": { + "name": "Project 2" + }, + "column": { + "name": "column B" + } + }, + { + "project": { + "name": "Project 3" + }, + "column": { + "name": "column C" + } + } + ], + "totalcount": 3 + }, + "milestone": { + "title": "uluru" + }, + "comments": { + "totalcount": 9 + }, + "url": "https://github.com/OWNER/REPO/issues/123" + } + } + } +} diff --git a/utils/utils.go b/utils/utils.go index 045faca2c..2efc5a95d 100644 --- a/utils/utils.go +++ b/utils/utils.go @@ -75,7 +75,5 @@ func Humanize(s string) string { } func Spinner(w io.Writer) *spinner.Spinner { - s := spinner.New(spinner.CharSets[11], 400*time.Millisecond) - s.Writer = w - return s + return spinner.New(spinner.CharSets[11], 400*time.Millisecond, spinner.WithWriter(w)) }