Merge remote-tracking branch 'origin/master' into wingkwong/master
This commit is contained in:
commit
ed1a3a60fd
109 changed files with 11109 additions and 1648 deletions
33
.github/CONTRIBUTING.md
vendored
33
.github/CONTRIBUTING.md
vendored
|
|
@ -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
22
.github/workflows/codeql.yml
vendored
Normal 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
|
||||
20
.github/workflows/go.yml
vendored
20
.github/workflows/go.yml
vendored
|
|
@ -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
54
.github/workflows/lint.yml
vendored
Normal 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
|
||||
36
.github/workflows/releases.yml
vendored
36
.github/workflows/releases.yml
vendored
|
|
@ -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
3
.gitignore
vendored
|
|
@ -9,6 +9,9 @@
|
|||
# VS Code
|
||||
.vscode
|
||||
|
||||
# IntelliJ
|
||||
.idea
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
|
|
|
|||
15
Makefile
15
Makefile
|
|
@ -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
|
||||
|
|
|
|||
39
README.md
39
README.md
|
|
@ -1,4 +1,4 @@
|
|||
# gh - The GitHub CLI tool
|
||||
# GitHub CLI
|
||||
|
||||
`gh` is GitHub on the command line, and it's now available in beta. It brings pull requests, issues, and other GitHub concepts to
|
||||
the terminal next to where you are already working with `git` and your code.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
69
api/queries_issue_test.go
Normal 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
110
api/queries_org.go
Normal 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
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
274
api/queries_repo_test.go
Normal 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
|
||||
}
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func main() {
|
|||
|
||||
func filePrepender(filename string) string {
|
||||
return `---
|
||||
layout: page
|
||||
layout: manual
|
||||
permalink: /:path/:basename
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
112
command/config.go
Normal 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
191
command/config_test.go
Normal 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
246
command/credits.go
Normal 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()
|
||||
}
|
||||
414
command/issue.go
414
command/issue.go
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
590
command/pr.go
590
command/pr.go
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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(), "")
|
||||
|
||||
|
|
|
|||
|
|
@ -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, ¬Found) {
|
||||
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`")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
276
command/pr_review.go
Normal 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
403
command/pr_review_test.go
Normal 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
553
command/repo.go
553
command/repo.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
184
command/root.go
184
command/root.go
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
@ -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"`))
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
|
|
@ -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, ¬Found) {
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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
1
docs/README.md
Normal 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).
|
||||
60
docs/command-line-syntax.md
Normal file
60
docs/command-line-syntax.md
Normal 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
27
docs/gh-vs-hub.md
Normal 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 didn’t you just build `gh` on top of `hub`?
|
||||
|
||||
We wrestled with the decision of whether to continue building onto `hub` and adopt it as an official GitHub project. In weighing different possibilities, we decided to start fresh without the constraints of 10 years of design decisions that `hub` has baked in and without the assumption that `hub` can be safely aliased to `git`. We also wanted to be more opinionated and focused on GitHub workflows, and doing this with `hub` had the risk of alienating many `hub` users who love the existing tool and expected it to work in the way they were used to.
|
||||
|
||||
## What’s next for `hub`?
|
||||
|
||||
The GitHub CLI team is focused solely on building out the new tool, `gh`. We aren’t shutting down `hub` or doing anything to change it. It’s an open source project and will continue to exist as long as it’s maintained and keeps receiving contributions.
|
||||
|
||||
## What does it mean that GitHub CLI is official and `hub` is unofficial?
|
||||
|
||||
GitHub CLI is built and maintained by a team of people who work on the tool on behalf of GitHub. When there’s something wrong with it, people can reach out to GitHub support or create an issue in the issue tracker, where an employee at GitHub will respond.
|
||||
|
||||
`hub` is a project whose maintainer also happens to be a GitHub employee. He chooses to maintain `hub` in his spare time, as many of our employees do with open source projects.
|
||||
|
||||
## Should I use `gh` or `hub`?
|
||||
|
||||
We have no interest in forcing anyone to use GitHub CLI instead of `hub`. We think people should use whatever set of tools makes them happiest and most productive working with GitHub.
|
||||
|
||||
If you are set on using a tool that acts as a wrapper for Git itself, `hub` is likely a better choice than `gh`. `hub` currently covers a larger overall surface area of GitHub’s API v3, provides more scripting functionality, and is compatible with GitHub Enterprise (though these are all things that we intend to improve in GitHub CLI).
|
||||
|
||||
If you want a tool that’s more opinionated and intended to help simplify your GitHub workflows from the command line, we hope you’ll use `gh`. And since `gh` is maintained by a team at GitHub, we intend to be responsive to people’s concerns and needs and improve the tool based on how people are using it over time.
|
||||
|
||||
GitHub CLI is not intended to be an exact replacement for `hub` and likely never will be, but our hope is that the vast majority of GitHub users who use the CLI will find more and more value in using `gh` as we continue to improve it.
|
||||
17
docs/releasing.md
Normal file
17
docs/releasing.md
Normal 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/`
|
||||
|
|
@ -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`
|
||||
128
git/git.go
128
git/git.go
|
|
@ -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
|
||||
|
||||
}
|
||||
|
|
|
|||
119
git/git_test.go
119
git/git_test.go
|
|
@ -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))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
18
go.mod
|
|
@ -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
65
go.sum
|
|
@ -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=
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
181
internal/config/config_file.go
Normal file
181
internal/config/config_file.go
Normal 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
|
||||
}
|
||||
96
internal/config/config_file_test.go
Normal file
96
internal/config/config_file_test.go
Normal 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)
|
||||
}
|
||||
133
internal/config/config_setup.go
Normal file
133
internal/config/config_setup.go
Normal 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()
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
package context
|
||||
package config
|
||||
|
||||
const oauthSuccessPage = `
|
||||
<!doctype html>
|
||||
218
internal/config/config_type.go
Normal file
218
internal/config/config_type.go
Normal 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, ¬Found) {
|
||||
keyNode := &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: key,
|
||||
}
|
||||
valueNode = &yaml.Node{
|
||||
Kind: yaml.ScalarNode,
|
||||
Value: "",
|
||||
}
|
||||
|
||||
cm.Root.Content = append(cm.Root.Content, keyNode, valueNode)
|
||||
} else if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
valueNode.Value = value
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (cm *ConfigMap) FindEntry(key string) (keyNode, valueNode *yaml.Node, err error) {
|
||||
err = nil
|
||||
|
||||
topLevelKeys := cm.Root.Content
|
||||
for i, v := range topLevelKeys {
|
||||
if v.Value == key && i+1 < len(topLevelKeys) {
|
||||
keyNode = v
|
||||
valueNode = topLevelKeys[i+1]
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
return nil, nil, &NotFoundError{errors.New("not found")}
|
||||
}
|
||||
|
||||
func NewConfig(root *yaml.Node) Config {
|
||||
return &fileConfig{
|
||||
ConfigMap: ConfigMap{Root: root.Content[0]},
|
||||
documentRoot: root,
|
||||
}
|
||||
}
|
||||
|
||||
// This type implements a Config interface and represents a config file on disk.
|
||||
type fileConfig struct {
|
||||
ConfigMap
|
||||
documentRoot *yaml.Node
|
||||
hosts []*HostConfig
|
||||
}
|
||||
|
||||
func (c *fileConfig) Get(hostname, key string) (string, error) {
|
||||
if hostname != "" {
|
||||
hostCfg, err := c.configForHost(hostname)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
hostValue, err := hostCfg.GetStringValue(key)
|
||||
var notFound *NotFoundError
|
||||
|
||||
if err != nil && !errors.As(err, ¬Found) {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if hostValue != "" {
|
||||
return hostValue, nil
|
||||
}
|
||||
}
|
||||
|
||||
value, err := c.GetStringValue(key)
|
||||
|
||||
var notFound *NotFoundError
|
||||
|
||||
if err != nil && errors.As(err, ¬Found) {
|
||||
return defaultFor(key), nil
|
||||
} else if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if value == "" {
|
||||
return defaultFor(key), nil
|
||||
}
|
||||
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (c *fileConfig) Set(hostname, key, value string) error {
|
||||
if hostname == "" {
|
||||
return c.SetStringValue(key, value)
|
||||
} else {
|
||||
hostCfg, err := c.configForHost(hostname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return hostCfg.SetStringValue(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *fileConfig) configForHost(hostname string) (*HostConfig, error) {
|
||||
hosts, err := c.Hosts()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse hosts config: %w", err)
|
||||
}
|
||||
|
||||
for _, hc := range hosts {
|
||||
if hc.Host == hostname {
|
||||
return hc, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("could not find config entry for %q", hostname)
|
||||
}
|
||||
|
||||
func (c *fileConfig) Write() error {
|
||||
marshalled, err := yaml.Marshal(c.documentRoot)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return WriteConfigFile(ConfigFile(), marshalled)
|
||||
}
|
||||
|
||||
func (c *fileConfig) Hosts() ([]*HostConfig, error) {
|
||||
if len(c.hosts) > 0 {
|
||||
return c.hosts, nil
|
||||
}
|
||||
|
||||
_, hostsEntry, err := c.FindEntry("hosts")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not find hosts config: %w", err)
|
||||
}
|
||||
|
||||
hostConfigs, err := c.parseHosts(hostsEntry)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not parse hosts config: %w", err)
|
||||
}
|
||||
|
||||
c.hosts = hostConfigs
|
||||
|
||||
return hostConfigs, nil
|
||||
}
|
||||
|
||||
func (c *fileConfig) parseHosts(hostsEntry *yaml.Node) ([]*HostConfig, error) {
|
||||
hostConfigs := []*HostConfig{}
|
||||
|
||||
for i := 0; i < len(hostsEntry.Content)-1; i = i + 2 {
|
||||
hostname := hostsEntry.Content[i].Value
|
||||
hostRoot := hostsEntry.Content[i+1]
|
||||
hostConfig := HostConfig{
|
||||
ConfigMap: ConfigMap{Root: hostRoot},
|
||||
Host: hostname,
|
||||
}
|
||||
hostConfigs = append(hostConfigs, &hostConfig)
|
||||
}
|
||||
|
||||
if len(hostConfigs) == 0 {
|
||||
return nil, errors.New("could not find any host configurations")
|
||||
}
|
||||
|
||||
return hostConfigs, nil
|
||||
}
|
||||
|
||||
func defaultFor(key string) string {
|
||||
// we only have a set default for one setting right now
|
||||
switch key {
|
||||
case "git_protocol":
|
||||
return defaultGitProtocol
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
37
internal/config/testing.go
Normal file
37
internal/config/testing.go
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
package utils
|
||||
package run
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
89
pkg/httpmock/legacy.go
Normal 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
70
pkg/httpmock/registry.go
Normal 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
112
pkg/httpmock/stub.go
Normal 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),
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ func Truncate(max int, s string) string {
|
|||
useEllipsis := false
|
||||
if max >= minWidthForEllipsis {
|
||||
useEllipsis = true
|
||||
max -= 3
|
||||
max -= ellipsisWidth
|
||||
}
|
||||
|
||||
cw := 0
|
||||
|
|
|
|||
27
releasing.md
27
releasing.md
|
|
@ -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
9
test/fixtures/forkResult.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
1
test/fixtures/issueList.json
vendored
1
test/fixtures/issueList.json
vendored
|
|
@ -3,6 +3,7 @@
|
|||
"repository": {
|
||||
"hasIssuesEnabled": true,
|
||||
"issues": {
|
||||
"totalCount": 3,
|
||||
"nodes": [
|
||||
{
|
||||
"number": 1,
|
||||
|
|
|
|||
36
test/fixtures/issueView_preview.json
vendored
Normal file
36
test/fixtures/issueView_preview.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
test/fixtures/issueView_previewClosedState.json
vendored
Normal file
28
test/fixtures/issueView_previewClosedState.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
28
test/fixtures/issueView_previewWithEmptyBody.json
vendored
Normal file
28
test/fixtures/issueView_previewWithEmptyBody.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
92
test/fixtures/issueView_previewWithMetadata.json
vendored
Normal file
92
test/fixtures/issueView_previewWithMetadata.json
vendored
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1
test/fixtures/prList.json
vendored
1
test/fixtures/prList.json
vendored
|
|
@ -2,6 +2,7 @@
|
|||
"data": {
|
||||
"repository": {
|
||||
"pullRequests": {
|
||||
"totalCount": 3,
|
||||
"edges": [
|
||||
{
|
||||
"node": {
|
||||
|
|
|
|||
50
test/fixtures/prListWithDuplicates.json
vendored
Normal file
50
test/fixtures/prListWithDuplicates.json
vendored
Normal 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": ""
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
18
test/fixtures/prStatus.json
vendored
18
test/fixtures/prStatus.json
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
]
|
||||
|
|
|
|||
3
test/fixtures/prStatusChecks.json
vendored
3
test/fixtures/prStatusChecks.json
vendored
|
|
@ -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",
|
||||
|
|
|
|||
61
test/fixtures/prStatusCurrentBranch.json
vendored
Normal file
61
test/fixtures/prStatusCurrentBranch.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
47
test/fixtures/prStatusCurrentBranchClosed.json
vendored
Normal file
47
test/fixtures/prStatusCurrentBranchClosed.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
29
test/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json
vendored
Normal file
29
test/fixtures/prStatusCurrentBranchClosedOnDefaultBranch.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
47
test/fixtures/prStatusCurrentBranchMerged.json
vendored
Normal file
47
test/fixtures/prStatusCurrentBranchMerged.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
29
test/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json
vendored
Normal file
29
test/fixtures/prStatusCurrentBranchMergedOnDefaultBranch.json
vendored
Normal 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
33
test/fixtures/prStatusFork.json
vendored
Normal 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": []
|
||||
}
|
||||
}
|
||||
}
|
||||
8
test/fixtures/prView.json
vendored
8
test/fixtures/prView.json
vendored
|
|
@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
|
|||
19
test/fixtures/prViewPreview.json
vendored
19
test/fixtures/prViewPreview.json
vendored
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
25
test/fixtures/prViewPreviewClosedState.json
vendored
Normal file
25
test/fixtures/prViewPreviewClosedState.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
test/fixtures/prViewPreviewDraftState.json
vendored
Normal file
26
test/fixtures/prViewPreviewDraftState.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
50
test/fixtures/prViewPreviewDraftStatebyBranch.json
vendored
Normal file
50
test/fixtures/prViewPreviewDraftStatebyBranch.json
vendored
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
25
test/fixtures/prViewPreviewMergedState.json
vendored
Normal file
25
test/fixtures/prViewPreviewMergedState.json
vendored
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
126
test/fixtures/prViewPreviewWithMetadataByBranch.json
vendored
Normal file
126
test/fixtures/prViewPreviewWithMetadataByBranch.json
vendored
Normal 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
Loading…
Add table
Add a link
Reference in a new issue