Merge remote-tracking branch 'origin/master' into wingkwong/master

This commit is contained in:
vilmibm 2020-05-13 13:43:09 -05:00
commit ed1a3a60fd
109 changed files with 11109 additions and 1648 deletions

View file

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

22
.github/workflows/codeql.yml vendored Normal file
View file

@ -0,0 +1,22 @@
name: Code Scanning
on:
push:
schedule:
- cron: "0 0 * * 0"
jobs:
CodeQL-Build:
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v2
- name: Initialize CodeQL
uses: github/codeql-action/init@v1
with:
languages: go
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v1

View file

@ -9,19 +9,19 @@ jobs:
runs-on: ${{ matrix.os }}
steps:
- name: Set up Go 1.13
uses: actions/setup-go@v1
- name: Set up Go 1.14
uses: actions/setup-go@v2-beta
with:
go-version: 1.13
go-version: 1.14
- name: Check out code into the Go module directory
- name: Check out code
uses: actions/checkout@v2
- name: Verify dependencies
run: go mod verify
- name: Download dependencies
run: go mod download
- name: Run tests
run: go test -race ./...
- name: Build
shell: bash
run: |
go test ./...
go build -v ./cmd/gh
run: go build -v ./cmd/gh

54
.github/workflows/lint.yml vendored Normal file
View file

@ -0,0 +1,54 @@
name: Lint
on:
push:
paths:
- "**.go"
- go.mod
- go.sum
jobs:
lint:
runs-on: ubuntu-latest
steps:
- name: Set up Go 1.14
uses: actions/setup-go@v2-beta
with:
go-version: 1.14
- name: Check out code
uses: actions/checkout@v2
- name: Verify dependencies
run: |
go mod verify
go mod download
LINT_VERSION=1.26.0
curl -fsSL https://github.com/golangci/golangci-lint/releases/download/v${LINT_VERSION}/golangci-lint-${LINT_VERSION}-linux-amd64.tar.gz | \
tar xz --strip-components 1 --wildcards \*/golangci-lint
mkdir -p bin && mv golangci-lint bin/
- name: Run checks
run: |
STATUS=0
assert-nothing-changed() {
local diff
"$@" >/dev/null || return 1
if ! diff="$(git diff -U1 --color --exit-code)"; then
printf '\e[31mError: running `\e[1m%s\e[22m` results in modifications that you must check into version control:\e[0m\n%s\n\n' "$*" "$diff" >&2
git checkout -- .
STATUS=1
fi
}
assert-nothing-changed go fmt ./...
assert-nothing-changed go mod tidy
while read -r file linter msg; do
IFS=: read -ra f <<<"$file"
printf '::error file=%s,line=%s,col=%s::%s\n' "${f[0]}" "${f[1]}" "${f[2]}" "[$linter] $msg"
STATUS=1
done < <(bin/golangci-lint run --out-format tab)
exit $STATUS

View file

