Merge remote-tracking branch 'origin/master' into pr-count

This commit is contained in:
UmairShahzad 2020-03-14 19:02:51 +05:00
commit f51669e228
27 changed files with 552 additions and 159 deletions

View file

@ -23,5 +23,5 @@ jobs:
- name: Build
shell: bash
run: |
go test ./...
go test -race ./...
go build -v ./cmd/gh

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

45
api/queries_repo_test.go Normal file
View file

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

View file

@ -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 <https://docs.brew.sh/Shell-Completion>
`,
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:

View file

@ -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"` {

View file

@ -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 {<number> | <url> | <branch>}",
Use: "view {<number> | <url>}",
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
}
})

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

1
docs/README.md Normal file
View file

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

27
docs/gh-vs-hub.md Normal file
View file

@ -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 didnt 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.
## Whats next for `hub`?
The GitHub CLI team is focused solely on building out the new tool, `gh`. We arent shutting down `hub` or doing anything to change it. Its an open source project and will continue to exist as long as its 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 theres 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 GitHubs 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 thats more opinionated and intended to help simplify your GitHub workflows from the command line, we hope youll use `gh`. And since `gh` is maintained by a team at GitHub, we intend to be responsive to peoples 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.

50
test/fixtures/prListWithDuplicates.json vendored Normal file
View file

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

View file

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

65
test/fixtures/prStatusClosedMerged.json vendored Normal file
View file

@ -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": []
}
}
}

View file

@ -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": []
}
}
}