Merge branch 'trunk' of https://github.com/cli/cli into feature/action-headers

This commit is contained in:
Gowtham Munukutla 2021-05-20 10:59:14 +05:30
commit 6b0a07f22e
99 changed files with 3279 additions and 3276 deletions

View file

@ -36,6 +36,8 @@ Run the new binary as:
Run tests with: `go test ./...`
See [project layout documentation](../project-layout.md) for information on where to find specific source files.
## Submitting a pull request
1. Create a new branch: `git checkout -b my-branch-name`

4
.github/PULL_REQUEST_TEMPLATE.md vendored Normal file
View file

@ -0,0 +1,4 @@
<!--
Thank you for contributing to GitHub CLI!
To reference an open issue, please write this in your description: `Fixes #NUMBER`
-->

View file

@ -1,19 +0,0 @@
---
name: "\U0001F41B Bug fix"
about: Fix a bug in GitHub CLI
---
<!--
Please make sure you read our contributing guidelines at
https://github.com/cli/cli/blob/trunk/.github/CONTRIBUTING.md
before opening a pull request. Thanks!
-->
## Summary
closes #[issue number]
## Details
-

View file

@ -80,7 +80,7 @@ jobs:
popd
- name: Run reprepro
env:
RELEASES: "cosmic eoan disco groovy focal stable oldstable testing unstable buster bullseye stretch jessie bionic trusty precise xenial"
RELEASES: "cosmic eoan disco groovy focal stable oldstable testing unstable buster bullseye stretch jessie bionic trusty precise xenial hirsute impish kali-rolling"
run: |
mkdir -p upload
for release in $RELEASES; do
@ -133,9 +133,11 @@ jobs:
- name: Build MSI
id: buildmsi
shell: bash
env:
ZIP_FILE: ${{ steps.download_exe.outputs.zip }}
run: |
mkdir -p build
msi="$(basename "${{ steps.download_exe.outputs.zip }}" ".zip").msi"
msi="$(basename "$ZIP_FILE" ".zip").msi"
printf "::set-output name=msi::%s\n" "$msi"
go-msi make --msi "$PWD/$msi" --out "$PWD/build" --version "${GITHUB_REF#refs/tags/}"
- name: Obtain signing cert
@ -145,14 +147,24 @@ jobs:
run: .\script\setup-windows-certificate.ps1
- name: Sign MSI
env:
CERT_FILE: ${{ steps.obtain_cert.outputs.cert-file }}
EXE_FILE: ${{ steps.buildmsi.outputs.msi }}
GITHUB_CERT_PASSWORD: ${{ secrets.GITHUB_CERT_PASSWORD }}
run: |
.\script\sign.ps1 -Certificate "${{ steps.obtain_cert.outputs.cert-file }}" `
-Executable "${{ steps.buildmsi.outputs.msi }}"
run: .\script\sign.ps1 -Certificate $env:CERT_FILE -Executable $env:EXE_FILE
- name: Upload MSI
shell: bash
run: hub release edit "${GITHUB_REF#refs/tags/}" -m "" --draft=false -a "${{ steps.buildmsi.outputs.msi }}"
run: |
tag_name="${GITHUB_REF#refs/tags/}"
hub release edit "$tag_name" -m "" -a "$MSI_FILE"
release_url="$(gh api repos/:owner/:repo/releases -q ".[]|select(.tag_name==\"${tag_name}\")|.url")"
publish_args=( -F draft=false )
if [[ $GITHUB_REF != *-* ]]; then
publish_args+=( -f discussion_category_name="$DISCUSSION_CATEGORY" )
fi
gh api -X PATCH "$release_url" "${publish_args[@]}"
env:
MSI_FILE: ${{ steps.buildmsi.outputs.msi }}
DISCUSSION_CATEGORY: General
GITHUB_TOKEN: ${{secrets.GITHUB_TOKEN}}
- name: Bump homebrew-core formula
uses: mislav/bump-homebrew-formula-action@v1

View file

@ -6,16 +6,11 @@ import (
)
func (issue *Issue) ExportData(fields []string) *map[string]interface{} {
v := reflect.ValueOf(issue).Elem()
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "milestone":
if issue.Milestone.Title != "" {
data[f] = &issue.Milestone
} else {
data[f] = nil
}
case "comments":
data[f] = issue.Comments.Nodes
case "assignees":
@ -25,7 +20,6 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} {
case "projectCards":
data[f] = issue.ProjectCards.Nodes
default:
v := reflect.ValueOf(issue).Elem()
sf := fieldByName(v, f)
data[f] = sf.Interface()
}
@ -35,24 +29,42 @@ func (issue *Issue) ExportData(fields []string) *map[string]interface{} {
}
func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
v := reflect.ValueOf(pr).Elem()
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "headRepository":
data[f] = map[string]string{"name": pr.HeadRepository.Name}
case "milestone":
if pr.Milestone.Title != "" {
data[f] = &pr.Milestone
} else {
data[f] = nil
}
data[f] = pr.HeadRepository
case "statusCheckRollup":
if n := pr.Commits.Nodes; len(n) > 0 {
if n := pr.StatusCheckRollup.Nodes; len(n) > 0 {
data[f] = n[0].Commit.StatusCheckRollup.Contexts.Nodes
} else {
data[f] = nil
}
case "commits":
commits := make([]interface{}, 0, len(pr.Commits.Nodes))
for _, c := range pr.Commits.Nodes {
commit := c.Commit
authors := make([]interface{}, 0, len(commit.Authors.Nodes))
for _, author := range commit.Authors.Nodes {
authors = append(authors, map[string]interface{}{
"name": author.Name,
"email": author.Email,
"id": author.User.ID,
"login": author.User.Login,
})
}
commits = append(commits, map[string]interface{}{
"oid": commit.OID,
"messageHeadline": commit.MessageHeadline,
"messageBody": commit.MessageBody,
"committedDate": commit.CommittedDate,
"authoredDate": commit.AuthoredDate,
"authors": authors,
})
}
data[f] = commits
case "comments":
data[f] = pr.Comments.Nodes
case "assignees":
@ -75,7 +87,6 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
}
data[f] = &requests
default:
v := reflect.ValueOf(pr).Elem()
sf := fieldByName(v, f)
data[f] = sf.Interface()
}
@ -84,22 +95,6 @@ func (pr *PullRequest) ExportData(fields []string) *map[string]interface{} {
return &data
}
func ExportIssues(issues []Issue, fields []string) *[]interface{} {
data := make([]interface{}, len(issues))
for i := range issues {
data[i] = issues[i].ExportData(fields)
}
return &data
}
func ExportPRs(prs []PullRequest, fields []string) *[]interface{} {
data := make([]interface{}, len(prs))
for i := range prs {
data[i] = prs[i].ExportData(fields)
}
return &data
}
func fieldByName(v reflect.Value, field string) reflect.Value {
return v.FieldByNameFunc(func(s string) bool {
return strings.EqualFold(field, s)

View file

@ -40,7 +40,10 @@ func TestIssue_ExportData(t *testing.T) {
outputJSON: heredoc.Doc(`
{
"milestone": {
"title": "The next big thing"
"number": 0,
"title": "The next big thing",
"description": "",
"dueOn": null
},
"number": 2345
}
@ -90,31 +93,6 @@ func TestIssue_ExportData(t *testing.T) {
}
}
func TestExportIssues(t *testing.T) {
issues := []Issue{
{Milestone: Milestone{Title: "hi"}},
{},
}
exported := ExportIssues(issues, []string{"milestone"})
buf := bytes.Buffer{}
enc := json.NewEncoder(&buf)
enc.SetIndent("", "\t")
require.NoError(t, enc.Encode(exported))
assert.Equal(t, heredoc.Doc(`
[
{
"milestone": {
"title": "hi"
}
},
{
"milestone": null
}
]
`), buf.String())
}
func TestPullRequest_ExportData(t *testing.T) {
tests := []struct {
name string
@ -144,7 +122,10 @@ func TestPullRequest_ExportData(t *testing.T) {
outputJSON: heredoc.Doc(`
{
"milestone": {
"title": "The next big thing"
"number": 0,
"title": "The next big thing",
"description": "",
"dueOn": null
},
"number": 2345
}
@ -154,7 +135,7 @@ func TestPullRequest_ExportData(t *testing.T) {
name: "status checks",
fields: []string{"statusCheckRollup"},
inputJSON: heredoc.Doc(`
{ "commits": { "nodes": [
{ "statusCheckRollup": { "nodes": [
{ "commit": { "statusCheckRollup": { "contexts": { "nodes": [
{
"__typename": "CheckRun",

53
api/export_repo.go Normal file
View file

@ -0,0 +1,53 @@
package api
import (
"reflect"
)
func (repo *Repository) ExportData(fields []string) *map[string]interface{} {
v := reflect.ValueOf(repo).Elem()
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "parent":
data[f] = miniRepoExport(repo.Parent)
case "templateRepository":
data[f] = miniRepoExport(repo.TemplateRepository)
case "languages":
data[f] = repo.Languages.Edges
case "labels":
data[f] = repo.Labels.Nodes
case "assignableUsers":
data[f] = repo.AssignableUsers.Nodes
case "mentionableUsers":
data[f] = repo.MentionableUsers.Nodes
case "milestones":
data[f] = repo.Milestones.Nodes
case "projects":
data[f] = repo.Projects.Nodes
case "repositoryTopics":
var topics []RepositoryTopic
for _, n := range repo.RepositoryTopics.Nodes {
topics = append(topics, n.Topic)
}
data[f] = topics
default:
sf := fieldByName(v, f)
data[f] = sf.Interface()
}
}
return &data
}
func miniRepoExport(r *Repository) map[string]interface{} {
if r == nil {
return nil
}
return map[string]interface{}{
"id": r.ID,
"name": r.Name,
"owner": r.Owner,
}
}

View file

@ -10,7 +10,7 @@ import (
func TestPullRequest_ChecksStatus(t *testing.T) {
pr := PullRequest{}
payload := `
{ "commits": { "nodes": [{ "commit": {
{ "statusCheckRollup": { "nodes": [{ "commit": {
"statusCheckRollup": {
"contexts": {
"nodes": [

View file

@ -4,7 +4,6 @@ import (
"context"
"time"
"github.com/cli/cli/internal/ghrepo"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
@ -12,7 +11,10 @@ import (
type Comments struct {
Nodes []Comment
TotalCount int
PageInfo PageInfo
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
type Comment struct {
@ -26,83 +28,6 @@ type Comment struct {
ReactionGroups ReactionGroups `json:"reactionGroups"`
}
type PageInfo struct {
HasNextPage bool
EndCursor string
}
func CommentsForIssue(client *Client, repo ghrepo.Interface, issue *Issue) (*Comments, error) {
type response struct {
Repository struct {
Issue struct {
Comments Comments `graphql:"comments(first: 100, after: $endCursor)"`
} `graphql:"issue(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"repo": githubv4.String(repo.RepoName()),
"number": githubv4.Int(issue.Number),
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var comments []Comment
for {
var query response
err := gql.QueryNamed(context.Background(), "CommentsForIssue", &query, variables)
if err != nil {
return nil, err
}
comments = append(comments, query.Repository.Issue.Comments.Nodes...)
if !query.Repository.Issue.Comments.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.Issue.Comments.PageInfo.EndCursor)
}
return &Comments{Nodes: comments, TotalCount: len(comments)}, nil
}
func CommentsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) (*Comments, error) {
type response struct {
Repository struct {
PullRequest struct {
Comments Comments `graphql:"comments(first: 100, after: $endCursor)"`
} `graphql:"pullRequest(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"repo": githubv4.String(repo.RepoName()),
"number": githubv4.Int(pr.Number),
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var comments []Comment
for {
var query response
err := gql.QueryNamed(context.Background(), "CommentsForPullRequest", &query, variables)
if err != nil {
return nil, err
}
comments = append(comments, query.Repository.PullRequest.Comments.Nodes...)
if !query.Repository.PullRequest.Comments.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.PullRequest.Comments.PageInfo.EndCursor)
}
return &Comments{Nodes: comments, TotalCount: len(comments)}, nil
}
type CommentCreateInput struct {
Body string
SubjectId string
@ -135,24 +60,6 @@ func CommentCreate(client *Client, repoHost string, params CommentCreateInput) (
return mutation.AddComment.CommentEdge.Node.URL, nil
}
func commentsFragment() string {
return `comments(last: 1) {
nodes {
author {
login
}
authorAssociation
body
createdAt
includesCreatedEdit
isMinimized
minimizedReason
` + reactionGroupsFragment() + `
}
totalCount
}`
}
func (c Comment) AuthorLogin() string {
return c.Author.Login
}

View file

@ -36,14 +36,12 @@ type Issue struct {
Assignees Assignees
Labels Labels
ProjectCards ProjectCards
Milestone Milestone
Milestone *Milestone
ReactionGroups ReactionGroups
}
type Assignees struct {
Nodes []struct {
Login string `json:"login"`
}
Nodes []GitHubUser
TotalCount int
}
@ -56,9 +54,7 @@ func (a Assignees) Logins() []string {
}
type Labels struct {
Nodes []struct {
Name string `json:"name"`
}
Nodes []IssueLabel
TotalCount int
}
@ -91,14 +87,26 @@ func (p ProjectCards) ProjectNames() []string {
}
type Milestone struct {
Title string `json:"title"`
Number int `json:"number"`
Title string `json:"title"`
Description string `json:"description"`
DueOn *time.Time `json:"dueOn"`
}
type IssuesDisabledError struct {
error
}
type Owner struct {
ID string `json:"id,omitempty"`
Name string `json:"name,omitempty"`
Login string `json:"login"`
}
type Author struct {
// adding these breaks generated GraphQL requests
//ID string `json:"id,omitempty"`
//Name string `json:"name,omitempty"`
Login string `json:"login"`
}
@ -266,13 +274,18 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
createdAt
assignees(first: 100) {
nodes {
id
name
login
}
totalCount
}
labels(first: 100) {
nodes {
id
name
description
color
}
totalCount
}
@ -288,7 +301,10 @@ func IssueByNumber(client *Client, repo ghrepo.Interface, number int) (*Issue, e
totalCount
}
milestone {
number
title
description
dueOn
}
reactionGroups {
content

View file

@ -6,7 +6,6 @@ import (
"fmt"
"io"
"net/http"
"sort"
"strings"
"time"
@ -34,6 +33,7 @@ type PullRequest struct {
Number int
Title string
State string
Closed bool
URL string
BaseRefName string
HeadRefName string
@ -57,12 +57,8 @@ type PullRequest struct {
Author Author
MergedBy *Author
HeadRepositoryOwner struct {
Login string `json:"login"`
}
HeadRepository struct {
Name string
}
HeadRepositoryOwner Owner
HeadRepository *PRRepository
IsCrossRepository bool
IsDraft bool
MaintainerCanModify bool
@ -77,9 +73,11 @@ type PullRequest struct {
Commits struct {
TotalCount int
Nodes []struct {
Nodes []PullRequestCommit
}
StatusCheckRollup struct {
Nodes []struct {
Commit struct {
Oid string
StatusCheckRollup struct {
Contexts struct {
Nodes []struct {
@ -94,25 +92,56 @@ type PullRequest struct {
DetailsURL string `json:"detailsUrl"`
TargetURL string `json:"targetUrl,omitempty"`
}
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
}
}
}
}
Assignees Assignees
Labels Labels
ProjectCards ProjectCards
Milestone Milestone
Milestone *Milestone
Comments Comments
ReactionGroups ReactionGroups
Reviews PullRequestReviews
ReviewRequests ReviewRequests
}
type PRRepository struct {
ID string `json:"id"`
Name string `json:"name"`
}
// Commit loads just the commit SHA and nothing else
type Commit struct {
OID string `json:"oid"`
}
type PullRequestCommit struct {
Commit PullRequestCommitCommit
}
// PullRequestCommitCommit contains full information about a commit
type PullRequestCommitCommit struct {
OID string `json:"oid"`
Authors struct {
Nodes []struct {
Name string
Email string
User GitHubUser
}
}
MessageHeadline string
MessageBody string
CommittedDate time.Time
AuthoredDate time.Time
}
type PullRequestFile struct {
Path string `json:"path"`
Additions int `json:"additions"`
@ -127,7 +156,6 @@ type ReviewRequests struct {
Name string `json:"name"`
}
}
TotalCount int
}
func (r ReviewRequests) Logins() []string {
@ -138,14 +166,6 @@ func (r ReviewRequests) Logins() []string {
return logins
}
type NotFoundError struct {
error
}
func (err *NotFoundError) Unwrap() error {
return err.error
}
func (pr PullRequest) HeadLabel() string {
if pr.IsCrossRepository {
return fmt.Sprintf("%s:%s", pr.HeadRepositoryOwner.Login, pr.HeadRefName)
@ -192,10 +212,10 @@ type PullRequestChecksStatus struct {
}
func (pr *PullRequest) ChecksStatus() (summary PullRequestChecksStatus) {
if len(pr.Commits.Nodes) == 0 {
if len(pr.StatusCheckRollup.Nodes) == 0 {
return
}
commit := pr.Commits.Nodes[0].Commit
commit := pr.StatusCheckRollup.Nodes[0].Commit
for _, c := range commit.StatusCheckRollup.Contexts.Nodes {
state := c.State // StatusContext
if state == "" {
@ -247,7 +267,7 @@ func (c Client) PullRequestDiff(baseRepo ghrepo.Interface, prNumber int) (io.Rea
}
if resp.StatusCode == 404 {
return nil, &NotFoundError{errors.New("pull request not found")}
return nil, errors.New("pull request not found")
} else if resp.StatusCode != 200 {
return nil, HandleHTTPError(resp)
}
@ -560,274 +580,6 @@ func pullRequestFragment(httpClient *http.Client, hostname string) (string, erro
return fragments, nil
}
func prCommitsFragment(httpClient *http.Client, hostname string) (string, error) {
cachedClient := NewCachedClient(httpClient, time.Hour*24)
if prFeatures, err := determinePullRequestFeatures(cachedClient, hostname); err != nil {
return "", err
} else if !prFeatures.HasStatusCheckRollup {
return "", nil
}
return `
commits(last: 1) {
totalCount
nodes {
commit {
oid
statusCheckRollup {
contexts(last: 100) {
nodes {
...on StatusContext {
context
state
targetUrl
}
...on CheckRun {
name
status
conclusion
startedAt
completedAt
detailsUrl
}
}
}
}
}
}
}
`, nil
}
func PullRequestByNumber(client *Client, repo ghrepo.Interface, number int) (*PullRequest, error) {
type response struct {
Repository struct {
PullRequest PullRequest
}
}
statusesFragment, err := prCommitsFragment(client.http, repo.RepoHost())
if err != nil {
return nil, err
}
query := `
query PullRequestByNumber($owner: String!, $repo: String!, $pr_number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr_number) {
id
url
number
title
state
closed
body
mergeable
additions
deletions
author {
login
}
` + statusesFragment + `
baseRefName
headRefName
headRepositoryOwner {
login
}
headRepository {
name
}
isCrossRepository
isDraft
maintainerCanModify
reviewRequests(first: 100) {
nodes {
requestedReviewer {
__typename
...on User {
login
}
...on Team {
name
}
}
}
totalCount
}
assignees(first: 100) {
nodes {
login
}
totalCount
}
labels(first: 100) {
nodes {
name
}
totalCount
}
projectCards(first: 100) {
nodes {
project {
name
}
column {
name
}
}
totalCount
}
milestone{
title
}
` + commentsFragment() + `
` + reactionGroupsFragment() + `
}
}
}`
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"pr_number": number,
}
var resp response
err = client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return nil, err
}
return &resp.Repository.PullRequest, nil
}
func PullRequestForBranch(client *Client, repo ghrepo.Interface, baseBranch, headBranch string, stateFilters []string) (*PullRequest, error) {
type response struct {
Repository struct {
PullRequests struct {
Nodes []PullRequest
}
}
}
statusesFragment, err := prCommitsFragment(client.http, repo.RepoHost())
if err != nil {
return nil, err
}
query := `
query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!, $states: [PullRequestState!]) {
repository(owner: $owner, name: $repo) {
pullRequests(headRefName: $headRefName, states: $states, first: 30, orderBy: { field: CREATED_AT, direction: DESC }) {
nodes {
id
number
title
state
body
mergeable
additions
deletions
author {
login
}
` + statusesFragment + `
url
baseRefName
headRefName
headRepositoryOwner {
login
}
headRepository {
name
}
isCrossRepository
isDraft
maintainerCanModify
reviewRequests(first: 100) {
nodes {
requestedReviewer {
__typename
...on User {
login
}
...on Team {
name
}
}
}
totalCount
}
assignees(first: 100) {
nodes {
login
}
totalCount
}
labels(first: 100) {
nodes {
name
}
totalCount
}
projectCards(first: 100) {
nodes {
project {
name
}
column {
name
}
}
totalCount
}
milestone{
title
}
` + commentsFragment() + `
` + reactionGroupsFragment() + `
}
}
}
}`
branchWithoutOwner := headBranch
if idx := strings.Index(headBranch, ":"); idx >= 0 {
branchWithoutOwner = headBranch[idx+1:]
}
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"headRefName": branchWithoutOwner,
"states": stateFilters,
}
var resp response
err = client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return nil, err
}
prs := resp.Repository.PullRequests.Nodes
sortPullRequestsByState(prs)
for _, pr := range prs {
if pr.HeadLabel() == headBranch && (baseBranch == "" || pr.BaseRefName == baseBranch) {
return &pr, nil
}
}
return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranch)}
}
// sortPullRequestsByState sorts a PullRequest slice by open-first
func sortPullRequestsByState(prs []PullRequest) {
sort.SliceStable(prs, func(a, b int) bool {
return prs[a].State == "OPEN"
})
}
// CreatePullRequest creates a pull request in a GitHub repository
func CreatePullRequest(client *Client, repo *Repository, params map[string]interface{}) (*PullRequest, error) {
query := `

View file

@ -22,8 +22,11 @@ type PullRequestReviewInput struct {
}
type PullRequestReviews struct {
Nodes []PullRequestReview
PageInfo PageInfo
Nodes []PullRequestReview
PageInfo struct {
HasNextPage bool
EndCursor string
}
TotalCount int
}
@ -66,42 +69,6 @@ func AddReview(client *Client, repo ghrepo.Interface, pr *PullRequest, input *Pu
return gql.MutateNamed(context.Background(), "PullRequestReviewAdd", &mutation, variables)
}
func ReviewsForPullRequest(client *Client, repo ghrepo.Interface, pr *PullRequest) (*PullRequestReviews, error) {
type response struct {
Repository struct {
PullRequest struct {
Reviews PullRequestReviews `graphql:"reviews(first: 100, after: $endCursor)"`
} `graphql:"pullRequest(number: $number)"`
} `graphql:"repository(owner: $owner, name: $repo)"`
}
variables := map[string]interface{}{
"owner": githubv4.String(repo.RepoOwner()),
"repo": githubv4.String(repo.RepoName()),
"number": githubv4.Int(pr.Number),
"endCursor": (*githubv4.String)(nil),
}
gql := graphQLClient(client.http, repo.RepoHost())
var reviews []PullRequestReview
for {
var query response
err := gql.QueryNamed(context.Background(), "ReviewsForPullRequest", &query, variables)
if err != nil {
return nil, err
}
reviews = append(reviews, query.Repository.PullRequest.Reviews.Nodes...)
if !query.Repository.PullRequest.Reviews.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Repository.PullRequest.Reviews.PageInfo.EndCursor)
}
return &PullRequestReviews{Nodes: reviews, TotalCount: len(reviews)}, nil
}
func (prr PullRequestReview) AuthorLogin() string {
return prr.Author.Login
}

View file

@ -158,32 +158,3 @@ func Test_determinePullRequestFeatures(t *testing.T) {
})
}
}
func Test_sortPullRequestsByState(t *testing.T) {
prs := []PullRequest{
{
BaseRefName: "test1",
State: "MERGED",
},
{
BaseRefName: "test2",
State: "CLOSED",
},
{
BaseRefName: "test3",
State: "OPEN",
},
}
sortPullRequestsByState(prs)
if prs[0].BaseRefName != "test3" {
t.Errorf("prs[0]: got %s, want %q", prs[0].BaseRefName, "test3")
}
if prs[1].BaseRefName != "test1" {
t.Errorf("prs[1]: got %s, want %q", prs[1].BaseRefName, "test1")
}
if prs[2].BaseRefName != "test2" {
t.Errorf("prs[2]: got %s, want %q", prs[2].BaseRefName, "test2")
}
}

View file

@ -16,25 +16,100 @@ import (
// Repository contains information about a GitHub repo
type Repository struct {
ID string
Name string
Description string
URL string
CloneURL string
CreatedAt time.Time
Owner RepositoryOwner
ID string
Name string
NameWithOwner string
Owner RepositoryOwner
Parent *Repository
TemplateRepository *Repository
Description string
HomepageURL string
OpenGraphImageURL string
UsesCustomOpenGraphImage bool
URL string
SSHURL string
MirrorURL string
SecurityPolicyURL string
IsPrivate bool
HasIssuesEnabled bool
HasWikiEnabled bool
ViewerPermission string
DefaultBranchRef BranchRef
CreatedAt time.Time
PushedAt *time.Time
UpdatedAt time.Time
Parent *Repository
IsBlankIssuesEnabled bool
IsSecurityPolicyEnabled bool
HasIssuesEnabled bool
HasProjectsEnabled bool
HasWikiEnabled bool
MergeCommitAllowed bool
SquashMergeAllowed bool
RebaseMergeAllowed bool
MergeCommitAllowed bool
RebaseMergeAllowed bool
SquashMergeAllowed bool
ForkCount int
StargazerCount int
Watchers struct {
TotalCount int `json:"totalCount"`
}
Issues struct {
TotalCount int `json:"totalCount"`
}
PullRequests struct {
TotalCount int `json:"totalCount"`
}
CodeOfConduct *CodeOfConduct
ContactLinks []ContactLink
DefaultBranchRef BranchRef
DeleteBranchOnMerge bool
DiskUsage int
FundingLinks []FundingLink
IsArchived bool
IsEmpty bool
IsFork bool
IsInOrganization bool
IsMirror bool
IsPrivate bool
IsTemplate bool
IsUserConfigurationRepository bool
LicenseInfo *RepositoryLicense
ViewerCanAdminister bool
ViewerDefaultCommitEmail string
ViewerDefaultMergeMethod string
ViewerHasStarred bool
ViewerPermission string
ViewerPossibleCommitEmails []string
ViewerSubscription string
RepositoryTopics struct {
Nodes []struct {
Topic RepositoryTopic
}
}
PrimaryLanguage *CodingLanguage
Languages struct {
Edges []struct {
Size int `json:"size"`
Node CodingLanguage `json:"node"`
}
}
IssueTemplates []IssueTemplate
PullRequestTemplates []PullRequestTemplate
Labels struct {
Nodes []IssueLabel
}
Milestones struct {
Nodes []Milestone
}
LatestRelease *RepositoryRelease
AssignableUsers struct {
Nodes []GitHubUser
}
MentionableUsers struct {
Nodes []GitHubUser
}
Projects struct {
Nodes []RepoProject
}
// pseudo-field that keeps track of host name of this repo
hostname string
@ -42,12 +117,76 @@ type Repository struct {
// RepositoryOwner is the owner of a GitHub repository
type RepositoryOwner struct {
Login string
ID string `json:"id"`
Login string `json:"login"`
}
type GitHubUser struct {
ID string `json:"id"`
Login string `json:"login"`
Name string `json:"name"`
}
// BranchRef is the branch name in a GitHub repository
type BranchRef struct {
Name string
Name string `json:"name"`
}
type CodeOfConduct struct {
Key string `json:"key"`
Name string `json:"name"`
URL string `json:"url"`
}
type RepositoryLicense struct {
Key string `json:"key"`
Name string `json:"name"`
Nickname string `json:"nickname"`
}
type ContactLink struct {
About string `json:"about"`
Name string `json:"name"`
URL string `json:"url"`
}
type FundingLink struct {
Platform string `json:"platform"`
URL string `json:"url"`
}
type CodingLanguage struct {
Name string `json:"name"`
}
type IssueTemplate struct {
Name string `json:"name"`
Title string `json:"title"`
Body string `json:"body"`
About string `json:"about"`
}
type PullRequestTemplate struct {
Filename string `json:"filename"`
Body string `json:"body"`
}
type RepositoryTopic struct {
Name string `json:"name"`
}
type RepositoryRelease struct {
Name string `json:"name"`
TagName string `json:"tagName"`
URL string `json:"url"`
PublishedAt time.Time `json:"publishedAt"`
}
type IssueLabel struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Color string `json:"color"`
}
// RepoOwner is the login name of the owner
@ -65,11 +204,6 @@ func (r Repository) RepoHost() string {
return r.hostname
}
// IsFork is true when this repository has a parent repository
func (r Repository) IsFork() bool {
return r.Parent != nil
}
// ViewerCanPush is true when the requesting user has push access
func (r Repository) ViewerCanPush() bool {
switch r.ViewerPermission {
@ -305,16 +439,26 @@ type repositoryV3 struct {
NodeID string
Name string
CreatedAt time.Time `json:"created_at"`
CloneURL string `json:"clone_url"`
Owner struct {
Login string
}
}
// ForkRepo forks the repository on GitHub and returns the new repository
func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
func ForkRepo(client *Client, repo ghrepo.Interface, org string) (*Repository, error) {
path := fmt.Sprintf("repos/%s/forks", ghrepo.FullName(repo))
body := bytes.NewBufferString(`{}`)
params := map[string]interface{}{}
if org != "" {
params["organization"] = org
}
body := &bytes.Buffer{}
enc := json.NewEncoder(body)
if err := enc.Encode(params); err != nil {
return nil, err
}
result := repositoryV3{}
err := client.REST(repo.RepoHost(), "POST", path, body, &result)
if err != nil {
@ -324,7 +468,6 @@ func ForkRepo(client *Client, repo ghrepo.Interface) (*Repository, error) {
return &Repository{
ID: result.NodeID,
Name: result.Name,
CloneURL: result.CloneURL,
CreatedAt: result.CreatedAt,
Owner: RepositoryOwner{
Login: result.Owner.Login,
@ -707,9 +850,10 @@ func RepoResolveMetadataIDs(client *Client, repo ghrepo.Interface, input RepoRes
}
type RepoProject struct {
ID string
Name string
ResourcePath string
ID string `json:"id"`
Name string `json:"name"`
Number int `json:"number"`
ResourcePath string `json:"resourcePath"`
}
// RepoProjects fetches all open projects for a repository

View file

@ -144,9 +144,9 @@ func Test_RepoMetadata(t *testing.T) {
func Test_ProjectsToPaths(t *testing.T) {
expectedProjectPaths := []string{"OWNER/REPO/PROJECT_NUMBER", "ORG/PROJECT_NUMBER"}
projects := []RepoProject{
{"id1", "My Project", "/OWNER/REPO/projects/PROJECT_NUMBER"},
{"id2", "Org Project", "/orgs/ORG/projects/PROJECT_NUMBER"},
{"id3", "Project", "/orgs/ORG/projects/PROJECT_NUMBER_2"},
{ID: "id1", Name: "My Project", ResourcePath: "/OWNER/REPO/projects/PROJECT_NUMBER"},
{ID: "id2", Name: "Org Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER"},
{ID: "id3", Name: "Project", ResourcePath: "/orgs/ORG/projects/PROJECT_NUMBER_2"},
}
projectNames := []string{"My Project", "Org Project"}

View file

@ -1,6 +1,7 @@
package api
import (
"fmt"
"strings"
)
@ -18,7 +19,7 @@ func shortenQuery(q string) string {
}
var issueComments = shortenQuery(`
comments(last: 100) {
comments(first: 100) {
nodes {
author{login},
authorAssociation,
@ -29,25 +30,25 @@ var issueComments = shortenQuery(`
minimizedReason,
reactionGroups{content,users{totalCount}}
},
pageInfo{hasNextPage,endCursor},
totalCount
}
`)
var prReviewRequests = shortenQuery(`
reviewRequests(last: 100) {
reviewRequests(first: 100) {
nodes {
requestedReviewer {
__typename,
...on User{login},
...on Team{name}
}
},
totalCount
}
}
`)
var prReviews = shortenQuery(`
reviews(last: 100) {
reviews(first: 100) {
nodes {
author{login},
authorAssociation,
@ -56,6 +57,7 @@ var prReviews = shortenQuery(`
state,
reactionGroups{content,users{totalCount}}
}
pageInfo{hasNextPage,endCursor}
}
`)
@ -69,14 +71,38 @@ var prFiles = shortenQuery(`
}
`)
var prStatusCheckRollup = shortenQuery(`
commits(last: 1) {
totalCount,
var prCommits = shortenQuery(`
commits(first: 100) {
nodes {
commit {
authors(first:100) {
nodes {
name,
email,
user{id,login}
}
},
messageHeadline,
messageBody,
oid,
committedDate,
authoredDate
}
}
}
`)
func StatusCheckRollupGraphQL(after string) string {
var afterClause string
if after != "" {
afterClause = ",after:" + after
}
return fmt.Sprintf(shortenQuery(`
statusCheckRollup: commits(last: 1) {
nodes {
commit {
statusCheckRollup {
contexts(last: 100) {
contexts(first:100%s) {
nodes {
__typename
...on StatusContext {
@ -92,13 +118,14 @@ var prStatusCheckRollup = shortenQuery(`
completedAt,
detailsUrl
}
}
},
pageInfo{hasNextPage,endCursor}
}
}
}
}
}
`)
}`), afterClause)
}
var IssueFields = []string{
"assignees",
@ -124,6 +151,7 @@ var PullRequestFields = append(IssueFields,
"additions",
"baseRefName",
"changedFiles",
"commits",
"deletions",
"files",
"headRefName",
@ -153,17 +181,17 @@ func PullRequestGraphQL(fields []string) string {
case "mergedBy":
q = append(q, `mergedBy{login}`)
case "headRepositoryOwner":
q = append(q, `headRepositoryOwner{login}`)
q = append(q, `headRepositoryOwner{id,login,...on User{name}}`)
case "headRepository":
q = append(q, `headRepository{name}`)
q = append(q, `headRepository{id,name}`)
case "assignees":
q = append(q, `assignees(first:100){nodes{login},totalCount}`)
q = append(q, `assignees(first:100){nodes{id,login,name},totalCount}`)
case "labels":
q = append(q, `labels(first:100){nodes{name},totalCount}`)
q = append(q, `labels(first:100){nodes{id,name,description,color},totalCount}`)
case "projectCards":
q = append(q, `projectCards(first:100){nodes{project{name}column{name}},totalCount}`)
case "milestone":
q = append(q, `milestone{title}`)
q = append(q, `milestone{number,title,description,dueOn}`)
case "reactionGroups":
q = append(q, `reactionGroups{content,users{totalCount}}`)
case "mergeCommit":
@ -178,8 +206,142 @@ func PullRequestGraphQL(fields []string) string {
q = append(q, prReviews)
case "files":
q = append(q, prFiles)
case "commits":
q = append(q, prCommits)
case "commitsCount": // pseudo-field
q = append(q, `commits{totalCount}`)
case "statusCheckRollup":
q = append(q, prStatusCheckRollup)
q = append(q, StatusCheckRollupGraphQL(""))
default:
q = append(q, field)
}
}
return strings.Join(q, ",")
}
var RepositoryFields = []string{
"id",
"name",
"nameWithOwner",
"owner",
"parent",
"templateRepository",
"description",
"homepageUrl",
"openGraphImageUrl",
"usesCustomOpenGraphImage",
"url",
"sshUrl",
"mirrorUrl",
"securityPolicyUrl",
"createdAt",
"pushedAt",
"updatedAt",
"isBlankIssuesEnabled",
"isSecurityPolicyEnabled",
"hasIssuesEnabled",
"hasProjectsEnabled",
"hasWikiEnabled",
"mergeCommitAllowed",
"squashMergeAllowed",
"rebaseMergeAllowed",
"forkCount",
"stargazerCount",
"watchers",
"issues",
"pullRequests",
"codeOfConduct",
"contactLinks",
"defaultBranchRef",
"deleteBranchOnMerge",
"diskUsage",
"fundingLinks",
"isArchived",
"isEmpty",
"isFork",
"isInOrganization",
"isMirror",
"isPrivate",
"isTemplate",
"isUserConfigurationRepository",
"licenseInfo",
"viewerCanAdminister",
"viewerDefaultCommitEmail",
"viewerDefaultMergeMethod",
"viewerHasStarred",
"viewerPermission",
"viewerPossibleCommitEmails",
"viewerSubscription",
"repositoryTopics",
"primaryLanguage",
"languages",
"issueTemplates",
"pullRequestTemplates",
"labels",
"milestones",
"latestRelease",
"assignableUsers",
"mentionableUsers",
"projects",
// "branchProtectionRules", // too complex to expose
// "collaborators", // does it make sense to expose without affiliation filter?
}
func RepositoryGraphQL(fields []string) string {
var q []string
for _, field := range fields {
switch field {
case "codeOfConduct":
q = append(q, "codeOfConduct{key,name,url}")
case "contactLinks":
q = append(q, "contactLinks{about,name,url}")
case "fundingLinks":
q = append(q, "fundingLinks{platform,url}")
case "licenseInfo":
q = append(q, "licenseInfo{key,name,nickname}")
case "owner":
q = append(q, "owner{id,login}")
case "parent":
q = append(q, "parent{id,name,owner{id,login}}")
case "templateRepository":
q = append(q, "templateRepository{id,name,owner{id,login}}")
case "repositoryTopics":
q = append(q, "repositoryTopics(first:100){nodes{topic{name}}}")
case "issueTemplates":
q = append(q, "issueTemplates{name,title,body,about}")
case "pullRequestTemplates":
q = append(q, "pullRequestTemplates{body,filename}")
case "labels":
q = append(q, "labels(first:100){nodes{id,color,name,description}}")
case "languages":
q = append(q, "languages(first:100){edges{size,node{name}}}")
case "primaryLanguage":
q = append(q, "primaryLanguage{name}")
case "latestRelease":
q = append(q, "latestRelease{publishedAt,tagName,name,url}")
case "milestones":
q = append(q, "milestones(first:100,states:OPEN){nodes{number,title,description,dueOn}}")
case "assignableUsers":
q = append(q, "assignableUsers(first:100){nodes{id,login,name}}")
case "mentionableUsers":
q = append(q, "mentionableUsers(first:100){nodes{id,login,name}}")
case "projects":
q = append(q, "projects(first:100,states:OPEN){nodes{id,name,number,body,resourcePath}}")
case "watchers":
q = append(q, "watchers{totalCount}")
case "issues":
q = append(q, "issues(states:OPEN){totalCount}")
case "pullRequests":
q = append(q, "pullRequests(states:OPEN){totalCount}")
case "defaultBranchRef":
q = append(q, "defaultBranchRef{name}")
default:
q = append(q, field)
}

View file

@ -21,7 +21,7 @@ func TestPullRequestGraphQL(t *testing.T) {
{
name: "fields with nested structures",
fields: []string{"author", "assignees"},
want: "author{login},assignees(first:100){nodes{login},totalCount}",
want: "author{login},assignees(first:100){nodes{id,login,name},totalCount}",
},
{
name: "compressed query",

View file

@ -57,12 +57,3 @@ var reactionEmoji = map[string]string{
"ROCKET": "\U0001f680",
"EYES": "\U0001f440",
}
func reactionGroupsFragment() string {
return `reactionGroups {
content
users {
totalCount
}
}`
}

View file

@ -104,7 +104,7 @@ func (r *ResolvedRemotes) BaseRepo(io *iostreams.IOStreams) (ghrepo.Interface, e
if repo == nil {
continue
}
if repo.IsFork() {
if repo.Parent != nil {
add(repo.Parent)
}
add(repo)

84
docs/project-layout.md Normal file
View file

@ -0,0 +1,84 @@
# GitHub CLI project layout
At a high level, these areas make up the `github.com/cli/cli` project:
- [`cmd/`](../cmd) - `main` packages for building binaries such as the `gh` executable
- [`pkg/`](../pkg) - most other packages, including the implementation for individual gh commands
- [`docs/`](../docs) - documentation for maintainers and contributors
- [`script/`](../script) - build and release scripts
- [`internal/`](../internal) - Go packages highly specific to our needs and thus internal
- [`go.mod`](../go.mod) - external Go dependencies for this project, automatically fetched by Go at build time
Some auxiliary Go packages are at the top level of the project for historical reasons:
- [`api/`](../api) - main utilities for making requests to the GitHub API
- [`context/`](../context) - DEPRECATED: use only for referencing git remotes
- [`git/`](../git) - utilities to gather information from a local git repository
- [`test/`](../test) - DEPRECATED: do not use
- [`utils/`](../utils) - DEPRECATED: use only for printing table output
## Command-line help text
Running `gh help issue list` displays help text for a topic. In this case, the topic is a specific command,
and help text for every command is embedded in that command's source code. The naming convention for gh
commands is:
```
pkg/cmd/<command>/<subcommand>/<subcommand>.go
```
Following the above example, the main implementation for the `gh issue list` command, including its help
text, is in [pkg/cmd/issue/view/view.go](../pkg/cmd/issue/view/view.go)
Other help topics not specific to any command, for example `gh help environment`, are found in
[pkg/cmd/root/help_topic.go](../pkg/cmd/root/help_topic.go).
During our release process, these help topics are [automatically converted](../cmd/gen-docs/main.go) to
manual pages and published under https://cli.github.com/manual/.
## How GitHub CLI works
To illustrate how GitHub CLI works in its typical mode of operation, let's build the project, run a command,
and talk through which code gets run in order.
1. `go run script/build.go` - Makes sure all external Go depedencies are fetched, then compiles the
`cmd/gh/main.go` file into a `bin/gh` binary.
2. `bin/gh issue list --limit 5` - Runs the newly built `bin/gh` binary (note: on Windows you must use
backslashes like `bin\gh`) and passes the following arguments to the process: `["issue", "list", "--limit", "5"]`.
3. `func main()` inside `cmd/gh/main.go` is the first Go function that runs. The arguments passed to the
process are available through `os.Args`.
4. The `main` package initializes the "root" command with `root.NewCmdRoot()` and dispatches execution to it
with `rootCmd.ExecuteC()`.
5. The [root command](../pkg/cmd/root/root.go) represents the top-level `gh` command and knows how to
dispatch execution to any other gh command nested under it.
6. Based on `["issue", "list"]` arguments, the execution reaches the `RunE` block of the `cobra.Command`
within [pkg/cmd/issue/list/list.go](../pkg/cmd/issue/list/list.go).
7. The `--limit 5` flag originally passed as arguments be automatically parsed and its value stored as
`opts.LimitResults`.
8. `func listRun()` is called, which is responsible for implementing the logic of the `gh issue list` command.
9. The command collects information from sources like the GitHub API then writes the final output to
standard output and standard error [streams](../pkg/iostreams/iostreams.go) available at `opts.IO`.
10. The program execution is now back at `func main()` of `cmd/gh/main.go`. If there were any Go errors as a
result of processing the command, the function will abort the process with a non-zero exit status.
Otherwise, the process ends with status 0 indicating success.
## How to add a new command
0. First, check on our issue tracker to verify that our team had approved the plans for a new command.
1. Create a package for the new command, e.g. for a new command `gh boom` create the following directory
structure: `pkg/cmd/boom/`
2. The new package should expose a method, e.g. `NewCmdBoom()`, that accepts a `*cmdutil.Factory` type and
returns a `*cobra.Command`.
* Any logic specific to this command should be kept within the command's package and not added to any
"global" packages like `api` or `utils`.
3. Use the method from the previous step to generate the command and add it to the command tree, typically
somewhere in the `NewCmdRoot()` method.
## How to write tests
This task might be tricky. Typically, gh commands do things like look up information from the git repository
in the current directory, query the GitHub API, scan the user's `~/.ssh/config` file, clone or fetch git
repositories, etc. Naturally, none of these things should ever happen for real when running tests, unless
you are sure that any filesystem operations are stricly scoped to a location made for and maintained by the
test itself. To avoid actually running things like making real API requests or shelling out to `git`
commands, we stub them. You should look at how that's done within some existing tests.
To make your code testable, write small, isolated pieces of functionality that are designed to be composed
together. Prefer table-driven tests for maintaining variations of different test inputs and expectations
when exercising a single piece of functionality.

2
go.mod
View file

@ -27,7 +27,7 @@ require (
github.com/rivo/uniseg v0.1.0
github.com/shurcooL/githubv4 v0.0.0-20200928013246-d292edc3691b
github.com/shurcooL/graphql v0.0.0-20181231061246-d48a9a75455f
github.com/spf13/cobra v1.1.1
github.com/spf13/cobra v1.1.3
github.com/spf13/pflag v1.0.5
github.com/stretchr/testify v1.6.1
golang.org/x/crypto v0.0.0-20201016220609-9e8e0b390897

6
go.sum
View file

@ -248,8 +248,8 @@ github.com/soheilhy/cmux v0.1.4/go.mod h1:IM3LyeVVIOuxMH7sFAkER9+bJ4dT7Ms6E4xg4k
github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA=
github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ=
github.com/spf13/cast v1.3.0/go.mod h1:Qx5cxh0v+4UWYiBimWS+eyWzqEqokIECu5etghLkUJE=
github.com/spf13/cobra v1.1.1 h1:KfztREH0tPxJJ+geloSLaAkaPkr4ki2Er5quFV1TDo4=
github.com/spf13/cobra v1.1.1/go.mod h1:WnodtKOvamDL/PwE2M4iKs8aMDBZ5Q5klgD3qfVJQMI=
github.com/spf13/cobra v1.1.3 h1:xghbfqPkxzxP3C/f3n5DdpAbdKLj4ZE4BWQI362l53M=
github.com/spf13/cobra v1.1.3/go.mod h1:pGADOWyqRD/YMrPZigI/zbliZ2wVD/23d+is3pSWzOo=
github.com/spf13/jwalterweatherman v1.0.0/go.mod h1:cQK4TGJAtQXfYWX+Ddv3mKDzgVb68N+wFjFa4jdeBTo=
github.com/spf13/pflag v1.0.3/go.mod h1:DYY7MBk1bdzusC3SYhjObp+wFpr4gzcvqqNjLnInEg4=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
@ -419,7 +419,7 @@ gopkg.in/resty.v1 v1.12.0/go.mod h1:mDo4pnntr5jdWRML875a/NmxYqAlA73dVijT2AXvQQo=
gopkg.in/yaml.v2 v2.0.0-20170812160011-eb3733d160e7/go.mod h1:JAlM8MvJe8wmxCU4Bli9HhUf9+ttbYbLASfIpnQbh74=
gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo=
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -3,10 +3,8 @@ package config
import (
"errors"
"fmt"
"io"
"io/ioutil"
"os"
"path"
"path/filepath"
"syscall"
@ -111,7 +109,7 @@ var ReadConfigFile = func(filename string) ([]byte, error) {
}
var WriteConfigFile = func(filename string, data []byte) error {
err := os.MkdirAll(path.Dir(filename), 0771)
err := os.MkdirAll(filepath.Dir(filename), 0771)
if err != nil {
return pathError(err)
}
@ -122,11 +120,7 @@ var WriteConfigFile = func(filename string, data []byte) error {
}
defer cfgFile.Close()
n, err := cfgFile.Write(data)
if err == nil && n < len(data) {
err = io.ErrShortWrite
}
_, err = cfgFile.Write(data)
return err
}
@ -263,7 +257,7 @@ func findRegularFile(p string) string {
if s, err := os.Stat(p); err == nil && s.Mode().IsRegular() {
return p
}
newPath := path.Dir(p)
newPath := filepath.Dir(p)
if newPath == p || newPath == "/" || newPath == "." {
break
}

View file

@ -3,7 +3,9 @@ package config
import (
"bytes"
"fmt"
"io/ioutil"
"os"
"path/filepath"
"testing"
"github.com/stretchr/testify/assert"
@ -167,3 +169,28 @@ func Test_ConfigDir(t *testing.T) {
})
}
}
func Test_configFile_Write_toDisk(t *testing.T) {
configDir := filepath.Join(t.TempDir(), ".config", "gh")
os.Setenv(GH_CONFIG_DIR, configDir)
defer os.Unsetenv(GH_CONFIG_DIR)
cfg := NewFromString(`pager: less`)
err := cfg.Write()
if err != nil {
t.Fatal(err)
}
expectedConfig := "pager: less\n"
if configBytes, err := ioutil.ReadFile(filepath.Join(configDir, "config.yml")); err != nil {
t.Error(err)
} else if string(configBytes) != expectedConfig {
t.Errorf("expected config.yml %q, got %q", expectedConfig, string(configBytes))
}
if configBytes, err := ioutil.ReadFile(filepath.Join(configDir, "hosts.yml")); err != nil {
t.Error(err)
} else if string(configBytes) != "" {
t.Errorf("unexpected hosts.yml: %q", string(configBytes))
}
}

View file

@ -175,11 +175,10 @@ func assertNextLineEquals(scanner *bufio.Scanner, expectedLine string) error {
}
func BenchmarkGenManToFile(b *testing.B) {
file, err := ioutil.TempFile("", "")
file, err := ioutil.TempFile(b.TempDir(), "")
if err != nil {
b.Fatal(err)
}
defer os.Remove(file.Name())
defer file.Close()
b.ResetTimer()

View file

@ -83,11 +83,10 @@ func TestGenMdTree(t *testing.T) {
}
func BenchmarkGenMarkdownToFile(b *testing.B) {
file, err := ioutil.TempFile("", "")
file, err := ioutil.TempFile(b.TempDir(), "")
if err != nil {
b.Fatal(err)
}
defer os.Remove(file.Name())
defer file.Close()
b.ResetTimer()

View file

@ -48,26 +48,26 @@ func actionsExplainer(cs *iostreams.ColorScheme) string {
GitHub CLI integrates with Actions to help you manage runs and workflows.
%s
gh run list: List recent workflow runs
gh run view: View details for a workflow run or one of its jobs
gh run watch: Watch a workflow run while it executes
gh run rerun: Rerun a failed workflow run
%s
gh run list: List recent workflow runs
gh run view: View details for a workflow run or one of its jobs
gh run watch: Watch a workflow run while it executes
gh run rerun: Rerun a failed workflow run
gh run download: Download artifacts generated by runs
To see more help, run 'gh help run <subcommand>'
%s
gh workflow list: List all the workflow files in your repository
gh workflow view: View details for a workflow file
gh workflow enable: Enable a workflow file
gh workflow disable: Disable a workflow file
%s
gh workflow list: List all the workflow files in your repository
gh workflow view: View details for a workflow file
gh workflow enable: Enable a workflow file
gh workflow disable: Disable a workflow file
gh workflow run: Trigger a workflow_dispatch run for a workflow file
To see more help, run 'gh help workflow <subcommand>'
For more in depth help including examples, see online documentation at:
https://docs.github.com/en/actions/guides/managing-github-actions-with-github-cli
<https://docs.github.com/en/actions/guides/managing-github-actions-with-github-cli>
`, header, runHeader, workflowHeader)
}

View file

@ -693,6 +693,9 @@ func Test_apiRun_inputFile(t *testing.T) {
contentLength: 10,
},
}
tempDir := t.TempDir()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, stdin, _, _ := iostreams.Test()
@ -702,13 +705,12 @@ func Test_apiRun_inputFile(t *testing.T) {
if tt.inputFile == "-" {
_, _ = stdin.Write(tt.inputContents)
} else {
f, err := ioutil.TempFile("", tt.inputFile)
f, err := ioutil.TempFile(tempDir, tt.inputFile)
if err != nil {
t.Fatal(err)
}
_, _ = f.Write(tt.inputContents)
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
defer f.Close()
inputFile = f.Name()
}
@ -825,13 +827,13 @@ func Test_parseFields(t *testing.T) {
}
func Test_magicFieldValue(t *testing.T) {
f, err := ioutil.TempFile("", "gh-test")
f, err := ioutil.TempFile(t.TempDir(), "gh-test")
if err != nil {
t.Fatal(err)
}
defer f.Close()
fmt.Fprint(f, "file contents")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
io, _, _, _ := iostreams.Test()
@ -932,13 +934,13 @@ func Test_magicFieldValue(t *testing.T) {
}
func Test_openUserFile(t *testing.T) {
f, err := ioutil.TempFile("", "gh-test")
f, err := ioutil.TempFile(t.TempDir(), "gh-test")
if err != nil {
t.Fatal(err)
}
defer f.Close()
fmt.Fprint(f, "file contents")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
file, length, err := openUserFile(f.Name(), nil)
if err != nil {

View file

@ -21,7 +21,7 @@ func NewCmdCompletion(io *iostreams.IOStreams) *cobra.Command {
When installing GitHub CLI through a package manager, it's possible that
no additional shell configuration is necessary to gain completion support. For
Homebrew, see https://docs.brew.sh/Shell-Completion
Homebrew, see <https://docs.brew.sh/Shell-Completion>
If you need to set up completions manually, follow the instructions below. The exact
config file locations might vary based on your system. Make sure to restart your

View file

@ -151,7 +151,13 @@ func createRun(opts *CreateOptions) error {
var httpError api.HTTPError
if errors.As(err, &httpError) {
if httpError.OAuthScopes != "" && !strings.Contains(httpError.OAuthScopes, "gist") {
return fmt.Errorf("This command requires the 'gist' OAuth scope.\nPlease re-authenticate by doing `gh config set -h github.com oauth_token ''` and running the command again.")
return fmt.Errorf("This command requires the 'gist' OAuth scope.\nPlease re-authenticate with: gh auth refresh -h %s -s gist", host)
}
if httpError.StatusCode == http.StatusUnprocessableEntity {
if detectEmptyFiles(files) {
fmt.Fprintf(errOut, "%s Failed to create gist: %s\n", cs.FailureIcon(), "a gist file cannot be blank")
return cmdutil.SilentError
}
}
}
return fmt.Errorf("%s Failed to create gist: %w", cs.Red("X"), err)
@ -266,3 +272,12 @@ func createGist(client *http.Client, hostname, description string, public bool,
return &result, nil
}
func detectEmptyFiles(files map[string]*shared.GistFile) bool {
for _, file := range files {
if strings.TrimSpace(file.Content) == "" {
return true
}
}
return false
}

View file

@ -5,9 +5,11 @@ import (
"encoding/json"
"io/ioutil"
"net/http"
"path"
"strings"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/gist/shared"
@ -18,10 +20,6 @@ import (
"github.com/stretchr/testify/assert"
)
const (
fixtureFile = "../fixture.txt"
)
func Test_processFiles(t *testing.T) {
fakeStdin := strings.NewReader("hey cool how is it going")
files, err := processFiles(ioutil.NopCloser(fakeStdin), "", []string{"-"})
@ -164,15 +162,22 @@ func TestNewCmdCreate(t *testing.T) {
}
func Test_createRun(t *testing.T) {
tempDir := t.TempDir()
fixtureFile := path.Join(tempDir, "fixture.txt")
assert.NoError(t, ioutil.WriteFile(fixtureFile, []byte("{}"), 0644))
emptyFile := path.Join(tempDir, "empty.txt")
assert.NoError(t, ioutil.WriteFile(emptyFile, []byte(" \t\n"), 0644))
tests := []struct {
name string
opts *CreateOptions
stdin string
wantOut string
wantStderr string
wantParams map[string]interface{}
wantErr bool
wantBrowse string
name string
opts *CreateOptions
stdin string
wantOut string
wantStderr string
wantParams map[string]interface{}
wantErr bool
wantBrowse string
responseStatus int
}{
{
name: "public",
@ -193,6 +198,7 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "with description",
@ -213,6 +219,7 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "multiple files",
@ -236,6 +243,28 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "file with empty content",
opts: &CreateOptions{
Filenames: []string{emptyFile},
},
wantOut: "",
wantStderr: heredoc.Doc(`
- Creating gist empty.txt
X Failed to create gist: a gist file cannot be blank
`),
wantErr: true,
wantParams: map[string]interface{}{
"description": "",
"updated_at": "0001-01-01T00:00:00Z",
"public": false,
"files": map[string]interface{}{
"empty.txt": map[string]interface{}{"content": " \t\n"},
},
},
responseStatus: http.StatusUnprocessableEntity,
},
{
name: "stdin arg",
@ -256,6 +285,7 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
{
name: "web arg",
@ -277,14 +307,22 @@ func Test_createRun(t *testing.T) {
},
},
},
responseStatus: http.StatusOK,
},
}
for _, tt := range tests {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("POST", "gists"),
httpmock.JSONResponse(struct {
Html_url string
}{"https://gist.github.com/aa5a315d61ae9438b18d"}))
if tt.responseStatus == http.StatusOK {
reg.Register(
httpmock.REST("POST", "gists"),
httpmock.StringResponse(`{
"html_url": "https://gist.github.com/aa5a315d61ae9438b18d"
}`))
} else {
reg.Register(
httpmock.REST("POST", "gists"),
httpmock.StatusStringResponse(tt.responseStatus, "{}"))
}
mockClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
@ -325,6 +363,32 @@ func Test_createRun(t *testing.T) {
}
}
func Test_detectEmptyFiles(t *testing.T) {
tests := []struct {
content string
isEmptyFile bool
}{
{
content: "{}",
isEmptyFile: false,
},
{
content: "\n\t",
isEmptyFile: true,
},
}
for _, tt := range tests {
files := map[string]*shared.GistFile{}
files["file"] = &shared.GistFile{
Content: tt.content,
}
isEmptyFile := detectEmptyFiles(files)
assert.Equal(t, tt.isEmptyFile, isEmptyFile)
}
}
func Test_CreateRun_reauth(t *testing.T) {
reg := &httpmock.Registry{}
reg.Register(httpmock.REST("POST", "gists"), func(req *http.Request) (*http.Response, error) {
@ -332,33 +396,24 @@ func Test_CreateRun_reauth(t *testing.T) {
StatusCode: 404,
Request: req,
Header: map[string][]string{
"X-Oauth-Scopes": {"coolScope"},
"X-Oauth-Scopes": {"repo, read:org"},
},
Body: ioutil.NopCloser(bytes.NewBufferString("oh no")),
}, nil
})
mockClient := func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
}
io, _, _, _ := iostreams.Test()
opts := &CreateOptions{
IO: io,
HttpClient: mockClient,
IO: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
Filenames: []string{fixtureFile},
}
err := createRun(opts)
if err == nil {
t.Fatalf("expected oauth error")
}
if !strings.Contains(err.Error(), "Please re-authenticate") {
t.Errorf("got unexpected error: %s", err)
}
assert.EqualError(t, err, "This command requires the 'gist' OAuth scope.\nPlease re-authenticate with: gh auth refresh -h github.com -s gist")
}

View file

@ -1 +0,0 @@
{}

View file

@ -20,7 +20,7 @@ type GistFile struct {
Filename string `json:"filename,omitempty"`
Type string `json:"type,omitempty"`
Language string `json:"language,omitempty"`
Content string `json:"content,omitempty"`
Content string `json:"content"`
}
type GistOwner struct {

View file

@ -6,7 +6,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"strings"
"testing"
@ -422,8 +421,9 @@ func TestIssueCreate_recover(t *testing.T) {
},
})
tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*")
tmpfile, err := ioutil.TempFile(t.TempDir(), "testrecover*")
assert.NoError(t, err)
defer tmpfile.Close()
state := prShared.IssueMetadataState{
Title: "recovered title",

View file

@ -149,7 +149,9 @@ func editRun(opts *EditOptions) error {
editable.Assignees.Default = issue.Assignees.Logins()
editable.Labels.Default = issue.Labels.Names()
editable.Projects.Default = issue.ProjectCards.ProjectNames()
editable.Milestone.Default = issue.Milestone.Title
if issue.Milestone != nil {
editable.Milestone.Default = issue.Milestone.Title
}
if opts.Interactive {
err = opts.FieldsToEditSurvey(&editable)

View file

@ -155,8 +155,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
data := api.ExportIssues(listResult.Issues, opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, listResult.Issues, opts.IO.ColorEnabled())
}
if isTerminal {

View file

@ -96,11 +96,11 @@ func statusRun(opts *StatusOptions) error {
if opts.Exporter != nil {
data := map[string]interface{}{
"createdBy": api.ExportIssues(issuePayload.Authored.Issues, opts.Exporter.Fields()),
"assigned": api.ExportIssues(issuePayload.Assigned.Issues, opts.Exporter.Fields()),
"mentioned": api.ExportIssues(issuePayload.Mentioned.Issues, opts.Exporter.Fields()),
"createdBy": issuePayload.Authored.Issues,
"assigned": issuePayload.Assigned.Issues,
"mentioned": issuePayload.Mentioned.Issues,
}
return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
}
out := opts.IO.Out

View file

@ -1,7 +1,6 @@
{
"data": {
"repository": {
"issue": {
"node": {
"comments": {
"nodes": [
{
@ -315,6 +314,5 @@
"totalCount": 6
}
}
}
}
}

View file

@ -0,0 +1,50 @@
package view
import (
"context"
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
func preloadIssueComments(client *http.Client, repo ghrepo.Interface, issue *api.Issue) error {
type response struct {
Node struct {
Issue struct {
Comments api.Comments `graphql:"comments(first: 100, after: $endCursor)"`
} `graphql:"...on Issue"`
} `graphql:"node(id: $id)"`
}
variables := map[string]interface{}{
"id": githubv4.ID(issue.ID),
"endCursor": (*githubv4.String)(nil),
}
if issue.Comments.PageInfo.HasNextPage {
variables["endCursor"] = githubv4.String(issue.Comments.PageInfo.EndCursor)
} else {
issue.Comments.Nodes = issue.Comments.Nodes[0:0]
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), client)
for {
var query response
err := gql.QueryNamed(context.Background(), "CommentsForIssue", &query, variables)
if err != nil {
return err
}
issue.Comments.Nodes = append(issue.Comments.Nodes, query.Node.Issue.Comments.Nodes...)
if !query.Node.Issue.Comments.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Node.Issue.Comments.PageInfo.EndCursor)
}
issue.Comments.PageInfo.HasNextPage = false
return nil
}

View file

@ -16,6 +16,7 @@ import (
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/markdown"
"github.com/cli/cli/pkg/set"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
@ -82,9 +83,17 @@ func viewRun(opts *ViewOptions) error {
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
issue, repo, err := issueShared.IssueFromArg(apiClient, opts.BaseRepo, opts.SelectorArg)
loadComments := opts.Comments
if !loadComments && opts.Exporter != nil {
fields := set.NewStringSet()
fields.AddValues(opts.Exporter.Fields())
loadComments = fields.Contains("comments")
}
opts.IO.StartProgressIndicator()
issue, err := findIssue(httpClient, opts.BaseRepo, opts.SelectorArg, loadComments)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
@ -97,27 +106,14 @@ func viewRun(opts *ViewOptions) error {
return opts.Browser.Browse(openURL)
}
if opts.Comments {
opts.IO.StartProgressIndicator()
comments, err := api.CommentsForIssue(apiClient, repo, issue)
opts.IO.StopProgressIndicator()
if err != nil {
return err
}
issue.Comments = *comments
}
opts.IO.DetectTerminalTheme()
err = opts.IO.StartPager()
if err != nil {
return err
if err := opts.IO.StartPager(); err != nil {
fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
}
defer opts.IO.StopPager()
if opts.Exporter != nil {
exportIssue := issue.ExportData(opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, exportIssue, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, issue, opts.IO.ColorEnabled())
}
if opts.IO.IsStdoutTTY() {
@ -132,6 +128,19 @@ func viewRun(opts *ViewOptions) error {
return printRawIssuePreview(opts.IO.Out, issue)
}
func findIssue(client *http.Client, baseRepoFn func() (ghrepo.Interface, error), selector string, loadComments bool) (*api.Issue, error) {
apiClient := api.NewClientFromHTTP(client)
issue, repo, err := issueShared.IssueFromArg(apiClient, baseRepoFn, selector)
if err != nil {
return issue, err
}
if loadComments {
err = preloadIssueComments(client, repo, issue)
}
return issue, err
}
func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
assignees := issueAssigneeList(*issue)
labels := shared.IssueLabelList(*issue)
@ -146,7 +155,11 @@ func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
fmt.Fprintf(out, "comments:\t%d\n", issue.Comments.TotalCount)
fmt.Fprintf(out, "assignees:\t%s\n", assignees)
fmt.Fprintf(out, "projects:\t%s\n", projects)
fmt.Fprintf(out, "milestone:\t%s\n", issue.Milestone.Title)
var milestoneTitle string
if issue.Milestone != nil {
milestoneTitle = issue.Milestone.Title
}
fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle)
fmt.Fprintln(out, "--")
fmt.Fprintln(out, issue.Body)
return nil
@ -187,7 +200,7 @@ func printHumanIssuePreview(opts *ViewOptions, issue *api.Issue) error {
fmt.Fprint(out, cs.Bold("Projects: "))
fmt.Fprintln(out, projects)
}
if issue.Milestone.Title != "" {
if issue.Milestone != nil {
fmt.Fprint(out, cs.Bold("Milestone: "))
fmt.Fprintln(out, issue.Milestone.Title)
}

View file

@ -24,10 +24,11 @@ type CheckoutOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Branch func() (string, error)
Finder shared.PRFinder
SelectorArg string
RecurseSubmodules bool
Force bool
@ -48,8 +49,7 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr
Short: "Check out a pull request in git",
Args: cmdutil.ExactArgs(1, "argument required"),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.Finder = shared.NewFinder(f)
if len(args) > 0 {
opts.SelectorArg = args[0]
@ -70,18 +70,11 @@ func NewCmdCheckout(f *cmdutil.Factory, runF func(*CheckoutOptions) error) *cobr
}
func checkoutRun(opts *CheckoutOptions) error {
remotes, err := opts.Remotes()
if err != nil {
return err
findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"},
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
pr, baseRepo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
@ -90,8 +83,12 @@ func checkoutRun(opts *CheckoutOptions) error {
if err != nil {
return err
}
protocol, _ := cfg.Get(baseRepo.RepoHost(), "git_protocol")
remotes, err := opts.Remotes()
if err != nil {
return err
}
baseRemote, _ := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName())
baseURLOrName := ghrepo.FormatRemoteURL(baseRepo, protocol)
if baseRemote != nil {
@ -112,6 +109,12 @@ func checkoutRun(opts *CheckoutOptions) error {
if headRemote != nil {
cmdQueue = append(cmdQueue, cmdsForExistingRemote(headRemote, pr, opts)...)
} else {
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
defaultBranch, err := api.RepoDefaultBranch(apiClient, baseRepo)
if err != nil {
return err

View file

@ -2,9 +2,9 @@ package checkout
import (
"bytes"
"encoding/json"
"io/ioutil"
"net/http"
"strings"
"testing"
"github.com/cli/cli/api"
@ -13,6 +13,7 @@ import (
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -21,6 +22,43 @@ import (
"github.com/stretchr/testify/assert"
)
// repo: either "baseOwner/baseRepo" or "baseOwner/baseRepo:defaultBranch"
// prHead: "headOwner/headRepo:headBranch"
func stubPR(repo, prHead string) (ghrepo.Interface, *api.PullRequest) {
defaultBranch := ""
if idx := strings.IndexRune(repo, ':'); idx >= 0 {
defaultBranch = repo[idx+1:]
repo = repo[:idx]
}
baseRepo, err := ghrepo.FromFullName(repo)
if err != nil {
panic(err)
}
if defaultBranch != "" {
baseRepo = api.InitRepoHostname(&api.Repository{
Name: baseRepo.RepoName(),
Owner: api.RepositoryOwner{Login: baseRepo.RepoOwner()},
DefaultBranchRef: api.BranchRef{Name: defaultBranch},
}, baseRepo.RepoHost())
}
idx := strings.IndexRune(prHead, ':')
headRefName := prHead[idx+1:]
headRepo, err := ghrepo.FromFullName(prHead[:idx])
if err != nil {
panic(err)
}
return baseRepo, &api.PullRequest{
Number: 123,
HeadRefName: headRefName,
HeadRepositoryOwner: api.Owner{Login: headRepo.RepoOwner()},
HeadRepository: &api.PRRepository{Name: headRepo.RepoName()},
IsCrossRepository: !ghrepo.IsSame(baseRepo, headRepo),
MaintainerCanModify: false,
}
}
func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
@ -32,13 +70,6 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, branch string, cl
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return api.InitRepoHostname(&api.Repository{
Name: "REPO",
Owner: api.RepositoryOwner{Login: "OWNER"},
DefaultBranchRef: api.BranchRef{Name: "master"},
}, "github.com"), nil
},
Remotes: func() (context.Remotes, error) {
if remotes == nil {
return context.Remotes{
@ -78,20 +109,9 @@ func TestPRCheckout_sameRepo(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature")
finder := shared.RunCommandFinder("123", pr, baseRepo)
finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"})
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -103,141 +123,17 @@ func TestPRCheckout_sameRepo(t *testing.T) {
cs.Register(`git config branch\.feature\.merge refs/heads/feature`, 0, "")
output, err := runCommand(http, nil, "master", `123`)
if !assert.NoError(t, err) {
return
}
assert.Equal(t, "", output.String())
}
func TestPRCheckout_urlArg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } }
`))
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch origin \+refs/heads/feature:refs/remotes/origin/feature`, 0, "")
cs.Register(`git show-ref --verify -- refs/heads/feature`, 1, "")
cs.Register(`git checkout -b feature --no-track origin/feature`, 0, "")
cs.Register(`git config branch\.feature\.remote origin`, 0, "")
cs.Register(`git config branch\.feature\.merge refs/heads/feature`, 0, "")
output, err := runCommand(http, nil, "master", `https://github.com/OWNER/REPO/pull/123/files`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
}
func TestPRCheckout_urlArg_differentBase(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "POE"
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } }
`))
http.StubRepoInfoResponse("OWNER", "REPO", "master")
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch https://github\.com/OTHER/POE\.git refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 1, "")
cs.Register(`git checkout feature`, 0, "")
cs.Register(`git config branch\.feature\.remote https://github\.com/OTHER/POE\.git`, 0, "")
cs.Register(`git config branch\.feature\.merge refs/pull/123/head`, 0, "")
output, err := runCommand(http, nil, "master", `https://github.com/OTHER/POE/pull/123/files`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
reqBody := struct {
Variables struct {
Owner string
Repo string
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
assert.Equal(t, "OTHER", reqBody.Variables.Owner)
assert.Equal(t, "POE", reqBody.Variables.Repo)
}
func TestPRCheckout_branchArg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": true,
"maintainerCanModify": false }
] } } } }
`))
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git fetch origin refs/pull/123/head:feature`, 0, "")
cs.Register(`git config branch\.feature\.merge`, 1, "")
cs.Register(`git checkout feature`, 0, "")
cs.Register(`git config branch\.feature\.remote origin`, 0, "")
cs.Register(`git config branch\.feature\.merge refs/pull/123/head`, 0, "")
output, err := runCommand(http, nil, "master", `hubot:feature`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_existingBranch(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature")
shared.RunCommandFinder("123", pr, baseRepo)
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -250,6 +146,7 @@ func TestPRCheckout_existingBranch(t *testing.T) {
output, err := runCommand(http, nil, "master", `123`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
@ -267,20 +164,9 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": true,
"maintainerCanModify": false
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO", "hubot/REPO:feature")
finder := shared.RunCommandFinder("123", pr, baseRepo)
finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"})
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -294,26 +180,16 @@ func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
output, err := runCommand(http, remotes, "master", `123`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_differentRepo(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": true,
"maintainerCanModify": false
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
finder := shared.RunCommandFinder("123", pr, baseRepo)
finder.ExpectFields([]string{"number", "headRefName", "headRepository", "headRepositoryOwner", "isCrossRepository"})
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -327,26 +203,15 @@ func TestPRCheckout_differentRepo(t *testing.T) {
output, err := runCommand(http, nil, "master", `123`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": true,
"maintainerCanModify": false
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
shared.RunCommandFinder("123", pr, baseRepo)
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -358,26 +223,15 @@ func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
output, err := runCommand(http, nil, "master", `123`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_detachedHead(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": true,
"maintainerCanModify": true
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
shared.RunCommandFinder("123", pr, baseRepo)
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -389,26 +243,15 @@ func TestPRCheckout_detachedHead(t *testing.T) {
output, err := runCommand(http, nil, "", `123`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": true,
"maintainerCanModify": false
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
shared.RunCommandFinder("123", pr, baseRepo)
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -420,26 +263,15 @@ func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
output, err := runCommand(http, nil, "feature", `123`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "-foo",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": true,
"maintainerCanModify": false
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO", "hubot/REPO:-foo")
shared.RunCommandFinder("123", pr, baseRepo)
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -447,26 +279,16 @@ func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) {
output, err := runCommand(http, nil, "master", `123`)
assert.EqualError(t, err, `invalid branch name: "-foo"`)
assert.Equal(t, "", output.Stderr())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_maintainerCanModify(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": true,
"maintainerCanModify": true
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
pr.MaintainerCanModify = true
shared.RunCommandFinder("123", pr, baseRepo)
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -480,25 +302,14 @@ func TestPRCheckout_maintainerCanModify(t *testing.T) {
output, err := runCommand(http, nil, "master", `123`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_recurseSubmodules(t *testing.T) {
http := &httpmock.Registry{}
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature")
shared.RunCommandFinder("123", pr, baseRepo)
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -513,25 +324,14 @@ func TestPRCheckout_recurseSubmodules(t *testing.T) {
output, err := runCommand(http, nil, "master", `123 --recurse-submodules`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_force(t *testing.T) {
http := &httpmock.Registry{}
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature")
shared.RunCommandFinder("123", pr, baseRepo)
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -545,26 +345,15 @@ func TestPRCheckout_force(t *testing.T) {
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_detach(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"headRef": "f8f8f8",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO"
},
"isCrossRepository": true,
"maintainerCanModify": true
} } } }
`))
baseRepo, pr := stubPR("OWNER/REPO:master", "hubot/REPO:feature")
shared.RunCommandFinder("123", pr, baseRepo)
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -573,6 +362,7 @@ func TestPRCheckout_detach(t *testing.T) {
cs.Register(`git fetch origin refs/pull/123/head`, 0, "")
output, err := runCommand(http, nil, "", `123 --detach`)
assert.Nil(t, err)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}

View file

@ -3,13 +3,10 @@ package checks
import (
"errors"
"fmt"
"net/http"
"sort"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
@ -23,26 +20,19 @@ type browser interface {
}
type ChecksOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
Browser browser
BaseRepo func() (ghrepo.Interface, error)
Branch func() (string, error)
Remotes func() (context.Remotes, error)
IO *iostreams.IOStreams
Browser browser
WebMode bool
Finder shared.PRFinder
SelectorArg string
WebMode bool
}
func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Command {
opts := &ChecksOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Branch: f.Branch,
Remotes: f.Remotes,
BaseRepo: f.BaseRepo,
Browser: f.Browser,
IO: f.IOStreams,
Browser: f.Browser,
}
cmd := &cobra.Command{
@ -56,8 +46,7 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.Finder = shared.NewFinder(f)
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
@ -81,25 +70,17 @@ func NewCmdChecks(f *cmdutil.Factory, runF func(*ChecksOptions) error) *cobra.Co
}
func checksRun(opts *ChecksOptions) error {
httpClient, err := opts.HttpClient()
findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"number", "baseRefName", "statusCheckRollup"},
}
if opts.WebMode {
findOptions.Fields = []string{"number"}
}
pr, baseRepo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
if err != nil {
return err
}
if len(pr.Commits.Nodes) == 0 {
return fmt.Errorf("no commit found on the pull request")
}
rollup := pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes
if len(rollup) == 0 {
return fmt.Errorf("no checks reported on the '%s' branch", pr.BaseRefName)
}
isTerminal := opts.IO.IsStdoutTTY()
@ -111,6 +92,15 @@ func checksRun(opts *ChecksOptions) error {
return opts.Browser.Browse(openURL)
}
if len(pr.StatusCheckRollup.Nodes) == 0 {
return fmt.Errorf("no commit found on the pull request")
}
rollup := pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes
if len(rollup) == 0 {
return fmt.Errorf("no checks reported on the '%s' branch", pr.BaseRefName)
}
passing := 0
failing := 0
pending := 0
@ -128,7 +118,7 @@ func checksRun(opts *ChecksOptions) error {
outputs := []output{}
for _, c := range pr.Commits.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes {
for _, c := range pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts.Nodes {
mark := "✓"
bucket := "pass"
state := c.State

View file

@ -2,16 +2,20 @@ package checks
import (
"bytes"
"net/http"
"encoding/json"
"io"
"os"
"testing"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
"github.com/google/shlex"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestNewCmdChecks(t *testing.T) {
@ -66,36 +70,20 @@ func Test_checksRun(t *testing.T) {
tests := []struct {
name string
fixture string
stubs func(*httpmock.Registry)
prJSON string
nontty bool
wantOut string
wantErr string
}{
{
name: "no commits",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "number": 123 }
} } }
`))
},
name: "no commits",
prJSON: `{ "number": 123 }`,
wantOut: "",
wantErr: "no commit found on the pull request",
},
{
name: "no checks",
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }
} } }
`))
},
name: "no checks",
prJSON: `{ "number": 123, "statusCheckRollup": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }`,
wantOut: "",
wantErr: "no checks reported on the 'master' branch",
},
@ -124,17 +112,9 @@ func Test_checksRun(t *testing.T) {
wantErr: "SilentError",
},
{
name: "no checks",
nontty: true,
stubs: func(reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "number": 123, "commits": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }
} } }
`))
},
name: "no checks",
nontty: true,
prJSON: `{ "number": 123, "statusCheckRollup": { "nodes": [{"commit": {"oid": "abc"}}]}, "baseRefName": "master" }`,
wantOut: "",
wantErr: "no checks reported on the 'master' branch",
},
@ -170,28 +150,26 @@ func Test_checksRun(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
io, _, stdout, _ := iostreams.Test()
io.SetStdoutTTY(!tt.nontty)
ios, _, stdout, _ := iostreams.Test()
ios.SetStdoutTTY(!tt.nontty)
var response *api.PullRequest
var jsonReader io.Reader
if tt.fixture != "" {
ff, err := os.Open(tt.fixture)
require.NoError(t, err)
defer ff.Close()
jsonReader = ff
} else {
jsonReader = bytes.NewBufferString(tt.prJSON)
}
dec := json.NewDecoder(jsonReader)
require.NoError(t, dec.Decode(&response))
opts := &ChecksOptions{
IO: io,
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
IO: ios,
SelectorArg: "123",
}
reg := &httpmock.Registry{}
defer reg.Verify(t)
if tt.stubs != nil {
tt.stubs(reg)
} else if tt.fixture != "" {
reg.Register(httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse(tt.fixture))
}
opts.HttpClient = func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
Finder: shared.NewMockFinder("123", response, ghrepo.New("OWNER", "REPO")),
}
err := checksRun(opts)
@ -232,10 +210,6 @@ func TestChecksRun_web(t *testing.T) {
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
browser := &cmdutil.TestBrowser{}
reg := &httpmock.Registry{}
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`), httpmock.FileResponse("./fixtures/allPassing.json"))
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(tc.isTTY)
@ -246,21 +220,15 @@ func TestChecksRun_web(t *testing.T) {
defer teardown(t)
err := checksRun(&ChecksOptions{
IO: io,
Browser: browser,
WebMode: true,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
IO: io,
Browser: browser,
WebMode: true,
SelectorArg: "123",
Finder: shared.NewMockFinder("123", &api.PullRequest{Number: 123}, ghrepo.New("OWNER", "REPO")),
})
assert.NoError(t, err)
assert.Equal(t, tc.wantStdout, stdout.String())
assert.Equal(t, tc.wantStderr, stderr.String())
reg.Verify(t)
browser.Verify(t, tc.wantBrowse)
})
}

View file

@ -1,6 +1,6 @@
{ "data": { "repository": { "pullRequest": {
{
"number": 123,
"commits": {
"statusCheckRollup": {
"nodes": [
{
"commit": {
@ -37,5 +37,6 @@
}
}
}
]}
} } } }
]
}
}

View file

@ -1,6 +1,6 @@
{ "data": { "repository": { "pullRequest": {
{
"number": 123,
"commits": {
"statusCheckRollup": {
"nodes": [
{
"commit": {
@ -37,5 +37,6 @@
}
}
}
]}
} } } }
]
}
}

View file

@ -1,6 +1,6 @@
{ "data": { "repository": { "pullRequest": {
{
"number": 123,
"commits": {
"statusCheckRollup": {
"nodes": [
{
"commit": {
@ -37,5 +37,6 @@
}
}
}
]}
} } } }
]
}
}

View file

@ -1,6 +1,6 @@
{ "data": { "repository": { "pullRequest": {
{
"number": 123,
"commits": {
"statusCheckRollup": {
"nodes": [
{
"commit": {
@ -34,5 +34,6 @@
}
}
}
]}
} } } }
]
}
}

View file

@ -6,8 +6,6 @@ import (
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -16,11 +14,11 @@ import (
type CloseOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Branch func() (string, error)
Finder shared.PRFinder
SelectorArg string
DeleteBranch bool
DeleteLocalBranch bool
@ -30,7 +28,6 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
opts := &CloseOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
Branch: f.Branch,
}
@ -39,8 +36,7 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
Short: "Close a pull request",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.Finder = shared.NewFinder(f)
if len(args) > 0 {
opts.SelectorArg = args[0]
@ -62,25 +58,29 @@ func NewCmdClose(f *cmdutil.Factory, runF func(*CloseOptions) error) *cobra.Comm
func closeRun(opts *CloseOptions) error {
cs := opts.IO.ColorScheme()
httpClient, err := opts.HttpClient()
if err != nil {
return err
findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"state", "number", "title", "isCrossRepository", "headRefName"},
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, nil, nil, opts.SelectorArg)
pr, baseRepo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
if pr.State == "MERGED" {
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be closed because it was already merged", cs.Red("!"), pr.Number, pr.Title)
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be closed because it was already merged\n", cs.FailureIcon(), pr.Number, pr.Title)
return cmdutil.SilentError
} else if !pr.IsOpen() {
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already closed\n", cs.Yellow("!"), pr.Number, pr.Title)
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already closed\n", cs.WarningIcon(), pr.Number, pr.Title)
return nil
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
err = api.PullRequestClose(apiClient, baseRepo, pr)
if err != nil {
return fmt.Errorf("API call failed: %w", err)
@ -88,12 +88,10 @@ func closeRun(opts *CloseOptions) error {
fmt.Fprintf(opts.IO.ErrOut, "%s Closed pull request #%d (%s)\n", cs.SuccessIconWithColor(cs.Red), pr.Number, pr.Title)
crossRepoPR := pr.HeadRepositoryOwner.Login != baseRepo.RepoOwner()
if opts.DeleteBranch {
branchSwitchString := ""
if opts.DeleteLocalBranch && !crossRepoPR {
if opts.DeleteLocalBranch {
currentBranch, err := opts.Branch()
if err != nil {
return err
@ -113,10 +111,8 @@ func closeRun(opts *CloseOptions) error {
localBranchExists := git.HasLocalBranch(pr.HeadRefName)
if localBranchExists {
err = git.DeleteLocalBranch(pr.HeadRefName)
if err != nil {
err = fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err)
return err
if err := git.DeleteLocalBranch(pr.HeadRefName); err != nil {
return fmt.Errorf("failed to delete local branch %s: %w", cs.Cyan(pr.HeadRefName), err)
}
}
@ -125,11 +121,14 @@ func closeRun(opts *CloseOptions) error {
}
}
if !crossRepoPR {
err = api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName)
if err != nil {
err = fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err)
return err
if pr.IsCrossRepository {
fmt.Fprintf(opts.IO.ErrOut, "%s Skipped deleting the remote branch of a pull request from fork\n", cs.WarningIcon())
if !opts.DeleteLocalBranch {
return nil
}
} else {
if err := api.BranchDeleteRemote(apiClient, baseRepo, pr.HeadRefName); err != nil {
return fmt.Errorf("failed to delete remote branch %s: %w", cs.Cyan(pr.HeadRefName), err)
}
}
fmt.Fprintf(opts.IO.ErrOut, "%s Deleted branch %s%s\n", cs.SuccessIconWithColor(cs.Red), cs.Cyan(pr.HeadRefName), branchSwitchString)

View file

@ -4,12 +4,14 @@ import (
"bytes"
"io/ioutil"
"net/http"
"regexp"
"strings"
"testing"
"github.com/cli/cli/internal/config"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -18,6 +20,44 @@ import (
"github.com/stretchr/testify/assert"
)
// repo: either "baseOwner/baseRepo" or "baseOwner/baseRepo:defaultBranch"
// prHead: "headOwner/headRepo:headBranch"
func stubPR(repo, prHead string) (ghrepo.Interface, *api.PullRequest) {
defaultBranch := ""
if idx := strings.IndexRune(repo, ':'); idx >= 0 {
defaultBranch = repo[idx+1:]
repo = repo[:idx]
}
baseRepo, err := ghrepo.FromFullName(repo)
if err != nil {
panic(err)
}
if defaultBranch != "" {
baseRepo = api.InitRepoHostname(&api.Repository{
Name: baseRepo.RepoName(),
Owner: api.RepositoryOwner{Login: baseRepo.RepoOwner()},
DefaultBranchRef: api.BranchRef{Name: defaultBranch},
}, baseRepo.RepoHost())
}
idx := strings.IndexRune(prHead, ':')
headRefName := prHead[idx+1:]
headRepo, err := ghrepo.FromFullName(prHead[:idx])
if err != nil {
panic(err)
}
return baseRepo, &api.PullRequest{
ID: "THE-ID",
Number: 96,
State: "OPEN",
HeadRefName: headRefName,
HeadRepositoryOwner: api.Owner{Login: headRepo.RepoOwner()},
HeadRepository: &api.PRRepository{Name: headRepo.RepoName()},
IsCrossRepository: !ghrepo.IsSame(baseRepo, headRepo),
}
}
func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
@ -29,12 +69,6 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Branch: func() (string, error) {
return "trunk", nil
},
@ -63,13 +97,10 @@ func TestPrClose(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "id": "THE-ID", "number": 96, "title": "The title of the PR", "state": "OPEN" }
} } }`),
)
baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature")
pr.Title = "The title of the PR"
shared.RunCommandFinder("96", pr, baseRepo)
http.Register(
httpmock.GraphQL(`mutation PullRequestClose\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
@ -79,57 +110,34 @@ func TestPrClose(t *testing.T) {
)
output, err := runCommand(http, true, "96")
if err != nil {
t.Fatalf("error running command `pr close`: %v", err)
}
r := regexp.MustCompile(`Closed pull request #96 \(The title of the PR\)`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Closed pull request #96 (The title of the PR)\n", output.Stderr())
}
func TestPrClose_alreadyClosed(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "number": 101, "title": "The title of the PR", "state": "CLOSED" }
} } }`),
)
baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:feature")
pr.State = "CLOSED"
pr.Title = "The title of the PR"
shared.RunCommandFinder("96", pr, baseRepo)
output, err := runCommand(http, true, "101")
if err != nil {
t.Fatalf("error running command `pr close`: %v", err)
}
r := regexp.MustCompile(`Pull request #101 \(The title of the PR\) is already closed`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
output, err := runCommand(http, true, "96")
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "! Pull request #96 (The title of the PR) is already closed\n", output.Stderr())
}
func TestPrClose_deleteBranch(t *testing.T) {
func TestPrClose_deleteBranch_sameRepo(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "THE-ID",
"number": 96,
"title": "The title of the PR",
"headRefName":"blueberries",
"headRepositoryOwner": {"login": "OWNER"},
"state": "OPEN" }
} } }`),
)
baseRepo, pr := stubPR("OWNER/REPO", "OWNER/REPO:blueberries")
pr.Title = "The title of the PR"
shared.RunCommandFinder("96", pr, baseRepo)
http.Register(
httpmock.GraphQL(`mutation PullRequestClose\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
@ -148,10 +156,77 @@ func TestPrClose_deleteBranch(t *testing.T) {
cs.Register(`git branch -D blueberries`, 0, "")
output, err := runCommand(http, true, `96 --delete-branch`)
if err != nil {
t.Fatalf("Got unexpected error running `pr close` %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), `Closed pull request #96 \(The title of the PR\)`, `Deleted branch blueberries`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
Closed pull request #96 (The title of the PR)
Deleted branch blueberries
`), output.Stderr())
}
func TestPrClose_deleteBranch_crossRepo(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
baseRepo, pr := stubPR("OWNER/REPO", "hubot/REPO:blueberries")
pr.Title = "The title of the PR"
shared.RunCommandFinder("96", pr, baseRepo)
http.Register(
httpmock.GraphQL(`mutation PullRequestClose\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["pullRequestId"], "THE-ID")
}),
)
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "")
cs.Register(`git branch -D blueberries`, 0, "")
output, err := runCommand(http, true, `96 --delete-branch`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
Closed pull request #96 (The title of the PR)
! Skipped deleting the remote branch of a pull request from fork
Deleted branch blueberries
`), output.Stderr())
}
func TestPrClose_deleteBranch_sameBranch(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
baseRepo, pr := stubPR("OWNER/REPO:main", "OWNER/REPO:trunk")
pr.Title = "The title of the PR"
shared.RunCommandFinder("96", pr, baseRepo)
http.Register(
httpmock.GraphQL(`mutation PullRequestClose\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["pullRequestId"], "THE-ID")
}),
)
http.Register(
httpmock.REST("DELETE", "repos/OWNER/REPO/git/refs/heads/trunk"),
httpmock.StringResponse(`{}`))
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git checkout main`, 0, "")
cs.Register(`git rev-parse --verify refs/heads/trunk`, 0, "")
cs.Register(`git branch -D trunk`, 0, "")
output, err := runCommand(http, true, `96 --delete-branch`)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
Closed pull request #96 (The title of the PR)
Deleted branch trunk and switched to branch main
`), output.Stderr())
}

View file

@ -2,11 +2,8 @@ package comment
import (
"errors"
"net/http"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
@ -48,7 +45,13 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
if len(args) > 0 {
selector = args[0]
}
opts.RetrieveCommentable = retrievePR(f.HttpClient, f.BaseRepo, f.Branch, f.Remotes, selector)
finder := shared.NewFinder(f)
opts.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
return finder.Find(shared.FindOptions{
Selector: selector,
Fields: []string{"id", "url"},
})
}
return shared.CommentablePreRun(cmd, opts)
},
RunE: func(cmd *cobra.Command, args []string) error {
@ -74,24 +77,3 @@ func NewCmdComment(f *cmdutil.Factory, runF func(*shared.CommentableOptions) err
return cmd
}
func retrievePR(httpClient func() (*http.Client, error),
baseRepo func() (ghrepo.Interface, error),
branch func() (string, error),
remotes func() (context.Remotes, error),
selector string) func() (shared.Commentable, ghrepo.Interface, error) {
return func() (shared.Commentable, ghrepo.Interface, error) {
httpClient, err := httpClient()
if err != nil {
return nil, nil, err
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, repo, err := shared.PRFromArgs(apiClient, baseRepo, branch, remotes, selector)
if err != nil {
return nil, nil, err
}
return pr, repo, nil
}
}

View file

@ -8,7 +8,7 @@ import (
"path/filepath"
"testing"
"github.com/cli/cli/context"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
@ -224,7 +224,6 @@ func Test_commentRun(t *testing.T) {
ConfirmSubmitSurvey: func() (bool, error) { return true, nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestFromNumber(t, reg)
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
@ -238,9 +237,6 @@ func Test_commentRun(t *testing.T) {
OpenInBrowser: func(string) error { return nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestFromNumber(t, reg)
},
stderr: "Opening github.com/OWNER/REPO/pull/123 in your browser.\n",
},
{
@ -253,7 +249,6 @@ func Test_commentRun(t *testing.T) {
EditSurvey: func() (string, error) { return "comment body", nil },
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestFromNumber(t, reg)
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
@ -266,7 +261,6 @@ func Test_commentRun(t *testing.T) {
Body: "comment body",
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestFromNumber(t, reg)
mockCommentCreate(t, reg)
},
stdout: "https://github.com/OWNER/REPO/pull/123#issuecomment-456\n",
@ -280,16 +274,20 @@ func Test_commentRun(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
tt.httpStubs(t, reg)
if tt.httpStubs != nil {
tt.httpStubs(t, reg)
}
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
branch := func() (string, error) { return "", nil }
remotes := func() (context.Remotes, error) { return nil, nil }
tt.input.IO = io
tt.input.HttpClient = httpClient
tt.input.RetrieveCommentable = retrievePR(httpClient, baseRepo, branch, remotes, "123")
tt.input.RetrieveCommentable = func() (shared.Commentable, ghrepo.Interface, error) {
return &api.PullRequest{
Number: 123,
URL: "https://github.com/OWNER/REPO/pull/123",
}, ghrepo.New("OWNER", "REPO"), nil
}
t.Run(tt.name, func(t *testing.T) {
err := shared.CommentableRun(tt.input)
@ -300,17 +298,6 @@ func Test_commentRun(t *testing.T) {
}
}
func mockPullRequestFromNumber(_ *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"number": 123,
"url": "https://github.com/OWNER/REPO/pull/123"
} } } }`),
)
}
func mockCommentCreate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation CommentCreate\b`),

View file

@ -36,6 +36,7 @@ type CreateOptions struct {
Remotes func() (context.Remotes, error)
Branch func() (string, error)
Browser browser
Finder shared.PRFinder
TitleProvided bool
BodyProvided bool
@ -117,6 +118,8 @@ func NewCmdCreate(f *cmdutil.Factory, runF func(*CreateOptions) error) *cobra.Co
`),
Args: cmdutil.NoArgsQuoteReminder,
RunE: func(cmd *cobra.Command, args []string) error {
opts.Finder = shared.NewFinder(f)
opts.TitleProvided = cmd.Flags().Changed("title")
opts.RepoOverride, _ = cmd.Flags().GetString("repo")
noMaintainerEdit, _ := cmd.Flags().GetBool("no-maintainer-edit")
@ -220,9 +223,13 @@ func createRun(opts *CreateOptions) (err error) {
state.Body = opts.Body
}
existingPR, err := api.PullRequestForBranch(
client, ctx.BaseRepo, ctx.BaseBranch, ctx.HeadBranchLabel, []string{"OPEN"})
var notFound *api.NotFoundError
existingPR, _, err := opts.Finder.Find(shared.FindOptions{
Selector: ctx.HeadBranchLabel,
BaseBranch: ctx.BaseBranch,
States: []string{"OPEN"},
Fields: []string{"url"},
})
var notFound *shared.NotFoundError
if err != nil && !errors.As(err, &notFound) {
return fmt.Errorf("error checking for existing pull request: %w", err)
}
@ -674,7 +681,7 @@ func handlePush(opts CreateOptions, ctx CreateContext) error {
// one by forking the base repository
if headRepo == nil && ctx.IsPushEnabled {
opts.IO.StartProgressIndicator()
headRepo, err = api.ForkRepo(client, ctx.BaseRepo)
headRepo, err = api.ForkRepo(client, ctx.BaseRepo, "")
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("error forking repo: %w", err)

View file

@ -6,7 +6,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"path/filepath"
"testing"
@ -17,6 +16,7 @@ import (
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/pr/shared"
prShared "github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
@ -247,12 +247,7 @@ func TestPRCreate_recover(t *testing.T) {
defer http.Verify(t)
http.StubRepoInfoResponse("OWNER", "REPO", "master")
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
shared.RunCommandFinder("feature", nil, nil)
http.Register(
httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`),
httpmock.StringResponse(`
@ -309,8 +304,9 @@ func TestPRCreate_recover(t *testing.T) {
},
})
tmpfile, err := ioutil.TempFile(os.TempDir(), "testrecover*")
tmpfile, err := ioutil.TempFile(t.TempDir(), "testrecover*")
assert.NoError(t, err)
defer tmpfile.Close()
state := prShared.IssueMetadataState{
Title: "recovered title",
@ -337,12 +333,7 @@ func TestPRCreate_nontty(t *testing.T) {
defer http.Verify(t)
http.StubRepoInfoResponse("OWNER", "REPO", "master")
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }`),
)
shared.RunCommandFinder("feature", nil, nil)
http.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
@ -379,12 +370,7 @@ func TestPRCreate(t *testing.T) {
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
shared.RunCommandFinder("feature", nil, nil)
http.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
@ -428,12 +414,7 @@ func TestPRCreate_NoMaintainerModify(t *testing.T) {
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "OWNER"} } }`))
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
shared.RunCommandFinder("feature", nil, nil)
http.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
@ -477,12 +458,7 @@ func TestPRCreate_createFork(t *testing.T) {
http.Register(
httpmock.GraphQL(`query UserCurrent\b`),
httpmock.StringResponse(`{"data": {"viewer": {"login": "monalisa"} } }`))
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
shared.RunCommandFinder("feature", nil, nil)
http.Register(
httpmock.REST("POST", "repos/OWNER/REPO/forks"),
httpmock.StatusStringResponse(201, `
@ -544,12 +520,7 @@ func TestPRCreate_pushedToNonBaseRepo(t *testing.T) {
defer http.Verify(t)
http.StubRepoInfoResponse("OWNER", "REPO", "master")
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
shared.RunCommandFinder("feature", nil, nil)
http.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
@ -588,12 +559,7 @@ func TestPRCreate_pushedToDifferentBranchName(t *testing.T) {
defer http.Verify(t)
http.StubRepoInfoResponse("OWNER", "REPO", "master")
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
shared.RunCommandFinder("feature", nil, nil)
http.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
@ -634,12 +600,7 @@ func TestPRCreate_nonLegacyTemplate(t *testing.T) {
defer http.Verify(t)
http.StubRepoInfoResponse("OWNER", "REPO", "master")
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes" : [
] } } } }
`))
shared.RunCommandFinder("feature", nil, nil)
http.Register(
httpmock.GraphQL(`mutation PullRequestCreate\b`),
httpmock.GraphQLMutation(`
@ -684,12 +645,7 @@ func TestPRCreate_metadata(t *testing.T) {
defer http.Verify(t)
http.StubRepoInfoResponse("OWNER", "REPO", "master")
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
] } } } }
`))
shared.RunCommandFinder("feature", nil, nil)
http.Register(
httpmock.GraphQL(`query RepositoryResolveMetadataIDs\b`),
httpmock.StringResponse(`
@ -790,15 +746,7 @@ func TestPRCreate_alreadyExists(t *testing.T) {
defer http.Verify(t)
http.StubRepoInfoResponse("OWNER", "REPO", "master")
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`),
)
shared.RunCommandFinder("feature", &api.PullRequest{URL: "https://github.com/OWNER/REPO/pull/123"}, ghrepo.New("OWNER", "REPO"))
_, err := runCommand(http, nil, "feature", true, `-t title -b body -H feature`)
assert.EqualError(t, err, "a pull request for branch \"feature\" into branch \"master\" already exists:\nhttps://github.com/OWNER/REPO/pull/123")

View file

@ -11,8 +11,6 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -22,9 +20,8 @@ import (
type DiffOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Branch func() (string, error)
Finder shared.PRFinder
SelectorArg string
UseColor string
@ -34,8 +31,6 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman
opts := &DiffOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Remotes: f.Remotes,
Branch: f.Branch,
}
cmd := &cobra.Command{
@ -49,8 +44,7 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.Finder = shared.NewFinder(f)
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
@ -81,17 +75,21 @@ func NewCmdDiff(f *cmdutil.Factory, runF func(*DiffOptions) error) *cobra.Comman
}
func diffRun(opts *DiffOptions) error {
findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"number"},
}
pr, baseRepo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
if err != nil {
return err
}
diff, err := apiClient.PullRequestDiff(baseRepo, pr.Number)
if err != nil {
return fmt.Errorf("could not find pull request diff: %w", err)

View file

@ -6,10 +6,10 @@ import (
"net/http"
"testing"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -119,27 +119,6 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Remotes: func() (context.Remotes, error) {
if remotes == nil {
return context.Remotes{
{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
}
return remotes, nil
},
Branch: func() (string, error) {
return "feature", nil
},
}
cmd := NewCmdDiff(factory, nil)
@ -161,61 +140,15 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s
}, err
}
func TestPRDiff_no_current_pr(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequests": { "nodes": [] }
} } }`),
)
_, err := runCommand(http, nil, false, "")
assert.EqualError(t, err, `no pull requests found for branch "feature"`)
}
func TestPRDiff_argument_not_found(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "number": 123 }
} } }`),
)
http.Register(
httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"),
httpmock.StatusStringResponse(404, ""),
)
_, err := runCommand(http, nil, false, "123")
assert.EqualError(t, err, `could not find pull request diff: pull request not found`)
}
func TestPRDiff_notty(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`),
)
shared.RunCommandFinder("", &api.PullRequest{Number: 123}, ghrepo.New("OWNER", "REPO"))
http.Register(
httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"),
httpmock.StringResponse(testDiff),
)
httpmock.StringResponse(testDiff))
output, err := runCommand(http, nil, false, "")
if err != nil {
@ -230,23 +163,14 @@ func TestPRDiff_tty(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`),
)
shared.RunCommandFinder("123", &api.PullRequest{Number: 123}, ghrepo.New("OWNER", "REPO"))
http.Register(
httpmock.REST("GET", "repos/OWNER/REPO/pulls/123"),
httpmock.StringResponse(testDiff),
)
output, err := runCommand(http, nil, true, "")
output, err := runCommand(http, nil, true, "123")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}

View file

@ -7,7 +7,6 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
shared "github.com/cli/cli/pkg/cmd/pr/shared"
@ -20,10 +19,8 @@ import (
type EditOptions struct {
HttpClient func() (*http.Client, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Branch func() (string, error)
Finder shared.PRFinder
Surveyor Surveyor
Fetcher EditableOptionsFetcher
EditorRetriever EditorRetriever
@ -38,8 +35,6 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
opts := &EditOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Remotes: f.Remotes,
Branch: f.Branch,
Surveyor: surveyor{},
Fetcher: fetcher{},
EditorRetriever: editorRetriever{config: f.Config},
@ -66,8 +61,7 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.Finder = shared.NewFinder(f)
if len(args) > 0 {
opts.SelectorArg = args[0]
@ -155,13 +149,11 @@ func NewCmdEdit(f *cmdutil.Factory, runF func(*EditOptions) error) *cobra.Comman
}
func editRun(opts *EditOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"id", "url", "title", "body", "baseRefName", "reviewRequests", "assignees", "labels", "projectCards", "milestone"},
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
pr, repo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
@ -175,7 +167,9 @@ func editRun(opts *EditOptions) error {
editable.Assignees.Default = pr.Assignees.Logins()
editable.Labels.Default = pr.Labels.Names()
editable.Projects.Default = pr.ProjectCards.ProjectNames()
editable.Milestone.Default = pr.Milestone.Title
if pr.Milestone != nil {
editable.Milestone.Default = pr.Milestone.Title
}
if opts.Interactive {
err = opts.Surveyor.FieldsToEdit(&editable)
@ -184,6 +178,12 @@ func editRun(opts *EditOptions) error {
}
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
opts.IO.StartProgressIndicator()
err = opts.Fetcher.EditableOptionsFetch(apiClient, repo, &editable)
opts.IO.StopProgressIndicator()

View file

@ -309,6 +309,9 @@ func Test_editRun(t *testing.T) {
name: "non-interactive",
input: &EditOptions{
SelectorArg: "123",
Finder: shared.NewMockFinder("123", &api.PullRequest{
URL: "https://github.com/OWNER/REPO/pull/123",
}, ghrepo.New("OWNER", "REPO")),
Interactive: false,
Editable: shared.Editable{
Title: shared.EditableString{
@ -351,7 +354,6 @@ func Test_editRun(t *testing.T) {
Fetcher: testFetcher{},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestGet(t, reg)
mockRepoMetadata(t, reg, false)
mockPullRequestUpdate(t, reg)
mockPullRequestReviewersUpdate(t, reg)
@ -362,6 +364,9 @@ func Test_editRun(t *testing.T) {
name: "non-interactive skip reviewers",
input: &EditOptions{
SelectorArg: "123",
Finder: shared.NewMockFinder("123", &api.PullRequest{
URL: "https://github.com/OWNER/REPO/pull/123",
}, ghrepo.New("OWNER", "REPO")),
Interactive: false,
Editable: shared.Editable{
Title: shared.EditableString{
@ -399,7 +404,6 @@ func Test_editRun(t *testing.T) {
Fetcher: testFetcher{},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestGet(t, reg)
mockRepoMetadata(t, reg, true)
mockPullRequestUpdate(t, reg)
},
@ -408,14 +412,16 @@ func Test_editRun(t *testing.T) {
{
name: "interactive",
input: &EditOptions{
SelectorArg: "123",
SelectorArg: "123",
Finder: shared.NewMockFinder("123", &api.PullRequest{
URL: "https://github.com/OWNER/REPO/pull/123",
}, ghrepo.New("OWNER", "REPO")),
Interactive: true,
Surveyor: testSurveyor{},
Fetcher: testFetcher{},
EditorRetriever: testEditorRetriever{},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestGet(t, reg)
mockRepoMetadata(t, reg, false)
mockPullRequestUpdate(t, reg)
mockPullRequestReviewersUpdate(t, reg)
@ -425,14 +431,16 @@ func Test_editRun(t *testing.T) {
{
name: "interactive skip reviewers",
input: &EditOptions{
SelectorArg: "123",
SelectorArg: "123",
Finder: shared.NewMockFinder("123", &api.PullRequest{
URL: "https://github.com/OWNER/REPO/pull/123",
}, ghrepo.New("OWNER", "REPO")),
Interactive: true,
Surveyor: testSurveyor{skipReviewers: true},
Fetcher: testFetcher{},
EditorRetriever: testEditorRetriever{},
},
httpStubs: func(t *testing.T, reg *httpmock.Registry) {
mockPullRequestGet(t, reg)
mockRepoMetadata(t, reg, true)
mockPullRequestUpdate(t, reg)
},
@ -450,11 +458,9 @@ func Test_editRun(t *testing.T) {
tt.httpStubs(t, reg)
httpClient := func() (*http.Client, error) { return &http.Client{Transport: reg}, nil }
baseRepo := func() (ghrepo.Interface, error) { return ghrepo.New("OWNER", "REPO"), nil }
tt.input.IO = io
tt.input.HttpClient = httpClient
tt.input.BaseRepo = baseRepo
t.Run(tt.name, func(t *testing.T) {
err := editRun(tt.input)
@ -465,18 +471,6 @@ func Test_editRun(t *testing.T) {
}
}
func mockPullRequestGet(_ *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "456",
"number": 123,
"url": "https://github.com/OWNER/REPO/pull/123"
} } } }`),
)
}
func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry, skipReviewers bool) {
reg.Register(
httpmock.GraphQL(`query RepositoryAssignableUsers\b`),
@ -551,23 +545,13 @@ func mockRepoMetadata(_ *testing.T, reg *httpmock.Registry, skipReviewers bool)
func mockPullRequestUpdate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestUpdate\b`),
httpmock.GraphQLMutation(`
{ "data": { "updatePullRequest": { "pullRequest": {
"id": "456"
} } } }`,
func(inputs map[string]interface{}) {}),
)
httpmock.StringResponse(`{}`))
}
func mockPullRequestReviewersUpdate(t *testing.T, reg *httpmock.Registry) {
reg.Register(
httpmock.GraphQL(`mutation PullRequestUpdateRequestReviews\b`),
httpmock.GraphQLMutation(`
{ "data": { "requestReviews": { "pullRequest": {
"id": "456"
} } } }`,
func(inputs map[string]interface{}) {}),
)
httpmock.StringResponse(`{}`))
}
type testFetcher struct{}

View file

@ -155,8 +155,7 @@ func listRun(opts *ListOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
data := api.ExportPRs(listResult.PullRequests, opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, listResult.PullRequests, opts.IO.ColorEnabled())
}
if opts.IO.IsStdoutTTY() {

View file

@ -8,10 +8,8 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -26,12 +24,11 @@ type editor interface {
type MergeOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Branch func() (string, error)
Finder shared.PRFinder
SelectorArg string
DeleteBranch bool
MergeMethod PullRequestMergeMethod
@ -52,8 +49,6 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
opts := &MergeOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
Remotes: f.Remotes,
Branch: f.Branch,
}
@ -76,8 +71,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.Finder = shared.NewFinder(f)
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
@ -136,7 +130,7 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
opts.Editor = &userEditor{
io: opts.IO,
config: opts.Config,
config: f.Config,
}
if runF != nil {
@ -160,19 +154,23 @@ func NewCmdMerge(f *cmdutil.Factory, runF func(*MergeOptions) error) *cobra.Comm
func mergeRun(opts *MergeOptions) error {
cs := opts.IO.ColorScheme()
httpClient, err := opts.HttpClient()
if err != nil {
return err
findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"id", "number", "state", "title", "commits", "mergeable", "headRepositoryOwner", "headRefName"},
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
pr, baseRepo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
isTerminal := opts.IO.IsStdoutTTY()
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
if opts.AutoMergeDisable {
err := disableAutoMerge(httpClient, baseRepo, pr.ID)
if err != nil {
@ -186,7 +184,7 @@ func mergeRun(opts *MergeOptions) error {
if opts.SelectorArg == "" && len(pr.Commits.Nodes) > 0 {
if localBranchLastCommit, err := git.LastCommit(); err == nil {
if localBranchLastCommit.Sha != pr.Commits.Nodes[0].Commit.Oid {
if localBranchLastCommit.Sha != pr.Commits.Nodes[len(pr.Commits.Nodes)-1].Commit.OID {
fmt.Fprintf(opts.IO.ErrOut,
"%s Pull request #%d (%s) has diverged from local branch\n", cs.Yellow("!"), pr.Number, pr.Title)
}

View file

@ -13,11 +13,9 @@ import (
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -197,6 +195,20 @@ func Test_NewCmdMerge(t *testing.T) {
}
}
func baseRepo(owner, repo, branch string) ghrepo.Interface {
return api.InitRepoHostname(&api.Repository{
Name: repo,
Owner: api.RepositoryOwner{Login: owner},
DefaultBranchRef: api.BranchRef{Name: branch},
}, "github.com")
}
func stubCommit(pr *api.PullRequest, oid string) {
pr.Commits.Nodes = append(pr.Commits.Nodes, api.PullRequestCommit{
Commit: api.PullRequestCommitCommit{OID: oid},
})
}
func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*test.CmdOut, error) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(isTTY)
@ -208,24 +220,6 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return api.InitRepoHostname(&api.Repository{
Name: "REPO",
Owner: api.RepositoryOwner{Login: "OWNER"},
DefaultBranchRef: api.BranchRef{Name: "master"},
}, "github.com"), nil
},
Remotes: func() (context.Remotes, error) {
return context.Remotes{
{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
},
Branch: func() (string, error) {
return branch, nil
},
@ -259,17 +253,18 @@ func initFakeHTTP() *httpmock.Registry {
func TestPrMerge(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "THE-ID",
"number": 1,
"title": "The title of the PR",
"state": "OPEN",
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"}
} } } }`))
shared.RunCommandFinder(
"1",
&api.PullRequest{
ID: "THE-ID",
Number: 1,
State: "OPEN",
Title: "The title of the PR",
},
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -296,17 +291,18 @@ func TestPrMerge(t *testing.T) {
func TestPrMerge_nontty(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "THE-ID",
"number": 1,
"title": "The title of the PR",
"state": "OPEN",
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"}
} } } }`))
shared.RunCommandFinder(
"1",
&api.PullRequest{
ID: "THE-ID",
Number: 1,
State: "OPEN",
Title: "The title of the PR",
},
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -330,17 +326,18 @@ func TestPrMerge_nontty(t *testing.T) {
func TestPrMerge_withRepoFlag(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "THE-ID",
"number": 1,
"title": "The title of the PR",
"state": "OPEN",
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"}
} } } }`))
shared.RunCommandFinder(
"1",
&api.PullRequest{
ID: "THE-ID",
Number: 1,
State: "OPEN",
Title: "The title of the PR",
},
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -367,10 +364,19 @@ func TestPrMerge_withRepoFlag(t *testing.T) {
func TestPrMerge_deleteBranch(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
// FIXME: references fixture from another package
httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json"))
shared.RunCommandFinder(
"",
&api.PullRequest{
ID: "PR_10",
Number: 10,
State: "OPEN",
Title: "Blueberries are a good fruit",
HeadRefName: "blueberries",
},
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -385,8 +391,6 @@ func TestPrMerge_deleteBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git .+ show .+ HEAD`, 1, "")
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
cs.Register(`git checkout master`, 0, "")
cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "")
cs.Register(`git branch -D blueberries`, 0, "")
@ -406,10 +410,19 @@ func TestPrMerge_deleteBranch(t *testing.T) {
func TestPrMerge_deleteNonCurrentBranch(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
// FIXME: references fixture from another package
httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json"))
shared.RunCommandFinder(
"blueberries",
&api.PullRequest{
ID: "PR_10",
Number: 10,
State: "OPEN",
Title: "Blueberries are a good fruit",
HeadRefName: "blueberries",
},
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -439,59 +452,24 @@ func TestPrMerge_deleteNonCurrentBranch(t *testing.T) {
`), output.Stderr())
}
func TestPrMerge_noPrNumberGiven(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
// FIXME: references fixture from another package
httpmock.FileResponse("../view/fixtures/prViewPreviewWithMetadataByBranch.json"))
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
assert.Equal(t, "PR_10", input["pullRequestId"].(string))
assert.Equal(t, "MERGE", input["mergeMethod"].(string))
assert.NotContains(t, input, "commitHeadline")
}))
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git .+ show .+ HEAD`, 1, "")
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
output, err := runCommand(http, "blueberries", true, "pr merge --merge")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, heredoc.Doc(`
Merged pull request #10 (Blueberries are a good fruit)
`), output.Stderr())
}
func Test_nonDivergingPullRequest(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [{
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"},
"id": "PR_10",
"title": "Blueberries are a good fruit",
"number": 10,
"commits": {
"nodes": [{
"commit": {
"oid": "COMMITSHA1"
}
}],
"totalCount": 1
}
}] } } } }`))
pr := &api.PullRequest{
ID: "PR_10",
Number: 10,
Title: "Blueberries are a good fruit",
State: "OPEN",
}
stubCommit(pr, "COMMITSHA1")
shared.RunCommandFinder(
"",
pr,
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -504,7 +482,6 @@ func Test_nonDivergingPullRequest(t *testing.T) {
defer cmdTeardown(t)
cs.Register(`git .+ show .+ HEAD`, 0, "COMMITSHA1,title")
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
output, err := runCommand(http, "blueberries", true, "pr merge --merge")
if err != nil {
@ -519,24 +496,21 @@ func Test_nonDivergingPullRequest(t *testing.T) {
func Test_divergingPullRequestWarning(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [{
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"},
"id": "PR_10",
"title": "Blueberries are a good fruit",
"number": 10,
"commits": {
"nodes": [{
"commit": {
"oid": "COMMITSHA1"
}
}],
"totalCount": 1
}
}] } } } }`))
pr := &api.PullRequest{
ID: "PR_10",
Number: 10,
Title: "Blueberries are a good fruit",
State: "OPEN",
}
stubCommit(pr, "COMMITSHA1")
shared.RunCommandFinder(
"",
pr,
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -549,7 +523,6 @@ func Test_divergingPullRequestWarning(t *testing.T) {
defer cmdTeardown(t)
cs.Register(`git .+ show .+ HEAD`, 0, "COMMITSHA2,title")
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
output, err := runCommand(http, "blueberries", true, "pr merge --merge")
if err != nil {
@ -565,20 +538,18 @@ func Test_divergingPullRequestWarning(t *testing.T) {
func Test_pullRequestWithoutCommits(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [{
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"},
"id": "PR_10",
"title": "Blueberries are a good fruit",
"number": 10,
"commits": {
"nodes": [],
"totalCount": 0
}
}] } } } }`))
shared.RunCommandFinder(
"",
&api.PullRequest{
ID: "PR_10",
Number: 10,
Title: "Blueberries are a good fruit",
State: "OPEN",
},
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -587,11 +558,9 @@ func Test_pullRequestWithoutCommits(t *testing.T) {
assert.NotContains(t, input, "commitHeadline")
}))
cs, cmdTeardown := run.Stub()
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
output, err := runCommand(http, "blueberries", true, "pr merge --merge")
if err != nil {
t.Fatalf("error running command `pr merge`: %v", err)
@ -605,17 +574,18 @@ func Test_pullRequestWithoutCommits(t *testing.T) {
func TestPrMerge_rebase(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "THE-ID",
"number": 2,
"title": "The title of the PR",
"state": "OPEN",
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"}
} } } }`))
shared.RunCommandFinder(
"2",
&api.PullRequest{
ID: "THE-ID",
Number: 2,
Title: "The title of the PR",
State: "OPEN",
},
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -642,17 +612,18 @@ func TestPrMerge_rebase(t *testing.T) {
func TestPrMerge_squash(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "THE-ID",
"number": 3,
"title": "The title of the PR",
"state": "OPEN",
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"}
} } } }`))
shared.RunCommandFinder(
"3",
&api.PullRequest{
ID: "THE-ID",
Number: 3,
Title: "The title of the PR",
State: "OPEN",
},
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -678,22 +649,18 @@ func TestPrMerge_squash(t *testing.T) {
func TestPrMerge_alreadyMerged(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": {
"number": 4,
"title": "The title of the PR",
"state": "MERGED",
"baseRefName": "master",
"headRefName": "blueberries",
"headRepositoryOwner": {
"login": "OWNER"
},
"isCrossRepository": false
}
} } }`))
shared.RunCommandFinder(
"4",
&api.PullRequest{
ID: "THE-ID",
Number: 4,
State: "MERGED",
HeadRefName: "blueberries",
BaseRefName: "master",
},
baseRepo("OWNER", "REPO", "master"),
)
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -707,23 +674,25 @@ func TestPrMerge_alreadyMerged(t *testing.T) {
as.StubOne(true)
output, err := runCommand(http, "blueberries", true, "pr merge 4")
if err != nil {
t.Fatalf("Got unexpected error running `pr merge` %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "✓ Deleted branch blueberries and switched to branch master")
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Deleted branch blueberries and switched to branch master\n", output.Stderr())
}
func TestPrMerge_alreadyMerged_nonInteractive(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "number": 4, "title": "The title of the PR", "state": "MERGED"}
} } }`))
shared.RunCommandFinder(
"4",
&api.PullRequest{
ID: "THE-ID",
Number: 4,
State: "MERGED",
HeadRepositoryOwner: api.Owner{Login: "monalisa"},
},
baseRepo("OWNER", "REPO", "master"),
)
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -740,15 +709,18 @@ func TestPrMerge_alreadyMerged_nonInteractive(t *testing.T) {
func TestPRMerge_interactive(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [{
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"},
"id": "THE-ID",
"number": 3
}] } } } }`))
shared.RunCommandFinder(
"",
&api.PullRequest{
ID: "THE-ID",
Number: 3,
Title: "It was the best of times",
HeadRefName: "blueberries",
},
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
@ -765,11 +737,9 @@ func TestPRMerge_interactive(t *testing.T) {
assert.NotContains(t, input, "commitHeadline")
}))
cs, cmdTeardown := run.Stub()
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
as, surveyTeardown := prompt.InitAskStubber()
defer surveyTeardown()
@ -789,16 +759,18 @@ func TestPRMerge_interactive(t *testing.T) {
func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [{
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"},
"id": "THE-ID",
"title": "It was the best of times",
"number": 3
}] } } } }`))
shared.RunCommandFinder(
"",
&api.PullRequest{
ID: "THE-ID",
Number: 3,
Title: "It was the best of times",
HeadRefName: "blueberries",
},
baseRepo("OWNER", "REPO", "master"),
)
http.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
@ -821,7 +793,6 @@ func TestPRMerge_interactiveWithDeleteBranch(t *testing.T) {
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
cs.Register(`git checkout master`, 0, "")
cs.Register(`git rev-parse --verify refs/heads/blueberries`, 0, "")
cs.Register(`git branch -D blueberries`, 0, "")
@ -851,16 +822,6 @@ func TestPRMerge_interactiveSquashEditCommitMsg(t *testing.T) {
tr := initFakeHTTP()
defer tr.Verify(t)
tr.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"headRepositoryOwner": {"login": "OWNER"},
"id": "THE-ID",
"number": 3,
"title": "title"
} } } }`))
tr.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
@ -902,25 +863,28 @@ func TestPRMerge_interactiveSquashEditCommitMsg(t *testing.T) {
},
SelectorArg: "https://github.com/OWNER/REPO/pull/123",
InteractiveMode: true,
Finder: shared.NewMockFinder(
"https://github.com/OWNER/REPO/pull/123",
&api.PullRequest{ID: "THE-ID", Number: 123, Title: "title"},
ghrepo.New("OWNER", "REPO"),
),
})
assert.NoError(t, err)
assert.Equal(t, "", stdout.String())
assert.Equal(t, "✓ Squashed and merged pull request #3 (title)\n", stderr.String())
assert.Equal(t, "✓ Squashed and merged pull request #123 (title)\n", stderr.String())
}
func TestPRMerge_interactiveCancelled(t *testing.T) {
http := initFakeHTTP()
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [{
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"},
"id": "THE-ID",
"number": 3
}] } } } }`))
shared.RunCommandFinder(
"",
&api.PullRequest{ID: "THE-ID", Number: 123},
ghrepo.New("OWNER", "REPO"),
)
http.Register(
httpmock.GraphQL(`query RepositoryInfo\b`),
httpmock.StringResponse(`
@ -930,11 +894,9 @@ func TestPRMerge_interactiveCancelled(t *testing.T) {
"squashMergeAllowed": true
} } }`))
cs, cmdTeardown := run.Stub()
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
as, surveyTeardown := prompt.InitAskStubber()
defer surveyTeardown()
@ -971,18 +933,6 @@ func TestMergeRun_autoMerge(t *testing.T) {
tr := initFakeHTTP()
defer tr.Verify(t)
tr.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "THE-ID",
"number": 123,
"title": "The title of the PR",
"state": "OPEN",
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"}
} } } }`))
tr.Register(
httpmock.GraphQL(`mutation PullRequestAutoMerge\b`),
httpmock.GraphQLMutation(`{}`, func(input map[string]interface{}) {
@ -1001,6 +951,11 @@ func TestMergeRun_autoMerge(t *testing.T) {
SelectorArg: "https://github.com/OWNER/REPO/pull/123",
AutoMergeEnable: true,
MergeMethod: PullRequestMergeMethodSquash,
Finder: shared.NewMockFinder(
"https://github.com/OWNER/REPO/pull/123",
&api.PullRequest{ID: "THE-ID", Number: 123},
ghrepo.New("OWNER", "REPO"),
),
})
assert.NoError(t, err)
@ -1015,21 +970,11 @@ func TestMergeRun_disableAutoMerge(t *testing.T) {
tr := initFakeHTTP()
defer tr.Verify(t)
tr.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "THE-ID",
"number": 123,
"title": "The title of the PR",
"state": "OPEN",
"headRefName": "blueberries",
"headRepositoryOwner": {"login": "OWNER"}
} } } }`))
tr.Register(
httpmock.GraphQL(`mutation PullRequestAutoMergeDisable\b`),
httpmock.StringResponse(`{}`))
httpmock.GraphQLQuery(`{}`, func(s string, m map[string]interface{}) {
assert.Equal(t, map[string]interface{}{"prID": "THE-ID"}, m)
}))
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
@ -1041,6 +986,11 @@ func TestMergeRun_disableAutoMerge(t *testing.T) {
},
SelectorArg: "https://github.com/OWNER/REPO/pull/123",
AutoMergeDisable: true,
Finder: shared.NewMockFinder(
"https://github.com/OWNER/REPO/pull/123",
&api.PullRequest{ID: "THE-ID", Number: 123},
ghrepo.New("OWNER", "REPO"),
),
})
assert.NoError(t, err)

