diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 557f87049..2789ade34 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -23,5 +23,5 @@ jobs: - name: Build shell: bash run: | - go test ./... + go test -race ./... go build -v ./cmd/gh diff --git a/README.md b/README.md index d731a3cb6..3259510a2 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# gh - The GitHub CLI tool +# GitHub CLI `gh` is GitHub on the command line, and it's now available in beta. It brings pull requests, issues, and other GitHub concepts to the terminal next to where you are already working with `git` and your code. @@ -24,15 +24,16 @@ And if you spot bugs or have features that you'd really like to see in `gh`, ple - `gh repo [view, create, clone, fork]` - `gh help` -Check out the [docs][] for more information. +## Documentation +Read the [official docs](https://cli.github.com/manual/) for more information. ## Comparison with hub For many years, [hub][] was the unofficial GitHub CLI tool. `gh` is a new project for us to explore what an official GitHub CLI tool can look like with a fundamentally different design. While both tools bring GitHub to the terminal, `hub` behaves as a proxy to `git` and `gh` is a standalone -tool. +tool. Check out our [more detailed explanation](/docs/gh-vs-hub.md) to learn more. ## Installation and Upgrading @@ -83,7 +84,14 @@ 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 -### Fedora/Centos Linux +### Fedora Linux + +Install and upgrade: + +1. Download the `.rpm` file from the [releases page][] +2. `sudo dnf install gh_*_linux_amd64.rpm` install the downloaded file + +### Centos Linux Install and upgrade: @@ -109,7 +117,7 @@ $ yay -S github-cli Install a prebuilt binary from the [releases page][] -### [Build from source](/source.md) +### [Build from source](/docs/source.md) [docs]: https://cli.github.com/manual [scoop]: https://scoop.sh diff --git a/api/fake_http.go b/api/fake_http.go index 773567aaf..92b904bd5 100644 --- a/api/fake_http.go +++ b/api/fake_http.go @@ -13,7 +13,7 @@ import ( // FakeHTTP provides a mechanism by which to stub HTTP responses through type FakeHTTP struct { - // Requests stores references to sequental requests that RoundTrip has received + // Requests stores references to sequential requests that RoundTrip has received Requests []*http.Request count int responseStubs []*http.Response diff --git a/api/queries_issue.go b/api/queries_issue.go index ae56bfab2..ae27878a6 100644 --- a/api/queries_issue.go +++ b/api/queries_issue.go @@ -171,7 +171,7 @@ func IssueStatus(client *Client, repo ghrepo.Interface, currentUsername string) return &payload, nil } -func IssueList(client *Client, repo ghrepo.Interface, state string, labels []string, assigneeString string, limit int) (*IssuesAndTotalCount, error) { +func IssueList(client *Client, repo ghrepo.Interface, state string, labels []string, assigneeString string, limit int, authorString string) (*IssuesAndTotalCount, error) { var states []string switch state { case "open", "": @@ -185,10 +185,10 @@ func IssueList(client *Client, repo ghrepo.Interface, state string, labels []str } query := fragments + ` - query($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $labels: [String!], $assignee: String) { + query($owner: String!, $repo: String!, $limit: Int, $endCursor: String, $states: [IssueState!] = OPEN, $labels: [String!], $assignee: String, $author: String) { repository(owner: $owner, name: $repo) { hasIssuesEnabled - issues(first: $limit, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, labels: $labels, filterBy: {assignee: $assignee}) { + issues(first: $limit, after: $endCursor, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, labels: $labels, filterBy: {assignee: $assignee, createdBy: $author}) { totalCount nodes { ...issue @@ -213,6 +213,9 @@ func IssueList(client *Client, repo ghrepo.Interface, state string, labels []str if assigneeString != "" { variables["assignee"] = assigneeString } + if authorString != "" { + variables["author"] = authorString + } var response struct { Repository struct { diff --git a/api/queries_issue_test.go b/api/queries_issue_test.go index bde6b8696..c9db7ead6 100644 --- a/api/queries_issue_test.go +++ b/api/queries_issue_test.go @@ -38,7 +38,7 @@ func TestIssueList(t *testing.T) { } } } `)) - _, err := IssueList(client, ghrepo.FromFullName("OWNER/REPO"), "open", []string{}, "", 251) + _, err := IssueList(client, ghrepo.FromFullName("OWNER/REPO"), "open", []string{}, "", 251, "") if err != nil { t.Fatalf("unexpected error: %v", err) } diff --git a/api/queries_pr.go b/api/queries_pr.go index 6e459ce58..be86dc679 100644 --- a/api/queries_pr.go +++ b/api/queries_pr.go @@ -10,7 +10,7 @@ import ( type PullRequestsPayload struct { ViewerCreated PullRequestAndTotalCount ReviewRequested PullRequestAndTotalCount - CurrentPRs []PullRequest + CurrentPR *PullRequest } type PullRequestAndTotalCount struct { @@ -262,13 +262,12 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu reviewRequested = append(reviewRequested, edge.Node) } - var currentPRs []PullRequest - if resp.Repository.PullRequest != nil { - currentPRs = append(currentPRs, *resp.Repository.PullRequest) - } else { + var currentPR = resp.Repository.PullRequest + if currentPR == nil { for _, edge := range resp.Repository.PullRequests.Edges { if edge.Node.HeadLabel() == currentPRHeadRef { - currentPRs = append(currentPRs, edge.Node) + currentPR = &edge.Node + break // Take the most recent PR for the current branch } } } @@ -282,7 +281,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu PullRequests: reviewRequested, TotalCount: resp.ReviewRequested.TotalCount, }, - CurrentPRs: currentPRs, + CurrentPR: currentPR, } return &payload, nil @@ -507,6 +506,7 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) (*P } }` + var check = make(map[int]struct{}) var prs []PullRequest pageLimit := min(limit, 100) variables := map[string]interface{}{} @@ -583,7 +583,12 @@ loop: } for _, edge := range prData.Edges { + if _, exists := check[edge.Node.Number]; exists { + continue + } + prs = append(prs, edge.Node) + check[edge.Node.Number] = struct{}{} if len(prs) == limit { break loop } diff --git a/api/queries_repo.go b/api/queries_repo.go index 1c5bfe826..048b72b50 100644 --- a/api/queries_repo.go +++ b/api/queries_repo.go @@ -224,7 +224,7 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) { type RepoCreateInput struct { Name string `json:"name"` Visibility string `json:"visibility"` - Homepage string `json:"homepage,omitempty"` + HomepageURL string `json:"homepageUrl,omitempty"` Description string `json:"description,omitempty"` OwnerID string `json:"ownerId,omitempty"` diff --git a/api/queries_repo_test.go b/api/queries_repo_test.go new file mode 100644 index 000000000..454814fb4 --- /dev/null +++ b/api/queries_repo_test.go @@ -0,0 +1,45 @@ +package api + +import ( + "bytes" + "encoding/json" + "io/ioutil" + "testing" +) + +func Test_RepoCreate(t *testing.T) { + http := &FakeHTTP{} + client := NewClient(ReplaceTripper(http)) + + http.StubResponse(200, bytes.NewBufferString(`{}`)) + + input := RepoCreateInput{ + Description: "roasted chesnuts", + HomepageURL: "http://example.com", + } + + _, err := RepoCreate(client, input) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(http.Requests) != 1 { + t.Fatalf("expected 1 HTTP request, seen %d", len(http.Requests)) + } + + var reqBody struct { + Query string + Variables struct { + Input map[string]interface{} + } + } + + bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body) + json.Unmarshal(bodyBytes, &reqBody) + if description := reqBody.Variables.Input["description"].(string); description != "roasted chesnuts" { + t.Errorf("expected description to be %q, got %q", "roasted chesnuts", description) + } + if homepage := reqBody.Variables.Input["homepageUrl"].(string); homepage != "http://example.com" { + t.Errorf("expected homepageUrl to be %q, got %q", "http://example.com", homepage) + } +} diff --git a/command/completion.go b/command/completion.go index d225082ef..94c9ccf5d 100644 --- a/command/completion.go +++ b/command/completion.go @@ -9,21 +9,21 @@ import ( func init() { RootCmd.AddCommand(completionCmd) - completionCmd.Flags().StringP("shell", "s", "bash", "The type of shell") + completionCmd.Flags().StringP("shell", "s", "bash", "Shell type: {bash|zsh|fish|powershell}") } var completionCmd = &cobra.Command{ - Use: "completion", - Hidden: true, - Short: "Generates completion scripts", - Long: `To enable completion in your shell, run: + Use: "completion", + Short: "Generate shell completion scripts", + Long: `Generate shell completion scripts for GitHub CLI commands. - eval "$(gh completion)" +For example, for bash you could add this to your '~/.bash_profile': -You can add that to your '~/.bash_profile' to enable completion whenever you -start a new shell. + eval "$(gh completion)" -When installing with Homebrew, see https://docs.brew.sh/Shell-Completion +When installing GitHub CLI through a package manager, however, it's possible that +no additional shell configuration is necessary to gain completion support. For +Homebrew, see `, RunE: func(cmd *cobra.Command, args []string) error { shellType, err := cmd.Flags().GetString("shell") @@ -36,6 +36,8 @@ When installing with Homebrew, see https://docs.brew.sh/Shell-Completion return RootCmd.GenBashCompletion(cmd.OutOrStdout()) case "zsh": return RootCmd.GenZshCompletion(cmd.OutOrStdout()) + case "powershell": + return RootCmd.GenPowerShellCompletion(cmd.OutOrStdout()) case "fish": return cobrafish.GenCompletion(RootCmd, cmd.OutOrStdout()) default: diff --git a/command/completion_test.go b/command/completion_test.go index e8a15db56..49ca8c4db 100644 --- a/command/completion_test.go +++ b/command/completion_test.go @@ -38,6 +38,17 @@ func TestCompletion_fish(t *testing.T) { } } +func TestCompletion_powerShell(t *testing.T) { + output, err := RunCommand(completionCmd, `completion -s powershell`) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(output.String(), "Register-ArgumentCompleter") { + t.Errorf("problem in fish completion:\n%s", output) + } +} + func TestCompletion_unsupported(t *testing.T) { _, err := RunCommand(completionCmd, `completion -s csh`) if err == nil || err.Error() != `unsupported shell type "csh"` { diff --git a/command/issue.go b/command/issue.go index 84b66964f..db05c90a6 100644 --- a/command/issue.go +++ b/command/issue.go @@ -23,7 +23,6 @@ import ( func init() { RootCmd.AddCommand(issueCmd) issueCmd.AddCommand(issueStatusCmd) - issueCmd.AddCommand(issueViewCmd) issueCmd.AddCommand(issueCreateCmd) issueCreateCmd.Flags().StringP("title", "t", "", @@ -37,7 +36,9 @@ func init() { issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label") issueListCmd.Flags().StringP("state", "s", "open", "Filter by state: {open|closed|all}") issueListCmd.Flags().IntP("limit", "L", 30, "Maximum number of issues to fetch") + issueListCmd.Flags().StringP("author", "A", "", "Filter by author") + issueCmd.AddCommand(issueViewCmd) issueViewCmd.Flags().BoolP("preview", "p", false, "Display preview of issue content") } @@ -66,7 +67,7 @@ var issueStatusCmd = &cobra.Command{ RunE: issueStatus, } var issueViewCmd = &cobra.Command{ - Use: "view { | | }", + Use: "view { | }", Args: func(cmd *cobra.Command, args []string) error { if len(args) < 1 { return FlagError{errors.New("issue number or URL required as argument")} @@ -109,7 +110,12 @@ func issueList(cmd *cobra.Command, args []string) error { return err } - listResult, err := api.IssueList(apiClient, baseRepo, state, labels, assignee, limit) + author, err := cmd.Flags().GetString("author") + if err != nil { + return err + } + + listResult, err := api.IssueList(apiClient, baseRepo, state, labels, assignee, limit, author) if err != nil { return err } @@ -117,7 +123,7 @@ func issueList(cmd *cobra.Command, args []string) error { hasFilters := false cmd.Flags().Visit(func(f *pflag.Flag) { switch f.Name { - case "state", "label", "assignee": + case "state", "label", "assignee", "author": hasFilters = true } }) diff --git a/command/issue_test.go b/command/issue_test.go index dd9581984..ae54adcc8 100644 --- a/command/issue_test.go +++ b/command/issue_test.go @@ -141,7 +141,7 @@ func TestIssueList_withFlags(t *testing.T) { } } } `)) - output, err := RunCommand(issueListCmd, "issue list -a probablyCher -l web,bug -s open") + output, err := RunCommand(issueListCmd, "issue list -a probablyCher -l web,bug -s open -A foo") if err != nil { t.Errorf("error running command `issue list`: %v", err) } @@ -158,6 +158,7 @@ No issues match your search in OWNER/REPO Assignee string Labels []string States []string + Author string } }{} json.Unmarshal(bodyBytes, &reqBody) @@ -165,6 +166,7 @@ No issues match your search in OWNER/REPO eq(t, reqBody.Variables.Assignee, "probablyCher") eq(t, reqBody.Variables.Labels, []string{"web", "bug"}) eq(t, reqBody.Variables.States, []string{"OPEN"}) + eq(t, reqBody.Variables.Author, "foo") } func TestIssueList_nullAssigneeLabels(t *testing.T) { diff --git a/command/pr.go b/command/pr.go index 781b1d914..a1076353d 100644 --- a/command/pr.go +++ b/command/pr.go @@ -98,8 +98,8 @@ func prStatus(cmd *cobra.Command, args []string) error { fmt.Fprintln(out, "") printHeader(out, "Current branch") - if prPayload.CurrentPRs != nil { - printPrs(out, 0, prPayload.CurrentPRs...) + if prPayload.CurrentPR != nil { + printPrs(out, 0, *prPayload.CurrentPR) } else { message := fmt.Sprintf(" There is no pull request associated with %s", utils.Cyan("["+currentPRHeadRef+"]")) printMessage(out, message) @@ -386,45 +386,51 @@ func printPrs(w io.Writer, totalCount int, prs ...api.PullRequest) { for _, pr := range prs { prNumber := fmt.Sprintf("#%d", pr.Number) - prNumberColorFunc := utils.Green + prStateColorFunc := utils.Green if pr.IsDraft { - prNumberColorFunc = utils.Gray + prStateColorFunc = utils.Gray } else if pr.State == "MERGED" { - prNumberColorFunc = utils.Magenta + prStateColorFunc = utils.Magenta } else if pr.State == "CLOSED" { - prNumberColorFunc = utils.Red + prStateColorFunc = utils.Red } - fmt.Fprintf(w, " %s %s %s", prNumberColorFunc(prNumber), text.Truncate(50, replaceExcessiveWhitespace(pr.Title)), utils.Cyan("["+pr.HeadLabel()+"]")) + fmt.Fprintf(w, " %s %s %s", prStateColorFunc(prNumber), text.Truncate(50, replaceExcessiveWhitespace(pr.Title)), utils.Cyan("["+pr.HeadLabel()+"]")) checks := pr.ChecksStatus() reviews := pr.ReviewStatus() - if checks.Total > 0 || reviews.ChangesRequested || reviews.Approved { - fmt.Fprintf(w, "\n ") - } - if checks.Total > 0 { - var summary string - if checks.Failing > 0 { - if checks.Failing == checks.Total { - summary = utils.Red("All checks failing") - } else { - summary = utils.Red(fmt.Sprintf("%d/%d checks failing", checks.Failing, checks.Total)) - } - } else if checks.Pending > 0 { - summary = utils.Yellow("Checks pending") - } else if checks.Passing == checks.Total { - summary = utils.Green("Checks passing") + if pr.State == "OPEN" { + if checks.Total > 0 || reviews.ChangesRequested || reviews.Approved { + fmt.Fprintf(w, "\n ") } - fmt.Fprintf(w, " - %s", summary) - } - if reviews.ChangesRequested { - fmt.Fprintf(w, " - %s", utils.Red("Changes requested")) - } else if reviews.ReviewRequired { - fmt.Fprintf(w, " - %s", utils.Yellow("Review required")) - } else if reviews.Approved { - fmt.Fprintf(w, " - %s", utils.Green("Approved")) + if checks.Total > 0 { + var summary string + if checks.Failing > 0 { + if checks.Failing == checks.Total { + summary = utils.Red("All checks failing") + } else { + summary = utils.Red(fmt.Sprintf("%d/%d checks failing", checks.Failing, checks.Total)) + } + } else if checks.Pending > 0 { + summary = utils.Yellow("Checks pending") + } else if checks.Passing == checks.Total { + summary = utils.Green("Checks passing") + } + fmt.Fprintf(w, " - %s", summary) + } + + if reviews.ChangesRequested { + fmt.Fprintf(w, " - %s", utils.Red("Changes requested")) + } else if reviews.ReviewRequired { + fmt.Fprintf(w, " - %s", utils.Yellow("Review required")) + } else if reviews.Approved { + fmt.Fprintf(w, " - %s", utils.Green("Approved")) + } + } else { + s := strings.Title(strings.ToLower(pr.State)) + fmt.Fprintf(w, " - %s", prStateColorFunc(s)) } fmt.Fprint(w, "\n") diff --git a/command/pr_create.go b/command/pr_create.go index c40f5d8c9..3ebbfaa61 100644 --- a/command/pr_create.go +++ b/command/pr_create.go @@ -1,6 +1,7 @@ package command import ( + "errors" "fmt" "net/url" "time" @@ -41,6 +42,7 @@ func prCreate(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("could not determine the current branch: %w", err) } + headRepo, headRepoErr := repoContext.HeadRepo() baseBranch, err := cmd.Flags().GetString("base") if err != nil { @@ -49,70 +51,13 @@ func prCreate(cmd *cobra.Command, _ []string) error { if baseBranch == "" { baseBranch = baseRepo.DefaultBranchRef.Name } - - didForkRepo := false - var headRemote *context.Remote - headRepo, err := repoContext.HeadRepo() - if err != nil { - if baseRepo.IsPrivate { - return fmt.Errorf("cannot write to private repository '%s'", ghrepo.FullName(baseRepo)) - } - headRepo, err = api.ForkRepo(client, baseRepo) - if err != nil { - return fmt.Errorf("error forking repo: %w", err) - } - didForkRepo = true - // TODO: support non-HTTPS git remote URLs - baseRepoURL := fmt.Sprintf("https://github.com/%s.git", ghrepo.FullName(baseRepo)) - headRepoURL := fmt.Sprintf("https://github.com/%s.git", ghrepo.FullName(headRepo)) - // TODO: figure out what to name the new git remote - gitRemote, err := git.AddRemote("fork", baseRepoURL, headRepoURL) - if err != nil { - return fmt.Errorf("error adding remote: %w", err) - } - headRemote = &context.Remote{ - Remote: gitRemote, - Owner: headRepo.RepoOwner(), - Repo: headRepo.RepoName(), - } - } - - if headBranch == baseBranch && ghrepo.IsSame(baseRepo, headRepo) { + if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) { return fmt.Errorf("must be on a branch named differently than %q", baseBranch) } - if headRemote == nil { - headRemote, err = repoContext.RemoteForRepo(headRepo) - if err != nil { - return fmt.Errorf("git remote not found for head repository: %w", err) - } - } - if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 { fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change")) } - pushTries := 0 - maxPushTries := 3 - for { - // TODO: respect existing upstream configuration of the current branch - if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", headBranch)); err != nil { - if didForkRepo && pushTries < maxPushTries { - pushTries++ - // first wait 2 seconds after forking, then 4s, then 6s - waitSeconds := 2 * pushTries - fmt.Fprintf(cmd.ErrOrStderr(), "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second")) - time.Sleep(time.Duration(waitSeconds) * time.Second) - continue - } - return err - } - break - } - - headBranchLabel := headBranch - if !ghrepo.IsSame(baseRepo, headRepo) { - headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch) - } title, err := cmd.Flags().GetString("title") if err != nil { @@ -127,22 +72,19 @@ func prCreate(cmd *cobra.Command, _ []string) error { if err != nil { return fmt.Errorf("could not parse web: %q", err) } - if isWeb { - compareURL := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body) - fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(compareURL)) - return utils.OpenInBrowser(compareURL) - } - - fmt.Fprintf(colorableErr(cmd), "\nCreating pull request for %s into %s in %s\n\n", - utils.Cyan(headBranchLabel), - utils.Cyan(baseBranch), - ghrepo.FullName(baseRepo)) action := SubmitAction + if isWeb { + action = PreviewAction + } else { + fmt.Fprintf(colorableErr(cmd), "\nCreating pull request for %s into %s in %s\n\n", + utils.Cyan(headBranch), + utils.Cyan(baseBranch), + ghrepo.FullName(baseRepo)) + } - interactive := title == "" || body == "" - - if interactive { + // TODO: only drop into interactive mode if stdin & stdout are a tty + if !isWeb && (title == "" || body == "") { var templateFiles []string if rootDir, err := git.ToplevelDir(); err == nil { // TODO: figure out how to stub this in tests @@ -169,27 +111,81 @@ func prCreate(cmd *cobra.Command, _ []string) error { } } + if action == SubmitAction && title == "" { + return errors.New("pull request title must not be blank") + } + isDraft, err := cmd.Flags().GetBool("draft") if err != nil { return fmt.Errorf("could not parse draft: %w", err) } + if isDraft && isWeb { + return errors.New("the --draft flag is not supported with --web") + } + + didForkRepo := false + var headRemote *context.Remote + if headRepoErr != nil { + if baseRepo.IsPrivate { + return fmt.Errorf("cannot fork private repository '%s'", ghrepo.FullName(baseRepo)) + } + headRepo, err = api.ForkRepo(client, baseRepo) + if err != nil { + return fmt.Errorf("error forking repo: %w", err) + } + didForkRepo = true + // TODO: support non-HTTPS git remote URLs + baseRepoURL := fmt.Sprintf("https://github.com/%s.git", ghrepo.FullName(baseRepo)) + headRepoURL := fmt.Sprintf("https://github.com/%s.git", ghrepo.FullName(headRepo)) + // TODO: figure out what to name the new git remote + gitRemote, err := git.AddRemote("fork", baseRepoURL, headRepoURL) + if err != nil { + return fmt.Errorf("error adding remote: %w", err) + } + headRemote = &context.Remote{ + Remote: gitRemote, + Owner: headRepo.RepoOwner(), + Repo: headRepo.RepoName(), + } + } + + headBranchLabel := headBranch + if !ghrepo.IsSame(baseRepo, headRepo) { + headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch) + } + + if headRemote == nil { + headRemote, err = repoContext.RemoteForRepo(headRepo) + if err != nil { + return fmt.Errorf("git remote not found for head repository: %w", err) + } + } + + pushTries := 0 + maxPushTries := 3 + for { + // TODO: respect existing upstream configuration of the current branch + if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", headBranch)); err != nil { + if didForkRepo && pushTries < maxPushTries { + pushTries++ + // first wait 2 seconds after forking, then 4s, then 6s + waitSeconds := 2 * pushTries + fmt.Fprintf(cmd.ErrOrStderr(), "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second")) + time.Sleep(time.Duration(waitSeconds) * time.Second) + continue + } + return err + } + break + } if action == SubmitAction { - if title == "" { - return fmt.Errorf("pull request title must not be blank") - } - - headRefName := headBranch - if !ghrepo.IsSame(headRemote, baseRepo) { - headRefName = fmt.Sprintf("%s:%s", headRemote.RepoOwner(), headBranch) - } - params := map[string]interface{}{ "title": title, "body": body, "draft": isDraft, "baseRefName": baseBranch, - "headRefName": headRefName, + "headRefName": headBranchLabel, } pr, err := api.CreatePullRequest(client, baseRepo, params) @@ -208,7 +204,6 @@ func prCreate(cmd *cobra.Command, _ []string) error { } return nil - } func generateCompareURL(r ghrepo.Interface, base, head, title, body string) string { diff --git a/command/pr_test.go b/command/pr_test.go index 6ed9106ea..84282e37e 100644 --- a/command/pr_test.go +++ b/command/pr_test.go @@ -155,6 +155,65 @@ func TestPRStatus_reviewsAndChecks(t *testing.T) { } } +func TestPRStatus_closedMerged(t *testing.T) { + initBlankContext("OWNER/REPO", "blueberries") + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + + jsonFile, _ := os.Open("../test/fixtures/prStatusClosedMerged.json") + defer jsonFile.Close() + http.StubResponse(200, jsonFile) + + output, err := RunCommand(prStatusCmd, "pr status") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expected := []string{ + "- Checks passing - Changes requested", + "- Closed", + "- Merged", + } + + for _, line := range expected { + if !strings.Contains(output.String(), line) { + t.Errorf("output did not contain %q: %q", line, output.String()) + } + } +} + +func TestPRStatus_currentBranch_showTheMostRecentPR(t *testing.T) { + initBlankContext("OWNER/REPO", "blueberries") + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + + jsonFile, _ := os.Open("../test/fixtures/prStatusCurrentBranch.json") + defer jsonFile.Close() + http.StubResponse(200, jsonFile) + + output, err := RunCommand(prStatusCmd, "pr status") + if err != nil { + t.Errorf("error running command `pr status`: %v", err) + } + + expectedLine := regexp.MustCompile(`#10 Blueberries are certainly a good fruit \[blueberries\]`) + if !expectedLine.MatchString(output.String()) { + t.Errorf("output did not match regexp /%s/\n> output\n%s\n", expectedLine, output) + return + } + + unexpectedLines := []*regexp.Regexp{ + regexp.MustCompile(`#9 Blueberries are a good fruit \[blueberries\] - Merged`), + regexp.MustCompile(`#8 Blueberries are probably a good fruit \[blueberries\] - Closed`), + } + for _, r := range unexpectedLines { + if r.MatchString(output.String()) { + t.Errorf("output unexpectedly match regexp /%s/\n> output\n%s\n", r, output) + return + } + } +} + func TestPRStatus_blankSlate(t *testing.T) { initBlankContext("OWNER/REPO", "blueberries") http := initFakeHTTP() @@ -243,6 +302,26 @@ No pull requests match your search in OWNER/REPO eq(t, reqBody.Variables.Labels, []string{"one", "two", "three"}) } +func TestPRList_filteringRemoveDuplicate(t *testing.T) { + initBlankContext("OWNER/REPO", "master") + http := initFakeHTTP() + http.StubRepoResponse("OWNER", "REPO") + + jsonFile, _ := os.Open("../test/fixtures/prListWithDuplicates.json") + defer jsonFile.Close() + http.StubResponse(200, jsonFile) + + output, err := RunCommand(prListCmd, "pr list -l one,two") + if err != nil { + t.Fatal(err) + } + + eq(t, output.String(), `32 New feature feature +29 Fixed bad bug hubot:bug-fix +28 Improve documentation docs +`) +} + func TestPRList_filteringClosed(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() diff --git a/command/repo.go b/command/repo.go index 53b2ca8f8..1dc9b13a5 100644 --- a/command/repo.go +++ b/command/repo.go @@ -158,7 +158,7 @@ func repoCreate(cmd *cobra.Command, args []string) error { OwnerID: orgName, TeamID: teamSlug, Description: description, - Homepage: homepage, + HomepageURL: homepage, HasIssuesEnabled: hasIssuesEnabled, HasWikiEnabled: hasWikiEnabled, } @@ -386,23 +386,40 @@ var Confirm = func(prompt string, result *bool) error { func repoView(cmd *cobra.Command, args []string) error { ctx := contextForCommand(cmd) - - var openURL string + var toView ghrepo.Interface if len(args) == 0 { - baseRepo, err := determineBaseRepo(cmd, ctx) + var err error + toView, err = determineBaseRepo(cmd, ctx) if err != nil { return err } - openURL = fmt.Sprintf("https://github.com/%s", ghrepo.FullName(baseRepo)) } else { repoArg := args[0] if isURL(repoArg) { - openURL = repoArg + parsedURL, err := url.Parse(repoArg) + if err != nil { + return fmt.Errorf("did not understand argument: %w", err) + } + + toView, err = ghrepo.FromURL(parsedURL) + if err != nil { + return fmt.Errorf("did not understand argument: %w", err) + } } else { - openURL = fmt.Sprintf("https://github.com/%s", repoArg) + toView = ghrepo.FromFullName(repoArg) } } + apiClient, err := apiClientForContext(ctx) + if err != nil { + return err + } + _, err = api.GitHubRepo(apiClient, toView) + if err != nil { + return err + } + + openURL := fmt.Sprintf("https://github.com/%s", ghrepo.FullName(toView)) fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL)) return utils.OpenInBrowser(openURL) } diff --git a/command/repo_test.go b/command/repo_test.go index da64712b7..a3ba656c3 100644 --- a/command/repo_test.go +++ b/command/repo_test.go @@ -579,10 +579,14 @@ func TestRepoCreate_orgWithTeam(t *testing.T) { } } + func TestRepoView(t *testing.T) { initBlankContext("OWNER/REPO", "master") http := initFakeHTTP() http.StubRepoResponse("OWNER", "REPO") + http.StubResponse(200, bytes.NewBufferString(` + { } + `)) var seenCmd *exec.Cmd restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { @@ -612,7 +616,10 @@ func TestRepoView_ownerRepo(t *testing.T) { initContext = func() context.Context { return ctx } - initFakeHTTP() + http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { } + `)) var seenCmd *exec.Cmd restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { @@ -642,8 +649,10 @@ func TestRepoView_fullURL(t *testing.T) { initContext = func() context.Context { return ctx } - initFakeHTTP() - + http := initFakeHTTP() + http.StubResponse(200, bytes.NewBufferString(` + { } + `)) var seenCmd *exec.Cmd restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable { seenCmd = cmd diff --git a/command/root.go b/command/root.go index 7b0d078ef..cae4bab4e 100644 --- a/command/root.go +++ b/command/root.go @@ -134,9 +134,7 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) { api.AddHeader("Authorization", fmt.Sprintf("token %s", token)), api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)), // antiope-preview: Checks - // shadow-cat-preview: Draft pull requests - api.AddHeader("Accept", "application/vnd.github.antiope-preview+json, application/vnd.github.shadow-cat-preview"), - api.AddHeader("GraphQL-Features", "pe_mobile"), + api.AddHeader("Accept", "application/vnd.github.antiope-preview+json"), ) return api.NewClient(opts...), nil diff --git a/context/context.go b/context/context.go index af4dd65fb..6c3bd5aee 100644 --- a/context/context.go +++ b/context/context.go @@ -25,7 +25,7 @@ type Context interface { } // cap the number of git remotes looked up, since the user might have an -// unusally large number of git remotes +// unusually large number of git remotes const maxRemotesForLookup = 5 func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (ResolvedRemotes, error) { diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..35faf36a6 --- /dev/null +++ b/docs/README.md @@ -0,0 +1 @@ +This folder is used for documentation related to developing `gh`. Docs for `gh` installation and usage are available at [https://cli.github.com/manual](https://cli.github.com/manual). \ No newline at end of file diff --git a/docs/gh-vs-hub.md b/docs/gh-vs-hub.md new file mode 100644 index 000000000..207a0a956 --- /dev/null +++ b/docs/gh-vs-hub.md @@ -0,0 +1,27 @@ +# GitHub CLI & `hub` + +[GitHub CLI](https://cli.github.com/) (`gh`) was [announced in early 2020](https://github.blog/2020-02-12-supercharge-your-command-line-experience-github-cli-is-now-in-beta/) and provides a more seamless way to interact with your GitHub repositories from the command line. We also know that many people are interested in the very similar [`hub`](https://hub.github.com/) project, so we wanted to clarify some potential points of confusion. + +## Why didn’t you just build `gh` on top of `hub`? + +We wrestled with the decision of whether to continue building onto `hub` and adopt it as an official GitHub project. In weighing different possibilities, we decided to start fresh without the constraints of 10 years of design decisions that `hub` has baked in and without the assumption that `hub` can be safely aliased to `git`. We also wanted to be more opinionated and focused on GitHub workflows, and doing this with `hub` had the risk of alienating many `hub` users who love the existing tool and expected it to work in the way they were used to. + +## What’s next for `hub`? + +The GitHub CLI team is focused solely on building out the new tool, `gh`. We aren’t shutting down `hub` or doing anything to change it. It’s an open source project and will continue to exist as long as it’s maintained and keeps receiving contributions. + +## What does it mean that GitHub CLI is official and `hub` is unofficial? + +GitHub CLI is built and maintained by a team of people who work on the tool on behalf of GitHub. When there’s something wrong with it, people can reach out to GitHub support or create an issue in the issue tracker, where an employee at GitHub will respond. + +`hub` is a project whose maintainer also happens to be a GitHub employee. He chooses to maintain `hub` in his spare time, as many of our employees do with open source projects. + +## Should I use `gh` or `hub`? + +We have no interest in forcing anyone to use GitHub CLI instead of `hub`. We think people should use whatever set of tools makes them happiest and most productive working with GitHub. + +If you are set on using a tool that acts as a wrapper for Git itself, `hub` is likely a better choice than `gh`. `hub` currently covers a larger overall surface area of GitHub’s API v3, provides more scripting functionality, and is compatible with GitHub Enterprise (though these are all things that we intend to improve in GitHub CLI). + +If you want a tool that’s more opinionated and intended to help simplify your GitHub workflows from the command line, we hope you’ll use `gh`. And since `gh` is maintained by a team at GitHub, we intend to be responsive to people’s concerns and needs and improve the tool based on how people are using it over time. + +GitHub CLI is not intended to be an exact replacement for `hub` and likely never will be, but our hope is that the vast majority of GitHub users who use the CLI will find more and more value in using `gh` as we continue to improve it. diff --git a/releasing.md b/docs/releasing.md similarity index 100% rename from releasing.md rename to docs/releasing.md diff --git a/source.md b/docs/source.md similarity index 100% rename from source.md rename to docs/source.md diff --git a/test/fixtures/prListWithDuplicates.json b/test/fixtures/prListWithDuplicates.json new file mode 100644 index 000000000..a84800f72 --- /dev/null +++ b/test/fixtures/prListWithDuplicates.json @@ -0,0 +1,50 @@ +{ + "data": { + "repository": { + "pullRequests": { + "edges": [ + { + "node": { + "number": 32, + "title": "New feature", + "url": "https://github.com/monalisa/hello/pull/32", + "headRefName": "feature" + } + }, + { + "node": { + "number": 32, + "title": "New feature", + "url": "https://github.com/monalisa/hello/pull/32", + "headRefName": "feature" + } + }, + { + "node": { + "number": 29, + "title": "Fixed bad bug", + "url": "https://github.com/monalisa/hello/pull/29", + "headRefName": "bug-fix", + "isCrossRepository": true, + "headRepositoryOwner": { + "login": "hubot" + } + } + }, + { + "node": { + "number": 28, + "title": "Improve documentation", + "url": "https://github.com/monalisa/hello/pull/28", + "headRefName": "docs" + } + } + ], + "pageInfo": { + "hasNextPage": false, + "endCursor": "" + } + } + } + } +} diff --git a/test/fixtures/prStatusChecks.json b/test/fixtures/prStatusChecks.json index 922fe8ce7..55035ae36 100644 --- a/test/fixtures/prStatusChecks.json +++ b/test/fixtures/prStatusChecks.json @@ -13,6 +13,7 @@ "node": { "number": 8, "title": "Strawberries are not actually berries", + "state": "OPEN", "url": "https://github.com/cli/cli/pull/8", "headRefName": "strawberries", "reviewDecision": "CHANGES_REQUESTED", @@ -39,6 +40,7 @@ "node": { "number": 7, "title": "Bananas are berries", + "state": "OPEN", "url": "https://github.com/cli/cli/pull/7", "headRefName": "banananana", "reviewDecision": "APPROVED", @@ -66,6 +68,7 @@ "node": { "number": 6, "title": "Avocado is probably not a berry", + "state": "OPEN", "url": "https://github.com/cli/cli/pull/6", "headRefName": "avo", "reviewDecision": "REVIEW_REQUIRED", diff --git a/test/fixtures/prStatusClosedMerged.json b/test/fixtures/prStatusClosedMerged.json new file mode 100644 index 000000000..03e31637e --- /dev/null +++ b/test/fixtures/prStatusClosedMerged.json @@ -0,0 +1,65 @@ +{ + "data": { + "repository": { + "pullRequests": { + "totalCount": 1, + "edges": [ + { + "node": { + "number": 8, + "title": "Blueberries are a good fruit", + "state": "OPEN", + "url": "https://github.com/cli/cli/pull/8", + "headRefName": "blueberries", + "reviewDecision": "CHANGES_REQUESTED", + "commits": { + "nodes": [ + { + "commit": { + "statusCheckRollup": { + "contexts": { + "nodes": [ + { + "state": "SUCCESS" + } + ] + } + } + } + } + ] + } + } + } + ] + } + }, + "viewerCreated": { + "totalCount": 1, + "edges": [ + { + "node": { + "number": 10, + "state": "CLOSED", + "title": "Strawberries are not actually berries", + "url": "https://github.com/cli/cli/pull/10", + "headRefName": "strawberries" + } + }, + { + "node": { + "number": 9, + "state": "MERGED", + "title": "Bananas are berries", + "url": "https://github.com/cli/cli/pull/9", + "headRefName": "banananana" + } + } + ] + }, + "reviewRequested": { + "totalCount": 0, + "edges": [] + } + } +} diff --git a/test/fixtures/prStatusCurrentBranch.json b/test/fixtures/prStatusCurrentBranch.json new file mode 100644 index 000000000..d024d48c3 --- /dev/null +++ b/test/fixtures/prStatusCurrentBranch.json @@ -0,0 +1,61 @@ +{ + "data": { + "repository": { + "pullRequests": { + "totalCount": 3, + "edges": [ + { + "node": { + "number": 10, + "title": "Blueberries are certainly a good fruit", + "state": "OPEN", + "url": "https://github.com/PARENT/REPO/pull/10", + "headRefName": "blueberries", + "isDraft": false, + "headRepositoryOwner": { + "login": "OWNER/REPO" + }, + "isCrossRepository": false + } + }, + { + "node": { + "number": 9, + "title": "Blueberries are a good fruit", + "state": "MERGED", + "url": "https://github.com/PARENT/REPO/pull/9", + "headRefName": "blueberries", + "isDraft": false, + "headRepositoryOwner": { + "login": "OWNER/REPO" + }, + "isCrossRepository": false + } + }, + { + "node": { + "number": 8, + "title": "Blueberries are probably a good fruit", + "state": "CLOSED", + "url": "https://github.com/PARENT/REPO/pull/8", + "headRefName": "blueberries", + "isDraft": false, + "headRepositoryOwner": { + "login": "OWNER/REPO" + }, + "isCrossRepository": false + } + } + ] + } + }, + "viewerCreated": { + "totalCount": 0, + "edges": [] + }, + "reviewRequested": { + "totalCount": 0, + "edges": [] + } + } +}