Merge remote-tracking branch 'origin' into fix-milestone

This commit is contained in:
Mislav Marohnić 2020-08-11 19:01:58 +02:00
commit 20ffa49d69
107 changed files with 7113 additions and 4697 deletions

View file

@ -6,6 +6,7 @@ import (
"strings"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/utils"
"github.com/google/shlex"
"github.com/spf13/cobra"
@ -166,9 +167,9 @@ func aliasList(cmd *cobra.Command, args []string) error {
return nil
}
stdout := colorableOut(cmd)
tp := utils.NewTablePrinter(stdout)
tp := utils.NewTablePrinter(&iostreams.IOStreams{
Out: cmd.OutOrStdout(),
})
aliasMap := aliasCfg.All()
keys := []string{}
@ -193,7 +194,7 @@ func aliasList(cmd *cobra.Command, args []string) error {
var aliasDeleteCmd = &cobra.Command{
Use: "delete <alias>",
Short: "Delete an alias.",
Short: "Delete an alias",
Args: cobra.ExactArgs(1),
RunE: aliasDelete,
}

View file

@ -3,7 +3,6 @@ package command
import (
"fmt"
"io"
"net/url"
"strconv"
"strings"
"time"
@ -12,8 +11,11 @@ import (
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"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/githubtemplate"
"github.com/cli/cli/pkg/iostreams"
"github.com/cli/cli/pkg/text"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@ -121,57 +123,6 @@ var issueReopenCmd = &cobra.Command{
RunE: issueReopen,
}
type filterOptions struct {
entity string
state string
assignee string
labels []string
author string
baseBranch string
mention string
milestone string
}
func listURLWithQuery(listURL string, options filterOptions) (string, error) {
u, err := url.Parse(listURL)
if err != nil {
return "", err
}
query := fmt.Sprintf("is:%s ", options.entity)
if options.state != "all" {
query += fmt.Sprintf("is:%s ", options.state)
}
if options.assignee != "" {
query += fmt.Sprintf("assignee:%s ", options.assignee)
}
for _, label := range options.labels {
query += fmt.Sprintf("label:%s ", quoteValueForQuery(label))
}
if options.author != "" {
query += fmt.Sprintf("author:%s ", options.author)
}
if options.baseBranch != "" {
query += fmt.Sprintf("base:%s ", options.baseBranch)
}
if options.mention != "" {
query += fmt.Sprintf("mentions:%s ", options.mention)
}
if options.milestone != "" {
query += fmt.Sprintf("milestone:%s ", quoteValueForQuery(options.milestone))
}
q := u.Query()
q.Set("q", strings.TrimSuffix(query, " "))
u.RawQuery = q.Encode()
return u.String(), nil
}
func quoteValueForQuery(v string) string {
if strings.ContainsAny(v, " \"\t\r\n") {
return fmt.Sprintf("%q", v)
}
return v
}
func issueList(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
@ -229,14 +180,14 @@ func issueList(cmd *cobra.Command, args []string) error {
if web {
issueListURL := ghrepo.GenerateRepoURL(baseRepo, "issues")
openURL, err := listURLWithQuery(issueListURL, filterOptions{
entity: "issue",
state: state,
assignee: assignee,
labels: labels,
author: author,
mention: mention,
milestone: milestone,
openURL, err := shared.ListURLWithQuery(issueListURL, shared.FilterOptions{
Entity: "issue",
State: state,
Assignee: assignee,
Labels: labels,
Author: author,
Mention: mention,
Milestone: milestone,
})
if err != nil {
return err
@ -258,7 +209,7 @@ func issueList(cmd *cobra.Command, args []string) error {
}
})
title := listHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters)
title := shared.ListHeader(ghrepo.FullName(baseRepo), "issue", len(listResult.Issues), listResult.TotalCount, hasFilters)
if connectedToTerminal(cmd) {
fmt.Fprintf(colorableErr(cmd), "\n%s\n\n", title)
}
@ -282,7 +233,7 @@ func issueStatus(cmd *cobra.Command, args []string) error {
return err
}
currentUser, err := api.CurrentLoginName(apiClient)
currentUser, err := api.CurrentLoginName(apiClient, baseRepo.RepoHost())
if err != nil {
return err
}
@ -298,28 +249,28 @@ func issueStatus(cmd *cobra.Command, args []string) error {
fmt.Fprintf(out, "Relevant issues in %s\n", ghrepo.FullName(baseRepo))
fmt.Fprintln(out, "")
printHeader(out, "Issues assigned to you")
shared.PrintHeader(out, "Issues assigned to you")
if issuePayload.Assigned.TotalCount > 0 {
printIssues(out, " ", issuePayload.Assigned.TotalCount, issuePayload.Assigned.Issues)
} else {
message := " There are no issues assigned to you"
printMessage(out, message)
shared.PrintMessage(out, message)
}
fmt.Fprintln(out)
printHeader(out, "Issues mentioning you")
shared.PrintHeader(out, "Issues mentioning you")
if issuePayload.Mentioned.TotalCount > 0 {
printIssues(out, " ", issuePayload.Mentioned.TotalCount, issuePayload.Mentioned.Issues)
} else {
printMessage(out, " There are no issues mentioning you")
shared.PrintMessage(out, " There are no issues mentioning you")
}
fmt.Fprintln(out)
printHeader(out, "Issues opened by you")
shared.PrintHeader(out, "Issues opened by you")
if issuePayload.Authored.TotalCount > 0 {
printIssues(out, " ", issuePayload.Authored.TotalCount, issuePayload.Authored.Issues)
} else {
printMessage(out, " There are no issues opened by you")
shared.PrintMessage(out, " There are no issues opened by you")
}
fmt.Fprintln(out)
@ -357,29 +308,10 @@ func issueView(cmd *cobra.Command, args []string) error {
}
func issueStateTitleWithColor(state string) string {
colorFunc := colorFuncForState(state)
colorFunc := shared.ColorFuncForState(state)
return colorFunc(strings.Title(strings.ToLower(state)))
}
func listHeader(repoName string, itemName string, matchCount int, totalMatchCount int, hasFilters bool) string {
if totalMatchCount == 0 {
if hasFilters {
return fmt.Sprintf("No %ss match your search in %s", itemName, repoName)
}
return fmt.Sprintf("There are no open %ss in %s", itemName, repoName)
}
if hasFilters {
matchVerb := "match"
if totalMatchCount == 1 {
matchVerb = "matches"
}
return fmt.Sprintf("Showing %d of %s in %s that %s your search", matchCount, utils.Pluralize(totalMatchCount, itemName), repoName, matchVerb)
}
return fmt.Sprintf("Showing %d of %s in %s", matchCount, utils.Pluralize(totalMatchCount, itemName), repoName)
}
func printRawIssuePreview(out io.Writer, issue *api.Issue) error {
assignees := issueAssigneeList(*issue)
labels := issueLabelList(*issue)
@ -507,11 +439,7 @@ func issueCreate(cmd *cobra.Command, args []string) error {
if isWeb, err := cmd.Flags().GetBool("web"); err == nil && isWeb {
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
if title != "" || body != "" {
milestone := ""
if len(milestoneTitles) > 0 {
milestone = milestoneTitles[0]
}
openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone)
openURL, err = shared.WithPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestoneTitles)
if err != nil {
return err
}
@ -534,9 +462,9 @@ func issueCreate(cmd *cobra.Command, args []string) error {
return fmt.Errorf("the '%s' repository has disabled issues", ghrepo.FullName(baseRepo))
}
action := SubmitAction
tb := issueMetadataState{
Type: issueMetadata,
action := shared.SubmitAction
tb := shared.IssueMetadataState{
Type: shared.IssueMetadata,
Assignees: assignees,
Labels: labelNames,
Projects: projectNames,
@ -557,14 +485,20 @@ func issueCreate(cmd *cobra.Command, args []string) error {
legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "ISSUE_TEMPLATE")
}
}
err := titleBodySurvey(cmd, &tb, apiClient, baseRepo, title, body, defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage())
editorCommand, err := cmdutil.DetermineEditor(ctx.Config)
if err != nil {
return err
}
err = shared.TitleBodySurvey(defaultStreams, editorCommand, &tb, apiClient, baseRepo, title, body, shared.Defaults{}, nonLegacyTemplateFiles, legacyTemplateFile, false, repo.ViewerCanTriage())
if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err)
}
action = tb.Action
if tb.Action == CancelAction {
if tb.Action == shared.CancelAction {
fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.")
return nil
@ -582,26 +516,22 @@ func issueCreate(cmd *cobra.Command, args []string) error {
}
}
if action == PreviewAction {
if action == shared.PreviewAction {
openURL := ghrepo.GenerateRepoURL(baseRepo, "issues/new")
milestone := ""
if len(milestoneTitles) > 0 {
milestone = milestoneTitles[0]
}
openURL, err = withPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestone)
openURL, err = shared.WithPrAndIssueQueryParams(openURL, title, body, assignees, labelNames, projectNames, milestoneTitles)
if err != nil {
return err
}
// TODO could exceed max url length for explorer
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL))
return utils.OpenInBrowser(openURL)
} else if action == SubmitAction {
} else if action == shared.SubmitAction {
params := map[string]interface{}{
"title": title,
"body": body,
}
err = addMetadataToIssueParams(apiClient, baseRepo, params, &tb)
err = shared.AddMetadataToIssueParams(apiClient, baseRepo, params, &tb)
if err != nil {
return err
}
@ -619,84 +549,10 @@ func issueCreate(cmd *cobra.Command, args []string) error {
return nil
}
func addMetadataToIssueParams(client *api.Client, baseRepo ghrepo.Interface, params map[string]interface{}, tb *issueMetadataState) error {
if !tb.HasMetadata() {
return nil
}
if tb.MetadataResult == nil {
resolveInput := api.RepoResolveInput{
Reviewers: tb.Reviewers,
Assignees: tb.Assignees,
Labels: tb.Labels,
Projects: tb.Projects,
Milestones: tb.Milestones,
}
var err error
tb.MetadataResult, err = api.RepoResolveMetadataIDs(client, baseRepo, resolveInput)
if err != nil {
return err
}
}
assigneeIDs, err := tb.MetadataResult.MembersToIDs(tb.Assignees)
if err != nil {
return fmt.Errorf("could not assign user: %w", err)
}
params["assigneeIds"] = assigneeIDs
labelIDs, err := tb.MetadataResult.LabelsToIDs(tb.Labels)
if err != nil {
return fmt.Errorf("could not add label: %w", err)
}
params["labelIds"] = labelIDs
projectIDs, err := tb.MetadataResult.ProjectsToIDs(tb.Projects)
if err != nil {
return fmt.Errorf("could not add to project: %w", err)
}
params["projectIds"] = projectIDs
if len(tb.Milestones) > 0 {
milestoneID, err := tb.MetadataResult.MilestoneToID(tb.Milestones[0])
if err != nil {
return fmt.Errorf("could not add to milestone '%s': %w", tb.Milestones[0], err)
}
params["milestoneId"] = milestoneID
}
if len(tb.Reviewers) == 0 {
return nil
}
var userReviewers []string
var teamReviewers []string
for _, r := range tb.Reviewers {
if strings.ContainsRune(r, '/') {
teamReviewers = append(teamReviewers, r)
} else {
userReviewers = append(userReviewers, r)
}
}
userReviewerIDs, err := tb.MetadataResult.MembersToIDs(userReviewers)
if err != nil {
return fmt.Errorf("could not request reviewer: %w", err)
}
params["userReviewerIds"] = userReviewerIDs
teamReviewerIDs, err := tb.MetadataResult.TeamsToIDs(teamReviewers)
if err != nil {
return fmt.Errorf("could not request reviewer: %w", err)
}
params["teamReviewerIds"] = teamReviewerIDs
return nil
}
func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue) {
table := utils.NewTablePrinter(w)
io := &iostreams.IOStreams{Out: w}
io.SetStdoutTTY(utils.IsTerminal(w))
table := utils.NewTablePrinter(io)
for _, issue := range issues {
issueNum := strconv.Itoa(issue.Number)
if table.IsTTY() {
@ -709,11 +565,11 @@ func printIssues(w io.Writer, prefix string, totalCount int, issues []api.Issue)
}
now := time.Now()
ago := now.Sub(issue.UpdatedAt)
table.AddField(issueNum, nil, colorFuncForState(issue.State))
table.AddField(issueNum, nil, shared.ColorFuncForState(issue.State))
if !table.IsTTY() {
table.AddField(issue.State, nil, nil)
}
table.AddField(replaceExcessiveWhitespace(issue.Title), nil, nil)
table.AddField(text.ReplaceExcessiveWhitespace(issue.Title), nil, nil)
table.AddField(labels, nil, utils.Gray)
if table.IsTTY() {
table.AddField(utils.FuzzyAgo(ago), nil, utils.Gray)

View file

@ -145,7 +145,7 @@ func TestIssueList_tty(t *testing.T) {
}
eq(t, output.Stderr(), `
Showing 3 of 3 issues in OWNER/REPO
Showing 3 of 3 open issues in OWNER/REPO
`)
@ -834,117 +834,6 @@ func TestIssueCreate_webTitleBody(t *testing.T) {
eq(t, output.String(), "Opening github.com/OWNER/REPO/issues/new in your browser.\n")
}
func Test_listHeader(t *testing.T) {
type args struct {
repoName string
itemName string
matchCount int
totalMatchCount int
hasFilters bool
}
tests := []struct {
name string
args args
want string
}{
{
name: "no results",
args: args{
repoName: "REPO",
itemName: "table",
matchCount: 0,
totalMatchCount: 0,
hasFilters: false,
},
want: "There are no open tables in REPO",
},
{
name: "no matches after filters",
args: args{
repoName: "REPO",
itemName: "Luftballon",
matchCount: 0,
totalMatchCount: 0,
hasFilters: true,
},
want: "No Luftballons match your search in REPO",
},
{
name: "one result",
args: args{
repoName: "REPO",
itemName: "genie",
matchCount: 1,
totalMatchCount: 23,
hasFilters: false,
},
want: "Showing 1 of 23 genies in REPO",
},
{
name: "one result after filters",
args: args{
repoName: "REPO",
itemName: "tiny cup",
matchCount: 1,
totalMatchCount: 23,
hasFilters: true,
},
want: "Showing 1 of 23 tiny cups in REPO that match your search",
},
{
name: "one result in total",
args: args{
repoName: "REPO",
itemName: "chip",
matchCount: 1,
totalMatchCount: 1,
hasFilters: false,
},
want: "Showing 1 of 1 chip in REPO",
},
{
name: "one result in total after filters",
args: args{
repoName: "REPO",
itemName: "spicy noodle",
matchCount: 1,
totalMatchCount: 1,
hasFilters: true,
},
want: "Showing 1 of 1 spicy noodle in REPO that matches your search",
},
{
name: "multiple results",
args: args{
repoName: "REPO",
itemName: "plant",
matchCount: 4,
totalMatchCount: 23,
hasFilters: false,
},
want: "Showing 4 of 23 plants in REPO",
},
{
name: "multiple results after filters",
args: args{
repoName: "REPO",
itemName: "boomerang",
matchCount: 4,
totalMatchCount: 23,
hasFilters: true,
},
want: "Showing 4 of 23 boomerangs in REPO that match your search",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := listHeader(tt.args.repoName, tt.args.itemName, tt.args.matchCount, tt.args.totalMatchCount, tt.args.hasFilters); got != tt.want {
t.Errorf("listHeader() = %v, want %v", got, tt.want)
}
})
}
}
func TestIssueStateTitleWithColor(t *testing.T) {
tests := map[string]struct {
state string
@ -1102,71 +991,3 @@ func TestIssueReopen_issuesDisabled(t *testing.T) {
t.Fatalf("got error: %v", err)
}
}
func Test_listURLWithQuery(t *testing.T) {
type args struct {
listURL string
options filterOptions
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
name: "blank",
args: args{
listURL: "https://example.com/path?a=b",
options: filterOptions{
entity: "issue",
state: "open",
},
},
want: "https://example.com/path?a=b&q=is%3Aissue+is%3Aopen",
wantErr: false,
},
{
name: "all",
args: args{
listURL: "https://example.com/path",
options: filterOptions{
entity: "issue",
state: "open",
assignee: "bo",
author: "ka",
baseBranch: "trunk",
mention: "nu",
},
},
want: "https://example.com/path?q=is%3Aissue+is%3Aopen+assignee%3Abo+author%3Aka+base%3Atrunk+mentions%3Anu",
wantErr: false,
},
{
name: "spaces in values",
args: args{
listURL: "https://example.com/path",
options: filterOptions{
entity: "pr",
state: "open",
labels: []string{"docs", "help wanted"},
milestone: `Codename "What Was Missing"`,
},
},
want: "https://example.com/path?q=is%3Apr+is%3Aopen+label%3Adocs+label%3A%22help+wanted%22+milestone%3A%22Codename+%5C%22What+Was+Missing%5C%22%22",
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := listURLWithQuery(tt.args.listURL, tt.args.options)
if (err != nil) != tt.wantErr {
t.Errorf("listURLWithQuery() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("listURLWithQuery() = %v, want %v", got, tt.want)
}
})
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,129 +0,0 @@
package command
import (
"errors"
"fmt"
"os"
"os/exec"
"strings"
"github.com/spf13/cobra"
"github.com/cli/cli/api"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
)
func prCheckout(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
currentBranch, _ := ctx.Branch()
remotes, err := ctx.Remotes()
if err != nil {
return err
}
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return err
}
baseRemote, _ := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName())
// baseRemoteSpec is a repository URL or a remote name to be used in git fetch
baseURLOrName := formatRemoteURL(cmd, baseRepo)
if baseRemote != nil {
baseURLOrName = baseRemote.Name
}
headRemote := baseRemote
if pr.IsCrossRepository {
headRemote, _ = remotes.FindByRepo(pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name)
}
var cmdQueue [][]string
newBranchName := pr.HeadRefName
if strings.HasPrefix(newBranchName, "-") {
return fmt.Errorf("invalid branch name: %q", newBranchName)
}
if headRemote != nil {
// there is an existing git remote for PR head
remoteBranch := fmt.Sprintf("%s/%s", headRemote.Name, pr.HeadRefName)
refSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/%s", pr.HeadRefName, remoteBranch)
cmdQueue = append(cmdQueue, []string{"git", "fetch", headRemote.Name, refSpec})
// local branch already exists
if _, err := git.ShowRefs("refs/heads/" + newBranchName); err == nil {
cmdQueue = append(cmdQueue, []string{"git", "checkout", newBranchName})
cmdQueue = append(cmdQueue, []string{"git", "merge", "--ff-only", fmt.Sprintf("refs/remotes/%s", remoteBranch)})
} else {
cmdQueue = append(cmdQueue, []string{"git", "checkout", "-b", newBranchName, "--no-track", remoteBranch})
cmdQueue = append(cmdQueue, []string{"git", "config", fmt.Sprintf("branch.%s.remote", newBranchName), headRemote.Name})
cmdQueue = append(cmdQueue, []string{"git", "config", fmt.Sprintf("branch.%s.merge", newBranchName), "refs/heads/" + pr.HeadRefName})
}
} else {
// no git remote for PR head
defaultBranchName, err := api.RepoDefaultBranch(apiClient, baseRepo)
if err != nil {
return err
}
// avoid naming the new branch the same as the default branch
if newBranchName == defaultBranchName {
newBranchName = fmt.Sprintf("%s/%s", pr.HeadRepositoryOwner.Login, newBranchName)
}
ref := fmt.Sprintf("refs/pull/%d/head", pr.Number)
if newBranchName == currentBranch {
// PR head matches currently checked out branch
cmdQueue = append(cmdQueue, []string{"git", "fetch", baseURLOrName, ref})
cmdQueue = append(cmdQueue, []string{"git", "merge", "--ff-only", "FETCH_HEAD"})
} else {
// create a new branch
cmdQueue = append(cmdQueue, []string{"git", "fetch", baseURLOrName, fmt.Sprintf("%s:%s", ref, newBranchName)})
cmdQueue = append(cmdQueue, []string{"git", "checkout", newBranchName})
}
remote := baseURLOrName
mergeRef := ref
if pr.MaintainerCanModify {
headRepo := ghrepo.NewWithHost(pr.HeadRepositoryOwner.Login, pr.HeadRepository.Name, baseRepo.RepoHost())
remote = formatRemoteURL(cmd, headRepo)
mergeRef = fmt.Sprintf("refs/heads/%s", pr.HeadRefName)
}
if mc, err := git.Config(fmt.Sprintf("branch.%s.merge", newBranchName)); err != nil || mc == "" {
cmdQueue = append(cmdQueue, []string{"git", "config", fmt.Sprintf("branch.%s.remote", newBranchName), remote})
cmdQueue = append(cmdQueue, []string{"git", "config", fmt.Sprintf("branch.%s.merge", newBranchName), mergeRef})
}
}
for _, args := range cmdQueue {
cmd := exec.Command(args[0], args[1:]...)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
if err := run.PrepareCmd(cmd).Run(); err != nil {
return err
}
}
return nil
}
var prCheckoutCmd = &cobra.Command{
Use: "checkout {<number> | <url> | <branch>}",
Short: "Check out a pull request in Git",
Args: func(cmd *cobra.Command, args []string) error {
if len(args) < 1 {
return errors.New("requires a pull request number as an argument")
}
return nil
},
RunE: prCheckout,
}

View file

@ -1,567 +0,0 @@
package command
import (
"encoding/json"
"io/ioutil"
"os/exec"
"strings"
"testing"
"github.com/cli/cli/context"
"github.com/cli/cli/internal/run"
"github.com/cli/cli/pkg/httpmock"
"github.com/cli/cli/test"
"github.com/stretchr/testify/assert"
)
func TestPRCheckout_sameRepo(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoResponse("OWNER", "REPO")
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
} } } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
return &errorStub{"exit status: 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, len(ranCommands), 4)
eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature")
eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature")
eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin")
eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature")
}
func TestPRCheckout_urlArg(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
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
} } } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
return &errorStub{"exit status: 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout https://github.com/OWNER/REPO/pull/123/files`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, len(ranCommands), 4)
eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track origin/feature")
}
func TestPRCheckout_urlArg_differentBase(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
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.Register(httpmock.GraphQL(`query RepositoryInfo\b`), httpmock.StringResponse(`
{ "data": { "repository": {
"defaultBranchRef": {"name": "master"}
} } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
return &errorStub{"exit status: 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout https://github.com/OTHER/POE/pull/123/files`)
eq(t, err, nil)
eq(t, output.String(), "")
bodyBytes, _ := ioutil.ReadAll(http.Requests[0].Body)
reqBody := struct {
Variables struct {
Owner string
Repo string
}
}{}
_ = json.Unmarshal(bodyBytes, &reqBody)
eq(t, reqBody.Variables.Owner, "OTHER")
eq(t, reqBody.Variables.Repo, "POE")
eq(t, len(ranCommands), 5)
eq(t, strings.Join(ranCommands[1], " "), "git fetch https://github.com/OTHER/POE.git refs/pull/123/head:feature")
eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.remote https://github.com/OTHER/POE.git")
}
func TestPRCheckout_branchArg(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoResponse("OWNER", "REPO")
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 }
] } } } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
return &errorStub{"exit status: 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout hubot:feature`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, len(ranCommands), 5)
eq(t, strings.Join(ranCommands[1], " "), "git fetch origin refs/pull/123/head:feature")
}
func TestPRCheckout_existingBranch(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoResponse("OWNER", "REPO")
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
} } } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
return &test.OutputStub{}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, len(ranCommands), 3)
eq(t, strings.Join(ranCommands[0], " "), "git fetch origin +refs/heads/feature:refs/remotes/origin/feature")
eq(t, strings.Join(ranCommands[1], " "), "git checkout feature")
eq(t, strings.Join(ranCommands[2], " "), "git merge --ff-only refs/remotes/origin/feature")
}
func TestPRCheckout_differentRepo_remoteExists(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
"robot-fork": "hubot/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoResponse("OWNER", "REPO")
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
} } } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git show-ref --verify -- refs/heads/feature":
return &errorStub{"exit status: 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, len(ranCommands), 4)
eq(t, strings.Join(ranCommands[0], " "), "git fetch robot-fork +refs/heads/feature:refs/remotes/robot-fork/feature")
eq(t, strings.Join(ranCommands[1], " "), "git checkout -b feature --no-track robot-fork/feature")
eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote robot-fork")
eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature")
}
func TestPRCheckout_differentRepo(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoResponse("OWNER", "REPO")
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
} } } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
return &errorStub{"exit status 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, len(ranCommands), 4)
eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature")
eq(t, strings.Join(ranCommands[1], " "), "git checkout feature")
eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote origin")
eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/pull/123/head")
}
func TestPRCheckout_differentRepo_existingBranch(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoResponse("OWNER", "REPO")
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
} } } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
return &test.OutputStub{Out: []byte("refs/heads/feature\n")}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, len(ranCommands), 2)
eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature")
eq(t, strings.Join(ranCommands[1], " "), "git checkout feature")
}
func TestPRCheckout_differentRepo_currentBranch(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("feature")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoResponse("OWNER", "REPO")
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
} } } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
return &test.OutputStub{Out: []byte("refs/heads/feature\n")}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, len(ranCommands), 2)
eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head")
eq(t, strings.Join(ranCommands[1], " "), "git merge --ff-only FETCH_HEAD")
}
func TestPRCheckout_differentRepo_invalidBranchName(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("feature")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoResponse("OWNER", "REPO")
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
} } } }
`))
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
t.Errorf("unexpected external invocation: %v", cmd.Args)
return &test.OutputStub{}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout 123`)
if assert.Errorf(t, err, "expected command to fail") {
assert.Equal(t, `invalid branch name: "-foo"`, err.Error())
}
assert.Equal(t, "", output.Stderr())
}
func TestPRCheckout_maintainerCanModify(t *testing.T) {
ctx := context.NewBlank()
ctx.SetBranch("master")
ctx.SetRemotes(map[string]string{
"origin": "OWNER/REPO",
})
initContext = func() context.Context {
return ctx
}
http := initFakeHTTP()
defer http.Verify(t)
http.StubRepoResponse("OWNER", "REPO")
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
} } } }
`))
ranCommands := [][]string{}
restoreCmd := run.SetPrepareCmd(func(cmd *exec.Cmd) run.Runnable {
switch strings.Join(cmd.Args, " ") {
case "git config branch.feature.merge":
return &errorStub{"exit status 1"}
default:
ranCommands = append(ranCommands, cmd.Args)
return &test.OutputStub{}
}
})
defer restoreCmd()
output, err := RunCommand(`pr checkout 123`)
eq(t, err, nil)
eq(t, output.String(), "")
eq(t, len(ranCommands), 4)
eq(t, strings.Join(ranCommands[0], " "), "git fetch origin refs/pull/123/head:feature")
eq(t, strings.Join(ranCommands[1], " "), "git checkout feature")
eq(t, strings.Join(ranCommands[2], " "), "git config branch.feature.remote https://github.com/hubot/REPO.git")
eq(t, strings.Join(ranCommands[3], " "), "git config branch.feature.merge refs/heads/feature")
}

View file

@ -1,490 +0,0 @@
package command
import (
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/MakeNowJust/heredoc"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
"github.com/cli/cli/git"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/pkg/cmdutil"
"github.com/cli/cli/pkg/githubtemplate"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
type defaults struct {
Title string
Body string
}
func computeDefaults(baseRef, headRef string) (defaults, error) {
commits, err := git.Commits(baseRef, headRef)
if err != nil {
return defaults{}, err
}
out := defaults{}
if len(commits) == 1 {
out.Title = commits[0].Title
body, err := git.CommitBody(commits[0].Sha)
if err != nil {
return defaults{}, err
}
out.Body = body
} else {
out.Title = utils.Humanize(headRef)
body := ""
for i := len(commits) - 1; i >= 0; i-- {
body += fmt.Sprintf("- %s\n", commits[i].Title)
}
out.Body = body
}
return out, nil
}
func prCreate(cmd *cobra.Command, _ []string) error {
ctx := contextForCommand(cmd)
remotes, err := ctx.Remotes()
if err != nil {
return err
}
client, err := apiClientForContext(ctx)
if err != nil {
return fmt.Errorf("could not initialize API client: %w", err)
}
baseRepoOverride, _ := cmd.Flags().GetString("repo")
repoContext, err := context.ResolveRemotesToRepos(remotes, client, baseRepoOverride)
if err != nil {
return err
}
baseRepo, err := repoContext.BaseRepo()
if err != nil {
return fmt.Errorf("could not determine base repository: %w", err)
}
headBranch, err := ctx.Branch()
if err != nil {
return fmt.Errorf("could not determine the current branch: %w", err)
}
var headRepo ghrepo.Interface
var headRemote *context.Remote
// determine whether the head branch is already pushed to a remote
headBranchPushedTo := determineTrackingBranch(remotes, headBranch)
if headBranchPushedTo != nil {
for _, r := range remotes {
if r.Name != headBranchPushedTo.RemoteName {
continue
}
headRepo = r
headRemote = r
break
}
}
// otherwise, determine the head repository with info obtained from the API
if headRepo == nil {
if r, err := repoContext.HeadRepo(); err == nil {
headRepo = r
}
}
baseBranch, err := cmd.Flags().GetString("base")
if err != nil {
return err
}
if baseBranch == "" {
baseBranch = baseRepo.DefaultBranchRef.Name
}
if headBranch == baseBranch && headRepo != nil && ghrepo.IsSame(baseRepo, headRepo) {
return fmt.Errorf("must be on a branch named differently than %q", baseBranch)
}
if ucc, err := git.UncommittedChangeCount(); err == nil && ucc > 0 {
fmt.Fprintf(cmd.ErrOrStderr(), "Warning: %s\n", utils.Pluralize(ucc, "uncommitted change"))
}
title, err := cmd.Flags().GetString("title")
if err != nil {
return fmt.Errorf("could not parse title: %w", err)
}
body, err := cmd.Flags().GetString("body")
if err != nil {
return fmt.Errorf("could not parse body: %w", err)
}
reviewers, err := cmd.Flags().GetStringSlice("reviewer")
if err != nil {
return fmt.Errorf("could not parse reviewers: %w", err)
}
assignees, err := cmd.Flags().GetStringSlice("assignee")
if err != nil {
return fmt.Errorf("could not parse assignees: %w", err)
}
labelNames, err := cmd.Flags().GetStringSlice("label")
if err != nil {
return fmt.Errorf("could not parse labels: %w", err)
}
projectNames, err := cmd.Flags().GetStringSlice("project")
if err != nil {
return fmt.Errorf("could not parse projects: %w", err)
}
var milestoneTitles []string
if milestoneTitle, err := cmd.Flags().GetString("milestone"); err != nil {
return fmt.Errorf("could not parse milestone: %w", err)
} else if milestoneTitle != "" {
milestoneTitles = append(milestoneTitles, milestoneTitle)
}
baseTrackingBranch := baseBranch
if baseRemote, err := remotes.FindByRepo(baseRepo.RepoOwner(), baseRepo.RepoName()); err == nil {
baseTrackingBranch = fmt.Sprintf("%s/%s", baseRemote.Name, baseBranch)
}
defs, defaultsErr := computeDefaults(baseTrackingBranch, headBranch)
isWeb, err := cmd.Flags().GetBool("web")
if err != nil {
return fmt.Errorf("could not parse web: %q", err)
}
autofill, err := cmd.Flags().GetBool("fill")
if err != nil {
return fmt.Errorf("could not parse fill: %q", err)
}
action := SubmitAction
if isWeb {
action = PreviewAction
if (title == "" || body == "") && defaultsErr != nil {
return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
}
} else if autofill {
if defaultsErr != nil {
return fmt.Errorf("could not compute title or body defaults: %w", defaultsErr)
}
title = defs.Title
body = defs.Body
}
if !isWeb {
headBranchLabel := headBranch
if headRepo != nil && !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
}
existingPR, err := api.PullRequestForBranch(client, baseRepo, baseBranch, headBranchLabel)
var notFound *api.NotFoundError
if err != nil && !errors.As(err, &notFound) {
return fmt.Errorf("error checking for existing pull request: %w", err)
}
if err == nil {
return fmt.Errorf("a pull request for branch %q into branch %q already exists:\n%s", headBranchLabel, baseBranch, existingPR.URL)
}
}
isDraft, err := cmd.Flags().GetBool("draft")
if err != nil {
return fmt.Errorf("could not parse draft: %w", err)
}
if !isWeb && !autofill {
message := "\nCreating pull request for %s into %s in %s\n\n"
if isDraft {
message = "\nCreating draft pull request for %s into %s in %s\n\n"
}
if connectedToTerminal(cmd) {
fmt.Fprintf(colorableErr(cmd), message,
utils.Cyan(headBranch),
utils.Cyan(baseBranch),
ghrepo.FullName(baseRepo))
if (title == "" || body == "") && defaultsErr != nil {
fmt.Fprintf(colorableErr(cmd), "%s warning: could not compute title or body defaults: %s\n", utils.Yellow("!"), defaultsErr)
}
}
}
tb := issueMetadataState{
Type: prMetadata,
Reviewers: reviewers,
Assignees: assignees,
Labels: labelNames,
Projects: projectNames,
Milestones: milestoneTitles,
}
if !connectedToTerminal(cmd) {
if !isWeb && (!cmd.Flags().Changed("title") && !autofill) {
return errors.New("--title or --fill required when not attached to a tty")
}
}
interactive := connectedToTerminal(cmd) && !(cmd.Flags().Changed("title") && cmd.Flags().Changed("body"))
if !isWeb && !autofill && interactive {
var nonLegacyTemplateFiles []string
var legacyTemplateFile *string
if rootDir, err := git.ToplevelDir(); err == nil {
// TODO: figure out how to stub this in tests
nonLegacyTemplateFiles = githubtemplate.FindNonLegacy(rootDir, "PULL_REQUEST_TEMPLATE")
legacyTemplateFile = githubtemplate.FindLegacy(rootDir, "PULL_REQUEST_TEMPLATE")
}
err := titleBodySurvey(cmd, &tb, client, baseRepo, title, body, defs, nonLegacyTemplateFiles, legacyTemplateFile, true, baseRepo.ViewerCanTriage())
if err != nil {
return fmt.Errorf("could not collect title and/or body: %w", err)
}
action = tb.Action
if action == CancelAction {
fmt.Fprintln(cmd.ErrOrStderr(), "Discarding.")
return nil
}
if title == "" {
title = tb.Title
}
if body == "" {
body = tb.Body
}
}
if action == SubmitAction && title == "" {
return errors.New("pull request title must not be blank")
}
if isDraft && isWeb {
return errors.New("the --draft flag is not supported with --web")
}
if len(reviewers) > 0 && isWeb {
return errors.New("the --reviewer flag is not supported with --web")
}
didForkRepo := false
// if a head repository could not be determined so far, automatically create
// one by forking the base repository
if headRepo == nil {
if baseRepo.IsPrivate {
return fmt.Errorf("cannot fork private repository '%s'", ghrepo.FullName(baseRepo))
}
headRepo, err = api.ForkRepo(client, baseRepo)
if err != nil {
return fmt.Errorf("error forking repo: %w", err)
}
didForkRepo = true
}
headBranchLabel := headBranch
if !ghrepo.IsSame(baseRepo, headRepo) {
headBranchLabel = fmt.Sprintf("%s:%s", headRepo.RepoOwner(), headBranch)
}
if headRemote == nil {
headRemote, _ = repoContext.RemoteForRepo(headRepo)
}
// There are two cases when an existing remote for the head repo will be
// missing:
// 1. the head repo was just created by auto-forking;
// 2. an existing fork was discovered by quering the API.
//
// In either case, we want to add the head repo as a new git remote so we
// can push to it.
if headRemote == nil {
headRepoURL := formatRemoteURL(cmd, headRepo)
// TODO: prevent clashes with another remote of a same name
gitRemote, err := git.AddRemote("fork", headRepoURL)
if err != nil {
return fmt.Errorf("error adding remote: %w", err)
}
headRemote = &context.Remote{
Remote: gitRemote,
Repo: headRepo,
}
}
// automatically push the branch if it hasn't been pushed anywhere yet
if headBranchPushedTo == nil {
pushTries := 0
maxPushTries := 3
for {
if err := git.Push(headRemote.Name, fmt.Sprintf("HEAD:%s", headBranch)); err != nil {
if didForkRepo && pushTries < maxPushTries {
pushTries++
// first wait 2 seconds after forking, then 4s, then 6s
waitSeconds := 2 * pushTries
fmt.Fprintf(cmd.ErrOrStderr(), "waiting %s before retrying...\n", utils.Pluralize(waitSeconds, "second"))
time.Sleep(time.Duration(waitSeconds) * time.Second)
continue
}
return err
}
break
}
}
if action == SubmitAction {
params := map[string]interface{}{
"title": title,
"body": body,
"draft": isDraft,
"baseRefName": baseBranch,
"headRefName": headBranchLabel,
}
err = addMetadataToIssueParams(client, baseRepo, params, &tb)
if err != nil {
return err
}
pr, err := api.CreatePullRequest(client, baseRepo, params)
if err != nil {
return fmt.Errorf("failed to create pull request: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout(), pr.URL)
} else if action == PreviewAction {
milestone := ""
if len(milestoneTitles) > 0 {
milestone = milestoneTitles[0]
}
openURL, err := generateCompareURL(baseRepo, baseBranch, headBranchLabel, title, body, assignees, labelNames, projectNames, milestone)
if err != nil {
return err
}
if connectedToTerminal(cmd) {
// TODO could exceed max url length for explorer
fmt.Fprintf(cmd.ErrOrStderr(), "Opening %s in your browser.\n", utils.DisplayURL(openURL))
}
return utils.OpenInBrowser(openURL)
} else {
panic("Unreachable state")
}
return nil
}
func determineTrackingBranch(remotes context.Remotes, headBranch string) *git.TrackingRef {
refsForLookup := []string{"HEAD"}
var trackingRefs []git.TrackingRef
headBranchConfig := git.ReadBranchConfig(headBranch)
if headBranchConfig.RemoteName != "" {
tr := git.TrackingRef{
RemoteName: headBranchConfig.RemoteName,
BranchName: strings.TrimPrefix(headBranchConfig.MergeRef, "refs/heads/"),
}
trackingRefs = append(trackingRefs, tr)
refsForLookup = append(refsForLookup, tr.String())
}
for _, remote := range remotes {
tr := git.TrackingRef{
RemoteName: remote.Name,
BranchName: headBranch,
}
trackingRefs = append(trackingRefs, tr)
refsForLookup = append(refsForLookup, tr.String())
}
resolvedRefs, _ := git.ShowRefs(refsForLookup...)
if len(resolvedRefs) > 1 {
for _, r := range resolvedRefs[1:] {
if r.Hash != resolvedRefs[0].Hash {
continue
}
for _, tr := range trackingRefs {
if tr.String() != r.Name {
continue
}
return &tr
}
}
}
return nil
}
func withPrAndIssueQueryParams(baseURL, title, body string, assignees, labels, projects []string, milestone string) (string, error) {
u, err := url.Parse(baseURL)
if err != nil {
return "", err
}
q := u.Query()
if title != "" {
q.Set("title", title)
}
if body != "" {
q.Set("body", body)
}
if len(assignees) > 0 {
q.Set("assignees", strings.Join(assignees, ","))
}
if len(labels) > 0 {
q.Set("labels", strings.Join(labels, ","))
}
if len(projects) > 0 {
q.Set("projects", strings.Join(projects, ","))
}
if milestone != "" {
q.Set("milestone", milestone)
}
u.RawQuery = q.Encode()
return u.String(), nil
}
func generateCompareURL(r ghrepo.Interface, base, head, title, body string, assignees, labels, projects []string, milestone string) (string, error) {
u := ghrepo.GenerateRepoURL(r, "compare/%s...%s?expand=1", base, head)
url, err := withPrAndIssueQueryParams(u, title, body, assignees, labels, projects, milestone)
if err != nil {
return "", err
}
return url, nil
}
var prCreateCmd = &cobra.Command{
Use: "create",
Short: "Create a pull request",
Args: cmdutil.NoArgsQuoteReminder,
RunE: prCreate,
Example: heredoc.Doc(`
$ gh pr create --title "The bug is fixed" --body "Everything works again"
$ gh issue create --label "bug,help wanted"
$ gh issue create --label bug --label "help wanted"
$ gh pr create --reviewer monalisa,hubot
$ gh pr create --project "Roadmap"
$ gh pr create --base develop
`),
}
func init() {
prCreateCmd.Flags().BoolP("draft", "d", false,
"Mark pull request as a draft")
prCreateCmd.Flags().StringP("title", "t", "",
"Supply a title. Will prompt for one otherwise.")
prCreateCmd.Flags().StringP("body", "b", "",
"Supply a body. Will prompt for one otherwise.")
prCreateCmd.Flags().StringP("base", "B", "",
"The branch into which you want your code merged")
prCreateCmd.Flags().BoolP("web", "w", false, "Open the web browser to create a pull request")
prCreateCmd.Flags().BoolP("fill", "f", false, "Do not prompt for title/body and just use commit info")
prCreateCmd.Flags().StringSliceP("reviewer", "r", nil, "Request reviews from people by their `login`")
prCreateCmd.Flags().StringSliceP("assignee", "a", nil, "Assign people by their `login`")
prCreateCmd.Flags().StringSliceP("label", "l", nil, "Add labels by `name`")
prCreateCmd.Flags().StringSliceP("project", "p", nil, "Add the pull request to projects by `name`")
prCreateCmd.Flags().StringP("milestone", "m", "", "Add the pull request to a milestone by `name`")
}

File diff suppressed because it is too large Load diff

View file

@ -1,104 +0,0 @@
package command
import (
"fmt"
"os"
"strings"
"github.com/cli/cli/utils"
"github.com/spf13/cobra"
)
var prDiffCmd = &cobra.Command{
Use: "diff {<number> | <url>}",
Short: "View a pull request's changes.",
RunE: prDiff,
}
func init() {
prDiffCmd.Flags().StringP("color", "c", "auto", "Whether or not to output color: {always|never|auto}")
prCmd.AddCommand(prDiffCmd)
}
func prDiff(cmd *cobra.Command, args []string) error {
color, err := cmd.Flags().GetString("color")
if err != nil {
return err
}
if !validColorFlag(color) {
return fmt.Errorf("did not understand color: %q. Expected one of always, never, or auto", color)
}
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
pr, baseRepo, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return fmt.Errorf("could not find pull request: %w", err)
}
diff, err := apiClient.PullRequestDiff(baseRepo, pr.Number)
if err != nil {
return fmt.Errorf("could not find pull request diff: %w", err)
}
out := cmd.OutOrStdout()
if color == "auto" {
color = "never"
isTTY := false
if outFile, isFile := out.(*os.File); isFile {
isTTY = utils.IsTerminal(outFile)
if isTTY {
color = "always"
}
}
}
if color == "never" {
fmt.Fprint(out, diff)
return nil
}
out = colorableOut(cmd)
for _, diffLine := range strings.Split(diff, "\n") {
output := diffLine
switch {
case isHeaderLine(diffLine):
output = utils.Bold(diffLine)
case isAdditionLine(diffLine):
output = utils.Green(diffLine)
case isRemovalLine(diffLine):
output = utils.Red(diffLine)
}
fmt.Fprintln(out, output)
}
return nil
}
func isHeaderLine(dl string) bool {
prefixes := []string{"+++", "---", "diff", "index"}
for _, p := range prefixes {
if strings.HasPrefix(dl, p) {
return true
}
}
return false
}
func isAdditionLine(dl string) bool {
return strings.HasPrefix(dl, "+")
}
func isRemovalLine(dl string) bool {
return strings.HasPrefix(dl, "-")
}
func validColorFlag(c string) bool {
return c == "auto" || c == "always" || c == "never"
}

View file

@ -1,104 +0,0 @@
package command
import (
"bytes"
"testing"
)
func TestPRDiff_validation(t *testing.T) {
_, err := RunCommand("pr diff --color=doublerainbow")
if err == nil {
t.Fatal("expected error")
}
eq(t, err.Error(), `did not understand color: "doublerainbow". Expected one of always, never, or auto`)
}
func TestPRDiff_no_current_pr(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`))
http.StubResponse(200, bytes.NewBufferString(testDiff))
_, err := RunCommand("pr diff")
if err == nil {
t.Fatal("expected error", err)
}
eq(t, err.Error(), `could not find pull request: no open pull requests found for branch "master"`)
}
func TestPRDiff_argument_not_found(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": {
"pullRequest": { "number": 123 }
} } }
`))
http.StubResponse(404, bytes.NewBufferString(""))
_, err := RunCommand("pr diff 123")
if err == nil {
t.Fatal("expected error", err)
}
eq(t, err.Error(), `could not find pull request diff: pull request not found`)
}
func TestPRDiff(t *testing.T) {
initBlankContext("", "OWNER/REPO", "feature")
http := initFakeHTTP()
http.StubRepoResponse("OWNER", "REPO")
http.StubResponse(200, bytes.NewBufferString(`
{ "data": { "repository": { "pullRequests": { "nodes": [
{ "url": "https://github.com/OWNER/REPO/pull/123",
"number": 123,
"id": "foobar123",
"headRefName": "feature",
"baseRefName": "master" }
] } } } }`))
http.StubResponse(200, bytes.NewBufferString(testDiff))
output, err := RunCommand("pr diff")
if err != nil {
t.Fatalf("unexpected error: %s", err)
}
eq(t, output.String(), testDiff)
}
const testDiff = `diff --git a/.github/workflows/releases.yml b/.github/workflows/releases.yml
index 73974448..b7fc0154 100644
--- a/.github/workflows/releases.yml
+++ b/.github/workflows/releases.yml
@@ -44,6 +44,11 @@ jobs:
token: ${{secrets.SITE_GITHUB_TOKEN}}
- name: Publish documentation site
if: "!contains(github.ref, '-')" # skip prereleases
+ env:
+ GIT_COMMITTER_NAME: cli automation
+ GIT_AUTHOR_NAME: cli automation
+ GIT_COMMITTER_EMAIL: noreply@github.com
+ GIT_AUTHOR_EMAIL: noreply@github.com
run: make site-publish
- name: Move project cards
if: "!contains(github.ref, '-')" # skip prereleases
diff --git a/Makefile b/Makefile
index f2b4805c..3d7bd0f9 100644
--- a/Makefile
+++ b/Makefile
@@ -22,8 +22,8 @@ test:
go test ./...
.PHONY: test
-site:
- git clone https://github.com/github/cli.github.com.git "$@"
+site: bin/gh
+ bin/gh repo clone github/cli.github.com "$@"
site-docs: site
git -C site pull
`

View file

@ -1,121 +0,0 @@
package command
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"
"github.com/spf13/cobra"
)
func prFromArgs(ctx context.Context, apiClient *api.Client, cmd *cobra.Command, args []string) (*api.PullRequest, ghrepo.Interface, error) {
if len(args) == 1 {
// 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
prString := args[0]
pr, r, err := prFromURL(ctx, apiClient, prString)
if pr != nil || err != nil {
return pr, r, err
}
}
repo, err := determineBaseRepo(apiClient, cmd, ctx)
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 len(args) == 0 {
pr, err := prForCurrentBranch(ctx, apiClient, repo)
return pr, repo, err
} else {
prString := args[0]
// Next see if the prString is a number and use that to look up the url
pr, err := prFromNumberString(ctx, apiClient, repo, prString)
if pr != nil || err != nil {
return pr, repo, err
}
// Last see if it is a branch name
pr, err = api.PullRequestForBranch(apiClient, repo, "", prString)
return pr, repo, err
}
}
func prFromNumberString(ctx context.Context, 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(ctx context.Context, 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(ctx, apiClient, repo, prNumberString)
return pr, repo, err
}
func prForCurrentBranch(ctx context.Context, apiClient *api.Client, repo ghrepo.Interface) (*api.PullRequest, error) {
prHeadRef, err := ctx.Branch()
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(ctx, 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, _ := ctx.Remotes()
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)
}

View file

@ -1,254 +0,0 @@
package command
import (
"errors"
"fmt"
"github.com/AlecAivazis/survey/v2"
"github.com/MakeNowJust/heredoc"
"github.com/spf13/cobra"
"github.com/cli/cli/api"
"github.com/cli/cli/pkg/prompt"
"github.com/cli/cli/pkg/surveyext"
"github.com/cli/cli/utils"
)
func init() {
prCmd.AddCommand(prReviewCmd)
prReviewCmd.Flags().BoolP("approve", "a", false, "Approve pull request")
prReviewCmd.Flags().BoolP("request-changes", "r", false, "Request changes on a pull request")
prReviewCmd.Flags().BoolP("comment", "c", false, "Comment on a pull request")
prReviewCmd.Flags().StringP("body", "b", "", "Specify the body of a review")
}
var prReviewCmd = &cobra.Command{
Use: "review [<number> | <url> | <branch>]",
Short: "Add a review to a pull request",
Long: `Add a review to a pull request.
Without an argument, the pull request that belongs to the current branch is reviewed.`,
Example: heredoc.Doc(`
# approve the pull request of the current branch
$ gh pr review --approve
# leave a review comment for the current branch
$ gh pr review --comment -b "interesting"
# add a review for a specific pull request
$ gh pr review 123
# request changes on a specific pull request
$ gh pr review 123 -r -b "needs more ASCII art"
`),
Args: cobra.MaximumNArgs(1),
RunE: prReview,
}
func processReviewOpt(cmd *cobra.Command) (*api.PullRequestReviewInput, error) {
found := 0
flag := ""
var state api.PullRequestReviewState
if cmd.Flags().Changed("approve") {
found++
flag = "approve"
state = api.ReviewApprove
}
if cmd.Flags().Changed("request-changes") {
found++
flag = "request-changes"
state = api.ReviewRequestChanges
}
if cmd.Flags().Changed("comment") {
found++
flag = "comment"
state = api.ReviewComment
}
body, err := cmd.Flags().GetString("body")
if err != nil {
return nil, err
}
if found == 0 && body == "" {
if connectedToTerminal(cmd) {
return nil, nil // signal interactive mode
}
return nil, errors.New("--approve, --request-changes, or --comment required when not attached to a tty")
} else if found == 0 && body != "" {
return nil, errors.New("--body unsupported without --approve, --request-changes, or --comment")
} else if found > 1 {
return nil, errors.New("need exactly one of --approve, --request-changes, or --comment")
}
if (flag == "request-changes" || flag == "comment") && body == "" {
return nil, fmt.Errorf("body cannot be blank for %s review", flag)
}
return &api.PullRequestReviewInput{
Body: body,
State: state,
}, nil
}
func prReview(cmd *cobra.Command, args []string) error {
ctx := contextForCommand(cmd)
apiClient, err := apiClientForContext(ctx)
if err != nil {
return err
}
pr, _, err := prFromArgs(ctx, apiClient, cmd, args)
if err != nil {
return err
}
reviewData, err := processReviewOpt(cmd)
if err != nil {
return fmt.Errorf("did not understand desired review action: %w", err)
}
stderr := colorableErr(cmd)
if reviewData == nil {
reviewData, err = reviewSurvey(cmd)
if err != nil {
return err
}
if reviewData == nil && err == nil {
fmt.Fprint(stderr, "Discarding.\n")
return nil
}
}
err = api.AddReview(apiClient, pr, reviewData)
if err != nil {
return fmt.Errorf("failed to create review: %w", err)
}
if !connectedToTerminal(cmd) {
return nil
}
switch reviewData.State {
case api.ReviewComment:
fmt.Fprintf(stderr, "%s Reviewed pull request #%d\n", utils.Gray("-"), pr.Number)
case api.ReviewApprove:
fmt.Fprintf(stderr, "%s Approved pull request #%d\n", utils.Green("✓"), pr.Number)
case api.ReviewRequestChanges:
fmt.Fprintf(stderr, "%s Requested changes to pull request #%d\n", utils.Red("+"), pr.Number)
}
return nil
}
func reviewSurvey(cmd *cobra.Command) (*api.PullRequestReviewInput, error) {
editorCommand, err := determineEditor(cmd)
if err != nil {
return nil, err
}
typeAnswers := struct {
ReviewType string
}{}
typeQs := []*survey.Question{
{
Name: "reviewType",
Prompt: &survey.Select{
Message: "What kind of review do you want to give?",
Options: []string{
"Comment",
"Approve",
"Request changes",
},
},
},
}
err = prompt.SurveyAsk(typeQs, &typeAnswers)
if err != nil {
return nil, err
}
var reviewState api.PullRequestReviewState
switch typeAnswers.ReviewType {
case "Approve":
reviewState = api.ReviewApprove
case "Request changes":
reviewState = api.ReviewRequestChanges
case "Comment":
reviewState = api.ReviewComment
default:
panic("unreachable state")
}
bodyAnswers := struct {
Body string
}{}
blankAllowed := false
if reviewState == api.ReviewApprove {
blankAllowed = true
}
bodyQs := []*survey.Question{
{
Name: "body",
Prompt: &surveyext.GhEditor{
BlankAllowed: blankAllowed,
EditorCommand: editorCommand,
Editor: &survey.Editor{
Message: "Review body",
FileName: "*.md",
},
},
},
}
err = prompt.SurveyAsk(bodyQs, &bodyAnswers)
if err != nil {
return nil, err
}
if bodyAnswers.Body == "" && (reviewState == api.ReviewComment || reviewState == api.ReviewRequestChanges) {
return nil, errors.New("this type of review cannot be blank")
}
if len(bodyAnswers.Body) > 0 {
out := colorableOut(cmd)
renderedBody, err := utils.RenderMarkdown(bodyAnswers.Body)
if err != nil {
return nil, err
}
fmt.Fprintf(out, "Got:\n%s", renderedBody)
}
confirm := false
confirmQs := []*survey.Question{
{
Name: "confirm",
Prompt: &survey.Confirm{
Message: "Submit?",
Default: true,
},
},
}
err = prompt.SurveyAsk(confirmQs, &confirm)
if err != nil {
return nil, err
}
if !confirm {
return nil, nil
}
return &api.PullRequestReviewInput{
Body: bodyAnswers.Body,
State: reviewState,
}, nil
}

View file

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

File diff suppressed because it is too large Load diff

View file

@ -16,11 +16,17 @@ 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/ghinstance"
"github.com/cli/cli/internal/ghrepo"
"github.com/cli/cli/internal/run"
apiCmd "github.com/cli/cli/pkg/cmd/api"
authCmd "github.com/cli/cli/pkg/cmd/auth"
authLoginCmd "github.com/cli/cli/pkg/cmd/auth/login"
authLogoutCmd "github.com/cli/cli/pkg/cmd/auth/logout"
gistCreateCmd "github.com/cli/cli/pkg/cmd/gist/create"
prCmd "github.com/cli/cli/pkg/cmd/pr"
repoCmd "github.com/cli/cli/pkg/cmd/repo"
repoCloneCmd "github.com/cli/cli/pkg/cmd/repo/clone"
repoCreateCmd "github.com/cli/cli/pkg/cmd/repo/create"
@ -36,9 +42,6 @@ import (
"github.com/spf13/pflag"
)
// TODO these are sprinkled across command, context, config, and ghrepo
const defaultHostname = "github.com"
// Version is dynamically set by the toolchain or overridden by the Makefile.
var Version = "DEV"
@ -47,6 +50,8 @@ var BuildDate = "" // YYYY-MM-DD
var versionOutput = ""
var defaultStreams *iostreams.IOStreams
func init() {
if Version == "DEV" {
if info, ok := debug.ReadBuildInfo(); ok && info.Main.Version != "(devel)" {
@ -78,22 +83,21 @@ func init() {
return &cmdutil.FlagError{Err: err}
})
defaultStreams = iostreams.System()
// TODO: iron out how a factory incorporates context
cmdFactory := &cmdutil.Factory{
IOStreams: iostreams.System(),
IOStreams: defaultStreams,
HttpClient: func() (*http.Client, error) {
token := os.Getenv("GITHUB_TOKEN")
if len(token) == 0 {
// TODO: decouple from `context`
ctx := context.New()
var err error
// TODO: pass IOStreams to this so that the auth flow knows if it's interactive or not
token, err = ctx.AuthToken()
if err != nil {
return nil, err
}
// TODO: decouple from `context`
ctx := context.New()
cfg, err := ctx.Config()
if err != nil {
return nil, err
}
return httpClient(token), nil
// TODO: avoid setting Accept header for `api` command
return httpClient(defaultStreams, cfg, true), nil
},
BaseRepo: func() (ghrepo.Interface, error) {
// TODO: decouple from `context`
@ -113,6 +117,13 @@ func init() {
}
return cfg, nil
},
Branch: func() (string, error) {
currentBranch, err := git.CurrentBranch()
if err != nil {
return "", fmt.Errorf("could not determine current branch: %w", err)
}
return currentBranch, nil
},
}
RootCmd.AddCommand(apiCmd.NewCmdApi(cmdFactory, nil))
@ -124,6 +135,10 @@ func init() {
RootCmd.AddCommand(gistCmd)
gistCmd.AddCommand(gistCreateCmd.NewCmdCreate(cmdFactory, nil))
RootCmd.AddCommand(authCmd.Cmd)
authCmd.Cmd.AddCommand(authLoginCmd.NewCmdLogin(cmdFactory, nil))
authCmd.Cmd.AddCommand(authLogoutCmd.NewCmdLogout(cmdFactory, nil))
resolvedBaseRepo := func() (ghrepo.Interface, error) {
httpClient, err := cmdFactory.HttpClient()
if err != nil {
@ -160,6 +175,7 @@ func init() {
repoCmd.Cmd.AddCommand(repoCreateCmd.NewCmdCreate(cmdFactory, nil))
repoCmd.Cmd.AddCommand(creditsCmd.NewCmdRepoCredits(&repoResolvingCmdFactory, nil))
RootCmd.AddCommand(prCmd.NewCmdPR(&repoResolvingCmdFactory))
RootCmd.AddCommand(creditsCmd.NewCmdCredits(cmdFactory, nil))
}
@ -218,14 +234,11 @@ var initContext = func() context.Context {
if repo := os.Getenv("GH_REPO"); repo != "" {
ctx.SetBaseRepo(repo)
}
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
ctx.SetAuthToken(token)
}
return ctx
}
// BasicClient returns an API client that borrows from but does not depend on
// user configuration
// BasicClient returns an API client for github.com only that borrows from but
// does not depend on user configuration
func BasicClient() (*api.Client, error) {
var opts []api.ClientOption
if verbose := os.Getenv("DEBUG"); verbose != "" {
@ -236,7 +249,7 @@ func BasicClient() (*api.Client, error) {
token := os.Getenv("GITHUB_TOKEN")
if token == "" {
if c, err := config.ParseDefaultConfig(); err == nil {
token, _ = c.Get(defaultHostname, "oauth_token")
token, _ = c.Get(ghinstance.Default(), "oauth_token")
}
}
if token != "" {
@ -253,72 +266,71 @@ func contextForCommand(cmd *cobra.Command) context.Context {
return ctx
}
// for cmdutil-powered commands
func httpClient(token string) *http.Client {
// generic authenticated HTTP client for commands
func httpClient(io *iostreams.IOStreams, cfg config.Config, setAccept bool) *http.Client {
var opts []api.ClientOption
if verbose := os.Getenv("DEBUG"); verbose != "" {
opts = append(opts, apiVerboseLog())
}
opts = append(opts,
api.AddHeader("Authorization", fmt.Sprintf("token %s", token)),
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)),
// antiope-preview: Checks
// FIXME: avoid setting this header for `api` command
api.AddHeader("Accept", "application/vnd.github.antiope-preview+json"),
api.AddHeaderFunc("Authorization", func(req *http.Request) (string, error) {
if token := os.Getenv("GITHUB_TOKEN"); token != "" {
return fmt.Sprintf("token %s", token), nil
}
hostname := ghinstance.NormalizeHostname(req.URL.Hostname())
token, err := cfg.Get(hostname, "oauth_token")
if token == "" {
var notFound *config.NotFoundError
// TODO: check if stdout is TTY too
if errors.As(err, &notFound) && io.IsStdinTTY() {
// interactive OAuth flow
token, err = config.AuthFlowWithConfig(cfg, hostname, "Notice: authentication required")
}
if err != nil {
return "", err
}
if token == "" {
// TODO: instruct user how to manually authenticate
return "", fmt.Errorf("authentication required for %s", hostname)
}
}
return fmt.Sprintf("token %s", token), nil
}),
)
if setAccept {
opts = append(opts,
api.AddHeaderFunc("Accept", func(req *http.Request) (string, error) {
// antiope-preview: Checks
accept := "application/vnd.github.antiope-preview+json"
if ghinstance.IsEnterprise(req.URL.Hostname()) {
// shadow-cat-preview: Draft pull requests
accept += ", application/vnd.github.shadow-cat-preview"
}
return accept, nil
}),
)
}
return api.NewHTTPClient(opts...)
}
// overridden in tests
// LEGACY; overridden in tests
var apiClientForContext = func(ctx context.Context) (*api.Client, error) {
token, err := ctx.AuthToken()
cfg, err := ctx.Config()
if err != nil {
return nil, err
}
var opts []api.ClientOption
if verbose := os.Getenv("DEBUG"); verbose != "" {
opts = append(opts, apiVerboseLog())
}
getAuthValue := func() string {
return fmt.Sprintf("token %s", token)
}
tokenFromEnv := func() bool {
return os.Getenv("GITHUB_TOKEN") == token
}
checkScopesFunc := func(appID string) error {
if config.IsGitHubApp(appID) && !tokenFromEnv() && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) {
cfg, err := ctx.Config()
if err != nil {
return err
}
newToken, err := config.AuthFlowWithConfig(cfg, defaultHostname, "Notice: additional authorization required")
if err != nil {
return err
}
// update configuration in memory
token = newToken
} else {
fmt.Fprintln(os.Stderr, "Warning: gh now requires the `read:org` OAuth scope.")
fmt.Fprintln(os.Stderr, "Visit https://github.com/settings/tokens and edit your token to enable `read:org`")
if tokenFromEnv() {
fmt.Fprintln(os.Stderr, "or generate a new token for the GITHUB_TOKEN environment variable")
} else {
fmt.Fprintln(os.Stderr, "or generate a new token and paste it via `gh config set -h github.com oauth_token MYTOKEN`")
}
}
return nil
}
opts = append(opts,
api.CheckScopes("read:org", checkScopesFunc),
api.AddHeaderFunc("Authorization", getAuthValue),
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)),
// antiope-preview: Checks
api.AddHeader("Accept", "application/vnd.github.antiope-preview+json"),
)
return api.NewClient(opts...), nil
http := httpClient(defaultStreams, cfg, true)
return api.NewClientFromHTTP(http), nil
}
func apiVerboseLog() api.ClientOption {
@ -382,39 +394,6 @@ func determineBaseRepo(apiClient *api.Client, cmd *cobra.Command, ctx context.Co
return baseRepo, nil
}
// TODO there is a parallel implementation for isolated commands
func formatRemoteURL(cmd *cobra.Command, repo ghrepo.Interface) string {
ctx := contextForCommand(cmd)
var protocol string
cfg, err := ctx.Config()
if err != nil {
fmt.Fprintf(colorableErr(cmd), "%s failed to load config: %s. using defaults\n", utils.Yellow("!"), err)
} else {
protocol, _ = cfg.Get(repo.RepoHost(), "git_protocol")
}
if protocol == "ssh" {
return fmt.Sprintf("git@%s:%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
}
return fmt.Sprintf("https://%s/%s/%s.git", repo.RepoHost(), repo.RepoOwner(), repo.RepoName())
}
func determineEditor(cmd *cobra.Command) (string, error) {
editorCommand := os.Getenv("GH_EDITOR")
if editorCommand == "" {
ctx := contextForCommand(cmd)
cfg, err := ctx.Config()
if err != nil {
return "", fmt.Errorf("could not read config: %w", err)
}
editorCommand, _ = cfg.Get(defaultHostname, "editor")
}
return editorCommand, nil
}
func ExecuteShellAlias(args []string) error {
externalCmd := exec.Command(args[0], args[1:]...)
externalCmd.Stderr = os.Stderr

View file

@ -2,8 +2,6 @@ package command
import (
"testing"
"github.com/cli/cli/internal/ghrepo"
)
func TestChangelogURL(t *testing.T) {
@ -42,22 +40,3 @@ func TestChangelogURL(t *testing.T) {
t.Errorf("expected %s to create url %s but got %s", tag, url, result)
}
}
func TestRemoteURLFormatting_no_config(t *testing.T) {
initBlankContext("", "OWNER/REPO", "master")
result := formatRemoteURL(prCheckoutCmd, ghrepo.New("OWNER", "REPO"))
eq(t, result, "https://github.com/OWNER/REPO.git")
}
func TestRemoteURLFormatting_ssh_config(t *testing.T) {
cfg := `---
hosts:
github.com:
user: OWNER
oauth_token: MUSTBEHIGHCUZIMATOKEN
git_protocol: ssh
`
initBlankContext(cfg, "OWNER/REPO", "master")
result := formatRemoteURL(prCheckoutCmd, ghrepo.New("OWNER", "REPO"))
eq(t, result, "git@github.com:OWNER/REPO.git")
}

View file

@ -2,8 +2,9 @@ package command
import (
"bytes"
"errors"
"fmt"
"reflect"
"testing"
"github.com/cli/cli/api"
"github.com/cli/cli/context"
@ -14,6 +15,13 @@ import (
"github.com/spf13/pflag"
)
func eq(t *testing.T, got interface{}, expected interface{}) {
t.Helper()
if !reflect.DeepEqual(got, expected) {
t.Errorf("expected: %v, got: %v", expected, got)
}
}
const defaultTestConfig = `hosts:
github.com:
user: OWNER
@ -102,18 +110,6 @@ func RunCommand(args string) (*cmdOut, error) {
return &cmdOut{&outBuf, &errBuf}, err
}
type errorStub struct {
message string
}
func (s errorStub) Output() ([]byte, error) {
return nil, errors.New(s.message)
}
func (s errorStub) Run() error {
return errors.New(s.message)
}
func stubTerminal(connected bool) func() {
isTerminal := utils.IsTerminal
utils.IsTerminal = func(_ interface{}) bool {

View file

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