View file

@ -7,9 +7,6 @@ import (
"github.com/MakeNowJust/heredoc"
"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/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -18,11 +15,9 @@ import (
type ReadyOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Branch func() (string, error)
Finder shared.PRFinder
SelectorArg string
}
@ -31,9 +26,6 @@ func NewCmdReady(f *cmdutil.Factory, runF func(*ReadyOptions) error) *cobra.Comm
opts := &ReadyOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
Remotes: f.Remotes,
Branch: f.Branch,
}
cmd := &cobra.Command{
@ -47,8 +39,7 @@ func NewCmdReady(f *cmdutil.Factory, runF func(*ReadyOptions) error) *cobra.Comm
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.Finder = shared.NewFinder(f)
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
@ -71,25 +62,29 @@ func NewCmdReady(f *cmdutil.Factory, runF func(*ReadyOptions) error) *cobra.Comm
func readyRun(opts *ReadyOptions) error {
cs := opts.IO.ColorScheme()
httpClient, err := opts.HttpClient()
if err != nil {
return err
findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"id", "number", "state", "isDraft"},
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
pr, baseRepo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
if !pr.IsOpen() {
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"", cs.Red("!"), pr.Number)
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is closed. Only draft pull requests can be marked as \"ready for review\"\n", cs.FailureIcon(), pr.Number)
return cmdutil.SilentError
} else if !pr.IsDraft {
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is already \"ready for review\"\n", cs.Yellow("!"), pr.Number)
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d is already \"ready for review\"\n", cs.WarningIcon(), pr.Number)
return nil
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
err = api.PullRequestReady(apiClient, baseRepo, pr)
if err != nil {
return fmt.Errorf("API call failed: %w", err)

View file

@ -4,13 +4,11 @@ import (
"bytes"
"io/ioutil"
"net/http"
"regexp"
"testing"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -101,23 +99,6 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Remotes: func() (context.Remotes, error) {
return context.Remotes{
{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
},
Branch: func() (string, error) {
return "main", nil
},
}
cmd := NewCmdReady(factory, nil)
@ -143,13 +124,13 @@ func TestPRReady(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "id": "THE-ID", "number": 444, "state": "OPEN", "isDraft": true}
} } }`),
)
shared.RunCommandFinder("123", &api.PullRequest{
ID: "THE-ID",
Number: 123,
State: "OPEN",
IsDraft: true,
}, ghrepo.New("OWNER", "REPO"))
http.Register(
httpmock.GraphQL(`mutation PullRequestReadyForReview\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
@ -158,62 +139,42 @@ func TestPRReady(t *testing.T) {
}),
)
output, err := runCommand(http, true, "444")
if err != nil {
t.Fatalf("error running command `pr ready`: %v", err)
}
r := regexp.MustCompile(`Pull request #444 is marked as "ready for review"`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
output, err := runCommand(http, true, "123")
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Pull request #123 is marked as \"ready for review\"\n", output.Stderr())
}
func TestPRReady_alreadyReady(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "number": 445, "state": "OPEN", "isDraft": false}
} } }`),
)
shared.RunCommandFinder("123", &api.PullRequest{
ID: "THE-ID",
Number: 123,
State: "OPEN",
IsDraft: false,
}, ghrepo.New("OWNER", "REPO"))
output, err := runCommand(http, true, "445")
if err != nil {
t.Fatalf("error running command `pr ready`: %v", err)
}
r := regexp.MustCompile(`Pull request #445 is already "ready for review"`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
output, err := runCommand(http, true, "123")
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "! Pull request #123 is already \"ready for review\"\n", output.Stderr())
}
func TestPRReady_closed(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "number": 446, "state": "CLOSED", "isDraft": true}
} } }`),
)
shared.RunCommandFinder("123", &api.PullRequest{
ID: "THE-ID",
Number: 123,
State: "CLOSED",
IsDraft: true,
}, ghrepo.New("OWNER", "REPO"))
output, err := runCommand(http, true, "446")
if err == nil {
t.Fatalf("expected an error running command `pr ready` on a review that is closed!: %v", err)
}
r := regexp.MustCompile(`Pull request #446 is closed. Only draft pull requests can be marked as "ready for review"`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
output, err := runCommand(http, true, "123")
assert.EqualError(t, err, "SilentError")
assert.Equal(t, "", output.String())
assert.Equal(t, "X Pull request #123 is closed. Only draft pull requests can be marked as \"ready for review\"\n", output.Stderr())
}

