github.com/jingweno/gh@v2.1.1-0.20221007190738-04a7985fa9a1+incompatible/commands/pull_request.go (about)

     1  package commands
     2  
     3  import (
     4  	"fmt"
     5  	"reflect"
     6  	"regexp"
     7  	"strings"
     8  
     9  	"github.com/jingweno/gh/git"
    10  	"github.com/jingweno/gh/github"
    11  	"github.com/jingweno/gh/utils"
    12  )
    13  
    14  var cmdPullRequest = &Command{
    15  	Run:   pullRequest,
    16  	Usage: "pull-request [-f] [-m <MESSAGE>|-F <FILE>|-i <ISSUE>|<ISSUE-URL>] [-b <BASE>] [-h <HEAD>] ",
    17  	Short: "Open a pull request on GitHub",
    18  	Long: `Opens a pull request on GitHub for the project that the "origin" remote
    19  points to. The default head of the pull request is the current branch.
    20  Both base and head of the pull request can be explicitly given in one of
    21  the following formats: "branch", "owner:branch", "owner/repo:branch".
    22  This command will abort operation if it detects that the current topic
    23  branch has local commits that are not yet pushed to its upstream branch
    24  on the remote. To skip this check, use "-f".
    25  
    26  Without <MESSAGE> or <FILE>, a text editor will open in which title and body
    27  of the pull request can be entered in the same manner as git commit message.
    28  Pull request message can also be passed via stdin with "-F -".
    29  
    30  If instead of normal <TITLE> an issue number is given with "-i", the pull
    31  request will be attached to an existing GitHub issue. Alternatively, instead
    32  of title you can paste a full URL to an issue on GitHub.
    33  `,
    34  }
    35  
    36  var (
    37  	flagPullRequestBase,
    38  	flagPullRequestHead,
    39  	flagPullRequestIssue,
    40  	flagPullRequestMessage,
    41  	flagPullRequestFile string
    42  	flagPullRequestForce bool
    43  )
    44  
    45  func init() {
    46  	cmdPullRequest.Flag.StringVarP(&flagPullRequestBase, "base", "b", "", "BASE")
    47  	cmdPullRequest.Flag.StringVarP(&flagPullRequestHead, "head", "h", "", "HEAD")
    48  	cmdPullRequest.Flag.StringVarP(&flagPullRequestIssue, "issue", "i", "", "ISSUE")
    49  	cmdPullRequest.Flag.StringVarP(&flagPullRequestMessage, "message", "m", "", "MESSAGE")
    50  	cmdPullRequest.Flag.BoolVarP(&flagPullRequestForce, "force", "f", false, "FORCE")
    51  	cmdPullRequest.Flag.StringVarP(&flagPullRequestFile, "file", "F", "", "FILE")
    52  
    53  	CmdRunner.Use(cmdPullRequest)
    54  }
    55  
    56  /*
    57    # while on a topic branch called "feature":
    58    $ gh pull-request
    59    [ opens text editor to edit title & body for the request ]
    60    [ opened pull request on GitHub for "YOUR_USER:feature" ]
    61  
    62    # explicit pull base & head:
    63    $ gh pull-request -b jingweno:master -h jingweno:feature
    64  
    65    $ gh pull-request -m "title\n\nbody"
    66    [ create pull request with title & body  ]
    67  
    68    $ gh pull-request -i 123
    69    [ attached pull request to issue #123 ]
    70  
    71    $ gh pull-request https://github.com/jingweno/gh/pull/123
    72    [ attached pull request to issue #123 ]
    73  
    74    $ gh pull-request -F FILE
    75    [ create pull request with title & body from FILE ]
    76  */
    77  func pullRequest(cmd *Command, args *Args) {
    78  	localRepo := github.LocalRepo()
    79  
    80  	currentBranch, err := localRepo.CurrentBranch()
    81  	utils.Check(err)
    82  
    83  	baseProject, err := localRepo.MainProject()
    84  	utils.Check(err)
    85  
    86  	client := github.NewClient(baseProject.Host)
    87  
    88  	trackedBranch, headProject, err := localRepo.RemoteBranchAndProject(client.Credentials.User)
    89  	utils.Check(err)
    90  
    91  	var (
    92  		base, head string
    93  		force      bool
    94  	)
    95  
    96  	force = flagPullRequestForce
    97  
    98  	if flagPullRequestBase != "" {
    99  		baseProject, base = parsePullRequestProject(baseProject, flagPullRequestBase)
   100  	}
   101  
   102  	if flagPullRequestHead != "" {
   103  		headProject, head = parsePullRequestProject(headProject, flagPullRequestHead)
   104  	}
   105  
   106  	if args.ParamsSize() == 1 {
   107  		arg := args.RemoveParam(0)
   108  		flagPullRequestIssue = parsePullRequestIssueNumber(arg)
   109  	}
   110  
   111  	if base == "" {
   112  		masterBranch := localRepo.MasterBranch()
   113  		base = masterBranch.ShortName()
   114  	}
   115  
   116  	if head == "" {
   117  		if !trackedBranch.IsRemote() {
   118  			// the current branch tracking another branch
   119  			// pretend there's no upstream at all
   120  			trackedBranch = nil
   121  		} else {
   122  			if reflect.DeepEqual(baseProject, headProject) && base == trackedBranch.ShortName() {
   123  				e := fmt.Errorf(`Aborted: head branch is the same as base ("%s")`, base)
   124  				e = fmt.Errorf("%s\n(use `-h <branch>` to specify an explicit pull request head)", e)
   125  				utils.Check(e)
   126  			}
   127  		}
   128  
   129  		if trackedBranch == nil {
   130  			head = currentBranch.ShortName()
   131  		} else {
   132  			head = trackedBranch.ShortName()
   133  		}
   134  	}
   135  
   136  	title, body, err := getTitleAndBodyFromFlags(flagPullRequestMessage, flagPullRequestFile)
   137  	utils.Check(err)
   138  
   139  	fullBase := fmt.Sprintf("%s:%s", baseProject.Owner, base)
   140  	fullHead := fmt.Sprintf("%s:%s", headProject.Owner, head)
   141  
   142  	if !force && trackedBranch != nil {
   143  		remoteCommits, _ := git.RefList(trackedBranch.LongName(), "")
   144  		if len(remoteCommits) > 0 {
   145  			err = fmt.Errorf("Aborted: %d commits are not yet pushed to %s", len(remoteCommits), trackedBranch.LongName())
   146  			err = fmt.Errorf("%s\n(use `-f` to force submit a pull request anyway)", err)
   147  			utils.Check(err)
   148  		}
   149  	}
   150  
   151  	if title == "" && flagPullRequestIssue == "" {
   152  		commits, _ := git.RefList(base, head)
   153  		title, body, err = writePullRequestTitleAndBody(base, head, fullBase, fullHead, commits)
   154  		utils.Check(err)
   155  	}
   156  
   157  	if title == "" && flagPullRequestIssue == "" {
   158  		utils.Check(fmt.Errorf("Aborting due to empty pull request title"))
   159  	}
   160  
   161  	var pullRequestURL string
   162  	if args.Noop {
   163  		args.Before(fmt.Sprintf("Would request a pull request to %s from %s", fullBase, fullHead), "")
   164  		pullRequestURL = "PULL_REQUEST_URL"
   165  	} else {
   166  		if title != "" {
   167  			pr, err := client.CreatePullRequest(baseProject, base, fullHead, title, body)
   168  			utils.Check(err)
   169  			pullRequestURL = pr.HTMLURL
   170  		}
   171  
   172  		if flagPullRequestIssue != "" {
   173  			pr, err := client.CreatePullRequestForIssue(baseProject, base, fullHead, flagPullRequestIssue)
   174  			utils.Check(err)
   175  			pullRequestURL = pr.HTMLURL
   176  		}
   177  	}
   178  
   179  	args.Replace("echo", "", pullRequestURL)
   180  	if flagPullRequestIssue != "" {
   181  		args.After("echo", "Warning: Issue to pull request conversion is deprecated and might not work in the future.")
   182  	}
   183  }
   184  
   185  func writePullRequestTitleAndBody(base, head, fullBase, fullHead string, commits []string) (title, body string, err error) {
   186  	message, err := pullRequestChangesMessage(base, head, fullBase, fullHead, commits)
   187  	if err != nil {
   188  		return
   189  	}
   190  
   191  	editor, err := github.NewEditor("PULLREQ", message)
   192  	if err != nil {
   193  		return
   194  	}
   195  
   196  	return editor.EditTitleAndBody()
   197  }
   198  
   199  func pullRequestChangesMessage(base, head, fullBase, fullHead string, commits []string) (string, error) {
   200  	var defaultMsg, commitSummary string
   201  	if len(commits) == 1 {
   202  		msg, err := git.Show(commits[0])
   203  		if err != nil {
   204  			return "", err
   205  		}
   206  		defaultMsg = fmt.Sprintf("%s\n", msg)
   207  	} else if len(commits) > 1 {
   208  		commitLogs, err := git.Log(base, head)
   209  		if err != nil {
   210  			return "", err
   211  		}
   212  
   213  		if len(commitLogs) > 0 {
   214  			startRegexp := regexp.MustCompilePOSIX("^")
   215  			endRegexp := regexp.MustCompilePOSIX(" +$")
   216  
   217  			commitLogs = strings.TrimSpace(commitLogs)
   218  			commitLogs = startRegexp.ReplaceAllString(commitLogs, "# ")
   219  			commitLogs = endRegexp.ReplaceAllString(commitLogs, "")
   220  			commitSummary = `
   221  #
   222  # Changes:
   223  #
   224  %s`
   225  			commitSummary = fmt.Sprintf(commitSummary, commitLogs)
   226  		}
   227  	}
   228  
   229  	message := `%s
   230  # Requesting a pull to %s from %s
   231  #
   232  # Write a message for this pull request. The first block
   233  # of the text is the title and the rest is description.%s
   234  `
   235  	message = fmt.Sprintf(message, defaultMsg, fullBase, fullHead, commitSummary)
   236  
   237  	return message, nil
   238  }
   239  
   240  func parsePullRequestProject(context *github.Project, s string) (p *github.Project, ref string) {
   241  	p = context
   242  	ref = s
   243  
   244  	if strings.Contains(s, ":") {
   245  		split := strings.SplitN(s, ":", 2)
   246  		ref = split[1]
   247  		var name string
   248  		if !strings.Contains(split[0], "/") {
   249  			name = context.Name
   250  		}
   251  		p = github.NewProject(split[0], name, context.Host)
   252  	}
   253  
   254  	return
   255  }
   256  
   257  func parsePullRequestIssueNumber(url string) string {
   258  	u, e := github.ParseURL(url)
   259  	if e != nil {
   260  		return ""
   261  	}
   262  
   263  	r := regexp.MustCompile(`^issues\/(\d+)`)
   264  	p := u.ProjectPath()
   265  	if r.MatchString(p) {
   266  		return r.FindStringSubmatch(p)[1]
   267  	}
   268  
   269  	return ""
   270  }