@ -11,12 +11,13 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v2
- name: Set up Go 1.13
uses: actions/setup-go@v1
- name: Set up Go 1.14
uses: actions/setup-go@v2-beta
with:
go-version: 1.13
go-version: 1.14
- name: Generate changelog
run: |
echo ::set-env name=GORELEASER_CURRENT_TAG::${GITHUB_REF#refs/tags/}
git fetch --unshallow
script/changelog | tee CHANGELOG.md
- name: Run GoReleaser
@ -26,6 +27,35 @@ jobs:
args: release --release-notes=CHANGELOG.md
env:
GITHUB_TOKEN: ${{secrets.UPLOAD_GITHUB_TOKEN}}
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@v1
if: "!contains(github.ref, '-')" # skip prereleases
with:
formula-name: gh
env:
COMMITTER_TOKEN: ${{ secrets.UPLOAD_GITHUB_TOKEN }}
- name: Checkout documentation site
if: "!contains(github.ref, '-')" # skip prereleases
uses: actions/checkout@v2
with:
repository: github/cli.github.com
path: site
fetch-depth: 0
token: ${{secrets.SITE_GITHUB_TOKEN}}
- name: Publish documentation site
if: "!contains(github.ref, '-')" # skip prereleases
run: make site-publish
- name: Move project cards
if: "!contains(github.ref, '-')" # skip prereleases
env:
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
PENDING_COLUMN: 8189733
DONE_COLUMN: 7110130
run: |
curl -fsSL https://github.com/github/hub/raw/master/script/get | bash -s 2.14.1
api() { bin/hub api -H 'accept: application/vnd.github.inertia-preview+json' "$@"; }
cards=$(api projects/columns/$PENDING_COLUMN/cards | jq ".[].id")
for card in $cards; do api projects/columns/cards/$card/moves --field position=top --field column_id=$DONE_COLUMN; done
msi:
needs: goreleaser
runs-on: windows-latest

3
.gitignore vendored
View file

@ -9,6 +9,9 @@
# VS Code
.vscode
# IntelliJ
.idea
# macOS
.DS_Store

View file

@ -23,7 +23,7 @@ test:
.PHONY: test
site:
git worktree add site gh-pages
git clone https://github.com/github/cli.github.com.git "$@"
site-docs: site
git -C site pull
@ -32,6 +32,15 @@ site-docs: site
for f in site/manual/gh*.md; do sed -i.bak -e '/^### SEE ALSO/,$$d' "$$f"; done
rm -f site/manual/*.bak
git -C site add 'manual/gh*.md'
git -C site commit -m 'update help docs'
git -C site push
git -C site commit -m 'update help docs' || true
.PHONY: site-docs
site-publish: site-docs
ifndef GITHUB_REF
$(error GITHUB_REF is not set)
endif
sed -i.bak -E 's/(assign version = )".+"/\1"$(GITHUB_REF:refs/tags/v%=%)"/' site/index.html
rm -f site/index.html.bak
git -C site commit -m '$(GITHUB_REF:refs/tags/v%=%)' index.html
git -C site push
.PHONY: site-publish

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.
@ -7,7 +7,7 @@ the terminal next to where you are already working with `git` and your code.
## Availability
While in beta, GitHub CLI is available for repos hosted on GitHub.com only. It does not currently support repositories hosted on GitHub Enterprise Server or other hosting providers.
While in beta, GitHub CLI is available for repos hosted on GitHub.com only. It does not currently support repositories hosted on GitHub Enterprise Server or other hosting providers. We are planning support for GitHub Enterprise Server after GitHub CLI is out of beta (likely toward the end of 2020), and we want to ensure that the API endpoints we use are more widely available for GHES versions that most GitHub customers are on.
## We need your feedback
@ -21,26 +21,40 @@ And if you spot bugs or have features that you'd really like to see in `gh`, ple
- `gh pr [status, list, view, checkout, create]`
- `gh issue [status, list, view, create]`
- `gh repo [view, create, clone, fork]`
- `gh config [get, set]`
- `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
<!-- this anchor is linked to from elsewhere, so avoid renaming it -->
## Installation
### macOS
`gh` is available via Homebrew and MacPorts.
#### Homebrew
Install: `brew install github/gh/gh`
Upgrade: `brew update && brew upgrade gh`
Upgrade: `brew upgrade gh`
#### MacPorts
Install: `sudo port install gh`
Upgrade: `sudo port selfupdate && sudo port upgrade gh`
### Windows
@ -80,9 +94,16 @@ 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/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:
@ -108,7 +129,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

@ -37,6 +37,16 @@ func AddHeader(name, value string) ClientOption {
}
}
// AddHeaderFunc is an AddHeader that gets the string value from a function
func AddHeaderFunc(name string, value func() string) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
req.Header.Add(name, value())
return tr.RoundTrip(req)
}}
}
}
// VerboseLog enables request/response logging within a RoundTripper
func VerboseLog(out io.Writer, logTraffic bool, colorize bool) ClientOption {
logger := &httpretty.Logger{
@ -63,6 +73,40 @@ func ReplaceTripper(tr http.RoundTripper) ClientOption {
}
}
var issuedScopesWarning bool
// CheckScopes checks whether an OAuth scope is present in a response
func CheckScopes(wantedScope string, cb func(string) error) ClientOption {
return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
res, err := tr.RoundTrip(req)
if err != nil || res.StatusCode > 299 || issuedScopesWarning {
return res, err
}
appID := res.Header.Get("X-Oauth-Client-Id")
hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",")
hasWanted := false
for _, s := range hasScopes {
if wantedScope == strings.TrimSpace(s) {
hasWanted = true
break
}
}
if !hasWanted {
if err := cb(appID); err != nil {
return res, err
}
issuedScopesWarning = true
}
return res, nil
}}
}
}
type funcTripper struct {
roundTrip func(*http.Request) (*http.Response, error)
}
@ -146,6 +190,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

View file

@ -5,6 +5,8 @@ import (
"io/ioutil"
"reflect"
"testing"
"github.com/cli/cli/pkg/httpmock"
)
func eq(t *testing.T, got interface{}, expected interface{}) {
@ -15,7 +17,7 @@ func eq(t *testing.T, got interface{}, expected interface{}) {
}
func TestGraphQL(t *testing.T) {
http := &FakeHTTP{}
http := &httpmock.Registry{}
client := NewClient(
ReplaceTripper(http),
AddHeader("Authorization", "token OTOKEN"),
@ -40,7 +42,7 @@ func TestGraphQL(t *testing.T) {
}
func TestGraphQLError(t *testing.T) {
http := &FakeHTTP{}
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
response := struct{}{}
@ -50,3 +52,17 @@ func TestGraphQLError(t *testing.T) {
t.Fatalf("got %q", err.Error())
}
}
func TestRESTGetDelete(t *testing.T) {
http := &httpmock.Registry{}
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)
}

View file

@ -1,58 +0,0 @@
package api
import (
"bytes"
"fmt"
"io"
"io/ioutil"
"net/http"
)
// 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 []*http.Request
count int
responseStubs []*http.Response
}
// StubResponse pre-records an HTTP response
func (f *FakeHTTP) StubResponse(status int, body io.Reader) {
resp := &http.Response{
StatusCode: status,
Body: ioutil.NopCloser(body),
}
f.responseStubs = append(f.responseStubs, resp)
}
// RoundTrip satisfies http.RoundTripper
func (f *FakeHTTP) RoundTrip(req *http.Request) (*http.Response, error) {
if len(f.responseStubs) <= f.count {
return nil, fmt.Errorf("FakeHTTP: missing response stub for request %d", f.count)
}
resp := f.responseStubs[f.count]
f.count++
resp.Request = req
f.Requests = append(f.Requests, req)
return resp, nil
}
func (f *FakeHTTP) StubRepoResponse(owner, repo string) {
body := bytes.NewBufferString(fmt.Sprintf(`
{ "data": { "repo_000": {
"id": "REPOID",
"name": "%s",
"owner": {"login": "%s"},
"defaultBranchRef": {
"name": "master",
"target": {"oid": "deadbeef"}
},
"viewerPermission": "WRITE"
} } }
`, repo, owner))
resp := &http.Response{
StatusCode: 200,
Body: ioutil.NopCloser(body),
}
f.responseStubs = append(f.responseStubs, resp)
}

View file

@ -22,7 +22,9 @@ func TestPullRequest_ChecksStatus(t *testing.T) {
{ "status": "COMPLETED",
"conclusion": "FAILURE" },
{ "status": "COMPLETED",
"conclusion": "ACTION_REQUIRED" }
"conclusion": "ACTION_REQUIRED" },
{ "status": "COMPLETED",
"conclusion": "STALE" }
]
}
}
@ -32,8 +34,8 @@ func TestPullRequest_ChecksStatus(t *testing.T) {
eq(t, err, nil)
checks := pr.ChecksStatus()
eq(t, checks.Total, 7)
eq(t, checks.Pending, 2)
eq(t, checks.Total, 8)
eq(t, checks.Pending, 3)
eq(t, checks.Failing, 3)
eq(t, checks.Passing, 2)
}

View file

@ -1,10 +1,12 @@
package api
import (
"context"
"fmt"
"time"
"github.com/cli/cli/internal/ghrepo"
"github.com/shurcooL/githubv4"
)
type IssuesPayload struct {
@ -18,12 +20,16 @@ type IssuesAndTotalCount struct {
TotalCount int
}
// Ref. https://developer.github.com/v4/object/issue/
type Issue struct {
ID string
Number int
Title string
URL string
State string
Closed bool
Body string
CreatedAt time.Time
UpdatedAt time.Time
Comments struct {
TotalCount int
@ -31,15 +37,36 @@ type Issue struct {
Author struct {
Login string
}
Labels struct {
Nodes []IssueLabel
Assignees struct {
Nodes []struct {
Login string
}
TotalCount int
}
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
}
}
type IssueLabel struct {
Name string
type IssuesDisabledError struct {
error
}
const fragments = `
@ -171,7 +198,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) ([]Issue, 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,20 +212,24 @@ func IssueList(client *Client, repo ghrepo.Interface, state string, labels []str
}
query := fragments + `
query($owner: String!, $repo: String!, $limit: Int, $states: [IssueState!] = OPEN, $labels: [String!], $assignee: String) {
repository(owner: $owner, name: $repo) {
hasIssuesEnabled
issues(first: $limit, orderBy: {field: CREATED_AT, direction: DESC}, states: $states, labels: $labels, filterBy: {assignee: $assignee}) {
nodes {
...issue
}
}
}
}
`
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, createdBy: $author}) {
totalCount
nodes {
...issue
}
pageInfo {
hasNextPage
endCursor
}
}
}
}
`
variables := map[string]interface{}{
"limit": limit,
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"states": states,
@ -209,26 +240,55 @@ func IssueList(client *Client, repo ghrepo.Interface, state string, labels []str
if assigneeString != "" {
variables["assignee"] = assigneeString
}
if authorString != "" {
variables["author"] = authorString
}
var resp struct {
var response struct {
Repository struct {
Issues struct {
Nodes []Issue
TotalCount int
Nodes []Issue
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
HasIssuesEnabled bool
}
}
err := client.GraphQL(query, variables, &resp)
if err != nil {
return nil, err
var issues []Issue
pageLimit := min(limit, 100)
loop:
for {
variables["limit"] = pageLimit
err := client.GraphQL(query, variables, &response)
if err != nil {
return nil, err
}
if !response.Repository.HasIssuesEnabled {
return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))
}
for _, issue := range response.Repository.Issues.Nodes {
issues = append(issues, issue)
if len(issues) == limit {
break loop
}
}
if response.Repository.Issues.PageInfo.HasNextPage {
variables["endCursor"] = response.Repository.Issues.PageInfo.EndCursor
pageLimit = min(pageLimit, limit-len(issues))
} else {
break
}
}
if !resp.Repository.HasIssuesEnabled {
return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))
}
return resp.Repository.Issues.Nodes, nil
res := IssuesAndTotalCount{Issues: issues, TotalCount: response.Repository.Issues.TotalCount}
return &res, nil
}
func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, error) {
@ -244,7 +304,10 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
repository(owner: $owner, name: $repo) {
hasIssuesEnabled
issue(number: $issue_number) {
id
title
state
closed
body
author {
login
@ -252,13 +315,35 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
comments {
totalCount
}
labels(first: 3) {
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
}
number
url
}
}
}`
@ -276,8 +361,51 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
}
if !resp.Repository.HasIssuesEnabled {
return nil, fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))
return nil, &IssuesDisabledError{fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(repo))}
}
return &resp.Repository.Issue, nil
}
func IssueClose(client *Client, repo ghrepo.Interface, issue Issue) error {
var mutation struct {
CloseIssue struct {
Issue struct {
ID githubv4.ID
}
} `graphql:"closeIssue(input: $input)"`
}
input := githubv4.CloseIssueInput{
IssueID: issue.ID,
}
v4 := githubv4.NewClient(client.http)
err := v4.Mutate(context.Background(), &mutation, input, nil)
if err != nil {
return err
}
return nil
}
func IssueReopen(client *Client, repo ghrepo.Interface, issue Issue) error {
var mutation struct {
ReopenIssue struct {
Issue struct {
ID githubv4.ID
}
} `graphql:"reopenIssue(input: $input)"`
}
input := githubv4.ReopenIssueInput{
IssueID: issue.ID,
}
v4 := githubv4.NewClient(client.http)
err := v4.Mutate(context.Background(), &mutation, input, nil)
return err
}

69
api/queries_issue_test.go Normal file
View file

@ -0,0 +1,69 @@
package api
import (
"bytes"
"encoding/json"
"io/ioutil"
"testing"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/httpmock"
)
func TestIssueList(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": {
"nodes": [],
"pageInfo": {
"hasNextPage": true,
"endCursor": "ENDCURSOR"
}
}
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issues": {
"nodes": [],
"pageInfo": {
"hasNextPage": false,
"endCursor": "ENDCURSOR"
}
}
} } }
`))
_, err := IssueList(client, ghrepo.FromFullName("OWNER/REPO"), "open", []string{}, "", 251, "")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(http.Requests) != 2 {
t.Fatalf("expected 2 HTTP requests, seen %d", len(http.Requests))
}
var reqBody struct {
Query string
Variables map[string]interface{}
}
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
_ = json.Unmarshal(bodyBytes, &reqBody)
if reqLimit := reqBody.Variables["limit"].(float64); reqLimit != 100 {
t.Errorf("expected 100, got %v", reqLimit)
}
if _, cursorPresent := reqBody.Variables["endCursor"]; cursorPresent {
t.Error("did not expect first request to pass 'endCursor'")
}
bodyBytes, _ = ioutil.ReadAll(http.Requests[1].Body)
_ = json.Unmarshal(bodyBytes, &reqBody)
if endCursor := reqBody.Variables["endCursor"].(string); endCursor != "ENDCURSOR" {
t.Errorf("expected %q, got %q", "ENDCURSOR", endCursor)
}
}

110
api/queries_org.go Normal file
View file

@ -0,0 +1,110 @@
package api
import (
"context"
"fmt"
"github.com/shurcooL/githubv4"
)
// using API v3 here because the equivalent in GraphQL needs `read:org` scope
func resolveOrganization(client *Client, orgName string) (string, error) {
var response struct {
NodeID string `json:"node_id"`
}
err := client.REST("GET", fmt.Sprintf("users/%s", orgName), nil, &response)
return response.NodeID, err
}
// using API v3 here because the equivalent in GraphQL needs `read:org` scope
func resolveOrganizationTeam(client *Client, orgName, teamSlug string) (string, string, error) {
var response struct {
NodeID string `json:"node_id"`
Organization struct {
NodeID string `json:"node_id"`
}
}
err := client.REST("GET", fmt.Sprintf("orgs/%s/teams/%s", orgName, teamSlug), nil, &response)
return response.Organization.NodeID, response.NodeID, err
}
// OrganizationProjects fetches all open projects for an organization
func OrganizationProjects(client *Client, owner string) ([]RepoProject, error) {
var query struct {
Organization struct {
Projects struct {
Nodes []RepoProject
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"projects(states: [OPEN], first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"`
} `graphql:"organization(login: $owner)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(owner),
"endCursor": (*githubv4.String)(nil),
}
v4 := githubv4.NewClient(client.http)
var projects []RepoProject
for {
err := v4.Query(context.Background(), &query, variables)
if err != nil {
return nil, err
}
projects = append(projects, query.Organization.Projects.Nodes...)
if !query.Organization.Projects.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Organization.Projects.PageInfo.EndCursor)
}
return projects, nil
}
type OrgTeam struct {
ID string
Slug string
}
// OrganizationTeams fetches all the teams in an organization
func OrganizationTeams(client *Client, owner string) ([]OrgTeam, error) {
var query struct {
Organization struct {
Teams struct {
Nodes []OrgTeam
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"teams(first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"`
} `graphql:"organization(login: $owner)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(owner),
"endCursor": (*githubv4.String)(nil),
}
v4 := githubv4.NewClient(client.http)
var teams []OrgTeam
for {
err := v4.Query(context.Background(), &query, variables)
if err != nil {
return nil, err
}
teams = append(teams, query.Organization.Teams.Nodes...)
if !query.Organization.Teams.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Organization.Teams.PageInfo.EndCursor)
}
return teams, nil
}

View file

@ -1,16 +1,33 @@
package api
import (
"context"
"fmt"
"strings"
"github.com/shurcooL/githubv4"
"github.com/cli/cli/internal/ghrepo"
)
type PullRequestReviewState int
const (
ReviewApprove PullRequestReviewState = iota
ReviewRequestChanges
ReviewComment
)
type PullRequestReviewInput struct {
Body string
State PullRequestReviewState
}
type PullRequestsPayload struct {
ViewerCreated PullRequestAndTotalCount
ReviewRequested PullRequestAndTotalCount
CurrentPR *PullRequest
DefaultBranch string
}
type PullRequestAndTotalCount struct {
@ -19,9 +36,11 @@ type PullRequestAndTotalCount struct {
}
type PullRequest struct {
ID string
Number int
Title string
State string
Closed bool
URL string
BaseRefName string
HeadRefName string
@ -40,6 +59,7 @@ type PullRequest struct {
}
}
IsCrossRepository bool
IsDraft bool
MaintainerCanModify bool
ReviewDecision string
@ -60,6 +80,50 @@ type PullRequest struct {
}
}
}
ReviewRequests struct {
Nodes []struct {
RequestedReviewer struct {
TypeName string `json:"__typename"`
Login string
Name string
}
}
TotalCount int
}
Reviews struct {
Nodes []struct {
Author struct {
Login string
}
State string
}
}
Assignees struct {
Nodes []struct {
Login string
}
TotalCount int
}
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
}
}
type NotFoundError struct {
@ -79,8 +143,16 @@ type PullRequestReviewStatus struct {
ReviewRequired bool
}
type PullRequestMergeMethod int
const (
PullRequestMergeMethodMerge PullRequestMergeMethod = iota
PullRequestMergeMethodRebase
PullRequestMergeMethodSquash
)
func (pr *PullRequest) ReviewStatus() PullRequestReviewStatus {
status := PullRequestReviewStatus{}
var status PullRequestReviewStatus
switch pr.ReviewDecision {
case "CHANGES_REQUESTED":
status.ChangesRequested = true
@ -119,7 +191,7 @@ func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
summary.Passing++
case "ERROR", "FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED":
summary.Failing++
case "EXPECTED", "REQUESTED", "QUEUED", "PENDING", "IN_PROGRESS":
case "EXPECTED", "REQUESTED", "QUEUED", "PENDING", "IN_PROGRESS", "STALE":
summary.Pending++
default:
panic(fmt.Errorf("unsupported status: %q", state))
@ -139,6 +211,9 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
type response struct {
Repository struct {
DefaultBranchRef struct {
Name string
}
PullRequests edges
PullRequest *PullRequest
}
@ -150,12 +225,14 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
fragment pr on PullRequest {
number
title
state
url
headRefName
headRepositoryOwner {
login
}
isCrossRepository
isDraft
commits(last: 1) {
nodes {
commit {
@ -185,7 +262,8 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
queryPrefix := `
query($owner: String!, $repo: String!, $headRefName: String!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) {
repository(owner: $owner, name: $repo) {
pullRequests(headRefName: $headRefName, states: OPEN, first: $per_page) {
defaultBranchRef { name }
pullRequests(headRefName: $headRefName, first: $per_page, orderBy: { field: CREATED_AT, direction: DESC }) {
totalCount
edges {
node {
@ -199,6 +277,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
queryPrefix = `
query($owner: String!, $repo: String!, $number: Int!, $viewerQuery: String!, $reviewerQuery: String!, $per_page: Int = 10) {
repository(owner: $owner, name: $repo) {
defaultBranchRef { name }
pullRequest(number: $number) {
...prWithReviews
}
@ -264,6 +343,7 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
for _, edge := range resp.Repository.PullRequests.Edges {
if edge.Node.HeadLabel() == currentPRHeadRef {
currentPR = &edge.Node
break // Take the most recent PR for the current branch
}
}
}
@ -277,7 +357,8 @@ func PullRequests(client *Client, repo ghrepo.Interface, currentPRNumber int, cu
PullRequests: reviewRequested,
TotalCount: resp.ReviewRequested.TotalCount,
},
CurrentPR: currentPR,
CurrentPR: currentPR,
DefaultBranch: resp.Repository.DefaultBranchRef.Name,
}
return &payload, nil
@ -294,9 +375,12 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
query($owner: String!, $repo: String!, $pr_number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr_number) {
id
url
number
title
state
closed
body
author {
login
@ -316,7 +400,57 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
}
}
isCrossRepository
isDraft
maintainerCanModify
reviewRequests(first: 100) {
nodes {
requestedReviewer {
__typename
...on User {
login
}
...on Team {
name
}
}
}
totalCount
}
reviews(last: 100) {
nodes {
author {
login
}
state
}
totalCount
}
assignees(first: 100) {
nodes {
login
}
totalCount
}
labels(first: 100) {
nodes {
name
}
totalCount
}
projectCards(first: 100) {
nodes {
project {
name
}
column {
name
}
}
totalCount
}
milestone{
title
}
}
}
}`
@ -336,10 +470,11 @@ func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*Pu
return &resp.Repository.PullRequest, nil
}
func PullRequestForBranch(client *Client, repo ghrepo.Interface, branch string) (*PullRequest, error) {
func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, headBranch string) (*PullRequest, error) {
type response struct {
Repository struct {
PullRequests struct {
ID githubv4.ID
Nodes []PullRequest
}
}
@ -350,8 +485,10 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, branch string)
repository(owner: $owner, name: $repo) {
pullRequests(headRefName: $headRefName, states: OPEN, first: 30) {
nodes {
id
number
title
state
body
author {
login
@ -366,14 +503,64 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, branch string)
login
}
isCrossRepository
isDraft
reviewRequests(first: 100) {
nodes {
requestedReviewer {
__typename
...on User {
login
}
...on Team {
name
}
}
}
totalCount
}
reviews(last: 100) {
nodes {
author {
login
}
state
}
totalCount
}
assignees(first: 100) {
nodes {
login
}
totalCount
}
labels(first: 100) {
nodes {
name
}
totalCount
}
projectCards(first: 100) {
nodes {
project {
name
}
column {
name
}
}
totalCount
}
milestone{
title
}
}
}
}
}`
branchWithoutOwner := branch
if idx := strings.Index(branch, ":"); idx >= 0 {
branchWithoutOwner = branch[idx+1:]
branchWithoutOwner := headBranch
if idx := strings.Index(headBranch, ":"); idx >= 0 {
branchWithoutOwner = headBranch[idx+1:]
}
variables := map[string]interface{}{
@ -389,12 +576,17 @@ func PullRequestForBranch(client *Client, repo ghrepo.Interface, branch string)
}
for _, pr := range resp.Repository.PullRequests.Nodes {
if pr.HeadLabel() == branch {
if pr.HeadLabel() == headBranch {
if baseBranch != "" {
if pr.BaseRefName != baseBranch {
continue
}
}
return &pr, nil
}
}
return nil, &NotFoundError{fmt.Errorf("no open pull requests found for branch %q", branch)}
return nil, &NotFoundError{fmt.Errorf("no open pull requests found for branch %q", headBranch)}
}
// CreatePullRequest creates a pull request in a GitHub repository
@ -403,6 +595,7 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
mutation CreatePullRequest($input: CreatePullRequestInput!) {
createPullRequest(input: $input) {
pullRequest {
id
url
}
}
@ -412,7 +605,10 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
"repositoryId": repo.ID,
}
for key, val := range params {
inputParams[key] = val
switch key {
case "title", "body", "draft", "baseRefName", "headRefName":
inputParams[key] = val
}
}
variables := map[string]interface{}{
"input": inputParams,
@ -428,11 +624,102 @@ func CreatePullRequest(client *Client, repo *Repository, params map[string]inter
if err != nil {
return nil, err
}
pr := &result.CreatePullRequest.PullRequest
return &result.CreatePullRequest.PullRequest, nil
// metadata parameters aren't currently available in `createPullRequest`,
// but they are in `updatePullRequest`
updateParams := make(map[string]interface{})
for key, val := range params {
switch key {
case "assigneeIds", "labelIds", "projectIds", "milestoneId":
if !isBlank(val) {
updateParams[key] = val
}
}
}
if len(updateParams) > 0 {
updateQuery := `
mutation UpdatePullRequest($input: UpdatePullRequestInput!) {
updatePullRequest(input: $input) { clientMutationId }
}`
updateParams["pullRequestId"] = pr.ID
variables := map[string]interface{}{
"input": updateParams,
}
err := client.GraphQL(updateQuery, variables, &result)
if err != nil {
return nil, err
}
}
// reviewers are requested in yet another additional mutation
reviewParams := make(map[string]interface{})
if ids, ok := params["userReviewerIds"]; ok && !isBlank(ids) {
reviewParams["userIds"] = ids
}
if ids, ok := params["teamReviewerIds"]; ok && !isBlank(ids) {
reviewParams["teamIds"] = ids
}
if len(reviewParams) > 0 {
reviewQuery := `
mutation RequestReviews($input: RequestReviewsInput!) {
requestReviews(input: $input) { clientMutationId }
}`
reviewParams["pullRequestId"] = pr.ID
reviewParams["union"] = true
variables := map[string]interface{}{
"input": reviewParams,
}
err := client.GraphQL(reviewQuery, variables, &result)
if err != nil {
return nil, err
}
}
return pr, nil
}
func PullRequestList(client *Client, vars map[string]interface{}, limit int) ([]PullRequest, error) {
func isBlank(v interface{}) bool {
switch vv := v.(type) {
case string:
return vv == ""
case []string:
return len(vv) == 0
default:
return true
}
}
func AddReview(client *Client, pr *PullRequest, input *PullRequestReviewInput) error {
var mutation struct {
AddPullRequestReview struct {
ClientMutationID string
} `graphql:"addPullRequestReview(input:$input)"`
}
state := githubv4.PullRequestReviewEventComment
switch input.State {
case ReviewApprove:
state = githubv4.PullRequestReviewEventApprove
case ReviewRequestChanges:
state = githubv4.PullRequestReviewEventRequestChanges
}
body := githubv4.String(input.Body)
gqlInput := githubv4.AddPullRequestReviewInput{
PullRequestID: pr.ID,
Event: &state,
Body: &body,
}
v4 := githubv4.NewClient(client.http)
return v4.Mutate(context.Background(), &mutation, gqlInput, nil)
}
func PullRequestList(client *Client, vars map[string]interface{}, limit int) (*PullRequestAndTotalCount, error) {
type prBlock struct {
Edges []struct {
Node PullRequest
@ -441,6 +728,8 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) ([]
HasNextPage bool
EndCursor string
}
TotalCount int
IssueCount int
}
type response struct {
Repository struct {
@ -460,6 +749,7 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) ([]
login
}
isCrossRepository
isDraft
}
`
@ -483,23 +773,26 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) ([]
first: $limit,
after: $endCursor,
orderBy: {field: CREATED_AT, direction: DESC}
) {
edges {
node {
...pr
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
) {
totalCount
edges {
node {
...pr
}
}
pageInfo {
hasNextPage
endCursor
}
}
}
}`
var check = make(map[int]struct{})
var prs []PullRequest
pageLimit := min(limit, 100)
variables := map[string]interface{}{}
res := PullRequestAndTotalCount{}
// If assignee was specified, use the `search` API rather than
// `Repository.pullRequests`, but this mode doesn't support multiple labels
@ -511,6 +804,7 @@ func PullRequestList(client *Client, vars map[string]interface{}, limit int) ([]
$endCursor: String,
) {
search(query: $q, type: ISSUE, first: $limit, after: $endCursor) {
issueCount
edges {
node {
...pr
@ -564,12 +858,19 @@ loop:
return nil, err
}
prData := data.Repository.PullRequests
res.TotalCount = prData.TotalCount
if _, ok := variables["q"]; ok {
prData = data.Search
res.TotalCount = prData.IssueCount
}
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
}
@ -582,8 +883,74 @@ loop:
break
}
}
res.PullRequests = prs
return &res, nil
}
return prs, nil
func PullRequestClose(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
var mutation struct {
ClosePullRequest struct {
PullRequest struct {
ID githubv4.ID
}
} `graphql:"closePullRequest(input: $input)"`
}
input := githubv4.ClosePullRequestInput{
PullRequestID: pr.ID,
}
v4 := githubv4.NewClient(client.http)
err := v4.Mutate(context.Background(), &mutation, input, nil)
return err
}
func PullRequestReopen(client *Client, repo ghrepo.Interface, pr *PullRequest) error {
var mutation struct {
ReopenPullRequest struct {
PullRequest struct {
ID githubv4.ID
}
} `graphql:"reopenPullRequest(input: $input)"`
}
input := githubv4.ReopenPullRequestInput{
PullRequestID: pr.ID,
}
v4 := githubv4.NewClient(client.http)
err := v4.Mutate(context.Background(), &mutation, input, nil)
return err
}
func PullRequestMerge(client *Client, repo ghrepo.Interface, pr *PullRequest, m PullRequestMergeMethod) error {
mergeMethod := githubv4.PullRequestMergeMethodMerge
switch m {
case PullRequestMergeMethodRebase:
mergeMethod = githubv4.PullRequestMergeMethodRebase
case PullRequestMergeMethodSquash:
mergeMethod = githubv4.PullRequestMergeMethodSquash
}
var mutation struct {
MergePullRequest struct {
PullRequest struct {
ID githubv4.ID
}
} `graphql:"mergePullRequest(input: $input)"`
}
input := githubv4.MergePullRequestInput{
PullRequestID: pr.ID,
MergeMethod: &mergeMethod,
}
v4 := githubv4.NewClient(client.http)
err := v4.Mutate(context.Background(), &mutation, input, nil)
return err
}
func min(a, b int) int {

View file

@ -2,28 +2,35 @@ package api
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"sort"
"strings"
"time"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/utils"
"github.com/shurcooL/githubv4"
)
// Repository contains information about a GitHub repo
type Repository struct {
ID string
Name string
Owner RepositoryOwner
ID string
Name string
Description string
URL string
CloneURL string
CreatedAt time.Time
Owner RepositoryOwner
IsPrivate bool
HasIssuesEnabled bool
ViewerPermission string
DefaultBranchRef struct {
Name string
Target struct {
OID string
}
Name string
}
Parent *Repository
@ -59,13 +66,24 @@ func (r Repository) ViewerCanPush() bool {
}
}
// GitHubRepo looks up the node ID of a named repository
// ViewerCanTriage is true when the requesting user can triage issues and pull requests
func (r Repository) ViewerCanTriage() bool {
switch r.ViewerPermission {
case "ADMIN", "MAINTAIN", "WRITE", "TRIAGE":
return true
default:
return false
}
}
func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
query := `
query($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {
id
hasIssuesEnabled
description
viewerPermission
}
}`
variables := map[string]interface{}{
@ -78,17 +96,44 @@ func GitHubRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
}{}
err := client.GraphQL(query, variables, &result)
if err != nil || result.Repository.ID == "" {
newErr := fmt.Errorf("failed to determine repository ID for '%s'", ghrepo.FullName(repo))
if err != nil {
newErr = fmt.Errorf("%s: %w", newErr, err)
}
return nil, newErr
if err != nil {
return nil, err
}
return &result.Repository, nil
}
// RepoParent finds out the parent repository of a fork
func RepoParent(client *Client, repo ghrepo.Interface) (ghrepo.Interface, error) {
var query struct {
Repository struct {
Parent *struct {
Name string
Owner struct {
Login string
}
}
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
}
v4 := githubv4.NewClient(client.http)
err := v4.Query(context.Background(), &query, variables)
if err != nil {
return nil, err
}
if query.Repository.Parent == nil {
return nil, nil
}
parent := ghrepo.New(query.Repository.Parent.Owner.Login, query.Repository.Parent.Name)
return parent, nil
}
// RepoNetworkResult describes the relationship between related repositories
type RepoNetworkResult struct {
ViewerLogin string
@ -97,7 +142,7 @@ type RepoNetworkResult struct {
// RepoNetwork inspects the relationship between multiple GitHub repositories
func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, error) {
queries := []string{}
queries := make([]string, 0, len(repos))
for i, repo := range repos {
queries = append(queries, fmt.Sprintf(`
repo_%03d: repository(owner: %q, name: %q) {
@ -112,8 +157,8 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e
// Since the query is constructed dynamically, we can't parse a response
// format using a static struct. Instead, hold the raw JSON data until we
// decide how to parse it manually.
graphqlResult := map[string]*json.RawMessage{}
result := RepoNetworkResult{}
graphqlResult := make(map[string]*json.RawMessage)
var result RepoNetworkResult
err := client.GraphQL(fmt.Sprintf(`
fragment repo on Repository {
@ -123,7 +168,6 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e
viewerPermission
defaultBranchRef {
name
target { oid }
}
isPrivate
}
@ -150,12 +194,12 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e
return result, err
}
keys := []string{}
keys := make([]string, 0, len(graphqlResult))
for key := range graphqlResult {
keys = append(keys, key)
}
// sort keys to ensure `repo_{N}` entries are processed in order
sort.Sort(sort.StringSlice(keys))
sort.Strings(keys)
// Iterate over keys of GraphQL response data and, based on its name,
// dynamically allocate the target struct an individual message gets decoded to.
@ -175,8 +219,8 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e
result.Repositories = append(result.Repositories, nil)
continue
}
repo := Repository{}
decoder := json.NewDecoder(bytes.NewReader([]byte(*jsonMessage)))
var repo Repository
decoder := json.NewDecoder(bytes.NewReader(*jsonMessage))
if err := decoder.Decode(&repo); err != nil {
return result, err
}
@ -190,9 +234,11 @@ func RepoNetwork(client *Client, repos []ghrepo.Interface) (RepoNetworkResult, e
// repositoryV3 is the repository result from GitHub API v3
type repositoryV3 struct {
NodeID string
Name string
Owner struct {
NodeID string
Name string
CreatedAt time.Time `json:"created_at"`
CloneURL string `json:"clone_url"`
Owner struct {
Login string
}
}
@ -208,11 +254,615 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
}
return &Repository{
ID: result.NodeID,
Name: result.Name,
ID: result.NodeID,
Name: result.Name,
CloneURL: result.CloneURL,
CreatedAt: result.CreatedAt,
Owner: RepositoryOwner{
Login: result.Owner.Login,
},
ViewerPermission: "WRITE",
}, nil
}
// RepoFindFork finds a fork of repo affiliated with the viewer
func RepoFindFork(client *Client, repo ghrepo.Interface) (*Repository, error) {
result := struct {
Repository struct {
Forks struct {
Nodes []Repository
}
}
}{}
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
}
if err := client.GraphQL(`
query($owner: String!, $repo: String!) {
repository(owner: $owner, name: $repo) {
forks(first: 1, affiliations: [OWNER, COLLABORATOR]) {
nodes {
id
name
owner { login }
url
viewerPermission
}
}
}
}
`, variables, &result); err != nil {
return nil, err
}
forks := result.Repository.Forks.Nodes
// we check ViewerCanPush, even though we expect it to always be true per
// `affiliations` condition, to guard against versions of GitHub with a
// faulty `affiliations` implementation
if len(forks) > 0 && forks[0].ViewerCanPush() {
return &forks[0], nil
}
return nil, &NotFoundError{errors.New("no fork found")}
}
// RepoCreateInput represents input parameters for RepoCreate
type RepoCreateInput struct {
Name string `json:"name"`
Visibility string `json:"visibility"`
HomepageURL string `json:"homepageUrl,omitempty"`
Description string `json:"description,omitempty"`
OwnerID string `json:"ownerId,omitempty"`
TeamID string `json:"teamId,omitempty"`
HasIssuesEnabled bool `json:"hasIssuesEnabled"`
HasWikiEnabled bool `json:"hasWikiEnabled"`
}
// RepoCreate creates a new GitHub repository
func RepoCreate(client *Client, input RepoCreateInput) (*Repository, error) {
var response struct {
CreateRepository struct {
Repository Repository
}
}
if input.TeamID != "" {
orgID, teamID, err := resolveOrganizationTeam(client, input.OwnerID, input.TeamID)
if err != nil {
return nil, err
}
input.TeamID = teamID
input.OwnerID = orgID
} else if input.OwnerID != "" {
orgID, err := resolveOrganization(client, input.OwnerID)
if err != nil {
return nil, err
}
input.OwnerID = orgID
}
variables := map[string]interface{}{
"input": input,
}
err := client.GraphQL(`
mutation($input: CreateRepositoryInput!) {
createRepository(input: $input) {
repository {
id
name
owner { login }
url
}
}
}
`, variables, &response)
if err != nil {
return nil, err
}
return &response.CreateRepository.Repository, nil
}
func RepositoryReadme(client *Client, fullName string) (string, error) {
type readmeResponse struct {
Name string
Content string
}
var readme readmeResponse
err := client.REST("GET", fmt.Sprintf("repos/%s/readme", fullName), nil, &readme)
if err != nil && !strings.HasSuffix(err.Error(), "'Not Found'") {
return "", fmt.Errorf("could not get readme for repo: %w", err)
}
decoded, err := base64.StdEncoding.DecodeString(readme.Content)
if err != nil {
return "", fmt.Errorf("failed to decode readme: %w", err)
}
readmeContent := string(decoded)
if isMarkdownFile(readme.Name) {
readmeContent, err = utils.RenderMarkdown(readmeContent)
if err != nil {
return "", fmt.Errorf("failed to render readme as markdown: %w", err)
}
}
return readmeContent, nil
}
type RepoMetadataResult struct {
AssignableUsers []RepoAssignee
Labels []RepoLabel
Projects []RepoProject
Milestones []RepoMilestone
Teams []OrgTeam
}
func (m *RepoMetadataResult) MembersToIDs(names []string) ([]string, error) {
var ids []string
for _, assigneeLogin := range names {
found := false
for _, u := range m.AssignableUsers {
if strings.EqualFold(assigneeLogin, u.Login) {
ids = append(ids, u.ID)
found = true
break
}
}
if !found {
return nil, fmt.Errorf("'%s' not found", assigneeLogin)
}
}
return ids, nil
}
func (m *RepoMetadataResult) TeamsToIDs(names []string) ([]string, error) {
var ids []string
for _, teamSlug := range names {
found := false
slug := teamSlug[strings.IndexRune(teamSlug, '/')+1:]
for _, t := range m.Teams {
if strings.EqualFold(slug, t.Slug) {
ids = append(ids, t.ID)
found = true
break
}
}
if !found {
return nil, fmt.Errorf("'%s' not found", teamSlug)
}
}
return ids, nil
}
func (m *RepoMetadataResult) LabelsToIDs(names []string) ([]string, error) {
var ids []string
for _, labelName := range names {
found := false
for _, l := range m.Labels {
if strings.EqualFold(labelName, l.Name) {
ids = append(ids, l.ID)
found = true
break
}
}
if !found {
return nil, fmt.Errorf("'%s' not found", labelName)
}
}
return ids, nil
}
func (m *RepoMetadataResult) ProjectsToIDs(names []string) ([]string, error) {
var ids []string
for _, projectName := range names {
found := false
for _, p := range m.Projects {
if strings.EqualFold(projectName, p.Name) {
ids = append(ids, p.ID)
found = true
break
}
}
if !found {
return nil, fmt.Errorf("'%s' not found", projectName)
}
}
return ids, nil
}
func (m *RepoMetadataResult) MilestoneToID(title string) (string, error) {
for _, m := range m.Milestones {
if strings.EqualFold(title, m.Title) {
return m.ID, nil
}
}
return "", errors.New("not found")
}
type RepoMetadataInput struct {
Assignees bool
Reviewers bool
Labels bool
Projects bool
Milestones bool
}
// RepoMetadata pre-fetches the metadata for attaching to issues and pull requests
func RepoMetadata(client *Client, repo ghrepo.Interface, input RepoMetadataInput) (*RepoMetadataResult, error) {
result := RepoMetadataResult{}
errc := make(chan error)
count := 0
if input.Assignees || input.Reviewers {
count++
go func() {
users, err := RepoAssignableUsers(client, repo)
if err != nil {
err = fmt.Errorf("error fetching assignees: %w", err)
}
result.AssignableUsers = users
errc <- err
}()
}
if input.Reviewers {
count++
go func() {
teams, err := OrganizationTeams(client, repo.RepoOwner())
// TODO: better detection of non-org repos
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
errc <- fmt.Errorf("error fetching organization teams: %w", err)
return
}
result.Teams = teams
errc <- nil
}()
}
if input.Labels {
count++
go func() {
labels, err := RepoLabels(client, repo)
if err != nil {
err = fmt.Errorf("error fetching labels: %w", err)
}
result.Labels = labels
errc <- err
}()
}
if input.Projects {
count++
go func() {
projects, err := RepoProjects(client, repo)
if err != nil {
errc <- fmt.Errorf("error fetching projects: %w", err)
return
}
result.Projects = projects
orgProjects, err := OrganizationProjects(client, repo.RepoOwner())
// TODO: better detection of non-org repos
if err != nil && !strings.HasPrefix(err.Error(), "Could not resolve to an Organization") {
errc <- fmt.Errorf("error fetching organization projects: %w", err)
return
}
result.Projects = append(result.Projects, orgProjects...)
errc <- nil
}()
}
if input.Milestones {
count++
go func() {
milestones, err := RepoMilestones(client, repo)
if err != nil {
err = fmt.Errorf("error fetching milestones: %w", err)
}
result.Milestones = milestones
errc <- err
}()
}
var err error
for i := 0; i < count; i++ {
if e := <-errc; e != nil {
err = e
}
}
return &result, err
}
type RepoResolveInput struct {
Assignees []string
Reviewers []string
Labels []string
Projects []string
Milestones []string
}
// RepoResolveMetadataIDs looks up GraphQL node IDs in bulk
func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoResolveInput) (*RepoMetadataResult, error) {
users := input.Assignees
hasUser := func(target string) bool {
for _, u := range users {
if strings.EqualFold(u, target) {
return true
}
}
return false
}
var teams []string
for _, r := range input.Reviewers {
if i := strings.IndexRune(r, '/'); i > -1 {
teams = append(teams, r[i+1:])
} else if !hasUser(r) {
users = append(users, r)
}
}
// there is no way to look up projects nor milestones by name, so preload them all
mi := RepoMetadataInput{
Projects: len(input.Projects) > 0,
Milestones: len(input.Milestones) > 0,
}
result, err := RepoMetadata(client, repo, mi)
if err != nil {
return result, err
}
if len(users) == 0 && len(teams) == 0 && len(input.Labels) == 0 {
return result, nil
}
query := &bytes.Buffer{}
fmt.Fprint(query, "{\n")
for i, u := range users {
fmt.Fprintf(query, "u%03d: user(login:%q){id,login}\n", i, u)
}
if len(input.Labels) > 0 {
fmt.Fprintf(query, "repository(owner:%q,name:%q){\n", repo.RepoOwner(), repo.RepoName())
for i, l := range input.Labels {
fmt.Fprintf(query, "l%03d: label(name:%q){id,name}\n", i, l)
}
fmt.Fprint(query, "}\n")
}
if len(teams) > 0 {
fmt.Fprintf(query, "organization(login:%q){\n", repo.RepoOwner())
for i, t := range teams {
fmt.Fprintf(query, "t%03d: team(slug:%q){id,slug}\n", i, t)
}
fmt.Fprint(query, "}\n")
}
fmt.Fprint(query, "}\n")
response := make(map[string]json.RawMessage)
err = client.GraphQL(query.String(), nil, &response)
if err != nil {
return result, err
}
for key, v := range response {
switch key {
case "repository":
repoResponse := make(map[string]RepoLabel)
err := json.Unmarshal(v, &repoResponse)
if err != nil {
return result, err
}
for _, l := range repoResponse {
result.Labels = append(result.Labels, l)
}
case "organization":
orgResponse := make(map[string]OrgTeam)
err := json.Unmarshal(v, &orgResponse)
if err != nil {
return result, err
}
for _, t := range orgResponse {
result.Teams = append(result.Teams, t)
}
default:
user := RepoAssignee{}
err := json.Unmarshal(v, &user)
if err != nil {
return result, err
}
result.AssignableUsers = append(result.AssignableUsers, user)
}
}
return result, nil
}
type RepoProject struct {
ID string
Name string
}
// RepoProjects fetches all open projects for a repository
func RepoProjects(client *Client, repo ghrepo.Interface) ([]RepoProject, error) {
var query struct {
Repository struct {
Projects struct {
Nodes []RepoProject
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"projects(states: [OPEN], first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"endCursor": (*githubv4.String)(nil),
}
v4 := githubv4.NewClient(client.http)
var projects []RepoProject
for {
err := v4.Query(context.Background(), &query, variables)
if err != nil {
return nil, err
}
projects = append(projects, query.Repository.Projects.Nodes...)
if !query.Repository.Projects.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.Projects.PageInfo.EndCursor)
}
return projects, nil
}
type RepoAssignee struct {
ID string
Login string
}
// RepoAssignableUsers fetches all the assignable users for a repository
func RepoAssignableUsers(client *Client, repo ghrepo.Interface) ([]RepoAssignee, error) {
var query struct {
Repository struct {
AssignableUsers struct {
Nodes []RepoAssignee
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"assignableUsers(first: 100, after: $endCursor)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"endCursor": (*githubv4.String)(nil),
}
v4 := githubv4.NewClient(client.http)
var users []RepoAssignee
for {
err := v4.Query(context.Background(), &query, variables)
if err != nil {
return nil, err
}
users = append(users, query.Repository.AssignableUsers.Nodes...)
if !query.Repository.AssignableUsers.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.AssignableUsers.PageInfo.EndCursor)
}
return users, nil
}
type RepoLabel struct {
ID string
Name string
}
// RepoLabels fetches all the labels in a repository
func RepoLabels(client *Client, repo ghrepo.Interface) ([]RepoLabel, error) {
var query struct {
Repository struct {
Labels struct {
Nodes []RepoLabel
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"labels(first: 100, orderBy: {field: NAME, direction: ASC}, after: $endCursor)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"endCursor": (*githubv4.String)(nil),
}
v4 := githubv4.NewClient(client.http)
var labels []RepoLabel
for {
err := v4.Query(context.Background(), &query, variables)
if err != nil {
return nil, err
}
labels = append(labels, query.Repository.Labels.Nodes...)
if !query.Repository.Labels.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.Labels.PageInfo.EndCursor)
}
return labels, nil
}
type RepoMilestone struct {
ID string
Title string
}
// RepoMilestones fetches all open milestones in a repository
func RepoMilestones(client *Client, repo ghrepo.Interface) ([]RepoMilestone, error) {
var query struct {
Repository struct {
Milestones struct {
Nodes []RepoMilestone
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"milestones(states: [OPEN], first: 100, after: $endCursor)"`
} `graphql:"repository(owner: $owner, name: $name)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"name": githubv4.String(repo.RepoName()),
"endCursor": (*githubv4.String)(nil),
}
v4 := githubv4.NewClient(client.http)
var milestones []RepoMilestone
for {
err := v4.Query(context.Background(), &query, variables)
if err != nil {
return nil, err
}
milestones = append(milestones, query.Repository.Milestones.Nodes...)
if !query.Repository.Milestones.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.Milestones.PageInfo.EndCursor)
}
return milestones, nil
}
func isMarkdownFile(filename string) bool {
// kind of gross, but i'm assuming that 90% of the time the suffix will just be .md. it didn't
// seem worth executing a regex for this given that assumption.
return strings.HasSuffix(filename, ".md") ||
strings.HasSuffix(filename, ".markdown") ||
strings.HasSuffix(filename, ".mdown") ||
strings.HasSuffix(filename, ".mkdown")
}

274
api/queries_repo_test.go Normal file
View file

@ -0,0 +1,274 @@
package api
import (
"bytes"
"encoding/json"
"io/ioutil"
"testing"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/httpmock"
)
func Test_RepoCreate(t *testing.T) {
http := &httpmock.Registry{}
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)
}
}
func Test_RepoMetadata(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
repo := ghrepo.FromFullName("OWNER/REPO")
input := RepoMetadataInput{
Assignees: true,
Reviewers: true,
Labels: true,
Projects: true,
Milestones: true,
}
http.Register(
httpmock.GraphQL(`\bassignableUsers\(`),
httpmock.StringResponse(`
{ "data": { "repository": { "assignableUsers": {
"nodes": [
{ "login": "hubot", "id": "HUBOTID" },
{ "login": "MonaLisa", "id": "MONAID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`\blabels\(`),
httpmock.StringResponse(`
{ "data": { "repository": { "labels": {
"nodes": [
{ "name": "feature", "id": "FEATUREID" },
{ "name": "TODO", "id": "TODOID" },
{ "name": "bug", "id": "BUGID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`\bmilestones\(`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`\brepository\(.+\bprojects\(`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "Cleanup", "id": "CLEANUPID" },
{ "name": "Roadmap", "id": "ROADMAPID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`\borganization\(.+\bprojects\(`),
httpmock.StringResponse(`
{ "data": { "organization": { "projects": {
"nodes": [
{ "name": "Triage", "id": "TRIAGEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`\borganization\(.+\bteams\(`),
httpmock.StringResponse(`
{ "data": { "organization": { "teams": {
"nodes": [
{ "slug": "owners", "id": "OWNERSID" },
{ "slug": "Core", "id": "COREID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
result, err := RepoMetadata(client, repo, input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedMemberIDs := []string{"MONAID", "HUBOTID"}
memberIDs, err := result.MembersToIDs([]string{"monalisa", "hubot"})
if err != nil {
t.Errorf("error resolving members: %v", err)
}
if !sliceEqual(memberIDs, expectedMemberIDs) {
t.Errorf("expected members %v, got %v", expectedMemberIDs, memberIDs)
}
expectedTeamIDs := []string{"COREID", "OWNERSID"}
teamIDs, err := result.TeamsToIDs([]string{"OWNER/core", "/owners"})
if err != nil {
t.Errorf("error resolving teams: %v", err)
}
if !sliceEqual(teamIDs, expectedTeamIDs) {
t.Errorf("expected teams %v, got %v", expectedTeamIDs, teamIDs)
}
expectedLabelIDs := []string{"BUGID", "TODOID"}
labelIDs, err := result.LabelsToIDs([]string{"bug", "todo"})
if err != nil {
t.Errorf("error resolving labels: %v", err)
}
if !sliceEqual(labelIDs, expectedLabelIDs) {
t.Errorf("expected labels %v, got %v", expectedLabelIDs, labelIDs)
}
expectedProjectIDs := []string{"TRIAGEID", "ROADMAPID"}
projectIDs, err := result.ProjectsToIDs([]string{"triage", "roadmap"})
if err != nil {
t.Errorf("error resolving projects: %v", err)
}
if !sliceEqual(projectIDs, expectedProjectIDs) {
t.Errorf("expected projects %v, got %v", expectedProjectIDs, projectIDs)
}
expectedMilestoneID := "BIGONEID"
milestoneID, err := result.MilestoneToID("big one.oh")
if err != nil {
t.Errorf("error resolving milestone: %v", err)
}
if milestoneID != expectedMilestoneID {
t.Errorf("expected milestone %v, got %v", expectedMilestoneID, milestoneID)
}
}
func Test_RepoResolveMetadataIDs(t *testing.T) {
http := &httpmock.Registry{}
client := NewClient(ReplaceTripper(http))
repo := ghrepo.FromFullName("OWNER/REPO")
input := RepoResolveInput{
Assignees: []string{"monalisa", "hubot"},
Reviewers: []string{"monalisa", "octocat", "OWNER/core", "/robots"},
Labels: []string{"bug", "help wanted"},
}
expectedQuery := `{
u000: user(login:"monalisa"){id,login}
u001: user(login:"hubot"){id,login}
u002: user(login:"octocat"){id,login}
repository(owner:"OWNER",name:"REPO"){
l000: label(name:"bug"){id,name}
l001: label(name:"help wanted"){id,name}
}
organization(login:"OWNER"){
t000: team(slug:"core"){id,slug}
t001: team(slug:"robots"){id,slug}
}
}
`
responseJSON := `
{ "data": {
"u000": { "login": "MonaLisa", "id": "MONAID" },
"u001": { "login": "hubot", "id": "HUBOTID" },
"u002": { "login": "octocat", "id": "OCTOID" },
"repository": {
"l000": { "name": "bug", "id": "BUGID" },
"l001": { "name": "Help Wanted", "id": "HELPID" }
},
"organization": {
"t000": { "slug": "core", "id": "COREID" },
"t001": { "slug": "Robots", "id": "ROBOTID" }
}
} }
`
http.Register(
httpmock.MatchAny,
httpmock.GraphQLQuery(responseJSON, func(q string, _ map[string]interface{}) {
if q != expectedQuery {
t.Errorf("expected query %q, got %q", expectedQuery, q)
}
}))
result, err := RepoResolveMetadataIDs(client, repo, input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
expectedMemberIDs := []string{"MONAID", "HUBOTID", "OCTOID"}
memberIDs, err := result.MembersToIDs([]string{"monalisa", "hubot", "octocat"})
if err != nil {
t.Errorf("error resolving members: %v", err)
}
if !sliceEqual(memberIDs, expectedMemberIDs) {
t.Errorf("expected members %v, got %v", expectedMemberIDs, memberIDs)
}
expectedTeamIDs := []string{"COREID", "ROBOTID"}
teamIDs, err := result.TeamsToIDs([]string{"/core", "/robots"})
if err != nil {
t.Errorf("error resolving teams: %v", err)
}
if !sliceEqual(teamIDs, expectedTeamIDs) {
t.Errorf("expected members %v, got %v", expectedTeamIDs, teamIDs)
}
expectedLabelIDs := []string{"BUGID", "HELPID"}
labelIDs, err := result.LabelsToIDs([]string{"bug", "help wanted"})
if err != nil {
t.Errorf("error resolving labels: %v", err)
}
if !sliceEqual(labelIDs, expectedLabelIDs) {
t.Errorf("expected members %v, got %v", expectedLabelIDs, labelIDs)
}
}
func sliceEqual(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View file

@ -11,6 +11,7 @@ import (
"net/http"
"net/url"
"os"
"strings"
"github.com/cli/cli/pkg/browser"
)
@ -29,6 +30,7 @@ type OAuthFlow struct {
Hostname string
ClientID string
ClientSecret string
Scopes []string
WriteSuccessHTML func(io.Writer)
VerboseStream io.Writer
}
@ -45,21 +47,31 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
}
port := listener.Addr().(*net.TCPAddr).Port
scopes := "repo"
if oa.Scopes != nil {
scopes = strings.Join(oa.Scopes, " ")
}
q := url.Values{}
q.Set("client_id", oa.ClientID)
q.Set("redirect_uri", fmt.Sprintf("http://localhost:%d/callback", port))
// TODO: make scopes configurable
q.Set("scope", "repo, gist")
q.Set("redirect_uri", fmt.Sprintf("http://127.0.0.1:%d/callback", port))
q.Set("scope", scopes)
q.Set("state", state)
startURL := fmt.Sprintf("https://%s/login/oauth/authorize?%s", oa.Hostname, q.Encode())
oa.logf("open %s\n", startURL)
if err := openInBrowser(startURL); err != nil {
fmt.Fprintf(os.Stderr, "error opening web browser: %s\n", err)
fmt.Fprintf(os.Stderr, "")
fmt.Fprintf(os.Stderr, "Please open the following URL manually:\n%s\n", startURL)
fmt.Fprintf(os.Stderr, "")
// TODO: Temporary workaround for https://github.com/cli/cli/issues/297
fmt.Fprintf(os.Stderr, "If you are on a server or other headless system, use this workaround instead:")
fmt.Fprintf(os.Stderr, " 1. Complete authentication on a GUI system")
fmt.Fprintf(os.Stderr, " 2. Copy the contents of ~/.config/gh/config.yml to this system")
}
http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = http.Serve(listener, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
oa.logf("server handler: %s\n", r.URL.Path)
if r.URL.Path != "/callback" {
w.WriteHeader(404)

View file

@ -28,7 +28,7 @@ func main() {
func filePrepender(filename string) string {
return `---
layout: page
layout: manual
permalink: /:path/:basename
---

View file

@ -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"
@ -70,7 +70,11 @@ func printError(out io.Writer, err error, cmd *cobra.Command, debug bool) {
}
func shouldCheckForUpdate() bool {
return updaterEnabled != "" && utils.IsTerminal(os.Stderr)
return updaterEnabled != "" && !isCompletionCommand() && utils.IsTerminal(os.Stderr)
}
func isCompletionCommand() bool {
return len(os.Args) > 1 && os.Args[1] == "completion"
}
func checkForUpdate(currentVersion string) (*update.ReleaseInfo, error) {
@ -84,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)
}

View file

@ -1,29 +1,35 @@
package command
import (
"errors"
"fmt"
"os"
"github.com/cli/cli/internal/cobrafish"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
func init() {
RootCmd.AddCommand(completionCmd)
completionCmd.Flags().StringP("shell", "s", "bash", "The type of shell")
completionCmd.Flags().StringP("shell", "s", "", "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)"
The output of this command will be computer code and is meant to be saved to a
file or immediately evaluated by an interactive shell.
You can add that to your '~/.bash_profile' to enable completion whenever you
start a new shell.
For example, for bash you could add this to your '~/.bash_profile':
When installing with Homebrew, see https://docs.brew.sh/Shell-Completion
eval "$(gh completion -s bash)"
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")
@ -31,11 +37,26 @@ When installing with Homebrew, see https://docs.brew.sh/Shell-Completion
return err
}
if shellType == "" {
out := cmd.OutOrStdout()
isTTY := false
if outFile, isFile := out.(*os.File); isFile {
isTTY = utils.IsTerminal(outFile)
}
if isTTY {
return errors.New("error: the value for `--shell` is required\nsee `gh help completion` for more information")
}
shellType = "bash"
}
switch shellType {
case "bash":
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

@ -6,7 +6,7 @@ import (
)
func TestCompletion_bash(t *testing.T) {
output, err := RunCommand(completionCmd, `completion`)
output, err := RunCommand(`completion`)
if err != nil {
t.Fatal(err)
}
@ -17,7 +17,7 @@ func TestCompletion_bash(t *testing.T) {
}
func TestCompletion_zsh(t *testing.T) {
output, err := RunCommand(completionCmd, `completion -s zsh`)
output, err := RunCommand(`completion -s zsh`)
if err != nil {
t.Fatal(err)
}
@ -28,7 +28,7 @@ func TestCompletion_zsh(t *testing.T) {
}
func TestCompletion_fish(t *testing.T) {
output, err := RunCommand(completionCmd, `completion -s fish`)
output, err := RunCommand(`completion -s fish`)
if err != nil {
t.Fatal(err)
}
@ -38,8 +38,19 @@ func TestCompletion_fish(t *testing.T) {
}
}
func TestCompletion_powerShell(t *testing.T) {
output, err := RunCommand(`completion -s powershell`)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(output.String(), "Register-ArgumentCompleter") {
t.Errorf("problem in powershell completion:\n%s", output)
}
}
func TestCompletion_unsupported(t *testing.T) {
_, err := RunCommand(completionCmd, `completion -s csh`)
_, err := RunCommand(`completion -s csh`)
if err == nil || err.Error() != `unsupported shell type "csh"` {
t.Fatal(err)
}

112
command/config.go Normal file
View file

@ -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 <key>",
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 <key> <value>",
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
}

191
command/config_test.go Normal file
View file

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

246
command/credits.go Normal file
View file

@ -0,0 +1,246 @@
package command
import (
"bytes"
"fmt"
"math"
"math/rand"
"os"
"os/exec"
"runtime"
"strings"
"time"
"github.com/spf13/cobra"
"golang.org/x/crypto/ssh/terminal"
"github.com/cli/cli/utils"
)
var thankYou = `
_ _
| | | |
_|_ | | __, _ _ | | __
| |/ \ / | / |/ | |/_) | | / \_| |
|_/| |_/\_/|_/ | |_/| \_/ \_/|/\__/ \_/|_/
/|
\|
_
o | | |
__ __ _ _ _|_ ,_ | | _|_ __ ,_ , |
/ / \_/ |/ | | / | | |/ \_| | | / \_/ | / \_|
\___/\__/ | |_/|_/ |_/|_/\_/ \_/|_/|_/\__/ |_/ \/ o
`
func init() {
RootCmd.AddCommand(creditsCmd)
creditsCmd.Flags().BoolP("static", "s", false, "Print a static version of the credits")
}
var creditsCmd = &cobra.Command{
Use: "credits [repository]",
Short: "View project's credits",
Long: `View animated credits for this or another project.
Examples:
gh credits # see a credits animation for this project
gh credits owner/repo # see a credits animation for owner/repo
gh credits -s # display a non-animated thank you
gh credits | cat # just print the contributors, one per line
`,
Args: cobra.MaximumNArgs(1),
RunE: credits,
}
func credits(cmd *cobra.Command, args []string) error {
isWindows := runtime.GOOS == "windows"
ctx := contextForCommand(cmd)
client, err := apiClientForContext(ctx)
if err != nil {
return err
}
owner := "cli"
repo := "cli"
if len(args) > 0 {
parts := strings.SplitN(args[0], "/", 2)
owner = parts[0]
repo = parts[1]
}
type Contributor struct {
Login string
}
type Result []Contributor
result := Result{}
body := bytes.NewBufferString("")
path := fmt.Sprintf("repos/%s/%s/contributors", owner, repo)
err = client.REST("GET", path, body, &result)
if err != nil {
return err
}
out := cmd.OutOrStdout()
isTTY := false
outFile, isFile := out.(*os.File)
if isFile {
isTTY = utils.IsTerminal(outFile)
if isTTY {
// FIXME: duplicates colorableOut
out = utils.NewColorable(outFile)
}
}
static, err := cmd.Flags().GetBool("static")
if err != nil {
return err
}
static = static || isWindows
if isTTY && static {
fmt.Fprintln(out, "THANK YOU CONTRIBUTORS!!! <3")
fmt.Println()
}
logins := []string{}
for x, c := range result {
if isTTY && !static {
logins = append(logins, getColor(x)(c.Login))
} else {
fmt.Fprintf(out, "%s\n", c.Login)
}
}
if !isTTY || static {
return nil
}
rand.Seed(time.Now().UnixNano())
lines := []string{}
thankLines := strings.Split(thankYou, "\n")
for x, tl := range thankLines {
lines = append(lines, getColor(x)(tl))
}
lines = append(lines, "")
lines = append(lines, logins...)
lines = append(lines, "( <3 press ctrl-c to quit <3 )")
termWidth, termHeight, err := terminal.GetSize(int(outFile.Fd()))
if err != nil {
return err
}
margin := termWidth / 3
starLinesLeft := []string{}
for x := 0; x < len(lines); x++ {
starLinesLeft = append(starLinesLeft, starLine(margin))
}
starLinesRight := []string{}
for x := 0; x < len(lines); x++ {
lineWidth := termWidth - (margin + len(lines[x]))
starLinesRight = append(starLinesRight, starLine(lineWidth))
}
loop := true
startx := termHeight - 1
li := 0
for loop {
clear()
for x := 0; x < termHeight; x++ {
if x == startx || startx < 0 {
starty := 0
if startx < 0 {
starty = int(math.Abs(float64(startx)))
}
for y := starty; y < li+1; y++ {
if y >= len(lines) {
continue
}
starLineLeft := starLinesLeft[y]
starLinesLeft[y] = twinkle(starLineLeft)
starLineRight := starLinesRight[y]
starLinesRight[y] = twinkle(starLineRight)
fmt.Fprintf(out, "%s %s %s\n", starLineLeft, lines[y], starLineRight)
}
li += 1
x += li
} else {
fmt.Fprintf(out, "\n")
}
}
if li < len(lines) {
startx -= 1
}
time.Sleep(300 * time.Millisecond)
}
return nil
}
func starLine(width int) string {
line := ""
starChance := 0.1
for y := 0; y < width; y++ {
chance := rand.Float64()
if chance <= starChance {
charRoll := rand.Float64()
switch {
case charRoll < 0.3:
line += "."
case charRoll > 0.3 && charRoll < 0.6:
line += "+"
default:
line += "*"
}
} else {
line += " "
}
}
return line
}
func twinkle(starLine string) string {
starLine = strings.ReplaceAll(starLine, ".", "P")
starLine = strings.ReplaceAll(starLine, "+", "A")
starLine = strings.ReplaceAll(starLine, "*", ".")
starLine = strings.ReplaceAll(starLine, "P", "+")
starLine = strings.ReplaceAll(starLine, "A", "*")
return starLine
}
func getColor(x int) func(string) string {
rainbow := []func(string) string{
utils.Magenta,
utils.Red,
utils.Yellow,
utils.Green,
utils.Cyan,
utils.Blue,
}
ix := x % len(rainbow)
return rainbow[ix]
}
func clear() {
// on windows we'd do cmd := exec.Command("cmd", "/c", "cls"); unfortunately the draw speed is so
// slow that the animation is very jerky, flashy, and painful to look at.
cmd := exec.Command("clear")
cmd.Stdout = os.Stdout
_ = cmd.Run()
}

View file

@ -14,7 +14,6 @@ import (
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/pkg/text"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@ -23,7 +22,6 @@ import (
func init() {
RootCmd.AddCommand(issueCmd)
issueCmd.AddCommand(issueStatusCmd)
issueCmd.AddCommand(issueViewCmd)
issueCmd.AddCommand(issueCreateCmd)
issueCreateCmd.Flags().StringP("title", "t", "",
@ -31,14 +29,23 @@ func init() {
issueCreateCmd.Flags().StringP("body", "b", "",
"Supply a body. Will prompt for one otherwise.")
issueCreateCmd.Flags().BoolP("web", "w", false, "Open the browser to create an issue")
issueCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign a person by their `login`")
issueCreateCmd.Flags().StringSliceP("label", "l", nil, "Add a label by `name`")
issueCreateCmd.Flags().StringSliceP("project", "p", nil, "Add the issue to a project by `name`")
issueCreateCmd.Flags().StringP("milestone", "m", "", "Add the issue to a milestone by `name`")
issueCmd.AddCommand(issueListCmd)
issueListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
issueListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label")
issueListCmd.Flags().StringP("state", "s", "", "Filter by state: {open|closed|all}")
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")
issueViewCmd.Flags().BoolP("preview", "p", false, "Display preview of issue content")
issueCmd.AddCommand(issueViewCmd)
issueViewCmd.Flags().BoolP("web", "w", false, "Open an issue in the browser")
issueCmd.AddCommand(issueCloseCmd)
issueCmd.AddCommand(issueReopenCmd)
}
var issueCmd = &cobra.Command{
@ -66,15 +73,30 @@ 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 required as argument")}
return FlagError{errors.New("issue number or URL required as argument")}
}
return nil
},
Short: "View an issue in the browser",
RunE: issueView,
Short: "View an issue",
Long: `Display the title, body, and other information about an issue.
With '--web', open the issue in a web browser instead.`,
RunE: issueView,
}
var issueCloseCmd = &cobra.Command{
Use: "close {<number> | <url>}",
Short: "close issue",
Args: cobra.ExactArgs(1),
RunE: issueClose,
}
var issueReopenCmd = &cobra.Command{
Use: "reopen {<number> | <url>}",
Short: "reopen issue",
Args: cobra.ExactArgs(1),
RunE: issueReopen,
}
func issueList(cmd *cobra.Command, args []string) error {
@ -109,45 +131,31 @@ func issueList(cmd *cobra.Command, args []string) error {
return err
}
fmt.Fprintf(colorableErr(cmd), "\nIssues for %s\n\n", ghrepo.FullName(baseRepo))
issues, err := api.IssueList(apiClient, baseRepo, state, labels, assignee, limit)
author, err := cmd.Flags().GetString("author")
if err != nil {
return err
}
if len(issues) == 0 {
colorErr := colorableErr(cmd) // Send to stderr because otherwise when piping this command it would seem like the "no open issues" message is actually an issue
msg := "There are no open issues"
userSetFlags := false
cmd.Flags().Visit(func(f *pflag.Flag) {
userSetFlags = true
})
if userSetFlags {
msg = "No issues match your search"
}
printMessage(colorErr, msg)
return nil
listResult, err := api.IssueList(apiClient, baseRepo, state, labels, assignee, limit, author)
if err != nil {
return err
}
hasFilters := false
cmd.Flags().Visit(func(f *pflag.Flag) {
switch f.Name {
case "state", "label", "assignee", "author":
hasFilters = true
}
})
title := listHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters)
// TODO: avoid printing header if piped to a script
fmt.Fprintf(colorableErr(cmd), "\n%s\n\n", title)
out := cmd.OutOrStdout()
table := utils.NewTablePrinter(out)
for _, issue := range issues {
issueNum := strconv.Itoa(issue.Number)
if table.IsTTY() {
issueNum = "#" + issueNum
}
labels := labelList(issue)
if labels != "" && table.IsTTY() {
labels = fmt.Sprintf("(%s)", labels)
}
table.AddField(issueNum, nil, colorFuncForState(issue.State))
table.AddField(replaceExcessiveWhitespace(issue.Title), nil, nil)
table.AddField(labels, nil, utils.Gray)
table.EndRow()
}
table.Render()
printIssues(out, "", len(listResult.Issues), listResult.Issues)
return nil
}
@ -184,7 +192,7 @@ func issueStatus(cmd *cobra.Command, args []string) error {
if issuePayload.Assigned.TotalCount > 0 {
printIssues(out, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues)
} else {
message := fmt.Sprintf(" There are no issues assigned to you")
message := " There are no issues assigned to you"
printMessage(out, message)
}
fmt.Fprintln(out)
@ -227,35 +235,79 @@ func issueView(cmd *cobra.Command, args []string) error {
}
openURL := issue.URL
preview, err := cmd.Flags().GetBool("preview")
web, err := cmd.Flags().GetBool("web")
if err != nil {
return err
}
if preview {
out := colorableOut(cmd)
return printIssuePreview(out, issue)
} else {
if web {
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", openURL)
return utils.OpenInBrowser(openURL)
} else {
out := colorableOut(cmd)
return printIssuePreview(out, issue)
}
}
func printIssuePreview(out io.Writer, issue *api.Issue) error {
coloredLabels := labelList(*issue)
if coloredLabels != "" {
coloredLabels = utils.Gray(fmt.Sprintf("(%s)", coloredLabels))
func issueStateTitleWithColor(state string) string {
colorFunc := colorFuncForState(state)
return colorFunc(strings.Title(strings.ToLower(state)))
}
func listHeader(repoName string, itemName string, matchCount int, totalMatchCount int, hasFilters bool) string {
if totalMatchCount == 0 {
if hasFilters {
return fmt.Sprintf("No %ss match your search in %s", itemName, repoName)
}
return fmt.Sprintf("There are no open %ss in %s", itemName, repoName)
}
if hasFilters {
matchVerb := "match"
if totalMatchCount == 1 {
matchVerb = "matches"
}
return fmt.Sprintf("Showing %d of %s in %s that %s your search", matchCount, utils.Pluralize(totalMatchCount, itemName), repoName, matchVerb)
}
return fmt.Sprintf("Showing %d of %s in %s", matchCount, utils.Pluralize(totalMatchCount, itemName), repoName)
}
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.Fprint(out, issueStateTitleWithColor(issue.State))
fmt.Fprintln(out, utils.Gray(fmt.Sprintf(
"opened by %s. %s. %s",
" • %s opened %s • %s",
issue.Author.Login,
utils.FuzzyAgo(ago),
utils.Pluralize(issue.Comments.TotalCount, "comment"),
coloredLabels,
)))
// 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)
@ -263,9 +315,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
}
@ -273,7 +326,7 @@ func printIssuePreview(out io.Writer, issue *api.Issue) error {
var issueURLRE = regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/issues/(\d+)`)
func issueFromArg(apiClient *api.Client, baseRepo ghrepo.Interface, arg string) (*api.Issue, error) {
if issueNumber, err := strconv.Atoi(arg); err == nil {
if issueNumber, err := strconv.Atoi(strings.TrimPrefix(arg, "#")); err == nil {
return api.IssueByNumber(apiClient, baseRepo, issueNumber)
}
@ -316,6 +369,25 @@ func issueCreate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("could not parse body: %w", err)
}
assignees, err := cmd.Flags().GetStringSlice("assignee")
if err != nil {
return fmt.Errorf("could not parse assignees: %w", err)
}
labelNames, err := cmd.Flags().GetStringSlice("label")
if err != nil {
return fmt.Errorf("could not parse labels: %w", err)
}
projectNames, err := cmd.Flags().GetStringSlice("project")
if err != nil {
return fmt.Errorf("could not parse projects: %w", err)
}
var milestoneTitles []string
if milestoneTitle, err := cmd.Flags().GetString("milestone"); err != nil {
return fmt.Errorf("could not parse milestone: %w", err)
} else if milestoneTitle != "" {
milestoneTitles = append(milestoneTitles, milestoneTitle)
}
if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb {
// TODO: move URL generation into GitHubRepository
openURL := fmt.Sprintf("https://github.com/%s/issues/new", ghrepo.FullName(baseRepo))
@ -348,11 +420,17 @@ func issueCreate(cmd *cobra.Command, args []string) error {
}
action := SubmitAction
tb := issueMetadataState{
Assignees: assignees,
Labels: labelNames,
Projects: projectNames,
Milestones: milestoneTitles,
}
interactive := title == "" || body == ""
interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body"))
if interactive {
tb, err := titleBodySurvey(cmd, title, body, templateFiles)
err := titleBodySurvey(cmd, &tb, apiClient, baseRepo, title, body, defaults{}, templateFiles, false, repo.ViewerCanTriage())
if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err)
}
@ -371,6 +449,10 @@ func issueCreate(cmd *cobra.Command, args []string) error {
if body == "" {
body = tb.Body
}
} else {
if title == "" {
return fmt.Errorf("title can't be blank")
}
}
if action == PreviewAction {
@ -389,6 +471,11 @@ func issueCreate(cmd *cobra.Command, args []string) error {
"body": body,
}
err = addMetadataToIssueParams(apiClient, baseRepo, params, &tb)
if err != nil {
return err
}
newIssue, err := api.IssueCreate(apiClient, repo, params)
if err != nil {
return err
@ -402,34 +489,132 @@ func issueCreate(cmd *cobra.Command, args []string) error {
return nil
}
func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) {
for _, issue := range issues {
number := utils.Green("#" + strconv.Itoa(issue.Number))
coloredLabels := labelList(issue)
if coloredLabels != "" {
coloredLabels = utils.Gray(fmt.Sprintf(" (%s)", coloredLabels))
func addMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *issueMetadataState) error {
if !tb.HasMetadata() {
return nil
}
if tb.MetadataResult == nil {
resolveInput := api.RepoResolveInput{
Reviewers: tb.Reviewers,
Assignees: tb.Assignees,
Labels: tb.Labels,
Projects: tb.Projects,
Milestones: tb.Milestones,
}
var err error
tb.MetadataResult, err = api.RepoResolveMetadataIDs(client, baseRepo, resolveInput)
if err != nil {
return err
}
}
assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees)
if err != nil {
return fmt.Errorf("could not assign user: %w", err)
}
params["assigneeIds"] = assigneeIDs
labelIDs, err := tb.MetadataResult.LabelsToIDs(tb.Labels)
if err != nil {
return fmt.Errorf("could not add label: %w", err)
}
params["labelIds"] = labelIDs
projectIDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects)
if err != nil {
return fmt.Errorf("could not add to project: %w", err)
}
params["projectIds"] = projectIDs
if len(tb.Milestones) > 0 {
milestoneID, err := tb.MetadataResult.MilestoneToID(tb.Milestones[0])
if err != nil {
return fmt.Errorf("could not add to milestone '%s': %w", tb.Milestones[0], err)
}
params["milestoneId"] = milestoneID
}
if len(tb.Reviewers) == 0 {
return nil
}
var userReviewers []string
var teamReviewers []string
for _, r := range tb.Reviewers {
if strings.ContainsRune(r, '/') {
teamReviewers = append(teamReviewers, r)
} else {
userReviewers = append(userReviewers, r)
}
}
userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers)
if err != nil {
return fmt.Errorf("could not request reviewer: %w", err)
}
params["userReviewerIds"] = userReviewerIDs
teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers)
if err != nil {
return fmt.Errorf("could not request reviewer: %w", err)
}
params["teamReviewerIds"] = teamReviewerIDs
return nil
}
func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) {
table := utils.NewTablePrinter(w)
for _, issue := range issues {
issueNum := strconv.Itoa(issue.Number)
if table.IsTTY() {
issueNum = "#" + issueNum
}
issueNum = prefix + issueNum
labels := issueLabelList(issue)
if labels != "" && table.IsTTY() {
labels = fmt.Sprintf("(%s)", labels)
}
now := time.Now()
ago := now.Sub(issue.UpdatedAt)
fmt.Fprintf(w, "%s%s %s%s %s\n", prefix, number,
text.Truncate(70, replaceExcessiveWhitespace(issue.Title)),
coloredLabels,
utils.Gray(utils.FuzzyAgo(ago)))
table.AddField(issueNum, nil, colorFuncForState(issue.State))
table.AddField(replaceExcessiveWhitespace(issue.Title), nil, nil)
table.AddField(labels, nil, utils.Gray)
table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray)
table.EndRow()
}
_ = table.Render()
remaining := totalCount - len(issues)
if remaining > 0 {
fmt.Fprintf(w, utils.Gray("%sAnd %d more\n"), prefix, remaining)
}
}
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 ""
}
labelNames := []string{}
labelNames := make([]string, 0, len(issue.Labels.Nodes))
for _, label := range issue.Labels.Nodes {
labelNames = append(labelNames, label.Name)
}
@ -441,6 +626,97 @@ 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 {
colName := project.Column.Name
if colName == "" {
colName = "Awaiting triage"
}
projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName))
}
list := strings.Join(projectNames, ", ")
if issue.ProjectCards.TotalCount > len(issue.ProjectCards.Nodes) {
list += ", …"
}
return list
}
func issueClose(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
baseRepo, err := determineBaseRepo(cmd, ctx)
if err != nil {
return err
}
issue, err := issueFromArg(apiClient, baseRepo, args[0])
var idErr *api.IssuesDisabledError
if errors.As(err, &idErr) {
return fmt.Errorf("issues disabled for %s", ghrepo.FullName(baseRepo))
} else if err != nil {
return fmt.Errorf("failed to find issue #%d: %w", issue.Number, err)
}
if issue.Closed {
fmt.Fprintf(colorableErr(cmd), "%s Issue #%d is already closed\n", utils.Yellow("!"), issue.Number)
return nil
}
err = api.IssueClose(apiClient, baseRepo, *issue)
if err != nil {
return fmt.Errorf("API call failed:%w", err)
}
fmt.Fprintf(colorableErr(cmd), "%s Closed issue #%d\n", utils.Red("✔"), issue.Number)
return nil
}
func issueReopen(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
baseRepo, err := determineBaseRepo(cmd, ctx)
if err != nil {
return err
}
issue, err := issueFromArg(apiClient, baseRepo, args[0])
var idErr *api.IssuesDisabledError
if errors.As(err, &idErr) {
return fmt.Errorf("issues disabled for %s", ghrepo.FullName(baseRepo))
} else if err != nil {
return fmt.Errorf("failed to find issue #%d: %w", issue.Number, err)
}
if !issue.Closed {
fmt.Fprintf(colorableErr(cmd), "%s Issue #%d is already open\n", utils.Yellow("!"), issue.Number)
return nil
}
err = api.IssueReopen(apiClient, baseRepo, *issue)
if err != nil {
return fmt.Errorf("API call failed:%w", err)
}
fmt.Fprintf(colorableErr(cmd), "%s Reopened issue #%d\n", utils.Green("✔"), issue.Number)
return nil
}
func displayURL(urlStr string) string {
u, err := url.Parse(urlStr)
if err != nil {

View file

@ -10,11 +10,14 @@ import (
"strings"
"testing"
"github.com/cli/cli/utils"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/test"
"github.com/google/go-cmp/cmp"
)
func TestIssueStatus(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -22,16 +25,16 @@ func TestIssueStatus(t *testing.T) {
defer jsonFile.Close()
http.StubResponse(200, jsonFile)
output, err := RunCommand(issueStatusCmd, "issue status")
output, err := RunCommand("issue status")
if err != nil {
t.Errorf("error running command `issue status`: %v", err)
}
expectedIssues := []*regexp.Regexp{
regexp.MustCompile(`#8.*carrots`),
regexp.MustCompile(`#9.*squash`),
regexp.MustCompile(`#10.*broccoli`),
regexp.MustCompile(`#11.*swiss chard`),
regexp.MustCompile(`(?m)8.*carrots.*about.*ago`),
regexp.MustCompile(`(?m)9.*squash.*about.*ago`),
regexp.MustCompile(`(?m)10.*broccoli.*about.*ago`),
regexp.MustCompile(`(?m)11.*swiss chard.*about.*ago`),
}
for _, r := range expectedIssues {
@ -43,7 +46,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")
@ -56,7 +59,7 @@ func TestIssueStatus_blankSlate(t *testing.T) {
} } }
`))
output, err := RunCommand(issueStatusCmd, "issue status")
output, err := RunCommand("issue status")
if err != nil {
t.Errorf("error running command `issue status`: %v", err)
}
@ -80,7 +83,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")
@ -90,14 +93,14 @@ func TestIssueStatus_disabledIssues(t *testing.T) {
} } }
`))
_, err := RunCommand(issueStatusCmd, "issue status")
_, err := RunCommand("issue status")
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Errorf("error running command `issue status`: %v", err)
}
}
func TestIssueList(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -105,11 +108,16 @@ func TestIssueList(t *testing.T) {
defer jsonFile.Close()
http.StubResponse(200, jsonFile)
output, err := RunCommand(issueListCmd, "issue list")
output, err := RunCommand("issue list")
if err != nil {
t.Errorf("error running command `issue list`: %v", err)
}
eq(t, output.Stderr(), `
Showing 3 of 3 issues in OWNER/REPO
`)
expectedIssues := []*regexp.Regexp{
regexp.MustCompile(`(?m)^1\t.*won`),
regexp.MustCompile(`(?m)^2\t.*too`),
@ -125,7 +133,7 @@ func TestIssueList(t *testing.T) {
}
func TestIssueList_withFlags(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -136,16 +144,15 @@ func TestIssueList_withFlags(t *testing.T) {
} } }
`))
output, err := RunCommand(issueListCmd, "issue list -a probablyCher -l web,bug -s open")
output, err := RunCommand("issue list -a probablyCher -l web,bug -s open -A foo")
if err != nil {
t.Errorf("error running command `issue list`: %v", err)
}
eq(t, output.String(), "")
eq(t, output.Stderr(), `
Issues for OWNER/REPO
No issues match your search in OWNER/REPO
No issues match your search
`)
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
@ -154,17 +161,19 @@ No issues match your search
Assignee string
Labels []string
States []string
Author string
}
}{}
json.Unmarshal(bodyBytes, &reqBody)
_ = json.Unmarshal(bodyBytes, &reqBody)
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) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -175,7 +184,7 @@ func TestIssueList_nullAssigneeLabels(t *testing.T) {
} } }
`))
_, err := RunCommand(issueListCmd, "issue list")
_, err := RunCommand("issue list")
if err != nil {
t.Errorf("error running command `issue list`: %v", err)
}
@ -184,7 +193,7 @@ func TestIssueList_nullAssigneeLabels(t *testing.T) {
reqBody := struct {
Variables map[string]interface{}
}{}
json.Unmarshal(bodyBytes, &reqBody)
_ = json.Unmarshal(bodyBytes, &reqBody)
_, assigneeDeclared := reqBody.Variables["assignee"]
_, labelsDeclared := reqBody.Variables["labels"]
@ -193,7 +202,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")
@ -203,14 +212,14 @@ func TestIssueList_disabledIssues(t *testing.T) {
} } }
`))
_, err := RunCommand(issueListCmd, "issue list")
_, err := RunCommand("issue list")
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Errorf("error running command `issue list`: %v", err)
}
}
func TestIssueView(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
func TestIssueView_web(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -222,13 +231,13 @@ func TestIssueView(t *testing.T) {
`))
var seenCmd *exec.Cmd
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &outputStub{}
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand(issueViewCmd, "issue view 123")
output, err := RunCommand("issue view -w 123")
if err != nil {
t.Errorf("error running command `issue view`: %v", err)
}
@ -243,144 +252,8 @@ func TestIssueView(t *testing.T) {
eq(t, url, "https://github.com/OWNER/REPO/issues/123")
}
func TestIssueView_preview(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
"number": 123,
"body": "**bold story**",
"title": "ix of coins",
"author": {
"login": "marseilles"
},
"labels": {
"nodes": [
{"name": "tarot"}
]
},
"comments": {
"totalCount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
} } } }
`))
output, err := RunCommand(issueViewCmd, "issue view -p 123")
if err != nil {
t.Errorf("error running command `issue view`: %v", err)
}
eq(t, output.Stderr(), "")
expectedLines := []*regexp.Regexp{
regexp.MustCompile(`ix of coins`),
regexp.MustCompile(`opened by marseilles. 9 comments. \(tarot\)`),
regexp.MustCompile(`bold story`),
regexp.MustCompile(`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`),
}
for _, r := range expectedLines {
if !r.MatchString(output.String()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
}
}
func TestIssueView_previewWithEmptyBody(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
"number": 123,
"body": "",
"title": "ix of coins",
"author": {
"login": "marseilles"
},
"labels": {
"nodes": [
{"name": "tarot"}
]
},
"comments": {
"totalCount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
} } } }
`))
output, err := RunCommand(issueViewCmd, "issue view -p 123")
if err != nil {
t.Errorf("error running command `issue view`: %v", err)
}
eq(t, output.Stderr(), "")
expectedLines := []*regexp.Regexp{
regexp.MustCompile(`ix of coins`),
regexp.MustCompile(`opened by marseilles. 9 comments. \(tarot\)`),
regexp.MustCompile(`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`),
}
for _, r := range expectedLines {
if !r.MatchString(output.String()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
}
}
func TestIssueView_notFound(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "errors": [
{ "message": "Could not resolve to an Issue with the number of 9999." }
] }
`))
var seenCmd *exec.Cmd
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
seenCmd = cmd
return &outputStub{}
})
defer restoreCmd()
_, err := RunCommand(issueViewCmd, "issue view 9999")
if err == nil || err.Error() != "graphql error: 'Could not resolve to an Issue with the number of 9999.'" {
t.Errorf("error running command `issue view`: %v", err)
}
if seenCmd != nil {
t.Fatal("did not expect any command to run")
}
}
func TestIssueView_disabledIssues(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": false
} } }
`))
_, err := RunCommand(issueViewCmd, `issue view 6666`)
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Errorf("error running command `issue view`: %v", err)
}
}
func TestIssueView_urlArg(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
func TestIssueView_web_numberArgWithHash(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -392,13 +265,169 @@ func TestIssueView_urlArg(t *testing.T) {
`))
var seenCmd *exec.Cmd
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &outputStub{}
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand(issueViewCmd, "issue view https://github.com/OWNER/REPO/issues/123")
output, err := RunCommand("issue view -w \"#123\"")
if err != nil {
t.Errorf("error running command `issue view`: %v", err)
}
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening https://github.com/OWNER/REPO/issues/123 in your browser.\n")
if seenCmd == nil {
t.Fatal("expected a command to run")
}
url := seenCmd.Args[len(seenCmd.Args)-1]
eq(t, url, "https://github.com/OWNER/REPO/issues/123")
}
func TestIssueView_Preview(t *testing.T) {
tests := map[string]struct {
ownerRepo string
command string
fixture string
expectedOutputs []string
}{
"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`,
},
},
"Open issue with metadata": {
ownerRepo: "master",
command: "issue view 123",
fixture: "../test/fixtures/issueView_previewWithMetadata.json",
expectedOutputs: []string{
`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\), Project 4 \(Awaiting triage\)\n`,
`Milestone: uluru\n`,
`bold story`,
`View this issue on GitHub: https://github.com/OWNER/REPO/issues/123`,
},
},
"Open issue with empty body": {
ownerRepo: "master",
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`,
},
},
"Closed issue": {
ownerRepo: "master",
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`,
},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
initBlankContext("", "OWNER/REPO", tc.ownerRepo)
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
jsonFile, _ := os.Open(tc.fixture)
defer jsonFile.Close()
http.StubResponse(200, jsonFile)
output, err := RunCommand(tc.command)
if err != nil {
t.Errorf("error running command `%v`: %v", tc.command, err)
}
eq(t, output.Stderr(), "")
test.ExpectLines(t, output.String(), tc.expectedOutputs...)
})
}
}
func TestIssueView_web_notFound(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "errors": [
{ "message": "Could not resolve to an Issue with the number of 9999." }
] }
`))
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
_, err := RunCommand("issue view -w 9999")
if err == nil || err.Error() != "graphql error: 'Could not resolve to an Issue with the number of 9999.'" {
t.Errorf("error running command `issue view`: %v", err)
}
if seenCmd != nil {
t.Fatal("did not expect any command to run")
}
}
func TestIssueView_disabledIssues(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": false
} } }
`))
_, err := RunCommand(`issue view 6666`)
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Errorf("error running command `issue view`: %v", err)
}
}
func TestIssueView_web_urlArg(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "hasIssuesEnabled": true, "issue": {
"number": 123,
"url": "https://github.com/OWNER/REPO/issues/123"
} } } }
`))
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand("issue view -w https://github.com/OWNER/REPO/issues/123")
if err != nil {
t.Errorf("error running command `issue view`: %v", err)
}
@ -413,7 +442,7 @@ func TestIssueView_urlArg(t *testing.T) {
}
func TestIssueCreate(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -429,7 +458,7 @@ func TestIssueCreate(t *testing.T) {
} } } }
`))
output, err := RunCommand(issueCreateCmd, `issue create -t hello -b "cash rules everything around me"`)
output, err := RunCommand(`issue create -t hello -b "cash rules everything around me"`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
@ -444,7 +473,7 @@ func TestIssueCreate(t *testing.T) {
}
}
}{}
json.Unmarshal(bodyBytes, &reqBody)
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
eq(t, reqBody.Variables.Input.Title, "hello")
@ -453,8 +482,98 @@ func TestIssueCreate(t *testing.T) {
eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
}
func TestIssueCreate_metadata(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`\bviewerPermission\b`),
httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE")))
http.Register(
httpmock.GraphQL(`\bhasIssuesEnabled\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"id": "REPOID",
"hasIssuesEnabled": true,
"viewerPermission": "WRITE"
} } }
`))
http.Register(
httpmock.GraphQL(`\bu000:`),
httpmock.StringResponse(`
{ "data": {
"u000": { "login": "MonaLisa", "id": "MONAID" },
"repository": {
"l000": { "name": "bug", "id": "BUGID" },
"l001": { "name": "TODO", "id": "TODOID" }
}
} }
`))
http.Register(
httpmock.GraphQL(`\bmilestones\(`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`\brepository\(.+\bprojects\(`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "Cleanup", "id": "CLEANUPID" },
{ "name": "Roadmap", "id": "ROADMAPID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`\borganization\(.+\bprojects\(`),
httpmock.StringResponse(`
{ "data": { "organization": null },
"errors": [{
"type": "NOT_FOUND",
"path": [ "organization" ],
"message": "Could not resolve to an Organization with the login of 'OWNER'."
}]
}
`))
http.Register(
httpmock.GraphQL(`\bcreateIssue\(`),
httpmock.GraphQLMutation(`
{ "data": { "createIssue": { "issue": {
"URL": "https://github.com/OWNER/REPO/issues/12"
} } } }
`, func(inputs map[string]interface{}) {
eq(t, inputs["title"], "TITLE")
eq(t, inputs["body"], "BODY")
eq(t, inputs["assigneeIds"], []interface{}{"MONAID"})
eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"})
eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"})
eq(t, inputs["milestoneId"], "BIGONEID")
if v, ok := inputs["userIds"]; ok {
t.Errorf("did not expect userIds: %v", v)
}
if v, ok := inputs["teamIds"]; ok {
t.Errorf("did not expect teamIds: %v", v)
}
}))
output, err := RunCommand(`issue create -t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh'`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
eq(t, output.String(), "https://github.com/OWNER/REPO/issues/12\n")
}
func TestIssueCreate_disabledIssues(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
@ -465,25 +584,25 @@ func TestIssueCreate_disabledIssues(t *testing.T) {
} } }
`))
_, err := RunCommand(issueCreateCmd, `issue create -t heres -b johnny`)
_, err := RunCommand(`issue create -t heres -b johnny`)
if err == nil || err.Error() != "the 'OWNER/REPO' repository has disabled issues" {
t.Errorf("error running command `issue create`: %v", err)
}
}
func TestIssueCreate_web(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
var seenCmd *exec.Cmd
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &outputStub{}
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand(issueCreateCmd, `issue create --web`)
output, err := RunCommand(`issue create --web`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
@ -498,18 +617,18 @@ 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")
var seenCmd *exec.Cmd
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &outputStub{}
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand(issueCreateCmd, `issue create -w -t mytitle -b mybody`)
output, err := RunCommand(`issue create -w -t mytitle -b mybody`)
if err != nil {
t.Errorf("error running command `issue create`: %v", err)
}
@ -521,3 +640,280 @@ func TestIssueCreate_webTitleBody(t *testing.T) {
eq(t, url, "https://github.com/OWNER/REPO/issues/new?title=mytitle&body=mybody")
eq(t, output.String(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n")
}
func Test_listHeader(t *testing.T) {
type args struct {
repoName string
itemName string
matchCount int
totalMatchCount int
hasFilters bool
}
tests := []struct {
name string
args args
want string
}{
{
name: "no results",
args: args{
repoName: "REPO",
itemName: "table",
matchCount: 0,
totalMatchCount: 0,
hasFilters: false,
},
want: "There are no open tables in REPO",
},
{
name: "no matches after filters",
args: args{
repoName: "REPO",
itemName: "Luftballon",
matchCount: 0,
totalMatchCount: 0,
hasFilters: true,
},
want: "No Luftballons match your search in REPO",
},
{
name: "one result",
args: args{
repoName: "REPO",
itemName: "genie",
matchCount: 1,
totalMatchCount: 23,
hasFilters: false,
},
want: "Showing 1 of 23 genies in REPO",
},
{
name: "one result after filters",
args: args{
repoName: "REPO",
itemName: "tiny cup",
matchCount: 1,
totalMatchCount: 23,
hasFilters: true,
},
want: "Showing 1 of 23 tiny cups in REPO that match your search",
},
{
name: "one result in total",
args: args{
repoName: "REPO",
itemName: "chip",
matchCount: 1,
totalMatchCount: 1,
hasFilters: false,
},
want: "Showing 1 of 1 chip in REPO",
},
{
name: "one result in total after filters",
args: args{
repoName: "REPO",
itemName: "spicy noodle",
matchCount: 1,
totalMatchCount: 1,
hasFilters: true,
},
want: "Showing 1 of 1 spicy noodle in REPO that matches your search",
},
{
name: "multiple results",
args: args{
repoName: "REPO",
itemName: "plant",
matchCount: 4,
totalMatchCount: 23,
hasFilters: false,
},
want: "Showing 4 of 23 plants in REPO",
},
{
name: "multiple results after filters",
args: args{
repoName: "REPO",
itemName: "boomerang",
matchCount: 4,
totalMatchCount: 23,
hasFilters: true,
},
want: "Showing 4 of 23 boomerangs in REPO that match your search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := listHeader(tt.args.repoName, tt.args.itemName, tt.args.matchCount, tt.args.totalMatchCount, tt.args.hasFilters); got != tt.want {
t.Errorf("listHeader() = %v, want %v", got, tt.want)
}
})
}
}
func TestIssueStateTitleWithColor(t *testing.T) {
tests := map[string]struct {
state string
want string
}{
"Open state": {state: "OPEN", want: "Open"},
"Closed state": {state: "CLOSED", want: "Closed"},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
got := issueStateTitleWithColor(tc.state)
diff := cmp.Diff(tc.want, got)
if diff != "" {
t.Fatalf(diff)
}
})
}
}
func TestIssueClose(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 13}
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
output, err := RunCommand("issue close 13")
if err != nil {
t.Fatalf("error running command `issue close`: %v", err)
}
r := regexp.MustCompile(`Closed issue #13`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestIssueClose_alreadyClosed(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 13, "closed": true}
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
output, err := RunCommand("issue close 13")
if err != nil {
t.Fatalf("error running command `issue close`: %v", err)
}
r := regexp.MustCompile(`#13 is already closed`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestIssueClose_issuesDisabled(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": false
} } }
`))
_, err := RunCommand("issue close 13")
if err == nil {
t.Fatalf("expected error when issues are disabled")
}
if !strings.Contains(err.Error(), "issues disabled") {
t.Fatalf("got unexpected error: %s", err)
}
}
func TestIssueReopen(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 2, "closed": true}
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
output, err := RunCommand("issue reopen 2")
if err != nil {
t.Fatalf("error running command `issue reopen`: %v", err)
}
r := regexp.MustCompile(`Reopened issue #2`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestIssueReopen_alreadyOpen(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": true,
"issue": { "number": 2, "closed": false}
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"id": "THE-ID"}`))
output, err := RunCommand("issue reopen 2")
if err != nil {
t.Fatalf("error running command `issue reopen`: %v", err)
}
r := regexp.MustCompile(`#2 is already open`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestIssueReopen_issuesDisabled(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"hasIssuesEnabled": false
} } }
`))
_, err := RunCommand("issue reopen 2")
if err == nil {
t.Fatalf("expected error when issues are disabled")
}
if !strings.Contains(err.Error(), "issues disabled") {
t.Fatalf("got unexpected error: %s", err)
}
}

View file

@ -4,6 +4,7 @@ import (
"fmt"
"io"
"regexp"
"sort"
"strconv"
"strings"
@ -21,17 +22,23 @@ func init() {
RootCmd.AddCommand(prCmd)
prCmd.AddCommand(prCheckoutCmd)
prCmd.AddCommand(prCreateCmd)
prCmd.AddCommand(prListCmd)
prCmd.AddCommand(prStatusCmd)
prCmd.AddCommand(prViewCmd)
prCmd.AddCommand(prCloseCmd)
prCmd.AddCommand(prReopenCmd)
prCmd.AddCommand(prMergeCmd)
prMergeCmd.Flags().BoolP("merge", "m", true, "Merge the commits with the base branch")
prMergeCmd.Flags().BoolP("rebase", "r", false, "Rebase the commits onto the base branch")
prMergeCmd.Flags().BoolP("squash", "s", false, "Squash the commits into one commit and merge it into the base branch")
prCmd.AddCommand(prListCmd)
prListCmd.Flags().IntP("limit", "L", 30, "Maximum number of items to fetch")
prListCmd.Flags().StringP("state", "s", "open", "Filter by state: {open|closed|merged|all}")
prListCmd.Flags().StringP("base", "B", "", "Filter by base branch")
prListCmd.Flags().StringSliceP("label", "l", nil, "Filter by label")
prListCmd.Flags().StringP("assignee", "a", "", "Filter by assignee")
prViewCmd.Flags().BoolP("preview", "p", false, "Display preview of pull request content")
prCmd.AddCommand(prViewCmd)
prViewCmd.Flags().BoolP("web", "w", false, "Open a pull request in the browser")
}
var prCmd = &cobra.Command{
@ -55,14 +62,35 @@ var prStatusCmd = &cobra.Command{
RunE: prStatus,
}
var prViewCmd = &cobra.Command{
Use: "view [{<number> | <url> | <branch>}]",
Short: "View a pull request in the browser",
Long: `View a pull request specified by the argument in the browser.
Use: "view [<number> | <url> | <branch>]",
Short: "View a pull request",
Long: `Display the title, body, and other information about a pull request.
Without an argument, the pull request that belongs to the current
branch is opened.`,
Without an argument, the pull request that belongs to the current branch
is displayed.
With '--web', open the pull request in a web browser instead.`,
RunE: prView,
}
var prCloseCmd = &cobra.Command{
Use: "close {<number> | <url> | <branch>}",
Short: "Close a pull request",
Args: cobra.ExactArgs(1),
RunE: prClose,
}
var prReopenCmd = &cobra.Command{
Use: "reopen {<number> | <url> | <branch>}",
Short: "Reopen a pull request",
Args: cobra.ExactArgs(1),
RunE: prReopen,
}
var prMergeCmd = &cobra.Command{
Use: "merge [<number> | <url> | <branch>]",
Short: "Merge a pull request",
Args: cobra.MaximumNArgs(1),
RunE: prMerge,
}
func prStatus(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
@ -71,10 +99,6 @@ func prStatus(cmd *cobra.Command, args []string) error {
return err
}
currentPRNumber, currentPRHeadRef, err := prSelectorForCurrentBranch(ctx)
if err != nil {
return err
}
currentUser, err := ctx.AuthLogin()
if err != nil {
return err
@ -85,6 +109,13 @@ func prStatus(cmd *cobra.Command, args []string) error {
return err
}
repoOverride, _ := cmd.Flags().GetString("repo")
currentPRNumber, currentPRHeadRef, err := prSelectorForCurrentBranch(ctx, baseRepo)
if err != nil && repoOverride == "" && err.Error() != "git: not on any branch" {
return fmt.Errorf("could not query for pull request for current branch: %w", err)
}
prPayload, err := api.PullRequests(apiClient, baseRepo, currentPRNumber, currentPRHeadRef, currentUser)
if err != nil {
return err
@ -97,11 +128,17 @@ func prStatus(cmd *cobra.Command, args []string) error {
fmt.Fprintln(out, "")
printHeader(out, "Current branch")
if prPayload.CurrentPR != nil {
printPrs(out, 0, *prPayload.CurrentPR)
currentPR := prPayload.CurrentPR
currentBranch, _ := ctx.Branch()
if currentPR != nil && currentPR.State != "OPEN" && prPayload.DefaultBranch == currentBranch {
currentPR = nil
}
if currentPR != nil {
printPrs(out, 1, *currentPR)
} else if currentPRHeadRef == "" {
printMessage(out, " There is no current branch")
} else {
message := fmt.Sprintf(" There is no pull request associated with %s", utils.Cyan("["+currentPRHeadRef+"]"))
printMessage(out, message)
printMessage(out, fmt.Sprintf(" There is no pull request associated with %s", utils.Cyan("["+currentPRHeadRef+"]")))
}
fmt.Fprintln(out)
@ -136,8 +173,6 @@ func prList(cmd *cobra.Command, args []string) error {
return err
}
fmt.Fprintf(colorableErr(cmd), "\nPull requests for %s\n\n", ghrepo.FullName(baseRepo))
limit, err := cmd.Flags().GetInt("limit")
if err != nil {
return err
@ -188,33 +223,30 @@ func prList(cmd *cobra.Command, args []string) error {
params["assignee"] = assignee
}
prs, err := api.PullRequestList(apiClient, params, limit)
listResult, err := api.PullRequestList(apiClient, params, limit)
if err != nil {
return err
}
if len(prs) == 0 {
colorErr := colorableErr(cmd) // Send to stderr because otherwise when piping this command it would seem like the "no open prs" message is actually a pr
msg := "There are no open pull requests"
userSetFlags := false
cmd.Flags().Visit(func(f *pflag.Flag) {
userSetFlags = true
})
if userSetFlags {
msg = "No pull requests match your search"
hasFilters := false
cmd.Flags().Visit(func(f *pflag.Flag) {
switch f.Name {
case "state", "label", "base", "assignee":
hasFilters = true
}
printMessage(colorErr, msg)
return nil
}
})
title := listHeader(ghrepo.FullName(baseRepo), "pull request", len(listResult.PullRequests), listResult.TotalCount, hasFilters)
// TODO: avoid printing header if piped to a script
fmt.Fprintf(colorableErr(cmd), "\n%s\n\n", title)
table := utils.NewTablePrinter(cmd.OutOrStdout())
for _, pr := range prs {
for _, pr := range listResult.PullRequests {
prNum := strconv.Itoa(pr.Number)
if table.IsTTY() {
prNum = "#" + prNum
}
table.AddField(prNum, nil, colorFuncForState(pr.State))
table.AddField(prNum, nil, colorFuncForPR(pr))
table.AddField(replaceExcessiveWhitespace(pr.Title), nil, nil)
table.AddField(pr.HeadLabel(), nil, utils.Cyan)
table.EndRow()
@ -227,6 +259,22 @@ func prList(cmd *cobra.Command, args []string) error {
return nil
}
func prStateTitleWithColor(pr api.PullRequest) string {
prStateColorFunc := colorFuncForPR(pr)
if pr.State == "OPEN" && pr.IsDraft {
return prStateColorFunc(strings.Title(strings.ToLower("Draft")))
}
return prStateColorFunc(strings.Title(strings.ToLower(pr.State)))
}
func colorFuncForPR(pr api.PullRequest) func(string) string {
if pr.State == "OPEN" && pr.IsDraft {
return utils.Gray
}
return colorFuncForState(pr.State)
}
// colorFuncForState returns a color function for a PR/Issue state
func colorFuncForState(state string) func(string) string {
switch state {
case "OPEN":
@ -248,12 +296,24 @@ func prView(cmd *cobra.Command, args []string) error {
return err
}
baseRepo, err := determineBaseRepo(cmd, ctx)
if err != nil {
return err
var baseRepo ghrepo.Interface
var prArg string
if len(args) > 0 {
prArg = args[0]
if prNum, repo := prFromURL(prArg); repo != nil {
prArg = prNum
baseRepo = repo
}
}
preview, err := cmd.Flags().GetBool("preview")
if baseRepo == nil {
baseRepo, err = determineBaseRepo(cmd, ctx)
if err != nil {
return err
}
}
web, err := cmd.Flags().GetBool("web")
if err != nil {
return err
}
@ -261,27 +321,27 @@ func prView(cmd *cobra.Command, args []string) error {
var openURL string
var pr *api.PullRequest
if len(args) > 0 {
pr, err = prFromArg(apiClient, baseRepo, args[0])
pr, err = prFromArg(apiClient, baseRepo, prArg)
if err != nil {
return err
}
openURL = pr.URL
} else {
prNumber, branchWithOwner, err := prSelectorForCurrentBranch(ctx)
prNumber, branchWithOwner, err := prSelectorForCurrentBranch(ctx, baseRepo)
if err != nil {
return err
}
if prNumber > 0 {
openURL = fmt.Sprintf("https://github.com/%s/pull/%d", ghrepo.FullName(baseRepo), prNumber)
if preview {
if !web {
pr, err = api.PullRequestByNumber(apiClient, baseRepo, prNumber)
if err != nil {
return err
}
}
} else {
pr, err = api.PullRequestForBranch(apiClient, baseRepo, branchWithOwner)
pr, err = api.PullRequestForBranch(apiClient, baseRepo, "", branchWithOwner)
if err != nil {
return err
}
@ -290,24 +350,192 @@ func prView(cmd *cobra.Command, args []string) error {
}
}
if preview {
out := colorableOut(cmd)
return printPrPreview(out, pr)
} else {
if web {
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", openURL)
return utils.OpenInBrowser(openURL)
} else {
out := colorableOut(cmd)
return printPrPreview(out, pr)
}
}
func prClose(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
baseRepo, err := determineBaseRepo(cmd, ctx)
if err != nil {
return err
}
pr, err := prFromArg(apiClient, baseRepo, args[0])
if err != nil {
return err
}
if pr.State == "MERGED" {
err := fmt.Errorf("%s Pull request #%d can't be closed because it was already merged", utils.Red("!"), pr.Number)
return err
} else if pr.Closed {
fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d is already closed\n", utils.Yellow("!"), pr.Number)
return nil
}
err = api.PullRequestClose(apiClient, baseRepo, pr)
if err != nil {
return fmt.Errorf("API call failed: %w", err)
}
fmt.Fprintf(colorableErr(cmd), "%s Closed pull request #%d\n", utils.Red("✔"), pr.Number)
return nil
}
func prReopen(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
baseRepo, err := determineBaseRepo(cmd, ctx)
if err != nil {
return err
}
pr, err := prFromArg(apiClient, baseRepo, args[0])
if err != nil {
return err
}
if pr.State == "MERGED" {
err := fmt.Errorf("%s Pull request #%d can't be reopened because it was already merged", utils.Red("!"), pr.Number)
return err
}
if !pr.Closed {
fmt.Fprintf(colorableErr(cmd), "%s Pull request #%d is already open\n", utils.Yellow("!"), pr.Number)
return nil
}
err = api.PullRequestReopen(apiClient, baseRepo, pr)
if err != nil {
return fmt.Errorf("API call failed: %w", err)
}
fmt.Fprintf(colorableErr(cmd), "%s Reopened pull request #%d\n", utils.Green("✔"), pr.Number)
return nil
}
func prMerge(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
baseRepo, err := determineBaseRepo(cmd, ctx)
if err != nil {
return err
}
var pr *api.PullRequest
if len(args) > 0 {
pr, err = prFromArg(apiClient, baseRepo, args[0])
if err != nil {
return err
}
} else {
prNumber, branchWithOwner, err := prSelectorForCurrentBranch(ctx, baseRepo)
if err != nil {
return err
}
if prNumber != 0 {
pr, err = api.PullRequestByNumber(apiClient, baseRepo, prNumber)
} else {
pr, err = api.PullRequestForBranch(apiClient, baseRepo, "", branchWithOwner)
}
if err != nil {
return err
}
}
if pr.State == "MERGED" {
err := fmt.Errorf("%s Pull request #%d was already merged", utils.Red("!"), pr.Number)
return err
}
rebase, err := cmd.Flags().GetBool("rebase")
if err != nil {
return err
}
squash, err := cmd.Flags().GetBool("squash")
if err != nil {
return err
}
var output string
if rebase {
output = fmt.Sprintf("%s Rebased and merged pull request #%d\n", utils.Green("✔"), pr.Number)
err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodRebase)
} else if squash {
output = fmt.Sprintf("%s Squashed and merged pull request #%d\n", utils.Green("✔"), pr.Number)
err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodSquash)
} else {
output = fmt.Sprintf("%s Merged pull request #%d\n", utils.Green("✔"), pr.Number)
err = api.PullRequestMerge(apiClient, baseRepo, pr, api.PullRequestMergeMethodMerge)
}
if err != nil {
return fmt.Errorf("API call failed: %w", err)
}
fmt.Fprint(colorableOut(cmd), output)
return nil
}
func printPrPreview(out io.Writer, pr *api.PullRequest) error {
// Header (Title and State)
fmt.Fprintln(out, utils.Bold(pr.Title))
fmt.Fprintf(out, "%s", prStateTitleWithColor(*pr))
fmt.Fprintln(out, utils.Gray(fmt.Sprintf(
"%s wants to merge %s into %s from %s",
"%s wants to merge %s into %s from %s",
pr.Author.Login,
utils.Pluralize(pr.Commits.TotalCount, "commit"),
pr.BaseRefName,
pr.HeadRefName,
)))
fmt.Fprintln(out)
// Metadata
if reviewers := prReviewerList(*pr); reviewers != "" {
fmt.Fprint(out, utils.Bold("Reviewers: "))
fmt.Fprintln(out, reviewers)
}
if assignees := prAssigneeList(*pr); assignees != "" {
fmt.Fprint(out, utils.Bold("Assignees: "))
fmt.Fprintln(out, assignees)
}
if labels := prLabelList(*pr); labels != "" {
fmt.Fprint(out, utils.Bold("Labels: "))
fmt.Fprintln(out, labels)
}
if projects := prProjectList(*pr); projects != "" {
fmt.Fprint(out, utils.Bold("Projects: "))
fmt.Fprintln(out, projects)
}
if pr.Milestone.Title != "" {
fmt.Fprint(out, utils.Bold("Milestone: "))
fmt.Fprintln(out, pr.Milestone.Title)
}
// Body
if pr.Body != "" {
fmt.Fprintln(out)
md, err := utils.RenderMarkdown(pr.Body)
@ -315,33 +543,197 @@ func printPrPreview(out io.Writer, pr *api.PullRequest) error {
return err
}
fmt.Fprintln(out, md)
fmt.Fprintln(out)
}
fmt.Fprintln(out)
// Footer
fmt.Fprintf(out, utils.Gray("View this pull request on GitHub: %s\n"), pr.URL)
return nil
}
var prURLRE = regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/pull/(\d+)`)
// Ref. https://developer.github.com/v4/enum/pullrequestreviewstate/
const (
requestedReviewState = "REQUESTED" // This is our own state for review request
approvedReviewState = "APPROVED"
changesRequestedReviewState = "CHANGES_REQUESTED"
commentedReviewState = "COMMENTED"
)
func prFromArg(apiClient *api.Client, baseRepo ghrepo.Interface, arg string) (*api.PullRequest, error) {
if prNumber, err := strconv.Atoi(arg); err == nil {
return api.PullRequestByNumber(apiClient, baseRepo, prNumber)
}
if m := prURLRE.FindStringSubmatch(arg); m != nil {
prNumber, _ := strconv.Atoi(m[3])
return api.PullRequestByNumber(apiClient, baseRepo, prNumber)
}
return api.PullRequestForBranch(apiClient, baseRepo, arg)
type reviewerState struct {
Name string
State string
}
func prSelectorForCurrentBranch(ctx context.Context) (prNumber int, prHeadRef string, err error) {
baseRepo, err := ctx.BaseRepo()
if err != nil {
return
// colorFuncForReviewerState returns a color function for a reviewer state
func colorFuncForReviewerState(state string) func(string) string {
switch state {
case requestedReviewState:
return utils.Yellow
case approvedReviewState:
return utils.Green
case changesRequestedReviewState:
return utils.Red
case commentedReviewState:
return func(str string) string { return str } // Do nothing
default:
return nil
}
}
// formattedReviewerState formats a reviewerState with state color
func formattedReviewerState(reviewer *reviewerState) string {
stateColorFunc := colorFuncForReviewerState(reviewer.State)
return fmt.Sprintf("%s (%s)", reviewer.Name, stateColorFunc(strings.ReplaceAll(strings.Title(strings.ToLower(reviewer.State)), "_", " ")))
}
// prReviewerList generates a reviewer list with their last state
func prReviewerList(pr api.PullRequest) string {
reviewerStates := parseReviewers(pr)
reviewers := make([]string, 0, len(reviewerStates))
sortReviewerStates(reviewerStates)
for _, reviewer := range reviewerStates {
reviewers = append(reviewers, formattedReviewerState(reviewer))
}
reviewerList := strings.Join(reviewers, ", ")
return reviewerList
}
// Ref. https://developer.github.com/v4/union/requestedreviewer/
const teamTypeName = "Team"
const ghostName = "ghost"
// parseReviewers parses given Reviews and ReviewRequests
func parseReviewers(pr api.PullRequest) []*reviewerState {
reviewerStates := make(map[string]*reviewerState)
for _, review := range pr.Reviews.Nodes {
if review.Author.Login != pr.Author.Login {
name := review.Author.Login
if name == "" {
name = ghostName
}
reviewerStates[name] = &reviewerState{
Name: name,
State: review.State,
}
}
}
// Overwrite reviewer's state if a review request for the same reviewer exists.
for _, reviewRequest := range pr.ReviewRequests.Nodes {
name := reviewRequest.RequestedReviewer.Login
if reviewRequest.RequestedReviewer.TypeName == teamTypeName {
name = reviewRequest.RequestedReviewer.Name
}
reviewerStates[name] = &reviewerState{
Name: name,
State: requestedReviewState,
}
}
// Convert map to slice for ease of sort
result := make([]*reviewerState, 0, len(reviewerStates))
for _, reviewer := range reviewerStates {
result = append(result, reviewer)
}
return result
}
// sortReviewerStates puts completed reviews before review requests and sorts names alphabetically
func sortReviewerStates(reviewerStates []*reviewerState) {
sort.Slice(reviewerStates, func(i, j int) bool {
if reviewerStates[i].State == requestedReviewState &&
reviewerStates[j].State != requestedReviewState {
return false
}
if reviewerStates[j].State == requestedReviewState &&
reviewerStates[i].State != requestedReviewState {
return true
}
return reviewerStates[i].Name < reviewerStates[j].Name
})
}
func prAssigneeList(pr api.PullRequest) string {
if len(pr.Assignees.Nodes) == 0 {
return ""
}
AssigneeNames := make([]string, 0, len(pr.Assignees.Nodes))
for _, assignee := range pr.Assignees.Nodes {
AssigneeNames = append(AssigneeNames, assignee.Login)
}
list := strings.Join(AssigneeNames, ", ")
if pr.Assignees.TotalCount > len(pr.Assignees.Nodes) {
list += ", …"
}
return list
}
func prLabelList(pr api.PullRequest) string {
if len(pr.Labels.Nodes) == 0 {
return ""
}
labelNames := make([]string, 0, len(pr.Labels.Nodes))
for _, label := range pr.Labels.Nodes {
labelNames = append(labelNames, label.Name)
}
list := strings.Join(labelNames, ", ")
if pr.Labels.TotalCount > len(pr.Labels.Nodes) {
list += ", …"
}
return list
}
func prProjectList(pr api.PullRequest) string {
if len(pr.ProjectCards.Nodes) == 0 {
return ""
}
projectNames := make([]string, 0, len(pr.ProjectCards.Nodes))
for _, project := range pr.ProjectCards.Nodes {
colName := project.Column.Name
if colName == "" {
colName = "Awaiting triage"
}
projectNames = append(projectNames, fmt.Sprintf("%s (%s)", project.Project.Name, colName))
}
list := strings.Join(projectNames, ", ")
if pr.ProjectCards.TotalCount > len(pr.ProjectCards.Nodes) {
list += ", …"
}
return list
}
var prURLRE = regexp.MustCompile(`^https://github\.com/([^/]+)/([^/]+)/pull/(\d+)`)
func prFromURL(arg string) (string, ghrepo.Interface) {
if m := prURLRE.FindStringSubmatch(arg); m != nil {
return m[3], ghrepo.New(m[1], m[2])
}
return "", nil
}
func prFromArg(apiClient *api.Client, baseRepo ghrepo.Interface, arg string) (*api.PullRequest, error) {
if prNumber, err := strconv.Atoi(strings.TrimPrefix(arg, "#")); err == nil {
return api.PullRequestByNumber(apiClient, baseRepo, prNumber)
}
return api.PullRequestForBranch(apiClient, baseRepo, "", arg)
}
func prSelectorForCurrentBranch(ctx context.Context, baseRepo ghrepo.Interface) (prNumber int, prHeadRef string, err error) {
prHeadRef, err = ctx.Branch()
if err != nil {
return
@ -385,36 +777,58 @@ func prSelectorForCurrentBranch(ctx context.Context) (prNumber int, prHeadRef st
func printPrs(w io.Writer, totalCount int, prs ...api.PullRequest) {
for _, pr := range prs {
prNumber := fmt.Sprintf("#%d", pr.Number)
fmt.Fprintf(w, " %s %s %s", utils.Green(prNumber), text.Truncate(50, replaceExcessiveWhitespace(pr.Title)), utils.Cyan("["+pr.HeadLabel()+"]"))
prStateColorFunc := utils.Green
if pr.IsDraft {
prStateColorFunc = utils.Gray
} else if pr.State == "MERGED" {
prStateColorFunc = utils.Magenta
} else if pr.State == "CLOSED" {
prStateColorFunc = utils.Red
}
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" {
reviewStatus := reviews.ChangesRequested || reviews.Approved || reviews.ReviewRequired
if checks.Total > 0 || reviewStatus {
// show checks & reviews on their own line
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.Fprint(w, summary)
}
if checks.Total > 0 && reviewStatus {
// add padding between checks & reviews
fmt.Fprint(w, " ")
}
if reviews.ChangesRequested {
fmt.Fprint(w, utils.Red("+ Changes requested"))
} else if reviews.ReviewRequired {
fmt.Fprint(w, utils.Yellow("- Review required"))
} else if reviews.Approved {
fmt.Fprint(w, utils.Green("✓ Approved"))
}
} else {
fmt.Fprintf(w, " - %s", prStateTitleWithColor(pr))
}
fmt.Fprint(w, "\n")

View file

@ -6,9 +6,11 @@ import (
"os"
"os/exec"
"github.com/cli/cli/git"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
)
func prCheckout(cmd *cobra.Command, args []string) error {
@ -18,28 +20,44 @@ func prCheckout(cmd *cobra.Command, args []string) error {
if err != nil {
return err
}
// FIXME: duplicates logic from fsContext.BaseRepo
baseRemote, err := remotes.FindByName("upstream", "github", "origin", "*")
if err != nil {
return err
}
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
pr, err := prFromArg(apiClient, baseRemote, args[0])
var baseRepo ghrepo.Interface
prArg := args[0]
if prNum, repo := prFromURL(prArg); repo != nil {
prArg = prNum
baseRepo = repo
}
if baseRepo == nil {
baseRepo, err = determineBaseRepo(cmd, ctx)
if err != nil {
return err
}
}
pr, err := prFromArg(apiClient, baseRepo, prArg)
if err != nil {
return err
}
baseRemote, _ := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName())
// baseRemoteSpec is a repository URL or a remote name to be used in git fetch
baseURLOrName := formatRemoteURL(cmd, ghrepo.FullName(baseRepo))
if baseRemote != nil {
baseURLOrName = baseRemote.Name
}
headRemote := baseRemote
if pr.IsCrossRepository {
headRemote, _ = remotes.FindByRepo(pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name)
}
cmdQueue := [][]string{}
var cmdQueue [][]string
newBranchName := pr.HeadRefName
if headRemote != nil {
// there is an existing git remote for PR head
@ -49,7 +67,7 @@ func prCheckout(cmd *cobra.Command, args []string) error {
cmdQueue = append(cmdQueue, []string{"git", "fetch", headRemote.Name, refSpec})
// local branch already exists
if git.VerifyRef("refs/heads/" + newBranchName) {
if _, err := git.ShowRefs("refs/heads/" + newBranchName); err == nil {
cmdQueue = append(cmdQueue, []string{"git", "checkout", newBranchName})
cmdQueue = append(cmdQueue, []string{"git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)})
} else {
@ -68,18 +86,18 @@ func prCheckout(cmd *cobra.Command, args []string) error {
ref := fmt.Sprintf("refs/pull/%d/head", pr.Number)
if newBranchName == currentBranch {
// PR head matches currently checked out branch
cmdQueue = append(cmdQueue, []string{"git", "fetch", baseRemote.Name, ref})
cmdQueue = append(cmdQueue, []string{"git", "fetch", baseURLOrName, ref})
cmdQueue = append(cmdQueue, []string{"git", "merge", "--ff-only", "FETCH_HEAD"})
} else {
// create a new branch
cmdQueue = append(cmdQueue, []string{"git", "fetch", baseRemote.Name, fmt.Sprintf("%s:%s", ref, newBranchName)})
cmdQueue = append(cmdQueue, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, newBranchName)})
cmdQueue = append(cmdQueue, []string{"git", "checkout", newBranchName})
}
remote := baseRemote.Name
remote := baseURLOrName
mergeRef := ref
if pr.MaintainerCanModify {
remote = fmt.Sprintf("https://github.com/%s/%s.git", pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name)
remote = formatRemoteURL(cmd, fmt.Sprintf("%s/%s", pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name))
mergeRef = fmt.Sprintf("refs/heads/%s", pr.HeadRefName)
}
if mc, err := git.Config(fmt.Sprintf("branch.%s.merge", newBranchName)); err != nil || mc == "" {
@ -92,7 +110,7 @@ func prCheckout(cmd *cobra.Command, args []string) error {
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := utils.PrepareCmd(cmd).Run(); err != nil {
if err := run.PrepareCmd(cmd).Run(); err != nil {
return err
}
}
@ -105,7 +123,7 @@ var prCheckoutCmd = &cobra.Command{
Short: "Check out a pull request in Git",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return errors.New("requires a PR number as an argument")
return errors.New("requires a pull request number as an argument")
}
return nil
},

View file

@ -2,12 +2,15 @@ package command
import (
"bytes"
"encoding/json"
"io/ioutil"
"os/exec"
"strings"
"testing"
"github.com/cli/cli/context"
"github.com/cli/cli/utils"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/test"
)
func TestPRCheckout_sameRepo(t *testing.T) {
@ -20,6 +23,7 @@ func TestPRCheckout_sameRepo(t *testing.T) {
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
@ -40,18 +44,18 @@ func TestPRCheckout_sameRepo(t *testing.T) {
`))
ranCommands := [][]string{}
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify --quiet refs/heads/feature":
case "git show-ref --verify -- refs/heads/feature":
return &errorStub{"exit status: 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &outputStub{}
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(prCheckoutCmd, `pr checkout 123`)
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
@ -92,18 +96,18 @@ func TestPRCheckout_urlArg(t *testing.T) {
`))
ranCommands := [][]string{}
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify --quiet refs/heads/feature":
case "git show-ref --verify -- refs/heads/feature":
return &errorStub{"exit status: 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &outputStub{}
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(prCheckoutCmd, `pr checkout https://github.com/OWNER/REPO/pull/123/files`)
output, err := RunCommand(`pr checkout https://github.com/OWNER/REPO/pull/123/files`)
eq(t, err, nil)
eq(t, output.String(), "")
@ -111,6 +115,68 @@ func TestPRCheckout_urlArg(t *testing.T) {
eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature")
}
func TestPRCheckout_urlArg_differentBase(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "POE",
"defaultBranchRef": {
"name": "master"
}
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
return &errorStub{"exit status: 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout https://github.com/OTHER/POE/pull/123/files`)
eq(t, err, nil)
eq(t, output.String(), "")
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
reqBody := struct {
Variables struct {
Owner string
Repo string
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Owner, "OTHER")
eq(t, reqBody.Variables.Repo, "POE")
eq(t, len(ranCommands), 5)
eq(t, strings.Join(ranCommands[1], " "), "git fetch https://github.com/OTHER/POE.git refs/pull/123/head:feature")
eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.remote https://github.com/OTHER/POE.git")
}
func TestPRCheckout_branchArg(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
@ -121,6 +187,7 @@ func TestPRCheckout_branchArg(t *testing.T) {
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
@ -141,18 +208,18 @@ func TestPRCheckout_branchArg(t *testing.T) {
`))
ranCommands := [][]string{}
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify --quiet refs/heads/feature":
case "git show-ref --verify -- refs/heads/feature":
return &errorStub{"exit status: 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &outputStub{}
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(prCheckoutCmd, `pr checkout hubot:feature`)
output, err := RunCommand(`pr checkout hubot:feature`)
eq(t, err, nil)
eq(t, output.String(), "")
@ -170,6 +237,7 @@ func TestPRCheckout_existingBranch(t *testing.T) {
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
@ -190,18 +258,18 @@ func TestPRCheckout_existingBranch(t *testing.T) {
`))
ranCommands := [][]string{}
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify --quiet refs/heads/feature":
return &outputStub{}
case "git show-ref --verify -- refs/heads/feature":
return &test.OutputStub{}
default:
ranCommands = append(ranCommands, cmd.Args)
return &outputStub{}
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(prCheckoutCmd, `pr checkout 123`)
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
@ -222,6 +290,7 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
@ -242,18 +311,18 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
`))
ranCommands := [][]string{}
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify --quiet refs/heads/feature":
case "git show-ref --verify -- refs/heads/feature":
return &errorStub{"exit status: 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &outputStub{}
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(prCheckoutCmd, `pr checkout 123`)
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
@ -274,6 +343,7 @@ func TestPRCheckout_differentRepo(t *testing.T) {
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
@ -294,18 +364,18 @@ func TestPRCheckout_differentRepo(t *testing.T) {
`))
ranCommands := [][]string{}
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
return &errorStub{"exit status 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &outputStub{}
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(prCheckoutCmd, `pr checkout 123`)
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
@ -326,6 +396,7 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
@ -346,18 +417,18 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
`))
ranCommands := [][]string{}
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
return &outputStub{[]byte("refs/heads/feature\n")}
return &test.OutputStub{Out: []byte("refs/heads/feature\n")}
default:
ranCommands = append(ranCommands, cmd.Args)
return &outputStub{}
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(prCheckoutCmd, `pr checkout 123`)
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
@ -376,6 +447,7 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
@ -396,18 +468,18 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
`))
ranCommands := [][]string{}
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
return &outputStub{[]byte("refs/heads/feature\n")}
return &test.OutputStub{Out: []byte("refs/heads/feature\n")}
default:
ranCommands = append(ranCommands, cmd.Args)
return &outputStub{}
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(prCheckoutCmd, `pr checkout 123`)
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
@ -426,6 +498,7 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) {
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
@ -446,18 +519,18 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) {
`))
ranCommands := [][]string{}
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
return &errorStub{"exit status 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &outputStub{}
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(prCheckoutCmd, `pr checkout 123`)
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")

View file

@ -1,8 +1,10 @@
package command
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/cli/cli/api"
@ -14,6 +16,39 @@ import (
"github.com/spf13/cobra"
)
type defaults struct {
Title string
Body string
}
func computeDefaults(baseRef, headRef string) (defaults, error) {
commits, err := git.Commits(baseRef, headRef)
if err != nil {
return defaults{}, err
}
out := defaults{}
if len(commits) == 1 {
out.Title = commits[0].Title
body, err := git.CommitBody(commits[0].Sha)
if err != nil {
return defaults{}, err
}
out.Body = body
} else {
out.Title = utils.Humanize(headRef)
body := ""
for _, c := range commits {
body += fmt.Sprintf("- %s\n", c.Title)
}
out.Body = body
}
return out, nil
}
func prCreate(cmd *cobra.Command, _ []string) error {
ctx := contextForCommand(cmd)
remotes, err := ctx.Remotes()
@ -42,6 +77,29 @@ func prCreate(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("could not determine the current branch: %w", err)
}
var headRepo ghrepo.Interface
var headRemote *context.Remote
// determine whether the head branch is already pushed to a remote
headBranchPushedTo := determineTrackingBranch(remotes, headBranch)
if headBranchPushedTo != nil {
for _, r := range remotes {
if r.Name != headBranchPushedTo.RemoteName {
continue
}
headRepo = r
headRemote = r
break
}
}
// otherwise, determine the head repository with info obtained from the API
if headRepo == nil {
if r, err := repoContext.HeadRepo(); err == nil {
headRepo = r
}
}
baseBranch, err := cmd.Flags().GetString("base")
if err != nil {
return err
@ -49,70 +107,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 {
@ -123,33 +124,102 @@ func prCreate(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("could not parse body: %w", err)
}
reviewers, err := cmd.Flags().GetStringSlice("reviewer")
if err != nil {
return fmt.Errorf("could not parse reviewers: %w", err)
}
assignees, err := cmd.Flags().GetStringSlice("assignee")
if err != nil {
return fmt.Errorf("could not parse assignees: %w", err)
}
labelNames, err := cmd.Flags().GetStringSlice("label")
if err != nil {
return fmt.Errorf("could not parse labels: %w", err)
}
projectNames, err := cmd.Flags().GetStringSlice("project")
if err != nil {
return fmt.Errorf("could not parse projects: %w", err)
}
var milestoneTitles []string
if milestoneTitle, err := cmd.Flags().GetString("milestone"); err != nil {
return fmt.Errorf("could not parse milestone: %w", err)
} else if milestoneTitle != "" {
milestoneTitles = append(milestoneTitles, milestoneTitle)
}
baseTrackingBranch := baseBranch
if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil {
baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch)
}
defs, defaultsErr := computeDefaults(baseTrackingBranch, headBranch)
isWeb, err := cmd.Flags().GetBool("web")
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)
autofill, err := cmd.Flags().GetBool("fill")
if err != nil {
return fmt.Errorf("could not parse fill: %q", err)
}
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
if (title == "" || body == "") && defaultsErr != nil {
return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
}
} else if autofill {
if defaultsErr != nil {
return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
}
title = defs.Title
body = defs.Body
}
interactive := title == "" || body == ""
if !isWeb {
headBranchLabel := headBranch
if headRepo != nil && !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
}
existingPR, err := api.PullRequestForBranch(client, baseRepo, baseBranch, headBranchLabel)
var notFound *api.NotFoundError
if err != nil && !errors.As(err, &notFound) {
return fmt.Errorf("error checking for existing pull request: %w", err)
}
if err == nil {
return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s", headBranchLabel, baseBranch, existingPR.URL)
}
}
if interactive {
if !isWeb && !autofill {
fmt.Fprintf(colorableErr(cmd), "\nCreating pull request for %s into %s in %s\n\n",
utils.Cyan(headBranch),
utils.Cyan(baseBranch),
ghrepo.FullName(baseRepo))
if (title == "" || body == "") && defaultsErr != nil {
fmt.Fprintf(colorableErr(cmd), "%s warning: could not compute title or body defaults: %s\n", utils.Yellow("!"), defaultsErr)
}
}
tb := issueMetadataState{
Reviewers: reviewers,
Assignees: assignees,
Labels: labelNames,
Projects: projectNames,
Milestones: milestoneTitles,
}
interactive := !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body"))
if !isWeb && !autofill && interactive {
var templateFiles []string
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
templateFiles = githubtemplate.Find(rootDir, "PULL_REQUEST_TEMPLATE")
}
tb, err := titleBodySurvey(cmd, title, body, templateFiles)
err := titleBodySurvey(cmd, &tb, client, baseRepo, title, body, defs, templateFiles, true, baseRepo.ViewerCanTriage())
if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err)
}
@ -169,27 +239,95 @@ 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
// if a head repository could not be determined so far, automatically create
// one by forking the base repository
if headRepo == 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
}
headBranchLabel := headBranch
if !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
}
if headRemote == nil {
headRemote, _ = repoContext.RemoteForRepo(headRepo)
}
// There are two cases when an existing remote for the head repo will be
// missing:
// 1. the head repo was just created by auto-forking;
// 2. an existing fork was discovered by quering the API.
//
// In either case, we want to add the head repo as a new git remote so we
// can push to it.
if headRemote == nil {
headRepoURL := formatRemoteURL(cmd, ghrepo.FullName(headRepo))
// TODO: prevent clashes with another remote of a same name
gitRemote, err := git.AddRemote("fork", headRepoURL)
if err != nil {
return fmt.Errorf("error adding remote: %w", err)
}
headRemote = &context.Remote{
Remote: gitRemote,
Owner: headRepo.RepoOwner(),
Repo: headRepo.RepoName(),
}
}
// automatically push the branch if it hasn't been pushed anywhere yet
if headBranchPushedTo == nil {
pushTries := 0
maxPushTries := 3
for {
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,
}
err = addMetadataToIssueParams(client, baseRepo, params, &tb)
if err != nil {
return err
}
pr, err := api.CreatePullRequest(client, baseRepo, params)
@ -208,7 +346,47 @@ func prCreate(cmd *cobra.Command, _ []string) error {
}
return nil
}
func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef {
refsForLookup := []string{"HEAD"}
var trackingRefs []git.TrackingRef
headBranchConfig := git.ReadBranchConfig(headBranch)
if headBranchConfig.RemoteName != "" {
tr := git.TrackingRef{
RemoteName: headBranchConfig.RemoteName,
BranchName: strings.TrimPrefix(headBranchConfig.MergeRef, "refs/heads/"),
}
trackingRefs = append(trackingRefs, tr)
refsForLookup = append(refsForLookup, tr.String())
}
for _, remote := range remotes {
tr := git.TrackingRef{
RemoteName: remote.Name,
BranchName: headBranch,
}
trackingRefs = append(trackingRefs, tr)
refsForLookup = append(refsForLookup, tr.String())
}
resolvedRefs, _ := git.ShowRefs(refsForLookup...)
if len(resolvedRefs) > 1 {
for _, r := range resolvedRefs[1:] {
if r.Hash != resolvedRefs[0].Hash {
continue
}
for _, tr := range trackingRefs {
if tr.String() != r.Name {
continue
}
return &tr
}
}
}
return nil
}
func generateCompareURL(r ghrepo.Interface, base, head, title, body string) string {
@ -243,4 +421,11 @@ func init() {
prCreateCmd.Flags().StringP("base", "B", "",
"The branch into which you want your code merged")
prCreateCmd.Flags().BoolP("web", "w", false, "Open the web browser to create a pull request")
prCreateCmd.Flags().BoolP("fill", "f", false, "Do not prompt for title/body and just use commit info")
prCreateCmd.Flags().StringSliceP("reviewer", "r", nil, "Request a review from someone by their `login`")
prCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign a person by their `login`")
prCreateCmd.Flags().StringSliceP("label", "l", nil, "Add a label by `name`")
prCreateCmd.Flags().StringSliceP("project", "p", nil, "Add the pull request to a project by `name`")
prCreateCmd.Flags().StringP("milestone", "m", "", "Add the pull request to a milestone by `name`")
}

View file

@ -3,63 +3,47 @@ package command
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"os"
"os/exec"
"strings"
"testing"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/test"
"github.com/cli/cli/utils"
)
func TestPrCreateHelperProcess(*testing.T) {
if test.SkipTestHelperProcess() {
return
}
args := test.GetTestHelperProcessArgs()
switch args[1] {
case "status":
switch args[0] {
case "clean":
case "dirty":
fmt.Println(" M git/git.go")
default:
fmt.Fprintf(os.Stderr, "unknown scenario: %q", args[0])
os.Exit(1)
}
case "push":
default:
fmt.Fprintf(os.Stderr, "unknown command: %q", args[1])
os.Exit(1)
}
os.Exit(0)
}
func TestPRCreate(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`))
origGitCommand := git.GitCommand
git.GitCommand = test.StubExecCommand("TestPrCreateHelperProcess", "clean")
defer func() {
git.GitCommand = origGitCommand
}()
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
output, err := RunCommand(prCreateCmd, `pr create -t "my title" -b "my body"`)
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git push
output, err := RunCommand(`pr create -t "my title" -b "my body"`)
eq(t, err, nil)
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body)
reqBody := struct {
Variables struct {
Input struct {
@ -71,7 +55,7 @@ func TestPRCreate(t *testing.T) {
}
}
}{}
json.Unmarshal(bodyBytes, &reqBody)
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
eq(t, reqBody.Variables.Input.Title, "my title")
@ -82,47 +66,298 @@ func TestPRCreate(t *testing.T) {
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
}
func TestPRCreate_web(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
func TestPRCreate_metadata(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.Verify(t)
ranCommands := [][]string{}
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
ranCommands = append(ranCommands, cmd.Args)
return &outputStub{}
})
defer restoreCmd()
http.Register(
httpmock.GraphQL(`\bviewerPermission\b`),
httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE")))
http.Register(
httpmock.GraphQL(`\bforks\(`),
httpmock.StringResponse(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.Register(
httpmock.GraphQL(`\bpullRequests\(`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
] } } } }
`))
http.Register(
httpmock.GraphQL(`\bteam\(`),
httpmock.StringResponse(`
{ "data": {
"u000": { "login": "MonaLisa", "id": "MONAID" },
"u001": { "login": "hubot", "id": "HUBOTID" },
"repository": {
"l000": { "name": "bug", "id": "BUGID" },
"l001": { "name": "TODO", "id": "TODOID" }
},
"organization": {
"t000": { "slug": "core", "id": "COREID" },
"t001": { "slug": "robots", "id": "ROBOTID" }
}
} }
`))
http.Register(
httpmock.GraphQL(`\bmilestones\(`),
httpmock.StringResponse(`
{ "data": { "repository": { "milestones": {
"nodes": [
{ "title": "GA", "id": "GAID" },
{ "title": "Big One.oh", "id": "BIGONEID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`\brepository\(.+\bprojects\(`),
httpmock.StringResponse(`
{ "data": { "repository": { "projects": {
"nodes": [
{ "name": "Cleanup", "id": "CLEANUPID" },
{ "name": "Roadmap", "id": "ROADMAPID" }
],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`\borganization\(.+\bprojects\(`),
httpmock.StringResponse(`
{ "data": { "organization": { "projects": {
"nodes": [],
"pageInfo": { "hasNextPage": false }
} } } }
`))
http.Register(
httpmock.GraphQL(`\bcreatePullRequest\(`),
httpmock.GraphQLMutation(`
{ "data": { "createPullRequest": { "pullRequest": {
"id": "NEWPULLID",
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`, func(inputs map[string]interface{}) {
eq(t, inputs["title"], "TITLE")
eq(t, inputs["body"], "BODY")
if v, ok := inputs["assigneeIds"]; ok {
t.Errorf("did not expect assigneeIds: %v", v)
}
if v, ok := inputs["userIds"]; ok {
t.Errorf("did not expect userIds: %v", v)
}
}))
http.Register(
httpmock.GraphQL(`\bupdatePullRequest\(`),
httpmock.GraphQLMutation(`
{ "data": { "updatePullRequest": {
"clientMutationId": ""
} } }
`, func(inputs map[string]interface{}) {
eq(t, inputs["pullRequestId"], "NEWPULLID")
eq(t, inputs["assigneeIds"], []interface{}{"MONAID"})
eq(t, inputs["labelIds"], []interface{}{"BUGID", "TODOID"})
eq(t, inputs["projectIds"], []interface{}{"ROADMAPID"})
eq(t, inputs["milestoneId"], "BIGONEID")
}))
http.Register(
httpmock.GraphQL(`\brequestReviews\(`),
httpmock.GraphQLMutation(`
{ "data": { "requestReviews": {
"clientMutationId": ""
} } }
`, func(inputs map[string]interface{}) {
eq(t, inputs["pullRequestId"], "NEWPULLID")
eq(t, inputs["userIds"], []interface{}{"HUBOTID", "MONAID"})
eq(t, inputs["teamIds"], []interface{}{"COREID", "ROBOTID"})
eq(t, inputs["union"], true)
}))
output, err := RunCommand(prCreateCmd, `pr create --web`)
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git push
output, err := RunCommand(`pr create -t TITLE -b BODY -a monalisa -l bug -l todo -p roadmap -m 'big one.oh' -r hubot -r monalisa -r /core -r /robots`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n")
eq(t, len(ranCommands), 3)
eq(t, strings.Join(ranCommands[1], " "), "git push --set-upstream origin HEAD:feature")
eq(t, ranCommands[2][len(ranCommands[2])-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1")
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
}
func TestPRCreate_ReportsUncommittedChanges(t *testing.T) {
initBlankContext("OWNER/REPO", "feature")
func TestPRCreate_withForking(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubRepoResponseWithPermission("OWNER", "REPO", "READ")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "node_id": "NODEID",
"name": "REPO",
"owner": {"login": "myself"},
"clone_url": "http://example.com",
"created_at": "2008-02-25T20:21:40Z"
}
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`))
origGitCommand := git.GitCommand
git.GitCommand = test.StubExecCommand("TestPrCreateHelperProcess", "dirty")
defer func() {
git.GitCommand = origGitCommand
}()
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
output, err := RunCommand(prCreateCmd, `pr create -t "my title" -b "my body"`)
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git remote add
cs.Stub("") // git push
output, err := RunCommand(`pr create -t title -b body`)
eq(t, err, nil)
eq(t, http.Requests[3].URL.Path, "/repos/OWNER/REPO/forks")
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
}
func TestPRCreate_alreadyExists(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }
`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
_, err := RunCommand(`pr create`)
if err == nil {
t.Fatal("error expected, got nil")
}
if err.Error() != "a pull request for branch \"feature\" into branch \"master\" already exists:\nhttps://github.com/OWNER/REPO/pull/123" {
t.Errorf("got error %q", err)
}
}
func TestPRCreate_alreadyExistsDifferentBase(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString("{}"))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git rev-parse
_, err := RunCommand(`pr create -BanotherBase -t"cool" -b"nah"`)
if err != nil {
t.Errorf("got unexpected error %q", err)
}
}
func TestPRCreate_web(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git push
cs.Stub("") // browser
output, err := RunCommand(`pr create --web`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, output.Stderr(), "Opening github.com/OWNER/REPO/compare/master...feature in your browser.\n")
eq(t, len(cs.Calls), 6)
eq(t, strings.Join(cs.Calls[4].Args, " "), "git push --set-upstream origin HEAD:feature")
browserCall := cs.Calls[5].Args
eq(t, browserCall[len(browserCall)-1], "https://github.com/OWNER/REPO/compare/master...feature?expand=1")
}
func TestPRCreate_ReportsUncommittedChanges(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub(" M git/git.go") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git push
output, err := RunCommand(`pr create -t "my title" -b "my body"`)
eq(t, err, nil)
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
@ -149,8 +384,7 @@ func TestPRCreate_cross_repo_same_branch(t *testing.T) {
"name": "REPO",
"owner": {"login": "OWNER"},
"defaultBranchRef": {
"name": "default",
"target": {"oid": "deadbeef"}
"name": "default"
},
"viewerPermission": "READ"
},
@ -160,8 +394,7 @@ func TestPRCreate_cross_repo_same_branch(t *testing.T) {
"name": "REPO",
"owner": {"login": "OWNER"},
"defaultBranchRef": {
"name": "default",
"target": {"oid": "deadbeef"}
"name": "default"
},
"viewerPermission": "READ"
},
@ -169,28 +402,34 @@ func TestPRCreate_cross_repo_same_branch(t *testing.T) {
"name": "REPO",
"owner": {"login": "MYSELF"},
"defaultBranchRef": {
"name": "default",
"target": {"oid": "deadbeef"}
"name": "default"
},
"viewerPermission": "WRITE"
} } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`))
origGitCommand := git.GitCommand
git.GitCommand = test.StubExecCommand("TestPrCreateHelperProcess", "clean")
defer func() {
git.GitCommand = origGitCommand
}()
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
output, err := RunCommand(prCreateCmd, `pr create -t "cross repo" -b "same branch"`)
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git push
output, err := RunCommand(`pr create -t "cross repo" -b "same branch"`)
eq(t, err, nil)
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body)
reqBody := struct {
Variables struct {
Input struct {
@ -202,7 +441,7 @@ func TestPRCreate_cross_repo_same_branch(t *testing.T) {
}
}
}{}
json.Unmarshal(bodyBytes, &reqBody)
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID0")
eq(t, reqBody.Variables.Input.Title, "cross repo")
@ -214,3 +453,386 @@ func TestPRCreate_cross_repo_same_branch(t *testing.T) {
// goal: only care that gql is formatted properly
}
func TestPRCreate_survey_defaults_multicommit(t *testing.T) {
initBlankContext("", "OWNER/REPO", "cool_bug-fixes")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,commit 0\n2345678901,commit 1") // git log
cs.Stub("") // git rev-parse
cs.Stub("") // git push
as, surveyTeardown := initAskStubber()
defer surveyTeardown()
as.Stub([]*QuestionStub{
{
Name: "title",
Default: true,
},
{
Name: "body",
Default: true,
},
})
as.Stub([]*QuestionStub{
{
Name: "confirmation",
Value: 0,
},
})
output, err := RunCommand(`pr create`)
eq(t, err, nil)
bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body)
reqBody := struct {
Variables struct {
Input struct {
RepositoryID string
Title string
Body string
BaseRefName string
HeadRefName string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
expectedBody := "- commit 0\n- commit 1\n"
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
eq(t, reqBody.Variables.Input.Title, "cool bug fixes")
eq(t, reqBody.Variables.Input.Body, expectedBody)
eq(t, reqBody.Variables.Input.BaseRefName, "master")
eq(t, reqBody.Variables.Input.HeadRefName, "cool_bug-fixes")
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
}
func TestPRCreate_survey_defaults_monocommit(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
defer http.Verify(t)
http.Register(httpmock.GraphQL(`\bviewerPermission\b`), httpmock.StringResponse(httpmock.RepoNetworkStubResponse("OWNER", "REPO", "master", "WRITE")))
http.Register(httpmock.GraphQL(`\bforks\(`), httpmock.StringResponse(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.Register(httpmock.GraphQL(`\bpullRequests\(`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
http.Register(httpmock.GraphQL(`\bcreatePullRequest\(`), httpmock.GraphQLMutation(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`, func(inputs map[string]interface{}) {
eq(t, inputs["repositoryId"], "REPOID")
eq(t, inputs["title"], "the sky above the port")
eq(t, inputs["body"], "was the color of a television, turned to a dead channel")
eq(t, inputs["baseRefName"], "master")
eq(t, inputs["headRefName"], "feature")
}))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,the sky above the port") // git log
cs.Stub("was the color of a television, turned to a dead channel") // git show
cs.Stub("") // git rev-parse
cs.Stub("") // git push
as, surveyTeardown := initAskStubber()
defer surveyTeardown()
as.Stub([]*QuestionStub{
{
Name: "title",
Default: true,
},
{
Name: "body",
Default: true,
},
})
as.Stub([]*QuestionStub{
{
Name: "confirmation",
Value: 0,
},
})
output, err := RunCommand(`pr create`)
eq(t, err, nil)
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
}
func TestPRCreate_survey_autofill(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("1234567890,the sky above the port") // git log
cs.Stub("was the color of a television, turned to a dead channel") // git show
cs.Stub("") // git rev-parse
cs.Stub("") // git push
cs.Stub("") // browser open
output, err := RunCommand(`pr create -f`)
eq(t, err, nil)
bodyBytes, _ := ioutil.ReadAll(http.Requests[3].Body)
reqBody := struct {
Variables struct {
Input struct {
RepositoryID string
Title string
Body string
BaseRefName string
HeadRefName string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
expectedBody := "was the color of a television, turned to a dead channel"
eq(t, reqBody.Variables.Input.RepositoryID, "REPOID")
eq(t, reqBody.Variables.Input.Title, "the sky above the port")
eq(t, reqBody.Variables.Input.Body, expectedBody)
eq(t, reqBody.Variables.Input.BaseRefName, "master")
eq(t, reqBody.Variables.Input.HeadRefName, "feature")
eq(t, output.String(), "https://github.com/OWNER/REPO/pull/12\n")
}
func TestPRCreate_defaults_error_autofill(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("") // git log
_, err := RunCommand("pr create -f")
eq(t, err.Error(), "could not compute title or body defaults: could not find any commits between origin/master and feature")
}
func TestPRCreate_defaults_error_web(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("") // git log
_, err := RunCommand("pr create -w")
eq(t, err.Error(), "could not compute title or body defaults: could not find any commits between origin/master and feature")
}
func TestPRCreate_defaults_error_interactive(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createPullRequest": { "pullRequest": {
"URL": "https://github.com/OWNER/REPO/pull/12"
} } } }
`))
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
cs.Stub("") // git config --get-regexp (determineTrackingBranch)
cs.Stub("") // git show-ref --verify (determineTrackingBranch)
cs.Stub("") // git status
cs.Stub("") // git log
cs.Stub("") // git rev-parse
cs.Stub("") // git push
cs.Stub("") // browser open
as, surveyTeardown := initAskStubber()
defer surveyTeardown()
as.Stub([]*QuestionStub{
{
Name: "title",
Default: true,
},
{
Name: "body",
Value: "social distancing",
},
})
as.Stub([]*QuestionStub{
{
Name: "confirmation",
Value: 1,
},
})
output, err := RunCommand(`pr create`)
eq(t, err, nil)
stderr := string(output.Stderr())
eq(t, strings.Contains(stderr, "warning: could not compute title or body defaults: could not find any commits"), true)
}
func Test_determineTrackingBranch_empty(t *testing.T) {
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
remotes := context.Remotes{}
cs.Stub("") // git config --get-regexp (ReadBranchConfig)
cs.Stub("deadbeef HEAD") // git show-ref --verify (ShowRefs)
ref := determineTrackingBranch(remotes, "feature")
if ref != nil {
t.Errorf("expected nil result, got %v", ref)
}
}
func Test_determineTrackingBranch_noMatch(t *testing.T) {
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
remotes := context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Owner: "hubot",
Repo: "Spoon-Knife",
},
&context.Remote{
Remote: &git.Remote{Name: "upstream"},
Owner: "octocat",
Repo: "Spoon-Knife",
},
}
cs.Stub("") // git config --get-regexp (ReadBranchConfig)
cs.Stub(`deadbeef HEAD
deadb00f refs/remotes/origin/feature`) // git show-ref --verify (ShowRefs)
ref := determineTrackingBranch(remotes, "feature")
if ref != nil {
t.Errorf("expected nil result, got %v", ref)
}
}
func Test_determineTrackingBranch_hasMatch(t *testing.T) {
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
remotes := context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Owner: "hubot",
Repo: "Spoon-Knife",
},
&context.Remote{
Remote: &git.Remote{Name: "upstream"},
Owner: "octocat",
Repo: "Spoon-Knife",
},
}
cs.Stub("") // git config --get-regexp (ReadBranchConfig)
cs.Stub(`deadbeef HEAD
deadb00f refs/remotes/origin/feature
deadbeef refs/remotes/upstream/feature`) // git show-ref --verify (ShowRefs)
ref := determineTrackingBranch(remotes, "feature")
if ref == nil {
t.Fatal("expected result, got nil")
}
eq(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/feature", "refs/remotes/upstream/feature"})
eq(t, ref.RemoteName, "upstream")
eq(t, ref.BranchName, "feature")
}
func Test_determineTrackingBranch_respectTrackingConfig(t *testing.T) {
cs, cmdTeardown := test.InitCmdStubber()
defer cmdTeardown()
remotes := context.Remotes{
&context.Remote{
Remote: &git.Remote{Name: "origin"},
Owner: "hubot",
Repo: "Spoon-Knife",
},
}
cs.Stub(`branch.feature.remote origin
branch.feature.merge refs/heads/great-feat`) // git config --get-regexp (ReadBranchConfig)
cs.Stub(`deadbeef HEAD
deadb00f refs/remotes/origin/feature`) // git show-ref --verify (ShowRefs)
ref := determineTrackingBranch(remotes, "feature")
if ref != nil {
t.Errorf("expected nil result, got %v", ref)
}
eq(t, cs.Calls[1].Args, []string{"git", "show-ref", "--verify", "--", "HEAD", "refs/remotes/origin/great-feat", "refs/remotes/origin/feature"})
}

276
command/pr_review.go Normal file
View file

@ -0,0 +1,276 @@
package command
import (
"errors"
"fmt"
"strconv"
"strings"
"github.com/AlecAivazis/survey/v2"
"github.com/spf13/cobra"
"github.com/cli/cli/api"
"github.com/cli/cli/pkg/surveyext"
"github.com/cli/cli/utils"
)
func init() {
prCmd.AddCommand(prReviewCmd)
prReviewCmd.Flags().BoolP("approve", "a", false, "Approve pull request")
prReviewCmd.Flags().BoolP("request-changes", "r", false, "Request changes on a pull request")
prReviewCmd.Flags().BoolP("comment", "c", false, "Comment on a pull request")
prReviewCmd.Flags().StringP("body", "b", "", "Specify the body of a review")
}
var prReviewCmd = &cobra.Command{
Use: "review [{<number> | <url> | <branch>]",
Short: "Add a review to a pull request.",
Args: cobra.MaximumNArgs(1),
Long: `Add a review to either a specified pull request or the pull request associated with the current branch.
Examples:
gh pr review # add a review for the current branch's pull request
gh pr review 123 # add a review for pull request 123
gh pr review -a # mark the current branch's pull request as approved
gh pr review -c -b "interesting" # comment on the current branch's pull request
gh pr review 123 -r -b "needs more ascii art" # request changes on pull request 123
`,
RunE: prReview,
}
func processReviewOpt(cmd *cobra.Command) (*api.PullRequestReviewInput, error) {
found := 0
flag := ""
var state api.PullRequestReviewState
if cmd.Flags().Changed("approve") {
found++
flag = "approve"
state = api.ReviewApprove
}
if cmd.Flags().Changed("request-changes") {
found++
flag = "request-changes"
state = api.ReviewRequestChanges
}
if cmd.Flags().Changed("comment") {
found++
flag = "comment"
state = api.ReviewComment
}
body, err := cmd.Flags().GetString("body")
if err != nil {
return nil, err
}
if found == 0 && body == "" {
return nil, nil // signal interactive mode
} else if found == 0 && body != "" {
return nil, errors.New("--body unsupported without --approve, --request-changes, or --comment")
} else if found > 1 {
return nil, errors.New("need exactly one of --approve, --request-changes, or --comment")
}
if (flag == "request-changes" || flag == "comment") && body == "" {
return nil, fmt.Errorf("body cannot be blank for %s review", flag)
}
return &api.PullRequestReviewInput{
Body: body,
State: state,
}, nil
}
func prReview(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
baseRepo, err := determineBaseRepo(cmd, ctx)
if err != nil {
return fmt.Errorf("could not determine base repo: %w", err)
}
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
var prNum int
branchWithOwner := ""
if len(args) == 0 {
prNum, branchWithOwner, err = prSelectorForCurrentBranch(ctx, baseRepo)
if err != nil {
return fmt.Errorf("could not query for pull request for current branch: %w", err)
}
} else {
prArg, repo := prFromURL(args[0])
if repo != nil {
baseRepo = repo
} else {
prArg = strings.TrimPrefix(args[0], "#")
}
prNum, err = strconv.Atoi(prArg)
if err != nil {
return errors.New("could not parse pull request argument")
}
}
reviewData, err := processReviewOpt(cmd)
if err != nil {
return fmt.Errorf("did not understand desired review action: %w", err)
}
var pr *api.PullRequest
if prNum > 0 {
pr, err = api.PullRequestByNumber(apiClient, baseRepo, prNum)
if err != nil {
return fmt.Errorf("could not find pull request: %w", err)
}
} else {
pr, err = api.PullRequestForBranch(apiClient, baseRepo, "", branchWithOwner)
if err != nil {
return fmt.Errorf("could not find pull request: %w", err)
}
prNum = pr.Number
}
out := colorableOut(cmd)
if reviewData == nil {
reviewData, err = reviewSurvey(cmd)
if err != nil {
return err
}
if reviewData == nil && err == nil {
fmt.Fprint(out, "Discarding.\n")
return nil
}
}
err = api.AddReview(apiClient, pr, reviewData)
if err != nil {
return fmt.Errorf("failed to create review: %w", err)
}
switch reviewData.State {
case api.ReviewComment:
fmt.Fprintf(out, "%s Reviewed pull request #%d\n", utils.Gray("-"), prNum)
case api.ReviewApprove:
fmt.Fprintf(out, "%s Approved pull request #%d\n", utils.Green("✓"), prNum)
case api.ReviewRequestChanges:
fmt.Fprintf(out, "%s Requested changes to pull request #%d\n", utils.Red("+"), prNum)
}
return nil
}
func reviewSurvey(cmd *cobra.Command) (*api.PullRequestReviewInput, error) {
editorCommand, err := determineEditor(cmd)
if err != nil {
return nil, err
}
typeAnswers := struct {
ReviewType string
}{}
typeQs := []*survey.Question{
{
Name: "reviewType",
Prompt: &survey.Select{
Message: "What kind of review do you want to give?",
Options: []string{
"Comment",
"Approve",
"Request changes",
},
},
},
}
err = SurveyAsk(typeQs, &typeAnswers)
if err != nil {
return nil, err
}
var reviewState api.PullRequestReviewState
switch typeAnswers.ReviewType {
case "Approve":
reviewState = api.ReviewApprove
case "Request changes":
reviewState = api.ReviewRequestChanges
case "Comment":
reviewState = api.ReviewComment
default:
panic("unreachable state")
}
bodyAnswers := struct {
Body string
}{}
blankAllowed := false
if reviewState == api.ReviewApprove {
blankAllowed = true
}
bodyQs := []*survey.Question{
&survey.Question{
Name: "body",
Prompt: &surveyext.GhEditor{
BlankAllowed: blankAllowed,
EditorCommand: editorCommand,
Editor: &survey.Editor{
Message: "Review body",
FileName: "*.md",
},
},
},
}
err = SurveyAsk(bodyQs, &bodyAnswers)
if err != nil {
return nil, err
}
if bodyAnswers.Body == "" && (reviewState == api.ReviewComment || reviewState == api.ReviewRequestChanges) {
return nil, errors.New("this type of review cannot be blank")
}
if len(bodyAnswers.Body) > 0 {
out := colorableOut(cmd)
renderedBody, err := utils.RenderMarkdown(bodyAnswers.Body)
if err != nil {
return nil, err
}
fmt.Fprintf(out, "Got:\n%s", renderedBody)
}
confirm := false
confirmQs := []*survey.Question{
{
Name: "confirm",
Prompt: &survey.Confirm{
Message: "Submit?",
Default: true,
},
},
}
err = SurveyAsk(confirmQs, &confirm)
if err != nil {
return nil, err
}
if !confirm {
return nil, nil
}
return &api.PullRequestReviewInput{
Body: bodyAnswers.Body,
State: reviewState,
}, nil
}

403
command/pr_review_test.go Normal file
View file

@ -0,0 +1,403 @@
package command
import (
"bytes"
"encoding/json"
"io/ioutil"
"regexp"
"testing"
"github.com/cli/cli/test"
)
func TestPRReview_validation(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
for _, cmd := range []string{
`pr review --approve --comment 123`,
`pr review --approve --comment -b"hey" 123`,
} {
http.StubRepoResponse("OWNER", "REPO")
_, err := RunCommand(cmd)
if err == nil {
t.Fatal("expected error")
}
eq(t, err.Error(), "did not understand desired review action: need exactly one of --approve, --request-changes, or --comment")
}
}
func TestPRReview_bad_body(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
_, err := RunCommand(`pr review -b "radical"`)
if err == nil {
t.Fatal("expected error")
}
eq(t, err.Error(), "did not understand desired review action: --body unsupported without --approve, --request-changes, or --comment")
}
func TestPRReview_url_arg(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
"id": "foobar123",
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO",
"defaultBranchRef": {
"name": "master"
}
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } } `))
http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
output, err := RunCommand("pr review --approve https://github.com/OWNER/REPO/pull/123")
if err != nil {
t.Fatalf("error running pr review: %s", err)
}
test.ExpectLines(t, output.String(), "Approved pull request #123")
bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body)
reqBody := struct {
Variables struct {
Input struct {
PullRequestID string
Event string
Body string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.PullRequestID, "foobar123")
eq(t, reqBody.Variables.Input.Event, "APPROVE")
eq(t, reqBody.Variables.Input.Body, "")
}
func TestPRReview_number_arg(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequest": {
"id": "foobar123",
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO",
"defaultBranchRef": {
"name": "master"
}
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } } `))
http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
output, err := RunCommand("pr review --approve 123")
if err != nil {
t.Fatalf("error running pr review: %s", err)
}
test.ExpectLines(t, output.String(), "Approved pull request #123")
bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body)
reqBody := struct {
Variables struct {
Input struct {
PullRequestID string
Event string
Body string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.PullRequestID, "foobar123")
eq(t, reqBody.Variables.Input.Event, "APPROVE")
eq(t, reqBody.Variables.Input.Body, "")
}
func TestPRReview_no_arg(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`))
http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
output, err := RunCommand(`pr review --comment -b "cool story"`)
if err != nil {
t.Fatalf("error running pr review: %s", err)
}
test.ExpectLines(t, output.String(), "- Reviewed pull request #123")
bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body)
reqBody := struct {
Variables struct {
Input struct {
PullRequestID string
Event string
Body string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.PullRequestID, "foobar123")
eq(t, reqBody.Variables.Input.Event, "COMMENT")
eq(t, reqBody.Variables.Input.Body, "cool story")
}
func TestPRReview_blank_comment(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
_, err := RunCommand(`pr review --comment 123`)
eq(t, err.Error(), "did not understand desired review action: body cannot be blank for comment review")
}
func TestPRReview_blank_request_changes(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
_, err := RunCommand(`pr review -r 123`)
eq(t, err.Error(), "did not understand desired review action: body cannot be blank for request-changes review")
}
func TestPRReview(t *testing.T) {
type c struct {
Cmd string
ExpectedEvent string
ExpectedBody string
}
cases := []c{
c{`pr review --request-changes -b"bad"`, "REQUEST_CHANGES", "bad"},
c{`pr review --approve`, "APPROVE", ""},
c{`pr review --approve -b"hot damn"`, "APPROVE", "hot damn"},
c{`pr review --comment --body "i donno"`, "COMMENT", "i donno"},
}
for _, kase := range cases {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
_, err := RunCommand(kase.Cmd)
if err != nil {
t.Fatalf("got unexpected error running %s: %s", kase.Cmd, err)
}
bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body)
reqBody := struct {
Variables struct {
Input struct {
Event string
Body string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.Event, kase.ExpectedEvent)
eq(t, reqBody.Variables.Input.Body, kase.ExpectedBody)
}
}
func TestPRReview_interactive(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
as, teardown := initAskStubber()
defer teardown()
as.Stub([]*QuestionStub{
{
Name: "reviewType",
Value: "Approve",
},
})
as.Stub([]*QuestionStub{
{
Name: "body",
Value: "cool story",
},
})
as.Stub([]*QuestionStub{
{
Name: "confirm",
Value: true,
},
})
output, err := RunCommand(`pr review`)
if err != nil {
t.Fatalf("got unexpected error running pr review: %s", err)
}
test.ExpectLines(t, output.String(),
"Approved pull request #123",
"Got:",
"cool.*story") // weird because markdown rendering puts a bunch of junk between works
bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body)
reqBody := struct {
Variables struct {
Input struct {
Event string
Body string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.Event, "APPROVE")
eq(t, reqBody.Variables.Input.Body, "cool story")
}
func TestPRReview_interactive_no_body(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
as, teardown := initAskStubber()
defer teardown()
as.Stub([]*QuestionStub{
{
Name: "reviewType",
Value: "Request changes",
},
})
as.Stub([]*QuestionStub{
{
Name: "body",
Default: true,
},
})
as.Stub([]*QuestionStub{
{
Name: "confirm",
Value: true,
},
})
_, err := RunCommand(`pr review`)
if err == nil {
t.Fatal("expected error")
}
eq(t, err.Error(), "this type of review cannot be blank")
}
func TestPRReview_interactive_blank_approve(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }
`))
http.StubResponse(200, bytes.NewBufferString(`{"data": {} }`))
as, teardown := initAskStubber()
defer teardown()
as.Stub([]*QuestionStub{
{
Name: "reviewType",
Value: "Approve",
},
})
as.Stub([]*QuestionStub{
{
Name: "body",
Default: true,
},
})
as.Stub([]*QuestionStub{
{
Name: "confirm",
Value: true,
},
})
output, err := RunCommand(`pr review`)
if err != nil {
t.Fatalf("got unexpected error running pr review: %s", err)
}
unexpect := regexp.MustCompile("Got:")
if unexpect.MatchString(output.String()) {
t.Errorf("did not expect to see body printed in %s", output.String())
}
test.ExpectLines(t, output.String(), "Approved pull request #123")
bodyBytes, _ := ioutil.ReadAll(http.Requests[2].Body)
reqBody := struct {
Variables struct {
Input struct {
Event string
Body string
}
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Input.Event, "APPROVE")
eq(t, reqBody.Variables.Input.Body, "")
}

File diff suppressed because it is too large Load diff

View file

@ -2,21 +2,47 @@ package command
import (
"fmt"
"net/url"
"os"
"path"
"strings"
"text/template"
"time"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
func init() {
RootCmd.AddCommand(repoCmd)
repoCmd.AddCommand(repoCloneCmd)
repoCmd.AddCommand(repoCreateCmd)
repoCreateCmd.Flags().StringP("description", "d", "", "Description of repository")
repoCreateCmd.Flags().StringP("homepage", "h", "", "Repository home page URL")
repoCreateCmd.Flags().StringP("team", "t", "", "The name of the organization team to be granted access")
repoCreateCmd.Flags().Bool("enable-issues", true, "Enable issues in the new repository")
repoCreateCmd.Flags().Bool("enable-wiki", true, "Enable wiki in the new repository")
repoCreateCmd.Flags().Bool("public", false, "Make the new repository public")
repoCmd.AddCommand(repoForkCmd)
repoForkCmd.Flags().String("clone", "prompt", "Clone fork: {true|false|prompt}")
repoForkCmd.Flags().String("remote", "prompt", "Add remote for fork: {true|false|prompt}")
repoForkCmd.Flags().Lookup("clone").NoOptDefVal = "true"
repoForkCmd.Flags().Lookup("remote").NoOptDefVal = "true"
repoCmd.AddCommand(repoViewCmd)
repoViewCmd.Flags().BoolP("web", "w", false, "Open a repository in the browser")
}
var repoCmd = &cobra.Command{
Use: "repo",
Short: "View repositories",
Short: "Create, clone, fork, and view repositories",
Long: `Work with GitHub repositories.
A repository can be supplied as an argument in any of the following formats:
@ -24,34 +50,533 @@ A repository can be supplied as an argument in any of the following formats:
- by URL, e.g. "https://github.com/OWNER/REPO"`,
}
var repoViewCmd = &cobra.Command{
Use: "view [<repo>]",
Short: "View a repository in the browser",
Long: `View a GitHub repository in the browser.
var repoCloneCmd = &cobra.Command{
Use: "clone <repository> [<directory>]",
Args: cobra.MinimumNArgs(1),
Short: "Clone a repository locally",
Long: `Clone a GitHub repository locally.
With no argument, the repository for the current directory is opened.`,
To pass 'git clone' flags, separate them with '--'.`,
RunE: repoClone,
}
var repoCreateCmd = &cobra.Command{
Use: "create [<name>]",
Short: "Create a new repository",
Long: `Create a new GitHub repository.
Use the "ORG/NAME" syntax to create a repository within your organization.`,
RunE: repoCreate,
}
var repoForkCmd = &cobra.Command{
Use: "fork [<repository>]",
Short: "Create a fork of a repository",
Long: `Create a fork of a repository.
With no argument, creates a fork of the current repository. Otherwise, forks the specified repository.`,
RunE: repoFork,
}
var repoViewCmd = &cobra.Command{
Use: "view [<repository>]",
Short: "View a repository",
Long: `Display the description and the README of a GitHub repository.
With no argument, the repository for the current directory is displayed.
With '--web', open the repository in a web browser instead.`,
RunE: repoView,
}
func parseCloneArgs(extraArgs []string) (args []string, target string) {
args = extraArgs
if len(args) > 0 {
if !strings.HasPrefix(args[0], "-") {
target, args = args[0], args[1:]
}
}
return
}
func runClone(cloneURL string, args []string) (target string, err error) {
cloneArgs, target := parseCloneArgs(args)
cloneArgs = append(cloneArgs, cloneURL)
// If the args contain an explicit target, pass it to clone
// otherwise, parse the URL to determine where git cloned it to so we can return it
if target != "" {
cloneArgs = append(cloneArgs, target)
} else {
target = path.Base(strings.TrimSuffix(cloneURL, ".git"))
}
cloneArgs = append([]string{"clone"}, cloneArgs...)
cloneCmd := git.GitCommand(cloneArgs...)
cloneCmd.Stdin = os.Stdin
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
err = run.PrepareCmd(cloneCmd).Run()
return
}
func repoClone(cmd *cobra.Command, args []string) error {
cloneURL := args[0]
if !strings.Contains(cloneURL, ":") {
cloneURL = formatRemoteURL(cmd, cloneURL)
}
var repo ghrepo.Interface
var parentRepo ghrepo.Interface
// TODO: consider caching and reusing `git.ParseSSHConfig().Translator()`
// here to handle hostname aliases in SSH remotes
if u, err := git.ParseURL(cloneURL); err == nil {
repo, _ = ghrepo.FromURL(u)
}
if repo != nil {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
parentRepo, err = api.RepoParent(apiClient, repo)
if err != nil {
return err
}
}
cloneDir, err := runClone(cloneURL, args[1:])
if err != nil {
return err
}
if parentRepo != nil {
err := addUpstreamRemote(cmd, parentRepo, cloneDir)
if err != nil {
return err
}
}
return nil
}
func addUpstreamRemote(cmd *cobra.Command, parentRepo ghrepo.Interface, cloneDir string) error {
upstreamURL := formatRemoteURL(cmd, ghrepo.FullName(parentRepo))
cloneCmd := git.GitCommand("-C", cloneDir, "remote", "add", "-f", "upstream", upstreamURL)
cloneCmd.Stdout = os.Stdout
cloneCmd.Stderr = os.Stderr
return run.PrepareCmd(cloneCmd).Run()
}
func repoCreate(cmd *cobra.Command, args []string) error {
projectDir, projectDirErr := git.ToplevelDir()
orgName := ""
teamSlug, err := cmd.Flags().GetString("team")
if err != nil {
return err
}
var name string
if len(args) > 0 {
name = args[0]
if strings.Contains(name, "/") {
newRepo := ghrepo.FromFullName(name)
orgName = newRepo.RepoOwner()
name = newRepo.RepoName()
}
} else {
if projectDirErr != nil {
return projectDirErr
}
name = path.Base(projectDir)
}
isPublic, err := cmd.Flags().GetBool("public")
if err != nil {
return err
}
hasIssuesEnabled, err := cmd.Flags().GetBool("enable-issues")
if err != nil {
return err
}
hasWikiEnabled, err := cmd.Flags().GetBool("enable-wiki")
if err != nil {
return err
}
description, err := cmd.Flags().GetString("description")
if err != nil {
return err
}
homepage, err := cmd.Flags().GetString("homepage")
if err != nil {
return err
}
// TODO: move this into constant within `api`
visibility := "PRIVATE"
if isPublic {
visibility = "PUBLIC"
}
input := api.RepoCreateInput{
Name: name,
Visibility: visibility,
OwnerID: orgName,
TeamID: teamSlug,
Description: description,
HomepageURL: homepage,
HasIssuesEnabled: hasIssuesEnabled,
HasWikiEnabled: hasWikiEnabled,
}
ctx := contextForCommand(cmd)
client, err := apiClientForContext(ctx)
if err != nil {
return err
}
repo, err := api.RepoCreate(client, input)
if err != nil {
return err
}
out := cmd.OutOrStdout()
greenCheck := utils.Green("✓")
isTTY := false
if outFile, isFile := out.(*os.File); isFile {
isTTY = utils.IsTerminal(outFile)
if isTTY {
// FIXME: duplicates colorableOut
out = utils.NewColorable(outFile)
}
}
if isTTY {
fmt.Fprintf(out, "%s Created repository %s on GitHub\n", greenCheck, ghrepo.FullName(repo))
} else {
fmt.Fprintln(out, repo.URL)
}
remoteURL := formatRemoteURL(cmd, ghrepo.FullName(repo))
if projectDirErr == nil {
_, err = git.AddRemote("origin", remoteURL)
if err != nil {
return err
}
if isTTY {
fmt.Fprintf(out, "%s Added remote %s\n", greenCheck, remoteURL)
}
} else if isTTY {
doSetup := false
err := Confirm(fmt.Sprintf("Create a local project directory for %s?", ghrepo.FullName(repo)), &doSetup)
if err != nil {
return err
}
if doSetup {
path := repo.Name
gitInit := git.GitCommand("init", path)
gitInit.Stdout = os.Stdout
gitInit.Stderr = os.Stderr
err = run.PrepareCmd(gitInit).Run()
if err != nil {
return err
}
gitRemoteAdd := git.GitCommand("-C", path, "remote", "add", "origin", remoteURL)
gitRemoteAdd.Stdout = os.Stdout
gitRemoteAdd.Stderr = os.Stderr
err = run.PrepareCmd(gitRemoteAdd).Run()
if err != nil {
return err
}
fmt.Fprintf(out, "%s Initialized repository in './%s/'\n", greenCheck, path)
}
}
return nil
}
func isURL(arg string) bool {
return strings.HasPrefix(arg, "http:/") || strings.HasPrefix(arg, "https:/")
}
var Since = func(t time.Time) time.Duration {
return time.Since(t)
}
func repoFork(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
clonePref, err := cmd.Flags().GetString("clone")
if err != nil {
return err
}
remotePref, err := cmd.Flags().GetString("remote")
if err != nil {
return err
}
apiClient, err := apiClientForContext(ctx)
if err != nil {
return fmt.Errorf("unable to create client: %w", err)
}
var repoToFork ghrepo.Interface
inParent := false // whether or not we're forking the repo we're currently "in"
if len(args) == 0 {
baseRepo, err := determineBaseRepo(cmd, ctx)
if err != nil {
return fmt.Errorf("unable to determine base repository: %w", err)
}
inParent = true
repoToFork = baseRepo
} else {
repoArg := args[0]
if isURL(repoArg) {
parsedURL, err := url.Parse(repoArg)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
repoToFork, err = ghrepo.FromURL(parsedURL)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
} else if strings.HasPrefix(repoArg, "git@") {
parsedURL, err := git.ParseURL(repoArg)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
repoToFork, err = ghrepo.FromURL(parsedURL)
if err != nil {
return fmt.Errorf("did not understand argument: %w", err)
}
} else {
repoToFork = ghrepo.FromFullName(repoArg)
if repoToFork.RepoName() == "" || repoToFork.RepoOwner() == "" {
return fmt.Errorf("could not parse owner or repo name from %s", repoArg)
}
}
}
greenCheck := utils.Green("✓")
out := colorableOut(cmd)
s := utils.Spinner(out)
loading := utils.Gray("Forking ") + utils.Bold(utils.Gray(ghrepo.FullName(repoToFork))) + utils.Gray("...")
s.Suffix = " " + loading
s.FinalMSG = utils.Gray(fmt.Sprintf("- %s\n", loading))
utils.StartSpinner(s)
forkedRepo, err := api.ForkRepo(apiClient, repoToFork)
if err != nil {
utils.StopSpinner(s)
return fmt.Errorf("failed to fork: %w", err)
}
s.Stop()
// This is weird. There is not an efficient way to determine via the GitHub API whether or not a
// given user has forked a given repo. We noticed, also, that the create fork API endpoint just
// returns the fork repo data even if it already exists -- with no change in status code or
// anything. We thus check the created time to see if the repo is brand new or not; if it's not,
// we assume the fork already existed and report an error.
createdAgo := Since(forkedRepo.CreatedAt)
if createdAgo > time.Minute {
fmt.Fprintf(out, "%s %s %s\n",
utils.Yellow("!"),
utils.Bold(ghrepo.FullName(forkedRepo)),
"already exists")
} else {
fmt.Fprintf(out, "%s Created fork %s\n", greenCheck, utils.Bold(ghrepo.FullName(forkedRepo)))
}
if (inParent && remotePref == "false") || (!inParent && clonePref == "false") {
return nil
}
if inParent {
remotes, err := ctx.Remotes()
if err != nil {
return err
}
if remote, err := remotes.FindByRepo(forkedRepo.RepoOwner(), forkedRepo.RepoName()); err == nil {
fmt.Fprintf(out, "%s Using existing remote %s\n", greenCheck, utils.Bold(remote.Name))
return nil
}
remoteDesired := remotePref == "true"
if remotePref == "prompt" {
err = Confirm("Would you like to add a remote for the fork?", &remoteDesired)
if err != nil {
return fmt.Errorf("failed to prompt: %w", err)
}
}
if remoteDesired {
remoteName := "origin"
remotes, err := ctx.Remotes()
if err != nil {
return err
}
if _, err := remotes.FindByName(remoteName); err == nil {
renameTarget := "upstream"
renameCmd := git.GitCommand("remote", "rename", remoteName, renameTarget)
err = run.PrepareCmd(renameCmd).Run()
if err != nil {
return err
}
fmt.Fprintf(out, "%s Renamed %s remote to %s\n", greenCheck, utils.Bold(remoteName), utils.Bold(renameTarget))
}
forkedRepoCloneURL := formatRemoteURL(cmd, ghrepo.FullName(forkedRepo))
_, err = git.AddRemote(remoteName, forkedRepoCloneURL)
if err != nil {
return fmt.Errorf("failed to add remote: %w", err)
}
fmt.Fprintf(out, "%s Added remote %s\n", greenCheck, utils.Bold(remoteName))
}
} else {
cloneDesired := clonePref == "true"
if clonePref == "prompt" {
err = Confirm("Would you like to clone the fork?", &cloneDesired)
if err != nil {
return fmt.Errorf("failed to prompt: %w", err)
}
}
if cloneDesired {
forkedRepoCloneURL := formatRemoteURL(cmd, ghrepo.FullName(forkedRepo))
cloneDir, err := runClone(forkedRepoCloneURL, []string{})
if err != nil {
return fmt.Errorf("failed to clone fork: %w", err)
}
err = addUpstreamRemote(cmd, repoToFork, cloneDir)
if err != nil {
return err
}
fmt.Fprintf(out, "%s Cloned fork\n", greenCheck)
}
}
return nil
}
var Confirm = func(prompt string, result *bool) error {
p := &survey.Confirm{
Message: prompt,
Default: true,
}
return survey.AskOne(p, result)
}
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 strings.HasPrefix(repoArg, "http:/") || strings.HasPrefix(repoArg, "https:/") {
openURL = repoArg
if isURL(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)
}
}
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL))
return utils.OpenInBrowser(openURL)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
repo, err := api.GitHubRepo(apiClient, toView)
if err != nil {
return err
}
web, err := cmd.Flags().GetBool("web")
if err != nil {
return err
}
fullName := ghrepo.FullName(toView)
openURL := fmt.Sprintf("https://github.com/%s", fullName)
if web {
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", displayURL(openURL))
return utils.OpenInBrowser(openURL)
}
repoTmpl := `
{{.FullName}}
{{.Description}}
{{.Readme}}
{{.View}}
`
tmpl, err := template.New("repo").Parse(repoTmpl)
if err != nil {
return err
}
readmeContent, _ := api.RepositoryReadme(apiClient, fullName)
if readmeContent == "" {
readmeContent = utils.Gray("No README provided")
}
description := repo.Description
if description == "" {
description = utils.Gray("No description provided")
}
repoData := struct {
FullName string
Description string
Readme string
View string
}{
FullName: utils.Bold(fullName),
Description: description,
Readme: readmeContent,
View: utils.Gray(fmt.Sprintf("View this repository on GitHub: %s", openURL)),
}
out := colorableOut(cmd)
err = tmpl.Execute(out, repoData)
if err != nil {
return err
}
return nil
}

View file

@ -1,26 +1,731 @@
package command
import (
"bytes"
"encoding/json"
"io/ioutil"
"os/exec"
"reflect"
"regexp"
"strings"
"testing"
"time"
"github.com/briandowns/spinner"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/test"
"github.com/cli/cli/utils"
)
func TestRepoView(t *testing.T) {
initBlankContext("OWNER/REPO", "master")
func stubSpinner() {
// not bothering with teardown since we never want spinners when doing tests
utils.StartSpinner = func(_ *spinner.Spinner) {
}
utils.StopSpinner = func(_ *spinner.Spinner) {
}
}
func TestRepoFork_already_forked(t *testing.T) {
stubSpinner()
initContext = func() context.Context {
ctx := context.NewBlank()
ctx.SetBaseRepo("OWNER/REPO")
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
output, err := RunCommand("repo fork --remote=false")
if err != nil {
t.Errorf("got unexpected error: %v", err)
}
r := regexp.MustCompile(`someone/REPO already exists`)
if !r.MatchString(output.String()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
}
func TestRepoFork_reuseRemote(t *testing.T) {
stubSpinner()
initContext = func() context.Context {
ctx := context.NewBlank()
ctx.SetBaseRepo("OWNER/REPO")
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"upstream": "OWNER/REPO",
"origin": "someone/REPO",
})
return ctx
}
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
output, err := RunCommand("repo fork")
if err != nil {
t.Errorf("got unexpected error: %v", err)
}
if !strings.Contains(output.String(), "Using existing remote origin") {
t.Errorf("output did not match: %q", output)
return
}
}
func stubSince(d time.Duration) func() {
originalSince := Since
Since = func(t time.Time) time.Duration {
return d
}
return func() {
Since = originalSince
}
}
func TestRepoFork_in_parent(t *testing.T) {
stubSpinner()
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
output, err := RunCommand("repo fork --remote=false")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.Stderr(), "")
r := regexp.MustCompile(`Created fork someone/REPO`)
if !r.MatchString(output.String()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
}
func TestRepoFork_outside(t *testing.T) {
stubSpinner()
tests := []struct {
name string
args string
}{
{
name: "url arg",
args: "repo fork --clone=false http://github.com/OWNER/REPO.git",
},
{
name: "full name arg",
args: "repo fork --clone=false OWNER/REPO",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
defer http.StubWithFixture(200, "forkResult.json")()
output, err := RunCommand(tt.args)
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.Stderr(), "")
r := regexp.MustCompile(`Created fork someone/REPO`)
if !r.MatchString(output.String()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
})
}
}
func TestRepoFork_in_parent_yes(t *testing.T) {
stubSpinner()
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
var seenCmds []*exec.Cmd
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmds = append(seenCmds, cmd)
return &test.OutputStub{}
})()
output, err := RunCommand("repo fork --remote")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
expectedCmds := []string{
"git remote rename origin upstream",
"git remote add -f origin https://github.com/someone/REPO.git",
}
for x, cmd := range seenCmds {
eq(t, strings.Join(cmd.Args, " "), expectedCmds[x])
}
eq(t, output.Stderr(), "")
test.ExpectLines(t, output.String(),
"Created fork someone/REPO",
"Added remote origin")
}
func TestRepoFork_outside_yes(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
defer http.StubWithFixture(200, "forkResult.json")()
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs.Stub("") // git remote add
output, err := RunCommand("repo fork --clone OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.Stderr(), "")
eq(t, strings.Join(cs.Calls[0].Args, " "), "git clone https://github.com/someone/REPO.git")
eq(t, strings.Join(cs.Calls[1].Args, " "), "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git")
test.ExpectLines(t, output.String(),
"Created fork someone/REPO",
"Cloned fork")
}
func TestRepoFork_outside_survey_yes(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
defer http.StubWithFixture(200, "forkResult.json")()
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs.Stub("") // git remote add
oldConfirm := Confirm
Confirm = func(_ string, result *bool) error {
*result = true
return nil
}
defer func() { Confirm = oldConfirm }()
output, err := RunCommand("repo fork OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.Stderr(), "")
eq(t, strings.Join(cs.Calls[0].Args, " "), "git clone https://github.com/someone/REPO.git")
eq(t, strings.Join(cs.Calls[1].Args, " "), "git -C REPO remote add -f upstream https://github.com/OWNER/REPO.git")
test.ExpectLines(t, output.String(),
"Created fork someone/REPO",
"Cloned fork")
}
func TestRepoFork_outside_survey_no(t *testing.T) {
stubSpinner()
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
defer http.StubWithFixture(200, "forkResult.json")()
cmdRun := false
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
cmdRun = true
return &test.OutputStub{}
})()
oldConfirm := Confirm
Confirm = func(_ string, result *bool) error {
*result = false
return nil
}
defer func() { Confirm = oldConfirm }()
output, err := RunCommand("repo fork OWNER/REPO")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.Stderr(), "")
eq(t, cmdRun, false)
r := regexp.MustCompile(`Created fork someone/REPO`)
if !r.MatchString(output.String()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
}
func TestRepoFork_in_parent_survey_yes(t *testing.T) {
stubSpinner()
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
var seenCmds []*exec.Cmd
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmds = append(seenCmds, cmd)
return &test.OutputStub{}
})()
oldConfirm := Confirm
Confirm = func(_ string, result *bool) error {
*result = true
return nil
}
defer func() { Confirm = oldConfirm }()
output, err := RunCommand("repo fork")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
expectedCmds := []string{
"git remote rename origin upstream",
"git remote add -f origin https://github.com/someone/REPO.git",
}
for x, cmd := range seenCmds {
eq(t, strings.Join(cmd.Args, " "), expectedCmds[x])
}
eq(t, output.Stderr(), "")
test.ExpectLines(t, output.String(),
"Created fork someone/REPO",
"Renamed origin remote to upstream",
"Added remote origin")
}
func TestRepoFork_in_parent_survey_no(t *testing.T) {
stubSpinner()
initBlankContext("", "OWNER/REPO", "master")
defer stubSince(2 * time.Second)()
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
defer http.StubWithFixture(200, "forkResult.json")()
cmdRun := false
defer run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
cmdRun = true
return &test.OutputStub{}
})()
oldConfirm := Confirm
Confirm = func(_ string, result *bool) error {
*result = false
return nil
}
defer func() { Confirm = oldConfirm }()
output, err := RunCommand("repo fork")
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
eq(t, output.Stderr(), "")
eq(t, cmdRun, false)
r := regexp.MustCompile(`Created fork someone/REPO`)
if !r.MatchString(output.String()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
}
func TestParseExtraArgs(t *testing.T) {
type Wanted struct {
args []string
dir string
}
tests := []struct {
name string
args []string
want Wanted
}{
{
name: "args and target",
args: []string{"target_directory", "-o", "upstream", "--depth", "1"},
want: Wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "target_directory",
},
},
{
name: "only args",
args: []string{"-o", "upstream", "--depth", "1"},
want: Wanted{
args: []string{"-o", "upstream", "--depth", "1"},
dir: "",
},
},
{
name: "only target",
args: []string{"target_directory"},
want: Wanted{
args: []string{},
dir: "target_directory",
},
},
{
name: "no args",
args: []string{},
want: Wanted{
args: []string{},
dir: "",
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
args, dir := parseCloneArgs(tt.args)
got := Wanted{
args: args,
dir: dir,
}
if !reflect.DeepEqual(got, tt.want) {
t.Errorf("got %#v want %#v", got, tt.want)
}
})
}
}
func TestRepoClone(t *testing.T) {
tests := []struct {
name string
args string
want string
}{
{
name: "shorthand",
args: "repo clone OWNER/REPO",
want: "git clone https://github.com/OWNER/REPO.git",
},
{
name: "shorthand with directory",
args: "repo clone OWNER/REPO target_directory",
want: "git clone https://github.com/OWNER/REPO.git target_directory",
},
{
name: "clone arguments",
args: "repo clone OWNER/REPO -- -o upstream --depth 1",
want: "git clone -o upstream --depth 1 https://github.com/OWNER/REPO.git",
},
{
name: "clone arguments with directory",
args: "repo clone OWNER/REPO target_directory -- -o upstream --depth 1",
want: "git clone -o upstream --depth 1 https://github.com/OWNER/REPO.git target_directory",
},
{
name: "HTTPS URL",
args: "repo clone https://github.com/OWNER/REPO",
want: "git clone https://github.com/OWNER/REPO",
},
{
name: "SSH URL",
args: "repo clone git@github.com:OWNER/REPO.git",
want: "git clone git@github.com:OWNER/REPO.git",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"parent": null
} } }
`))
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
output, err := RunCommand(tt.args)
if err != nil {
t.Fatalf("error running command `repo clone`: %v", err)
}
eq(t, output.String(), "")
eq(t, output.Stderr(), "")
eq(t, cs.Count, 1)
eq(t, strings.Join(cs.Calls[0].Args, " "), tt.want)
})
}
}
func TestRepoClone_hasParent(t *testing.T) {
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"parent": {
"owner": {"login": "hubot"},
"name": "ORIG"
}
} } }
`))
cs, restore := test.InitCmdStubber()
defer restore()
cs.Stub("") // git clone
cs.Stub("") // git remote add
_, err := RunCommand("repo clone OWNER/REPO")
if err != nil {
t.Fatalf("error running command `repo clone`: %v", err)
}
eq(t, cs.Count, 2)
eq(t, strings.Join(cs.Calls[1].Args, " "), "git -C REPO remote add -f upstream https://github.com/hubot/ORIG.git")
}
func TestRepoCreate(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createRepository": {
"repository": {
"id": "REPOID",
"url": "https://github.com/OWNER/REPO",
"name": "REPO",
"owner": {
"login": "OWNER"
}
}
} } }
`))
var seenCmd *exec.Cmd
restoreCmd := utils.SetPrepareCmd(func(cmd *exec.Cmd) utils.Runnable {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &outputStub{}
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand(repoViewCmd, "repo view")
output, err := RunCommand("repo create REPO")
if err != nil {
t.Errorf("error running command `repo create`: %v", err)
}
eq(t, output.String(), "https://github.com/OWNER/REPO\n")
eq(t, output.Stderr(), "")
if seenCmd == nil {
t.Fatal("expected a command to run")
}
eq(t, strings.Join(seenCmd.Args, " "), "git remote add -f origin https://github.com/OWNER/REPO.git")
var reqBody struct {
Query string
Variables struct {
Input map[string]interface{}
}
}
if len(http.Requests) != 1 {
t.Fatalf("expected 1 HTTP request, got %d", len(http.Requests))
}
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
_ = json.Unmarshal(bodyBytes, &reqBody)
if repoName := reqBody.Variables.Input["name"].(string); repoName != "REPO" {
t.Errorf("expected %q, got %q", "REPO", repoName)
}
if repoVisibility := reqBody.Variables.Input["visibility"].(string); repoVisibility != "PRIVATE" {
t.Errorf("expected %q, got %q", "PRIVATE", repoVisibility)
}
if _, ownerSet := reqBody.Variables.Input["ownerId"]; ownerSet {
t.Error("expected ownerId not to be set")
}
}
func TestRepoCreate_org(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "node_id": "ORGID"
}
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createRepository": {
"repository": {
"id": "REPOID",
"url": "https://github.com/ORG/REPO",
"name": "REPO",
"owner": {
"login": "ORG"
}
}
} } }
`))
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand("repo create ORG/REPO")
if err != nil {
t.Errorf("error running command `repo create`: %v", err)
}
eq(t, output.String(), "https://github.com/ORG/REPO\n")
eq(t, output.Stderr(), "")
if seenCmd == nil {
t.Fatal("expected a command to run")
}
eq(t, strings.Join(seenCmd.Args, " "), "git remote add -f origin https://github.com/ORG/REPO.git")
var reqBody struct {
Query string
Variables struct {
Input map[string]interface{}
}
}
if len(http.Requests) != 2 {
t.Fatalf("expected 2 HTTP requests, got %d", len(http.Requests))
}
eq(t, http.Requests[0].URL.Path, "/users/ORG")
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
_ = json.Unmarshal(bodyBytes, &reqBody)
if orgID := reqBody.Variables.Input["ownerId"].(string); orgID != "ORGID" {
t.Errorf("expected %q, got %q", "ORGID", orgID)
}
if _, teamSet := reqBody.Variables.Input["teamId"]; teamSet {
t.Error("expected teamId not to be set")
}
}
func TestRepoCreate_orgWithTeam(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
http.StubResponse(200, bytes.NewBufferString(`
{ "node_id": "TEAMID",
"organization": { "node_id": "ORGID" }
}
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "createRepository": {
"repository": {
"id": "REPOID",
"url": "https://github.com/ORG/REPO",
"name": "REPO",
"owner": {
"login": "ORG"
}
}
} } }
`))
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand("repo create ORG/REPO --team monkeys")
if err != nil {
t.Errorf("error running command `repo create`: %v", err)
}
eq(t, output.String(), "https://github.com/ORG/REPO\n")
eq(t, output.Stderr(), "")
if seenCmd == nil {
t.Fatal("expected a command to run")
}
eq(t, strings.Join(seenCmd.Args, " "), "git remote add -f origin https://github.com/ORG/REPO.git")
var reqBody struct {
Query string
Variables struct {
Input map[string]interface{}
}
}
if len(http.Requests) != 2 {
t.Fatalf("expected 2 HTTP requests, got %d", len(http.Requests))
}
eq(t, http.Requests[0].URL.Path, "/orgs/ORG/teams/monkeys")
bodyBytes, _ := ioutil.ReadAll(http.Requests[1].Body)
_ = json.Unmarshal(bodyBytes, &reqBody)
if orgID := reqBody.Variables.Input["ownerId"].(string); orgID != "ORGID" {
t.Errorf("expected %q, got %q", "ORGID", orgID)
}
if teamID := reqBody.Variables.Input["teamId"].(string); teamID != "TEAMID" {
t.Errorf("expected %q, got %q", "TEAMID", teamID)
}
}
func TestRepoView_web(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ }
`))
var seenCmd *exec.Cmd
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand("repo view -w")
if err != nil {
t.Errorf("error running command `repo view`: %v", err)
}
@ -35,22 +740,25 @@ func TestRepoView(t *testing.T) {
eq(t, url, "https://github.com/OWNER/REPO")
}
func TestRepoView_ownerRepo(t *testing.T) {
func TestRepoView_web_ownerRepo(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
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 {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &outputStub{}
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand(repoViewCmd, "repo view cli/cli")
output, err := RunCommand("repo view -w cli/cli")
if err != nil {
t.Errorf("error running command `repo view`: %v", err)
}
@ -65,22 +773,24 @@ func TestRepoView_ownerRepo(t *testing.T) {
eq(t, url, "https://github.com/cli/cli")
}
func TestRepoView_fullURL(t *testing.T) {
func TestRepoView_web_fullURL(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
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 {
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
seenCmd = cmd
return &outputStub{}
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand(repoViewCmd, "repo view https://github.com/cli/cli")
output, err := RunCommand("repo view -w https://github.com/cli/cli")
if err != nil {
t.Errorf("error running command `repo view`: %v", err)
}
@ -94,3 +804,77 @@ func TestRepoView_fullURL(t *testing.T) {
url := seenCmd.Args[len(seenCmd.Args)-1]
eq(t, url, "https://github.com/cli/cli")
}
func TestRepoView(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": {
"repository": {
"description": "social distancing"
}}}
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "name": "readme.md",
"content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="}
`))
output, err := RunCommand("repo view")
if err != nil {
t.Errorf("error running command `repo view`: %v", err)
}
test.ExpectLines(t, output.String(),
"OWNER/REPO",
"social distancing",
"truly cool readme",
"View this repository on GitHub: https://github.com/OWNER/REPO")
}
func TestRepoView_nonmarkdown_readme(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": {
"repository": {
"description": "social distancing"
}}}
`))
http.StubResponse(200, bytes.NewBufferString(`
{ "name": "readme.org",
"content": "IyB0cnVseSBjb29sIHJlYWRtZSBjaGVjayBpdCBvdXQ="}
`))
output, err := RunCommand("repo view")
if err != nil {
t.Errorf("error running command `repo view`: %v", err)
}
test.ExpectLines(t, output.String(),
"OWNER/REPO",
"social distancing",
"# truly cool readme",
"View this repository on GitHub: https://github.com/OWNER/REPO")
}
func TestRepoView_blanks(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString("{}"))
http.StubResponse(200, bytes.NewBufferString("{}"))
output, err := RunCommand("repo view")
if err != nil {
t.Errorf("error running command `repo view`: %v", err)
}
test.ExpectLines(t, output.String(),
"OWNER/REPO",
"No description provided",
"No README provided",
"View this repository on GitHub: https://github.com/OWNER/REPO")
}

View file

@ -10,19 +10,25 @@ 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"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
)
// Version is dynamically set by the toolchain or overriden by the Makefile.
// 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"
// BuildDate is dynamically set at build time in the Makefile.
var BuildDate = "" // YYYY-MM-DD
var versionOutput = ""
var cobraDefaultHelpFunc func(*cobra.Command, []string)
func init() {
if Version == "DEV" {
@ -46,7 +52,13 @@ func init() {
// TODO:
// RootCmd.PersistentFlags().BoolP("verbose", "V", false, "enable verbose output")
cobraDefaultHelpFunc = RootCmd.HelpFunc()
RootCmd.SetHelpFunc(rootHelpFunc)
RootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error {
if err == pflag.ErrHelp {
return err
}
return &FlagError{Err: err}
})
}
@ -66,12 +78,9 @@ func (fe FlagError) Unwrap() error {
// RootCmd is the entry point of command-line execution
var RootCmd = &cobra.Command{
Use: "gh",
Use: "gh <command> <subcommand> [flags]",
Short: "GitHub CLI",
Long: `Work seamlessly with GitHub from the command line.
GitHub CLI is in early stages of development, and we'd love to hear your
feedback at <https://forms.gle/umxd3h31c7aMQFKG7>`,
Long: `Work seamlessly with GitHub from the command line.`,
SilenceErrors: true,
SilenceUsage: true,
@ -81,7 +90,7 @@ var versionCmd = &cobra.Command{
Use: "version",
Hidden: true,
Run: func(cmd *cobra.Command, args []string) {
fmt.Printf(versionOutput)
fmt.Print(versionOutput)
},
}
@ -97,13 +106,16 @@ var initContext = func() context.Context {
// BasicClient returns an API client that borrows from but does not depend on
// user configuration
func BasicClient() (*api.Client, error) {
opts := []api.ClientOption{}
var opts []api.ClientOption
if verbose := os.Getenv("DEBUG"); verbose != "" {
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)))
if c, err := config.ParseDefaultConfig(); err == nil {
if token, _ := c.Get(defaultHostname, "oauth_token"); token != "" {
opts = append(opts, api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
}
}
return api.NewClient(opts...), nil
}
@ -122,17 +134,51 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) {
if err != nil {
return nil, err
}
opts := []api.ClientOption{}
var opts []api.ClientOption
if verbose := os.Getenv("DEBUG"); verbose != "" {
opts = append(opts, apiVerboseLog())
}
getAuthValue := func() string {
return fmt.Sprintf("token %s", token)
}
checkScopesFunc := func(appID string) error {
if config.IsGitHubApp(appID) && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) {
newToken, loginHandle, err := config.AuthFlow("Notice: additional authorization required")
if err != nil {
return err
}
cfg, err := ctx.Config()
if err != nil {
return err
}
_ = cfg.Set(defaultHostname, "oauth_token", newToken)
_ = cfg.Set(defaultHostname, "user", loginHandle)
// update config file on disk
err = cfg.Write()
if err != nil {
return err
}
// update configuration in memory
token = newToken
config.AuthFlowComplete()
} else {
// TODO for gist
fmt.Fprintln(os.Stderr, "Warning: gh now requires the `read:org` OAuth scope.")
fmt.Fprintln(os.Stderr, "Visit https://github.com/settings/tokens and edit your token to enable `read:org`")
fmt.Fprintln(os.Stderr, "or generate a new token and paste it via `gh config set -h github.com oauth_token MYTOKEN`")
}
return nil
}
opts = append(opts,
api.AddHeader("Authorization", fmt.Sprintf("token %s", token)),
api.CheckScopes("read:org", checkScopesFunc),
api.AddHeaderFunc("Authorization", getAuthValue),
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
@ -172,6 +218,11 @@ func changelogURL(version string) string {
}
func determineBaseRepo(cmd *cobra.Command, ctx context.Context) (ghrepo.Interface, error) {
repo, err := cmd.Flags().GetString("repo")
if err == nil && repo != "" {
return ghrepo.FromFullName(repo), nil
}
apiClient, err := apiClientForContext(ctx)
if err != nil {
return nil, err
@ -199,3 +250,106 @@ func determineBaseRepo(cmd *cobra.Command, ctx context.Context) (ghrepo.Interfac
return baseRepo, nil
}
func rootHelpFunc(command *cobra.Command, s []string) {
if command != RootCmd {
cobraDefaultHelpFunc(command, s)
return
}
type helpEntry struct {
Title string
Body string
}
coreCommandNames := []string{"issue", "pr", "repo"}
var coreCommands []string
var additionalCommands []string
for _, c := range command.Commands() {
if c.Short == "" {
continue
}
s := " " + rpad(c.Name()+":", c.NamePadding()) + c.Short
if includes(coreCommandNames, c.Name()) {
coreCommands = append(coreCommands, s)
} else if c != creditsCmd {
additionalCommands = append(additionalCommands, s)
}
}
helpEntries := []helpEntry{
{
"",
command.Long},
{"USAGE", command.Use},
{"CORE COMMANDS", strings.Join(coreCommands, "\n")},
{"ADDITIONAL COMMANDS", strings.Join(additionalCommands, "\n")},
{"FLAGS", strings.TrimRight(command.LocalFlags().FlagUsages(), "\n")},
{"EXAMPLES", `
$ gh issue create
$ gh repo clone
$ gh pr checkout 321`},
{"LEARN MORE", `
Use "gh <command> <subcommand> --help" for more information about a command.
Read the manual at <http://cli.github.com/manual>`},
{"FEEDBACK", `
Fill out our feedback form <https://forms.gle/umxd3h31c7aMQFKG7>
Open an issue using gh issue create -R cli/cli`},
}
out := colorableOut(command)
for _, e := range helpEntries {
if e.Title != "" {
fmt.Fprintln(out, utils.Bold(e.Title))
}
fmt.Fprintln(out, strings.TrimLeft(e.Body, "\n")+"\n")
}
}
// rpad adds padding to the right of a string.
func rpad(s string, padding int) string {
template := fmt.Sprintf("%%-%ds ", padding)
return fmt.Sprintf(template, s)
}
func includes(a []string, s string) bool {
for _, x := range a {
if x == s {
return true
}
}
return false
}
func formatRemoteURL(cmd *cobra.Command, fullRepoName string) string {
ctx := contextForCommand(cmd)
protocol := "https"
cfg, err := ctx.Config()
if err != nil {
fmt.Fprintf(colorableErr(cmd), "%s failed to load config: %s. using defaults\n", utils.Yellow("!"), err)
} else {
cfgProtocol, _ := cfg.Get(defaultHostname, "git_protocol")
protocol = cfgProtocol
}
if protocol == "ssh" {
return fmt.Sprintf("git@%s:%s.git", defaultHostname, fullRepoName)
}
return fmt.Sprintf("https://%s/%s.git", defaultHostname, fullRepoName)
}
func determineEditor(cmd *cobra.Command) (string, error) {
editorCommand := os.Getenv("GH_EDITOR")
if editorCommand == "" {
ctx := contextForCommand(cmd)
cfg, err := ctx.Config()
if err != nil {
return "", fmt.Errorf("could not read config: %w", err)
}
editorCommand, _ = cfg.Get(defaultHostname, "editor")
}
return editorCommand, nil
}

View file

@ -1,43 +1,61 @@
package command
import (
"fmt"
"testing"
)
func TestChangelogURL(t *testing.T) {
tag := "0.3.2"
url := fmt.Sprintf("https://github.com/cli/cli/releases/tag/v0.3.2")
url := "https://github.com/cli/cli/releases/tag/v0.3.2"
result := changelogURL(tag)
if result != url {
t.Errorf("expected %s to create url %s but got %s", tag, url, result)
}
tag = "v0.3.2"
url = fmt.Sprintf("https://github.com/cli/cli/releases/tag/v0.3.2")
url = "https://github.com/cli/cli/releases/tag/v0.3.2"
result = changelogURL(tag)
if result != url {
t.Errorf("expected %s to create url %s but got %s", tag, url, result)
}
tag = "0.3.2-pre.1"
url = fmt.Sprintf("https://github.com/cli/cli/releases/tag/v0.3.2-pre.1")
url = "https://github.com/cli/cli/releases/tag/v0.3.2-pre.1"
result = changelogURL(tag)
if result != url {
t.Errorf("expected %s to create url %s but got %s", tag, url, result)
}
tag = "0.3.5-90-gdd3f0e0"
url = fmt.Sprintf("https://github.com/cli/cli/releases/latest")
url = "https://github.com/cli/cli/releases/latest"
result = changelogURL(tag)
if result != url {
t.Errorf("expected %s to create url %s but got %s", tag, url, result)
}
tag = "deadbeef"
url = fmt.Sprintf("https://github.com/cli/cli/releases/latest")
url = "https://github.com/cli/cli/releases/latest"
result = changelogURL(tag)
if result != url {
t.Errorf("expected %s to create url %s but got %s", tag, url, result)
}
}
func TestRemoteURLFormatting_no_config(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
result := formatRemoteURL(repoForkCmd, "OWNER/REPO")
eq(t, result, "https://github.com/OWNER/REPO.git")
}
func TestRemoteURLFormatting_ssh_config(t *testing.T) {
cfg := `---
hosts:
github.com:
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
git_protocol: ssh
`
initBlankContext(cfg, "OWNER/REPO", "master")
result := formatRemoteURL(repoForkCmd, "OWNER/REPO")
eq(t, result, "git@github.com:OWNER/REPO.git")
}

View file

@ -1,13 +1,79 @@
package command
import (
"bytes"
"errors"
"fmt"
"reflect"
"github.com/AlecAivazis/survey/v2"
"github.com/AlecAivazis/survey/v2/core"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/httpmock"
"github.com/google/shlex"
"github.com/spf13/pflag"
)
func initBlankContext(repo, branch string) {
const defaultTestConfig = `hosts:
github.com:
user: OWNER
oauth_token: 1234567890
`
type askStubber struct {
Asks [][]*survey.Question
Count int
Stubs [][]*QuestionStub
}
func initAskStubber() (*askStubber, func()) {
origSurveyAsk := SurveyAsk
as := askStubber{}
SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error {
as.Asks = append(as.Asks, qs)
count := as.Count
as.Count += 1
if count >= len(as.Stubs) {
panic(fmt.Sprintf("more asks than stubs. most recent call: %v", qs))
}
// actually set response
stubbedQuestions := as.Stubs[count]
for i, sq := range stubbedQuestions {
q := qs[i]
if q.Name != sq.Name {
panic(fmt.Sprintf("stubbed question mismatch: %s != %s", q.Name, sq.Name))
}
if sq.Default {
defaultValue := reflect.ValueOf(q.Prompt).Elem().FieldByName("Default")
_ = core.WriteAnswer(response, q.Name, defaultValue)
} else {
_ = core.WriteAnswer(response, q.Name, sq.Value)
}
}
return nil
}
teardown := func() {
SurveyAsk = origSurveyAsk
}
return &as, teardown
}
type QuestionStub struct {
Name string
Value interface{}
Default bool
}
func (as *askStubber) Stub(stubbedQuestions []*QuestionStub) {
// A call to .Ask takes a list of questions; a stub is then a list of questions in the same order.
as.Stubs = append(as.Stubs, stubbedQuestions)
}
func initBlankContext(cfg, repo, branch string) {
initContext = func() context.Context {
ctx := context.NewBlank()
ctx.SetBaseRepo(repo)
@ -15,29 +81,78 @@ 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
}
}
func initFakeHTTP() *api.FakeHTTP {
http := &api.FakeHTTP{}
func initFakeHTTP() *httpmock.Registry {
http := &httpmock.Registry{}
apiClientForContext = func(context.Context) (*api.Client, error) {
return api.NewClient(api.ReplaceTripper(http)), nil
}
return http
}
// outputStub implements a simple utils.Runnable
type outputStub struct {
output []byte
type cmdOut struct {
outBuf, errBuf *bytes.Buffer
}
func (s outputStub) Output() ([]byte, error) {
return s.output, nil
func (c cmdOut) String() string {
return c.outBuf.String()
}
func (s outputStub) Run() error {
return nil
func (c cmdOut) Stderr() string {
return c.errBuf.String()
}
func RunCommand(args string) (*cmdOut, error) {
rootCmd := RootCmd
rootArgv, err := shlex.Split(args)
if err != nil {
return nil, err
}
cmd, _, err := rootCmd.Traverse(rootArgv)
if err != nil {
return nil, err
}
rootCmd.SetArgs(rootArgv)
outBuf := bytes.Buffer{}
cmd.SetOut(&outBuf)
errBuf := bytes.Buffer{}
cmd.SetErr(&errBuf)
// Reset flag values so they don't leak between tests
// FIXME: change how we initialize Cobra commands to render this hack unnecessary
cmd.Flags().VisitAll(func(f *pflag.Flag) {
f.Changed = false
switch v := f.Value.(type) {
case pflag.SliceValue:
_ = v.Replace([]string{})
default:
switch v.Type() {
case "bool", "string", "int":
_ = v.Set(f.DefValue)
}
}
})
_, err = rootCmd.ExecuteC()
cmd.SetOut(nil)
cmd.SetErr(nil)
return &cmdOut{&outBuf, &errBuf}, err
}
type errorStub struct {

View file

@ -4,26 +4,69 @@ import (
"fmt"
"github.com/AlecAivazis/survey/v2"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/pkg/surveyext"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type Action int
type titleBody struct {
type issueMetadataState struct {
Body string
Title string
Action Action
Metadata []string
Reviewers []string
Assignees []string
Labels []string
Projects []string
Milestones []string
MetadataResult *api.RepoMetadataResult
}
func (tb *issueMetadataState) HasMetadata() bool {
return len(tb.Reviewers) > 0 ||
len(tb.Assignees) > 0 ||
len(tb.Labels) > 0 ||
len(tb.Projects) > 0 ||
len(tb.Milestones) > 0
}
const (
PreviewAction Action = iota
SubmitAction
SubmitAction Action = iota
PreviewAction
CancelAction
MetadataAction
noMilestone = "(none)"
)
func confirm() (Action, error) {
var SurveyAsk = func(qs []*survey.Question, response interface{}, opts ...survey.AskOpt) error {
return survey.Ask(qs, response, opts...)
}
func confirmSubmission(allowPreview bool, allowMetadata bool) (Action, error) {
const (
submitLabel = "Submit"
previewLabel = "Continue in browser"
metadataLabel = "Add metadata"
cancelLabel = "Cancel"
)
options := []string{submitLabel}
if allowPreview {
options = append(options, previewLabel)
}
if allowMetadata {
options = append(options, metadataLabel)
}
options = append(options, cancelLabel)
confirmAnswers := struct {
Confirmation int
}{}
@ -32,21 +75,28 @@ func confirm() (Action, error) {
Name: "confirmation",
Prompt: &survey.Select{
Message: "What's next?",
Options: []string{
"Preview in browser",
"Submit",
"Cancel",
},
Options: options,
},
},
}
err := survey.Ask(confirmQs, &confirmAnswers)
err := SurveyAsk(confirmQs, &confirmAnswers)
if err != nil {
return -1, fmt.Errorf("could not prompt: %w", err)
}
return Action(confirmAnswers.Confirmation), nil
switch options[confirmAnswers.Confirmation] {
case submitLabel:
return SubmitAction, nil
case previewLabel:
return PreviewAction, nil
case metadataLabel:
return MetadataAction, nil
case cancelLabel:
return CancelAction, nil
default:
return -1, fmt.Errorf("invalid index: %d", confirmAnswers.Confirmation)
}
}
func selectTemplate(templatePaths []string) (string, error) {
@ -54,7 +104,7 @@ func selectTemplate(templatePaths []string) (string, error) {
Index int
}{}
if len(templatePaths) > 1 {
templateNames := []string{}
templateNames := make([]string, 0, len(templatePaths))
for _, p := range templatePaths {
templateNames = append(templateNames, githubtemplate.ExtractName(p))
}
@ -68,7 +118,7 @@ func selectTemplate(templatePaths []string) (string, error) {
},
},
}
if err := survey.Ask(selectQs, &templateResponse); err != nil {
if err := SurveyAsk(selectQs, &templateResponse); err != nil {
return "", fmt.Errorf("could not prompt: %w", err)
}
}
@ -77,40 +127,50 @@ func selectTemplate(templatePaths []string) (string, error) {
return string(templateContents), nil
}
func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody string, templatePaths []string) (*titleBody, error) {
inProgress := titleBody{}
func titleBodySurvey(cmd *cobra.Command, issueState *issueMetadataState, apiClient *api.Client, repo ghrepo.Interface, providedTitle, providedBody string, defs defaults, templatePaths []string, allowReviewers, allowMetadata bool) error {
editorCommand, err := determineEditor(cmd)
if err != nil {
return err
}
issueState.Title = defs.Title
templateContents := ""
if providedBody == "" && len(templatePaths) > 0 {
var err error
templateContents, err = selectTemplate(templatePaths)
if err != nil {
return nil, err
if providedBody == "" {
if len(templatePaths) > 0 {
var err error
templateContents, err = selectTemplate(templatePaths)
if err != nil {
return err
}
issueState.Body = templateContents
} else {
issueState.Body = defs.Body
}
inProgress.Body = templateContents
}
titleQuestion := &survey.Question{
Name: "title",
Prompt: &survey.Input{
Message: "Title",
Default: inProgress.Title,
Default: issueState.Title,
},
}
bodyQuestion := &survey.Question{
Name: "body",
Prompt: &surveyext.GhEditor{
EditorCommand: editorCommand,
Editor: &survey.Editor{
Message: "Body",
FileName: "*.md",
Default: inProgress.Body,
Default: issueState.Body,
HideDefault: true,
AppendDefault: true,
},
},
}
qs := []*survey.Question{}
var qs []*survey.Question
if providedTitle == "" {
qs = append(qs, titleQuestion)
}
@ -118,21 +178,179 @@ func titleBodySurvey(cmd *cobra.Command, providedTitle string, providedBody stri
qs = append(qs, bodyQuestion)
}
err := survey.Ask(qs, &inProgress)
err = SurveyAsk(qs, issueState)
if err != nil {
return nil, fmt.Errorf("could not prompt: %w", err)
return fmt.Errorf("could not prompt: %w", err)
}
if inProgress.Body == "" {
inProgress.Body = templateContents
if issueState.Body == "" {
issueState.Body = templateContents
}
confirmA, err := confirm()
allowPreview := !issueState.HasMetadata()
confirmA, err := confirmSubmission(allowPreview, allowMetadata)
if err != nil {
return nil, fmt.Errorf("unable to confirm: %w", err)
return fmt.Errorf("unable to confirm: %w", err)
}
inProgress.Action = confirmA
if confirmA == MetadataAction {
isChosen := func(m string) bool {
for _, c := range issueState.Metadata {
if m == c {
return true
}
}
return false
}
return &inProgress, nil
extraFieldsOptions := []string{}
if allowReviewers {
extraFieldsOptions = append(extraFieldsOptions, "Reviewers")
}
extraFieldsOptions = append(extraFieldsOptions, "Assignees", "Labels", "Projects", "Milestone")
err = SurveyAsk([]*survey.Question{
{
Name: "metadata",
Prompt: &survey.MultiSelect{
Message: "What would you like to add?",
Options: extraFieldsOptions,
},
},
}, issueState)
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
metadataInput := api.RepoMetadataInput{
Reviewers: isChosen("Reviewers"),
Assignees: isChosen("Assignees"),
Labels: isChosen("Labels"),
Projects: isChosen("Projects"),
Milestones: isChosen("Milestone"),
}
s := utils.Spinner(cmd.OutOrStderr())
utils.StartSpinner(s)
issueState.MetadataResult, err = api.RepoMetadata(apiClient, repo, metadataInput)
utils.StopSpinner(s)
if err != nil {
return fmt.Errorf("error fetching metadata options: %w", err)
}
var users []string
for _, u := range issueState.MetadataResult.AssignableUsers {
users = append(users, u.Login)
}
var teams []string
for _, t := range issueState.MetadataResult.Teams {
teams = append(teams, fmt.Sprintf("%s/%s", repo.RepoOwner(), t.Slug))
}
var labels []string
for _, l := range issueState.MetadataResult.Labels {
labels = append(labels, l.Name)
}
var projects []string
for _, l := range issueState.MetadataResult.Projects {
projects = append(projects, l.Name)
}
milestones := []string{noMilestone}
for _, m := range issueState.MetadataResult.Milestones {
milestones = append(milestones, m.Title)
}
var mqs []*survey.Question
if isChosen("Reviewers") {
if len(users) > 0 || len(teams) > 0 {
mqs = append(mqs, &survey.Question{
Name: "reviewers",
Prompt: &survey.MultiSelect{
Message: "Reviewers",
Options: append(users, teams...),
Default: issueState.Reviewers,
},
})
} else {
cmd.PrintErrln("warning: no available reviewers")
}
}
if isChosen("Assignees") {
if len(users) > 0 {
mqs = append(mqs, &survey.Question{
Name: "assignees",
Prompt: &survey.MultiSelect{
Message: "Assignees",
Options: users,
Default: issueState.Assignees,
},
})
} else {
cmd.PrintErrln("warning: no assignable users")
}
}
if isChosen("Labels") {
if len(labels) > 0 {
mqs = append(mqs, &survey.Question{
Name: "labels",
Prompt: &survey.MultiSelect{
Message: "Labels",
Options: labels,
Default: issueState.Labels,
},
})
} else {
cmd.PrintErrln("warning: no labels in the repository")
}
}
if isChosen("Projects") {
if len(projects) > 0 {
mqs = append(mqs, &survey.Question{
Name: "projects",
Prompt: &survey.MultiSelect{
Message: "Projects",
Options: projects,
Default: issueState.Projects,
},
})
} else {
cmd.PrintErrln("warning: no projects to choose from")
}
}
if isChosen("Milestone") {
if len(milestones) > 1 {
var milestoneDefault interface{}
if len(issueState.Milestones) > 0 {
milestoneDefault = issueState.Milestones[0]
}
mqs = append(mqs, &survey.Question{
Name: "milestone",
Prompt: &survey.Select{
Message: "Milestone",
Options: milestones,
Default: milestoneDefault,
},
})
} else {
cmd.PrintErrln("warning: no milestones in the repository")
}
}
err = SurveyAsk(mqs, issueState, survey.WithKeepFilter(true))
if err != nil {
return fmt.Errorf("could not prompt: %w", err)
}
if len(issueState.Milestones) > 0 && issueState.Milestones[0] == noMilestone {
issueState.Milestones = issueState.Milestones[0:0]
}
allowPreview = !issueState.HasMetadata()
allowMetadata = false
confirmA, err = confirmSubmission(allowPreview, allowMetadata)
if err != nil {
return fmt.Errorf("unable to confirm: %w", err)
}
}
issueState.Action = confirmA
return nil
}

View file

@ -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
}
@ -30,6 +39,10 @@ func (c *blankContext) SetAuthToken(t string) {
c.authToken = t
}
func (c *blankContext) SetAuthLogin(login string) {
c.authLogin = login
}
func (c *blankContext) AuthLogin() (string, error) {
return c.authLogin, nil
}
@ -53,7 +66,7 @@ func (c *blankContext) Remotes() (Remotes, error) {
}
func (c *blankContext) SetRemotes(stubs map[string]string) {
c.remotes = Remotes{}
c.remotes = make([]*Remote, 0, len(stubs))
for remoteName, repo := range stubs {
ownerWithName := strings.SplitN(repo, "/", 2)
c.remotes = append(c.remotes, &Remote{

View file

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

View file

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

View file

@ -1,108 +0,0 @@
package context
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/auth"
"gopkg.in/yaml.v3"
)
const (
oauthHost = "github.com"
)
var (
// The "GitHub CLI" OAuth app
oauthClientID = "178c6fc778ccc68e1d6a"
// This value is safe to be embedded in version control
oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b"
)
// TODO: have a conversation about whether this belongs in the "context" package
// FIXME: make testable
func setupConfigFile(filename string) (*configEntry, error) {
var verboseStream io.Writer
if strings.Contains(os.Getenv("DEBUG"), "oauth") {
verboseStream = os.Stderr
}
flow := &auth.OAuthFlow{
Hostname: oauthHost,
ClientID: oauthClientID,
ClientSecret: oauthClientSecret,
WriteSuccessHTML: func(w io.Writer) {
fmt.Fprintln(w, oauthSuccessPage)
},
VerboseStream: verboseStream,
}
fmt.Fprintln(os.Stderr, "Notice: authentication required")
fmt.Fprintf(os.Stderr, "Press Enter to open %s in your browser... ", flow.Hostname)
waitForEnter(os.Stdin)
token, err := flow.ObtainAccessToken()
if err != nil {
return nil, err
}
userLogin, err := getViewer(token)
if err != nil {
return nil, err
}
entry := configEntry{
User: userLogin,
Token: token,
}
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)
if err != nil {
return nil, err
}
defer config.Close()
yamlData, err := yaml.Marshal(data)
if err != nil {
return nil, err
}
n, err := config.Write(yamlData)
if err == nil && n < len(yamlData) {
err = io.ErrShortWrite
}
if err == nil {
fmt.Fprintln(os.Stderr, "Authentication complete. Press Enter to continue... ")
waitForEnter(os.Stdin)
}
return &entry, err
}
func getViewer(token string) (string, error) {
http := api.NewClient(api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
response := struct {
Viewer struct {
Login string
}
}{}
err := http.GraphQL("{ viewer { login } }", nil, &response)
return response.Viewer.Login, err
}
func waitForEnter(r io.Reader) error {
scanner := bufio.NewScanner(r)
scanner.Scan()
return scanner.Err()
}

View file

@ -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,10 +24,11 @@ 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
// 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) {
@ -38,7 +41,7 @@ func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (Re
hasBaseOverride := base != ""
baseOverride := ghrepo.FromFullName(base)
foundBaseOverride := false
repos := []ghrepo.Interface{}
repos := make([]ghrepo.Interface, 0, lenRemotesForLookup)
for _, r := range remotes[:lenRemotesForLookup] {
repos = append(repos, r)
if ghrepo.IsSame(r, baseOverride) {
@ -51,7 +54,10 @@ func ResolveRemotesToRepos(remotes Remotes, client *api.Client, base string) (Re
repos = append(repos, baseOverride)
}
result := ResolvedRemotes{Remotes: remotes}
result := ResolvedRemotes{
Remotes: remotes,
apiClient: client,
}
if hasBaseOverride {
result.BaseOverride = baseOverride
}
@ -67,6 +73,7 @@ type ResolvedRemotes struct {
BaseOverride ghrepo.Interface
Remotes Remotes
Network api.RepoNetworkResult
apiClient *api.Client
}
// BaseRepo is the first found repository in the "upstream", "github", "origin"
@ -95,8 +102,30 @@ func (r ResolvedRemotes) BaseRepo() (*api.Repository, error) {
return nil, errors.New("not found")
}
// HeadRepo is the first found repository that has push access
// HeadRepo is a fork of base repo (if any), or the first found repository that
// has push access
func (r ResolvedRemotes) HeadRepo() (*api.Repository, error) {
baseRepo, err := r.BaseRepo()
if err != nil {
return nil, err
}
// try to find a pushable fork among existing remotes
for _, repo := range r.Network.Repositories {
if repo != nil && repo.Parent != nil && repo.ViewerCanPush() && ghrepo.IsSame(repo.Parent, baseRepo) {
return repo, nil
}
}
// a fork might still exist on GitHub, so let's query for it
var notFound *api.NotFoundError
if repo, err := api.RepoFindFork(r.apiClient, baseRepo); err == nil {
return repo, nil
} else if !errors.As(err, &notFound) {
return nil, err
}
// fall back to any listed repository that has push access
for _, repo := range r.Network.Repositories {
if repo != nil && repo.ViewerCanPush() {
return repo, nil
@ -125,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
@ -158,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) {
@ -170,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) {
@ -184,7 +216,7 @@ func (c *fsContext) Branch() (string, error) {
currentBranch, err := git.CurrentBranch()
if err != nil {
return "", err
return "", fmt.Errorf("could not determine current branch: %w", err)
}
c.branch = currentBranch

View file

@ -1,15 +1,25 @@
package context
import (
"bytes"
"errors"
"net/url"
"reflect"
"testing"
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/httpmock"
)
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"},
@ -61,6 +71,14 @@ func Test_translateRemotes(t *testing.T) {
}
func Test_resolvedRemotes_triangularSetup(t *testing.T) {
http := &httpmock.Registry{}
apiClient := api.NewClient(api.ReplaceTripper(http))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
] } } } }
`))
resolved := ResolvedRemotes{
BaseOverride: nil,
Remotes: Remotes{
@ -89,6 +107,7 @@ func Test_resolvedRemotes_triangularSetup(t *testing.T) {
},
},
},
apiClient: apiClient,
}
baseRepo, err := resolved.BaseRepo()
@ -118,6 +137,53 @@ func Test_resolvedRemotes_triangularSetup(t *testing.T) {
}
}
func Test_resolvedRemotes_forkLookup(t *testing.T) {
http := &httpmock.Registry{}
apiClient := api.NewClient(api.ReplaceTripper(http))
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "forks": { "nodes": [
{ "id": "FORKID",
"url": "https://github.com/FORKOWNER/REPO",
"name": "REPO",
"owner": { "login": "FORKOWNER" },
"viewerPermission": "WRITE"
}
] } } } }
`))
resolved := ResolvedRemotes{
BaseOverride: nil,
Remotes: Remotes{
&Remote{
Remote: &git.Remote{Name: "origin"},
Owner: "OWNER",
Repo: "REPO",
},
},
Network: api.RepoNetworkResult{
Repositories: []*api.Repository{
&api.Repository{
Name: "NEWNAME",
Owner: api.RepositoryOwner{Login: "NEWOWNER"},
ViewerPermission: "READ",
},
},
},
apiClient: apiClient,
}
headRepo, err := resolved.HeadRepo()
if err != nil {
t.Fatalf("got %v", err)
}
eq(t, ghrepo.FullName(headRepo), "FORKOWNER/REPO")
_, err = resolved.RemoteForRepo(headRepo)
if err == nil {
t.Fatal("expected to not find a matching remote")
}
}
func Test_resolvedRemotes_clonedFork(t *testing.T) {
resolved := ResolvedRemotes{
BaseOverride: nil,

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

View file

@ -0,0 +1,60 @@
# How we document our command line syntax
## Literal text
Use plain text for parts of the command that cannot be changed
_example:_
`gh help`
The argument help is required in this command
## Placeholder values
Use angled brackets to represent a value the user must replace. No other expressions can be contained within the angeled brackets.
_example:_
`gh pr view <issue-number>`
Replace `<issue-number>` with an issue number
## Optional arguments
Place optional arguments in square brackets. Mutually exclusive arguments can be included inside square brackets if they are separated with vertical bars.
_example:_
`gh pr checkout [--web]`
The argument `--web` is optional.
`gh pr view [<number> | <url>]`
The `<number>` and `<url>` arguments are optional.
## Required mutually exclusive arguments
Place required mutually exclusive arguments inside braces, separate arguments with vertical bars.
_example:_
`gh pr {view | create}`
## Repeatable arguments
Ellipsis represent arguments that can appear multiple times
_example:_
`gh pr close <pr-number>...`
## Variable naming
For multi-word variables use dash-case (all lower case with words separated by dashes)
_example:_
`gh pr checkout <issue-number>`
## Additional examples
_optional argument with placeholder:_
`command sub-command [<arg>]`
_required argument with mutually exclusive options:_
`command sub-command {<path> | <string> | literal}`
_optional argument with mutually exclusive options:_
`command sub-command [<path> | <string>]`

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.

17
docs/releasing.md Normal file
View file

@ -0,0 +1,17 @@
# Releasing
## Release to production
This can all be done from your local terminal.
1. `git tag v1.2.3`
2. `git push origin v1.2.3`
3. Wait a few minutes for the build to run <https://github.com/cli/cli/actions>
4. Check <https://github.com/cli/cli/releases>
## Release locally for debugging
A local release can be created for testing without creating anything official on the release page.
1. `goreleaser --skip-validate --skip-publish --rm-dist`
2. Check and test files in `dist/`

View file

@ -1,9 +1,9 @@
# Installation from source
0. Verify that you have Go 1.13+ installed
0. Verify that you have Go 1.14+ installed
```
$ go version
go version go1.13.7
go version go1.14
```
1. Clone cli into `~/.githubcli`

View file

@ -10,36 +10,77 @@ import (
"regexp"
"strings"
"github.com/cli/cli/utils"
"github.com/cli/cli/internal/run"
)
func VerifyRef(ref string) bool {
showRef := exec.Command("git", "show-ref", "--verify", "--quiet", ref)
err := utils.PrepareCmd(showRef).Run()
return err == nil
// Ref represents a git commit reference
type Ref struct {
Hash string
Name string
}
// TrackingRef represents a ref for a remote tracking branch
type TrackingRef struct {
RemoteName string
BranchName string
}
func (r TrackingRef) String() string {
return "refs/remotes/" + r.RemoteName + "/" + r.BranchName
}
// ShowRefs resolves fully-qualified refs to commit hashes
func ShowRefs(ref ...string) ([]Ref, error) {
args := append([]string{"show-ref", "--verify", "--"}, ref...)
showRef := exec.Command("git", args...)
output, err := run.PrepareCmd(showRef).Output()
var refs []Ref
for _, line := range outputLines(output) {
parts := strings.SplitN(line, " ", 2)
if len(parts) < 2 {
continue
}
refs = append(refs, Ref{
Hash: parts[0],
Name: parts[1],
})
}
return refs, err
}
// CurrentBranch reads the checked-out branch for the git repository
func CurrentBranch() (string, error) {
// we avoid using `git branch --show-current` for compatibility with git < 2.22
branchCmd := exec.Command("git", "rev-parse", "--abbrev-ref", "HEAD")
output, err := utils.PrepareCmd(branchCmd).Output()
branchName := firstLine(output)
if err == nil && branchName == "HEAD" {
return "", errors.New("git: not on any branch")
refCmd := GitCommand("symbolic-ref", "--quiet", "--short", "HEAD")
output, err := run.PrepareCmd(refCmd).Output()
if err == nil {
// Found the branch name
return firstLine(output), nil
}
return branchName, err
var cmdErr *run.CmdError
if errors.As(err, &cmdErr) {
if cmdErr.Stderr.Len() == 0 {
// Detached head
return "", errors.New("git: not on any branch")
}
}
// Unknown error
return "", err
}
func listRemotes() ([]string, error) {
remoteCmd := exec.Command("git", "remote", "-v")
output, err := utils.PrepareCmd(remoteCmd).Output()
output, err := run.PrepareCmd(remoteCmd).Output()
return outputLines(output), err
}
func Config(name string) (string, error) {
configCmd := exec.Command("git", "config", name)
output, err := utils.PrepareCmd(configCmd).Output()
output, err := run.PrepareCmd(configCmd).Output()
if err != nil {
return "", fmt.Errorf("unknown config key: %s", name)
}
@ -54,7 +95,7 @@ var GitCommand = func(args ...string) *exec.Cmd {
func UncommittedChangeCount() (int, error) {
statusCmd := GitCommand("status", "--porcelain")
output, err := utils.PrepareCmd(statusCmd).Output()
output, err := run.PrepareCmd(statusCmd).Output()
if err != nil {
return 0, err
}
@ -71,12 +112,57 @@ func UncommittedChangeCount() (int, error) {
return count, nil
}
type Commit struct {
Sha string
Title string
}
func Commits(baseRef, headRef string) ([]*Commit, error) {
logCmd := GitCommand(
"-c", "log.ShowSignature=false",
"log", "--pretty=format:%H,%s",
"--cherry", fmt.Sprintf("%s...%s", baseRef, headRef))
output, err := run.PrepareCmd(logCmd).Output()
if err != nil {
return []*Commit{}, err
}
commits := []*Commit{}
sha := 0
title := 1
for _, line := range outputLines(output) {
split := strings.SplitN(line, ",", 2)
if len(split) != 2 {
continue
}
commits = append(commits, &Commit{
Sha: split[sha],
Title: split[title],
})
}
if len(commits) == 0 {
return commits, fmt.Errorf("could not find any commits between %s and %s", baseRef, headRef)
}
return commits, nil
}
func CommitBody(sha string) (string, error) {
showCmd := GitCommand("-c", "log.ShowSignature=false", "show", "-s", "--pretty=format:%b", sha)
output, err := run.PrepareCmd(showCmd).Output()
if err != nil {
return "", err
}
return string(output), nil
}
// Push publishes a git ref to a remote and sets up upstream configuration
func Push(remote string, ref string) error {
pushCmd := GitCommand("push", "--set-upstream", remote, ref)
pushCmd.Stdout = os.Stdout
pushCmd.Stderr = os.Stderr
return utils.PrepareCmd(pushCmd).Run()
return run.PrepareCmd(pushCmd).Run()
}
type BranchConfig struct {
@ -89,7 +175,7 @@ type BranchConfig struct {
func ReadBranchConfig(branch string) (cfg BranchConfig) {
prefix := regexp.QuoteMeta(fmt.Sprintf("branch.%s.", branch))
configCmd := GitCommand("config", "--get-regexp", fmt.Sprintf("^%s(remote|merge)$", prefix))
output, err := utils.PrepareCmd(configCmd).Output()
output, err := run.PrepareCmd(configCmd).Output()
if err != nil {
return
}
@ -107,7 +193,7 @@ func ReadBranchConfig(branch string) (cfg BranchConfig) {
continue
}
cfg.RemoteURL = u
} else {
} else if !isFilesystemPath(parts[1]) {
cfg.RemoteName = parts[1]
}
case "merge":
@ -117,10 +203,14 @@ func ReadBranchConfig(branch string) (cfg BranchConfig) {
return
}
func isFilesystemPath(p string) bool {
return p == "." || strings.HasPrefix(p, "./") || strings.HasPrefix(p, "/")
}
// ToplevelDir returns the top-level directory path of the current repository
func ToplevelDir() (string, error) {
showCmd := exec.Command("git", "rev-parse", "--show-toplevel")
output, err := utils.PrepareCmd(showCmd).Output()
output, err := run.PrepareCmd(showCmd).Output()
return firstLine(output), err
}

View file

@ -1,58 +1,97 @@
package git
import (
"fmt"
"os"
"regexp"
"os/exec"
"testing"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/test"
)
func TestGitStatusHelperProcess(*testing.T) {
if test.SkipTestHelperProcess() {
return
}
args := test.GetTestHelperProcessArgs()
switch args[0] {
case "no changes":
case "one change":
fmt.Println(" M poem.txt")
case "untracked file":
fmt.Println(" M poem.txt")
fmt.Println("?? new.txt")
case "boom":
os.Exit(1)
}
os.Exit(0)
}
func Test_UncommittedChangeCount(t *testing.T) {
origGitCommand := GitCommand
defer func() {
GitCommand = origGitCommand
}()
cases := map[string]int{
"no changes": 0,
"one change": 1,
"untracked file": 2,
type c struct {
Label string
Expected int
Output string
}
cases := []c{
c{Label: "no changes", Expected: 0, Output: ""},
c{Label: "one change", Expected: 1, Output: " M poem.txt"},
c{Label: "untracked file", Expected: 2, Output: " M poem.txt\n?? new.txt"},
}
for k, v := range cases {
GitCommand = test.StubExecCommand("TestGitStatusHelperProcess", k)
teardown := run.SetPrepareCmd(func(*exec.Cmd) run.Runnable {
return &test.OutputStub{}
})
defer teardown()
for _, v := range cases {
_ = run.SetPrepareCmd(func(*exec.Cmd) run.Runnable {
return &test.OutputStub{Out: []byte(v.Output)}
})
ucc, _ := UncommittedChangeCount()
if ucc != v {
t.Errorf("got unexpected ucc value: %d for case %s", ucc, k)
if ucc != v.Expected {
t.Errorf("got unexpected ucc value: %d for case %s", ucc, v.Label)
}
}
}
GitCommand = test.StubExecCommand("TestGitStatusHelperProcess", "boom")
_, err := UncommittedChangeCount()
errorRE := regexp.MustCompile(`git\.test(\.exe)?: exit status 1$`)
if !errorRE.MatchString(err.Error()) {
t.Errorf("got unexpected error message: %s", err)
func Test_CurrentBranch(t *testing.T) {
cs, teardown := test.InitCmdStubber()
defer teardown()
expected := "branch-name"
cs.Stub(expected)
result, err := CurrentBranch()
if err != nil {
t.Errorf("got unexpected error: %w", err)
}
if len(cs.Calls) != 1 {
t.Errorf("expected 1 git call, saw %d", len(cs.Calls))
}
if result != expected {
t.Errorf("unexpected branch name: %s instead of %s", result, expected)
}
}
func Test_CurrentBranch_detached_head(t *testing.T) {
cs, teardown := test.InitCmdStubber()
defer teardown()
cs.StubError("")
_, err := CurrentBranch()
if err == nil {
t.Errorf("expected an error")
}
expectedError := "git: not on any branch"
if err.Error() != expectedError {
t.Errorf("got unexpected error: %s instead of %s", err.Error(), expectedError)
}
if len(cs.Calls) != 1 {
t.Errorf("expected 1 git call, saw %d", len(cs.Calls))
}
}
func Test_CurrentBranch_unexpected_error(t *testing.T) {
cs, teardown := test.InitCmdStubber()
defer teardown()
cs.StubError("lol")
expectedError := "lol\nstub: lol"
_, err := CurrentBranch()
if err == nil {
t.Errorf("expected an error")
}
if err.Error() != expectedError {
t.Errorf("got unexpected error: %s instead of %s", err.Error(), expectedError)
}
if len(cs.Calls) != 1 {
t.Errorf("expected 1 git call, saw %d", len(cs.Calls))
}
}

View file

@ -6,7 +6,7 @@ import (
"regexp"
"strings"
"github.com/cli/cli/utils"
"github.com/cli/cli/internal/run"
)
var remoteRE = regexp.MustCompile(`(.+)\s+(.+)\s+\((push|fetch)\)`)
@ -71,34 +71,32 @@ func parseRemotes(gitRemotes []string) (remotes RemoteSet) {
return
}
// AddRemote adds a new git remote. The initURL is the remote URL with which the
// automatic fetch is made and finalURL, if non-blank, is set as the remote URL
// after the fetch.
func AddRemote(name, initURL, finalURL string) (*Remote, error) {
addCmd := exec.Command("git", "remote", "add", "-f", name, initURL)
err := utils.PrepareCmd(addCmd).Run()
// AddRemote adds a new git remote and auto-fetches objects from it
func AddRemote(name, u string) (*Remote, error) {
addCmd := exec.Command("git", "remote", "add", "-f", name, u)
err := run.PrepareCmd(addCmd).Run()
if err != nil {
return nil, err
}
if finalURL == "" {
finalURL = initURL
} else {
setCmd := exec.Command("git", "remote", "set-url", name, finalURL)
err := utils.PrepareCmd(setCmd).Run()
var urlParsed *url.URL
if strings.HasPrefix(u, "https") {
urlParsed, err = url.Parse(u)
if err != nil {
return nil, err
}
} else {
urlParsed, err = ParseURL(u)
if err != nil {
return nil, err
}
}
finalURLParsed, err := url.Parse(finalURL)
if err != nil {
return nil, err
}
return &Remote{
Name: name,
FetchURL: finalURLParsed,
PushURL: finalURLParsed,
FetchURL: urlParsed,
PushURL: urlParsed,
}, nil
}

View file

@ -57,7 +57,7 @@ func ParseSSHConfig() SSHAliasMap {
configFiles = append([]string{userConfig}, configFiles...)
}
openFiles := []io.Reader{}
openFiles := make([]io.Reader, 0, len(configFiles))
for _, file := range configFiles {
f, err := os.Open(file)
if err != nil {
@ -70,9 +70,9 @@ func ParseSSHConfig() SSHAliasMap {
}
func sshParse(r ...io.Reader) SSHAliasMap {
config := SSHAliasMap{}
config := make(SSHAliasMap)
for _, file := range r {
sshParseConfig(config, file)
_ = sshParseConfig(config, file)
}
return config
}

18
go.mod
View file

@ -3,28 +3,28 @@ module github.com/cli/cli
go 1.13
require (
github.com/AlecAivazis/survey/v2 v2.0.5
github.com/alecthomas/chroma v0.7.1 // indirect
github.com/charmbracelet/glamour v0.1.1-0.20200210132808-8d4e3f0692f8
github.com/AlecAivazis/survey/v2 v2.0.7
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
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510
github.com/hashicorp/go-version v1.2.0
github.com/henvic/httpretty v0.0.3
github.com/henvic/httpretty v0.0.4
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51
github.com/mattn/go-colorable v0.1.4
github.com/mattn/go-colorable v0.1.6
github.com/mattn/go-isatty v0.0.12
github.com/mattn/go-runewidth v0.0.8 // indirect
github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b
github.com/mitchellh/go-homedir v1.1.0
github.com/muesli/reflow v0.0.0-20200210202703-cf7e7eac5cb4 // indirect
github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f // indirect
github.com/spf13/cobra v0.0.6
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.4.0 // indirect
github.com/yuin/goldmark v1.1.23 // indirect
golang.org/x/crypto v0.0.0-20200219234226-1ad67e1f0ef4
golang.org/x/net v0.0.0-20200219183655-46282727080f // indirect
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c // indirect
golang.org/x/text v0.3.0
golang.org/x/text v0.3.2
gopkg.in/yaml.v2 v2.2.8 // indirect
gopkg.in/yaml.v3 v3.0.0-20200121175148-a6ecf24a6d71
)

65
go.sum
View file

@ -1,25 +1,18 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
github.com/AlecAivazis/survey/v2 v2.0.5 h1:xpZp+Q55wi5C7Iaze+40onHnEkex1jSc34CltJjOoPM=
github.com/AlecAivazis/survey/v2 v2.0.5/go.mod h1:WYBhg6f0y/fNYUuesWQc0PKbJcEliGcYHB9sNT3Bg74=
github.com/AlecAivazis/survey/v2 v2.0.7 h1:+f825XHLse/hWd2tE/V5df04WFGimk34Eyg/z35w/rc=
github.com/AlecAivazis/survey/v2 v2.0.7/go.mod h1:mlizQTaPjnR4jcpwRSaSlkbsRfYFEyKgLQvYTzxxiHA=
github.com/BurntSushi/toml v0.3.1 h1:WXkYYl6Yr3qBf1K79EBnL4mak0OimBfB0XUf9Vl28OQ=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/GeertJohan/go.incremental v1.0.0/go.mod h1:6fAjUhbVuX1KcMD3c8TEgVUqmo4seqhv0i0kdATSkM0=
github.com/GeertJohan/go.rice v1.0.0/go.mod h1:eH6gbSOAUv07dQuZVnBmoDP8mgsM1rtixis4Tib9if0=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8 h1:xzYJEypr/85nBpB11F9br+3HUrpgb+fcm5iADzXXYEw=
github.com/Netflix/go-expect v0.0.0-20180615182759-c93bf25de8e8/go.mod h1:oX5x61PbNXchhh0oikYAH+4Pcfw5LKv21+Jnpr6r6Pc=
github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU=
github.com/akavel/rsrc v0.8.0/go.mod h1:uLoCtb9J+EyAqh+26kdrTgmzRBFPGOolLWKpdxkKq+c=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38 h1:smF2tmSOzy2Mm+0dGI2AIUHY+w0BUc+4tn40djz7+6U=
github.com/alecthomas/assert v0.0.0-20170929043011-405dbfeb8e38/go.mod h1:r7bzyVFMNntcxPZXK3/+KdruV1H5KSlyVY0gc+NgInI=
github.com/alecthomas/chroma v0.7.0 h1:z+0HgTUmkpRDRz0SRSdMaqOLfJV4F+N1FPDZUZIDUzw=
github.com/alecthomas/chroma v0.7.0/go.mod h1:1U/PfCsTALWWYHDnsIQkxEBM0+6LLe0v8+RSVMOwxeY=
github.com/alecthomas/chroma v0.7.1 h1:G1i02OhUbRi2nJxcNkwJaY/J1gHXj9tt72qN6ZouLFQ=
github.com/alecthomas/chroma v0.7.1/go.mod h1:gHw09mkX1Qp80JlYbmN9L3+4R5o6DJJ3GRShh+AICNc=
github.com/alecthomas/chroma v0.7.2-0.20200304075647-34d9c7143bf5 h1:yt5ij+XTe1QL+TpFj7i547enM5YuGKp9nZ/WvOoqcsQ=
github.com/alecthomas/chroma v0.7.2-0.20200304075647-34d9c7143bf5/go.mod h1:fv5SzZPFJbwp2NXJWpFIX7DZS4HgV1K4ew4Pc2OZD9s=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721 h1:JHZL0hZKJ1VENNfmXvHbgYlbUOvpzYzvy2aZU5gXVeo=
github.com/alecthomas/colour v0.0.0-20160524082231-60882d9e2721/go.mod h1:QO9JBoKquHd+jz9nshCh40fOfO+JzsoXy8qTHF68zU0=
github.com/alecthomas/kong v0.1.17-0.20190424132513-439c674f7ae0/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/kong v0.2.1-0.20190708041108-0548c6b1afae/go.mod h1:+inYUSluD+p4L8KdviBSgzcqEjUQOfC5fQDRFuc36lI=
github.com/alecthomas/kong-hcl v0.1.8-0.20190615233001-b21fea9723c8/go.mod h1:MRgZdU3vrFd05IQ89AxUZ0aYdF39BYoNFa324SodPCA=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897 h1:p9Sln00KOTlrYkxI1zYWl1QLnEqAqEARBEYa8FQnQcY=
github.com/alecthomas/repr v0.0.0-20180818092828-117648cd9897/go.mod h1:xTS7Pm1pD1mvyM075QCDSRqH6qRLXylzS24ZTpRiSzQ=
github.com/alecthomas/template v0.0.0-20160405071501-a0175ee3bccc/go.mod h1:LOuyumcjzFXgccqObfd/Ljyb9UuFJ6TxHnclSeseNhc=
@ -27,9 +20,11 @@ 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.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.20200210132808-8d4e3f0692f8 h1:udjWAc2bbIP0LyzEMt7BWxk6O6lIoONWoonN3C9bMtA=
github.com/charmbracelet/glamour v0.1.1-0.20200210132808-8d4e3f0692f8/go.mod h1:UsY1vFbaLp/j/SYgLfPH0n2I0jngBL+q6+mCAsESih4=
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=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/coreos/bbolt v1.3.2/go.mod h1:iRUV2dpdMOn7Bo10OQBFzIJO9kkE559Wcmn+qkEiiKk=
github.com/coreos/etcd v3.3.10+incompatible/go.mod h1:uF7uidLiAD3TWHmW31ZFd/JWoc32PjwdhPthX9715RE=
@ -38,7 +33,6 @@ github.com/coreos/go-systemd v0.0.0-20190321100706-95778dfbb74e/go.mod h1:F5haX7
github.com/coreos/pkg v0.0.0-20180928190104-399ea9e2e55f/go.mod h1:E3G3o1h8I7cfcXa63jLwjI0eiQQMgzzUDFVpN/nH/eA=
github.com/cpuguy83/go-md2man/v2 v2.0.0 h1:EoUDS0afbrsXAZ9YQ9jdu/mZ2sXgT1/2yyNng4PGlyM=
github.com/cpuguy83/go-md2man/v2 v2.0.0/go.mod h1:maD7wRr/U5Z6m/iR4s+kqSMx2CaBsrgA7czyZG/E6dU=
github.com/daaku/go.zipexe v1.0.0/go.mod h1:z8IiR6TsVLEYKwXAoE/I+8ys/sDkgTzSL0CLnGVd57E=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964 h1:y5HC9v93H5EPKqaS1UYVg1uYah5Xf51mBfIoWehClUQ=
github.com/danwakefield/fnmatch v0.0.0-20160403171240-cbb64ac3d964/go.mod h1:Xd9hchkHSWYkEqJwUGisez3G1QY8Ryz0sdWrLPMGjLk=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@ -50,6 +44,8 @@ github.com/dlclark/regexp2 v1.1.6 h1:CqB4MjHw0MFCDj+PHHjiESmHX+N7t0tJzKvC6M97BRg
github.com/dlclark/regexp2 v1.1.6/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/dlclark/regexp2 v1.2.0 h1:8sAhBGEM0dRWogWqWyQeIJnxjWO6oIjl8FKqREDsGfk=
github.com/dlclark/regexp2 v1.2.0/go.mod h1:2pZnwuY/m+8K6iRw6wQdMtk+rH5tNGR1i55kozfMjCc=
github.com/fatih/color v1.7.0 h1:DkWD4oS2D8LGGgTQ6IvwJJXSL5Vp2ffcQg58nFV38Ys=
github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5KwzbycvMj4=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=
@ -62,17 +58,15 @@ github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfU
github.com/golang/groupcache v0.0.0-20190129154638-5b532d6fd5ef/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f h1:5CjVwnuUcp5adK4gmY6i72gpVFVnZDP2h5TmPScB6u4=
github.com/google/goterm v0.0.0-20190703233501-fc88cf888a3f/go.mod h1:nOFQdrUlIlx6M6ODdSpBj1NVA+VgLC6kmw60mkw34H4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4=
github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ=
github.com/gorilla/csrf v1.6.0/go.mod h1:7tSf8kmjNYr7IWDCYhd3U8Ck34iQ/Yw5CJu7bAkHEGI=
github.com/gorilla/handlers v1.4.1/go.mod h1:Qkdc/uu4tH4g6mTK6auzZ766c4CA0Ng8+o/OAirnOIQ=
github.com/gorilla/mux v1.7.3/go.mod h1:1lud6UwP+6orDFRuTfBEV8e9/aOM/c4fVVCaMa2zaAs=
github.com/gorilla/securecookie v1.1.1/go.mod h1:ra0sb63/xPlUeL+yeDciTfxMRAA+MP+HVt/4epWDjd4=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/grpc-ecosystem/go-grpc-middleware v1.0.0/go.mod h1:FiyG127CGDf3tlThmgyCl78X/SZQqEOJBCDaAfeWzPs=
github.com/grpc-ecosystem/go-grpc-prometheus v1.2.0/go.mod h1:8NvIoxWQoOIhqOTXgfV/d3M/q6VIi02HzZEHgUlZvzk=
@ -80,13 +74,12 @@ github.com/grpc-ecosystem/grpc-gateway v1.9.0/go.mod h1:vNeuVxBJEsws4ogUvrchl83t
github.com/hashicorp/go-version v1.2.0 h1:3vNe/fWF5CBgRIguda1meWhsZHy3m8gCJ5wx+dIzX/E=
github.com/hashicorp/go-version v1.2.0/go.mod h1:fltr4n8CU8Ke44wwGCBoEymUuxUHl09ZGVZPK5anwXA=
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
github.com/henvic/httpretty v0.0.3 h1:oHTreVv2lcdRYUNm4h3cgbrGN0dTieO9H8UnxEZNlvw=
github.com/henvic/httpretty v0.0.3/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
github.com/henvic/httpretty v0.0.4 h1:hyMkO0HugjsmWu63Z+7chDw7+RilkKBJ1vCwlqUOvOk=
github.com/henvic/httpretty v0.0.4/go.mod h1:X38wLjWXHkXT7r2+uK8LjCMne9rsuNaBLJ+5cU2/Pmo=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174 h1:WlZsjVhE8Af9IcZDGgJGQpNflI3+MJSBhsgT5PCtzBQ=
github.com/hinshun/vt10x v0.0.0-20180616224451-1954e6464174/go.mod h1:DqJ97dSdRW1W22yXSB90986pcOyQ7r45iio1KN2ez1A=
github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM=
github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/jonboulle/clockwork v0.1.0/go.mod h1:Ii8DK3G1RaLaWxj9trq07+26W01tbo22gdxWY5EU2bo=
github.com/julienschmidt/httprouter v1.2.0/go.mod h1:SYymIcj16QtmaHHD7aYtjjsJG7VTCxuUUipMqKk8s4w=
github.com/kballard/go-shellquote v0.0.0-20180428030007-95032a82bc51 h1:Z9n2FFNUXsshfwJMBgNA0RU6/i7WVaAegv3PtuIHPMs=
@ -108,8 +101,8 @@ github.com/magiconair/properties v1.8.0/go.mod h1:PppfXfuXeibc/6YijjN8zIbojt8czP
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.2 h1:/bC9yWikZXAL9uJdulbSfyVNIR3n3trXl+v8+1sx8mU=
github.com/mattn/go-colorable v0.1.2/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.4 h1:snbPLB8fVfU9iwbbo30TPtbLRzwWu6aJS6Xh4eaaviA=
github.com/mattn/go-colorable v0.1.4/go.mod h1:U0ppj6V5qS13XJ6of8GYAs25YV2eR4EVcfRqFIhoBtE=
github.com/mattn/go-colorable v0.1.6 h1:6Su7aK7lXmJ/U79bYtBjLNaha4Fs1Rg9plHpcH+vvnE=
github.com/mattn/go-colorable v0.1.6/go.mod h1:u6P/XSegPjTcexA+o6vUJrdnUu04hMope9wVRipJSqc=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.8/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.12 h1:wuysRhFDzyxgEmMf5xjvJ2M9dZoWAXNNr5LSBS7uHXY=
@ -126,13 +119,11 @@ github.com/microcosm-cc/bluemonday v1.0.2/go.mod h1:iVP4YcDBq+n/5fb23BhYFvIMq/le
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/mitchellh/mapstructure v1.1.2/go.mod h1:FVVH3fgwuzCH5S8UJGiWEs2h04kUh9fWfEaFds41c1Y=
github.com/muesli/reflow v0.0.0-20200210123334-eb23c6404749/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I=
github.com/muesli/reflow v0.0.0-20200210202703-cf7e7eac5cb4 h1:IQAzML2A961stVPyK3En5VajxyiujmTEUkJUXsXHY44=
github.com/muesli/reflow v0.0.0-20200210202703-cf7e7eac5cb4/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I=
github.com/muesli/reflow v0.1.0 h1:oQdpLfO56lr5pgLvqD0TcjW85rDjSYSBVdiG1Ch1ddM=
github.com/muesli/reflow v0.1.0/go.mod h1:I9bWAt7QTg/que/qmUCJBGlj7wEq8OAFBjPNjc6xK4I=
github.com/muesli/termenv v0.4.0 h1:8Bz8WDF/6OoL7QENtESkAl3TlMyQq6+c3bjtJO3y9V8=
github.com/muesli/termenv v0.4.0/go.mod h1:O1/I6sw+6KcrgAmcs6uiUVr7Lui+DNVbHTzt9Lm/PlI=
github.com/mwitkow/go-conntrack v0.0.0-20161129095857-cc309e4a2223/go.mod h1:qRWi+5nqEBWmkhHvq77mSJWrCKwh8bxhgT7d/eI7P4U=
github.com/nkovacs/streamquote v0.0.0-20170412213628-49af9bddb229/go.mod h1:0aYXnNPJ8l7uZxf45rWW1a/uME32OF0rhiYGNQ2oF2E=
github.com/oklog/ulid v1.3.1/go.mod h1:CirwcVhetQ6Lv90oh/F+FBtV6XMibvdAFo93nm5qn4U=
github.com/olekukonko/tablewriter v0.0.4 h1:vHD/YYe1Wolo78koG299f7V/VAS08c6IpCLn+Ejf/w8=
github.com/olekukonko/tablewriter v0.0.4/go.mod h1:zq6QwlOf5SlnkVbMSr5EoBv3636FWnp+qbPhuoO21uA=
@ -156,6 +147,10 @@ github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/sergi/go-diff v1.0.0 h1:Kpca3qRNrduNnOQeazBd0ysaKrUJiIuISHxogkT9RPQ=
github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0 h1:T9uus1QvcPgeLShS30YOnnzk3r9Vvygp45muhlrufgY=
github.com/shurcooL/githubv4 v0.0.0-20191127044304-8f68eb5628d0/go.mod h1:hAF0iLZy4td2EX+/8Tw+4nodhlMrwN3HupfaXj3zkGo=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f h1:tygelZueB1EtXkPI6mQ4o9DQ0+FKW41hTbunoXZCTqk=
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f/go.mod h1:AuYgA5Kyo4c7HfUmvRGs/6rGlMMV/6B1bVnB9JxJEEg=
github.com/shurcooL/sanitized_anchor_name v1.0.0 h1:PdmoCO6wvbs+7yrJyMORt4/BmY5IYyJwS/kOiWx8mHo=
github.com/shurcooL/sanitized_anchor_name v1.0.0/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/sirupsen/logrus v1.2.0/go.mod h1:LxeOpSwHxABJmUn/MG1IvRgCAasNZTLOkJPxbbu5VWo=
@ -181,13 +176,10 @@ github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJy
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/tmc/grpc-websocket-proxy v0.0.0-20190109142713-0ad062ec5ee5/go.mod h1:ncp9v5uamzpCO7NfCPTXjqaC+bZgJeR0sMTm6dMHP7U=
github.com/ugorji/go v1.1.4/go.mod h1:uQMGLiO92mf5W77hV/PUCpI3pbzQx3CRekS0kk+RGrc=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
github.com/xiang90/probing v0.0.0-20190116061207-43a291ad63a2/go.mod h1:UETIi67q53MR2AWcXfiuqkDkRtnGDLqkBTpCHuJHxtU=
github.com/xordataexchange/crypt v0.0.3-0.20170626215501-b2862e3d0a77/go.mod h1:aYKd//L2LvnjZzWKhF00oedf4jCCReLcmhLdhm1A27Q=
github.com/yuin/goldmark v1.1.22/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.23 h1:eTodJ8hwEUvwXhb9qxQNuL/q1d+xMQClrXR4mdvV7gs=
github.com/yuin/goldmark v1.1.23/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
github.com/yuin/goldmark v1.1.24 h1:K4FemPDr4x/ZcqldoXWnexTLfdMIy2eEfXxsLnotTRI=
github.com/yuin/goldmark v1.1.24/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
go.etcd.io/bbolt v1.3.2/go.mod h1:IbVyRI1SCnLcuJnV2u8VeU0CEYM7e686BmAb1XKL+uU=
go.uber.org/atomic v1.4.0/go.mod h1:gD2HeocX3+yG+ygLZcrzQJaqmWj9AIm7n08wl/qW/PE=
go.uber.org/multierr v1.1.0/go.mod h1:wR5kodmAFQ0UK8QlbwjlSNy0Z68gJhDJUG5sjR94q/0=
@ -209,6 +201,7 @@ golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn
golang.org/x/net v0.0.0-20190522155817-f3200d17e092/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20200219183655-46282727080f h1:dB42wwhNuwPvh8f+5zZWNcU+F2Xs/B9wXXwvUCOH7r8=
golang.org/x/net v0.0.0-20200219183655-46282727080f/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be h1:vEDujvNQGv4jgYKudGeI/+DAX4Jffq6hpD55MmoEvKs=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@ -223,14 +216,18 @@ golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5h
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190530182044-ad28b68e88f1/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200116001909-b77594299b42/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c h1:jceGD5YNJGgGMkJz79agzOln1K9TaZUjv5ird16qniQ=
golang.org/x/sys v0.0.0-20200219091948-cb0a6d8edb6c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae h1:/WDfKMnPU+m5M4xB+6x4kaepxRw6jWvR5iDRdvjHgy8=
golang.org/x/sys v0.0.0-20200223170610-d5e6a3e2c0ae/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2 h1:tW2bmiBqwgJj/UpqtC8EpXEZVYOwU0yG4iWbprSVAcs=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/tools v0.0.0-20180221164845-07fd8470d635/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
google.golang.org/appengine v1.1.0 h1:igQkv0AAhEIvTEpD5LIpAfav2eeVO9HBTjvKHVJPRSs=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=

View file

@ -117,7 +117,7 @@ func flagRequiresArgumentCompletion(flag *pflag.Flag) string {
}
func subCommandPath(rootCmd *cobra.Command, cmd *cobra.Command) string {
path := []string{}
path := make([]string, 0, 1)
currentCmd := cmd
if rootCmd == cmd {
return ""
@ -142,7 +142,7 @@ func rangeCommands(cmd *cobra.Command, callback func(subCmd *cobra.Command)) {
func commandCompletionCondition(rootCmd, cmd *cobra.Command) string {
localNonPersistentFlags := cmd.LocalNonPersistentFlags()
bareConditions := []string{}
bareConditions := make([]string, 0, 1)
if rootCmd != cmd {
bareConditions = append(bareConditions, fmt.Sprintf("__fish_%s_seen_subcommand_path %s", rootCmd.Name(), subCommandPath(rootCmd, cmd)))
} else {

View file

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

View file

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

View file

@ -0,0 +1,133 @@
package config
import (
"bufio"
"fmt"
"io"
"os"
"path/filepath"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/auth"
"gopkg.in/yaml.v3"
)
const (
oauthHost = "github.com"
)
var (
// The "GitHub CLI" OAuth app
oauthClientID = "178c6fc778ccc68e1d6a"
// This value is safe to be embedded in version control
oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b"
)
// IsGitHubApp reports whether an OAuth app is "GitHub CLI" or "GitHub CLI (dev)"
func IsGitHubApp(id string) bool {
// this intentionally doesn't use `oauthClientID` because that is a variable
// that can potentially be changed at build time via GH_OAUTH_CLIENT_ID
return id == "178c6fc778ccc68e1d6a" || id == "4d747ba5675d5d66553f"
}
func AuthFlow(notice string) (string, string, error) {
var verboseStream io.Writer
if strings.Contains(os.Getenv("DEBUG"), "oauth") {
verboseStream = os.Stderr
}
flow := &auth.OAuthFlow{
Hostname: oauthHost,
ClientID: oauthClientID,
ClientSecret: oauthClientSecret,
Scopes: []string{"repo", "read:org", "gist"},
WriteSuccessHTML: func(w io.Writer) {
fmt.Fprintln(w, oauthSuccessPage)
},
VerboseStream: verboseStream,
}
fmt.Fprintln(os.Stderr, notice)
fmt.Fprintf(os.Stderr, "Press Enter to open %s in your browser... ", flow.Hostname)
_ = waitForEnter(os.Stdin)
token, err := flow.ObtainAccessToken()
if err != nil {
return "", "", err
}
userLogin, err := getViewer(token)
if err != nil {
return "", "", err
}
return token, userLogin, nil
}
func AuthFlowComplete() {
fmt.Fprintln(os.Stderr, "Authentication complete. Press Enter to continue... ")
_ = waitForEnter(os.Stdin)
}
// FIXME: make testable
func setupConfigFile(filename string) (Config, error) {
token, userLogin, err := AuthFlow("Notice: authentication required")
if err != nil {
return nil, err
}
// 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[oauthHost] = map[string]string{}
yamlHosts[oauthHost]["user"] = userLogin
yamlHosts[oauthHost]["oauth_token"] = token
defaultConfig := yamlConfig{
Hosts: yamlHosts,
}
err = os.MkdirAll(filepath.Dir(filename), 0771)
if err != nil {
return nil, err
}
cfgFile, err := os.OpenFile(filename, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
if err != nil {
return nil, err
}
defer cfgFile.Close()
yamlData, err := yaml.Marshal(defaultConfig)
if err != nil {
return nil, err
}
_, err = cfgFile.Write(yamlData)
if err != nil {
return nil, 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) {
http := api.NewClient(api.AddHeader("Authorization", fmt.Sprintf("token %s", token)))
response := struct {
Viewer struct {
Login string
}
}{}
err := http.GraphQL("{ viewer { login } }", nil, &response)
return response.Viewer.Login, err
}
func waitForEnter(r io.Reader) error {
scanner := bufio.NewScanner(r)
scanner.Scan()
return scanner.Err()
}

View file

@ -1,4 +1,4 @@
package context
package config
const oauthSuccessPage = `
<!doctype html>

View file

@ -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, &notFound) {
keyNode := &yaml.Node{
Kind: yaml.ScalarNode,
Value: key,
}
valueNode = &yaml.Node{
Kind: yaml.ScalarNode,
Value: "",
}
cm.Root.Content = append(cm.Root.Content, keyNode, valueNode)
} else if err != nil {
return err
}
valueNode.Value = value
return nil
}
func (cm *ConfigMap) FindEntry(key string) (keyNode, valueNode *yaml.Node, err error) {
err = nil
topLevelKeys := cm.Root.Content
for i, v := range topLevelKeys {
if v.Value == key && i+1 < len(topLevelKeys) {
keyNode = v
valueNode = topLevelKeys[i+1]
return
}
}
return nil, nil, &NotFoundError{errors.New("not found")}
}
func NewConfig(root *yaml.Node) Config {
return &fileConfig{
ConfigMap: ConfigMap{Root: root.Content[0]},
documentRoot: root,
}
}
// This type implements a Config interface and represents a config file on disk.
type fileConfig struct {
ConfigMap
documentRoot *yaml.Node
hosts []*HostConfig
}
func (c *fileConfig) Get(hostname, key string) (string, error) {
if hostname != "" {
hostCfg, err := c.configForHost(hostname)
if err != nil {
return "", err
}
hostValue, err := hostCfg.GetStringValue(key)
var notFound *NotFoundError
if err != nil && !errors.As(err, &notFound) {
return "", err
}
if hostValue != "" {
return hostValue, nil
}
}
value, err := c.GetStringValue(key)
var notFound *NotFoundError
if err != nil && errors.As(err, &notFound) {
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 ""
}
}

View file

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

View file

@ -6,25 +6,31 @@ 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
type Interface interface {
RepoName() string
RepoOwner() string
}
// New instantiates a GitHub repository from owner and name arguments
func New(owner, repo string) Interface {
return &ghRepo{
owner: owner,
name: repo,
}
}
// FullName serializes a GitHub repository into an "OWNER/REPO" string
func FullName(r Interface) string {
return fmt.Sprintf("%s/%s", r.RepoOwner(), r.RepoName())
}
// FromFullName extracts the GitHub repository inforation from an "OWNER/REPO" string
func FromFullName(nwo string) Interface {
r := ghRepo{}
var r ghRepo
parts := strings.SplitN(nwo, "/", 2)
if len(parts) == 2 {
r.owner, r.name = parts[0], parts[1]
@ -32,8 +38,9 @@ func FromFullName(nwo string) Interface {
return &r
}
// FromURL extracts the GitHub repository information from a URL
func FromURL(u *url.URL) (Interface, error) {
if !strings.EqualFold(u.Hostname(), defaultHostname) {
if !strings.EqualFold(u.Hostname(), defaultHostname) && !strings.EqualFold(u.Hostname(), "www."+defaultHostname) {
return nil, fmt.Errorf("unsupported hostname: %s", u.Hostname())
}
parts := strings.SplitN(strings.TrimPrefix(u.Path, "/"), "/", 3)
@ -43,6 +50,7 @@ func FromURL(u *url.URL) (Interface, error) {
return New(parts[0], strings.TrimSuffix(parts[1], ".git")), nil
}
// IsSame compares two GitHub repositories
func IsSame(a, b Interface) bool {
return strings.EqualFold(a.RepoOwner(), b.RepoOwner()) &&
strings.EqualFold(a.RepoName(), b.RepoName())

View file

@ -1,40 +1,66 @@
package ghrepo
import (
"errors"
"fmt"
"net/url"
"testing"
)
func Test_repoFromURL(t *testing.T) {
u, _ := url.Parse("http://github.com/monalisa/octo-cat.git")
repo, err := FromURL(u)
if err != nil {
t.Fatalf("got error %q", err)
tests := []struct {
name string
input string
result string
err error
}{
{
name: "github.com URL",
input: "https://github.com/monalisa/octo-cat.git",
result: "monalisa/octo-cat",
err: nil,
},
{
name: "www.github.com URL",
input: "http://www.GITHUB.com/monalisa/octo-cat.git",
result: "monalisa/octo-cat",
err: nil,
},
{
name: "unsupported hostname",
input: "https://example.com/one/two",
result: "",
err: errors.New("unsupported hostname: example.com"),
},
{
name: "filesystem path",
input: "/path/to/file",
result: "",
err: errors.New("unsupported hostname: "),
},
}
if repo.RepoOwner() != "monalisa" {
t.Errorf("got owner %q", repo.RepoOwner())
}
if repo.RepoName() != "octo-cat" {
t.Errorf("got name %q", repo.RepoName())
}
}
func Test_repoFromURL_invalid(t *testing.T) {
cases := [][]string{
[]string{
"https://example.com/one/two",
"unsupported hostname: example.com",
},
[]string{
"/path/to/disk",
"unsupported hostname: ",
},
}
for _, c := range cases {
u, _ := url.Parse(c[0])
_, err := FromURL(u)
if err == nil || err.Error() != c[1] {
t.Errorf("got %q", err)
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
u, err := url.Parse(tt.input)
if err != nil {
t.Fatalf("got error %q", err)
}
repo, err := FromURL(u)
if err != nil {
if tt.err == nil {
t.Fatalf("got error %q", err)
} else if tt.err.Error() == err.Error() {
return
}
t.Fatalf("got error %q", err)
}
got := fmt.Sprintf("%s/%s", repo.RepoOwner(), repo.RepoName())
if tt.result != got {
t.Errorf("expected %q, got %q", tt.result, got)
}
})
}
}

View file

@ -1,4 +1,4 @@
package utils
package run
import (
"bytes"

View file

@ -59,18 +59,19 @@ mainLoop:
}
}
sort.Sort(sort.StringSlice(results))
sort.Strings(results)
return results
}
// ExtractName returns the name of the template from YAML front-matter
func ExtractName(filePath string) string {
contents, err := ioutil.ReadFile(filePath)
if err == nil && detectFrontmatter(contents)[0] == 0 {
frontmatterBoundaries := detectFrontmatter(contents)
if err == nil && frontmatterBoundaries[0] == 0 {
templateData := struct {
Name string
}{}
if err := yaml.Unmarshal(contents, &templateData); err == nil && templateData.Name != "" {
if err := yaml.Unmarshal(contents[0:frontmatterBoundaries[1]], &templateData); err == nil && templateData.Name != "" {
return templateData.Name
}
}

View file

@ -108,7 +108,7 @@ func TestFind(t *testing.T) {
t.Run(tt.name, func(t *testing.T) {
for _, p := range tt.prepare {
fp := path.Join(tmpdir, p)
os.MkdirAll(path.Dir(fp), 0700)
_ = os.MkdirAll(path.Dir(fp), 0700)
file, err := os.Create(fp)
if err != nil {
t.Fatal(err)
@ -148,9 +148,7 @@ name: Bug Report
about: This is how you report bugs
---
Template contents
---
More of template
**Template contents**
`,
args: args{
filePath: tmpfile.Name(),
@ -179,7 +177,7 @@ about: This is how you report bugs
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ioutil.WriteFile(tmpfile.Name(), []byte(tt.prepare), 0600)
_ = ioutil.WriteFile(tmpfile.Name(), []byte(tt.prepare), 0600)
if got := ExtractName(tt.args.filePath); got != tt.want {
t.Errorf("ExtractName() = %v, want %v", got, tt.want)
}
@ -244,7 +242,7 @@ Even more
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ioutil.WriteFile(tmpfile.Name(), []byte(tt.prepare), 0600)
_ = ioutil.WriteFile(tmpfile.Name(), []byte(tt.prepare), 0600)
if got := ExtractContents(tt.args.filePath); string(got) != tt.want {
t.Errorf("ExtractContents() = %v, want %v", string(got), tt.want)
}

89
pkg/httpmock/legacy.go Normal file
View file

@ -0,0 +1,89 @@
package httpmock
import (
"fmt"
"io"
"net/http"
"os"
"path"
"strings"
)
// TODO: clean up methods in this file when there are no more callers
func (r *Registry) StubResponse(status int, body io.Reader) {
r.Register(MatchAny, func(*http.Request) (*http.Response, error) {
return httpResponse(status, body), nil
})
}
func (r *Registry) StubWithFixture(status int, fixtureFileName string) func() {
fixturePath := path.Join("../test/fixtures/", fixtureFileName)
fixtureFile, err := os.Open(fixturePath)
r.Register(MatchAny, func(*http.Request) (*http.Response, error) {
if err != nil {
return nil, err
}
return httpResponse(200, fixtureFile), nil
})
return func() {
if err == nil {
fixtureFile.Close()
}
}
}
func (r *Registry) StubRepoResponse(owner, repo string) {
r.StubRepoResponseWithPermission(owner, repo, "WRITE")
}
func (r *Registry) StubRepoResponseWithPermission(owner, repo, permission string) {
r.Register(MatchAny, StringResponse(RepoNetworkStubResponse(owner, repo, "master", permission)))
}
func (r *Registry) StubRepoResponseWithDefaultBranch(owner, repo, defaultBranch string) {
r.Register(MatchAny, StringResponse(RepoNetworkStubResponse(owner, repo, defaultBranch, "WRITE")))
}
func (r *Registry) StubForkedRepoResponse(ownRepo, parentRepo string) {
r.Register(MatchAny, StringResponse(RepoNetworkStubForkResponse(ownRepo, parentRepo)))
}
func RepoNetworkStubResponse(owner, repo, defaultBranch, permission string) string {
return fmt.Sprintf(`
{ "data": { "repo_000": {
"id": "REPOID",
"name": "%s",
"owner": {"login": "%s"},
"defaultBranchRef": {
"name": "%s"
},
"viewerPermission": "%s"
} } }
`, repo, owner, defaultBranch, permission)
}
func RepoNetworkStubForkResponse(forkFullName, parentFullName string) string {
forkRepo := strings.SplitN(forkFullName, "/", 2)
parentRepo := strings.SplitN(parentFullName, "/", 2)
return fmt.Sprintf(`
{ "data": { "repo_000": {
"id": "REPOID2",
"name": "%s",
"owner": {"login": "%s"},
"defaultBranchRef": {
"name": "master"
},
"viewerPermission": "ADMIN",
"parent": {
"id": "REPOID1",
"name": "%s",
"owner": {"login": "%s"},
"defaultBranchRef": {
"name": "master"
},
"viewerPermission": "READ"
}
} } }
`, forkRepo[1], forkRepo[0], parentRepo[1], parentRepo[0])
}

70
pkg/httpmock/registry.go Normal file
View file

@ -0,0 +1,70 @@
package httpmock
import (
"fmt"
"net/http"
"sync"
)
type Registry struct {
mu sync.Mutex
stubs []*Stub
Requests []*http.Request
}
func (r *Registry) Register(m Matcher, resp Responder) {
r.stubs = append(r.stubs, &Stub{
Matcher: m,
Responder: resp,
})
}
type Testing interface {
Errorf(string, ...interface{})
}
func (r *Registry) Verify(t Testing) {
n := 0
for _, s := range r.stubs {
if !s.matched {
n++
}
}
if n > 0 {
// NOTE: stubs offer no useful reflection, so we can't print details
// about dead stubs and what they were trying to match
t.Errorf("%d unmatched HTTP stubs", n)
}
}
// RoundTrip satisfies http.RoundTripper
func (r *Registry) RoundTrip(req *http.Request) (*http.Response, error) {
var stub *Stub
r.mu.Lock()
for _, s := range r.stubs {
if s.matched || !s.Matcher(req) {
continue
}
// TODO: reinstate this check once the legacy layer has been cleaned up
// if stub != nil {
// r.mu.Unlock()
// return nil, fmt.Errorf("more than 1 stub matched %v", req)
// }
stub = s
break // TODO: remove
}
if stub != nil {
stub.matched = true
}
if stub == nil {
r.mu.Unlock()
return nil, fmt.Errorf("no registered stubs matched %v", req)
}
r.Requests = append(r.Requests, req)
r.mu.Unlock()
return stub.Responder(req)
}

112
pkg/httpmock/stub.go Normal file
View file

@ -0,0 +1,112 @@
package httpmock
import (
"bytes"
"encoding/json"
"io"
"io/ioutil"
"net/http"
"regexp"
"strings"
)
type Matcher func(req *http.Request) bool
type Responder func(req *http.Request) (*http.Response, error)
type Stub struct {
matched bool
Matcher Matcher
Responder Responder
}
func MatchAny(*http.Request) bool {
return true
}
func GraphQL(q string) Matcher {
re := regexp.MustCompile(q)
return func(req *http.Request) bool {
if !strings.EqualFold(req.Method, "POST") {
return false
}
if req.URL.Path != "/graphql" {
return false
}
var bodyData struct {
Query string
}
_ = decodeJSONBody(req, &bodyData)
return re.MatchString(bodyData.Query)
}
}
func readBody(req *http.Request) ([]byte, error) {
bodyCopy := &bytes.Buffer{}
r := io.TeeReader(req.Body, bodyCopy)
req.Body = ioutil.NopCloser(bodyCopy)
return ioutil.ReadAll(r)
}
func decodeJSONBody(req *http.Request, dest interface{}) error {
b, err := readBody(req)
if err != nil {
return err
}
return json.Unmarshal(b, dest)
}
func StringResponse(body string) Responder {
return func(*http.Request) (*http.Response, error) {
return httpResponse(200, bytes.NewBufferString(body)), nil
}
}
func JSONResponse(body interface{}) Responder {
return func(*http.Request) (*http.Response, error) {
b, _ := json.Marshal(body)
return httpResponse(200, bytes.NewBuffer(b)), nil
}
}
func GraphQLMutation(body string, cb func(map[string]interface{})) Responder {
return func(req *http.Request) (*http.Response, error) {
var bodyData struct {
Variables struct {
Input map[string]interface{}
}
}
err := decodeJSONBody(req, &bodyData)
if err != nil {
return nil, err
}
cb(bodyData.Variables.Input)
return httpResponse(200, bytes.NewBufferString(body)), nil
}
}
func GraphQLQuery(body string, cb func(string, map[string]interface{})) Responder {
return func(req *http.Request) (*http.Response, error) {
var bodyData struct {
Query string
Variables map[string]interface{}
}
err := decodeJSONBody(req, &bodyData)
if err != nil {
return nil, err
}
cb(bodyData.Query, bodyData.Variables)
return httpResponse(200, bytes.NewBufferString(body)), nil
}
}
func httpResponse(status int, body io.Reader) *http.Response {
return &http.Response{
StatusCode: status,
Body: ioutil.NopCloser(body),
}
}

View file

@ -18,25 +18,35 @@ import (
)
var (
bom = []byte{0xef, 0xbb, 0xbf}
editor = "nano" // EXTENDED to switch from vim as a default editor
bom = []byte{0xef, 0xbb, 0xbf}
defaultEditor = "nano" // EXTENDED to switch from vim as a default editor
)
func init() {
if runtime.GOOS == "windows" {
editor = "notepad"
defaultEditor = "notepad"
} else if g := os.Getenv("GIT_EDITOR"); g != "" {
editor = g
defaultEditor = g
} else if v := os.Getenv("VISUAL"); v != "" {
editor = v
defaultEditor = v
} else if e := os.Getenv("EDITOR"); e != "" {
editor = e
defaultEditor = e
}
}
// EXTENDED to enable different prompting behavior
type GhEditor struct {
*survey.Editor
EditorCommand string
BlankAllowed bool
}
func (e *GhEditor) editorCommand() string {
if e.EditorCommand == "" {
return defaultEditor
}
return e.EditorCommand
}
// EXTENDED to change prompt text
@ -49,28 +59,30 @@ var EditorQuestionTemplate = `
{{- else }}
{{- if and .Help (not .ShowHelp)}}{{color "cyan"}}[{{ .Config.HelpInput }} for help]{{color "reset"}} {{end}}
{{- if and .Default (not .HideDefault)}}{{color "white"}}({{.Default}}) {{color "reset"}}{{end}}
{{- color "cyan"}}[(e) to launch {{ .EditorName }}, enter to skip] {{color "reset"}}
{{- color "cyan"}}[(e) to launch {{ .EditorCommand }}{{- if .BlankAllowed }}, enter to skip{{ end }}] {{color "reset"}}
{{- end}}`
// EXTENDED to pass editor name (to use in prompt)
type EditorTemplateData struct {
survey.Editor
EditorName string
Answer string
ShowAnswer bool
ShowHelp bool
Config *survey.PromptConfig
EditorCommand string
BlankAllowed bool
Answer string
ShowAnswer bool
ShowHelp bool
Config *survey.PromptConfig
}
// EXTENDED to augment prompt text and keypress handling
func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (interface{}, error) {
err := e.Render(
EditorQuestionTemplate,
// EXTENDED to support printing editor in prompt
// EXTENDED to support printing editor in prompt and BlankAllowed
EditorTemplateData{
Editor: *e.Editor,
EditorName: filepath.Base(editor),
Config: config,
Editor: *e.Editor,
BlankAllowed: e.BlankAllowed,
EditorCommand: filepath.Base(e.editorCommand()),
Config: config,
},
)
if err != nil {
@ -79,15 +91,15 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int
// start reading runes from the standard in
rr := e.NewRuneReader()
rr.SetTermMode()
defer rr.RestoreTermMode()
_ = rr.SetTermMode()
defer func() { _ = rr.RestoreTermMode() }()
cursor := e.NewCursor()
cursor.Hide()
defer cursor.Show()
for {
// EXTENDED to handle the e to edit / enter to skip behavior
// EXTENDED to handle the e to edit / enter to skip behavior + BlankAllowed
r, _, err := rr.ReadRune()
if err != nil {
return "", err
@ -96,7 +108,11 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int
break
}
if r == '\r' || r == '\n' {
return "", nil
if e.BlankAllowed {
return "", nil
} else {
continue
}
}
if r == terminal.KeyInterrupt {
return "", terminal.InterruptErr
@ -108,11 +124,12 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int
err = e.Render(
EditorQuestionTemplate,
EditorTemplateData{
// EXTENDED to support printing editor in prompt
Editor: *e.Editor,
EditorName: filepath.Base(editor),
ShowHelp: true,
Config: config,
// EXTENDED to support printing editor in prompt, BlankAllowed
Editor: *e.Editor,
BlankAllowed: e.BlankAllowed,
EditorCommand: filepath.Base(e.editorCommand()),
ShowHelp: true,
Config: config,
},
)
if err != nil {
@ -155,7 +172,7 @@ func (e *GhEditor) prompt(initialValue string, config *survey.PromptConfig) (int
stdio := e.Stdio()
args, err := shellquote.Split(editor)
args, err := shellquote.Split(e.editorCommand())
if err != nil {
return "", err
}

View file

@ -28,7 +28,7 @@ func Truncate(max int, s string) string {
useEllipsis := false
if max >= minWidthForEllipsis {
useEllipsis = true
max -= 3
max -= ellipsisWidth
}
cw := 0

View file

@ -1,27 +0,0 @@
# Releasing
## Test Locally
`go test ./...`
## Push new docs
build docs locally: `make site`
build and push docs to production: `make site-docs`
## Release locally for debugging
A local release can be created for testing without creating anything official on the release page.
1. `env GH_OAUTH_CLIENT_SECRET= GH_OAUTH_CLIENT_ID= goreleaser --skip-validate --skip-publish --rm-dist`
2. Check and test files in `dist/`
## Release to production
This can all be done from your local terminal.
1. `git tag 'vVERSION_NUMBER' # example git tag 'v0.0.1'`
2. `git push origin vVERSION_NUMBER`
3. Wait a few minutes for the build to run and CI to pass. Look at the [actions tab](https://github.com/cli/cli/actions) to check the progress.
4. Go to <https://github.com/cli/cli/releases> and look at the release

9
test/fixtures/forkResult.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"node_id": "123",
"name": "REPO",
"clone_url": "https://github.com/someone/repo.git",
"created_at": "2011-01-26T19:01:12Z",
"owner": {
"login": "someone"
}
}

View file

@ -3,6 +3,7 @@
"repository": {
"hasIssuesEnabled": true,
"issues": {
"totalCount": 3,
"nodes": [
{
"number": 1,

36
test/fixtures/issueView_preview.json vendored Normal file
View file

@ -0,0 +1,36 @@
{
"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": [],
"totalcount": 0
},
"labels": {
"nodes": [],
"totalcount": 0
},
"projectcards": {
"nodes": [],
"totalcount": 0
},
"milestone": {
"title": ""
},
"comments": {
"totalCount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
}
}
}
}

View file

@ -0,0 +1,28 @@
{
"data": {
"repository": {
"hasIssuesEnabled": true,
"issue": {
"number": 123,
"body": "**bold story**",
"title": "ix of coins",
"state": "CLOSED",
"created_at": "2011-01-26T19:01:12Z",
"author": {
"login": "marseilles"
},
"labels": {
"nodes": [
{
"name": "tarot"
}
]
},
"comments": {
"totalCount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
}
}
}
}

View file

@ -0,0 +1,28 @@
{
"data": {
"repository": {
"hasIssuesEnabled": true,
"issue": {
"number": 123,
"body": "",
"title": "ix of coins",
"state": "OPEN",
"created_at": "2011-01-26T19:01:12Z",
"author": {
"login": "marseilles"
},
"labels": {
"nodes": [
{
"name": "tarot"
}
]
},
"comments": {
"totalCount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
}
}
}
}

View file

@ -0,0 +1,92 @@
{
"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"
}
},
{
"project": {
"name": "Project 4"
},
"column": {
"name": ""
}
}
],
"totalcount": 3
},
"milestone": {
"title": "uluru"
},
"comments": {
"totalcount": 9
},
"url": "https://github.com/OWNER/REPO/issues/123"
}
}
}
}

View file

@ -2,6 +2,7 @@
"data": {
"repository": {
"pullRequests": {
"totalCount": 3,
"edges": [
{
"node": {

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

@ -8,8 +8,10 @@
"node": {
"number": 10,
"title": "Blueberries are a good fruit",
"state": "OPEN",
"url": "https://github.com/cli/cli/pull/10",
"headRefName": "blueberries",
"isDraft": false,
"headRepositoryOwner": {
"login": "OWNER"
},
@ -26,8 +28,10 @@
"node": {
"number": 8,
"title": "Strawberries are not actually berries",
"state": "OPEN",
"url": "https://github.com/cli/cli/pull/8",
"headRefName": "strawberries"
"headRefName": "strawberries",
"isDraft": false
}
}
]
@ -39,16 +43,18 @@
"node": {
"number": 9,
"title": "Apples are tasty",
"state": "OPEN",
"url": "https://github.com/cli/cli/pull/9",
"headRefName": "apples"
}
},
{
"headRefName": "apples",
"isDraft": false
} }, {
"node": {
"number": 11,
"title": "Figs are my favorite",
"state": "OPEN",
"url": "https://github.com/cli/cli/pull/1",
"headRefName": "figs"
"headRefName": "figs",
"isDraft": true
}
}
]

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

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"
},
"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"
},
"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"
},
"isCrossRepository": false
}
}
]
}
},
"viewerCreated": {
"totalCount": 0,
"edges": []
},
"reviewRequested": {
"totalCount": 0,
"edges": []
}
}
}

View file

@ -0,0 +1,47 @@
{
"data": {
"repository": {
"defaultBranchRef": { "name": "master" },
"pullRequests": {
"totalCount": 1,
"edges": [
{
"node": {
"number": 8,
"title": "Blueberries are a good fruit",
"state": "CLOSED",
"url": "https://github.com/cli/cli/pull/8",
"headRefName": "blueberries",
"reviewDecision": "CHANGES_REQUESTED",
"commits": {
"nodes": [
{
"commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"state": "SUCCESS"
}
]
}
}
}
}
]
}
}
}
]
}
},
"viewerCreated": {
"totalCount": 0,
"edges": []
},
"reviewRequested": {
"totalCount": 0,
"edges": []
}
}
}

View file

@ -0,0 +1,29 @@
{
"data": {
"repository": {
"defaultBranchRef": { "name": "blueberries" },
"pullRequests": {
"totalCount": 1,
"edges": [
{
"node": {
"number": 8,
"title": "Blueberries are a good fruit",
"state": "CLOSED",
"url": "https://github.com/cli/cli/pull/8",
"headRefName": "blueberries"
}
}
]
}
},
"viewerCreated": {
"totalCount": 0,
"edges": []
},
"reviewRequested": {
"totalCount": 0,
"edges": []
}
}
}

View file

@ -0,0 +1,47 @@
{
"data": {
"repository": {
"defaultBranchRef": { "name": "master" },
"pullRequests": {
"totalCount": 1,
"edges": [
{
"node": {
"number": 8,
"title": "Blueberries are a good fruit",
"state": "MERGED",
"url": "https://github.com/cli/cli/pull/8",
"headRefName": "blueberries",
"reviewDecision": "CHANGES_REQUESTED",
"commits": {
"nodes": [
{
"commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [
{
"state": "SUCCESS"
}
]
}
}
}
}
]
}
}
}
]
}
},
"viewerCreated": {
"totalCount": 0,
"edges": []
},
"reviewRequested": {
"totalCount": 0,
"edges": []
}
}
}

View file

@ -0,0 +1,29 @@
{
"data": {
"repository": {
"defaultBranchRef": { "name": "blueberries" },
"pullRequests": {
"totalCount": 1,
"edges": [
{
"node": {
"number": 8,
"title": "Blueberries are a good fruit",
"state": "MERGED",
"url": "https://github.com/cli/cli/pull/8",
"headRefName": "blueberries"
}
}
]
}
},
"viewerCreated": {
"totalCount": 0,
"edges": []
},
"reviewRequested": {
"totalCount": 0,
"edges": []
}
}
}

33
test/fixtures/prStatusFork.json vendored Normal file
View file

@ -0,0 +1,33 @@
{
"data": {
"repository": {
"pullRequests": {
"totalCount": 1,
"edges": [
{
"node": {
"number": 10,
"title": "Blueberries are a good fruit",
"state": "OPEN",
"url": "https://github.com/PARENT/REPO/pull/10",
"headRefName": "blueberries",
"isDraft": false,
"headRepositoryOwner": {
"login": "OWNER"
},
"isCrossRepository": true
}
}
]
}
},
"viewerCreated": {
"totalCount": 0,
"edges": []
},
"reviewRequested": {
"totalCount": 0,
"edges": []
}
}
}

View file

@ -6,6 +6,7 @@
{
"number": 12,
"title": "Blueberries are from a fork",
"state": "OPEN",
"body": "yeah",
"url": "https://github.com/OWNER/REPO/pull/12",
"headRefName": "blueberries",
@ -19,11 +20,13 @@
"author": {
"login": "nobody"
},
"isCrossRepository": true
"isCrossRepository": true,
"isDraft": false
},
{
"number": 10,
"title": "Blueberries are a good fruit",
"state": "OPEN",
"body": "**blueberries taste good**",
"url": "https://github.com/OWNER/REPO/pull/10",
"baseRefName": "master",
@ -37,7 +40,8 @@
"commits": {
"totalCount": 8
},
"isCrossRepository": false
"isCrossRepository": false,
"isDraft": false
}
]
}

View file

@ -4,11 +4,27 @@
"pullRequest": {
"number": 12,
"title": "Blueberries are from a fork",
"state": "OPEN",
"body": "**blueberries taste good**",
"url": "https://github.com/OWNER/REPO/pull/12",
"author": {
"login": "nobody"
},
"assignees": {
"nodes": [],
"totalcount": 0
},
"labels": {
"nodes": [],
"totalcount": 0
},
"projectcards": {
"nodes": [],
"totalcount": 0
},
"milestone": {
"title": ""
},
"commits": {
"totalCount": 12
},
@ -17,7 +33,8 @@
"headRepositoryOwner": {
"login": "hubot"
},
"isCrossRepository": true
"isCrossRepository": true,
"isDraft": false
}
}
}

View file

@ -0,0 +1,25 @@
{
"data": {
"repository": {
"pullRequest": {
"number": 12,
"title": "Blueberries are from a fork",
"state": "CLOSED",
"body": "**blueberries taste good**",
"url": "https://github.com/OWNER/REPO/pull/12",
"author": {
"login": "nobody"
},
"commits": {
"totalCount": 12
},
"baseRefName": "master",
"headRefName": "blueberries",
"headRepositoryOwner": {
"login": "hubot"
},
"isCrossRepository": true
}
}
}
}

View file

@ -0,0 +1,26 @@
{
"data": {
"repository": {
"pullRequest": {
"number": 12,
"title": "Blueberries are from a fork",
"state": "OPEN",
"body": "**blueberries taste good**",
"url": "https://github.com/OWNER/REPO/pull/12",
"author": {
"login": "nobody"
},
"commits": {
"totalCount": 12
},
"baseRefName": "master",
"headRefName": "blueberries",
"headRepositoryOwner": {
"login": "hubot"
},
"isCrossRepository": true,
"isDraft": true
}
}
}
}

View file

@ -0,0 +1,50 @@
{
"data": {
"repository": {
"pullRequests": {
"nodes": [
{
"number": 12,
"title": "Blueberries are from a fork",
"state": "OPEN",
"body": "yeah",
"url": "https://github.com/OWNER/REPO/pull/12",
"headRefName": "blueberries",
"baseRefName": "master",
"headRepositoryOwner": {
"login": "hubot"
},
"commits": {
"totalCount": 12
},
"author": {
"login": "nobody"
},
"isCrossRepository": true,
"isDraft": false
},
{
"number": 10,
"title": "Blueberries are a good fruit",
"state": "OPEN",
"body": "**blueberries taste good**",
"url": "https://github.com/OWNER/REPO/pull/10",
"baseRefName": "master",
"headRefName": "blueberries",
"author": {
"login": "nobody"
},
"headRepositoryOwner": {
"login": "OWNER"
},
"commits": {
"totalCount": 8
},
"isCrossRepository": false,
"isDraft": true
}
]
}
}
}
}

View file

@ -0,0 +1,25 @@
{
"data": {
"repository": {
"pullRequest": {
"number": 12,
"title": "Blueberries are from a fork",
"state": "MERGED",
"body": "**blueberries taste good**",
"url": "https://github.com/OWNER/REPO/pull/12",
"author": {
"login": "nobody"
},
"commits": {
"totalCount": 12
},
"baseRefName": "master",
"headRefName": "blueberries",
"headRepositoryOwner": {
"login": "hubot"
},
"isCrossRepository": true
}
}
}
}

View file

@ -0,0 +1,126 @@
{
"data": {
"repository": {
"pullRequests": {
"nodes": [
{
"number": 12,
"title": "Blueberries are from a fork",
"state": "OPEN",
"body": "yeah",
"url": "https://github.com/OWNER/REPO/pull/12",
"headRefName": "blueberries",
"baseRefName": "master",
"headRepositoryOwner": {
"login": "hubot"
},
"assignees": {
"nodes": [],
"totalcount": 0
},
"labels": {
"nodes": [],
"totalcount": 0
},
"projectcards": {
"nodes": [],
"totalcount": 0
},
"milestone": {},
"commits": {
"totalCount": 12
},
"author": {
"login": "nobody"
},
"isCrossRepository": true,
"isDraft": false
},
{
"number": 10,
"title": "Blueberries are a good fruit",
"state": "OPEN",
"body": "**blueberries taste good**",
"url": "https://github.com/OWNER/REPO/pull/10",
"baseRefName": "master",
"headRefName": "blueberries",
"author": {
"login": "nobody"
},
"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"
},
"headRepositoryOwner": {
"login": "OWNER"
},
"commits": {
"totalCount": 8
},
"isCrossRepository": false,
"isDraft": false
}
]
}
}
}
}

Some files were not shown because too many files have changed in this diff Show more