View file

@ -5,8 +5,6 @@ import (
"net/http"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -15,9 +13,9 @@ import (
type ReopenOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Finder shared.PRFinder
SelectorArg string
}
@ -26,7 +24,6 @@ func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Co
opts := &ReopenOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
}
cmd := &cobra.Command{
@ -34,8 +31,7 @@ func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Co
Short: "Reopen a pull request",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.Finder = shared.NewFinder(f)
if len(args) > 0 {
opts.SelectorArg = args[0]
@ -54,27 +50,31 @@ func NewCmdReopen(f *cmdutil.Factory, runF func(*ReopenOptions) error) *cobra.Co
func reopenRun(opts *ReopenOptions) error {
cs := opts.IO.ColorScheme()
httpClient, err := opts.HttpClient()
if err != nil {
return err
findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"id", "number", "state", "title"},
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, nil, nil, opts.SelectorArg)
pr, baseRepo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
if pr.State == "MERGED" {
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be reopened because it was already merged", cs.Red("!"), pr.Number, pr.Title)
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) can't be reopened because it was already merged\n", cs.FailureIcon(), pr.Number, pr.Title)
return cmdutil.SilentError
}
if pr.IsOpen() {
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already open\n", cs.Yellow("!"), pr.Number, pr.Title)
fmt.Fprintf(opts.IO.ErrOut, "%s Pull request #%d (%s) is already open\n", cs.WarningIcon(), pr.Number, pr.Title)
return nil
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
err = api.PullRequestReopen(apiClient, baseRepo, pr)
if err != nil {
return fmt.Errorf("API call failed: %w", err)

View file

@ -4,11 +4,11 @@ import (
"bytes"
"io/ioutil"
"net/http"
"regexp"
"testing"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -28,12 +28,6 @@ func runCommand(rt http.RoundTripper, isTTY bool, cli string) (*test.CmdOut, err
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
}
cmd := NewCmdReopen(factory, nil)
@ -59,13 +53,13 @@ func TestPRReopen(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "id": "THE-ID", "number": 666, "title": "The title of the PR", "state": "CLOSED" }
} } }`),
)
shared.RunCommandFinder("123", &api.PullRequest{
ID: "THE-ID",
Number: 123,
State: "CLOSED",
Title: "The title of the PR",
}, ghrepo.New("OWNER", "REPO"))
http.Register(
httpmock.GraphQL(`mutation PullRequestReopen\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
@ -74,95 +68,42 @@ func TestPRReopen(t *testing.T) {
}),
)
output, err := runCommand(http, true, "666")
if err != nil {
t.Fatalf("error running command `pr reopen`: %v", err)
}
r := regexp.MustCompile(`Reopened pull request #666 \(The title of the PR\)`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
}
func TestPRReopen_BranchArg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": {
"nodes": [
{ "id": "THE-ID", "number": 666, "title": "The title of the PR", "headRefName": "fix-bug", "state": "CLOSED" }
]
} } } }`),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestReopen\b`),
httpmock.GraphQLMutation(`{"id": "THE-ID"}`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["pullRequestId"], "THE-ID")
}),
)
output, err := runCommand(http, true, "fix-bug")
if err != nil {
t.Fatalf("error running command `pr reopen`: %v", err)
}
r := regexp.MustCompile(`Reopened pull request #666 \(The title of the PR\)`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
output, err := runCommand(http, true, "123")
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Reopened pull request #123 (The title of the PR)\n", output.Stderr())
}
func TestPRReopen_alreadyOpen(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "number": 666, "title": "The title of the PR", "state": "OPEN" }
} } }`),
)
shared.RunCommandFinder("123", &api.PullRequest{
ID: "THE-ID",
Number: 123,
State: "OPEN",
Title: "The title of the PR",
}, ghrepo.New("OWNER", "REPO"))
output, err := runCommand(http, true, "666")
if err != nil {
t.Fatalf("error running command `pr reopen`: %v", err)
}
r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) is already open`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
output, err := runCommand(http, true, "123")
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "! Pull request #123 (The title of the PR) is already open\n", output.Stderr())
}
func TestPRReopen_alreadyMerged(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": {
"pullRequest": { "number": 666, "title": "The title of the PR", "state": "MERGED"}
} } }`),
)
shared.RunCommandFinder("123", &api.PullRequest{
ID: "THE-ID",
Number: 123,
State: "MERGED",
Title: "The title of the PR",
}, ghrepo.New("OWNER", "REPO"))
output, err := runCommand(http, true, "666")
if err == nil {
t.Fatalf("expected an error running command `pr reopen`: %v", err)
}
r := regexp.MustCompile(`Pull request #666 \(The title of the PR\) can't be reopened because it was already merged`)
if !r.MatchString(output.Stderr()) {
t.Fatalf("output did not match regexp /%s/\n> output\n%q\n", r, output.Stderr())
}
output, err := runCommand(http, true, "123")
assert.EqualError(t, err, "SilentError")
assert.Equal(t, "", output.String())
assert.Equal(t, "X Pull request #123 (The title of the PR) can't be reopened because it was already merged\n", output.Stderr())
}

View file

@ -8,9 +8,7 @@ import (
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"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/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -24,9 +22,8 @@ type ReviewOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Branch func() (string, error)
Finder shared.PRFinder
SelectorArg string
InteractiveMode bool
@ -39,8 +36,6 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
Remotes: f.Remotes,
Branch: f.Branch,
}
var (
@ -74,8 +69,7 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.Finder = shared.NewFinder(f)
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
@ -151,13 +145,11 @@ func NewCmdReview(f *cmdutil.Factory, runF func(*ReviewOptions) error) *cobra.Co
}
func reviewRun(opts *ReviewOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
return err
findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: []string{"id", "number"},
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, baseRepo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
pr, baseRepo, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
@ -183,6 +175,12 @@ func reviewRun(opts *ReviewOptions) error {
}
}
httpClient, err := opts.HttpClient()
if err != nil {
return err
}
apiClient := api.NewClientFromHTTP(httpClient)
err = api.AddReview(apiClient, baseRepo, pr, reviewData)
if err != nil {
return fmt.Errorf("failed to create review: %w", err)

View file

@ -6,13 +6,14 @@ import (
"io/ioutil"
"net/http"
"path/filepath"
"regexp"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -178,24 +179,6 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Remotes: func() (context.Remotes, error) {
if remotes == nil {
return context.Remotes{
{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
}
return remotes, nil
},
Branch: func() (string, error) {
return "feature", nil
},
}
cmd := NewCmdReview(factory, nil)
@ -217,219 +200,67 @@ func runCommand(rt http.RoundTripper, remotes context.Remotes, isTTY bool, cli s
}, err
}
func TestPRReview_url_arg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "foobar123",
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO",
"defaultBranchRef": {
"name": "master"
}
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } }`),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
httpmock.GraphQLMutation(`{"data": {} }`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["pullRequestId"], "foobar123")
assert.Equal(t, inputs["event"], "APPROVE")
assert.Equal(t, inputs["body"], "")
}),
)
output, err := runCommand(http, nil, true, "--approve https://github.com/OWNER/REPO/pull/123")
if err != nil {
t.Fatalf("error running pr review: %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
}
func TestPRReview_number_arg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"id": "foobar123",
"number": 123,
"headRefName": "feature",
"headRepositoryOwner": {
"login": "hubot"
},
"headRepository": {
"name": "REPO",
"defaultBranchRef": {
"name": "master"
}
},
"isCrossRepository": false,
"maintainerCanModify": false
} } } } `),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestReviewAdd`),
httpmock.GraphQLMutation(`{"data": {} }`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["pullRequestId"], "foobar123")
assert.Equal(t, inputs["event"], "APPROVE")
assert.Equal(t, inputs["body"], "")
}),
)
output, err := runCommand(http, nil, true, "--approve 123")
if err != nil {
t.Fatalf("error running pr review: %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
}
func TestPRReview_no_arg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
httpmock.GraphQLMutation(`{"data": {} }`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["pullRequestId"], "foobar123")
assert.Equal(t, inputs["event"], "COMMENT")
assert.Equal(t, inputs["body"], "cool story")
}),
)
output, err := runCommand(http, nil, true, `--comment -b "cool story"`)
if err != nil {
t.Fatalf("error running pr review: %s", err)
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Reviewed pull request #123")
}
func TestPRReview(t *testing.T) {
type c struct {
Cmd string
ExpectedEvent string
ExpectedBody string
}
cases := []c{
{`--request-changes -b"bad"`, "REQUEST_CHANGES", "bad"},
{`--approve`, "APPROVE", ""},
{`--approve -b"hot damn"`, "APPROVE", "hot damn"},
{`--comment --body "i dunno"`, "COMMENT", "i dunno"},
tests := []struct {
args string
wantEvent string
wantBody string
}{
{
args: `--request-changes -b"bad"`,
wantEvent: "REQUEST_CHANGES",
wantBody: "bad",
},
{
args: `--approve`,
wantEvent: "APPROVE",
wantBody: "",
},
{
args: `--approve -b"hot damn"`,
wantEvent: "APPROVE",
wantBody: "hot damn",
},
{
args: `--comment --body "i dunno"`,
wantEvent: "COMMENT",
wantBody: "i dunno",
},
}
for _, kase := range cases {
t.Run(kase.Cmd, func(t *testing.T) {
for _, tt := range tests {
t.Run(tt.args, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`),
)
shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID"}, ghrepo.New("OWNER", "REPO"))
http.Register(
httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
httpmock.GraphQLMutation(`{"data": {} }`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["event"], kase.ExpectedEvent)
assert.Equal(t, inputs["body"], kase.ExpectedBody)
assert.Equal(t, map[string]interface{}{
"pullRequestId": "THE-ID",
"event": tt.wantEvent,
"body": tt.wantBody,
}, inputs)
}),
)
_, err := runCommand(http, nil, false, kase.Cmd)
if err != nil {
t.Fatalf("got unexpected error running %s: %s", kase.Cmd, err)
}
output, err := runCommand(http, nil, false, tt.args)
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
})
}
}
func TestPRReview_nontty(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`),
)
http.Register(
httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
httpmock.GraphQLMutation(`{"data": {} }`,
func(inputs map[string]interface{}) {
assert.Equal(t, inputs["event"], "COMMENT")
assert.Equal(t, inputs["body"], "cool")
}),
)
output, err := runCommand(http, nil, false, "-c -bcool")
if err != nil {
t.Fatalf("unexpected error running command: %s", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, "", output.Stderr())
}
func TestPRReview_interactive(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`),
)
shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO"))
http.Register(
httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
httpmock.GraphQLMutation(`{"data": {} }`,
@ -462,33 +293,21 @@ func TestPRReview_interactive(t *testing.T) {
})
output, err := runCommand(http, nil, true, "")
if err != nil {
t.Fatalf("got unexpected error running pr review: %s", err)
}
assert.NoError(t, err)
assert.Equal(t, heredoc.Doc(`
Got:
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.String(),
"Got:",
"cool.*story")
cool story
`), output.String())
assert.Equal(t, "✓ Approved pull request #123\n", output.Stderr())
}
func TestPRReview_interactive_no_body(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`),
)
shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO"))
as, teardown := prompt.InitAskStubber()
defer teardown()
@ -520,17 +339,8 @@ func TestPRReview_interactive_blank_approve(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`),
)
shared.RunCommandFinder("", &api.PullRequest{ID: "THE-ID", Number: 123}, ghrepo.New("OWNER", "REPO"))
http.Register(
httpmock.GraphQL(`mutation PullRequestReviewAdd\b`),
httpmock.GraphQLMutation(`{"data": {} }`,
@ -563,15 +373,7 @@ func TestPRReview_interactive_blank_approve(t *testing.T) {
})
output, err := runCommand(http, nil, true, "")
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())
}
//nolint:staticcheck // prefer exact matchers over ExpectLines
test.ExpectLines(t, output.Stderr(), "Approved pull request #123")
assert.NoError(t, err)
assert.Equal(t, "", output.String())
assert.Equal(t, "✓ Approved pull request #123\n", output.Stderr())
}

532
pkg/cmd/pr/shared/finder.go Normal file
View file

@ -0,0 +1,532 @@
package shared
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"regexp"
"sort"
"strconv"
"strings"
"github.com/cli/cli/api"
remotes "github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/set"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
"golang.org/x/sync/errgroup"
)
type PRFinder interface {
Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error)
}
type progressIndicator interface {
StartProgressIndicator()
StopProgressIndicator()
}
type finder struct {
baseRepoFn func() (ghrepo.Interface, error)
branchFn func() (string, error)
remotesFn func() (remotes.Remotes, error)
httpClient func() (*http.Client, error)
branchConfig func(string) git.BranchConfig
progress progressIndicator
repo ghrepo.Interface
prNumber int
branchName string
}
func NewFinder(factory *cmdutil.Factory) PRFinder {
if runCommandFinder != nil {
f := runCommandFinder
runCommandFinder = &mockFinder{err: errors.New("you must use a RunCommandFinder to stub PR lookups")}
return f
}
return &finder{
baseRepoFn: factory.BaseRepo,
branchFn: factory.Branch,
remotesFn: factory.Remotes,
httpClient: factory.HttpClient,
progress: factory.IOStreams,
branchConfig: git.ReadBranchConfig,
}
}
var runCommandFinder PRFinder
// RunCommandFinder is the NewMockFinder substitute to be used ONLY in runCommand-style tests.
func RunCommandFinder(selector string, pr *api.PullRequest, repo ghrepo.Interface) *mockFinder {
finder := NewMockFinder(selector, pr, repo)
runCommandFinder = finder
return finder
}
type FindOptions struct {
// Selector can be a number with optional `#` prefix, a branch name with optional `<owner>:` prefix, or
// a PR URL.
Selector string
// Fields lists the GraphQL fields to fetch for the PullRequest.
Fields []string
// BaseBranch is the name of the base branch to scope the PR-for-branch lookup to.
BaseBranch string
// States lists the possible PR states to scope the PR-for-branch lookup to.
States []string
}
func (f *finder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error) {
if len(opts.Fields) == 0 {
return nil, nil, errors.New("Find error: no fields specified")
}
if repo, prNumber, err := f.parseURL(opts.Selector); err == nil {
f.prNumber = prNumber
f.repo = repo
}
if f.repo == nil {
repo, err := f.baseRepoFn()
if err != nil {
return nil, nil, fmt.Errorf("could not determine base repo: %w", err)
}
f.repo = repo
}
if opts.Selector == "" {
if branch, prNumber, err := f.parseCurrentBranch(); err != nil {
return nil, nil, err
} else if prNumber > 0 {
f.prNumber = prNumber
} else {
f.branchName = branch
}
} else if f.prNumber == 0 {
if prNumber, err := strconv.Atoi(strings.TrimPrefix(opts.Selector, "#")); err == nil {
f.prNumber = prNumber
} else {
f.branchName = opts.Selector
}
}
httpClient, err := f.httpClient()
if err != nil {
return nil, nil, err
}
if f.progress != nil {
f.progress.StartProgressIndicator()
defer f.progress.StopProgressIndicator()
}
fields := set.NewStringSet()
fields.AddValues(opts.Fields)
numberFieldOnly := fields.Len() == 1 && fields.Contains("number")
fields.Add("id") // for additional preload queries below
var pr *api.PullRequest
if f.prNumber > 0 {
if numberFieldOnly {
// avoid hitting the API if we already have all the information
return &api.PullRequest{Number: f.prNumber}, f.repo, nil
}
pr, err = findByNumber(httpClient, f.repo, f.prNumber, fields.ToSlice())
} else {
pr, err = findForBranch(httpClient, f.repo, opts.BaseBranch, f.branchName, opts.States, fields.ToSlice())
}
if err != nil {
return pr, f.repo, err
}
g, _ := errgroup.WithContext(context.Background())
if fields.Contains("reviews") {
g.Go(func() error {
return preloadPrReviews(httpClient, f.repo, pr)
})
}
if fields.Contains("comments") {
g.Go(func() error {
return preloadPrComments(httpClient, f.repo, pr)
})
}
if fields.Contains("statusCheckRollup") {
g.Go(func() error {
return preloadPrChecks(httpClient, f.repo, pr)
})
}
return pr, f.repo, g.Wait()
}
var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)`)
func (f *finder) parseURL(prURL string) (ghrepo.Interface, int, error) {
if prURL == "" {
return nil, 0, fmt.Errorf("invalid URL: %q", prURL)
}
u, err := url.Parse(prURL)
if err != nil {
return nil, 0, err
}
if u.Scheme != "https" && u.Scheme != "http" {
return nil, 0, fmt.Errorf("invalid scheme: %s", u.Scheme)
}
m := pullURLRE.FindStringSubmatch(u.Path)
if m == nil {
return nil, 0, fmt.Errorf("not a pull request URL: %s", prURL)
}
repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname())
prNumber, _ := strconv.Atoi(m[3])
return repo, prNumber, nil
}
var prHeadRE = regexp.MustCompile(`^refs/pull/(\d+)/head$`)
func (f *finder) parseCurrentBranch() (string, int, error) {
prHeadRef, err := f.branchFn()
if err != nil {
return "", 0, err
}
branchConfig := f.branchConfig(prHeadRef)
// the branch is configured to merge a special PR head ref
if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
prNumber, _ := strconv.Atoi(m[1])
return "", prNumber, nil
}
var branchOwner string
if branchConfig.RemoteURL != nil {
// the branch merges from a remote specified by URL
if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil {
branchOwner = r.RepoOwner()
}
} else if branchConfig.RemoteName != "" {
// the branch merges from a remote specified by name
rem, _ := f.remotesFn()
if r, err := rem.FindByName(branchConfig.RemoteName); err == nil {
branchOwner = r.RepoOwner()
}
}
if branchOwner != "" {
if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") {
prHeadRef = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
}
// prepend `OWNER:` if this branch is pushed to a fork
if !strings.EqualFold(branchOwner, f.repo.RepoOwner()) {
prHeadRef = fmt.Sprintf("%s:%s", branchOwner, prHeadRef)
}
}
return prHeadRef, 0, nil
}
func findByNumber(httpClient *http.Client, repo ghrepo.Interface, number int, fields []string) (*api.PullRequest, error) {
type response struct {
Repository struct {
PullRequest api.PullRequest
}
}
query := fmt.Sprintf(`
query PullRequestByNumber($owner: String!, $repo: String!, $pr_number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $pr_number) {%s}
}
}`, api.PullRequestGraphQL(fields))
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"pr_number": number,
}
var resp response
client := api.NewClientFromHTTP(httpClient)
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return nil, err
}
return &resp.Repository.PullRequest, nil
}
func findForBranch(httpClient *http.Client, repo ghrepo.Interface, baseBranch, headBranch string, stateFilters, fields []string) (*api.PullRequest, error) {
type response struct {
Repository struct {
PullRequests struct {
Nodes []api.PullRequest
}
}
}
fieldSet := set.NewStringSet()
fieldSet.AddValues(fields)
// these fields are required for filtering below
fieldSet.AddValues([]string{"state", "baseRefName", "headRefName", "isCrossRepository", "headRepositoryOwner"})
query := fmt.Sprintf(`
query PullRequestForBranch($owner: String!, $repo: String!, $headRefName: String!, $states: [PullRequestState!]) {
repository(owner: $owner, name: $repo) {
pullRequests(headRefName: $headRefName, states: $states, first: 30, orderBy: { field: CREATED_AT, direction: DESC }) {
nodes {%s}
}
}
}`, api.PullRequestGraphQL(fieldSet.ToSlice()))
branchWithoutOwner := headBranch
if idx := strings.Index(headBranch, ":"); idx >= 0 {
branchWithoutOwner = headBranch[idx+1:]
}
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"repo": repo.RepoName(),
"headRefName": branchWithoutOwner,
"states": stateFilters,
}
var resp response
client := api.NewClientFromHTTP(httpClient)
err := client.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return nil, err
}
prs := resp.Repository.PullRequests.Nodes
sort.SliceStable(prs, func(a, b int) bool {
return prs[a].State == "OPEN" && prs[b].State != "OPEN"
})
for _, pr := range prs {
if pr.HeadLabel() == headBranch && (baseBranch == "" || pr.BaseRefName == baseBranch) {
return &pr, nil
}
}
return nil, &NotFoundError{fmt.Errorf("no pull requests found for branch %q", headBranch)}
}
func preloadPrReviews(httpClient *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error {
if !pr.Reviews.PageInfo.HasNextPage {
return nil
}
type response struct {
Node struct {
PullRequest struct {
Reviews api.PullRequestReviews `graphql:"reviews(first: 100, after: $endCursor)"`
} `graphql:"...on PullRequest"`
} `graphql:"node(id: $id)"`
}
variables := map[string]interface{}{
"id": githubv4.ID(pr.ID),
"endCursor": githubv4.String(pr.Reviews.PageInfo.EndCursor),
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), httpClient)
for {
var query response
err := gql.QueryNamed(context.Background(), "ReviewsForPullRequest", &query, variables)
if err != nil {
return err
}
pr.Reviews.Nodes = append(pr.Reviews.Nodes, query.Node.PullRequest.Reviews.Nodes...)
pr.Reviews.TotalCount = len(pr.Reviews.Nodes)
if !query.Node.PullRequest.Reviews.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Node.PullRequest.Reviews.PageInfo.EndCursor)
}
pr.Reviews.PageInfo.HasNextPage = false
return nil
}
func preloadPrComments(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error {
if !pr.Comments.PageInfo.HasNextPage {
return nil
}
type response struct {
Node struct {
PullRequest struct {
Comments api.Comments `graphql:"comments(first: 100, after: $endCursor)"`
} `graphql:"...on PullRequest"`
} `graphql:"node(id: $id)"`
}
variables := map[string]interface{}{
"id": githubv4.ID(pr.ID),
"endCursor": githubv4.String(pr.Comments.PageInfo.EndCursor),
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(repo.RepoHost()), client)
for {
var query response
err := gql.QueryNamed(context.Background(), "CommentsForPullRequest", &query, variables)
if err != nil {
return err
}
pr.Comments.Nodes = append(pr.Comments.Nodes, query.Node.PullRequest.Comments.Nodes...)
pr.Comments.TotalCount = len(pr.Comments.Nodes)
if !query.Node.PullRequest.Comments.PageInfo.HasNextPage {
break
}
variables["endCursor"] = githubv4.String(query.Node.PullRequest.Comments.PageInfo.EndCursor)
}
pr.Comments.PageInfo.HasNextPage = false
return nil
}
func preloadPrChecks(client *http.Client, repo ghrepo.Interface, pr *api.PullRequest) error {
if len(pr.StatusCheckRollup.Nodes) == 0 {
return nil
}
statusCheckRollup := &pr.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts
if !statusCheckRollup.PageInfo.HasNextPage {
return nil
}
endCursor := statusCheckRollup.PageInfo.EndCursor
type response struct {
Node *api.PullRequest
}
query := fmt.Sprintf(`
query PullRequestStatusChecks($id: ID!, $endCursor: String!) {
node(id: $id) {
...on PullRequest {
%s
}
}
}`, api.StatusCheckRollupGraphQL("$endCursor"))
variables := map[string]interface{}{
"id": pr.ID,
}
apiClient := api.NewClientFromHTTP(client)
for {
variables["endCursor"] = endCursor
var resp response
err := apiClient.GraphQL(repo.RepoHost(), query, variables, &resp)
if err != nil {
return err
}
result := resp.Node.StatusCheckRollup.Nodes[0].Commit.StatusCheckRollup.Contexts
statusCheckRollup.Nodes = append(
statusCheckRollup.Nodes,
result.Nodes...,
)
if !result.PageInfo.HasNextPage {
break
}
endCursor = result.PageInfo.EndCursor
}
statusCheckRollup.PageInfo.HasNextPage = false
return nil
}
type NotFoundError struct {
error
}
func (err *NotFoundError) Unwrap() error {
return err.error
}
func NewMockFinder(selector string, pr *api.PullRequest, repo ghrepo.Interface) *mockFinder {
var err error
if pr == nil {
err = &NotFoundError{errors.New("no pull requests found")}
}
return &mockFinder{
expectSelector: selector,
pr: pr,
repo: repo,
err: err,
}
}
type mockFinder struct {
called bool
expectSelector string
expectFields []string
pr *api.PullRequest
repo ghrepo.Interface
err error
}
func (m *mockFinder) Find(opts FindOptions) (*api.PullRequest, ghrepo.Interface, error) {
if m.err != nil {
return nil, nil, m.err
}
if m.expectSelector != opts.Selector {
return nil, nil, fmt.Errorf("mockFinder: expected selector %q, got %q", m.expectSelector, opts.Selector)
}
if len(m.expectFields) > 0 && !isEqualSet(m.expectFields, opts.Fields) {
return nil, nil, fmt.Errorf("mockFinder: expected fields %v, got %v", m.expectFields, opts.Fields)
}
if m.called {
return nil, nil, errors.New("mockFinder used more than once")
}
m.called = true
if m.pr.HeadRepositoryOwner.Login == "" {
// pose as same-repo PR by default
m.pr.HeadRepositoryOwner.Login = m.repo.RepoOwner()
}
return m.pr, m.repo, nil
}
func (m *mockFinder) ExpectFields(fields []string) {
m.expectFields = fields
}
func isEqualSet(a, b []string) bool {
if len(a) != len(b) {
return false
}
aCopy := make([]string, len(a))
copy(aCopy, a)
bCopy := make([]string, len(b))
copy(bCopy, b)
sort.Strings(aCopy)
sort.Strings(bCopy)
for i := range aCopy {
if aCopy[i] != bCopy[i] {
return false
}
}
return true
}

View file

@ -0,0 +1,394 @@
package shared
import (
"errors"
"net/http"
"net/url"
"testing"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/httpmock"
)
func TestFind(t *testing.T) {
type args struct {
baseRepoFn func() (ghrepo.Interface, error)
branchFn func() (string, error)
branchConfig func(string) git.BranchConfig
remotesFn func() (context.Remotes, error)
selector string
fields []string
baseBranch string
}
tests := []struct {
name string
args args
httpStub func(*httpmock.Registry)
wantPR int
wantRepo string
wantErr bool
}{
{
name: "number argument",
args: args{
selector: "13",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequest":{"number":13}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "baseRepo is error",
args: args{
selector: "13",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return nil, errors.New("baseRepoErr")
},
},
wantErr: true,
},
{
name: "blank fields is error",
args: args{
selector: "13",
fields: []string{},
},
wantErr: true,
},
{
name: "number only",
args: args{
selector: "13",
fields: []string{"number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
},
httpStub: nil,
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "number with hash argument",
args: args{
selector: "#13",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequest":{"number":13}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "URL argument",
args: args{
selector: "https://example.org/OWNER/REPO/pull/13/files",
fields: []string{"id", "number"},
baseRepoFn: nil,
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequest":{"number":13}
}}}`))
},
wantPR: 13,
wantRepo: "https://example.org/OWNER/REPO",
},
{
name: "branch argument",
args: args{
selector: "blueberries",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequests":{"nodes":[
{
"number": 14,
"state": "CLOSED",
"baseRefName": "main",
"headRefName": "blueberries",
"isCrossRepository": false,
"headRepositoryOwner": {"login":"OWNER"}
},
{
"number": 13,
"state": "OPEN",
"baseRefName": "main",
"headRefName": "blueberries",
"isCrossRepository": false,
"headRepositoryOwner": {"login":"OWNER"}
}
]}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "branch argument with base branch",
args: args{
selector: "blueberries",
baseBranch: "main",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequests":{"nodes":[
{
"number": 14,
"state": "OPEN",
"baseRefName": "dev",
"headRefName": "blueberries",
"isCrossRepository": false,
"headRepositoryOwner": {"login":"OWNER"}
},
{
"number": 13,
"state": "OPEN",
"baseRefName": "main",
"headRefName": "blueberries",
"isCrossRepository": false,
"headRepositoryOwner": {"login":"OWNER"}
}
]}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "no argument reads current branch",
args: args{
selector: "",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: func(branch string) (c git.BranchConfig) {
return
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequests":{"nodes":[
{
"number": 13,
"state": "OPEN",
"baseRefName": "main",
"headRefName": "blueberries",
"isCrossRepository": false,
"headRepositoryOwner": {"login":"OWNER"}
}
]}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "current branch is error",
args: args{
selector: "",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
branchFn: func() (string, error) {
return "", errors.New("branchErr")
},
},
wantErr: true,
},
{
name: "current branch with upstream configuration",
args: args{
selector: "",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: func(branch string) (c git.BranchConfig) {
c.MergeRef = "refs/heads/blue-upstream-berries"
c.RemoteName = "origin"
return
},
remotesFn: func() (context.Remotes, error) {
return context.Remotes{{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("UPSTREAMOWNER", "REPO"),
}}, nil
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequests":{"nodes":[
{
"number": 13,
"state": "OPEN",
"baseRefName": "main",
"headRefName": "blue-upstream-berries",
"isCrossRepository": true,
"headRepositoryOwner": {"login":"UPSTREAMOWNER"}
}
]}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "current branch with upstream configuration",
args: args{
selector: "",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: func(branch string) (c git.BranchConfig) {
u, _ := url.Parse("https://github.com/UPSTREAMOWNER/REPO")
c.MergeRef = "refs/heads/blue-upstream-berries"
c.RemoteURL = u
return
},
remotesFn: nil,
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestForBranch\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequests":{"nodes":[
{
"number": 13,
"state": "OPEN",
"baseRefName": "main",
"headRefName": "blue-upstream-berries",
"isCrossRepository": true,
"headRepositoryOwner": {"login":"UPSTREAMOWNER"}
}
]}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "current branch made by pr checkout",
args: args{
selector: "",
fields: []string{"id", "number"},
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
branchFn: func() (string, error) {
return "blueberries", nil
},
branchConfig: func(branch string) (c git.BranchConfig) {
c.MergeRef = "refs/pull/13/head"
return
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequest":{"number":13}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
defer reg.Verify(t)
if tt.httpStub != nil {
tt.httpStub(reg)
}
f := finder{
httpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
baseRepoFn: tt.args.baseRepoFn,
branchFn: tt.args.branchFn,
branchConfig: tt.args.branchConfig,
remotesFn: tt.args.remotesFn,
}
pr, repo, err := f.Find(FindOptions{
Selector: tt.args.selector,
Fields: tt.args.fields,
BaseBranch: tt.args.baseBranch,
})
if (err != nil) != tt.wantErr {
t.Errorf("Find() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantErr {
if tt.wantPR > 0 {
t.Error("wantPR field is not checked in error case")
}
if tt.wantRepo != "" {
t.Error("wantRepo field is not checked in error case")
}
return
}
if pr.Number != tt.wantPR {
t.Errorf("want pr #%d, got #%d", tt.wantPR, pr.Number)
}
repoURL := ghrepo.GenerateRepoURL(repo, "")
if repoURL != tt.wantRepo {
t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL)
}
})
}
}

View file

@ -1,121 +0,0 @@
package shared
import (
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
)
// PRFromArgs looks up the pull request from either the number/branch/URL argument or one belonging to the current branch
//
// NOTE: this API isn't great, but is here as a compatibility layer between old-style and new-style commands
func PRFromArgs(apiClient *api.Client, baseRepoFn func() (ghrepo.Interface, error), branchFn func() (string, error), remotesFn func() (context.Remotes, error), arg string) (*api.PullRequest, ghrepo.Interface, error) {
if arg != "" {
// First check to see if the prString is a url, return repo from url if found. This
// is run first because we don't need to run determineBaseRepo for this path
pr, r, err := prFromURL(apiClient, arg)
if pr != nil || err != nil {
return pr, r, err
}
}
repo, err := baseRepoFn()
if err != nil {
return nil, nil, fmt.Errorf("could not determine base repo: %w", err)
}
// If there are no args see if we can guess the PR from the current branch
if arg == "" {
pr, err := prForCurrentBranch(apiClient, repo, branchFn, remotesFn)
return pr, repo, err
} else {
// Next see if the prString is a number and use that to look up the url
pr, err := prFromNumberString(apiClient, repo, arg)
if pr != nil || err != nil {
return pr, repo, err
}
// Last see if it is a branch name
pr, err = api.PullRequestForBranch(apiClient, repo, "", arg, nil)
return pr, repo, err
}
}
func prFromNumberString(apiClient *api.Client, repo ghrepo.Interface, s string) (*api.PullRequest, error) {
if prNumber, err := strconv.Atoi(strings.TrimPrefix(s, "#")); err == nil {
return api.PullRequestByNumber(apiClient, repo, prNumber)
}
return nil, nil
}
var pullURLRE = regexp.MustCompile(`^/([^/]+)/([^/]+)/pull/(\d+)`)
func prFromURL(apiClient *api.Client, s string) (*api.PullRequest, ghrepo.Interface, error) {
u, err := url.Parse(s)
if err != nil {
return nil, nil, nil
}
if u.Scheme != "https" && u.Scheme != "http" {
return nil, nil, nil
}
m := pullURLRE.FindStringSubmatch(u.Path)
if m == nil {
return nil, nil, nil
}
repo := ghrepo.NewWithHost(m[1], m[2], u.Hostname())
prNumberString := m[3]
pr, err := prFromNumberString(apiClient, repo, prNumberString)
return pr, repo, err
}
func prForCurrentBranch(apiClient *api.Client, repo ghrepo.Interface, branchFn func() (string, error), remotesFn func() (context.Remotes, error)) (*api.PullRequest, error) {
prHeadRef, err := branchFn()
if err != nil {
return nil, err
}
branchConfig := git.ReadBranchConfig(prHeadRef)
// the branch is configured to merge a special PR head ref
prHeadRE := regexp.MustCompile(`^refs/pull/(\d+)/head$`)
if m := prHeadRE.FindStringSubmatch(branchConfig.MergeRef); m != nil {
return prFromNumberString(apiClient, repo, m[1])
}
var branchOwner string
if branchConfig.RemoteURL != nil {
// the branch merges from a remote specified by URL
if r, err := ghrepo.FromURL(branchConfig.RemoteURL); err == nil {
branchOwner = r.RepoOwner()
}
} else if branchConfig.RemoteName != "" {
// the branch merges from a remote specified by name
rem, _ := remotesFn()
if r, err := rem.FindByName(branchConfig.RemoteName); err == nil {
branchOwner = r.RepoOwner()
}
}
if branchOwner != "" {
if strings.HasPrefix(branchConfig.MergeRef, "refs/heads/") {
prHeadRef = strings.TrimPrefix(branchConfig.MergeRef, "refs/heads/")
}
// prepend `OWNER:` if this branch is pushed to a fork
if !strings.EqualFold(branchOwner, repo.RepoOwner()) {
prHeadRef = fmt.Sprintf("%s:%s", branchOwner, prHeadRef)
}
}
return api.PullRequestForBranch(apiClient, repo, "", prHeadRef, nil)
}

View file

@ -1,108 +0,0 @@
package shared
import (
"net/http"
"testing"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/httpmock"
)
func TestPRFromArgs(t *testing.T) {
type args struct {
baseRepoFn func() (ghrepo.Interface, error)
branchFn func() (string, error)
remotesFn func() (context.Remotes, error)
selector string
}
tests := []struct {
name string
args args
httpStub func(*httpmock.Registry)
wantPR int
wantRepo string
wantErr bool
}{
{
name: "number argument",
args: args{
selector: "13",
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequest":{"number":13}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "number with hash argument",
args: args{
selector: "#13",
baseRepoFn: func() (ghrepo.Interface, error) {
return ghrepo.FromFullName("OWNER/REPO")
},
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequest":{"number":13}
}}}`))
},
wantPR: 13,
wantRepo: "https://github.com/OWNER/REPO",
},
{
name: "URL argument",
args: args{
selector: "https://example.org/OWNER/REPO/pull/13/files",
baseRepoFn: nil,
},
httpStub: func(r *httpmock.Registry) {
r.Register(
httpmock.GraphQL(`query PullRequest_fields\b`),
httpmock.StringResponse(`{"data":{}}`))
r.Register(
httpmock.GraphQL(`query PullRequest_fields2\b`),
httpmock.StringResponse(`{"data":{}}`))
r.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`{"data":{"repository":{
"pullRequest":{"number":13}
}}}`))
},
wantPR: 13,
wantRepo: "https://example.org/OWNER/REPO",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reg := &httpmock.Registry{}
if tt.httpStub != nil {
tt.httpStub(reg)
}
httpClient := &http.Client{Transport: reg}
pr, repo, err := PRFromArgs(api.NewClientFromHTTP(httpClient), tt.args.baseRepoFn, tt.args.branchFn, tt.args.remotesFn, tt.args.selector)
if (err != nil) != tt.wantErr {
t.Errorf("IssueFromArg() error = %v, wantErr %v", err, tt.wantErr)
return
}
if pr.Number != tt.wantPR {
t.Errorf("want issue #%d, got #%d", tt.wantPR, pr.Number)
}
repoURL := ghrepo.GenerateRepoURL(repo, "")
if repoURL != tt.wantRepo {
t.Errorf("want repo %s, got %s", tt.wantRepo, repoURL)
}
})
}
}

View file

@ -4,7 +4,6 @@ import (
"encoding/json"
"errors"
"io/ioutil"
"os"
"testing"
"github.com/cli/cli/pkg/iostreams"
@ -70,6 +69,8 @@ func Test_PreserveInput(t *testing.T) {
},
}
tempDir := t.TempDir()
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if tt.state == nil {
@ -78,9 +79,9 @@ func Test_PreserveInput(t *testing.T) {
io, _, _, errOut := iostreams.Test()
tf, tferr := tmpfile()
tf, tferr := ioutil.TempFile(tempDir, "testfile*")
assert.NoError(t, tferr)
defer os.Remove(tf.Name())
defer tf.Close()
io.TempFileOverride = tf
@ -111,13 +112,3 @@ func Test_PreserveInput(t *testing.T) {
})
}
}
func tmpfile() (*os.File, error) {
dir := os.TempDir()
tmpfile, err := ioutil.TempFile(dir, "testfile*")
if err != nil {
return nil, err
}
return tmpfile, nil
}

View file

@ -17,7 +17,7 @@
"url": "https://github.com/cli/cli/pull/8",
"headRefName": "strawberries",
"reviewDecision": "CHANGES_REQUESTED",
"commits": {
"statusCheckRollup": {
"nodes": [
{
"commit": {
@ -44,7 +44,7 @@
"url": "https://github.com/cli/cli/pull/7",
"headRefName": "banananana",
"reviewDecision": "APPROVED",
"commits": {
"statusCheckRollup": {
"nodes": [
{
"commit": {
@ -72,7 +72,7 @@
"url": "https://github.com/cli/cli/pull/6",
"headRefName": "avo",
"reviewDecision": "REVIEW_REQUIRED",
"commits": {
"statusCheckRollup": {
"nodes": [
{
"commit": {

View file

@ -113,13 +113,13 @@ func statusRun(opts *StatusOptions) error {
if opts.Exporter != nil {
data := map[string]interface{}{
"currentBranch": nil,
"createdBy": api.ExportPRs(prPayload.ViewerCreated.PullRequests, opts.Exporter.Fields()),
"needsReview": api.ExportPRs(prPayload.ReviewRequested.PullRequests, opts.Exporter.Fields()),
"createdBy": prPayload.ViewerCreated.PullRequests,
"needsReview": prPayload.ReviewRequested.PullRequests,
}
if prPayload.CurrentPR != nil {
data["currentBranch"] = prPayload.CurrentPR.ExportData(opts.Exporter.Fields())
data["currentBranch"] = prPayload.CurrentPR
}
return opts.Exporter.Write(opts.IO.Out, &data, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, data, opts.IO.ColorEnabled())
}
out := opts.IO.Out

View file

@ -1,54 +0,0 @@
{
"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"
},
"additions": 100,
"deletions": 10,
"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"
},
"additions": 100,
"deletions": 10,
"headRepositoryOwner": {
"login": "OWNER"
},
"commits": {
"totalCount": 8
},
"isCrossRepository": false,
"isDraft": false
}
]
}
}
}
}

View file

@ -1,54 +0,0 @@
{
"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"
},
"additions": 100,
"deletions": 10,
"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"
},
"additions": 100,
"deletions": 10,
"commits": {
"totalCount": 8
},
"isCrossRepository": false,
"isDraft": true
}
]
}
}
}
}

View file

@ -1 +0,0 @@
{ "data": { "repository": { "pullRequest": { "reviews": { } } } } }

View file

@ -1,139 +0,0 @@
{
"data": {
"repository": {
"pullRequests": {
"nodes": [
{
"id": "PR_12",
"number": 12,
"title": "Blueberries are from a fork",
"state": "OPEN",
"body": "yeah",
"url": "https://github.com/OWNER/REPO/pull/12",
"headRefName": "blueberries",
"baseRefName": "master",
"additions": 100,
"deletions": 10,
"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
},
{
"id": "PR_10",
"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"
},
"additions": 100,
"deletions": 10,
"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": {
"nodes": [
{
"commit": {
"oid": "123456789"
}
}
],
"totalCount": 8
},
"isCrossRepository": false,
"isDraft": false
}
]
}
}
}
}

View file

@ -1,52 +0,0 @@
{
"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"
},
"additions": 100,
"deletions": 10,
"commits": {
"totalCount": 12
},
"author": {
"login": "nobody"
},
"isCrossRepository": true
},
{
"number": 10,
"title": "Blueberries are a good fruit",
"state": "OPEN",
"body": "",
"url": "https://github.com/OWNER/REPO/pull/10",
"baseRefName": "master",
"headRefName": "blueberries",
"additions": 100,
"deletions": 10,
"author": {
"login": "nobody"
},
"headRepositoryOwner": {
"login": "OWNER"
},
"commits": {
"totalCount": 8
},
"isCrossRepository": false
}
]
}
}
}
}

View file

@ -1,15 +0,0 @@
{"data":{
"repository": {
"pullRequests": {
"edges": []
}
},
"viewerCreated": {
"edges": [],
"pageInfo": { "hasNextPage": false }
},
"reviewRequested": {
"edges": [],
"pageInfo": { "hasNextPage": false }
}
}}

View file

@ -3,17 +3,12 @@ package view
import (
"errors"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"sync"
"github.com/MakeNowJust/heredoc"
"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/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -27,14 +22,10 @@ type browser interface {
}
type ViewOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
Browser browser
BaseRepo func() (ghrepo.Interface, error)
Remotes func() (context.Remotes, error)
Branch func() (string, error)
IO *iostreams.IOStreams
Browser browser
Finder shared.PRFinder
Exporter cmdutil.Exporter
SelectorArg string
@ -44,12 +35,8 @@ type ViewOptions struct {
func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Command {
opts := &ViewOptions{
IO: f.IOStreams,
HttpClient: f.HttpClient,
Config: f.Config,
Remotes: f.Remotes,
Branch: f.Branch,
Browser: f.Browser,
IO: f.IOStreams,
Browser: f.Browser,
}
cmd := &cobra.Command{
@ -65,8 +52,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
`),
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
// support `-R, --repo` override
opts.BaseRepo = f.BaseRepo
opts.Finder = shared.NewFinder(f)
if repoOverride, _ := cmd.Flags().GetString("repo"); repoOverride != "" && len(args) == 0 {
return &cmdutil.FlagError{Err: errors.New("argument required when using the --repo flag")}
@ -90,10 +76,25 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
return cmd
}
var defaultFields = []string{
"url", "number", "title", "state", "body", "author",
"isDraft", "maintainerCanModify", "mergeable", "additions", "deletions", "commitsCount",
"baseRefName", "headRefName", "headRepositoryOwner", "headRepository", "isCrossRepository",
"reviewRequests", "reviews", "assignees", "labels", "projectCards", "milestone",
"comments", "reactionGroups",
}
func viewRun(opts *ViewOptions) error {
opts.IO.StartProgressIndicator()
pr, err := retrievePullRequest(opts)
opts.IO.StopProgressIndicator()
findOptions := shared.FindOptions{
Selector: opts.SelectorArg,
Fields: defaultFields,
}
if opts.BrowserMode {
findOptions.Fields = []string{"url"}
} else if opts.Exporter != nil {
findOptions.Fields = opts.Exporter.Fields()
}
pr, _, err := opts.Finder.Find(findOptions)
if err != nil {
return err
}
@ -117,8 +118,7 @@ func viewRun(opts *ViewOptions) error {
defer opts.IO.StopPager()
if opts.Exporter != nil {
exportPR := pr.ExportData(opts.Exporter.Fields())
return opts.Exporter.Write(opts.IO.Out, exportPR, opts.IO.ColorEnabled())
return opts.Exporter.Write(opts.IO.Out, pr, opts.IO.ColorEnabled())
}
if connectedToTerminal {
@ -149,7 +149,11 @@ func printRawPrPreview(io *iostreams.IOStreams, pr *api.PullRequest) error {
fmt.Fprintf(out, "assignees:\t%s\n", assignees)
fmt.Fprintf(out, "reviewers:\t%s\n", reviewers)
fmt.Fprintf(out, "projects:\t%s\n", projects)
fmt.Fprintf(out, "milestone:\t%s\n", pr.Milestone.Title)
var milestoneTitle string
if pr.Milestone != nil {
milestoneTitle = pr.Milestone.Title
}
fmt.Fprintf(out, "milestone:\t%s\n", milestoneTitle)
fmt.Fprintf(out, "number:\t%d\n", pr.Number)
fmt.Fprintf(out, "url:\t%s\n", pr.URL)
fmt.Fprintf(out, "additions:\t%s\n", cs.Green(strconv.Itoa(pr.Additions)))
@ -201,7 +205,7 @@ func printHumanPrPreview(opts *ViewOptions, pr *api.PullRequest) error {
fmt.Fprint(out, cs.Bold("Projects: "))
fmt.Fprintln(out, projects)
}
if pr.Milestone.Title != "" {
if pr.Milestone != nil {
fmt.Fprint(out, cs.Bold("Milestone: "))
fmt.Fprintln(out, pr.Milestone.Title)
}
@ -413,51 +417,3 @@ func prStateWithDraft(pr *api.PullRequest) string {
return pr.State
}
func retrievePullRequest(opts *ViewOptions) (*api.PullRequest, error) {
httpClient, err := opts.HttpClient()
if err != nil {
return nil, err
}
apiClient := api.NewClientFromHTTP(httpClient)
pr, repo, err := shared.PRFromArgs(apiClient, opts.BaseRepo, opts.Branch, opts.Remotes, opts.SelectorArg)
if err != nil {
return nil, err
}
if opts.BrowserMode {
return pr, nil
}
var errp, errc error
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
var reviews *api.PullRequestReviews
reviews, errp = api.ReviewsForPullRequest(apiClient, repo, pr)
pr.Reviews = *reviews
}()
if opts.Comments {
wg.Add(1)
go func() {
defer wg.Done()
var comments *api.Comments
comments, errc = api.CommentsForPullRequest(apiClient, repo, pr)
pr.Comments = *comments
}()
}
wg.Wait()
if errp != nil {
err = errp
}
if errc != nil {
err = errc
}
return pr, err
}

View file

@ -2,16 +2,17 @@ package view
import (
"bytes"
"encoding/json"
"fmt"
"io/ioutil"
"net/http"
"os"
"testing"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmd/pr/shared"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/pkg/iostreams"
@ -121,26 +122,6 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t
factory := &cmdutil.Factory{
IOStreams: io,
Browser: browser,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: rt}, nil
},
Config: func() (config.Config, error) {
return config.NewBlankConfig(), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Remotes: func() (context.Remotes, error) {
return context.Remotes{
{
Remote: &git.Remote{Name: "origin"},
Repo: ghrepo.New("OWNER", "REPO"),
},
}, nil
},
Branch: func() (string, error) {
return branch, nil
},
}
cmd := NewCmdView(factory, nil)
@ -163,6 +144,50 @@ func runCommand(rt http.RoundTripper, branch string, isTTY bool, cli string) (*t
}, err
}
// hack for compatibility with old JSON fixture files
func prFromFixtures(fixtures map[string]string) (*api.PullRequest, error) {
var response struct {
Data struct {
Repository struct {
PullRequest *api.PullRequest
}
}
}
ff, err := os.Open(fixtures["PullRequestByNumber"])
if err != nil {
return nil, err
}
defer ff.Close()
dec := json.NewDecoder(ff)
err = dec.Decode(&response)
if err != nil {
return nil, err
}
for name := range fixtures {
switch name {
case "PullRequestByNumber":
case "ReviewsForPullRequest", "CommentsForPullRequest":
ff, err := os.Open(fixtures[name])
if err != nil {
return nil, err
}
defer ff.Close()
dec := json.NewDecoder(ff)
err = dec.Decode(&response)
if err != nil {
return nil, err
}
default:
return nil, fmt.Errorf("unrecognized fixture type: %q", name)
}
}
return response.Data.Repository.PullRequest, nil
}
func TestPRView_Preview_nontty(t *testing.T) {
tests := map[string]struct {
branch string
@ -174,8 +199,7 @@ func TestPRView_Preview_nontty(t *testing.T) {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreview.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
"PullRequestByNumber": "./fixtures/prViewPreview.json",
},
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
@ -197,8 +221,7 @@ func TestPRView_Preview_nontty(t *testing.T) {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
"PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json",
},
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
@ -231,74 +254,11 @@ func TestPRView_Preview_nontty(t *testing.T) {
`\*\*blueberries taste good\*\*`,
},
},
"Open PR with metadata by branch": {
branch: "master",
args: "blueberries",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prViewPreviewWithMetadataByBranch.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are a good fruit`,
`state:\tOPEN`,
`author:\tnobody`,
`assignees:\tmarseilles, monaco\n`,
`reviewers:\t\n`,
`labels:\tone, two, three, four, five\n`,
`projects:\tProject 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`,
`milestone:\tuluru\n`,
`additions:\t100\n`,
`deletions:\t10\n`,
`blueberries taste good`,
},
},
"Open PR for the current branch": {
branch: "blueberries",
args: "",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prView.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are a good fruit`,
`state:\tOPEN`,
`author:\tnobody`,
`assignees:\t\n`,
`reviewers:\t\n`,
`labels:\t\n`,
`projects:\t\n`,
`milestone:\t\n`,
`additions:\t100\n`,
`deletions:\t10\n`,
`\*\*blueberries taste good\*\*`,
},
},
"Open PR wth empty body for the current branch": {
branch: "blueberries",
args: "",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prView_EmptyBody.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are a good fruit`,
`state:\tOPEN`,
`author:\tnobody`,
`assignees:\t\n`,
`reviewers:\t\n`,
`labels:\t\n`,
`projects:\t\n`,
`milestone:\t\n`,
`additions:\t100\n`,
`deletions:\t10\n`,
},
},
"Closed PR": {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
"PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json",
},
expectedOutputs: []string{
`state:\tCLOSED\n`,
@ -317,8 +277,7 @@ func TestPRView_Preview_nontty(t *testing.T) {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
"PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json",
},
expectedOutputs: []string{
`state:\tMERGED\n`,
@ -337,8 +296,7 @@ func TestPRView_Preview_nontty(t *testing.T) {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
"PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json",
},
expectedOutputs: []string{
`title:\tBlueberries are from a fork\n`,
@ -354,37 +312,16 @@ func TestPRView_Preview_nontty(t *testing.T) {
`\*\*blueberries taste good\*\*`,
},
},
"Draft PR by branch": {
branch: "master",
args: "blueberries",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prViewPreviewDraftStatebyBranch.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`title:\tBlueberries are a good fruit\n`,
`state:\tDRAFT\n`,
`author:\tnobody\n`,
`labels:`,
`assignees:`,
`reviewers:`,
`projects:`,
`milestone:`,
`additions:\t100\n`,
`deletions:\t10\n`,
`\*\*blueberries taste good\*\*`,
},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
for name, file := range tc.fixtures {
name := fmt.Sprintf(`query %s\b`, name)
http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
}
pr, err := prFromFixtures(tc.fixtures)
require.NoError(t, err)
shared.RunCommandFinder("12", pr, ghrepo.New("OWNER", "REPO"))
output, err := runCommand(http, tc.branch, false, tc.args)
if err != nil {
@ -410,8 +347,7 @@ func TestPRView_Preview(t *testing.T) {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreview.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
"PullRequestByNumber": "./fixtures/prViewPreview.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
@ -424,8 +360,7 @@ func TestPRView_Preview(t *testing.T) {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
"PullRequestByNumber": "./fixtures/prViewPreviewWithMetadataByNumber.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
@ -453,57 +388,11 @@ func TestPRView_Preview(t *testing.T) {
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
},
},
"Open PR with metadata by branch": {
branch: "master",
args: "blueberries",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prViewPreviewWithMetadataByBranch.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Open.*nobody wants to merge 8 commits into master from blueberries.+100.-10`,
`Assignees:.*marseilles, monaco\n`,
`Labels:.*one, two, three, four, five\n`,
`Projects:.*Project 1 \(column A\), Project 2 \(column B\), Project 3 \(column C\)\n`,
`Milestone:.*uluru\n`,
`blueberries taste good`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`,
},
},
"Open PR for the current branch": {
branch: "blueberries",
args: "",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prView.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Open.*nobody wants to merge 8 commits into master from blueberries.+100.-10`,
`blueberries taste good`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`,
},
},
"Open PR wth empty body for the current branch": {
branch: "blueberries",
args: "",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prView_EmptyBody.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Open.*nobody wants to merge 8 commits into master from blueberries.+100.-10`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`,
},
},
"Closed PR": {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
"PullRequestByNumber": "./fixtures/prViewPreviewClosedState.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
@ -516,8 +405,7 @@ func TestPRView_Preview(t *testing.T) {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
"PullRequestByNumber": "./fixtures/prViewPreviewMergedState.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
@ -530,8 +418,7 @@ func TestPRView_Preview(t *testing.T) {
branch: "master",
args: "12",
fixtures: map[string]string{
"PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
"PullRequestByNumber": "./fixtures/prViewPreviewDraftState.json",
},
expectedOutputs: []string{
`Blueberries are from a fork`,
@ -540,30 +427,16 @@ func TestPRView_Preview(t *testing.T) {
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/12`,
},
},
"Draft PR by branch": {
branch: "master",
args: "blueberries",
fixtures: map[string]string{
"PullRequestForBranch": "./fixtures/prViewPreviewDraftStatebyBranch.json",
"ReviewsForPullRequest": "./fixtures/prViewPreviewNoReviews.json",
},
expectedOutputs: []string{
`Blueberries are a good fruit`,
`Draft.*nobody wants to merge 8 commits into master from blueberries.+100.-10`,
`blueberries taste good`,
`View this pull request on GitHub: https://github.com/OWNER/REPO/pull/10`,
},
},
}
for name, tc := range tests {
t.Run(name, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
for name, file := range tc.fixtures {
name := fmt.Sprintf(`query %s\b`, name)
http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
}
pr, err := prFromFixtures(tc.fixtures)
require.NoError(t, err)
shared.RunCommandFinder("12", pr, ghrepo.New("OWNER", "REPO"))
output, err := runCommand(http, tc.branch, true, tc.args)
if err != nil {
@ -581,13 +454,12 @@ func TestPRView_Preview(t *testing.T) {
func TestPRView_web_currentBranch(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("./fixtures/prView.json"))
cs, cmdTeardown := run.Stub()
shared.RunCommandFinder("", &api.PullRequest{URL: "https://github.com/OWNER/REPO/pull/10"}, ghrepo.New("OWNER", "REPO"))
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
output, err := runCommand(http, "blueberries", true, "-w")
if err != nil {
t.Errorf("error running command `pr view`: %v", err)
@ -601,41 +473,16 @@ func TestPRView_web_currentBranch(t *testing.T) {
func TestPRView_web_noResultsForBranch(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(httpmock.GraphQL(`query PullRequestForBranch\b`), httpmock.FileResponse("./fixtures/prView_NoActiveBranch.json"))
cs, cmdTeardown := run.Stub()
defer cmdTeardown(t)
cs.Register(`git config --get-regexp.+branch\\\.blueberries\\\.`, 0, "")
_, err := runCommand(http, "blueberries", true, "-w")
if err == nil || err.Error() != `no pull requests found for branch "blueberries"` {
t.Errorf("error running command `pr view`: %v", err)
}
}
func TestPRView_web_numberArg(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
http.Register(
httpmock.GraphQL(`query PullRequestByNumber\b`),
httpmock.StringResponse(`
{ "data": { "repository": { "pullRequest": {
"url": "https://github.com/OWNER/REPO/pull/23"
} } } }`),
)
shared.RunCommandFinder("", nil, nil)
_, cmdTeardown := run.Stub()
defer cmdTeardown(t)
output, err := runCommand(http, "master", true, "-w 23")
if err != nil {
_, err := runCommand(http, "blueberries", true, "-w")
if err == nil || err.Error() != `no pull requests found` {
t.Errorf("error running command `pr view`: %v", err)
}
assert.Equal(t, "", output.String())
assert.Equal(t, "https://github.com/OWNER/REPO/pull/23", output.BrowsedURL)
}
func TestPRView_tty_Comments(t *testing.T) {
@ -715,10 +562,15 @@ func TestPRView_tty_Comments(t *testing.T) {
t.Run(name, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
for name, file := range tt.fixtures {
name := fmt.Sprintf(`query %s\b`, name)
http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
if len(tt.fixtures) > 0 {
pr, err := prFromFixtures(tt.fixtures)
require.NoError(t, err)
shared.RunCommandFinder("123", pr, ghrepo.New("OWNER", "REPO"))
} else {
shared.RunCommandFinder("123", nil, nil)
}
output, err := runCommand(http, tt.branch, true, tt.cli)
if tt.wantsErr {
assert.Error(t, err)
@ -821,10 +673,15 @@ func TestPRView_nontty_Comments(t *testing.T) {
t.Run(name, func(t *testing.T) {
http := &httpmock.Registry{}
defer http.Verify(t)
for name, file := range tt.fixtures {
name := fmt.Sprintf(`query %s\b`, name)
http.Register(httpmock.GraphQL(name), httpmock.FileResponse(file))
if len(tt.fixtures) > 0 {
pr, err := prFromFixtures(tt.fixtures)
require.NoError(t, err)
shared.RunCommandFinder("123", pr, ghrepo.New("OWNER", "REPO"))
} else {
shared.RunCommandFinder("123", nil, nil)
}
output, err := runCommand(http, tt.branch, false, tt.cli)
if tt.wantsErr {
assert.Error(t, err)

View file

@ -313,7 +313,7 @@ func createRun(opts *CreateOptions) error {
}
}
fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.HTMLURL)
fmt.Fprintf(opts.IO.Out, "%s\n", newRelease.URL)
return nil
}

View file

@ -6,6 +6,8 @@ import (
"fmt"
"io/ioutil"
"net/http"
"reflect"
"strings"
"time"
"github.com/cli/cli/api"
@ -13,30 +15,106 @@ import (
"github.com/cli/cli/internal/ghrepo"
)
type Release struct {
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
IsDraft bool `json:"draft"`
IsPrerelease bool `json:"prerelease"`
CreatedAt time.Time `json:"created_at"`
PublishedAt time.Time `json:"published_at"`
var ReleaseFields = []string{
"url",
"apiUrl",
"uploadUrl",
"tarballUrl",
"zipballUrl",
"id",
"tagName",
"name",
"body",
"isDraft",
"isPrerelease",
"createdAt",
"publishedAt",
"targetCommitish",
"author",
"assets",
}
APIURL string `json:"url"`
UploadURL string `json:"upload_url"`
HTMLURL string `json:"html_url"`
Assets []ReleaseAsset
type Release struct {
ID string `json:"node_id"`
TagName string `json:"tag_name"`
Name string `json:"name"`
Body string `json:"body"`
IsDraft bool `json:"draft"`
IsPrerelease bool `json:"prerelease"`
CreatedAt time.Time `json:"created_at"`
PublishedAt *time.Time `json:"published_at"`
TargetCommitish string `json:"target_commitish"`
APIURL string `json:"url"`
UploadURL string `json:"upload_url"`
TarballURL string `json:"tarball_url"`
ZipballURL string `json:"zipball_url"`
URL string `json:"html_url"`
Assets []ReleaseAsset
Author struct {
Login string
ID string `json:"node_id"`
Login string `json:"login"`
}
}
type ReleaseAsset struct {
ID string `json:"node_id"`
Name string
Label string
Size int64
State string
APIURL string `json:"url"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
DownloadCount int `json:"download_count"`
ContentType string `json:"content_type"`
BrowserDownloadURL string `json:"browser_download_url"`
}
func (rel *Release) ExportData(fields []string) *map[string]interface{} {
v := reflect.ValueOf(rel).Elem()
fieldByName := func(v reflect.Value, field string) reflect.Value {
return v.FieldByNameFunc(func(s string) bool {
return strings.EqualFold(field, s)
})
}
data := map[string]interface{}{}
for _, f := range fields {
switch f {
case "author":
data[f] = map[string]interface{}{
"id": rel.Author.ID,
"login": rel.Author.Login,
}
case "assets":
assets := make([]interface{}, 0, len(rel.Assets))
for _, a := range rel.Assets {
assets = append(assets, map[string]interface{}{
"url": a.BrowserDownloadURL,
"apiUrl": a.APIURL,
"id": a.ID,
"name": a.Name,
"label": a.Label,
"size": a.Size,
"state": a.State,
"createdAt": a.CreatedAt,
"updatedAt": a.UpdatedAt,
"downloadCount": a.DownloadCount,
"contentType": a.ContentType,
})
}
data[f] = assets
default:
sf := fieldByName(v, f)
data[f] = sf.Interface()
}
}
return &data
}
// FetchRelease finds a repository release by its tagName.
@ -55,11 +133,7 @@ func FetchRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagName st
defer resp.Body.Close()
if resp.StatusCode == 404 {
if canPush, err := api.CanPushToRepo(httpClient, baseRepo); err == nil && canPush {
return FindDraftRelease(httpClient, baseRepo, tagName)
} else if err != nil {
return nil, err
}
return FindDraftRelease(httpClient, baseRepo, tagName)
}
if resp.StatusCode > 299 {
@ -152,11 +226,8 @@ func FindDraftRelease(httpClient *http.Client, baseRepo ghrepo.Interface, tagNam
return &r, nil
}
}
if len(releases) < perPage {
break
}
page++
//nolint:staticcheck
break
}
return nil, errors.New("release not found")

View file

@ -26,6 +26,7 @@ type ViewOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Browser browser
Exporter cmdutil.Exporter
TagName string
WebMode bool
@ -64,6 +65,7 @@ func NewCmdView(f *cmdutil.Factory, runF func(*ViewOptions) error) *cobra.Comman
}
cmd.Flags().BoolVarP(&opts.WebMode, "web", "w", false, "Open the release in the browser")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, shared.ReleaseFields)
return cmd
}
@ -95,9 +97,19 @@ func viewRun(opts *ViewOptions) error {
if opts.WebMode {
if opts.IO.IsStdoutTTY() {
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(release.HTMLURL))
fmt.Fprintf(opts.IO.ErrOut, "Opening %s in your browser.\n", utils.DisplayURL(release.URL))
}
return opts.Browser.Browse(release.HTMLURL)
return opts.Browser.Browse(release.URL)
}
opts.IO.DetectTerminalTheme()
if err := opts.IO.StartPager(); err != nil {
fmt.Fprintf(opts.IO.ErrOut, "error starting pager: %v\n", err)
}
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, release, opts.IO.ColorEnabled())
}
if opts.IO.IsStdoutTTY() {
@ -126,10 +138,10 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
if release.IsDraft {
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s created this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.CreatedAt)))))
} else {
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, utils.FuzzyAgo(time.Since(release.PublishedAt)))))
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("%s released this %s", release.Author.Login, utils.FuzzyAgo(time.Since(*release.PublishedAt)))))
}
style := markdown.GetStyle(io.DetectTerminalTheme())
style := markdown.GetStyle(io.TerminalTheme())
renderedDescription, err := markdown.Render(release.Body, style)
if err != nil {
return err
@ -151,7 +163,7 @@ func renderReleaseTTY(io *iostreams.IOStreams, release *shared.Release) error {
fmt.Fprint(w, "\n")
}
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.HTMLURL)))
fmt.Fprintf(w, "%s\n", iofmt.Gray(fmt.Sprintf("View on GitHub: %s", release.URL)))
return nil
}
@ -165,7 +177,7 @@ func renderReleasePlain(w io.Writer, release *shared.Release) error {
if !release.IsDraft {
fmt.Fprintf(w, "published:\t%s\n", release.PublishedAt.Format(time.RFC3339))
}
fmt.Fprintf(w, "url:\t%s\n", release.HTMLURL)
fmt.Fprintf(w, "url:\t%s\n", release.URL)
for _, a := range release.Assets {
fmt.Fprintf(w, "asset:\t%s\n", a.Name)
}

View file

@ -36,6 +36,7 @@ type ForkOptions struct {
PromptClone bool
PromptRemote bool
RemoteName string
Organization string
Rename bool
}
@ -110,6 +111,7 @@ Additional 'git clone' flags can be passed in by listing them after '--'.`,
cmd.Flags().BoolVar(&opts.Clone, "clone", false, "Clone the fork {true|false}")
cmd.Flags().BoolVar(&opts.Remote, "remote", false, "Add remote for fork {true|false}")
cmd.Flags().StringVar(&opts.RemoteName, "remote-name", "origin", "Specify a name for a fork's new remote.")
cmd.Flags().StringVar(&opts.Organization, "org", "", "Create the fork in an organization")
return cmd
}
@ -169,7 +171,7 @@ func forkRun(opts *ForkOptions) error {
apiClient := api.NewClientFromHTTP(httpClient)
opts.IO.StartProgressIndicator()
forkedRepo, err := api.ForkRepo(apiClient, repoToFork)
forkedRepo, err := api.ForkRepo(apiClient, repoToFork, opts.Organization)
opts.IO.StopProgressIndicator()
if err != nil {
return fmt.Errorf("failed to fork: %w", err)

View file

@ -2,12 +2,14 @@ package fork
import (
"bytes"
"io/ioutil"
"net/http"
"net/url"
"regexp"
"testing"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/config"
@ -72,8 +74,9 @@ func TestNewCmdFork(t *testing.T) {
name: "blank nontty",
cli: "",
wants: ForkOptions{
RemoteName: "origin",
Rename: true,
RemoteName: "origin",
Rename: true,
Organization: "",
},
},
{
@ -85,6 +88,7 @@ func TestNewCmdFork(t *testing.T) {
PromptClone: true,
PromptRemote: true,
Rename: true,
Organization: "",
},
},
{
@ -104,6 +108,16 @@ func TestNewCmdFork(t *testing.T) {
Rename: true,
},
},
{
name: "to org",
cli: "--org batmanshome",
wants: ForkOptions{
RemoteName: "origin",
Remote: false,
Rename: false,
Organization: "batmanshome",
},
},
}
for _, tt := range tests {
@ -141,6 +155,7 @@ func TestNewCmdFork(t *testing.T) {
assert.Equal(t, tt.wants.Remote, gotOpts.Remote)
assert.Equal(t, tt.wants.PromptRemote, gotOpts.PromptRemote)
assert.Equal(t, tt.wants.PromptClone, gotOpts.PromptClone)
assert.Equal(t, tt.wants.Organization, gotOpts.Organization)
})
}
}
@ -289,6 +304,7 @@ func TestRepoFork_in_parent_tty(t *testing.T) {
assert.Equal(t, "✓ Created fork someone/REPO\n✓ Added remote origin\n", output.Stderr())
reg.Verify(t)
}
func TestRepoFork_in_parent_nontty(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
@ -409,37 +425,65 @@ func TestRepoFork_in_parent(t *testing.T) {
func TestRepoFork_outside(t *testing.T) {
tests := []struct {
name string
args string
name string
args string
postBody string
responseBody string
wantStderr string
}{
{
name: "url arg",
args: "--clone=false http://github.com/OWNER/REPO.git",
name: "url arg",
args: "--clone=false http://github.com/OWNER/REPO.git",
postBody: "{}\n",
responseBody: `{"name":"REPO", "owner":{"login":"monalisa"}}`,
wantStderr: heredoc.Doc(`
Created fork monalisa/REPO
`),
},
{
name: "full name arg",
args: "--clone=false OWNER/REPO",
name: "full name arg",
args: "--clone=false OWNER/REPO",
postBody: "{}\n",
responseBody: `{"name":"REPO", "owner":{"login":"monalisa"}}`,
wantStderr: heredoc.Doc(`
Created fork monalisa/REPO
`),
},
{
name: "fork to org without clone",
args: "--clone=false OWNER/REPO --org batmanshome",
postBody: "{\"organization\":\"batmanshome\"}\n",
responseBody: `{"name":"REPO", "owner":{"login":"BatmansHome"}}`,
wantStderr: heredoc.Doc(`
Created fork BatmansHome/REPO
`),
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
defer stubSince(2 * time.Second)()
reg := &httpmock.Registry{}
defer reg.StubWithFixturePath(200, "./forkResult.json")()
reg.Register(
httpmock.REST("POST", "repos/OWNER/REPO/forks"),
func(req *http.Request) (*http.Response, error) {
bb, err := ioutil.ReadAll(req.Body)
if err != nil {
return nil, err
}
assert.Equal(t, tt.postBody, string(bb))
return &http.Response{
Request: req,
StatusCode: 200,
Body: ioutil.NopCloser(bytes.NewBufferString(tt.responseBody)),
}, nil
})
httpClient := &http.Client{Transport: reg}
output, err := runCommand(httpClient, nil, true, tt.args)
if err != nil {
t.Errorf("error running command `repo fork`: %v", err)
}
assert.NoError(t, err)
assert.Equal(t, "", output.String())
r := regexp.MustCompile(`Created fork.*someone/REPO`)
if !r.MatchString(output.Stderr()) {
t.Errorf("output did not match regexp /%s/\n> output\n%s\n", r, output)
return
}
assert.Equal(t, tt.wantStderr, output.Stderr())
reg.Verify(t)
})
}

View file

@ -1,48 +1,18 @@
package list
import (
"context"
"fmt"
"net/http"
"reflect"
"strings"
"time"
"github.com/cli/cli/internal/ghinstance"
"github.com/cli/cli/api"
"github.com/cli/cli/pkg/githubsearch"
"github.com/shurcooL/githubv4"
"github.com/shurcooL/graphql"
)
type Repository struct {
NameWithOwner string
Description string
IsFork bool
IsPrivate bool
IsArchived bool
PushedAt time.Time
}
func (r Repository) Info() string {
var tags []string
if r.IsPrivate {
tags = append(tags, "private")
} else {
tags = append(tags, "public")
}
if r.IsFork {
tags = append(tags, "fork")
}
if r.IsArchived {
tags = append(tags, "archived")
}
return strings.Join(tags, ", ")
}
type RepositoryList struct {
Owner string
Repositories []Repository
Repositories []api.Repository
TotalCount int
FromSearch bool
}
@ -54,6 +24,7 @@ type FilterOptions struct {
Language string
Archived bool
NonArchived bool
Fields []string
}
func listRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) {
@ -67,62 +38,65 @@ func listRepos(client *http.Client, hostname string, limit int, owner string, fi
}
variables := map[string]interface{}{
"perPage": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"perPage": githubv4.Int(perPage),
}
if filter.Visibility != "" {
variables["privacy"] = githubv4.RepositoryPrivacy(strings.ToUpper(filter.Visibility))
} else {
variables["privacy"] = (*githubv4.RepositoryPrivacy)(nil)
}
if filter.Fork {
variables["fork"] = githubv4.Boolean(true)
} else if filter.Source {
variables["fork"] = githubv4.Boolean(false)
} else {
variables["fork"] = (*githubv4.Boolean)(nil)
}
inputs := []string{"$perPage:Int!", "$endCursor:String", "$privacy:RepositoryPrivacy", "$fork:Boolean"}
var ownerConnection string
if owner == "" {
ownerConnection = `graphql:"repositoryOwner: viewer"`
ownerConnection = "repositoryOwner: viewer"
} else {
ownerConnection = `graphql:"repositoryOwner(login: $owner)"`
ownerConnection = "repositoryOwner(login: $owner)"
variables["owner"] = githubv4.String(owner)
inputs = append(inputs, "$owner:String!")
}
type repositoryOwner struct {
Login string
Repositories struct {
Nodes []Repository
TotalCount int
PageInfo struct {
HasNextPage bool
EndCursor string
type result struct {
RepositoryOwner struct {
Login string
Repositories struct {
Nodes []api.Repository
TotalCount int
PageInfo struct {
HasNextPage bool
EndCursor string
}
}
} `graphql:"repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC })"`
}
}
query := reflect.StructOf([]reflect.StructField{
{
Name: "RepositoryOwner",
Type: reflect.TypeOf(repositoryOwner{}),
Tag: reflect.StructTag(ownerConnection),
},
})
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
query := fmt.Sprintf(`query RepositoryList(%s) {
%s {
login
repositories(first: $perPage, after: $endCursor, privacy: $privacy, isFork: $fork, ownerAffiliations: OWNER, orderBy: { field: PUSHED_AT, direction: DESC }) {
nodes{%s}
totalCount
pageInfo{hasNextPage,endCursor}
}
}
}`, strings.Join(inputs, ","), ownerConnection, api.RepositoryGraphQL(filter.Fields))
apiClient := api.NewClientFromHTTP(client)
listResult := RepositoryList{}
pagination:
for {
result := reflect.New(query)
err := gql.QueryNamed(context.Background(), "RepositoryList", result.Interface(), variables)
var res result
err := apiClient.GraphQL(hostname, query, variables, &res)
if err != nil {
return nil, err
}
owner := result.Elem().FieldByName("RepositoryOwner").Interface().(repositoryOwner)
owner := res.RepositoryOwner
listResult.TotalCount = owner.Repositories.TotalCount
listResult.Owner = owner.Login
@ -143,47 +117,52 @@ pagination:
}
func searchRepos(client *http.Client, hostname string, limit int, owner string, filter FilterOptions) (*RepositoryList, error) {
type query struct {
type result struct {
Search struct {
RepositoryCount int
Nodes []struct {
Repository Repository `graphql:"...on Repository"`
}
PageInfo struct {
Nodes []api.Repository
PageInfo struct {
HasNextPage bool
EndCursor string
}
} `graphql:"search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor)"`
}
}
query := fmt.Sprintf(`query RepositoryListSearch($query:String!,$perPage:Int!,$endCursor:String) {
search(type: REPOSITORY, query: $query, first: $perPage, after: $endCursor) {
repositoryCount
nodes{...on Repository{%s}}
pageInfo{hasNextPage,endCursor}
}
}`, api.RepositoryGraphQL(filter.Fields))
perPage := limit
if perPage > 100 {
perPage = 100
}
variables := map[string]interface{}{
"query": githubv4.String(searchQuery(owner, filter)),
"perPage": githubv4.Int(perPage),
"endCursor": (*githubv4.String)(nil),
"query": githubv4.String(searchQuery(owner, filter)),
"perPage": githubv4.Int(perPage),
}
gql := graphql.NewClient(ghinstance.GraphQLEndpoint(hostname), client)
apiClient := api.NewClientFromHTTP(client)
listResult := RepositoryList{FromSearch: true}
pagination:
for {
var result query
err := gql.QueryNamed(context.Background(), "RepositoryListSearch", &result, variables)
var result result
err := apiClient.GraphQL(hostname, query, variables, &result)
if err != nil {
return nil, err
}
listResult.TotalCount = result.Search.RepositoryCount
for _, node := range result.Search.Nodes {
if listResult.Owner == "" {
idx := strings.IndexRune(node.Repository.NameWithOwner, '/')
listResult.Owner = node.Repository.NameWithOwner[:idx]
for _, repo := range result.Search.Nodes {
if listResult.Owner == "" && repo.NameWithOwner != "" {
idx := strings.IndexRune(repo.NameWithOwner, '/')
listResult.Owner = repo.NameWithOwner[:idx]
}
listResult.Repositories = append(listResult.Repositories, node.Repository)
listResult.Repositories = append(listResult.Repositories, repo)
if len(listResult.Repositories) >= limit {
break pagination
}

View file

@ -3,8 +3,10 @@ package list
import (
"fmt"
"net/http"
"strings"
"time"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/config"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/iostreams"
@ -17,6 +19,7 @@ type ListOptions struct {
HttpClient func() (*http.Client, error)
Config func() (config.Config, error)
IO *iostreams.IOStreams
Exporter cmdutil.Exporter
Limit int
Owner string
@ -88,10 +91,13 @@ func NewCmdList(f *cmdutil.Factory, runF func(*ListOptions) error) *cobra.Comman
cmd.Flags().StringVarP(&opts.Language, "language", "l", "", "Filter by primary coding language")
cmd.Flags().BoolVar(&opts.Archived, "archived", false, "Show only archived repositories")
cmd.Flags().BoolVar(&opts.NonArchived, "no-archived", false, "Omit archived repositories")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields)
return cmd
}
var defaultFields = []string{"nameWithOwner", "description", "isPrivate", "isFork", "isArchived", "createdAt", "pushedAt"}
func listRun(opts *ListOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
@ -105,6 +111,10 @@ func listRun(opts *ListOptions) error {
Language: opts.Language,
Archived: opts.Archived,
NonArchived: opts.NonArchived,
Fields: defaultFields,
}
if opts.Exporter != nil {
filter.Fields = opts.Exporter.Fields()
}
cfg, err := opts.Config()
@ -127,27 +137,31 @@ func listRun(opts *ListOptions) error {
}
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, listResult.Repositories, opts.IO.ColorEnabled())
}
cs := opts.IO.ColorScheme()
tp := utils.NewTablePrinter(opts.IO)
now := opts.Now()
for _, repo := range listResult.Repositories {
info := repo.Info()
info := repoInfo(repo)
infoColor := cs.Gray
if repo.IsPrivate {
infoColor = cs.Yellow
}
t := repo.PushedAt
// if listResult.FromSearch {
// t = repo.UpdatedAt
// }
if repo.PushedAt == nil {
t = &repo.CreatedAt
}
tp.AddField(repo.NameWithOwner, nil, cs.Bold)
tp.AddField(text.ReplaceExcessiveWhitespace(repo.Description), nil, nil)
tp.AddField(info, nil, infoColor)
if tp.IsTTY() {
tp.AddField(utils.FuzzyAgoAbbr(now, t), nil, cs.Gray)
tp.AddField(utils.FuzzyAgoAbbr(now, *t), nil, cs.Gray)
} else {
tp.AddField(t.Format(time.RFC3339), nil, nil)
}
@ -179,3 +193,21 @@ func listHeader(owner string, matchCount, totalMatchCount int, hasFilters bool)
}
return fmt.Sprintf("Showing %d of %d repositories in @%s%s", matchCount, totalMatchCount, owner, matchStr)
}
func repoInfo(r api.Repository) string {
var tags []string
if r.IsPrivate {
tags = append(tags, "private")
} else {
tags = append(tags, "public")
}
if r.IsFork {
tags = append(tags, "fork")
}
if r.IsArchived {
tags = append(tags, "archived")
}
return strings.Join(tags, ", ")
}

View file

@ -12,6 +12,25 @@ import (
var NotFoundError = errors.New("not found")
func fetchRepository(apiClient *api.Client, repo ghrepo.Interface, fields []string) (*api.Repository, error) {
query := fmt.Sprintf(`query RepositoryInfo($owner: String!, $name: String!) {
repository(owner: $owner, name: $name) {%s}
}`, api.RepositoryGraphQL(fields))
variables := map[string]interface{}{
"owner": repo.RepoOwner(),
"name": repo.RepoName(),
}
var result struct {
Repository api.Repository
}
if err := apiClient.GraphQL(repo.RepoHost(), query, variables, &result); err != nil {
return nil, err
}
return api.InitRepoHostname(&result.Repository, repo.RepoHost()), nil
}
type RepoReadme struct {
Filename string
Content string

View file

@ -29,6 +29,7 @@ type ViewOptions struct {
IO *iostreams.IOStreams
BaseRepo func() (ghrepo.Interface, error)
Browser browser
Exporter cmdutil.Exporter
RepoArg string
Web bool
@ -67,10 +68,13 @@ With '--branch', view a specific branch of the repository.`,
cmd.Flags().BoolVarP(&opts.Web, "web", "w", false, "Open a repository in the browser")
cmd.Flags().StringVarP(&opts.Branch, "branch", "b", "", "View a specific branch of the repository")
cmdutil.AddJSONFlags(cmd, &opts.Exporter, api.RepositoryFields)
return cmd
}
var defaultFields = []string{"name", "owner", "description"}
func viewRun(opts *ViewOptions) error {
httpClient, err := opts.HttpClient()
if err != nil {
@ -101,11 +105,24 @@ func viewRun(opts *ViewOptions) error {
}
}
repo, err := api.GitHubRepo(apiClient, toView)
var readme *RepoReadme
fields := defaultFields
if opts.Exporter != nil {
fields = opts.Exporter.Fields()
}
repo, err := fetchRepository(apiClient, toView, fields)
if err != nil {
return err
}
if !opts.Web && opts.Exporter == nil {
readme, err = RepositoryReadme(httpClient, toView, opts.Branch)
if err != nil && !errors.Is(err, NotFoundError) {
return err
}
}
openURL := generateBranchURL(toView, opts.Branch)
if opts.Web {
if opts.IO.IsStdoutTTY() {
@ -114,21 +131,17 @@ func viewRun(opts *ViewOptions) error {
return opts.Browser.Browse(openURL)
}
fullName := ghrepo.FullName(toView)
readme, err := RepositoryReadme(httpClient, toView, opts.Branch)
if err != nil && err != NotFoundError {
return err
}
opts.IO.DetectTerminalTheme()
err = opts.IO.StartPager()
if err != nil {
if err := opts.IO.StartPager(); err != nil {
return err
}
defer opts.IO.StopPager()
if opts.Exporter != nil {
return opts.Exporter.Write(opts.IO.Out, repo, opts.IO.ColorEnabled())
}
fullName := ghrepo.FullName(toView)
stdout := opts.IO.Out
if !opts.IO.IsStdoutTTY() {

View file

@ -3,10 +3,12 @@ package view
import (
"bytes"
"fmt"
"io"
"net/http"
"testing"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/cmdutil"
@ -625,3 +627,51 @@ func Test_ViewRun_HandlesSpecialCharacters(t *testing.T) {
})
}
}
func Test_viewRun_json(t *testing.T) {
io, _, stdout, stderr := iostreams.Test()
io.SetStdoutTTY(false)
reg := &httpmock.Registry{}
defer reg.Verify(t)
reg.StubRepoInfoResponse("OWNER", "REPO", "main")
opts := &ViewOptions{
IO: io,
HttpClient: func() (*http.Client, error) {
return &http.Client{Transport: reg}, nil
},
BaseRepo: func() (ghrepo.Interface, error) {
return ghrepo.New("OWNER", "REPO"), nil
},
Exporter: &testExporter{
fields: []string{"name", "defaultBranchRef"},
},
}
_, teardown := run.Stub()
defer teardown(t)
err := viewRun(opts)
assert.NoError(t, err)
assert.Equal(t, heredoc.Doc(`
name: REPO
defaultBranchRef: main
`), stdout.String())
assert.Equal(t, "", stderr.String())
}
type testExporter struct {
fields []string
}
func (e *testExporter) Fields() []string {
return e.fields
}
func (e *testExporter) Write(w io.Writer, data interface{}, colorize bool) error {
r := data.(*api.Repository)
fmt.Fprintf(w, "name: %s\n", r.Name)
fmt.Fprintf(w, "defaultBranchRef: %s\n", r.DefaultBranchRef.Name)
return nil
}

View file

@ -134,11 +134,10 @@ func GetAnnotations(client *api.Client, repo ghrepo.Interface, job Job) ([]Annot
err := client.REST(repo.RepoHost(), "GET", path, nil, &result)
if err != nil {
var notFound *api.NotFoundError
if !errors.As(err, &notFound) {
var httpError api.HTTPError
if errors.As(err, &httpError) && httpError.StatusCode == 404 {
return []Annotation{}, nil
}
return nil, err
}

View file

@ -7,7 +7,6 @@ import (
"fmt"
"io/ioutil"
"net/http"
"os"
"testing"
"github.com/cli/cli/api"
@ -149,13 +148,13 @@ func TestNewCmdRun(t *testing.T) {
}
func Test_magicFieldValue(t *testing.T) {
f, err := ioutil.TempFile("", "gh-test")
f, err := ioutil.TempFile(t.TempDir(), "gh-test")
if err != nil {
t.Fatal(err)
}
defer f.Close()
fmt.Fprint(f, "file contents")
f.Close()
t.Cleanup(func() { os.Remove(f.Name()) })
io, _, _, _ := iostreams.Test()

View file

@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"reflect"
"sort"
"strings"
@ -26,6 +27,21 @@ func AddJSONFlags(cmd *cobra.Command, exportTarget *Exporter, fields []string) {
f.StringP("jq", "q", "", "Filter JSON output using a jq `expression`")
f.StringP("template", "t", "", "Format JSON output using a Go template")
_ = cmd.RegisterFlagCompletionFunc("json", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) {
var results []string
if idx := strings.IndexRune(toComplete, ','); idx >= 0 {
toComplete = toComplete[idx+1:]
}
toComplete = strings.ToLower(toComplete)
for _, f := range fields {
if strings.HasPrefix(strings.ToLower(f), toComplete) {
results = append(results, f)
}
}
sort.Strings(results)
return results, cobra.ShellCompDirectiveNoSpace
})
oldPreRun := cmd.PreRunE
cmd.PreRunE = func(c *cobra.Command, args []string) error {
if oldPreRun != nil {
@ -102,11 +118,14 @@ func (e *exportFormat) Fields() []string {
return e.fields
}
// Write serializes data into JSON output written to w. If the object passed as data implements exportable,
// or if data is a map or slice of exportable object, ExportData() will be called on each object to obtain
// raw data for serialization.
func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) error {
buf := bytes.Buffer{}
encoder := json.NewEncoder(&buf)
encoder.SetEscapeHTML(false)
if err := encoder.Encode(data); err != nil {
if err := encoder.Encode(e.exportData(reflect.ValueOf(data))); err != nil {
return err
}
@ -121,3 +140,44 @@ func (e *exportFormat) Write(w io.Writer, data interface{}, colorEnabled bool) e
_, err := io.Copy(w, &buf)
return err
}
func (e *exportFormat) exportData(v reflect.Value) interface{} {
switch v.Kind() {
case reflect.Ptr, reflect.Interface:
if !v.IsNil() {
return e.exportData(v.Elem())
}
case reflect.Slice:
a := make([]interface{}, v.Len())
for i := 0; i < v.Len(); i++ {
a[i] = e.exportData(v.Index(i))
}
return a
case reflect.Map:
t := reflect.MapOf(v.Type().Key(), emptyInterfaceType)
m := reflect.MakeMapWithSize(t, v.Len())
iter := v.MapRange()
for iter.Next() {
ve := reflect.ValueOf(e.exportData(iter.Value()))
m.SetMapIndex(iter.Key(), ve)
}
return m.Interface()
case reflect.Struct:
if v.CanAddr() && reflect.PtrTo(v.Type()).Implements(exportableType) {
ve := v.Addr().Interface().(exportable)
return ve.ExportData(e.fields)
} else if v.Type().Implements(exportableType) {
ve := v.Interface().(exportable)
return ve.ExportData(e.fields)
}
}
return v.Interface()
}
type exportable interface {
ExportData([]string) *map[string]interface{}
}
var exportableType = reflect.TypeOf((*exportable)(nil)).Elem()
var sliceOfEmptyInterface []interface{}
var emptyInterfaceType = reflect.TypeOf(sliceOfEmptyInterface).Elem()

View file

@ -2,6 +2,7 @@ package cmdutil
import (
"bytes"
"fmt"
"io/ioutil"
"testing"
@ -137,6 +138,29 @@ func Test_exportFormat_Write(t *testing.T) {
wantW: "{\"name\":\"hubot\"}\n",
wantErr: false,
},
{
name: "call ExportData",
exporter: exportFormat{fields: []string{"field1", "field2"}},
args: args{
data: &exportableItem{"item1"},
colorEnabled: false,
},
wantW: "{\"field1\":\"item1:field1\",\"field2\":\"item1:field2\"}\n",
wantErr: false,
},
{
name: "recursively call ExportData",
exporter: exportFormat{fields: []string{"f1", "f2"}},
args: args{
data: map[string]interface{}{
"s1": []exportableItem{{"i1"}, {"i2"}},
"s2": []exportableItem{{"i3"}},
},
colorEnabled: false,
},
wantW: "{\"s1\":[{\"f1\":\"i1:f1\",\"f2\":\"i1:f2\"},{\"f1\":\"i2:f1\",\"f2\":\"i2:f2\"}],\"s2\":[{\"f1\":\"i3:f1\",\"f2\":\"i3:f2\"}]}\n",
wantErr: false,
},
{
name: "with jq filter",
exporter: exportFormat{filter: ".name"},
@ -166,8 +190,20 @@ func Test_exportFormat_Write(t *testing.T) {
return
}
if gotW := w.String(); gotW != tt.wantW {
t.Errorf("exportFormat.Write() = %v, want %v", gotW, tt.wantW)
t.Errorf("exportFormat.Write() = %q, want %q", gotW, tt.wantW)
}
})
}
}
type exportableItem struct {
Name string
}
func (e *exportableItem) ExportData(fields []string) *map[string]interface{} {
m := map[string]interface{}{}
for _, f := range fields {
m[f] = fmt.Sprintf("%s:%s", e.Name, f)
}
return &m
}

View file

@ -261,12 +261,11 @@ func TestFindLegacy(t *testing.T) {
}
func TestExtractName(t *testing.T) {
tmpfile, err := ioutil.TempFile("", "gh-cli")
tmpfile, err := ioutil.TempFile(t.TempDir(), "gh-cli")
if err != nil {
t.Fatal(err)
}
tmpfile.Close()
defer os.Remove(tmpfile.Name())
defer tmpfile.Close()
type args struct {
filePath string
@ -322,12 +321,11 @@ about: This is how you report bugs
}
func TestExtractContents(t *testing.T) {
tmpfile, err := ioutil.TempFile("", "gh-cli")
tmpfile, err := ioutil.TempFile(t.TempDir(), "gh-cli")
if err != nil {
t.Fatal(err)
}
tmpfile.Close()
defer os.Remove(tmpfile.Name())
defer tmpfile.Close()
type args struct {
filePath string

View file

@ -10,7 +10,7 @@ import (
const (
colorDelim = "1;38" // bright white
colorKey = "1;34" // bright blue
colorNull = "1;30" // gray
colorNull = "36" // cyan
colorString = "32" // green
colorBool = "33" // yellow
)

View file

@ -142,3 +142,29 @@ Components: main
Description: The GitHub CLI - ubuntu cosmic repo
SignWith: C99B11DEB97541F0
DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: hirsute
Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu hirsute repo
SignWith: C99B11DEB97541F0
DebOverride: override.ubuntu
Origin: gh
Label: gh
Codename: kali-rolling
Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - kali repo
SignWith: C99B11DEB97541F0
Origin: gh
Label: gh
Codename: impish
Architectures: i386 amd64 armhf arm64
Components: main
Description: The GitHub CLI - ubuntu impish repo
SignWith: C99B11DEB97541F0
DebOverride: override.ubuntu